@sema-agent/server 7.10.0 → 7.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +1 -1
  2. package/USAGE.md +56 -0
  3. package/dist/adoption/plan.d.ts +152 -0
  4. package/dist/adoption/plan.js +513 -0
  5. package/dist/adoption/runner.d.ts +54 -0
  6. package/dist/adoption/runner.js +505 -0
  7. package/dist/adoption/sql.d.ts +76 -0
  8. package/dist/adoption/sql.js +106 -0
  9. package/dist/adoption/wire.d.ts +250 -0
  10. package/dist/adoption/wire.js +153 -0
  11. package/dist/approval-card.d.ts +24 -0
  12. package/dist/approval-card.js +32 -0
  13. package/dist/auth-keys.d.ts +28 -4
  14. package/dist/auth-keys.js +60 -15
  15. package/dist/boot/adoption.d.ts +30 -0
  16. package/dist/boot/adoption.js +57 -0
  17. package/dist/boot/coordinators.d.ts +4 -0
  18. package/dist/boot/coordinators.js +3 -1
  19. package/dist/boot/parked-revive-gate.d.ts +56 -7
  20. package/dist/boot/parked-revive-gate.js +177 -8
  21. package/dist/boot/permission-rules-audit.d.ts +49 -0
  22. package/dist/boot/permission-rules-audit.js +57 -0
  23. package/dist/boot/resolve-spec.js +43 -12
  24. package/dist/boot/runner-deps.d.ts +10 -2
  25. package/dist/boot/runner-deps.js +12 -1
  26. package/dist/budget.js +22 -0
  27. package/dist/config-types.d.ts +24 -1
  28. package/dist/config.d.ts +28 -2
  29. package/dist/config.js +348 -75
  30. package/dist/governance-ask-marks.js +8 -2
  31. package/dist/http/route-ctx.d.ts +6 -3
  32. package/dist/http/routes/adoption.d.ts +26 -0
  33. package/dist/http/routes/adoption.js +120 -0
  34. package/dist/http/routes/approvals-assistant.js +2 -1
  35. package/dist/http/routes/capabilities.js +49 -2
  36. package/dist/http/routes/rules.d.ts +35 -0
  37. package/dist/http/routes/rules.js +293 -0
  38. package/dist/http/routes/shared-memory.d.ts +31 -0
  39. package/dist/http/routes/shared-memory.js +181 -0
  40. package/dist/http/routes/trace-usage.js +139 -3
  41. package/dist/http/server.d.ts +23 -3
  42. package/dist/http/server.js +123 -3
  43. package/dist/http/wire-types.d.ts +48 -0
  44. package/dist/main.js +70 -3
  45. package/dist/observability/fail-open.d.ts +12 -0
  46. package/dist/observability/fail-open.js +12 -0
  47. package/dist/observability/metrics.js +2 -1
  48. package/dist/observability/tool-trace.d.ts +5 -1
  49. package/dist/observability/tool-trace.js +33 -6
  50. package/dist/parked-decide.d.ts +13 -3
  51. package/dist/parked-decide.js +10 -1
  52. package/dist/plugins/adoption-log-sql.d.ts +191 -0
  53. package/dist/plugins/adoption-log-sql.js +273 -0
  54. package/dist/plugins/checkpoint-store-sql.d.ts +13 -0
  55. package/dist/plugins/checkpoint-store-sql.js +11 -0
  56. package/dist/plugins/local-checkpoint-store.d.ts +10 -0
  57. package/dist/plugins/local-checkpoint-store.js +8 -0
  58. package/dist/plugins/permission-rule-store-file.d.ts +83 -0
  59. package/dist/plugins/permission-rule-store-file.js +371 -0
  60. package/dist/plugins/permission-rule-store-sql.d.ts +249 -0
  61. package/dist/plugins/permission-rule-store-sql.js +828 -0
  62. package/dist/plugins/pg-pool.js +37 -0
  63. package/dist/plugins/session-policy-store-sql.d.ts +6 -0
  64. package/dist/plugins/session-policy-store-sql.js +7 -1
  65. package/dist/plugins/shared-memory-store-sql.d.ts +223 -0
  66. package/dist/plugins/shared-memory-store-sql.js +516 -0
  67. package/dist/plugins/store-backend.d.ts +38 -2
  68. package/dist/plugins/store-backend.js +69 -6
  69. package/dist/plugins/tidb-pool.js +47 -0
  70. package/dist/rules-consent.d.ts +194 -0
  71. package/dist/rules-consent.js +240 -0
  72. package/dist/run-local.js +120 -13
  73. package/dist/runtime-governance.js +9 -3
  74. package/dist/shared-memory-scope-authorizer.d.ts +29 -0
  75. package/dist/shared-memory-scope-authorizer.js +17 -0
  76. package/dist/task-settings.d.ts +44 -0
  77. package/dist/task-settings.js +57 -1
  78. package/dist/tool-approval.d.ts +60 -0
  79. package/dist/tool-approval.js +223 -25
  80. package/dist/trace/core-keyset-guard.d.ts +15 -4
  81. package/dist/trace/project.d.ts +20 -2
  82. package/dist/trace/project.js +25 -4
  83. package/package.json +3 -3
