@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,394 @@
1
+ /** Dedicated loopback browser-clipper HTTP gateway. */
2
+
3
+ import type { HttpMcpPeerServer } from "../../mcp/http-security";
4
+ import type { SqliteAdapter } from "../../store/sqlite/adapter";
5
+ import type { ContextHolder } from "./api";
6
+
7
+ import { prepareBrowserClip } from "../../core/browser-clip";
8
+ import {
9
+ planResidentCapture,
10
+ type ResidentCapturePlanResult,
11
+ } from "../capture-service";
12
+ import { executeClipperCapture } from "../clipper-capture";
13
+ import {
14
+ clipperApprovalSchema,
15
+ clipperBearerToken,
16
+ clipperErrorResponse,
17
+ clipperLoopbackAuthority,
18
+ clipperResponse,
19
+ clipperSha256,
20
+ isClipperPairId,
21
+ } from "../clipper-contract";
22
+ import {
23
+ ClipperPairingService,
24
+ type ClipperPairPollResult,
25
+ } from "../clipper-pairing";
26
+ import {
27
+ ClipperSecurityBoundary,
28
+ type ClipperAdmission,
29
+ withClipperCors,
30
+ } from "../clipper-security";
31
+
32
+ type ClipperRoute = (
33
+ request: Request,
34
+ server: HttpMcpPeerServer
35
+ ) => Promise<Response> | Response;
36
+
37
+ export interface ClipperRouteGateway {
38
+ readonly routes: Record<
39
+ string,
40
+ Partial<Record<"GET" | "POST" | "OPTIONS", ClipperRoute>>
41
+ >;
42
+ }
43
+
44
+ interface AuthenticatedAdmission {
45
+ admission: ClipperAdmission;
46
+ grant: Extract<
47
+ ReturnType<ClipperPairingService["authorize"]>,
48
+ { status: "authorized" }
49
+ >["grant"];
50
+ }
51
+
52
+ const releaseWithCors = (
53
+ admission: ClipperAdmission,
54
+ result: Response
55
+ ): Response => {
56
+ admission.release();
57
+ return withClipperCors(result, admission.origin);
58
+ };
59
+
60
+ const planProjection = (
61
+ planned: Extract<ResidentCapturePlanResult, { ok: true }>
62
+ ): {
63
+ collection: string;
64
+ relPath: string;
65
+ outcome: string;
66
+ provenanceConflict: boolean;
67
+ } => ({
68
+ collection: planned.plan.collection,
69
+ relPath: planned.plan.relPath,
70
+ outcome: planned.plan.collisionPolicyResult,
71
+ provenanceConflict: planned.plan.provenanceConflict,
72
+ });
73
+
74
+ const pollStatusCode = (result: ClipperPairPollResult): number => {
75
+ if (result.status === "not_found") return 404;
76
+ if (
77
+ result.status === "expired" ||
78
+ result.status === "consumed" ||
79
+ result.status === "origin_mismatch"
80
+ ) {
81
+ return 410;
82
+ }
83
+ return 200;
84
+ };
85
+
86
+ export function createClipperRouteGateway(
87
+ ctxHolder: ContextHolder,
88
+ store: SqliteAdapter,
89
+ options: { host: string; port: number }
90
+ ): ClipperRouteGateway {
91
+ const authority = clipperLoopbackAuthority(options.host, options.port);
92
+ const listenerOrigin = `http://${authority}`;
93
+ const security = new ClipperSecurityBoundary({
94
+ allowedHosts: [authority],
95
+ sameOrigins: [listenerOrigin],
96
+ });
97
+ const pairing = new ClipperPairingService(store.getRawDb());
98
+ const db = store.getRawDb();
99
+
100
+ const admitExtension = async (
101
+ request: Request,
102
+ server: HttpMcpPeerServer,
103
+ readJson: boolean
104
+ ) =>
105
+ security.admit(request, server, {
106
+ origin: { kind: "extension" },
107
+ readJson,
108
+ });
109
+
110
+ const authenticate = async (
111
+ request: Request,
112
+ server: HttpMcpPeerServer,
113
+ readJson: boolean
114
+ ): Promise<
115
+ | { ok: true; value: AuthenticatedAdmission }
116
+ | { ok: false; response: Response }
117
+ > => {
118
+ const admitted = await admitExtension(request, server, readJson);
119
+ if (!admitted.ok) return admitted;
120
+ const token = clipperBearerToken(request);
121
+ if (!token) {
122
+ return {
123
+ ok: false,
124
+ response: releaseWithCors(
125
+ admitted.value,
126
+ clipperErrorResponse("CLIPPER_UNAUTHORIZED", "Unauthorized", 401)
127
+ ),
128
+ };
129
+ }
130
+ const authorized = pairing.authorize(token, admitted.value.origin);
131
+ if (authorized.status !== "authorized") {
132
+ return {
133
+ ok: false,
134
+ response: releaseWithCors(
135
+ admitted.value,
136
+ clipperErrorResponse("CLIPPER_UNAUTHORIZED", "Unauthorized", 401)
137
+ ),
138
+ };
139
+ }
140
+ return {
141
+ ok: true,
142
+ value: { admission: admitted.value, grant: authorized.grant },
143
+ };
144
+ };
145
+
146
+ const startPair: ClipperRoute = async (request, server) => {
147
+ const admitted = await admitExtension(request, server, false);
148
+ if (!admitted.ok) return admitted.response;
149
+ try {
150
+ const started = pairing.start(admitted.value.origin);
151
+ return withClipperCors(
152
+ clipperResponse({
153
+ schemaVersion: "1.0",
154
+ ...started,
155
+ origin: admitted.value.origin,
156
+ approvalPath: "/api/clipper/pair/approve",
157
+ }),
158
+ admitted.value.origin
159
+ );
160
+ } catch (error) {
161
+ return withClipperCors(
162
+ clipperErrorResponse(
163
+ "CLIPPER_PAIRING_UNAVAILABLE",
164
+ error instanceof Error ? error.message : "Pairing unavailable",
165
+ 429
166
+ ),
167
+ admitted.value.origin
168
+ );
169
+ } finally {
170
+ admitted.value.release();
171
+ }
172
+ };
173
+
174
+ const csrf: ClipperRoute = async (request, server) => {
175
+ const admitted = await security.admit(request, server, {
176
+ origin: { allowOriginlessSafeGet: true, kind: "same-origin" },
177
+ });
178
+ if (!admitted.ok) return admitted.response;
179
+ try {
180
+ return clipperResponse({
181
+ schemaVersion: "1.0",
182
+ csrfToken: pairing.csrfToken,
183
+ });
184
+ } finally {
185
+ admitted.value.release();
186
+ }
187
+ };
188
+
189
+ const approvePair: ClipperRoute = async (request, server) => {
190
+ const admitted = await security.admit(request, server, {
191
+ origin: { kind: "same-origin" },
192
+ readJson: true,
193
+ });
194
+ if (!admitted.ok) return admitted.response;
195
+ try {
196
+ if (request.headers.get("x-gno-csrf") !== pairing.csrfToken) {
197
+ return clipperErrorResponse("CLIPPER_CSRF", "Invalid CSRF token", 403);
198
+ }
199
+ const parsed = clipperApprovalSchema.safeParse(admitted.value.body);
200
+ if (!parsed.success) {
201
+ return clipperErrorResponse(
202
+ "CLIPPER_INVALID_REQUEST",
203
+ "Invalid pairing approval",
204
+ 400
205
+ );
206
+ }
207
+ const approved = pairing.approve(
208
+ parsed.data.pairId,
209
+ parsed.data.pairingCode
210
+ );
211
+ if (approved.status !== "approved") {
212
+ const status = approved.status === "invalid_code" ? 403 : 410;
213
+ return clipperErrorResponse(
214
+ `CLIPPER_PAIR_${approved.status.toUpperCase()}`,
215
+ "Pairing could not be approved",
216
+ status
217
+ );
218
+ }
219
+ return clipperResponse({
220
+ schemaVersion: "1.0",
221
+ status: approved.status,
222
+ origin: approved.origin,
223
+ expiresAt: approved.expiresAt,
224
+ });
225
+ } finally {
226
+ admitted.value.release();
227
+ }
228
+ };
229
+
230
+ const pollPair: ClipperRoute = async (request, server) => {
231
+ const admitted = await admitExtension(request, server, false);
232
+ if (!admitted.ok) return admitted.response;
233
+ try {
234
+ const pairId = new URL(request.url).pathname.split("/").at(-1) ?? "";
235
+ if (!isClipperPairId(pairId)) {
236
+ return withClipperCors(
237
+ clipperErrorResponse(
238
+ "CLIPPER_PAIR_NOT_FOUND",
239
+ "Pairing not found",
240
+ 404
241
+ ),
242
+ admitted.value.origin
243
+ );
244
+ }
245
+ const result = pairing.poll(pairId, admitted.value.origin);
246
+ return withClipperCors(
247
+ clipperResponse(
248
+ { schemaVersion: "1.0", ...result },
249
+ pollStatusCode(result)
250
+ ),
251
+ admitted.value.origin
252
+ );
253
+ } finally {
254
+ admitted.value.release();
255
+ }
256
+ };
257
+
258
+ const revoke: ClipperRoute = async (request, server) => {
259
+ const authenticated = await authenticate(request, server, false);
260
+ if (!authenticated.ok) return authenticated.response;
261
+ const { admission, grant } = authenticated.value;
262
+ try {
263
+ const revoked = pairing.revoke(grant);
264
+ return withClipperCors(
265
+ clipperResponse({
266
+ schemaVersion: "1.0",
267
+ grantId: grant.id,
268
+ ...revoked,
269
+ }),
270
+ admission.origin
271
+ );
272
+ } finally {
273
+ admission.release();
274
+ }
275
+ };
276
+
277
+ const preview: ClipperRoute = async (request, server) => {
278
+ const authenticated = await authenticate(request, server, true);
279
+ if (!authenticated.ok) return authenticated.response;
280
+ const { admission, grant } = authenticated.value;
281
+ try {
282
+ const prepared = prepareBrowserClip(admission.body);
283
+ const planned = await planResidentCapture(
284
+ ctxHolder,
285
+ store,
286
+ prepared.captureInput
287
+ );
288
+ if (!planned.ok) {
289
+ return withClipperCors(
290
+ clipperErrorResponse(planned.code, planned.message, planned.status),
291
+ admission.origin
292
+ );
293
+ }
294
+ const payloadDigest = clipperSha256(JSON.stringify(prepared.payload));
295
+ pairing.issuePreview(grant.id, prepared.preview.digest, payloadDigest);
296
+ return withClipperCors(
297
+ clipperResponse({
298
+ schemaVersion: "1.0",
299
+ preview: prepared.preview,
300
+ provenance: prepared.provenance,
301
+ plan: planProjection(planned),
302
+ }),
303
+ admission.origin
304
+ );
305
+ } catch (error) {
306
+ return withClipperCors(
307
+ clipperErrorResponse(
308
+ "CLIPPER_INVALID_REQUEST",
309
+ error instanceof Error ? error.message : "Invalid browser clip",
310
+ 400
311
+ ),
312
+ admission.origin
313
+ );
314
+ } finally {
315
+ admission.release();
316
+ }
317
+ };
318
+
319
+ const capture: ClipperRoute = async (request, server) => {
320
+ const authenticated = await authenticate(request, server, true);
321
+ if (!authenticated.ok) return authenticated.response;
322
+ const { admission, grant } = authenticated.value;
323
+ try {
324
+ return withClipperCors(
325
+ await executeClipperCapture({
326
+ request,
327
+ body: admission.body,
328
+ grantId: grant.id,
329
+ db,
330
+ context: ctxHolder,
331
+ store,
332
+ pairing,
333
+ }),
334
+ admission.origin
335
+ );
336
+ } catch (error) {
337
+ return withClipperCors(
338
+ clipperErrorResponse(
339
+ "CLIPPER_CAPTURE_FAILED",
340
+ error instanceof Error ? error.message : "Browser capture failed",
341
+ 500
342
+ ),
343
+ admission.origin
344
+ );
345
+ } finally {
346
+ admission.release();
347
+ }
348
+ };
349
+
350
+ const preflight =
351
+ (methods: readonly string[], headers?: readonly string[]): ClipperRoute =>
352
+ (request, server) =>
353
+ security.handlePreflight(request, server, {
354
+ origin: { kind: "extension" },
355
+ methods,
356
+ headers,
357
+ });
358
+
359
+ return {
360
+ routes: {
361
+ "/api/clipper/pair/start": {
362
+ POST: startPair,
363
+ OPTIONS: preflight(["POST"]),
364
+ },
365
+ "/api/clipper/pair/csrf": {
366
+ GET: csrf,
367
+ },
368
+ "/api/clipper/pair/approve": {
369
+ POST: approvePair,
370
+ },
371
+ "/api/clipper/pair/:pairId": {
372
+ OPTIONS: preflight(["POST"]),
373
+ POST: pollPair,
374
+ },
375
+ "/api/clipper/revoke": {
376
+ POST: revoke,
377
+ OPTIONS: preflight(["POST"], ["authorization"]),
378
+ },
379
+ "/api/capture/clip/preview": {
380
+ POST: preview,
381
+ OPTIONS: preflight(["POST"]),
382
+ },
383
+ "/api/capture/clip": {
384
+ POST: capture,
385
+ OPTIONS: preflight(["POST"]),
386
+ },
387
+ },
388
+ };
389
+ }
390
+
391
+ export const clipperRoutesForBind = (
392
+ loopback: boolean,
393
+ gateway: ClipperRouteGateway
394
+ ): ClipperRouteGateway["routes"] => (loopback ? gateway.routes : {});
@@ -70,6 +70,10 @@ import {
70
70
  handleVerifyConnector,
71
71
  } from "./routes/api";
