/** * 待确认动作(任务 A4.4):确认卡与一次性执行授权的持久层。 * * 一张表同时承担「待确认动作」与「执行记录」两件事(A4 备注:不设单独的凭据表与审批事件表): * 参数摘要、状态、回执、幂等键、有效期都在一行里,审批与执行的状态迁移是同一行的状态迁移。 * * 三条不变量: * 1. 确认卡绑定动作、参数摘要、目标版本与有效期——版本变了确认即失效 * 2. 确认人必须是会话发起人本人(单用户场景不做委托与转派) * 3. 待确认状态落库,进程重启后仍在;同一写请求重试不重复生效 */ import { randomUUID } from 'node:crypto'; import type { ExecutionReceipt } from '@aps/contracts'; import type { SealedSecret } from '../credentials/broker.js'; import type { Database } from '../storage/db.js'; export type PendingStatus = 'pending-confirmation' | 'succeeded' | 'failed' | 'rejected' | 'expired'; export type ApprovalDecision = 'approved' | 'rejected' | 'expired'; export interface PendingAction { executionId: string; approvalId: string; sessionId: string; projectId: string | null; ownerUserId: string; tenantId: string; capabilityId: string; providerId: string; paramsDigest: string; /** 脱敏后的关键参数:给确认卡看的,不是完整参数 */ paramsPreview: Record; /** 原始参数的密文:确认后要照原样执行,但库里不留明文 */ paramsSealed: SealedSecret; impactSummary: string; /** 目标数据版本;版本变了确认就失效 */ revision: number; status: PendingStatus; decision?: ApprovalDecision; decidedBy?: string; decidedAt?: number; /** 执行幂等键:同一写请求重试不重复生效 */ idempotencyKey?: string; /** 确认请求的幂等键 */ decisionIdempotencyKey?: string; grantId?: string; receipt: ExecutionReceipt; expiresAt: number; createdAt: number; updatedAt: number; } export interface CreatePendingInput { sessionId: string; projectId: string | null; ownerUserId: string; tenantId: string; capabilityId: string; providerId: string; paramsDigest: string; paramsPreview: Record; /** 原始参数的密文:确认后要照原样执行,但库里不留明文 */ paramsSealed: SealedSecret; impactSummary: string; revision: number; expiresAt: number; idempotencyKey?: string; } export type DecideKind = 'approved' | 'rejected' | 'expired' | 'replayed' | 'not-found' | 'revision-mismatch'; export interface DecideResult { kind: DecideKind; action: PendingAction; } interface PendingRow { execution_id: string; approval_id: string; session_id: string; project_id: string | null; owner_user_id: string; tenant_id: string; capability_id: string; provider_id: string; params_digest: string; params_preview: string; params_iv: string | null; params_ciphertext: string | null; params_tag: string | null; impact_summary: string; revision: number; status: string; decision: string | null; decided_by: string | null; decided_at: number | null; idempotency_key: string | null; decision_idempotency_key: string | null; grant_id: string | null; receipt: string; expires_at: number; created_at: number; updated_at: number; } function sealedFromRow(row: PendingRow): SealedSecret { return { iv: row.params_iv ?? '', ciphertext: row.params_ciphertext ?? '', tag: row.params_tag ?? '' }; } function fromRow(row: PendingRow): PendingAction { return { executionId: row.execution_id, approvalId: row.approval_id, sessionId: row.session_id, projectId: row.project_id, ownerUserId: row.owner_user_id, tenantId: row.tenant_id, capabilityId: row.capability_id, providerId: row.provider_id, paramsDigest: row.params_digest, paramsPreview: JSON.parse(row.params_preview) as Record, paramsSealed: sealedFromRow(row), impactSummary: row.impact_summary, revision: row.revision, status: row.status as PendingStatus, ...(row.decision === null ? {} : { decision: row.decision as ApprovalDecision }), ...(row.decided_by === null ? {} : { decidedBy: row.decided_by }), ...(row.decided_at === null ? {} : { decidedAt: row.decided_at }), ...(row.idempotency_key === null ? {} : { idempotencyKey: row.idempotency_key }), ...(row.decision_idempotency_key === null ? {} : { decisionIdempotencyKey: row.decision_idempotency_key }), ...(row.grant_id === null ? {} : { grantId: row.grant_id }), receipt: JSON.parse(row.receipt) as ExecutionReceipt, expiresAt: row.expires_at, createdAt: row.created_at, updatedAt: row.updated_at, }; } export class PendingActionStore { private readonly memory = new Map(); constructor( private readonly db?: Database, private readonly now: () => number = Date.now, ) {} create(input: CreatePendingInput): PendingAction { const at = this.now(); const action: PendingAction = { executionId: `exec-${randomUUID()}`, approvalId: `apr-${randomUUID()}`, sessionId: input.sessionId, projectId: input.projectId, ownerUserId: input.ownerUserId, tenantId: input.tenantId, capabilityId: input.capabilityId, providerId: input.providerId, paramsDigest: input.paramsDigest, paramsPreview: input.paramsPreview, paramsSealed: input.paramsSealed, impactSummary: input.impactSummary, revision: input.revision, status: 'pending-confirmation', ...(input.idempotencyKey === undefined ? {} : { idempotencyKey: input.idempotencyKey }), receipt: { executionId: '', status: 'pending-confirmation' }, expiresAt: input.expiresAt, createdAt: at, updatedAt: at, }; action.receipt = { executionId: action.executionId, status: 'pending-confirmation', approvalId: action.approvalId }; this.save(action); return action; } get(approvalId: string): PendingAction | undefined { if (this.db) { const row = this.db.prepare('select * from pending_actions where approval_id = ?').get(approvalId) as PendingRow | undefined; return row ? fromRow(row) : undefined; } return this.memory.get(approvalId); } byExecution(executionId: string): PendingAction | undefined { if (this.db) { const row = this.db.prepare('select * from pending_actions where execution_id = ?').get(executionId) as PendingRow | undefined; return row ? fromRow(row) : undefined; } return [...this.memory.values()].find((action) => action.executionId === executionId); } /** 同一写请求的重试:找到原待确认动作,不新建第二张确认卡 */ findByIdempotencyKey(sessionId: string, idempotencyKey: string): PendingAction | undefined { if (this.db) { const row = this.db .prepare('select * from pending_actions where session_id = ? and idempotency_key = ? limit 1') .get(sessionId, idempotencyKey) as PendingRow | undefined; return row ? fromRow(row) : undefined; } return [...this.memory.values()].find((action) => action.sessionId === sessionId && action.idempotencyKey === idempotencyKey); } listBySession(sessionId: string): PendingAction[] { if (this.db) { const rows = this.db .prepare('select * from pending_actions where session_id = ? order by created_at') .all(sessionId) as unknown as PendingRow[]; return rows.map(fromRow); } return [...this.memory.values()].filter((action) => action.sessionId === sessionId).sort((a, b) => a.createdAt - b.createdAt); } /** 确认或拒绝。返回 kind 描述这次调用发生了什么;已决策过的再次调用返回 replayed(幂等) */ decide( approvalId: string, input: { decision: 'approve' | 'reject'; revision: number; decidedBy: string; idempotencyKey: string }, ): DecideResult | undefined { const action = this.get(approvalId); if (!action) return undefined; // 已经决策过:同一请求重放返回同一结果;不重复执行 if (action.decision !== undefined && action.decision !== 'expired') return { kind: 'replayed', action }; const at = this.now(); if (at > action.expiresAt) { const expired: PendingAction = { ...action, status: 'expired', decision: 'expired', decidedBy: input.decidedBy, decidedAt: at, decisionIdempotencyKey: input.idempotencyKey, updatedAt: at }; this.save(expired); return { kind: 'expired', action: expired }; } if (input.revision !== action.revision) { // 目标数据在等待确认期间变了:这次确认作废,必须重新发起(协议第 7 节) const expired: PendingAction = { ...action, status: 'expired', decision: 'expired', decidedBy: input.decidedBy, decidedAt: at, decisionIdempotencyKey: input.idempotencyKey, updatedAt: at }; this.save(expired); return { kind: 'revision-mismatch', action: expired }; } if (input.decision === 'reject') { const rejected: PendingAction = { ...action, status: 'rejected', decision: 'rejected', decidedBy: input.decidedBy, decidedAt: at, decisionIdempotencyKey: input.idempotencyKey, updatedAt: at, }; this.save(rejected); return { kind: 'rejected', action: rejected }; } const approved: PendingAction = { ...action, decision: 'approved', decidedBy: input.decidedBy, decidedAt: at, decisionIdempotencyKey: input.idempotencyKey, updatedAt: at, }; this.save(approved); return { kind: 'approved', action: approved }; } /** 签发一次性授权后记账 */ attachGrant(approvalId: string, grantId: string): PendingAction | undefined { const action = this.get(approvalId); if (!action) return undefined; const updated: PendingAction = { ...action, grantId, updatedAt: this.now() }; this.save(updated); return updated; } /** 执行结束:回执与状态落到同一行 */ complete(executionId: string, receipt: ExecutionReceipt): PendingAction | undefined { const action = this.byExecution(executionId); if (!action) return undefined; const status: PendingStatus = receipt.status === 'succeeded' ? 'succeeded' : receipt.status === 'failed' ? 'failed' : 'pending-confirmation'; const updated: PendingAction = { ...action, status, receipt, updatedAt: this.now() }; this.save(updated); return updated; } private save(action: PendingAction): void { if (this.db) { this.db .prepare( `insert into pending_actions (execution_id, approval_id, session_id, project_id, owner_user_id, tenant_id, capability_id, provider_id, params_digest, params_preview, params_iv, params_ciphertext, params_tag, impact_summary, revision, status, decision, decided_by, decided_at, idempotency_key, decision_idempotency_key, grant_id, receipt, expires_at, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) on conflict(execution_id) do update set status = excluded.status, decision = excluded.decision, decided_by = excluded.decided_by, decided_at = excluded.decided_at, decision_idempotency_key = excluded.decision_idempotency_key, grant_id = excluded.grant_id, receipt = excluded.receipt, updated_at = excluded.updated_at`, ) .run( action.executionId, action.approvalId, action.sessionId, action.projectId, action.ownerUserId, action.tenantId, action.capabilityId, action.providerId, action.paramsDigest, JSON.stringify(action.paramsPreview), action.paramsSealed.iv, action.paramsSealed.ciphertext, action.paramsSealed.tag, action.impactSummary, action.revision, action.status, action.decision ?? null, action.decidedBy ?? null, action.decidedAt ?? null, action.idempotencyKey ?? null, action.decisionIdempotencyKey ?? null, action.grantId ?? null, JSON.stringify(action.receipt), action.expiresAt, action.createdAt, action.updatedAt, ); return; } this.memory.set(action.approvalId, action); } }