Files
curriculum-project-hub/hub/src/audit.ts
hongjr03 f8c3e16a52 feat(hub): 事件去重(A-4) + 审计写入路径(A-8)
A-4 FeishuEventReceipt:
- 新增 FeishuEventReceipt 表(eventId @unique),lark ws 至少一次投递的幂等兜底
- MessageReceiveEvent 加 header.event_id 字段
- trigger 入口查重:已存在的 event_id → 跳过(不建第二次 run)
- resetDb 加入 FeishuEventReceipt

A-8 审计写入:
- AuditEntry 加 projectId(权限拒绝/slash 命令等无 run 的审计点可定位)
- 新建 src/audit.ts:writeAudit(prisma, entry) best-effort 写入(吞错,不阻断 trigger)
- trigger 五个审计点:trigger.denied / trigger.role_denied / run.created / run.lock_race / run.finished / run.failed
- 新增 audit unit 测试(3) + trigger 集成测试(去重 + 审计,2)

59 tests 全过。
2026-07-07 21:34:15 +08:00

60 lines
2.6 KiB
TypeScript

/**
* Audit write path — A-8. The `AuditEntry` table existed but had no writer;
* callers (trigger path, permission denials, slash commands) had no way to
* record what happened. This module is that single write seam.
*
* ADR reference: spec Audit. The skeleton pins only the `runId` relation;
* event type / actor / timestamp / details are content-OPEN. This helper
* exposes `action` (event type) + `actorUserId` (actor) + `metadata`
* (details) + `projectId` (locates audit points that don't have a run, e.g.
* permission denials) + `runId` (loose string ref, not a Prisma relation —
* see schema). `createdAt` is `@default(now())` so we don't pass it.
*
* Best-effort by design: an audit failure must never break the caller's path
* (a trigger failing because the audit log is down would be a spec breach —
* audit is observability, not control). Errors are swallowed here. There's no
* logger in this layer yet; a future logger can be threaded in without
* changing the signature.
*/
import type { PrismaClient, Prisma } from "@prisma/client";
export interface AuditInput {
readonly runId?: string | undefined;
readonly projectId?: string | undefined;
readonly actorUserId?: string | undefined;
readonly action: string;
readonly metadata: Record<string, unknown>;
}
/**
* Write one audit entry. Best-effort: any Prisma error is swallowed so the
* caller's path (trigger, permission gate, slash command) is unaffected.
*
* Optional fields are only included when defined — under
* `exactOptionalPropertyTypes`, assigning `undefined` to an optional key in a
* Prisma `create` payload is a type error (and would also write an explicit
* NULL rather than omit the column). Conditional assignment keeps the payload
* honest. The data object is typed as `AuditEntryUncheckedCreateInput` (the
* direct-FK variant) so it matches one branch of Prisma's `create` input
* union exactly — the relation-connect branch has no `projectId`/`runId`
* columns and would otherwise reject these fields.
*/
export async function writeAudit(
prisma: PrismaClient,
entry: AuditInput,
): Promise<void> {
const data: Prisma.AuditEntryUncheckedCreateInput = {
action: entry.action,
metadata: entry.metadata as Prisma.InputJsonValue,
};
if (entry.runId !== undefined) data.runId = entry.runId;
if (entry.projectId !== undefined) data.projectId = entry.projectId;
if (entry.actorUserId !== undefined) data.actorUserId = entry.actorUserId;
try {
await prisma.auditEntry.create({ data });
} catch {
// Swallow: audit is best-effort and must not break the caller's path.
}
}