@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.
Files changed (68) hide show
  1. package/README.md +26 -12
  2. package/assets/skill/SKILL.md +37 -19
  3. package/assets/skill/recipes/capture-and-file.md +20 -5
  4. package/browser-extension/artifacts/gno-browser-clipper-v1.25.1.zip +0 -0
  5. package/browser-extension/artifacts/gno-browser-clipper-v1.25.1.zip.sha256 +1 -0
  6. package/browser-extension/dist/PRIVACY.md +55 -0
  7. package/browser-extension/dist/chunk-vn5f663b.js +50 -0
  8. package/browser-extension/dist/chunk-ydfx5d7p.css +1 -0
  9. package/browser-extension/dist/content.js +1 -0
  10. package/browser-extension/dist/manifest.json +25 -0
  11. package/browser-extension/dist/preview.html +13 -0
  12. package/browser-extension/dist/service-worker.js +40 -0
  13. package/package.json +13 -3
  14. package/spec/cli.md +141 -0
  15. package/spec/db/schema.sql +101 -0
  16. package/spec/mcp.md +10 -0
  17. package/spec/output-schemas/browser-clip-preview.schema.json +83 -0
  18. package/spec/output-schemas/browser-clip.schema.json +586 -0
  19. package/spec/output-schemas/capture-receipt.schema.json +22 -1
  20. package/spec/output-schemas/clipper-csrf.schema.json +12 -0
  21. package/spec/output-schemas/clipper-error.schema.json +46 -0
  22. package/spec/output-schemas/clipper-pair-approval.schema.json +17 -0
  23. package/spec/output-schemas/clipper-pair-start.schema.json +26 -0
  24. package/spec/output-schemas/clipper-pair-status.schema.json +46 -0
  25. package/spec/output-schemas/clipper-revoke.schema.json +28 -0
  26. package/spec/output-schemas/mcp-capture-result.schema.json +12 -1
  27. package/spec/output-schemas/setup-activation-result.schema.json +456 -0
  28. package/spec/output-schemas/setup-command-result.schema.json +93 -0
  29. package/spec/output-schemas/setup-receipt.schema.json +258 -0
  30. package/spec/output-schemas/setup-semantic-receipt.schema.json +195 -0
  31. package/src/cli/commands/completion/scripts.ts +2 -0
  32. package/src/cli/commands/embed.ts +7 -2
  33. package/src/cli/commands/setup-activation.ts +324 -0
  34. package/src/cli/commands/setup-semantic.ts +591 -0
  35. package/src/cli/commands/setup.ts +410 -0
  36. package/src/cli/program.ts +64 -0
  37. package/src/cli/setup-semantic-worker.ts +177 -0
  38. package/src/core/browser-clip-provenance.ts +139 -0
  39. package/src/core/browser-clip.ts +473 -0
  40. package/src/core/capture-write.ts +5 -0
  41. package/src/core/capture.ts +75 -18
  42. package/src/core/config-mutation.ts +94 -64
  43. package/src/core/file-lock.ts +89 -36
  44. package/src/core/folder-setup-planning.ts +453 -0
  45. package/src/core/folder-setup.ts +490 -0
  46. package/src/core/setup-activation.ts +309 -0
  47. package/src/core/setup-receipt.ts +321 -0
  48. package/src/serve/capture-service.ts +420 -0
  49. package/src/serve/clipper-body.ts +62 -0
  50. package/src/serve/clipper-capture.ts +248 -0
  51. package/src/serve/clipper-contract.ts +57 -0
  52. package/src/serve/clipper-idempotency.ts +35 -0
  53. package/src/serve/clipper-pairing.ts +297 -0
  54. package/src/serve/clipper-security-errors.ts +23 -0
  55. package/src/serve/clipper-security.ts +449 -0
  56. package/src/serve/connectors.ts +29 -2
  57. package/src/serve/public/app.tsx +8 -1
  58. package/src/serve/public/globals.built.css +1 -1
  59. package/src/serve/public/index.html +1 -0
  60. package/src/serve/public/lib/clipper-approval.ts +206 -0
  61. package/src/serve/public/pages/ClipperPairing.tsx +210 -0
  62. package/src/serve/routes/api.ts +19 -115
  63. package/src/serve/routes/clipper.ts +394 -0
  64. package/src/serve/server.ts +22 -0
  65. package/src/store/migrations/020-browser-clipper-security.ts +128 -0
  66. package/src/store/migrations/index.ts +2 -0
  67. package/src/store/sqlite/clipper-store-types.ts +104 -0
  68. 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
+ };