@gmickel/gno 1.24.0 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +18 -4
  2. package/assets/skill/SKILL.md +40 -17
  3. package/assets/skill/recipes/capture-and-file.md +20 -5
  4. package/browser-extension/artifacts/gno-browser-clipper-v1.26.0.zip +0 -0
  5. package/browser-extension/artifacts/gno-browser-clipper-v1.26.0.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 +196 -4
  15. package/spec/db/schema.sql +102 -1
  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/project-profile-apply.schema.json +209 -0
  28. package/spec/output-schemas/project-profile-command.schema.json +160 -0
  29. package/spec/output-schemas/query-diagnose.schema.json +7 -1
  30. package/spec/output-schemas/setup-profile-result.schema.json +87 -0
  31. package/spec/project-profile.schema.json +301 -0
  32. package/src/cli/commands/collection/add.ts +39 -45
  33. package/src/cli/commands/collection/remove.ts +28 -28
  34. package/src/cli/commands/collection/rename.ts +55 -73
  35. package/src/cli/commands/context/add.ts +37 -26
  36. package/src/cli/commands/context/rm.ts +47 -20
  37. package/src/cli/commands/init.ts +55 -125
  38. package/src/cli/commands/models/use.ts +43 -38
  39. package/src/cli/commands/profile-apply.ts +334 -0
  40. package/src/cli/commands/profile.ts +409 -0
  41. package/src/cli/commands/setup-activation.ts +205 -54
  42. package/src/cli/commands/setup-profile.ts +223 -0
  43. package/src/cli/commands/setup.ts +3 -0
  44. package/src/cli/program.ts +110 -9
  45. package/src/config/index.ts +3 -0
  46. package/src/config/project-profile.ts +367 -0
  47. package/src/config/saver.ts +16 -7
  48. package/src/config/types.ts +42 -0
  49. package/src/core/browser-clip-provenance.ts +139 -0
  50. package/src/core/browser-clip.ts +473 -0
  51. package/src/core/capture-write.ts +5 -0
  52. package/src/core/capture.ts +75 -18
  53. package/src/core/config-mutation.ts +138 -76
  54. package/src/core/config-write-lock.ts +89 -0
  55. package/src/core/context-identity.ts +16 -0
  56. package/src/core/context-resolver.ts +2 -12
  57. package/src/core/file-lock.ts +20 -6
  58. package/src/core/folder-setup-planning.ts +6 -21
  59. package/src/core/folder-setup.ts +30 -2
  60. package/src/core/path-rules.ts +53 -0
  61. package/src/core/project-affinity-surface.ts +102 -7
  62. package/src/core/project-profile-apply-state.ts +268 -0
  63. package/src/core/project-profile-apply-validation.ts +95 -0
  64. package/src/core/project-profile-apply.ts +408 -0
  65. package/src/core/project-profile-canonical.ts +71 -0
  66. package/src/core/project-profile-diff.ts +302 -0
  67. package/src/core/project-profile-discovery.ts +519 -0
  68. package/src/core/project-profile-file.ts +37 -0
  69. package/src/core/project-profile-parser.ts +98 -0
  70. package/src/core/project-profile.ts +490 -0
  71. package/src/ingestion/walker.ts +85 -44
  72. package/src/llm/cache.ts +21 -0
  73. package/src/serve/capture-service.ts +420 -0
  74. package/src/serve/clipper-body.ts +62 -0
  75. package/src/serve/clipper-capture.ts +248 -0
  76. package/src/serve/clipper-contract.ts +57 -0
  77. package/src/serve/clipper-idempotency.ts +35 -0
  78. package/src/serve/clipper-pairing.ts +297 -0
  79. package/src/serve/clipper-security-errors.ts +23 -0
  80. package/src/serve/clipper-security.ts +449 -0
  81. package/src/serve/config-sync.ts +2 -2
  82. package/src/serve/public/app.tsx +8 -1
  83. package/src/serve/public/globals.built.css +1 -1
  84. package/src/serve/public/index.html +1 -0
  85. package/src/serve/public/lib/clipper-approval.ts +206 -0
  86. package/src/serve/public/pages/ClipperPairing.tsx +210 -0
  87. package/src/serve/resident-runtime.ts +1 -0
  88. package/src/serve/routes/api.ts +20 -115
  89. package/src/serve/routes/clipper.ts +394 -0
  90. package/src/serve/server.ts +22 -0
  91. package/src/store/migrations/020-browser-clipper-security.ts +128 -0
  92. package/src/store/migrations/021-multi-context-identity.ts +37 -0
  93. package/src/store/migrations/index.ts +4 -0
  94. package/src/store/sqlite/adapter.ts +83 -0
  95. package/src/store/sqlite/clipper-store-types.ts +104 -0
  96. package/src/store/sqlite/clipper-store.ts +496 -0