@@ -0,0 +1,83 @@
1
+ import { FilePermissionRuleStoreProvider, type RuleApprovalRecord, type RuleApprovalRecordStore, type RuleScope } from "@sema-agent/core";
2
+ import { type RuleImportTicket, type RuleTicketRedeemResult } from "./permission-rule-store-sql.js";
3
+ import type { PermissionRuleStoreBundle } from "../rules-consent.js";
4
+ /**
5
+ * `RuleApprovalRecordStore` 的 File 形。
6
+ *
7
+ * CAS 按 `rev`(core 硬条款,理由逐字见 SQL 侧同名类的头注:只比 state 会让批记录的第二个候选上两次
8
+ * 并发重试都以为自己赢了)。
9
+ */
10
+ export declare class FileRuleApprovalRecordStore implements RuleApprovalRecordStore {
11
+ private readonly rows;
12
+ private readonly log;
13
+ private readonly gate;
14
+ constructor(dir: string, onError?: (message: string) => void);
15
+ /** 取证/自证用:本面是否因内部损坏而 fail-closed。 */
16
+ get corrupt(): boolean;
17
+ /** 归还本面 eager 持有的日志描述符(束的 `dispose()` 唯一调用点)。`AppendLog` 上没有 finalizer,
18
+ * 不显式关就是一只跟到进程末尾的 fd —— `LocalBackend` 反复开合(热重载/多根)时按次泄漏。
19
+ * 关后写面抛 `log_closed`(core 语义):**不重开**,因为「这只店已经交还」与「这条命还能写」
20
+ * 不能两立,静默重开会让一次 dispose 之后的写落进一份没人再读的日志。 */
21
+ close(): void;
22
+ get(id: string): Promise<RuleApprovalRecord | undefined>;
23
+ create(record: RuleApprovalRecord): Promise<void>;
24
+ cas(id: string, expectRev: number, next: RuleApprovalRecord): Promise<boolean>;
25
+ /** 与 SQL 侧同名方法同义(超帽拒绝时收掉 `prepareCcImport` 已落盘的那条 pending 记录)。
26
+ * `state === "pending"` 是硬的:已确认/已兑付的记录是一次真人同意的审计事实。 */
27
+ discardPendingRecord(recordId: string): Promise<boolean>;
28
+ }
29
+ /**
30
+ * CC 导入票的 File 形。四条信任边界(principal 绑定 / TTL / 一次性原子消费 / 载荷绑定)与 SQL 形
31
+ * **同语义**;唯一不同的是「原子」由谁保证 —— 那边是引擎行锁,这边是单进程 + 单写者(顶注)。
32
+ */
33
+ export declare class FileRuleImportTicketStore {
34
+ private readonly rows;
35
+ private readonly log;
36
+ private readonly gate;
37
+ constructor(dir: string, now?: () => number, onError?: (message: string) => void);
38
+ private readonly now;
39
+ /** 取证/自证用:本面是否因内部损坏而 fail-closed。 */
40
+ get corrupt(): boolean;
41
+ /** 归还本面 eager 持有的日志描述符(理由与姊妹面 {@link FileRuleApprovalRecordStore.close} 逐字同源)。 */
42
+ close(): void;
43
+ mint(input: {
44
+ ticketId: string;
45
+ principal: string;
46
+ approvalId: string;
47
+ candidates: ReadonlyArray<{
48
+ rule: string;
49
+ scope: RuleScope;
50
+ }>;
51
+ ttlMs: number;
52
+ }): Promise<RuleImportTicket>;
53
+ /** 一次性**认领**。四条否定项的判序与 SQL 形逐字相同(unknown → wrong-principal → consumed → expired),
54
+ * 于是两形的服务端日志归因可比;wire 面把四类折成同一个 404(零存在性 oracle)。 */
55
+ consume(ticketId: string, principal: string): Promise<RuleTicketRedeemResult>;
56
+ /** 把认领**放回去**。`mustRemainValidMs` = 放回之后至少还要能用多久 —— 撑不过就不算放回成功
57
+ * (理由逐字见 SQL 侧 `release` 的头注:承诺一次必然兑现不了的重试比不承诺更坏)。 */
58
+ release(ticketId: string, principal: string, mustRemainValidMs?: number): Promise<boolean>;
59
+ }
60
+ /** local 车道的三面束 + 生命周期。`dispose()` 释放 core File provider 的写锁(见 {@link createFilePermissionRuleStores})。 */
61
+ export interface FilePermissionRuleStores extends PermissionRuleStoreBundle {
62
+ provider: FilePermissionRuleStoreProvider;
63
+ approvals: FileRuleApprovalRecordStore;
64
+ tickets: FileRuleImportTicketStore;
65
+ dispose(): void;
66
+ }
67
+ /**
68
+ * 装配 local 三面束。
69
+ *
70
+ * 🔴 `provider` 必须是**单例**(F1):core 的 `FilePermissionRuleStoreProvider` 在第一次取写面时对规则
71
+ * 目录取一把进程级写锁,每次 `new` 一个就是第二个持有者 —— 而它对第二个持有者是**响亮拒绝**,不是
72
+ * 排队。所以束在 `LocalBackend` 上按字段持有,`LocalBackend.close()` 调 `dispose()` 释放
73
+ * (不释放 ⇒ 一次优雅重启会被自己上一条命留下的锁挡在门外,与数据根 `root/LOCK` 同一个病)。
74
+ *
75
+ * `onError` 接的是 core 规则文件的**披露面**(读不出来 / 校验和不符 / 撞上符号链接时它答零规则并
76
+ * 说出来)。接住它打一条 warn 是**必须**的:那条路径上「零规则」与「真的没有规则」在读面上同形,
77
+ * 不留痕就变成一次静默的 fail-closed(用户会突然被反复询问,却没有任何线索)。
78
+ */
79
+ export declare function createFilePermissionRuleStores(root: string, opts?: {
80
+ onError?: (message: string) => void;
81
+ now?: () => number;
82
+ }): FilePermissionRuleStores;
83
+ //# sourceMappingURL=permission-rule-store-file.d.ts.map
@@ -0,0 +1,371 @@
1
+ /**
2
+ * #203 §1(design/203 v2 §6 F1)—— 持久化权限规则店的 **local(File)三面束**。
3
+ *
4
+ * 车二在 `store-backend.ts` 上写下的那句「local 车道诚实缺席」有一条**明写的解除条件**:「要在 local
5
+ * 真上,先落 File 形(core 现成 `FilePermissionRuleStoreProvider` 即可当模子)」。本文件就是那一件 ——
6
+ * 三面里**规则桶那一面直接接线 core 的现成件**(不是照抄:它自带符号链接拒收、写锁、整文件校验和、
7
+ * 损坏即整份拒读,那几样都不是能顺手复刻对的东西),另外两面(审批记录 / 导入票)core 只给了
8
+ * `InMemory` 参照物,所以在这里落 File 形。
9
+ *
10
+ * ─────────────────────────────────────────────────────────────────────────────────────────────────
11
+ * 🔴 为什么 File 形的「单进程内 CAS」是**够的**(而不是一次偷工)
12
+ * ─────────────────────────────────────────────────────────────────────────────────────────────────
13
+ * SQL 形把 CAS 交给引擎的行锁,是因为多副本共享一张表。local 车道不是那个形状:`LocalBackend` 的构造
14
+ * 函数在数据根上取 `root/LOCK` 这只 boot pidfile 锁,**同主机第二个实例起不来**(store-backend.ts 的
15
+ * `LocalBackend` 头注逐字);core 的 `FilePermissionRuleStoreProvider` 自己还在规则目录上另取一把写锁,
16
+ * 第二个持有者被响亮拒绝而不是交错写。⇒ 在这条车道上「同一时刻只有一个写者」是**被强制**的,不是被
17
+ * 假设的,于是进程内的 `rev` 比较就是一次真 compare-and-set。
18
+ * 这条推理有一个已登记的边界(与 core 的 File 规则店同源、不是本文件新增的):跨 PID 命名空间或 NFS
19
+ * 上 `process.kill(pid,0)` 的活体判据会失效(见 `docs/DEPLOY-PREREQS.md`)。那时该换 SQL 后端 ——
20
+ * 这正是 store seam 存在的理由。
21
+ *
22
+ * ─────────────────────────────────────────────────────────────────────────────────────────────────
23
+ * 🔴 落盘形:一票据/一记录一行 JSONL 追加日志 + boot 重放,**不是**整文件覆写
24
+ * ─────────────────────────────────────────────────────────────────────────────────────────────────
25
+ * 与 `FileApprovalExemptionStore` / `FileSendFileLedger` 同一个模子(core 的 `AppendLog` /
26
+ * `readJsonlRecords`:追加 + fsync,撕裂的尾行在重放时被丢掉而不是被猜出来)。选它而不是「整份 JSON
27
+ * 原子改名」的理由是**这两面的写都是逐条的**(一次审批一条、一张票一条),整份覆写会让一次写的代价随
28
+ * 历史长度线性涨,而且把「两条无关记录」放进同一次 CAS 窗口。
29
+ * 代价如实登记:日志只增不减(没有紧凑腿)。票有 TTL、记录是审计事实,单用户一台机器上的量级是每天
30
+ * 几条到几十条 —— 真长到要紧凑时,该做的是紧凑腿(后续件),不是现在为它牺牲崩溃安全。
31
+ *
32
+ * ─────────────────────────────────────────────────────────────────────────────────────────────────
33
+ * 🔴 状态在**内存索引**里,磁盘是它的重放源
34
+ * ─────────────────────────────────────────────────────────────────────────────────────────────────
35
+ * 判决(CAS 成不成、票能不能认领)一律读内存索引,写成功后**先追加日志再改索引** —— 反过来(先改索引
36
+ * 再写盘)会在写失败时留下一个「进程认为已经发生、盘上没有」的事实,重启即回滚,而中间那段时间里
37
+ * 一次已经被消费掉的票会被当成还能用。
38
+ */
39
+ import { join } from "node:path";
40
+ import { readdirSync } from "node:fs";
41
+ import { FilePermissionRuleStoreProvider, AppendLog, ensureDir, readJsonlRecords, } from "@sema-agent/core";
42
+ import { z } from "zod";
43
+ import { buildRulePayloadHash } from "./permission-rule-store-sql.js";
44
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
45
+ // 落盘记录的边界 schema(宪法 [2704]:边界必 schema,禁裸 as-cast)
46
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
47
+ //
48
+ // 🔴 这**是**一条边界:磁盘上的字节可能来自上一个版本、来自一次手改、来自一次撕裂写。读回来的东西
49
+ // 若被 as-cast 成域类型,一条缺字段的记录会带着 `undefined` 走进 CAS 与兑付判决。判据用 zod。
50
+ //
51
+ // 🔴 读不出形的**内部**行 ⇒ 整面 fail-closed(codex 对抗复审 R1-F4,验真后修)。
52
+ // 本文件的初版写的是「坏行跳过 —— 一行是一条独立事实」,那句话是**错的**,而且错在最贵的地方:
53
+ // 票日志上的 mint / consume / release 是**同一张票的状态迁移**。一条 `consume` 坏掉而它前面的 `mint`
54
+ // 还读得出 ⇒ 重启后那张票复活成**未认领**,「一次性消费」这条信任边界当场失效(SQL 形靠引擎行锁,
55
+ // 永远不会有这种形)。审批记录同族:一条坏掉的快照会把记录连同 `rev` 回滚到更早的状态。
56
+ // 处置与 core 对**规则文件**的姿势对齐 ——「refusing the whole bucket rather than reporting a partial
57
+ // rule set」:整面拒 + 响亮留痕。失败方向是收紧(兑不动 ⇒ 多问几次),这正是这条轴该倒的方向。
58
+ // **撕裂的尾行不算**:core 的 `readJsonlRecords` 按崩溃尾丢弃它并且**不**报 `onCorrupt` —— 那是每次
59
+ // 非优雅退出的正常落盘形,把它算成损坏会让一次 kill -9 永久锁死这条车道。
60
+ const RuleScopeSchema = z.union([
61
+ z.object({ kind: z.literal("global") }).strict(),
62
+ z.object({ kind: z.literal("project"), root: z.string().min(1) }).strict(),
63
+ ]);
64
+ const RuleDotSchema = z.object({ actor: z.string().min(1), counter: z.number().int().nonnegative().safe() }).strict();
65
+ const RuleCandidateSchema = z.object({ rule: z.string(), scope: RuleScopeSchema }).strict();
66
+ const ApprovalRecordSchema = z
67
+ .object({
68
+ id: z.string().min(1),
69
+ principal: z.string().optional(),
70
+ owner: z.union([z.object({ kind: z.literal("principal"), principal: z.string() }).strict(), z.object({ kind: z.literal("local-owner") }).strict()]).optional(),
71
+ kind: z.enum(["card", "import", "starter"]),
72
+ state: z.enum(["pending", "approved", "redeemed"]),
73
+ candidates: z.array(RuleCandidateSchema),
74
+ createdAt: z.string(),
75
+ toolCallId: z.string().optional(),
76
+ boundInputHash: z.string().optional(),
77
+ rev: z.number().int().nonnegative().safe(),
78
+ selectedCandidate: z.number().int().nonnegative().safe().optional(),
79
+ redeemedDots: z.record(z.string(), RuleDotSchema).optional(),
80
+ })
81
+ .strict();
82
+ /**
83
+ * 校验产物 → 域类型的**显式**投影。
84
+ *
85
+ * 🔴 为什么是一个函数而不是 `.transform(...)` 上挂一个断言:`RuleApprovalRecord.redeemedDots` 的键是
86
+ * `number`,而 JSON 对象的键恒是字符串 —— zod 只表达得出 `Record<string, …>`,于是「用 zod 直接声明成
87
+ * 域类型」必然要一次 `as unknown as`,那正是宪法禁的裸断言。逐字段抄写换来的是:core 给
88
+ * `RuleApprovalRecord` 加一个必填字段时,**这里编译红**(而断言形会静默放行一条缺字段的记录)。
89
+ * 数字键的还原与 SQL 侧 `redeemed_dots_json` 的回读逐字同一处置。
90
+ */
91
+ function toApprovalRecord(row) {
92
+ return {
93
+ id: row.id,
94
+ ...(row.principal !== undefined ? { principal: row.principal } : {}),
95
+ ...(row.owner !== undefined ? { owner: row.owner } : {}),
96
+ kind: row.kind,
97
+ state: row.state,
98
+ candidates: row.candidates,
99
+ createdAt: row.createdAt,
100
+ ...(row.toolCallId !== undefined ? { toolCallId: row.toolCallId } : {}),
101
+ ...(row.boundInputHash !== undefined ? { boundInputHash: row.boundInputHash } : {}),
102
+ rev: row.rev,
103
+ ...(row.selectedCandidate !== undefined ? { selectedCandidate: row.selectedCandidate } : {}),
104
+ ...(row.redeemedDots !== undefined
105
+ ? { redeemedDots: Object.fromEntries(Object.entries(row.redeemedDots).map(([k, v]) => [Number(k), v])) }
106
+ : {}),
107
+ };
108
+ }
109
+ /** 审批记录日志的一行:整条记录的快照(last-wins 重放)或一条丢弃墓碑。 */
110
+ const ApprovalLineSchema = z.union([
111
+ z.object({ op: z.literal("put"), record: ApprovalRecordSchema }).strict(),
112
+ z.object({ op: z.literal("discard"), id: z.string().min(1) }).strict(),
113
+ ]);
114
+ /** 票日志的一行:铸票 / 认领 / 放回。三个动作各一行,重放顺序即真相。 */
115
+ const TicketLineSchema = z.union([
116
+ z
117
+ .object({
118
+ op: z.literal("mint"),
119
+ ticketId: z.string().min(1),
120
+ ownerKey: z.string().min(1),
121
+ approvalId: z.string().min(1),
122
+ payloadHash: z.string().min(1),
123
+ expiresAtMs: z.number().int().nonnegative().safe(),
124
+ })
125
+ .strict(),
126
+ z.object({ op: z.literal("consume"), ticketId: z.string().min(1), atMs: z.number().int().nonnegative().safe() }).strict(),
127
+ z.object({ op: z.literal("release"), ticketId: z.string().min(1) }).strict(),
128
+ ]);
129
+ /**
130
+ * 一面的**损坏闸**。置位之后该面的每一个方法都响亮拒绝 —— 读与写都拒:一份不完整的授权账既不能用来
131
+ * 判决,也不能在它上面继续追加(追加只会让下一次重启读到同样残缺的历史)。
132
+ */
133
+ class CorruptionGate {
134
+ face;
135
+ path;
136
+ onError;
137
+ reason;
138
+ constructor(face, path, onError) {
139
+ this.face = face;
140
+ this.path = path;
141
+ this.onError = onError;
142
+ }
143
+ /** 记一次内部损坏(幂等:第一条原因就是要报告的那条)。 */
144
+ trip(reason) {
145
+ this.reason ??= reason;
146
+ this.onError?.(`${this.face} at ${this.path} is corrupt: ${reason}`);
147
+ }
148
+ get tripped() {
149
+ return this.reason !== undefined;
150
+ }
151
+ /** 每个方法的第一行。文案里带上文件路径与处置 —— 一个运维读到它要知道去看哪个文件。 */
152
+ assertUsable() {
153
+ if (this.reason === undefined)
154
+ return;
155
+ throw new Error(`refusing to use a corrupt ${this.face} (${this.path}): ${this.reason} — ` +
156
+ "the permission-rule lane is fail-closed until a person inspects the file (moving it aside restarts the lane with an empty log)");
157
+ }
158
+ }
159
+ /**
160
+ * `RuleApprovalRecordStore` 的 File 形。
161
+ *
162
+ * CAS 按 `rev`(core 硬条款,理由逐字见 SQL 侧同名类的头注:只比 state 会让批记录的第二个候选上两次
163
+ * 并发重试都以为自己赢了)。
164
+ */
165
+ export class FileRuleApprovalRecordStore {
166
+ rows = new Map();
167
+ log;
168
+ gate;
169
+ constructor(dir, onError) {
170
+ ensureDir(dir);
171
+ const path = join(dir, "approvals.jsonl");
172
+ this.gate = new CorruptionGate("permission-rule approval log", path, onError);
173
+ // `onCorrupt` = core 对**内部**不可解析行的报告口(崩溃尾它自己丢弃且不报)。不接它就是把一次
174
+ // 「历史被削掉一块」变成静默 —— #191 门② 说的正是这个形。
175
+ for (const raw of readJsonlRecords(path, (info) => this.gate.trip(info.reason))) {
176
+ const parsed = ApprovalLineSchema.safeParse(raw);
177
+ if (!parsed.success) {
178
+ // 形不对 = 与「解析不出」同一类事实(版本漂/手改),同样是历史缺了一块 ⇒ 同样 fail-closed。
179
+ this.gate.trip(`a replayed record does not carry a readable shape: ${parsed.error.message}`);
180
+ continue;
181
+ }
182
+ if (parsed.data.op === "discard")
183
+ this.rows.delete(parsed.data.id);
184
+ else
185
+ this.rows.set(parsed.data.record.id, toApprovalRecord(parsed.data.record));
186
+ }
187
+ this.log = new AppendLog(path);
188
+ }
189
+ /** 取证/自证用:本面是否因内部损坏而 fail-closed。 */
190
+ get corrupt() {
191
+ return this.gate.tripped;
192
+ }
193
+ /** 归还本面 eager 持有的日志描述符(束的 `dispose()` 唯一调用点)。`AppendLog` 上没有 finalizer,
194
+ * 不显式关就是一只跟到进程末尾的 fd —— `LocalBackend` 反复开合(热重载/多根)时按次泄漏。
195
+ * 关后写面抛 `log_closed`(core 语义):**不重开**,因为「这只店已经交还」与「这条命还能写」
196
+ * 不能两立,静默重开会让一次 dispose 之后的写落进一份没人再读的日志。 */
197
+ close() {
198
+ this.log.close();
199
+ }
200
+ async get(id) {
201
+ this.gate.assertUsable();
202
+ const r = this.rows.get(id);
203
+ // 深拷贝出门:调用方(core 的兑付腿)会就地改它再交回 `cas`,共享同一个对象会让 CAS 比的是
204
+ // 「自己改过的那份」——那等于没有 CAS。
205
+ return r === undefined ? undefined : structuredClone(r);
206
+ }
207
+ async create(record) {
208
+ this.gate.assertUsable();
209
+ if (this.rows.has(record.id))
210
+ throw new Error(`rule approval record ${record.id} already exists`);
211
+ this.log.append({ op: "put", record }, true); // 先盘后索引(顶注)
212
+ this.rows.set(record.id, structuredClone(record));
213
+ }
214
+ async cas(id, expectRev, next) {
215
+ this.gate.assertUsable();
216
+ if (next.rev !== expectRev + 1) {
217
+ throw new Error(`rule-approval CAS must advance rev by exactly one (expectRev=${expectRev}, next.rev=${next.rev})`);
218
+ }
219
+ const cur = this.rows.get(id);
220
+ if (cur === undefined || cur.rev !== expectRev)
221
+ return false;
222
+ this.log.append({ op: "put", record: next }, true);
223
+ this.rows.set(id, structuredClone(next));
224
+ return true;
225
+ }
226
+ /** 与 SQL 侧同名方法同义(超帽拒绝时收掉 `prepareCcImport` 已落盘的那条 pending 记录)。
227
+ * `state === "pending"` 是硬的:已确认/已兑付的记录是一次真人同意的审计事实。 */
228
+ async discardPendingRecord(recordId) {
229
+ this.gate.assertUsable();
230
+ const cur = this.rows.get(recordId);
231
+ if (cur === undefined || cur.state !== "pending")
232
+ return false;
233
+ this.log.append({ op: "discard", id: recordId }, true);
234
+ this.rows.delete(recordId);
235
+ return true;
236
+ }
237
+ }
238
+ /**
239
+ * CC 导入票的 File 形。四条信任边界(principal 绑定 / TTL / 一次性原子消费 / 载荷绑定)与 SQL 形
240
+ * **同语义**;唯一不同的是「原子」由谁保证 —— 那边是引擎行锁,这边是单进程 + 单写者(顶注)。
241
+ */
242
+ export class FileRuleImportTicketStore {
243
+ rows = new Map();
244
+ log;
245
+ gate;
246
+ constructor(dir, now, onError) {
247
+ this.now = now ?? Date.now;
248
+ ensureDir(dir);
249
+ const path = join(dir, "import-tickets.jsonl");
250
+ this.gate = new CorruptionGate("permission-rule import-ticket log", path, onError);
251
+ // 🔴 这一面是四条信任边界里「一次性原子消费」的**唯一**载体(SQL 形靠引擎行锁,File 形靠这份日志)。
252
+ // 内部坏行 ⇒ 整面 fail-closed:一张票复活成未认领,代价是一次人的同意被重复兑付。
253
+ for (const raw of readJsonlRecords(path, (info) => this.gate.trip(info.reason))) {
254
+ const parsed = TicketLineSchema.safeParse(raw);
255
+ if (!parsed.success) {
256
+ this.gate.trip(`a replayed record does not carry a readable shape: ${parsed.error.message}`);
257
+ continue;
258
+ }
259
+ const line = parsed.data;
260
+ if (line.op === "mint") {
261
+ this.rows.set(line.ticketId, { ownerKey: line.ownerKey, approvalId: line.approvalId, payloadHash: line.payloadHash, expiresAtMs: line.expiresAtMs });
262
+ continue;
263
+ }
264
+ const row = this.rows.get(line.ticketId);
265
+ if (row === undefined)
266
+ continue;
267
+ if (line.op === "consume")
268
+ row.consumedAtMs = line.atMs;
269
+ else
270
+ delete row.consumedAtMs;
271
+ }
272
+ this.log = new AppendLog(path);
273
+ }
274
+ now;
275
+ /** 取证/自证用:本面是否因内部损坏而 fail-closed。 */
276
+ get corrupt() {
277
+ return this.gate.tripped;
278
+ }
279
+ /** 归还本面 eager 持有的日志描述符(理由与姊妹面 {@link FileRuleApprovalRecordStore.close} 逐字同源)。 */
280
+ close() {
281
+ this.log.close();
282
+ }
283
+ async mint(input) {
284
+ this.gate.assertUsable();
285
+ const expiresAtMs = this.now() + input.ttlMs;
286
+ // 摘要函数与 SQL 形**同一个**(载荷绑定的等式两侧同源;两份实现必然各自漂)。
287
+ const payloadHash = buildRulePayloadHash(input.candidates);
288
+ // owner 键:本车道只铸 principal 形的票(local-owner 桶没有跨网络的导入口)。刻意存**明文
289
+ // principal** 而不是 SQL 侧那把 sha —— 那把 hash 的两条理由(列宽静默截断、local-owner 需要定长
290
+ // 同域表示)在一份进程内 Map 上都不成立,而明文让一次取证直接读得懂。
291
+ this.log.append({ op: "mint", ticketId: input.ticketId, ownerKey: input.principal, approvalId: input.approvalId, payloadHash, expiresAtMs }, true);
292
+ this.rows.set(input.ticketId, { ownerKey: input.principal, approvalId: input.approvalId, payloadHash, expiresAtMs });
293
+ return { ticketId: input.ticketId, approvalId: input.approvalId, payloadHash, expiresAtMs };
294
+ }
295
+ /** 一次性**认领**。四条否定项的判序与 SQL 形逐字相同(unknown → wrong-principal → consumed → expired),
296
+ * 于是两形的服务端日志归因可比;wire 面把四类折成同一个 404(零存在性 oracle)。 */
297
+ async consume(ticketId, principal) {
298
+ this.gate.assertUsable();
299
+ const row = this.rows.get(ticketId);
300
+ if (row === undefined)
301
+ return { ok: false, reason: "unknown" };
302
+ if (row.ownerKey !== principal)
303
+ return { ok: false, reason: "wrong-principal" };
304
+ if (row.consumedAtMs !== undefined)
305
+ return { ok: false, reason: "consumed" };
306
+ const nowMs = this.now();
307
+ if (row.expiresAtMs <= nowMs)
308
+ return { ok: false, reason: "expired" };
309
+ this.log.append({ op: "consume", ticketId, atMs: nowMs }, true);
310
+ row.consumedAtMs = nowMs;
311
+ return { ok: true, approvalId: row.approvalId, payloadHash: row.payloadHash };
312
+ }
313
+ /** 把认领**放回去**。`mustRemainValidMs` = 放回之后至少还要能用多久 —— 撑不过就不算放回成功
314
+ * (理由逐字见 SQL 侧 `release` 的头注:承诺一次必然兑现不了的重试比不承诺更坏)。 */
315
+ async release(ticketId, principal, mustRemainValidMs = 0) {
316
+ this.gate.assertUsable();
317
+ const row = this.rows.get(ticketId);
318
+ if (row === undefined || row.ownerKey !== principal || row.consumedAtMs === undefined)
319
+ return false;
320
+ if (row.expiresAtMs <= this.now() + mustRemainValidMs)
321
+ return false;
322
+ this.log.append({ op: "release", ticketId }, true);
323
+ delete row.consumedAtMs;
324
+ return true;
325
+ }
326
+ }
327
+ /**
328
+ * 装配 local 三面束。
329
+ *
330
+ * 🔴 `provider` 必须是**单例**(F1):core 的 `FilePermissionRuleStoreProvider` 在第一次取写面时对规则
331
+ * 目录取一把进程级写锁,每次 `new` 一个就是第二个持有者 —— 而它对第二个持有者是**响亮拒绝**,不是
332
+ * 排队。所以束在 `LocalBackend` 上按字段持有,`LocalBackend.close()` 调 `dispose()` 释放
333
+ * (不释放 ⇒ 一次优雅重启会被自己上一条命留下的锁挡在门外,与数据根 `root/LOCK` 同一个病)。
334
+ *
335
+ * `onError` 接的是 core 规则文件的**披露面**(读不出来 / 校验和不符 / 撞上符号链接时它答零规则并
336
+ * 说出来)。接住它打一条 warn 是**必须**的:那条路径上「零规则」与「真的没有规则」在读面上同形,
337
+ * 不留痕就变成一次静默的 fail-closed(用户会突然被反复询问,却没有任何线索)。
338
+ */
339
+ export function createFilePermissionRuleStores(root, opts) {
340
+ const dir = join(root, "permission-rules");
341
+ ensureDir(dir);
342
+ const provider = opts?.onError ? new FilePermissionRuleStoreProvider(dir, opts.onError) : new FilePermissionRuleStoreProvider(dir);
343
+ // 三面共用**同一个**告警口:core 规则文件的披露与本仓两份日志的损坏对运维是同一件事
344
+ // (「这台机器上的规则面出问题了,去看这个目录」),分成两个 sink 只会让其中一个没人接。
345
+ const approvals = new FileRuleApprovalRecordStore(dir, opts?.onError);
346
+ const tickets = new FileRuleImportTicketStore(dir, opts?.now, opts?.onError);
347
+ return {
348
+ provider,
349
+ approvals,
350
+ tickets,
351
+ /** 桶数 = 规则目录里的桶文件数(core 的命名:`<64 hex>.json` = 一只 principal 桶,
352
+ * `local-owner.json` = 身份缺席桶)。**刻意不数**审批/票的日志文件与收编标记文件 ——
353
+ * 审计问的是「有没有既有的规则状态」,不是「这个目录里有几个文件」。
354
+ *
355
+ * 🔴 读不出目录就**抛**(不 catch 成 0)。目录在装配时刚 `ensureDir` 过,读不动只可能是被外力
356
+ * 删掉/权限被改 —— 那是「不知道」,不是「零」。回 0 会被休眠行审计读成「确认没有休眠行」,把一次
357
+ * 读失败伪装成一个结论;审计那侧本来就有 fail-open 臂(warn + 不拒启),让它去处置才是对的分工。 */
358
+ countBuckets: async () => readdirSync(dir).filter((n) => n === "local-owner.json" || /^[0-9a-f]{64}\.json$/.test(n)).length,
359
+ /** 三面各自的释放,**两只日志先于 provider**:provider 那步释放的是规则目录的进程级写锁,
360
+ * 一旦它先松手,同一个根就可能被下一位持有者开起来 —— 而此刻本束的两只 fd 还开着。顺序写死
361
+ * 在这里,`LocalBackend.close()` 只需调这一只口(与它对其余 file 店逐行关闭的既有纪律同形)。
362
+ * 幂等:两只 `close()` 与 `provider.dispose()` 都可重入(重复调用是常态 —— 兜底路径与
363
+ * `LocalBackend.close()` 会各调一次)。 */
364
+ dispose: () => {
365
+ approvals.close();
366
+ tickets.close();
367
+ provider.dispose();
368
+ },
369
+ };
370
+ }
371
+ //# sourceMappingURL=permission-rule-store-file.js.map