@sema-agent/server 7.10.0 → 7.11.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.
- package/dist/adoption/plan.d.ts +152 -0
- package/dist/adoption/plan.js +513 -0
- package/dist/adoption/runner.d.ts +54 -0
- package/dist/adoption/runner.js +505 -0
- package/dist/adoption/sql.d.ts +76 -0
- package/dist/adoption/sql.js +106 -0
- package/dist/adoption/wire.d.ts +250 -0
- package/dist/adoption/wire.js +153 -0
- package/dist/approval-card.d.ts +24 -0
- package/dist/approval-card.js +32 -0
- package/dist/boot/adoption.d.ts +30 -0
- package/dist/boot/adoption.js +57 -0
- package/dist/boot/coordinators.d.ts +4 -0
- package/dist/boot/coordinators.js +3 -1
- package/dist/boot/parked-revive-gate.d.ts +38 -5
- package/dist/boot/parked-revive-gate.js +53 -6
- package/dist/boot/runner-deps.d.ts +10 -2
- package/dist/boot/runner-deps.js +12 -1
- package/dist/config-types.d.ts +16 -1
- package/dist/config.js +5 -1
- package/dist/http/routes/adoption.d.ts +26 -0
- package/dist/http/routes/adoption.js +120 -0
- package/dist/http/routes/capabilities.js +12 -0
- package/dist/http/routes/rules.d.ts +23 -0
- package/dist/http/routes/rules.js +117 -0
- package/dist/http/routes/shared-memory.d.ts +31 -0
- package/dist/http/routes/shared-memory.js +181 -0
- package/dist/http/routes/trace-usage.js +139 -3
- package/dist/http/server.d.ts +19 -1
- package/dist/http/server.js +42 -0
- package/dist/main.js +52 -3
- package/dist/observability/fail-open.d.ts +8 -0
- package/dist/observability/fail-open.js +8 -0
- package/dist/plugins/adoption-log-sql.d.ts +191 -0
- package/dist/plugins/adoption-log-sql.js +273 -0
- package/dist/plugins/checkpoint-store-sql.d.ts +13 -0
- package/dist/plugins/checkpoint-store-sql.js +11 -0
- package/dist/plugins/local-checkpoint-store.d.ts +10 -0
- package/dist/plugins/local-checkpoint-store.js +8 -0
- package/dist/plugins/permission-rule-store-sql.d.ts +242 -0
- package/dist/plugins/permission-rule-store-sql.js +817 -0
- package/dist/plugins/pg-pool.js +37 -0
- package/dist/plugins/session-policy-store-sql.d.ts +6 -0
- package/dist/plugins/session-policy-store-sql.js +7 -1
- package/dist/plugins/shared-memory-store-sql.d.ts +223 -0
- package/dist/plugins/shared-memory-store-sql.js +516 -0
- package/dist/plugins/store-backend.d.ts +30 -0
- package/dist/plugins/store-backend.js +14 -0
- package/dist/plugins/tidb-pool.js +47 -0
- package/dist/rules-consent.d.ts +126 -0
- package/dist/rules-consent.js +198 -0
- package/dist/shared-memory-scope-authorizer.d.ts +29 -0
- package/dist/shared-memory-scope-authorizer.js +17 -0
- package/dist/tool-approval.d.ts +55 -0
- package/dist/tool-approval.js +124 -5
- package/dist/trace/core-keyset-guard.d.ts +2 -2
- package/dist/trace/project.d.ts +1 -0
- package/dist/trace/project.js +1 -0
- package/package.json +3 -3
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #154 车二 —— **持久化权限规则店的 SQL 双方言实装**(core 5.18.0 design/179 + 5.22.0 design/182)。
|
|
3
|
+
*
|
|
4
|
+
* 三张表,三个不同的问题:
|
|
5
|
+
* · `permission_rule` —— 一只**桶**(owner)的整份规则状态 + 同步游标面(actor/counter/rev/
|
|
6
|
+
* observed_vector),OCC 靠 `rev` 列。
|
|
7
|
+
* · `permission_rule_approval` —— core `RuleApprovalRecordStore` 的 SQL 形(durable 审批记录,CAS on rev)。
|
|
8
|
+
* · `permission_rule_ticket` —— **server 自铸**的 CC 导入票(principal 绑定 + TTL + 一次性原子消费 +
|
|
9
|
+
* 载荷绑定)。core 的 `RuleTicket` 没有这四样中的任何一样(亲读
|
|
10
|
+
* `mintRuleTicket` = `rt.<index>.<recordId>`,零 TTL、零签名、零消费位),
|
|
11
|
+
* 所以它们是 **server 附加的信任边界**,自建格。
|
|
12
|
+
*
|
|
13
|
+
* ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
14
|
+
* 🔴 为什么 `permission_rule` 是**一桶一行**而不是「一规则一行」
|
|
15
|
+
* ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
16
|
+
* 这个店的**每一个**操作都是全量的:`list()` 读整份、`readRaw()` 读整份、`apply(delta,{expectedRev})`
|
|
17
|
+
* 是**整份状态的一次 OCC 写**(core 明写「Partial landing 在写层不可拼写:要么整个 join 落地、要么一点
|
|
18
|
+
* 都不落」)。core 自己对同族问题的裁定逐字在 `RuleSyncFrontier` 的 JSDoc 里:「persist this as a JSON
|
|
19
|
+
* blob column —— the vector is read and written whole, never queried per-actor; **a row-per-actor table
|
|
20
|
+
* would invite partial updates the semantics cannot express**」。规则集与它是同一种量:CRDT 的收敛性
|
|
21
|
+
* 建立在「整份状态的 join」上,把它拆成行会让「半个 join 落地」在 SQL 层**变得可表达**——而那正是语义
|
|
22
|
+
* 禁止的形。一桶一行还带来一条真收益:OCC 写退化成**单条** `UPDATE … WHERE owner_key=? AND rev=?`,
|
|
23
|
+
* 天然原子,不需要事务、不需要 `FOR UPDATE`、不进本仓在案的悲观锁死锁族。
|
|
24
|
+
*
|
|
25
|
+
* ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
26
|
+
* 🔴 已登记的**上游契约缺口**(汇报件,不是本车的自由发挥)
|
|
27
|
+
* ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
* core 的**写面**(`PERMISSION_RULE_WRITER` 常量、`PermissionRuleWriter`/`WritablePermissionRuleStore`/
|
|
29
|
+
* `RuleWriteDelta`/`RawRuleSyncState` 类型、`foldDelta`/`applySyncJoin`/`writerOf`/
|
|
30
|
+
* `assertRedemptionNotQuarantined`/`assertDeleteDeltaCarriesNoAdd` 函数)**全部不在 `@sema-agent/core`
|
|
31
|
+
* 的公开导出面上**(亲读 `dist/index.d.ts`;`exports` map 只开 `.`/`./bench`/`./fixtures`,深路径
|
|
32
|
+
* import 被封)。core 的原话是「a host cannot hold one, so no API-level path to the store bypasses the
|
|
33
|
+
* consent protocol」——那条纪律针对的是「宿主自己往店里写」,而**一个宿主提供的 backend** 恰恰必须持有
|
|
34
|
+
* 写面才能被 `redeemRuleTicket` 使用(core 的 `writerOf(store)` 读的就是这个属性)。
|
|
35
|
+
*
|
|
36
|
+
* ⇒ 本文件按**结构**满足写面:属性名用 core 的字面常量 `"__semaPermissionRuleWriter"`,类型在下方
|
|
37
|
+
* 本地镜像(`RuleWriteDeltaMirror` 等)。这**是**一次形状复制,违反「schema 单一属主」的字面——
|
|
38
|
+
* 处置不是隐瞒而是登记:①镜像类型逐字对着 core 的 d.ts 写,并在 `test/permission-rule-store-*.test.ts`
|
|
39
|
+
* 里用**结构可赋值性**钉住(core 改形 ⇒ 编译红);②`sync-join` 那一臂**不实现**——它的落地管线
|
|
40
|
+
* (`applySyncJoin`)是 core 私有的一整条流水线,复制它才是真的会漂;本 backend 对该 delta **响亮拒绝**
|
|
41
|
+
* (fail-closed,不是静默 no-op),同步客户端本仓尚未接线,所以这条臂今天零调用点。
|
|
42
|
+
* ③ 求 core 导出写面(或提供一个 `createSqlBackedRuleStore` 适配器)已列入交接件。
|
|
43
|
+
*
|
|
44
|
+
* ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
45
|
+
* 边界纪律:列回读一律 zod `safeParse`(宪法 [2704] 禁裸 as-cast)。JSON 列是驱动交回的 `unknown`,
|
|
46
|
+
* 一条形状漂了的历史行若被 as-cast 成 `PersistedAllowRule[]`,会带着 `undefined` 字段一路走进
|
|
47
|
+
* `findAdmittingRule` —— 那是**放行面**上的静默垃圾,比读不出来坏得多。读不出来 ⇒ **throw**
|
|
48
|
+
* (core 明令:「a backend that fails to read must not silently report an empty set … it throws」)。
|
|
49
|
+
*/
|
|
50
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
51
|
+
import { z } from "zod";
|
|
52
|
+
import { applyTombstones, parseAllowRuleText, sameScope, screenRuleSyncState, RULE_SYNC_DROP_CODES, } from "@sema-agent/core";
|
|
53
|
+
import { mysqlDriver, pgDriver, dialectProtocolJsonEncoder } from "./sql-driver.js";
|
|
54
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
// core 私有写面的**本地镜像**(顶注 §「上游契约缺口」逐条理由)
|
|
56
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
57
|
+
/** core `permission-rule-store.ts` 的 `PERMISSION_RULE_WRITER` 字面值(逐字)。`writerOf(store)` 读的
|
|
58
|
+
* 就是这把键——它是 core 与 backend 之间**事实上的**协议名,只是没被导出。 */
|
|
59
|
+
export const PERMISSION_RULE_WRITER_KEY = "__semaPermissionRuleWriter";
|
|
60
|
+
/**
|
|
61
|
+
* core 私有 `writerOf` 的**本地对偶**:一只店的写面,或 `undefined`(该 backend 从引擎侧看是只读的)。
|
|
62
|
+
*
|
|
63
|
+
* 结构检查而不是断言 —— 它同时是本仓消费点(测试/诊断)读写面的**唯一**入口:没有这个函数,每个调用点
|
|
64
|
+
* 都会各写一次 `as unknown as {…}`,那既是三处宽松断言,也是三份会各自漂的形状假设。
|
|
65
|
+
*/
|
|
66
|
+
export function writerOfSqlRuleStore(store) {
|
|
67
|
+
const w = Reflect.get(store, PERMISSION_RULE_WRITER_KEY);
|
|
68
|
+
if (w === null || typeof w !== "object")
|
|
69
|
+
return undefined;
|
|
70
|
+
const nextDot = Reflect.get(w, "nextDot");
|
|
71
|
+
const apply = Reflect.get(w, "apply");
|
|
72
|
+
const readRaw = Reflect.get(w, "readRaw");
|
|
73
|
+
if (typeof nextDot !== "function" || typeof apply !== "function" || typeof readRaw !== "function")
|
|
74
|
+
return undefined;
|
|
75
|
+
return { nextDot: nextDot, apply: apply, readRaw: readRaw };
|
|
76
|
+
}
|
|
77
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
78
|
+
// 列回读的边界 schema(宪法 [2704]:边界必 schema,禁裸 as-cast)
|
|
79
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
80
|
+
const RuleScopeSchema = z.union([
|
|
81
|
+
z.object({ kind: z.literal("global") }).strict(),
|
|
82
|
+
// root 非空:一条 `{kind:"project", root:""}` 的规则在 `pathWithinRoot` 下会**覆盖每一个 cwd**
|
|
83
|
+
// (空前缀包含一切)—— 那是把一条项目内规则悄悄升成全局规则(codex round1 [high] 二)。
|
|
84
|
+
z.object({ kind: z.literal("project"), root: z.string().min(1) }).strict(),
|
|
85
|
+
]);
|
|
86
|
+
/** dot 的两位都是**身份**:actor 非空串,counter 是安全范围内的非负整数。
|
|
87
|
+
* (codex 交叉复审 round1 [high] 二:裸 `z.number()` 放行 `NaN` / `1e308` / 小数 —— 一个 `NaN` counter
|
|
88
|
+
* 会让 dot 身份比较恒不相等,tombstone 从此删不掉它对应的 add。) */
|
|
89
|
+
const RuleDotSchema = z.object({ actor: z.string().min(1), counter: z.number().int().nonnegative().safe() }).strict();
|
|
90
|
+
const RuleAddSchema = z
|
|
91
|
+
.object({ dot: RuleDotSchema, origin: z.enum(["user", "imported-cc", "starter"]), createdAt: z.string() })
|
|
92
|
+
.strict();
|
|
93
|
+
const PersistedAllowRuleSchema = z
|
|
94
|
+
.object({
|
|
95
|
+
rule: z.string(),
|
|
96
|
+
tool: z.literal("Bash"),
|
|
97
|
+
match: z.enum(["exact", "prefix"]),
|
|
98
|
+
command: z.string(),
|
|
99
|
+
scope: RuleScopeSchema,
|
|
100
|
+
adds: z.array(RuleAddSchema),
|
|
101
|
+
})
|
|
102
|
+
.strict();
|
|
103
|
+
/** 🔴 `removedDots` **非空**(codex 交叉复审 round2 [high],验真后修):core 的落地管线对空 removedDots
|
|
104
|
+
* 的 tombstone 是**抛错**(「a protocol violation」),而它落到读面上的后果更隐蔽 —— 一条删不掉任何
|
|
105
|
+
* add 的墓碑会让一条**本该已删**的规则继续放行。`removePersistedRule` 在没有可删对象时压根不铸墓碑,
|
|
106
|
+
* 所以健康数据恒满足这一条;不满足的只可能是版本回退/还原/另一个写者。 */
|
|
107
|
+
const RuleTombstoneSchema = z
|
|
108
|
+
.object({ rule: z.string(), scope: RuleScopeSchema, removedDots: z.array(RuleDotSchema).min(1), deletedBy: RuleDotSchema })
|
|
109
|
+
.strict();
|
|
110
|
+
/** 隔离原因是 core 的**闭集**(`RULE_SYNC_DROP_CODES`)——词表从 core 派生,core 加员时本 schema 自动跟,
|
|
111
|
+
* 绝不手抄一份(手抄的那份会在 core 加员的那天静默拒收一整类合法行)。 */
|
|
112
|
+
const QUARANTINE_REASONS = new Set(Object.entries(RULE_SYNC_DROP_CODES)
|
|
113
|
+
.filter(([, side]) => side.includes("local-quarantined"))
|
|
114
|
+
.map(([code]) => code));
|
|
115
|
+
/** 🔴 词表从 core 的表**派生**(而不是抄一份字面量):`RuleQuarantineReason` 是 `RULE_SYNC_DROP_CODES`
|
|
116
|
+
* 中「会把 LOCAL 行搬进隔离区」的那一侧。抄一份的那天 core 加员,本 schema 会静默拒收一整类合法行。 */
|
|
117
|
+
const isQuarantineReason = (v) => typeof v === "string" && QUARANTINE_REASONS.has(v);
|
|
118
|
+
const QuarantinedRuleAddSchema = z
|
|
119
|
+
.object({
|
|
120
|
+
rule: z.string(),
|
|
121
|
+
scope: RuleScopeSchema,
|
|
122
|
+
add: RuleAddSchema,
|
|
123
|
+
reason: z.custom(isQuarantineReason, { message: "not a registered permission-rule quarantine reason" }),
|
|
124
|
+
atMs: z.number().int().nonnegative().safe(),
|
|
125
|
+
})
|
|
126
|
+
.strict();
|
|
127
|
+
const FrontierSchema = z.record(z.string(), z.number().int().nonnegative().safe());
|
|
128
|
+
const RuleCandidateSchema = z.object({ rule: z.string(), scope: RuleScopeSchema }).strict();
|
|
129
|
+
const RedeemedDotsSchema = z.record(z.string(), RuleDotSchema);
|
|
130
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
131
|
+
// DDL
|
|
132
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
133
|
+
export const PERMISSION_RULE_TABLE = "permission_rule";
|
|
134
|
+
export const PERMISSION_RULE_APPROVAL_TABLE = "permission_rule_approval";
|
|
135
|
+
export const PERMISSION_RULE_TICKET_TABLE = "permission_rule_ticket";
|
|
136
|
+
/**
|
|
137
|
+
* MySQL-protocol(TiDB)侧的三条建表语句。真源在本文件(与 PG twin 并排,方言差异一眼可对);
|
|
138
|
+
* `tidb-pool.ts` 的 `SCHEMA_STATEMENTS` 用 `...TIDB_PERMISSION_RULE_STATEMENTS` 展开,于是三张表跟着
|
|
139
|
+
* 中央 `ensureSchema` 在 **named-lock 的那条 conn** 上建(同 `TIDB_APPROVAL_ASK_STATEMENTS` 先例)。
|
|
140
|
+
*
|
|
141
|
+
* 列宽依据(#192 A10 的「每列的值由谁铸、有没有入口上限」口径):
|
|
142
|
+
* · `owner_key` / `record_id` / `ticket_id` / `approval_id` / `payload_hash` / `checksum` → 190:
|
|
143
|
+
* 全是本仓自铸的键类值(sha256 hex 64 / uuid 36 / `sha256:` 前缀 71),190 是全仓键轴的统一宽度。
|
|
144
|
+
* · `principal` → 512:principal 入口上限是 `PRINCIPAL_MAX_LENGTH = 190`(`security.ts`),这里给
|
|
145
|
+
* 名类宽度 512 是**上界宽于入口**的方向(绝不制造静默截断面)。
|
|
146
|
+
* · `actor` → 190:本 backend 自铸的副本身份(`sql-<12 hex>`),按构造 ≤ 20 字符。
|
|
147
|
+
* · 内容列一律 LONGTEXT / TEXT:一只桶的规则集没有硬上限(导入一份大 settings 就能过 64K TEXT 墙)。
|
|
148
|
+
* · 毫秒列一律 `_ms` 后缀;OCC 列一律叫 `rev`(`version` 是形/schema 版本的保留词)。
|
|
149
|
+
*/
|
|
150
|
+
export const TIDB_PERMISSION_RULE_STATEMENTS = [
|
|
151
|
+
`CREATE TABLE IF NOT EXISTS ${PERMISSION_RULE_TABLE} (
|
|
152
|
+
owner_key VARCHAR(190) NOT NULL,
|
|
153
|
+
owner_kind VARCHAR(32) NOT NULL,
|
|
154
|
+
principal VARCHAR(512) NULL,
|
|
155
|
+
-- actor/counter:本副本的 dot 铸造轴(design/182 §4.2 的 replica identity + minted high water)。
|
|
156
|
+
actor VARCHAR(190) NOT NULL,
|
|
157
|
+
counter BIGINT NOT NULL DEFAULT 0,
|
|
158
|
+
-- rev:OCC 计数器(禁叫 version —— 那个词留给形/schema 版本,schema-naming 门③ 咬)。
|
|
159
|
+
rev BIGINT NOT NULL DEFAULT 0,
|
|
160
|
+
rules_json LONGTEXT NOT NULL,
|
|
161
|
+
tombstones_json LONGTEXT NOT NULL,
|
|
162
|
+
quarantined_json LONGTEXT NOT NULL,
|
|
163
|
+
-- observed_vector_json:同步游标面(design/182 §5.1 的观测向量)。整读整写的 JSON blob——
|
|
164
|
+
-- core 明令不得拆成一 actor 一行(会让「半个更新」变得可表达)。
|
|
165
|
+
observed_vector_json LONGTEXT NULL,
|
|
166
|
+
last_round_at_ms BIGINT NULL,
|
|
167
|
+
-- checksum:core \`ruleStoreChecksum\` 同精神的完整性指纹(**只覆盖内容三列**,不含 rev/counter)。
|
|
168
|
+
-- 读回不符 ⇒ throw(绝不静默当空店 —— core 对读失败的姿势明令如此)。
|
|
169
|
+
checksum VARCHAR(190) NOT NULL,
|
|
170
|
+
created_at_ms BIGINT NOT NULL,
|
|
171
|
+
updated_at_ms BIGINT NOT NULL,
|
|
172
|
+
PRIMARY KEY (owner_key)
|
|
173
|
+
) COLLATE utf8mb4_bin`,
|
|
174
|
+
`CREATE TABLE IF NOT EXISTS ${PERMISSION_RULE_APPROVAL_TABLE} (
|
|
175
|
+
record_id VARCHAR(190) NOT NULL,
|
|
176
|
+
owner_key VARCHAR(190) NOT NULL,
|
|
177
|
+
owner_kind VARCHAR(32) NOT NULL,
|
|
178
|
+
principal VARCHAR(512) NULL,
|
|
179
|
+
kind VARCHAR(32) NOT NULL,
|
|
180
|
+
state VARCHAR(32) NOT NULL,
|
|
181
|
+
rev BIGINT NOT NULL DEFAULT 0,
|
|
182
|
+
candidates_json LONGTEXT NOT NULL,
|
|
183
|
+
selected_candidate INT NULL,
|
|
184
|
+
redeemed_dots_json LONGTEXT NULL,
|
|
185
|
+
tool_call_id VARCHAR(255) NULL,
|
|
186
|
+
bound_input_hash VARCHAR(255) NULL,
|
|
187
|
+
-- created_at_iso:core 的 \`RuleApprovalRecord.createdAt\` 是 ISO **字符串**,逐字回放(不是毫秒列,
|
|
188
|
+
-- 故不带 _ms 后缀);排序/清扫另用 created_at_ms。
|
|
189
|
+
created_at_iso VARCHAR(64) NOT NULL,
|
|
190
|
+
created_at_ms BIGINT NOT NULL,
|
|
191
|
+
updated_at_ms BIGINT NOT NULL,
|
|
192
|
+
PRIMARY KEY (record_id),
|
|
193
|
+
KEY idx_permission_rule_approval_owner (owner_key)
|
|
194
|
+
) COLLATE utf8mb4_bin`,
|
|
195
|
+
`CREATE TABLE IF NOT EXISTS ${PERMISSION_RULE_TICKET_TABLE} (
|
|
196
|
+
ticket_id VARCHAR(190) NOT NULL,
|
|
197
|
+
-- owner_key:principal 绑定(F1 ①)。redeem 的 authenticated principal 必须算出同一把键,
|
|
198
|
+
-- 否则与 unknown ticket **同形 404**(禁存在性 oracle)。
|
|
199
|
+
owner_key VARCHAR(190) NOT NULL,
|
|
200
|
+
principal VARCHAR(512) NOT NULL,
|
|
201
|
+
approval_id VARCHAR(190) NOT NULL,
|
|
202
|
+
-- payload_hash:载荷绑定(F1 ④)。= prepare 时刻候选集的规范摘要;redeem 时与记录上的候选集
|
|
203
|
+
-- 重算比对,不符 ⇒ 拒(篡改/重放不可扩权)。
|
|
204
|
+
payload_hash VARCHAR(190) NOT NULL,
|
|
205
|
+
payload_json LONGTEXT NOT NULL,
|
|
206
|
+
expires_at_ms BIGINT NOT NULL,
|
|
207
|
+
-- consumed_at_ms:一次性消费位(F1 ③)。消费 = 单条条件 UPDATE(WHERE consumed_at_ms IS NULL),
|
|
208
|
+
-- **禁 read-then-write** —— 并发双 redeem 必须恰一成功,那只能由引擎的行锁回答。
|
|
209
|
+
consumed_at_ms BIGINT NULL,
|
|
210
|
+
created_at_ms BIGINT NOT NULL,
|
|
211
|
+
PRIMARY KEY (ticket_id),
|
|
212
|
+
KEY idx_permission_rule_ticket_owner (owner_key)
|
|
213
|
+
) COLLATE utf8mb4_bin`,
|
|
214
|
+
];
|
|
215
|
+
/** {@link TIDB_PERMISSION_RULE_STATEMENTS} 的遍历壳(生产路径走 `tidb-pool.ts` 中央 `ensureSchema`;
|
|
216
|
+
* 本函数留给只需要这三张表的集成测试)。 */
|
|
217
|
+
export async function ensureTiDBPermissionRuleSchema(pool) {
|
|
218
|
+
for (const stmt of TIDB_PERMISSION_RULE_STATEMENTS)
|
|
219
|
+
await pool.query(stmt);
|
|
220
|
+
}
|
|
221
|
+
export async function ensurePgPermissionRuleSchema(q) {
|
|
222
|
+
await q(`CREATE TABLE IF NOT EXISTS ${PERMISSION_RULE_TABLE} (
|
|
223
|
+
owner_key VARCHAR(190) COLLATE "C" NOT NULL,
|
|
224
|
+
owner_kind VARCHAR(32) COLLATE "C" NOT NULL,
|
|
225
|
+
principal VARCHAR(512) COLLATE "C",
|
|
226
|
+
actor VARCHAR(190) COLLATE "C" NOT NULL,
|
|
227
|
+
counter BIGINT NOT NULL DEFAULT 0,
|
|
228
|
+
rev BIGINT NOT NULL DEFAULT 0,
|
|
229
|
+
rules_json TEXT COLLATE "C" NOT NULL,
|
|
230
|
+
tombstones_json TEXT COLLATE "C" NOT NULL,
|
|
231
|
+
quarantined_json TEXT COLLATE "C" NOT NULL,
|
|
232
|
+
observed_vector_json TEXT COLLATE "C",
|
|
233
|
+
last_round_at_ms BIGINT,
|
|
234
|
+
checksum VARCHAR(190) COLLATE "C" NOT NULL,
|
|
235
|
+
created_at_ms BIGINT NOT NULL,
|
|
236
|
+
updated_at_ms BIGINT NOT NULL,
|
|
237
|
+
PRIMARY KEY (owner_key)
|
|
238
|
+
)`);
|
|
239
|
+
await q(`CREATE TABLE IF NOT EXISTS ${PERMISSION_RULE_APPROVAL_TABLE} (
|
|
240
|
+
record_id VARCHAR(190) COLLATE "C" NOT NULL,
|
|
241
|
+
owner_key VARCHAR(190) COLLATE "C" NOT NULL,
|
|
242
|
+
owner_kind VARCHAR(32) COLLATE "C" NOT NULL,
|
|
243
|
+
principal VARCHAR(512) COLLATE "C",
|
|
244
|
+
kind VARCHAR(32) COLLATE "C" NOT NULL,
|
|
245
|
+
state VARCHAR(32) COLLATE "C" NOT NULL,
|
|
246
|
+
rev BIGINT NOT NULL DEFAULT 0,
|
|
247
|
+
candidates_json TEXT COLLATE "C" NOT NULL,
|
|
248
|
+
selected_candidate INT,
|
|
249
|
+
redeemed_dots_json TEXT COLLATE "C",
|
|
250
|
+
tool_call_id VARCHAR(255) COLLATE "C",
|
|
251
|
+
bound_input_hash VARCHAR(255) COLLATE "C",
|
|
252
|
+
created_at_iso VARCHAR(64) COLLATE "C" NOT NULL,
|
|
253
|
+
created_at_ms BIGINT NOT NULL,
|
|
254
|
+
updated_at_ms BIGINT NOT NULL,
|
|
255
|
+
PRIMARY KEY (record_id)
|
|
256
|
+
)`);
|
|
257
|
+
await q(`CREATE INDEX IF NOT EXISTS idx_permission_rule_approval_owner ON ${PERMISSION_RULE_APPROVAL_TABLE} (owner_key)`);
|
|
258
|
+
await q(`CREATE TABLE IF NOT EXISTS ${PERMISSION_RULE_TICKET_TABLE} (
|
|
259
|
+
ticket_id VARCHAR(190) COLLATE "C" NOT NULL,
|
|
260
|
+
owner_key VARCHAR(190) COLLATE "C" NOT NULL,
|
|
261
|
+
principal VARCHAR(512) COLLATE "C" NOT NULL,
|
|
262
|
+
approval_id VARCHAR(190) COLLATE "C" NOT NULL,
|
|
263
|
+
payload_hash VARCHAR(190) COLLATE "C" NOT NULL,
|
|
264
|
+
payload_json TEXT COLLATE "C" NOT NULL,
|
|
265
|
+
expires_at_ms BIGINT NOT NULL,
|
|
266
|
+
consumed_at_ms BIGINT,
|
|
267
|
+
created_at_ms BIGINT NOT NULL,
|
|
268
|
+
PRIMARY KEY (ticket_id)
|
|
269
|
+
)`);
|
|
270
|
+
await q(`CREATE INDEX IF NOT EXISTS idx_permission_rule_ticket_owner ON ${PERMISSION_RULE_TICKET_TABLE} (owner_key)`);
|
|
271
|
+
}
|
|
272
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
273
|
+
// 纯函数(桶键 / 摘要 / delta 折叠)
|
|
274
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
275
|
+
/**
|
|
276
|
+
* 一只桶的**存储键**(纯数据 ⇒ `build*`)。
|
|
277
|
+
*
|
|
278
|
+
* 🔴 为什么 hash 而不是把 principal 直接当主键:①principal 是自由文本身份串,做主键会让**长度**与
|
|
279
|
+
* **排序规则**变成安全面(一个 191 字符的 principal 在 190 宽的列上被静默截断 ⇒ 两个身份共用一只桶,
|
|
280
|
+
* 那是一次跨租户放行);②`local-owner` 那一员**没有** principal,需要一个同域的定长表示。
|
|
281
|
+
* 种别前缀让两族键不可能相撞(`principal:` vs `local-owner`)。
|
|
282
|
+
*/
|
|
283
|
+
export function buildRuleOwnerKey(owner) {
|
|
284
|
+
const material = owner.kind === "principal" ? `principal:${owner.principal}` : "local-owner";
|
|
285
|
+
return createHash("sha256").update(material, "utf8").digest("hex");
|
|
286
|
+
}
|
|
287
|
+
/** 内容三列 + 观测向量的完整性指纹(core `ruleStoreChecksum` 同精神;**不含** rev/counter —— 那两样
|
|
288
|
+
* 是元数据轴,`nextDot` 只动 counter 就不该迫使重算内容指纹)。
|
|
289
|
+
*
|
|
290
|
+
* 导出(纯数据 ⇒ `build*`)是为了让**取证测试**能造一条「指纹自洽但语义矛盾」的行 —— 那正是
|
|
291
|
+
* codex round1 [high] 二的形:一条只靠指纹是拦不住的坏行,必须由语义筛拦。 */
|
|
292
|
+
export function buildRuleBucketChecksum(body) {
|
|
293
|
+
return `sha256:${createHash("sha256").update(canonicalJson(body), "utf8").digest("hex")}`;
|
|
294
|
+
}
|
|
295
|
+
/** 稳定序 JSON(键排序递归)——摘要必须与键序无关,否则同一份内容会算出两个指纹。 */
|
|
296
|
+
function canonicalJson(v) {
|
|
297
|
+
if (v === null || typeof v !== "object")
|
|
298
|
+
return JSON.stringify(v) ?? "null";
|
|
299
|
+
if (Array.isArray(v))
|
|
300
|
+
return `[${v.map(canonicalJson).join(",")}]`;
|
|
301
|
+
const entries = Object.entries(v)
|
|
302
|
+
.filter(([, val]) => val !== undefined)
|
|
303
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
304
|
+
return `{${entries.map(([k, val]) => `${JSON.stringify(k)}:${canonicalJson(val)}`).join(",")}}`;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* core `foldDelta` 的**语义镜像**:把一次 redemption-add 折进原始规则集。
|
|
308
|
+
*
|
|
309
|
+
* 逐字对着 core 的 JSDoc 与 in-memory 参照物写:同 (rule, scope) 归一组,组内 adds 按 **dot 身份**并集
|
|
310
|
+
* (同 dot 重放 ⇒ 恒等,这正是 core「一次兑付恒铸一个 dot,重放多少次都只有一个 add」的落点)。
|
|
311
|
+
*/
|
|
312
|
+
function foldAdd(rules, delta) {
|
|
313
|
+
const next = rules.map((r) => ({ ...r, adds: [...r.adds] }));
|
|
314
|
+
const hit = next.find((r) => r.rule === delta.rule && sameScope(r.scope, delta.scope));
|
|
315
|
+
if (hit === undefined) {
|
|
316
|
+
next.push({ rule: delta.rule, tool: delta.tool, match: delta.match, command: delta.command, scope: delta.scope, adds: [delta.add] });
|
|
317
|
+
return next;
|
|
318
|
+
}
|
|
319
|
+
if (!hit.adds.some((a) => a.dot.actor === delta.add.dot.actor && a.dot.counter === delta.add.dot.counter))
|
|
320
|
+
hit.adds.push(delta.add);
|
|
321
|
+
return next;
|
|
322
|
+
}
|
|
323
|
+
/** core `assertRedemptionNotQuarantined` 的语义镜像:**任何** (rule, scope) 下与本 delta 同 dot 的
|
|
324
|
+
* 隔离行都让这次写响亮失败——dot 只铸一次,被隔离钉住的 dot 再出现就是「复活」形。 */
|
|
325
|
+
function assertNotQuarantined(quarantined, delta) {
|
|
326
|
+
const hit = quarantined.find((q) => q.add.dot.actor === delta.add.dot.actor && q.add.dot.counter === delta.add.dot.counter);
|
|
327
|
+
if (hit !== undefined) {
|
|
328
|
+
throw new Error(`refusing to fold a redemption whose dot (${hit.add.dot.actor}#${hit.add.dot.counter}) is quarantined (${hit.reason}) — ` +
|
|
329
|
+
`re-entry requires a NEW consent (a new ask, a new record, a new dot)`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/** core `assertDeleteDeltaCarriesNoAdd` 的语义镜像(联合已让它不可拼写,但 JS 调用方不受联合约束 ⇒ 执行它)。 */
|
|
333
|
+
function assertDeleteCarriesNoAdd(delta) {
|
|
334
|
+
// `Reflect.get` 而不是 `as unknown as {…}`:这一行的**全部目的**就是看一个联合类型里不存在的键在不在
|
|
335
|
+
// (JS 调用方不受联合约束),用断言去表达「我知道它可能有个类型上没有的键」既绕过类型面又触类型卫生门。
|
|
336
|
+
const smuggled = Reflect.get(delta, "rules");
|
|
337
|
+
if (smuggled !== undefined)
|
|
338
|
+
throw new Error("a tighten-delete delta must carry a tombstone and nothing else");
|
|
339
|
+
if (delta.tombstone === undefined || delta.tombstone.removedDots === undefined)
|
|
340
|
+
throw new Error("a tighten-delete delta must carry a tombstone");
|
|
341
|
+
}
|
|
342
|
+
/** {@link SqlPermissionRuleStore} 的 dot 铸造 CAS 重试上限。有界:一个无界循环只会把「争不过」变成
|
|
343
|
+
* 挂死;用尽 ⇒ 响亮抛错,由 core 的兑付腿(它自己也有有界重试)如实回 refused。 */
|
|
344
|
+
const NEXT_DOT_MAX_ATTEMPTS = 8;
|
|
345
|
+
/** 空桶的读出形(core `EMPTY_READ` 同值:桶不存在 = 零规则,不是错误)。 */
|
|
346
|
+
const EMPTY_BUCKET = { actor: "", counter: 0, rev: 0, rules: [], tombstones: [], quarantined: [] };
|
|
347
|
+
function parseColumn(schema, raw, column, ownerKey) {
|
|
348
|
+
const text = typeof raw === "string" ? raw : raw === null || raw === undefined ? undefined : String(raw);
|
|
349
|
+
if (text === undefined)
|
|
350
|
+
throw new Error(`permission_rule.${column} is absent for bucket ${ownerKey}; refusing to guess its content`);
|
|
351
|
+
let json;
|
|
352
|
+
try {
|
|
353
|
+
json = JSON.parse(text);
|
|
354
|
+
}
|
|
355
|
+
catch (err) {
|
|
356
|
+
throw new Error(`permission_rule.${column} for bucket ${ownerKey} is not valid JSON (${err instanceof Error ? err.message : String(err)})`);
|
|
357
|
+
}
|
|
358
|
+
const parsed = schema.safeParse(json);
|
|
359
|
+
if (!parsed.success)
|
|
360
|
+
throw new Error(`permission_rule.${column} for bucket ${ownerKey} does not carry a readable shape: ${parsed.error.message}`);
|
|
361
|
+
return parsed.data;
|
|
362
|
+
}
|
|
363
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
364
|
+
// 店本体
|
|
365
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
366
|
+
class SqlPermissionRuleStore {
|
|
367
|
+
db;
|
|
368
|
+
owner;
|
|
369
|
+
now;
|
|
370
|
+
durability = "durable";
|
|
371
|
+
fidelity = "json";
|
|
372
|
+
ownerKey;
|
|
373
|
+
constructor(db, owner, now = Date.now) {
|
|
374
|
+
this.db = db;
|
|
375
|
+
this.owner = owner;
|
|
376
|
+
this.now = now;
|
|
377
|
+
this.ownerKey = buildRuleOwnerKey(owner);
|
|
378
|
+
}
|
|
379
|
+
enc(v, label) {
|
|
380
|
+
return dialectProtocolJsonEncoder(this.db.dialect)(v, label);
|
|
381
|
+
}
|
|
382
|
+
q(tidbSql, pgSql) {
|
|
383
|
+
return this.db.dialect === "tidb" ? tidbSql : pgSql;
|
|
384
|
+
}
|
|
385
|
+
/** 桶行的读出。不存在 ⇒ {@link EMPTY_BUCKET}(零规则,不是错误);存在但形不合/指纹不符 ⇒ **throw**。 */
|
|
386
|
+
async read() {
|
|
387
|
+
const { rows } = await this.db.query(this.q(`SELECT actor, counter, rev, rules_json, tombstones_json, quarantined_json, observed_vector_json, checksum FROM ${PERMISSION_RULE_TABLE} WHERE owner_key = ?`, `SELECT actor, counter, rev, rules_json, tombstones_json, quarantined_json, observed_vector_json, checksum FROM ${PERMISSION_RULE_TABLE} WHERE owner_key = $1`), [this.ownerKey]);
|
|
388
|
+
const row = rows[0];
|
|
389
|
+
if (row === undefined)
|
|
390
|
+
return { ...EMPTY_BUCKET, rules: [], tombstones: [], quarantined: [] };
|
|
391
|
+
const rules = parseColumn(z.array(PersistedAllowRuleSchema), row.rules_json, "rules_json", this.ownerKey);
|
|
392
|
+
const tombstones = parseColumn(z.array(RuleTombstoneSchema), row.tombstones_json, "tombstones_json", this.ownerKey);
|
|
393
|
+
const quarantined = parseColumn(z.array(QuarantinedRuleAddSchema), row.quarantined_json, "quarantined_json", this.ownerKey);
|
|
394
|
+
const observedVector = row.observed_vector_json === null || row.observed_vector_json === undefined ? undefined : parseColumn(FrontierSchema, row.observed_vector_json, "observed_vector_json", this.ownerKey);
|
|
395
|
+
const state = {
|
|
396
|
+
actor: String(row.actor ?? ""),
|
|
397
|
+
counter: Number(row.counter ?? 0),
|
|
398
|
+
rev: Number(row.rev ?? 0),
|
|
399
|
+
rules,
|
|
400
|
+
tombstones,
|
|
401
|
+
quarantined,
|
|
402
|
+
...(observedVector !== undefined ? { observedVector } : {}),
|
|
403
|
+
};
|
|
404
|
+
const expected = buildRuleBucketChecksum({ rules, tombstones, quarantined, ...(observedVector !== undefined ? { observedVector } : {}) });
|
|
405
|
+
if (String(row.checksum ?? "") !== expected) {
|
|
406
|
+
throw new Error(`permission_rule bucket ${this.ownerKey} failed its integrity check (stored ${String(row.checksum)}, computed ${expected}) — ` +
|
|
407
|
+
`refusing the whole bucket rather than reporting a partial rule set`);
|
|
408
|
+
}
|
|
409
|
+
// 🔴 codex 交叉复审 round1 [high] 二(验真后修):**形对 ≠ 语义安全**。
|
|
410
|
+
// 上面的 zod 只保证「这是一条 PersistedAllowRule 的形状」,指纹只保证「这些字节没被改过」——
|
|
411
|
+
// 两者都拦不住一条**自洽但语义矛盾**的行:`rule: "Bash(ls)"` 配 `match:"prefix", command:"rm"`。
|
|
412
|
+
// 而 core 的匹配器(`ruleAdmitsCommand`/`findAdmittingRule`)信的是 `match`/`command` 两个**去规范化**
|
|
413
|
+
// 字段,不重新解析 `rule` ⇒ 这样一行会放行一条它显示的规则根本没描述的命令。成因不必是攻击:
|
|
414
|
+
// 版本回退、备份还原、另一个写者,都会造出它。
|
|
415
|
+
// 判据用 core **导出的** `screenRuleSyncState`(它内部就是 design/182 §3.1 的 `normalizePersistedRule`:
|
|
416
|
+
// 从 `rule` 文本经**同一个**验证器重投影,与存的字段逐字比对)—— 不是我方另写一份等式(那必然会漂)。
|
|
417
|
+
// 处置 = **整桶拒绝**(throw),与上面的指纹臂同姿势:core 明令「refused (fail-closed), never repaired
|
|
418
|
+
// toward either side」,而修一半、留一半会让「这只桶到底有哪些规则」变成两个答案。
|
|
419
|
+
// 🔴 codex round2 [high](验真后修):`screenRuleSyncState` 只筛 **allow 行**,不看墓碑 ——
|
|
420
|
+
// 而墓碑的 `rule` 文本正是它与被删行的**唯一**配对键(`applyTombstones` 按 (rule, scope) 归组)。
|
|
421
|
+
// 一条文本非规范/验证器拒收的墓碑配不上任何一行 ⇒ 它删不掉任何东西,而调用方以为删掉了:
|
|
422
|
+
// 一条**本该已删**的规则继续放行。判据同样用 core 导出的**那一个**验证器(不是另写一份等式)。
|
|
423
|
+
const badTombstone = tombstones.find((t) => {
|
|
424
|
+
const parsed = parseAllowRuleText(t.rule);
|
|
425
|
+
return "reject" in parsed || parsed.rule.rule !== t.rule;
|
|
426
|
+
});
|
|
427
|
+
if (badTombstone !== undefined) {
|
|
428
|
+
throw new Error(`permission_rule bucket ${this.ownerKey} carries a tombstone whose rule text the single validator refuses or does not spell canonically (${badTombstone.rule}) — ` +
|
|
429
|
+
`refusing the whole bucket rather than keeping a rule alive under a tombstone that can never match it`);
|
|
430
|
+
}
|
|
431
|
+
const screened = screenRuleSyncState({ rules, tombstones });
|
|
432
|
+
if (screened.rejected.length > 0) {
|
|
433
|
+
const names = screened.rejected.map((r) => `${r.rule}(${r.reason})`).join(", ");
|
|
434
|
+
throw new Error(`permission_rule bucket ${this.ownerKey} carries rows the single validator refuses [${names}] — ` +
|
|
435
|
+
`refusing the whole bucket rather than authorizing a rule whose text and matcher metadata disagree`);
|
|
436
|
+
}
|
|
437
|
+
return state;
|
|
438
|
+
}
|
|
439
|
+
/** 桶行的**创建**(幂等)。actor 是本 backend 自铸的**副本身份**(design/182:replica identity,
|
|
440
|
+
* NEVER a person identity)——一只桶一个,首次落行时定,之后永不变。 */
|
|
441
|
+
async ensureBucket() {
|
|
442
|
+
const nowMs = this.now();
|
|
443
|
+
const empty = { rules: [], tombstones: [], quarantined: [] };
|
|
444
|
+
await this.db.query(this.q(`INSERT IGNORE INTO ${PERMISSION_RULE_TABLE} (owner_key, owner_kind, principal, actor, counter, rev, rules_json, tombstones_json, quarantined_json, observed_vector_json, last_round_at_ms, checksum, created_at_ms, updated_at_ms)
|
|
445
|
+
VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, NULL, NULL, ?, ?, ?)`, `INSERT INTO ${PERMISSION_RULE_TABLE} (owner_key, owner_kind, principal, actor, counter, rev, rules_json, tombstones_json, quarantined_json, observed_vector_json, last_round_at_ms, checksum, created_at_ms, updated_at_ms)
|
|
446
|
+
VALUES ($1, $2, $3, $4, 0, 0, $5, $6, $7, NULL, NULL, $8, $9, $10) ON CONFLICT (owner_key) DO NOTHING`), [
|
|
447
|
+
this.ownerKey,
|
|
448
|
+
this.owner.kind,
|
|
449
|
+
this.owner.kind === "principal" ? this.owner.principal : null,
|
|
450
|
+
`sql-${randomBytes(6).toString("hex")}`,
|
|
451
|
+
this.enc([], "permission_rule.rules_json"),
|
|
452
|
+
this.enc([], "permission_rule.tombstones_json"),
|
|
453
|
+
this.enc([], "permission_rule.quarantined_json"),
|
|
454
|
+
buildRuleBucketChecksum(empty),
|
|
455
|
+
nowMs,
|
|
456
|
+
nowMs,
|
|
457
|
+
]);
|
|
458
|
+
}
|
|
459
|
+
async list() {
|
|
460
|
+
const s = await this.read();
|
|
461
|
+
return { rules: applyTombstones(s.rules, s.tombstones), tombstones: s.tombstones, rev: s.rev, checksum: buildRuleBucketChecksum({ rules: s.rules, tombstones: s.tombstones, quarantined: s.quarantined, ...(s.observedVector !== undefined ? { observedVector: s.observedVector } : {}) }) };
|
|
462
|
+
}
|
|
463
|
+
async quarantined() {
|
|
464
|
+
return (await this.read()).quarantined;
|
|
465
|
+
}
|
|
466
|
+
get [PERMISSION_RULE_WRITER_KEY]() {
|
|
467
|
+
return this.writer;
|
|
468
|
+
}
|
|
469
|
+
writer = {
|
|
470
|
+
/**
|
|
471
|
+
* 铸下一个 dot。**方言同形的有界 CAS 循环**:读到 `counter=c` ⇒ 打
|
|
472
|
+
* `UPDATE … SET counter = c+1 WHERE owner_key = ? AND counter = c`,`affected === 1` 才认领 `c+1`。
|
|
473
|
+
*
|
|
474
|
+
* 🔴 为什么不是「事务 + `SELECT … FOR UPDATE`」(第一版就是那样,真双库当场红):
|
|
475
|
+
* · `BEGIN PESSIMISTIC` 是 **TiDB 方言**,真 MySQL 上是语法错误(本机 MySQL 9.x 直接拒);
|
|
476
|
+
* · 换成裸 `BEGIN` 也只是换一种锁形,却把一条**悲观锁腿**引进本仓在案的死锁族(MySQL vs TiDB
|
|
477
|
+
* 的锁语义分歧),而这一格根本不需要锁:分配一个单调计数器正是 CAS 的教科书形。
|
|
478
|
+
* 🔴 也不是 `SET counter = counter + 1` 那条「原子自增」:它在引擎里确实原子,但 mysql2 **没有**
|
|
479
|
+
* `RETURNING`,自增之后要另起一次 SELECT 才知道自己拿到了几 —— 那一跳之间又是一次竞态。
|
|
480
|
+
* CAS 把「加一」与「知道加到了几」变成同一件事,两个方言逐字同形。
|
|
481
|
+
*
|
|
482
|
+
* 一次 read-then-write(读 counter、加一、无条件写)会在两个并发兑付上发出**同一个 dot** ——
|
|
483
|
+
* 那等于把两次人的同意折成一次 add,是这段代码存在的全部理由。
|
|
484
|
+
*/
|
|
485
|
+
nextDot: async () => {
|
|
486
|
+
await this.ensureBucket();
|
|
487
|
+
for (let attempt = 0; attempt < NEXT_DOT_MAX_ATTEMPTS; attempt++) {
|
|
488
|
+
const { rows } = await this.db.query(this.q(`SELECT actor, counter FROM ${PERMISSION_RULE_TABLE} WHERE owner_key = ?`, `SELECT actor, counter FROM ${PERMISSION_RULE_TABLE} WHERE owner_key = $1`), [this.ownerKey]);
|
|
489
|
+
const row = rows[0];
|
|
490
|
+
if (row === undefined)
|
|
491
|
+
throw new Error(`permission_rule bucket ${this.ownerKey} vanished while minting a dot`);
|
|
492
|
+
const cur = Number(row.counter);
|
|
493
|
+
const next = cur + 1;
|
|
494
|
+
const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_TABLE} SET counter = ?, updated_at_ms = ? WHERE owner_key = ? AND counter = ?`, `UPDATE ${PERMISSION_RULE_TABLE} SET counter = $1, updated_at_ms = $2 WHERE owner_key = $3 AND counter = $4`), [next, this.now(), this.ownerKey, cur]);
|
|
495
|
+
if (affected === 1)
|
|
496
|
+
return { actor: String(row.actor), counter: next };
|
|
497
|
+
}
|
|
498
|
+
throw new Error(`could not mint a permission-rule dot for bucket ${this.ownerKey} after ${NEXT_DOT_MAX_ATTEMPTS} attempts`);
|
|
499
|
+
},
|
|
500
|
+
readRaw: async () => {
|
|
501
|
+
await this.ensureBucket();
|
|
502
|
+
const s = await this.read();
|
|
503
|
+
return {
|
|
504
|
+
actor: s.actor,
|
|
505
|
+
counter: s.counter,
|
|
506
|
+
rev: s.rev,
|
|
507
|
+
rules: structuredClone(s.rules),
|
|
508
|
+
tombstones: structuredClone(s.tombstones),
|
|
509
|
+
...(s.observedVector !== undefined ? { observedVector: structuredClone(s.observedVector) } : {}),
|
|
510
|
+
quarantined: structuredClone(s.quarantined),
|
|
511
|
+
};
|
|
512
|
+
},
|
|
513
|
+
apply: async (delta, opts) => {
|
|
514
|
+
// 🔴 fail-closed(顶注 ②):同步落地管线是 core 私有的一整条流水线,复制它才是真的会漂。
|
|
515
|
+
// 响亮拒绝而不是静默 no-op —— 静默 no-op 会让一个「join 已落地」的调用方带着错误的信念继续。
|
|
516
|
+
if (delta.kind === "sync-join") {
|
|
517
|
+
throw new Error("the SQL permission-rule backend does not implement the sync-join landing pipeline (core's applySyncJoin is not on the package's public export face); " +
|
|
518
|
+
"no sync client is wired in this deployment, so this arm has no caller — see permission-rule-store-sql.ts header");
|
|
519
|
+
}
|
|
520
|
+
await this.ensureBucket();
|
|
521
|
+
const cur = await this.read();
|
|
522
|
+
if (cur.rev !== opts.expectedRev)
|
|
523
|
+
return { conflict: true, rev: cur.rev };
|
|
524
|
+
let nextRules = cur.rules;
|
|
525
|
+
let nextTombstones = cur.tombstones;
|
|
526
|
+
if (delta.kind === "redemption-add") {
|
|
527
|
+
assertNotQuarantined(cur.quarantined, delta);
|
|
528
|
+
nextRules = foldAdd(cur.rules, delta);
|
|
529
|
+
}
|
|
530
|
+
else {
|
|
531
|
+
assertDeleteCarriesNoAdd(delta);
|
|
532
|
+
nextTombstones = [...cur.tombstones, delta.tombstone];
|
|
533
|
+
}
|
|
534
|
+
// 🔴 codex 交叉复审 round5 [high] 一(验真后修)——**只写读得回来的东西**。
|
|
535
|
+
// 读面比写面严(空 root 的 project scope、空 removedDots 的墓碑…都会让整桶拒),于是一个宽松的
|
|
536
|
+
// 上游入口可以写进一条**自己再也读不回来**的行:一次「成功」的导入把这位租户的桶永久毒死。
|
|
537
|
+
// 判据用的是**同一组** schema(不是另写一份等式),所以两面永不可能各自漂;违反 ⇒ 响亮拒绝,
|
|
538
|
+
// 绝不落盘(fail-closed:宁可这次写失败,也不留一条让整桶不可读的行)。
|
|
539
|
+
const writable = z.object({ rules: z.array(PersistedAllowRuleSchema), tombstones: z.array(RuleTombstoneSchema) }).safeParse({ rules: nextRules, tombstones: nextTombstones });
|
|
540
|
+
if (!writable.success) {
|
|
541
|
+
throw new Error(`refusing to write a permission_rule bucket this backend could not read back: ${writable.error.message}`);
|
|
542
|
+
}
|
|
543
|
+
const body = { rules: nextRules, tombstones: nextTombstones, quarantined: cur.quarantined, ...(cur.observedVector !== undefined ? { observedVector: cur.observedVector } : {}) };
|
|
544
|
+
const nowMs = this.now();
|
|
545
|
+
// OCC = **单条条件 UPDATE**(`WHERE owner_key=? AND rev=?`)。affected===0 ⇒ 有人先落了,如实回
|
|
546
|
+
// conflict + 当前 rev(调用方重读重试;core 的 applyRedemption 自带有界重试循环)。
|
|
547
|
+
const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_TABLE} SET rules_json = ?, tombstones_json = ?, quarantined_json = ?, checksum = ?, rev = rev + 1, updated_at_ms = ? WHERE owner_key = ? AND rev = ?`, `UPDATE ${PERMISSION_RULE_TABLE} SET rules_json = $1, tombstones_json = $2, quarantined_json = $3, checksum = $4, rev = rev + 1, updated_at_ms = $5 WHERE owner_key = $6 AND rev = $7`), [
|
|
548
|
+
this.enc(nextRules, "permission_rule.rules_json"),
|
|
549
|
+
this.enc(nextTombstones, "permission_rule.tombstones_json"),
|
|
550
|
+
this.enc(cur.quarantined, "permission_rule.quarantined_json"),
|
|
551
|
+
buildRuleBucketChecksum(body),
|
|
552
|
+
nowMs,
|
|
553
|
+
this.ownerKey,
|
|
554
|
+
opts.expectedRev,
|
|
555
|
+
]);
|
|
556
|
+
if (affected === 0) {
|
|
557
|
+
const again = await this.read();
|
|
558
|
+
return { conflict: true, rev: again.rev };
|
|
559
|
+
}
|
|
560
|
+
return { rev: opts.expectedRev + 1 };
|
|
561
|
+
},
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* SQL 双方言的 {@link PermissionRuleStoreProvider}。
|
|
566
|
+
*
|
|
567
|
+
* `forPrincipal(undefined)` 恒解析成**零规则**店(core 硬条款:未鉴权任务解析到零规则而不是一只共享桶
|
|
568
|
+
* —— 放宽面上的 fail-closed 是「更少的允许」,绝不是「一只共享的」)。
|
|
569
|
+
*/
|
|
570
|
+
export class SqlPermissionRuleStoreProvider {
|
|
571
|
+
db;
|
|
572
|
+
now;
|
|
573
|
+
constructor(db, now = Date.now) {
|
|
574
|
+
this.db = db;
|
|
575
|
+
this.now = now;
|
|
576
|
+
}
|
|
577
|
+
forPrincipal(principal) {
|
|
578
|
+
if (typeof principal !== "string" || principal === "")
|
|
579
|
+
return ZERO_RULE_STORE;
|
|
580
|
+
return new SqlPermissionRuleStore(this.db, { kind: "principal", principal }, this.now);
|
|
581
|
+
}
|
|
582
|
+
forLocalOwner() {
|
|
583
|
+
return new SqlPermissionRuleStore(this.db, { kind: "local-owner" }, this.now);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
/** 未鉴权解析到的零规则店。**刻意不复用** core 的 `EMPTY_RULE_STORE`:那一份声明的 durability 是
|
|
587
|
+
* 实现细节,而这一格的语义是「这条腿没有身份 ⇒ 没有规则」,自持一份让它不随上游默认值漂。 */
|
|
588
|
+
const ZERO_RULE_STORE = {
|
|
589
|
+
list: async () => ({ rules: [], tombstones: [], rev: 0 }),
|
|
590
|
+
durability: "process-local",
|
|
591
|
+
};
|
|
592
|
+
export function createTiDBPermissionRuleStoreProvider(pool, now) {
|
|
593
|
+
return new SqlPermissionRuleStoreProvider(mysqlDriver(pool), now);
|
|
594
|
+
}
|
|
595
|
+
export function createPgPermissionRuleStoreProvider(pool, now) {
|
|
596
|
+
return new SqlPermissionRuleStoreProvider(pgDriver(pool), now);
|
|
597
|
+
}
|
|
598
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
599
|
+
// 审批记录店(core `RuleApprovalRecordStore` 的 SQL 形)
|
|
600
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
601
|
+
/**
|
|
602
|
+
* durable 审批记录。CAS **按 rev**,不按 state —— core 的原话:批记录的第二个候选会 redeemed→redeemed,
|
|
603
|
+
* 只比 state 的两次并发重试会**都**认为自己看到了预期状态,各铸一个 dot、各写一次,后者静默覆盖前者的
|
|
604
|
+
* `redeemedDots`。那正是「一次同意变成两个 add、一条删掉的规则复活」的机制。
|
|
605
|
+
*/
|
|
606
|
+
export class SqlRuleApprovalRecordStore {
|
|
607
|
+
db;
|
|
608
|
+
now;
|
|
609
|
+
constructor(db, now = Date.now) {
|
|
610
|
+
this.db = db;
|
|
611
|
+
this.now = now;
|
|
612
|
+
}
|
|
613
|
+
q(tidbSql, pgSql) {
|
|
614
|
+
return this.db.dialect === "tidb" ? tidbSql : pgSql;
|
|
615
|
+
}
|
|
616
|
+
enc(v, label) {
|
|
617
|
+
return dialectProtocolJsonEncoder(this.db.dialect)(v, label);
|
|
618
|
+
}
|
|
619
|
+
async get(id) {
|
|
620
|
+
const { rows } = await this.db.query(this.q(`SELECT record_id, owner_kind, principal, kind, state, rev, candidates_json, selected_candidate, redeemed_dots_json, tool_call_id, bound_input_hash, created_at_iso FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = ?`, `SELECT record_id, owner_kind, principal, kind, state, rev, candidates_json, selected_candidate, redeemed_dots_json, tool_call_id, bound_input_hash, created_at_iso FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = $1`), [id]);
|
|
621
|
+
const row = rows[0];
|
|
622
|
+
if (row === undefined)
|
|
623
|
+
return undefined;
|
|
624
|
+
const candidates = parseColumn(z.array(RuleCandidateSchema), row.candidates_json, "candidates_json", id);
|
|
625
|
+
const dotsRaw = row.redeemed_dots_json === null || row.redeemed_dots_json === undefined ? undefined : parseColumn(RedeemedDotsSchema, row.redeemed_dots_json, "redeemed_dots_json", id);
|
|
626
|
+
const kind = z.enum(["card", "import", "starter"]).safeParse(row.kind);
|
|
627
|
+
const state = z.enum(["pending", "approved", "redeemed"]).safeParse(row.state);
|
|
628
|
+
if (!kind.success || !state.success) {
|
|
629
|
+
throw new Error(`${PERMISSION_RULE_APPROVAL_TABLE} row ${id} carries an unreadable kind/state (${String(row.kind)}/${String(row.state)})`);
|
|
630
|
+
}
|
|
631
|
+
const ownerKind = String(row.owner_kind);
|
|
632
|
+
const principal = row.principal === null || row.principal === undefined ? undefined : String(row.principal);
|
|
633
|
+
// 身份两族(design/182 §4.5):v1 记录只带 principal;local-owner 记录只带 owner。**绝不同时带两个**
|
|
634
|
+
// (core:两者不一致的记录是矛盾的、不可兑付的)。
|
|
635
|
+
const redeemedDots = dotsRaw === undefined ? undefined : Object.fromEntries(Object.entries(dotsRaw).map(([k, v]) => [Number(k), v]));
|
|
636
|
+
return {
|
|
637
|
+
id: String(row.record_id),
|
|
638
|
+
...(ownerKind === "principal" ? { principal: principal ?? "" } : { owner: { kind: "local-owner" } }),
|
|
639
|
+
kind: kind.data,
|
|
640
|
+
state: state.data,
|
|
641
|
+
candidates,
|
|
642
|
+
createdAt: String(row.created_at_iso),
|
|
643
|
+
rev: Number(row.rev ?? 0),
|
|
644
|
+
...(row.tool_call_id !== null && row.tool_call_id !== undefined ? { toolCallId: String(row.tool_call_id) } : {}),
|
|
645
|
+
...(row.bound_input_hash !== null && row.bound_input_hash !== undefined ? { boundInputHash: String(row.bound_input_hash) } : {}),
|
|
646
|
+
...(row.selected_candidate !== null && row.selected_candidate !== undefined ? { selectedCandidate: Number(row.selected_candidate) } : {}),
|
|
647
|
+
...(redeemedDots !== undefined ? { redeemedDots } : {}),
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
async create(record) {
|
|
651
|
+
const nowMs = this.now();
|
|
652
|
+
const owner = record.owner ?? { kind: "principal", principal: record.principal ?? "" };
|
|
653
|
+
await this.db.query(this.q(`INSERT INTO ${PERMISSION_RULE_APPROVAL_TABLE} (record_id, owner_key, owner_kind, principal, kind, state, rev, candidates_json, selected_candidate, redeemed_dots_json, tool_call_id, bound_input_hash, created_at_iso, created_at_ms, updated_at_ms)
|
|
654
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, `INSERT INTO ${PERMISSION_RULE_APPROVAL_TABLE} (record_id, owner_key, owner_kind, principal, kind, state, rev, candidates_json, selected_candidate, redeemed_dots_json, tool_call_id, bound_input_hash, created_at_iso, created_at_ms, updated_at_ms)
|
|
655
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`), [
|
|
656
|
+
record.id,
|
|
657
|
+
buildRuleOwnerKey(owner),
|
|
658
|
+
owner.kind,
|
|
659
|
+
owner.kind === "principal" ? owner.principal : null,
|
|
660
|
+
record.kind,
|
|
661
|
+
record.state,
|
|
662
|
+
record.rev,
|
|
663
|
+
this.enc(record.candidates, "permission_rule_approval.candidates_json"),
|
|
664
|
+
record.selectedCandidate ?? null,
|
|
665
|
+
record.redeemedDots === undefined ? null : this.enc(record.redeemedDots, "permission_rule_approval.redeemed_dots_json"),
|
|
666
|
+
record.toolCallId ?? null,
|
|
667
|
+
record.boundInputHash ?? null,
|
|
668
|
+
record.createdAt,
|
|
669
|
+
nowMs,
|
|
670
|
+
nowMs,
|
|
671
|
+
]);
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* 丢弃一条**从未被确认**的记录(codex 交叉复审 round8 [medium],可控半场)。
|
|
675
|
+
*
|
|
676
|
+
* 用处只有一个:`prepareCcImport` 会在**返回预览之前**就把 pending 记录落盘,所以「预览超帽、这次导入
|
|
677
|
+
* 不做了」这条路会留下一条永远没人要的行。`WHERE state = 'pending'` 是硬的 —— 已确认/已兑付的记录是
|
|
678
|
+
* 一次真人同意的**审计事实**,任何路径都不许把它删掉(那条轴上的清理属于保留期策略,不是本方法)。
|
|
679
|
+
* 不是 core `RuleApprovalRecordStore` 的成员(那个接口只有 get/cas/create),故命名上与它区分。
|
|
680
|
+
*/
|
|
681
|
+
async discardPendingRecord(recordId) {
|
|
682
|
+
const { affected } = await this.db.query(this.q(`DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = ? AND state = 'pending'`, `DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = $1 AND state = 'pending'`), [recordId]);
|
|
683
|
+
return affected === 1;
|
|
684
|
+
}
|
|
685
|
+
/** compare-and-set on `rev`(core 硬条款)。`next.rev` 必须是 `expectRev + 1`;不是 ⇒ 响亮拒绝
|
|
686
|
+
* (一个不推进的 CAS 会让两次写互相覆盖而两边都以为自己赢了)。 */
|
|
687
|
+
async cas(id, expectRev, next) {
|
|
688
|
+
if (next.rev !== expectRev + 1) {
|
|
689
|
+
throw new Error(`rule-approval CAS must advance rev by exactly one (expectRev=${expectRev}, next.rev=${next.rev})`);
|
|
690
|
+
}
|
|
691
|
+
const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_APPROVAL_TABLE} SET state = ?, rev = ?, candidates_json = ?, selected_candidate = ?, redeemed_dots_json = ?, updated_at_ms = ? WHERE record_id = ? AND rev = ?`, `UPDATE ${PERMISSION_RULE_APPROVAL_TABLE} SET state = $1, rev = $2, candidates_json = $3, selected_candidate = $4, redeemed_dots_json = $5, updated_at_ms = $6 WHERE record_id = $7 AND rev = $8`), [
|
|
692
|
+
next.state,
|
|
693
|
+
next.rev,
|
|
694
|
+
this.enc(next.candidates, "permission_rule_approval.candidates_json"),
|
|
695
|
+
next.selectedCandidate ?? null,
|
|
696
|
+
next.redeemedDots === undefined ? null : this.enc(next.redeemedDots, "permission_rule_approval.redeemed_dots_json"),
|
|
697
|
+
this.now(),
|
|
698
|
+
id,
|
|
699
|
+
expectRev,
|
|
700
|
+
]);
|
|
701
|
+
return affected === 1;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
/** prepare 时刻候选集的规范摘要(载荷绑定 F1 ④ 的判据)。纯数据 ⇒ `build*`。 */
|
|
705
|
+
export function buildRulePayloadHash(candidates) {
|
|
706
|
+
return createHash("sha256").update(canonicalJson(candidates), "utf8").digest("hex");
|
|
707
|
+
}
|
|
708
|
+
export class SqlRuleImportTicketStore {
|
|
709
|
+
db;
|
|
710
|
+
now;
|
|
711
|
+
constructor(db, now = Date.now) {
|
|
712
|
+
this.db = db;
|
|
713
|
+
this.now = now;
|
|
714
|
+
}
|
|
715
|
+
q(tidbSql, pgSql) {
|
|
716
|
+
return this.db.dialect === "tidb" ? tidbSql : pgSql;
|
|
717
|
+
}
|
|
718
|
+
async mint(input) {
|
|
719
|
+
const nowMs = this.now();
|
|
720
|
+
const expiresAtMs = nowMs + input.ttlMs;
|
|
721
|
+
const payloadHash = buildRulePayloadHash(input.candidates);
|
|
722
|
+
await this.db.query(this.q(`INSERT INTO ${PERMISSION_RULE_TICKET_TABLE} (ticket_id, owner_key, principal, approval_id, payload_hash, payload_json, expires_at_ms, consumed_at_ms, created_at_ms)
|
|
723
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?)`, `INSERT INTO ${PERMISSION_RULE_TICKET_TABLE} (ticket_id, owner_key, principal, approval_id, payload_hash, payload_json, expires_at_ms, consumed_at_ms, created_at_ms)
|
|
724
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, $8)`), [
|
|
725
|
+
input.ticketId,
|
|
726
|
+
buildRuleOwnerKey({ kind: "principal", principal: input.principal }),
|
|
727
|
+
input.principal,
|
|
728
|
+
input.approvalId,
|
|
729
|
+
payloadHash,
|
|
730
|
+
dialectProtocolJsonEncoder(this.db.dialect)(input.candidates, "permission_rule_ticket.payload_json"),
|
|
731
|
+
expiresAtMs,
|
|
732
|
+
nowMs,
|
|
733
|
+
]);
|
|
734
|
+
return { ticketId: input.ticketId, approvalId: input.approvalId, payloadHash, expiresAtMs };
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* **一次性原子认领**(F1 ③)。四个条件全部写在 `WHERE` 里 —— ticket 身份、principal 绑定、未过期、
|
|
738
|
+
* 未认领 —— 于是并发双 redeem 由引擎的行锁裁决,恰一条 `affected === 1`。
|
|
739
|
+
*
|
|
740
|
+
* 🔴 **认领的 UPDATE 是这条方法的最后一次 I/O**(codex 交叉复审 round4 [high],验真后修)。
|
|
741
|
+
* 旧形是「先 UPDATE 认领、再 SELECT 取 approvalId/payloadHash」——那条 SELECT 失败时本方法**抛错**,
|
|
742
|
+
* 而调用方(`redeemImport`)此时还没进它的恢复 `try`,于是**没有任何人去放回认领**:票被永久烧掉,
|
|
743
|
+
* 重试收到不可重试的 404,人的确认凭空消失。改法不是加一层 catch,而是**把那次读挪到认领之前**:
|
|
744
|
+
* `approval_id` / `payload_hash` 自铸票起就是**不可变**列,先读与后读读到的是同一份字节。
|
|
745
|
+
*
|
|
746
|
+
* 🔴 **这仍然不是 read-then-write**(禁令的语义没有松动):**裁决**依旧只由那条条件 `UPDATE` 的
|
|
747
|
+
* `WHERE` 做出 —— 预读的三条否定项只用来**短路 + 归因**(把拒绝分成 unknown/wrong-principal/
|
|
748
|
+
* expired/consumed 四类给服务端日志;wire 面把四类折成同一个 404)。预读说「可以」而 UPDATE 命中零行,
|
|
749
|
+
* 只可能是我们在这两跳之间输给了一个并发认领者 —— 如实报 `consumed`,而不是把这次失败当成成功。
|
|
750
|
+
*/
|
|
751
|
+
async consume(ticketId, principal) {
|
|
752
|
+
const nowMs = this.now();
|
|
753
|
+
const ownerKey = buildRuleOwnerKey({ kind: "principal", principal });
|
|
754
|
+
// 预读:①取不可变的 approvalId/payloadHash(于是认领之后零 I/O);②给四类拒绝一个准确归因。
|
|
755
|
+
const row = await this.readRow(ticketId);
|
|
756
|
+
if (row === undefined)
|
|
757
|
+
return { ok: false, reason: "unknown" };
|
|
758
|
+
if (row.ownerKey !== ownerKey)
|
|
759
|
+
return { ok: false, reason: "wrong-principal" };
|
|
760
|
+
if (row.consumedAtMs !== undefined)
|
|
761
|
+
return { ok: false, reason: "consumed" };
|
|
762
|
+
if (row.expiresAtMs <= nowMs)
|
|
763
|
+
return { ok: false, reason: "expired" };
|
|
764
|
+
const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = ? WHERE ticket_id = ? AND owner_key = ? AND consumed_at_ms IS NULL AND expires_at_ms > ?`, `UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = $1 WHERE ticket_id = $2 AND owner_key = $3 AND consumed_at_ms IS NULL AND expires_at_ms > $4`), [nowMs, ticketId, ownerKey, nowMs]);
|
|
765
|
+
// 认领成功 ⇒ 直接用预读那份不可变值返回,**不再发任何一条语句**(这正是本次修复的全部内容)。
|
|
766
|
+
if (affected === 1)
|
|
767
|
+
return { ok: true, approvalId: row.approvalId, payloadHash: row.payloadHash };
|
|
768
|
+
return { ok: false, reason: "consumed" }; // 预读三项刚刚都成立过 ⇒ 只可能是输给了并发认领者
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* **认领的释放**(codex 交叉复审 round1 [high] 一,验真后修)。
|
|
772
|
+
*
|
|
773
|
+
* `consume` 是一次**认领**(claim),不是「这件事已经做完了」。认领之后还有三步会失败:读记录、
|
|
774
|
+
* 载荷比对、confirm+批量兑付。旧形把认领当终局 ⇒ 一次数据库抖动就让票**永久**烧掉,而调用方拿到的
|
|
775
|
+
* 是与「票不存在」同形的 404:重试无门、最终状态对客户端不可判。
|
|
776
|
+
*
|
|
777
|
+
* ⇒ 认领之后**只有裁定性拒绝**才留着消费位(载荷被篡改、记录不存在 —— 重试不改判);**不确定/瞬时**
|
|
778
|
+
* 的失败(store 抛错、CAS 冲突)把认领**放回去**,让属主重试。放回去不会打开双兑付的门:同一时刻
|
|
779
|
+
* 只有一个持有者(认领本身仍是单条条件 UPDATE),而兑付腿(`redeemRuleBatch`)按 core 的设计是
|
|
780
|
+
* **幂等**的(记录里存着每个候选已铸的 dot,重放只会重放同一个 dot)。
|
|
781
|
+
*
|
|
782
|
+
* 残留(如实登记,不假称已解):认领与释放之间**硬崩**会把票留在已认领态 —— 它随 TTL 自然消失,
|
|
783
|
+
* 期间属主可以重新走 prepare 拿一张新票(导入是幂等的:同一条规则再兑付只是同一个 dot 的重放)。
|
|
784
|
+
* 要彻底消灭它需要一条带租约到期的认领状态机,属协议改动,列为后续件。
|
|
785
|
+
*/
|
|
786
|
+
async release(ticketId, principal, mustRemainValidMs = 0) {
|
|
787
|
+
// 🔴 codex 交叉复审 round6 [medium](验真后修):`expires_at_ms` 也进 WHERE,而且比的是
|
|
788
|
+
// **now + 通告的重试窗**,不是 now。理由:调用方拿本方法的返回值去决定要不要对客户端说
|
|
789
|
+
// 「稍后拿同一张票重试」——若这张票在那几秒里就会过期,那句话是**必然兑现不了**的承诺
|
|
790
|
+
// (客户端等完 Retry-After 回来只会收到 404,而且这一轮的确认已经丢了)。撑不过窗 ⇒ 如实回 false,
|
|
791
|
+
// 调用方于是按不可重试报(四类同形 404),不给一条注定走死的路。
|
|
792
|
+
const nowMs = this.now();
|
|
793
|
+
const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = NULL WHERE ticket_id = ? AND owner_key = ? AND consumed_at_ms IS NOT NULL AND expires_at_ms > ?`, `UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = NULL WHERE ticket_id = $1 AND owner_key = $2 AND consumed_at_ms IS NOT NULL AND expires_at_ms > $3`), [ticketId, buildRuleOwnerKey({ kind: "principal", principal }), nowMs + mustRemainValidMs]);
|
|
794
|
+
return affected === 1;
|
|
795
|
+
}
|
|
796
|
+
async readRow(ticketId) {
|
|
797
|
+
const { rows } = await this.db.query(this.q(`SELECT owner_key, approval_id, payload_hash, expires_at_ms, consumed_at_ms FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE ticket_id = ?`, `SELECT owner_key, approval_id, payload_hash, expires_at_ms, consumed_at_ms FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE ticket_id = $1`), [ticketId]);
|
|
798
|
+
const row = rows[0];
|
|
799
|
+
if (row === undefined)
|
|
800
|
+
return undefined;
|
|
801
|
+
return {
|
|
802
|
+
ownerKey: String(row.owner_key),
|
|
803
|
+
approvalId: String(row.approval_id),
|
|
804
|
+
payloadHash: String(row.payload_hash),
|
|
805
|
+
expiresAtMs: Number(row.expires_at_ms),
|
|
806
|
+
...(row.consumed_at_ms === null || row.consumed_at_ms === undefined ? {} : { consumedAtMs: Number(row.consumed_at_ms) }),
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
export function createSqlPermissionRuleStores(db, now) {
|
|
811
|
+
return {
|
|
812
|
+
provider: new SqlPermissionRuleStoreProvider(db, now),
|
|
813
|
+
approvals: new SqlRuleApprovalRecordStore(db, now),
|
|
814
|
+
tickets: new SqlRuleImportTicketStore(db, now),
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
//# sourceMappingURL=permission-rule-store-sql.js.map
|