@@ -625,6 +625,57 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
625
625
  }
626
626
  }
627
627
 
628
+ async upsertCollections(
629
+ collections: Collection[]
630
+ ): Promise<StoreResult<void>> {
631
+ try {
632
+ const db = this.ensureOpen();
633
+ const stmt = db.prepare(`
634
+ INSERT INTO collections (name, path, pattern, include, exclude, update_cmd, language_hint, synced_at)
635
+ VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
636
+ ON CONFLICT(name) DO UPDATE SET
637
+ path = excluded.path,
638
+ pattern = excluded.pattern,
639
+ include = excluded.include,
640
+ exclude = excluded.exclude,
641
+ update_cmd = excluded.update_cmd,
642
+ language_hint = excluded.language_hint,
643
+ synced_at = datetime('now')
644
+ WHERE collections.path IS NOT excluded.path
645
+ OR collections.pattern IS NOT excluded.pattern
646
+ OR collections.include IS NOT excluded.include
647
+ OR collections.exclude IS NOT excluded.exclude
648
+ OR collections.update_cmd IS NOT excluded.update_cmd
649
+ OR collections.language_hint IS NOT excluded.language_hint
650
+ `);
651
+ const transaction = db.transaction(() => {
652
+ for (const collection of collections) {
653
+ stmt.run(
654
+ collection.name,
655
+ collection.path,
656
+ collection.pattern,
657
+ collection.include.length > 0
658
+ ? JSON.stringify(collection.include)
659
+ : null,
660
+ collection.exclude.length > 0
661
+ ? JSON.stringify(collection.exclude)
662
+ : null,
663
+ collection.updateCmd ?? null,
664
+ collection.languageHint ?? null
665
+ );
666
+ }
667
+ });
668
+ transaction();
669
+ return ok(undefined);
670
+ } catch (cause) {
671
+ return err(
672
+ "QUERY_FAILED",
673
+ cause instanceof Error ? cause.message : "Failed to upsert collections",
674
+ cause
675
+ );
676
+ }
677
+ }
678
+
628
679
  async syncContexts(contexts: Context[]): Promise<StoreResult<void>> {
629
680
  try {
630
681
  const db = this.ensureOpen();
@@ -636,6 +687,8 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
636
687
  const stmt = db.prepare(`
637
688
  INSERT INTO contexts (scope_type, scope_key, text, synced_at)
638
689
  VALUES (?, ?, ?, datetime('now'))
690
+ ON CONFLICT(scope_type, scope_key, text) DO UPDATE SET
691
+ synced_at = excluded.synced_at
639
692
  `);
640
693
 
641
694
  for (const c of contexts) {
@@ -655,6 +708,36 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
655
708
  }
656
709
  }
657
710
 
711
+ async upsertContexts(contexts: Context[]): Promise<StoreResult<void>> {
712
+ try {
713
+ const db = this.ensureOpen();
714
+ const stmt = db.prepare(`
715
+ INSERT INTO contexts (scope_type, scope_key, text, synced_at)
716
+ VALUES (?, ?, ?, datetime('now'))
717
+ ON CONFLICT(scope_type, scope_key, text) DO NOTHING
718
+ `);
719
+ let inserted = 0;
720
+ const transaction = db.transaction(() => {
721
+ for (const context of contexts) {
722
+ inserted += stmt.run(
723
+ context.scopeType,
724
+ context.scopeKey,
725
+ context.text
726
+ ).changes;
727
+ }
728
+ });
729
+ transaction();
730
+ if (inserted > 0) this.contextGeneration += 1;
731
+ return ok(undefined);
732
+ } catch (cause) {
733
+ return err(
734
+ "QUERY_FAILED",
735
+ cause instanceof Error ? cause.message : "Failed to upsert contexts",
736
+ cause
737
+ );
738
+ }
739
+ }
740
+
658
741
  async getCollections(): Promise<StoreResult<CollectionRow[]>> {
659
742
  try {
660
743
  const db = this.ensureOpen();
@@ -0,0 +1,104 @@
1
+ /** Public contracts for browser-clipper SQLite persistence. */
2
+
3
+ export interface ClipperGrantInput {
4
+ id: string;
5
+ tokenHash: string;
6
+ origin: string;
7
+ createdAtMs: number;
8
+ expiresAtMs: number;
9
+ }
10
+
11
+ export interface ClipperGrant {
12
+ id: string;
13
+ origin: string;
14
+ scope: "capture";
15
+ createdAtMs: number;
16
+ expiresAtMs: number;
17
+ revokedAtMs: number | null;
18
+ }
19
+
20
+ export type CreateClipperGrantResult =
21
+ | { status: "created"; grant: ClipperGrant }
22
+ | { status: "duplicate"; grant: ClipperGrant }
23
+ | { status: "conflict" };
24
+
25
+ export type AuthorizeClipperGrantResult =
26
+ | { status: "authorized"; grant: ClipperGrant }
27
+ | { status: "unauthorized" }
28
+ | { status: "expired" }
29
+ | { status: "revoked" };
30
+
31
+ export type RevokeClipperGrantResult =
32
+ | { status: "revoked"; grant: ClipperGrant }
33
+ | { status: "already_revoked"; grant: ClipperGrant }
34
+ | { status: "expired"; grant: ClipperGrant }
35
+ | { status: "not_found" };
36
+
37
+ export type ClipperCollisionPolicyResult =
38
+ | "created"
39
+ | "opened_existing"
40
+ | "created_with_suffix"
41
+ | "overwritten"
42
+ | "conflict";
43
+
44
+ export interface ClipperIdempotencyPlan {
45
+ collection: string;
46
+ relPath: string;
47
+ collisionPolicyResult: ClipperCollisionPolicyResult;
48
+ contentHash: string;
49
+ clipIdentity: string;
50
+ }
51
+
52
+ export interface ClaimClipperIdempotencyInput {
53
+ grantId: string;
54
+ keyHash: string;
55
+ requestDigest: string;
56
+ plan: ClipperIdempotencyPlan;
57
+ nowMs: number;
58
+ }
59
+
60
+ export interface CompleteClipperIdempotencyInput {
61
+ grantId: string;
62
+ keyHash: string;
63
+ requestDigest: string;
64
+ responseJson: string;
65
+ statusCode: number;
66
+ nowMs: number;
67
+ }
68
+
69
+ export interface InspectClipperIdempotencyInput {
70
+ grantId: string;
71
+ keyHash: string;
72
+ requestDigest: string;
73
+ }
74
+
75
+ export interface ClipperIdempotencyPending {
76
+ requestDigest: string;
77
+ plan: ClipperIdempotencyPlan;
78
+ createdAtMs: number;
79
+ }
80
+
81
+ export interface ClipperIdempotencyReplay extends ClipperIdempotencyPending {
82
+ statusCode: number;
83
+ responseJson: string;
84
+ completedAtMs: number;
85
+ }
86
+
87
+ export type ClaimClipperIdempotencyResult =
88
+ | { status: "claimed" }
89
+ | { status: "pending"; pending: ClipperIdempotencyPending }
90
+ | { status: "replay"; replay: ClipperIdempotencyReplay }
91
+ | { status: "conflict" }
92
+ | { status: "grant_inactive" };
93
+
94
+ export type InspectClipperIdempotencyResult =
95
+ | { status: "pending"; pending: ClipperIdempotencyPending }
96
+ | { status: "replay"; replay: ClipperIdempotencyReplay }
97
+ | { status: "conflict" }
98
+ | { status: "not_found" };
99
+
100
+ export type CompleteClipperIdempotencyResult =
101
+ | { status: "completed"; replay: ClipperIdempotencyReplay }
102
+ | { status: "replay"; replay: ClipperIdempotencyReplay }
103
+ | { status: "conflict" }
104
+ | { status: "not_found" };
@@ -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
+ };