aps-agent/server/core/approval/store.ts

330 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 待确认动作(任务 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<string, unknown>;
/** 原始参数的密文:确认后要照原样执行,但库里不留明文 */
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<string, unknown>;
/** 原始参数的密文:确认后要照原样执行,但库里不留明文 */
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<string, unknown>,
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<string, PendingAction>();
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);
}
}