72
72
  import { handleChanges, handleDiff, handleImpact } from "./routes/changes";
73
+ import {
74
+ clipperRoutesForBind,
75
+ createClipperRouteGateway,
76
+ } from "./routes/clipper";
73
77
  import { handleGraph, handleGraphQuery } from "./routes/graph";
74
78
  import {
75
79
  handleDocBacklinks,
@@ -104,6 +108,7 @@ export interface ServeResult {
104
108
  interface StartServerDependencies {
105
109
  startBackgroundRuntime?: typeof startBackgroundRuntime;
106
110
  createMcpHttpGateway?: typeof createMcpHttpGateway;
111
+ createClipperRouteGateway?: typeof createClipperRouteGateway;
107
112
  serve?: typeof Bun.serve;
108
113
  handleInstallConnector?: typeof handleInstallConnector;
109
114
  handleDocs?: typeof handleDocs;
@@ -217,6 +222,18 @@ export async function startServer(
217
222
  error: error instanceof Error ? error.message : String(error),
218
223
  };
219
224
  }
225
+ const hasSqliteClipperStore =
226
+ typeof Reflect.get(store, "getRawDb") === "function";
227
+ const clipperGateway = hasSqliteClipperStore
228
+ ? (dependencies.createClipperRouteGateway ?? createClipperRouteGateway)(
229
+ ctxHolder,
230
+ store,
231
+ {
232
+ host: gatewayConfig.host,
233
+ port: gatewayConfig.port,
234
+ }
235
+ )
236
+ : { routes: {} };
220
237
 
221
238
  // Shutdown controller for clean lifecycle
222
239
  const shutdownController = new AbortController();
@@ -247,6 +264,10 @@ export async function startServer(
247
264
  // Static routes - Bun handles HTML bundling and /_bun/* assets automatically
248
265
  routes: {
249
266
  "/mcp": gateway.route,
267
+ ...clipperRoutesForBind(
268
+ isHttpGatewayLoopbackBind(gatewayConfig.host),
269
+ clipperGateway
270
+ ),
250
271
  // SPA routes - all serve the same React app
251
272
  "/": homepage,
252
273
  "/search": homepage,
@@ -258,6 +279,7 @@ export async function startServer(
258
279
  "/traces": homepage,
259
280
  "/ask": homepage,
260
281
  "/graph": homepage,
282
+ "/clipper/pair": homepage,
261
283
 
262
284
  // API routes with CSRF protection wrapper
263
285
  "/api/health": {
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Migration: persisted browser-clipper grants and write idempotency.
3
+ *
4
+ * Pairing codes and bearer tokens never enter SQLite. Only SHA-256 token/key
5
+ * digests, exact extension origins, bounded grant lifetimes, and completed
6
+ * capture receipts are durable across resident restarts.
7
+ *
8
+ * @module src/store/migrations/020-browser-clipper-security
9
+ */
10
+
11
+ import type { Database } from "bun:sqlite";
12
+
13
+ import type { Migration } from "./runner";
14
+
15
+ export const migration: Migration = {
16
+ version: 20,
17
+ name: "browser_clipper_security",
18
+
19
+ up(db: Database): void {
20
+ db.exec(`
21
+ CREATE TABLE clipper_grants (
22
+ id TEXT PRIMARY KEY,
23
+ token_hash TEXT NOT NULL UNIQUE,
24
+ origin TEXT NOT NULL,
25
+ scope TEXT NOT NULL DEFAULT 'capture'
26
+ CHECK (scope = 'capture'),
27
+ created_at_ms INTEGER NOT NULL CHECK (created_at_ms >= 0),
28
+ expires_at_ms INTEGER NOT NULL CHECK (expires_at_ms > created_at_ms),
29
+ revoked_at_ms INTEGER
30
+ CHECK (revoked_at_ms IS NULL OR revoked_at_ms >= created_at_ms),
31
+ CHECK (length(id) BETWEEN 1 AND 128),
32
+ CHECK (
33
+ length(token_hash) = 64
34
+ AND token_hash NOT GLOB '*[^0-9a-f]*'
35
+ ),
36
+ CHECK (
37
+ length(origin) = 51
38
+ AND substr(origin, 1, 19) = 'chrome-extension://'
39
+ AND substr(origin, 20) NOT GLOB '*[^a-p]*'
40
+ )
41
+ );
42
+
43
+ CREATE INDEX idx_clipper_grants_expiry
44
+ ON clipper_grants(expires_at_ms, id);
45
+
46
+ CREATE TABLE clipper_capture_idempotency (
47
+ grant_id TEXT NOT NULL,
48
+ key_hash TEXT NOT NULL,
49
+ request_digest TEXT NOT NULL,
50
+ collection TEXT NOT NULL,
51
+ rel_path TEXT NOT NULL,
52
+ collision_policy_result TEXT NOT NULL
53
+ CHECK (
54
+ collision_policy_result IN (
55
+ 'created',
56
+ 'opened_existing',
57
+ 'created_with_suffix',
58
+ 'overwritten',
59
+ 'conflict'
60
+ )
61
+ ),
62
+ content_hash TEXT NOT NULL,
63
+ clip_identity TEXT NOT NULL,
64
+ state TEXT NOT NULL CHECK (state IN ('pending', 'completed')),
65
+ status_code INTEGER,
66
+ response_json TEXT,
67
+ created_at_ms INTEGER NOT NULL CHECK (created_at_ms >= 0),
68
+ completed_at_ms INTEGER
69
+ CHECK (
70
+ completed_at_ms IS NULL OR completed_at_ms >= created_at_ms
71
+ ),
72
+ PRIMARY KEY (grant_id, key_hash),
73
+ FOREIGN KEY (grant_id)
74
+ REFERENCES clipper_grants(id)
75
+ ON DELETE CASCADE,
76
+ CHECK (
77
+ length(key_hash) = 64
78
+ AND key_hash NOT GLOB '*[^0-9a-f]*'
79
+ ),
80
+ UNIQUE (grant_id, request_digest),
81
+ CHECK (
82
+ length(request_digest) = 64
83
+ AND request_digest NOT GLOB '*[^0-9a-f]*'
84
+ ),
85
+ CHECK (length(CAST(collection AS BLOB)) BETWEEN 1 AND 256),
86
+ CHECK (length(CAST(rel_path AS BLOB)) BETWEEN 1 AND 8192),
87
+ CHECK (
88
+ length(content_hash) = 64
89
+ AND content_hash NOT GLOB '*[^0-9a-f]*'
90
+ ),
91
+ CHECK (
92
+ length(clip_identity) = 64
93
+ AND clip_identity NOT GLOB '*[^0-9a-f]*'
94
+ ),
95
+ CHECK (
96
+ response_json IS NULL
97
+ OR length(CAST(response_json AS BLOB)) <= 2097152
98
+ ),
99
+ CHECK (
100
+ (
101
+ state = 'pending'
102
+ AND status_code IS NULL
103
+ AND response_json IS NULL
104
+ AND completed_at_ms IS NULL
105
+ )
106
+ OR
107
+ (
108
+ state = 'completed'
109
+ AND status_code BETWEEN 200 AND 599
110
+ AND response_json IS NOT NULL
111
+ AND json_valid(response_json)
112
+ AND completed_at_ms IS NOT NULL
113
+ )
114
+ )
115
+ );
116
+
117
+ CREATE INDEX idx_clipper_idempotency_created
118
+ ON clipper_capture_idempotency(created_at_ms, grant_id, key_hash);
119
+ `);
120
+ },
121
+
122
+ down(db: Database): void {
123
+ db.exec("DROP INDEX IF EXISTS idx_clipper_idempotency_created");
124
+ db.exec("DROP TABLE IF EXISTS clipper_capture_idempotency");
125
+ db.exec("DROP INDEX IF EXISTS idx_clipper_grants_expiry");
126
+ db.exec("DROP TABLE IF EXISTS clipper_grants");
127
+ },
128
+ };
@@ -33,6 +33,7 @@ import { migration as m016 } from "./016-saved-capsules";
33
33
  import { migration as m017 } from "./017-document-change-retention-counters";
34
34
  import { migration as m018 } from "./018-saved-capsule-registration-epoch";
35
35
  import { migration as m019 } from "./019-saved-capsule-registration-generation";
36
+ import { migration as m020 } from "./020-browser-clipper-security";
36
37
 
37
38
  /** All migrations in order */
38
39
  export const migrations = [
@@ -55,4 +56,5 @@ export const migrations = [
55
56
  m017,
56
57
  m018,
57
58
  m019,
59
+ m020,
58
60
  ];
@@ -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" };