@gmickel/gno 1.23.0 → 1.25.1
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/README.md +26 -12
- package/assets/skill/SKILL.md +37 -19
- package/assets/skill/recipes/capture-and-file.md +20 -5
- package/browser-extension/artifacts/gno-browser-clipper-v1.25.1.zip +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.25.1.zip.sha256 +1 -0
- package/browser-extension/dist/PRIVACY.md +55 -0
- package/browser-extension/dist/chunk-vn5f663b.js +50 -0
- package/browser-extension/dist/chunk-ydfx5d7p.css +1 -0
- package/browser-extension/dist/content.js +1 -0
- package/browser-extension/dist/manifest.json +25 -0
- package/browser-extension/dist/preview.html +13 -0
- package/browser-extension/dist/service-worker.js +40 -0
- package/package.json +13 -3
- package/spec/cli.md +141 -0
- package/spec/db/schema.sql +101 -0
- package/spec/mcp.md +10 -0
- package/spec/output-schemas/browser-clip-preview.schema.json +83 -0
- package/spec/output-schemas/browser-clip.schema.json +586 -0
- package/spec/output-schemas/capture-receipt.schema.json +22 -1
- package/spec/output-schemas/clipper-csrf.schema.json +12 -0
- package/spec/output-schemas/clipper-error.schema.json +46 -0
- package/spec/output-schemas/clipper-pair-approval.schema.json +17 -0
- package/spec/output-schemas/clipper-pair-start.schema.json +26 -0
- package/spec/output-schemas/clipper-pair-status.schema.json +46 -0
- package/spec/output-schemas/clipper-revoke.schema.json +28 -0
- package/spec/output-schemas/mcp-capture-result.schema.json +12 -1
- package/spec/output-schemas/setup-activation-result.schema.json +456 -0
- package/spec/output-schemas/setup-command-result.schema.json +93 -0
- package/spec/output-schemas/setup-receipt.schema.json +258 -0
- package/spec/output-schemas/setup-semantic-receipt.schema.json +195 -0
- package/src/cli/commands/completion/scripts.ts +2 -0
- package/src/cli/commands/embed.ts +7 -2
- package/src/cli/commands/setup-activation.ts +324 -0
- package/src/cli/commands/setup-semantic.ts +591 -0
- package/src/cli/commands/setup.ts +410 -0
- package/src/cli/program.ts +64 -0
- package/src/cli/setup-semantic-worker.ts +177 -0
- package/src/core/browser-clip-provenance.ts +139 -0
- package/src/core/browser-clip.ts +473 -0
- package/src/core/capture-write.ts +5 -0
- package/src/core/capture.ts +75 -18
- package/src/core/config-mutation.ts +94 -64
- package/src/core/file-lock.ts +89 -36
- package/src/core/folder-setup-planning.ts +453 -0
- package/src/core/folder-setup.ts +490 -0
- package/src/core/setup-activation.ts +309 -0
- package/src/core/setup-receipt.ts +321 -0
- package/src/serve/capture-service.ts +420 -0
- package/src/serve/clipper-body.ts +62 -0
- package/src/serve/clipper-capture.ts +248 -0
- package/src/serve/clipper-contract.ts +57 -0
- package/src/serve/clipper-idempotency.ts +35 -0
- package/src/serve/clipper-pairing.ts +297 -0
- package/src/serve/clipper-security-errors.ts +23 -0
- package/src/serve/clipper-security.ts +449 -0
- package/src/serve/connectors.ts +29 -2
- package/src/serve/public/app.tsx +8 -1
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/index.html +1 -0
- package/src/serve/public/lib/clipper-approval.ts +206 -0
- package/src/serve/public/pages/ClipperPairing.tsx +210 -0
- package/src/serve/routes/api.ts +19 -115
- package/src/serve/routes/clipper.ts +394 -0
- package/src/serve/server.ts +22 -0
- package/src/store/migrations/020-browser-clipper-security.ts +128 -0
- package/src/store/migrations/index.ts +2 -0
- package/src/store/sqlite/clipper-store-types.ts +104 -0
- package/src/store/sqlite/clipper-store.ts +496 -0
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
/** SQLite persistence primitives for the loopback browser clipper. */
|
|
2
|
+
|
|
3
|
+
import type { Database } from "bun:sqlite";
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
AuthorizeClipperGrantResult,
|
|
7
|
+
ClaimClipperIdempotencyInput,
|
|
8
|
+
ClaimClipperIdempotencyResult,
|
|
9
|
+
ClipperCollisionPolicyResult,
|
|
10
|
+
ClipperGrant,
|
|
11
|
+
ClipperGrantInput,
|
|
12
|
+
ClipperIdempotencyPending,
|
|
13
|
+
ClipperIdempotencyPlan,
|
|
14
|
+
ClipperIdempotencyReplay,
|
|
15
|
+
CompleteClipperIdempotencyInput,
|
|
16
|
+
CompleteClipperIdempotencyResult,
|
|
17
|
+
CreateClipperGrantResult,
|
|
18
|
+
InspectClipperIdempotencyInput,
|
|
19
|
+
InspectClipperIdempotencyResult,
|
|
20
|
+
RevokeClipperGrantResult,
|
|
21
|
+
} from "./clipper-store-types";
|
|
22
|
+
|
|
23
|
+
export type {
|
|
24
|
+
AuthorizeClipperGrantResult,
|
|
25
|
+
ClaimClipperIdempotencyInput,
|
|
26
|
+
ClaimClipperIdempotencyResult,
|
|
27
|
+
ClipperCollisionPolicyResult,
|
|
28
|
+
ClipperGrant,
|
|
29
|
+
ClipperGrantInput,
|
|
30
|
+
ClipperIdempotencyPending,
|
|
31
|
+
ClipperIdempotencyPlan,
|
|
32
|
+
ClipperIdempotencyReplay,
|
|
33
|
+
CompleteClipperIdempotencyInput,
|
|
34
|
+
CompleteClipperIdempotencyResult,
|
|
35
|
+
CreateClipperGrantResult,
|
|
36
|
+
InspectClipperIdempotencyInput,
|
|
37
|
+
InspectClipperIdempotencyResult,
|
|
38
|
+
RevokeClipperGrantResult,
|
|
39
|
+
} from "./clipper-store-types";
|
|
40
|
+
|
|
41
|
+
const SHA256_PATTERN = /^[a-f0-9]{64}$/u;
|
|
42
|
+
const EXTENSION_ORIGIN_PATTERN = /^chrome-extension:\/\/[a-p]{32}$/u;
|
|
43
|
+
const MAX_IDENTIFIER_LENGTH = 128;
|
|
44
|
+
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
|
45
|
+
|
|
46
|
+
interface DbClipperGrantRow {
|
|
47
|
+
id: string;
|
|
48
|
+
token_hash: string;
|
|
49
|
+
origin: string;
|
|
50
|
+
scope: "capture";
|
|
51
|
+
created_at_ms: number;
|
|
52
|
+
expires_at_ms: number;
|
|
53
|
+
revoked_at_ms: number | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface DbClipperIdempotencyRow {
|
|
57
|
+
key_hash: string;
|
|
58
|
+
request_digest: string;
|
|
59
|
+
collection: string;
|
|
60
|
+
rel_path: string;
|
|
61
|
+
collision_policy_result: ClipperCollisionPolicyResult;
|
|
62
|
+
content_hash: string;
|
|
63
|
+
clip_identity: string;
|
|
64
|
+
state: "pending" | "completed";
|
|
65
|
+
status_code: number | null;
|
|
66
|
+
response_json: string | null;
|
|
67
|
+
created_at_ms: number;
|
|
68
|
+
completed_at_ms: number | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const assertIdentifier = (value: string, label: string): void => {
|
|
72
|
+
if (value.length < 1 || value.length > MAX_IDENTIFIER_LENGTH) {
|
|
73
|
+
throw new RangeError(
|
|
74
|
+
`${label} must contain between 1 and ${MAX_IDENTIFIER_LENGTH} characters`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const assertSha256 = (value: string, label: string): void => {
|
|
80
|
+
if (!SHA256_PATTERN.test(value)) {
|
|
81
|
+
throw new TypeError(`${label} must be a lowercase SHA-256 digest`);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const assertTimestamp = (value: number, label: string): void => {
|
|
86
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
87
|
+
throw new RangeError(`${label} must be a non-negative safe integer`);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const mapGrant = (row: DbClipperGrantRow): ClipperGrant => ({
|
|
92
|
+
id: row.id,
|
|
93
|
+
origin: row.origin,
|
|
94
|
+
scope: row.scope,
|
|
95
|
+
createdAtMs: row.created_at_ms,
|
|
96
|
+
expiresAtMs: row.expires_at_ms,
|
|
97
|
+
revokedAtMs: row.revoked_at_ms,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const getGrantByIdentity = (
|
|
101
|
+
db: Database,
|
|
102
|
+
id: string,
|
|
103
|
+
tokenHash: string
|
|
104
|
+
): DbClipperGrantRow | null =>
|
|
105
|
+
db
|
|
106
|
+
.query<DbClipperGrantRow, [string, string]>(
|
|
107
|
+
`SELECT id, token_hash, origin, scope, created_at_ms, expires_at_ms,
|
|
108
|
+
revoked_at_ms
|
|
109
|
+
FROM clipper_grants
|
|
110
|
+
WHERE id = ? OR token_hash = ?
|
|
111
|
+
LIMIT 1`
|
|
112
|
+
)
|
|
113
|
+
.get(id, tokenHash) ?? null;
|
|
114
|
+
|
|
115
|
+
const getGrantById = (db: Database, id: string): DbClipperGrantRow | null =>
|
|
116
|
+
db
|
|
117
|
+
.query<DbClipperGrantRow, [string]>(
|
|
118
|
+
`SELECT id, token_hash, origin, scope, created_at_ms, expires_at_ms,
|
|
119
|
+
revoked_at_ms
|
|
120
|
+
FROM clipper_grants
|
|
121
|
+
WHERE id = ?`
|
|
122
|
+
)
|
|
123
|
+
.get(id) ?? null;
|
|
124
|
+
|
|
125
|
+
const getIdempotencyRow = (
|
|
126
|
+
db: Database,
|
|
127
|
+
grantId: string,
|
|
128
|
+
keyHash: string
|
|
129
|
+
): DbClipperIdempotencyRow | null =>
|
|
130
|
+
db
|
|
131
|
+
.query<DbClipperIdempotencyRow, [string, string]>(
|
|
132
|
+
`SELECT key_hash, request_digest, collection, rel_path,
|
|
133
|
+
collision_policy_result, content_hash, clip_identity, state,
|
|
134
|
+
status_code, response_json, created_at_ms, completed_at_ms
|
|
135
|
+
FROM clipper_capture_idempotency
|
|
136
|
+
WHERE grant_id = ? AND key_hash = ?`
|
|
137
|
+
)
|
|
138
|
+
.get(grantId, keyHash) ?? null;
|
|
139
|
+
|
|
140
|
+
const getIdempotencyRowByDigest = (
|
|
141
|
+
db: Database,
|
|
142
|
+
grantId: string,
|
|
143
|
+
requestDigest: string
|
|
144
|
+
): DbClipperIdempotencyRow | null =>
|
|
145
|
+
db
|
|
146
|
+
.query<DbClipperIdempotencyRow, [string, string]>(
|
|
147
|
+
`SELECT key_hash, request_digest, collection, rel_path,
|
|
148
|
+
collision_policy_result, content_hash, clip_identity, state,
|
|
149
|
+
status_code, response_json, created_at_ms, completed_at_ms
|
|
150
|
+
FROM clipper_capture_idempotency
|
|
151
|
+
WHERE grant_id = ? AND request_digest = ?`
|
|
152
|
+
)
|
|
153
|
+
.get(grantId, requestDigest) ?? null;
|
|
154
|
+
|
|
155
|
+
const planFromRow = (row: DbClipperIdempotencyRow): ClipperIdempotencyPlan => ({
|
|
156
|
+
collection: row.collection,
|
|
157
|
+
relPath: row.rel_path,
|
|
158
|
+
collisionPolicyResult: row.collision_policy_result,
|
|
159
|
+
contentHash: row.content_hash,
|
|
160
|
+
clipIdentity: row.clip_identity,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const pendingFromRow = (
|
|
164
|
+
row: DbClipperIdempotencyRow
|
|
165
|
+
): ClipperIdempotencyPending => ({
|
|
166
|
+
requestDigest: row.request_digest,
|
|
167
|
+
plan: planFromRow(row),
|
|
168
|
+
createdAtMs: row.created_at_ms,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const replayFromRow = (
|
|
172
|
+
row: DbClipperIdempotencyRow
|
|
173
|
+
): ClipperIdempotencyReplay | null => {
|
|
174
|
+
if (
|
|
175
|
+
row.state !== "completed" ||
|
|
176
|
+
row.status_code === null ||
|
|
177
|
+
row.response_json === null ||
|
|
178
|
+
row.completed_at_ms === null
|
|
179
|
+
) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
...pendingFromRow(row),
|
|
184
|
+
statusCode: row.status_code,
|
|
185
|
+
responseJson: row.response_json,
|
|
186
|
+
completedAtMs: row.completed_at_ms,
|
|
187
|
+
};
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const idempotencyResultFromRow = (
|
|
191
|
+
row: DbClipperIdempotencyRow
|
|
192
|
+
):
|
|
193
|
+
| { status: "pending"; pending: ClipperIdempotencyPending }
|
|
194
|
+
| { status: "replay"; replay: ClipperIdempotencyReplay } => {
|
|
195
|
+
const replay = replayFromRow(row);
|
|
196
|
+
return replay
|
|
197
|
+
? { status: "replay", replay }
|
|
198
|
+
: { status: "pending", pending: pendingFromRow(row) };
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const assertPlan = (plan: ClipperIdempotencyPlan): void => {
|
|
202
|
+
if (
|
|
203
|
+
plan.collection.length < 1 ||
|
|
204
|
+
new TextEncoder().encode(plan.collection).byteLength > 256
|
|
205
|
+
) {
|
|
206
|
+
throw new RangeError("Clipper plan collection is empty or too long");
|
|
207
|
+
}
|
|
208
|
+
if (
|
|
209
|
+
plan.relPath.length < 1 ||
|
|
210
|
+
new TextEncoder().encode(plan.relPath).byteLength > 8192
|
|
211
|
+
) {
|
|
212
|
+
throw new RangeError("Clipper plan relative path is empty or too long");
|
|
213
|
+
}
|
|
214
|
+
if (
|
|
215
|
+
![
|
|
216
|
+
"created",
|
|
217
|
+
"opened_existing",
|
|
218
|
+
"created_with_suffix",
|
|
219
|
+
"overwritten",
|
|
220
|
+
"conflict",
|
|
221
|
+
].includes(plan.collisionPolicyResult)
|
|
222
|
+
) {
|
|
223
|
+
throw new TypeError("Clipper plan has an invalid collision result");
|
|
224
|
+
}
|
|
225
|
+
assertSha256(plan.contentHash, "Clipper plan content hash");
|
|
226
|
+
assertSha256(plan.clipIdentity, "Clipper plan identity");
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const plansMatch = (
|
|
230
|
+
left: ClipperIdempotencyPlan,
|
|
231
|
+
right: ClipperIdempotencyPlan
|
|
232
|
+
): boolean =>
|
|
233
|
+
left.collection === right.collection &&
|
|
234
|
+
left.relPath === right.relPath &&
|
|
235
|
+
left.collisionPolicyResult === right.collisionPolicyResult &&
|
|
236
|
+
left.contentHash === right.contentHash &&
|
|
237
|
+
left.clipIdentity === right.clipIdentity;
|
|
238
|
+
|
|
239
|
+
export const createClipperGrant = (
|
|
240
|
+
db: Database,
|
|
241
|
+
input: ClipperGrantInput
|
|
242
|
+
): CreateClipperGrantResult => {
|
|
243
|
+
assertIdentifier(input.id, "Grant id");
|
|
244
|
+
assertSha256(input.tokenHash, "Grant token hash");
|
|
245
|
+
if (!EXTENSION_ORIGIN_PATTERN.test(input.origin)) {
|
|
246
|
+
throw new TypeError(
|
|
247
|
+
"Grant origin must be an exact chrome-extension origin"
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
assertTimestamp(input.createdAtMs, "Grant creation time");
|
|
251
|
+
assertTimestamp(input.expiresAtMs, "Grant expiry");
|
|
252
|
+
if (input.expiresAtMs <= input.createdAtMs) {
|
|
253
|
+
throw new RangeError("Grant expiry must be after its creation time");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const insert = db.run(
|
|
257
|
+
`INSERT OR IGNORE INTO clipper_grants (
|
|
258
|
+
id, token_hash, origin, scope, created_at_ms, expires_at_ms
|
|
259
|
+
) VALUES (?, ?, ?, 'capture', ?, ?)`,
|
|
260
|
+
[
|
|
261
|
+
input.id,
|
|
262
|
+
input.tokenHash,
|
|
263
|
+
input.origin,
|
|
264
|
+
input.createdAtMs,
|
|
265
|
+
input.expiresAtMs,
|
|
266
|
+
]
|
|
267
|
+
);
|
|
268
|
+
const row = getGrantByIdentity(db, input.id, input.tokenHash);
|
|
269
|
+
if (!row) {
|
|
270
|
+
throw new Error("Failed to read persisted clipper grant");
|
|
271
|
+
}
|
|
272
|
+
const grant = mapGrant(row);
|
|
273
|
+
if (insert.changes > 0) return { status: "created", grant };
|
|
274
|
+
if (
|
|
275
|
+
row.id === input.id &&
|
|
276
|
+
row.token_hash === input.tokenHash &&
|
|
277
|
+
row.origin === input.origin &&
|
|
278
|
+
row.created_at_ms === input.createdAtMs &&
|
|
279
|
+
row.expires_at_ms === input.expiresAtMs
|
|
280
|
+
) {
|
|
281
|
+
return { status: "duplicate", grant };
|
|
282
|
+
}
|
|
283
|
+
return { status: "conflict" };
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
export const authorizeClipperGrant = (
|
|
287
|
+
db: Database,
|
|
288
|
+
tokenHash: string,
|
|
289
|
+
origin: string,
|
|
290
|
+
nowMs: number
|
|
291
|
+
): AuthorizeClipperGrantResult => {
|
|
292
|
+
assertSha256(tokenHash, "Grant token hash");
|
|
293
|
+
assertTimestamp(nowMs, "Authorization time");
|
|
294
|
+
if (!EXTENSION_ORIGIN_PATTERN.test(origin)) return { status: "unauthorized" };
|
|
295
|
+
const row = db
|
|
296
|
+
.query<DbClipperGrantRow, [string]>(
|
|
297
|
+
`SELECT id, token_hash, origin, scope, created_at_ms, expires_at_ms,
|
|
298
|
+
revoked_at_ms
|
|
299
|
+
FROM clipper_grants
|
|
300
|
+
WHERE token_hash = ?`
|
|
301
|
+
)
|
|
302
|
+
.get(tokenHash);
|
|
303
|
+
if (!row || row.origin !== origin) return { status: "unauthorized" };
|
|
304
|
+
const grant = mapGrant(row);
|
|
305
|
+
if (nowMs < grant.createdAtMs) return { status: "unauthorized" };
|
|
306
|
+
if (grant.revokedAtMs !== null) return { status: "revoked" };
|
|
307
|
+
if (nowMs >= grant.expiresAtMs) return { status: "expired" };
|
|
308
|
+
return { status: "authorized", grant };
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
export const revokeClipperGrant = (
|
|
312
|
+
db: Database,
|
|
313
|
+
grantId: string,
|
|
314
|
+
nowMs: number
|
|
315
|
+
): RevokeClipperGrantResult => {
|
|
316
|
+
assertIdentifier(grantId, "Grant id");
|
|
317
|
+
assertTimestamp(nowMs, "Revocation time");
|
|
318
|
+
const row = db
|
|
319
|
+
.query<DbClipperGrantRow, [string]>(
|
|
320
|
+
`SELECT id, token_hash, origin, scope, created_at_ms, expires_at_ms,
|
|
321
|
+
revoked_at_ms
|
|
322
|
+
FROM clipper_grants
|
|
323
|
+
WHERE id = ?`
|
|
324
|
+
)
|
|
325
|
+
.get(grantId);
|
|
326
|
+
if (!row) return { status: "not_found" };
|
|
327
|
+
const grant = mapGrant(row);
|
|
328
|
+
if (grant.revokedAtMs !== null) {
|
|
329
|
+
return { status: "already_revoked", grant };
|
|
330
|
+
}
|
|
331
|
+
if (nowMs >= grant.expiresAtMs) return { status: "expired", grant };
|
|
332
|
+
const update = db.run(
|
|
333
|
+
`UPDATE clipper_grants
|
|
334
|
+
SET revoked_at_ms = ?
|
|
335
|
+
WHERE id = ? AND revoked_at_ms IS NULL`,
|
|
336
|
+
[Math.max(nowMs, grant.createdAtMs), grantId]
|
|
337
|
+
);
|
|
338
|
+
if (update.changes === 0) {
|
|
339
|
+
const current = getGrantById(db, grantId);
|
|
340
|
+
if (!current) return { status: "not_found" };
|
|
341
|
+
return { status: "already_revoked", grant: mapGrant(current) };
|
|
342
|
+
}
|
|
343
|
+
return {
|
|
344
|
+
status: "revoked",
|
|
345
|
+
grant: { ...grant, revokedAtMs: Math.max(nowMs, grant.createdAtMs) },
|
|
346
|
+
};
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
export const claimClipperIdempotency = (
|
|
350
|
+
db: Database,
|
|
351
|
+
input: ClaimClipperIdempotencyInput
|
|
352
|
+
): ClaimClipperIdempotencyResult => {
|
|
353
|
+
assertIdentifier(input.grantId, "Grant id");
|
|
354
|
+
assertSha256(input.keyHash, "Idempotency key hash");
|
|
355
|
+
assertSha256(input.requestDigest, "Request digest");
|
|
356
|
+
assertPlan(input.plan);
|
|
357
|
+
assertTimestamp(input.nowMs, "Claim time");
|
|
358
|
+
const grant = db
|
|
359
|
+
.query<
|
|
360
|
+
{
|
|
361
|
+
created_at_ms: number;
|
|
362
|
+
expires_at_ms: number;
|
|
363
|
+
revoked_at_ms: number | null;
|
|
364
|
+
},
|
|
365
|
+
[string]
|
|
366
|
+
>(
|
|
367
|
+
`SELECT created_at_ms, expires_at_ms, revoked_at_ms
|
|
368
|
+
FROM clipper_grants
|
|
369
|
+
WHERE id = ?`
|
|
370
|
+
)
|
|
371
|
+
.get(input.grantId);
|
|
372
|
+
if (
|
|
373
|
+
!grant ||
|
|
374
|
+
grant.revoked_at_ms !== null ||
|
|
375
|
+
input.nowMs < grant.created_at_ms ||
|
|
376
|
+
input.nowMs >= grant.expires_at_ms
|
|
377
|
+
) {
|
|
378
|
+
return { status: "grant_inactive" };
|
|
379
|
+
}
|
|
380
|
+
const insert = db.run(
|
|
381
|
+
`INSERT OR IGNORE INTO clipper_capture_idempotency (
|
|
382
|
+
grant_id, key_hash, request_digest, collection, rel_path,
|
|
383
|
+
collision_policy_result, content_hash, clip_identity, state,
|
|
384
|
+
created_at_ms
|
|
385
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?)`,
|
|
386
|
+
[
|
|
387
|
+
input.grantId,
|
|
388
|
+
input.keyHash,
|
|
389
|
+
input.requestDigest,
|
|
390
|
+
input.plan.collection,
|
|
391
|
+
input.plan.relPath,
|
|
392
|
+
input.plan.collisionPolicyResult,
|
|
393
|
+
input.plan.contentHash,
|
|
394
|
+
input.plan.clipIdentity,
|
|
395
|
+
input.nowMs,
|
|
396
|
+
]
|
|
397
|
+
);
|
|
398
|
+
if (insert.changes > 0) return { status: "claimed" };
|
|
399
|
+
const keyRow = getIdempotencyRow(db, input.grantId, input.keyHash);
|
|
400
|
+
if (keyRow && keyRow.request_digest !== input.requestDigest) {
|
|
401
|
+
return { status: "conflict" };
|
|
402
|
+
}
|
|
403
|
+
const row =
|
|
404
|
+
keyRow ?? getIdempotencyRowByDigest(db, input.grantId, input.requestDigest);
|
|
405
|
+
if (!row) throw new Error("Failed to read clipper idempotency claim");
|
|
406
|
+
const storedPlan = planFromRow(row);
|
|
407
|
+
if (!plansMatch(storedPlan, input.plan)) {
|
|
408
|
+
return { status: "conflict" };
|
|
409
|
+
}
|
|
410
|
+
return idempotencyResultFromRow(row);
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
export const inspectClipperIdempotency = (
|
|
414
|
+
db: Database,
|
|
415
|
+
input: InspectClipperIdempotencyInput
|
|
416
|
+
): InspectClipperIdempotencyResult => {
|
|
417
|
+
assertIdentifier(input.grantId, "Grant id");
|
|
418
|
+
assertSha256(input.keyHash, "Idempotency key hash");
|
|
419
|
+
assertSha256(input.requestDigest, "Request digest");
|
|
420
|
+
const keyRow = getIdempotencyRow(db, input.grantId, input.keyHash);
|
|
421
|
+
if (keyRow && keyRow.request_digest !== input.requestDigest) {
|
|
422
|
+
return { status: "conflict" };
|
|
423
|
+
}
|
|
424
|
+
const row =
|
|
425
|
+
keyRow ?? getIdempotencyRowByDigest(db, input.grantId, input.requestDigest);
|
|
426
|
+
return row ? idempotencyResultFromRow(row) : { status: "not_found" };
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
export const completeClipperIdempotency = (
|
|
430
|
+
db: Database,
|
|
431
|
+
input: CompleteClipperIdempotencyInput
|
|
432
|
+
): CompleteClipperIdempotencyResult => {
|
|
433
|
+
assertIdentifier(input.grantId, "Grant id");
|
|
434
|
+
assertSha256(input.keyHash, "Idempotency key hash");
|
|
435
|
+
assertSha256(input.requestDigest, "Request digest");
|
|
436
|
+
assertTimestamp(input.nowMs, "Completion time");
|
|
437
|
+
if (
|
|
438
|
+
!Number.isInteger(input.statusCode) ||
|
|
439
|
+
input.statusCode < 200 ||
|
|
440
|
+
input.statusCode > 599
|
|
441
|
+
) {
|
|
442
|
+
throw new RangeError("Response status code must be between 200 and 599");
|
|
443
|
+
}
|
|
444
|
+
if (
|
|
445
|
+
new TextEncoder().encode(input.responseJson).byteLength > MAX_RESPONSE_BYTES
|
|
446
|
+
) {
|
|
447
|
+
throw new RangeError(
|
|
448
|
+
`Clipper response exceeds ${MAX_RESPONSE_BYTES} UTF-8 bytes`
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
try {
|
|
452
|
+
JSON.parse(input.responseJson);
|
|
453
|
+
} catch {
|
|
454
|
+
throw new TypeError("Clipper response must be valid JSON");
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const keyRow = getIdempotencyRow(db, input.grantId, input.keyHash);
|
|
458
|
+
if (keyRow && keyRow.request_digest !== input.requestDigest) {
|
|
459
|
+
return { status: "conflict" };
|
|
460
|
+
}
|
|
461
|
+
const existing =
|
|
462
|
+
keyRow ?? getIdempotencyRowByDigest(db, input.grantId, input.requestDigest);
|
|
463
|
+
if (!existing) return { status: "not_found" };
|
|
464
|
+
const existingReplay = replayFromRow(existing);
|
|
465
|
+
if (existingReplay) {
|
|
466
|
+
return existingReplay.statusCode === input.statusCode &&
|
|
467
|
+
existingReplay.responseJson === input.responseJson
|
|
468
|
+
? { status: "replay", replay: existingReplay }
|
|
469
|
+
: { status: "conflict" };
|
|
470
|
+
}
|
|
471
|
+
const update = db.run(
|
|
472
|
+
`UPDATE clipper_capture_idempotency
|
|
473
|
+
SET state = 'completed',
|
|
474
|
+
status_code = ?,
|
|
475
|
+
response_json = ?,
|
|
476
|
+
completed_at_ms = ?
|
|
477
|
+
WHERE grant_id = ? AND key_hash = ? AND request_digest = ?
|
|
478
|
+
AND state = 'pending'`,
|
|
479
|
+
[
|
|
480
|
+
input.statusCode,
|
|
481
|
+
input.responseJson,
|
|
482
|
+
input.nowMs,
|
|
483
|
+
input.grantId,
|
|
484
|
+
existing.key_hash,
|
|
485
|
+
input.requestDigest,
|
|
486
|
+
]
|
|
487
|
+
);
|
|
488
|
+
const completed = getIdempotencyRow(db, input.grantId, existing.key_hash);
|
|
489
|
+
const replay = completed ? replayFromRow(completed) : null;
|
|
490
|
+
if (!replay) throw new Error("Failed to complete clipper idempotency claim");
|
|
491
|
+
if (update.changes > 0) return { status: "completed", replay };
|
|
492
|
+
return replay.statusCode === input.statusCode &&
|
|
493
|
+
replay.responseJson === input.responseJson
|
|
494
|
+
? { status: "replay", replay }
|
|
495
|
+
: { status: "conflict" };
|
|
496
|
+
};
|