@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
@@ -0,0 +1,248 @@
1
+ /** Browser-clipper write orchestration and crash-safe idempotency recovery. */
2
+
3
+ import type { Database } from "bun:sqlite";
4
+
5
+ import type { PreparedBrowserClip } from "../core/browser-clip";
6
+ import type { SqliteAdapter } from "../store/sqlite/adapter";
7
+ import type { ClipperIdempotencyReplay } from "../store/sqlite/clipper-store-types";
8
+ import type { ResidentCaptureContext } from "./capture-service";
9
+ import type { ClipperPairingService } from "./clipper-pairing";
10
+
11
+ import { prepareBrowserClip } from "../core/browser-clip";
12
+ import {
13
+ claimClipperIdempotency,
14
+ inspectClipperIdempotency,
15
+ } from "../store/sqlite/clipper-store";
16
+ import {
17
+ browserClipIdempotencyPlan,
18
+ executeResidentCapturePlan,
19
+ planResidentCapture,
20
+ recoverPendingResidentBrowserClip,
21
+ } from "./capture-service";
22
+ import {
23
+ clipperCaptureWriteSchema,
24
+ clipperErrorResponse,
25
+ clipperSha256,
26
+ isClipperIdempotencyKey,
27
+ } from "./clipper-contract";
28
+ import { completeClipperHttpIdempotency } from "./clipper-idempotency";
29
+
30
+ interface ExecuteClipperCaptureInput {
31
+ request: Request;
32
+ body: unknown;
33
+ grantId: string;
34
+ db: Database;
35
+ context: ResidentCaptureContext;
36
+ store: SqliteAdapter;
37
+ pairing: ClipperPairingService;
38
+ }
39
+
40
+ const inFlightRequests = new Set<string>();
41
+
42
+ const inFlightKey = (grantId: string, requestDigest: string): string =>
43
+ `${grantId}\0${requestDigest}`;
44
+
45
+ const pendingResponse = (): Response =>
46
+ clipperErrorResponse(
47
+ "CLIPPER_IDEMPOTENCY_PENDING",
48
+ "The matching browser capture is still in progress",
49
+ 409
50
+ );
51
+
52
+ const replayResponse = (replay: ClipperIdempotencyReplay): Response =>
53
+ new Response(replay.responseJson, {
54
+ status: replay.statusCode,
55
+ headers: {
56
+ "Content-Type": "application/json",
57
+ "Idempotent-Replay": "true",
58
+ },
59
+ });
60
+
61
+ const completeResponse = (
62
+ input: ExecuteClipperCaptureInput,
63
+ keyHash: string,
64
+ requestDigest: string,
65
+ body: unknown,
66
+ status: number
67
+ ): Response =>
68
+ completeClipperHttpIdempotency(input.db, {
69
+ grantId: input.grantId,
70
+ keyHash,
71
+ requestDigest,
72
+ body:
73
+ body !== null && typeof body === "object" && !Array.isArray(body)
74
+ ? { schemaVersion: "1.0", ...body }
75
+ : body,
76
+ status,
77
+ });
78
+
79
+ const executeAndComplete = async (
80
+ input: ExecuteClipperCaptureInput,
81
+ keyHash: string,
82
+ requestDigest: string,
83
+ planned: Parameters<typeof executeResidentCapturePlan>[2]
84
+ ): Promise<Response> => {
85
+ const result = await executeResidentCapturePlan(
86
+ input.context,
87
+ input.store,
88
+ planned
89
+ );
90
+ return completeResponse(
91
+ input,
92
+ keyHash,
93
+ requestDigest,
94
+ result.body,
95
+ result.status
96
+ );
97
+ };
98
+
99
+ const recoverPending = async (
100
+ input: ExecuteClipperCaptureInput,
101
+ prepared: PreparedBrowserClip,
102
+ keyHash: string,
103
+ requestDigest: string,
104
+ plan: Parameters<typeof recoverPendingResidentBrowserClip>[3]
105
+ ): Promise<Response> => {
106
+ const activeKey = inFlightKey(input.grantId, requestDigest);
107
+ if (inFlightRequests.has(activeKey)) return pendingResponse();
108
+ inFlightRequests.add(activeKey);
109
+ try {
110
+ const recovered = await recoverPendingResidentBrowserClip(
111
+ input.context,
112
+ input.store,
113
+ prepared,
114
+ plan
115
+ );
116
+ if (recovered.status === "conflict") {
117
+ return clipperErrorResponse(
118
+ "CLIPPER_IDEMPOTENCY_RECOVERY_CONFLICT",
119
+ recovered.message,
120
+ 409
121
+ );
122
+ }
123
+ if (recovered.status === "execute") {
124
+ return executeAndComplete(
125
+ input,
126
+ keyHash,
127
+ requestDigest,
128
+ recovered.planned
129
+ );
130
+ }
131
+ return completeResponse(
132
+ input,
133
+ keyHash,
134
+ requestDigest,
135
+ recovered.body,
136
+ recovered.statusCode
137
+ );
138
+ } finally {
139
+ inFlightRequests.delete(activeKey);
140
+ }
141
+ };
142
+
143
+ export const executeClipperCapture = async (
144
+ input: ExecuteClipperCaptureInput
145
+ ): Promise<Response> => {
146
+ const parsed = clipperCaptureWriteSchema.safeParse(input.body);
147
+ const idempotencyKey = input.request.headers.get("idempotency-key") ?? "";
148
+ if (!parsed.success || !isClipperIdempotencyKey(idempotencyKey)) {
149
+ return clipperErrorResponse(
150
+ "CLIPPER_INVALID_REQUEST",
151
+ "A valid preview and Idempotency-Key are required",
152
+ 400
153
+ );
154
+ }
155
+
156
+ const prepared = prepareBrowserClip(parsed.data.payload);
157
+ if (prepared.preview.digest !== parsed.data.previewDigest) {
158
+ return clipperErrorResponse(
159
+ "CLIPPER_PREVIEW_MISMATCH",
160
+ "Browser clip changed after preview",
161
+ 409
162
+ );
163
+ }
164
+
165
+ const payloadDigest = clipperSha256(JSON.stringify(prepared.payload));
166
+ const requestDigest = clipperSha256(
167
+ `${parsed.data.previewDigest}\0${payloadDigest}`
168
+ );
169
+ const keyHash = clipperSha256(idempotencyKey);
170
+ const inspected = inspectClipperIdempotency(input.db, {
171
+ grantId: input.grantId,
172
+ keyHash,
173
+ requestDigest,
174
+ });
175
+ if (inspected.status === "replay") {
176
+ return replayResponse(inspected.replay);
177
+ }
178
+ if (inspected.status === "conflict") {
179
+ return clipperErrorResponse(
180
+ "CLIPPER_IDEMPOTENCY_CONFLICT",
181
+ "Idempotency key was used for a different browser capture",
182
+ 409
183
+ );
184
+ }
185
+ if (inspected.status === "pending") {
186
+ return recoverPending(
187
+ input,
188
+ prepared,
189
+ keyHash,
190
+ requestDigest,
191
+ inspected.pending.plan
192
+ );
193
+ }
194
+
195
+ if (
196
+ input.pairing.verifyPreview(
197
+ input.grantId,
198
+ parsed.data.previewDigest,
199
+ payloadDigest
200
+ ) !== "matched"
201
+ ) {
202
+ return clipperErrorResponse(
203
+ "CLIPPER_PREVIEW_REQUIRED",
204
+ "Create a fresh matching preview before capture",
205
+ 409
206
+ );
207
+ }
208
+
209
+ const planned = await planResidentCapture(
210
+ input.context,
211
+ input.store,
212
+ prepared.captureInput
213
+ );
214
+ if (!planned.ok) {
215
+ return clipperErrorResponse(planned.code, planned.message, planned.status);
216
+ }
217
+ const claim = claimClipperIdempotency(input.db, {
218
+ grantId: input.grantId,
219
+ keyHash,
220
+ requestDigest,
221
+ plan: browserClipIdempotencyPlan(planned),
222
+ nowMs: Date.now(),
223
+ });
224
+ if (claim.status === "replay") return replayResponse(claim.replay);
225
+ if (claim.status === "pending") {
226
+ return recoverPending(
227
+ input,
228
+ prepared,
229
+ keyHash,
230
+ requestDigest,
231
+ claim.pending.plan
232
+ );
233
+ }
234
+ if (claim.status !== "claimed") {
235
+ return clipperErrorResponse(
236
+ `CLIPPER_IDEMPOTENCY_${claim.status.toUpperCase()}`,
237
+ "Idempotency key cannot be claimed",
238
+ 409
239
+ );
240
+ }
241
+ const activeKey = inFlightKey(input.grantId, requestDigest);
242
+ inFlightRequests.add(activeKey);
243
+ try {
244
+ return await executeAndComplete(input, keyHash, requestDigest, planned);
245
+ } finally {
246
+ inFlightRequests.delete(activeKey);
247
+ }
248
+ };
@@ -0,0 +1,57 @@
1
+ /** Closed request parsing and stable responses for browser-clipper routes. */
2
+
3
+ import { z } from "zod";
4
+
5
+ const PAIR_ID_PATTERN = /^[a-f0-9]{64}$/u;
6
+ const IDEMPOTENCY_KEY_PATTERN = /^[\x21-\x7e]{1,256}$/u;
7
+
8
+ export const clipperApprovalSchema = z
9
+ .object({
10
+ pairId: z.string().regex(PAIR_ID_PATTERN),
11
+ pairingCode: z.string().regex(/^\d{8}$/u),
12
+ })
13
+ .strict();
14
+
15
+ export const clipperCaptureWriteSchema = z
16
+ .object({
17
+ payload: z.unknown(),
18
+ previewDigest: z.string().regex(/^[a-f0-9]{64}$/u),
19
+ })
20
+ .strict();
21
+
22
+ export const isClipperPairId = (value: string): boolean =>
23
+ PAIR_ID_PATTERN.test(value);
24
+
25
+ export const isClipperIdempotencyKey = (value: string): boolean =>
26
+ IDEMPOTENCY_KEY_PATTERN.test(value);
27
+
28
+ export const clipperLoopbackAuthority = (
29
+ host: string,
30
+ port: number
31
+ ): string => {
32
+ const authorityHost = host === "::1" || host === "[::1]" ? "[::1]" : host;
33
+ return `${authorityHost}:${port}`;
34
+ };
35
+
36
+ export const clipperBearerToken = (request: Request): string | null =>
37
+ request.headers
38
+ .get("authorization")
39
+ ?.match(/^Bearer (?<token>[a-f0-9]{64})$/u)?.groups?.token ?? null;
40
+
41
+ export const clipperSha256 = (value: string): string =>
42
+ new Bun.CryptoHasher("sha256").update(value).digest("hex");
43
+
44
+ export const clipperResponse = (body: unknown, status = 200): Response =>
45
+ Response.json(body, {
46
+ status,
47
+ headers: {
48
+ "Cache-Control": "no-store",
49
+ "X-Content-Type-Options": "nosniff",
50
+ },
51
+ });
52
+
53
+ export const clipperErrorResponse = (
54
+ code: string,
55
+ message: string,
56
+ status: number
57
+ ): Response => clipperResponse({ error: { code, message } }, status);
@@ -0,0 +1,35 @@
1
+ /** HTTP receipt persistence for browser-clipper idempotent writes. */
2
+
3
+ import type { Database } from "bun:sqlite";
4
+
5
+ import { completeClipperIdempotency } from "../store/sqlite/clipper-store";
6
+
7
+ export const completeClipperHttpIdempotency = (
8
+ db: Database,
9
+ input: {
10
+ grantId: string;
11
+ keyHash: string;
12
+ requestDigest: string;
13
+ body: unknown;
14
+ status: number;
15
+ }
16
+ ): Response => {
17
+ const responseJson = JSON.stringify(input.body);
18
+ const completed = completeClipperIdempotency(db, {
19
+ grantId: input.grantId,
20
+ keyHash: input.keyHash,
21
+ requestDigest: input.requestDigest,
22
+ responseJson,
23
+ statusCode: input.status,
24
+ nowMs: Date.now(),
25
+ });
26
+ if (completed.status === "conflict" || completed.status === "not_found") {
27
+ throw new Error(
28
+ `Browser clip idempotency completion failed: ${completed.status}`
29
+ );
30
+ }
31
+ return new Response(responseJson, {
32
+ status: input.status,
33
+ headers: { "Content-Type": "application/json" },
34
+ });
35
+ };
@@ -0,0 +1,297 @@
1
+ /** In-memory pairing and preview state for the loopback browser clipper. */
2
+
3
+ import type { Database } from "bun:sqlite";
4
+
5
+ import type {
6
+ AuthorizeClipperGrantResult,
7
+ ClipperGrant,
8
+ } from "../store/sqlite/clipper-store";
9
+
10
+ import {
11
+ authorizeClipperGrant,
12
+ createClipperGrant,
13
+ revokeClipperGrant,
14
+ } from "../store/sqlite/clipper-store";
15
+
16
+ const EXTENSION_ORIGIN_PATTERN = /^chrome-extension:\/\/[a-p]{32}$/u;
17
+ const PAIR_TTL_MS = 5 * 60 * 1000;
18
+ const GRANT_TTL_MS = 30 * 24 * 60 * 60 * 1000;
19
+ const PREVIEW_TTL_MS = 5 * 60 * 1000;
20
+ const MAX_PENDING_PAIRS = 128;
21
+ const MAX_PREVIEWS = 512;
22
+ const MAX_APPROVAL_ATTEMPTS = 5;
23
+
24
+ interface PendingPair {
25
+ approvalAttempts: number;
26
+ codeHash: string;
27
+ expiresAtMs: number;
28
+ origin: string;
29
+ state: "pending" | "approved" | "consumed";
30
+ grant?: {
31
+ id: string;
32
+ token: string;
33
+ expiresAtMs: number;
34
+ };
35
+ }
36
+
37
+ interface IssuedPreview {
38
+ expiresAtMs: number;
39
+ payloadDigest: string;
40
+ }
41
+
42
+ export interface ClipperPairingOptions {
43
+ now?: () => number;
44
+ randomBytes?: (length: number) => Uint8Array;
45
+ pairTtlMs?: number;
46
+ grantTtlMs?: number;
47
+ previewTtlMs?: number;
48
+ }
49
+
50
+ export type ClipperPairPollResult =
51
+ | { status: "pending"; expiresAt: string }
52
+ | {
53
+ status: "approved";
54
+ grantId: string;
55
+ grantToken: string;
56
+ expiresAt: string;
57
+ }
58
+ | { status: "consumed" }
59
+ | { status: "expired" }
60
+ | { status: "not_found" }
61
+ | { status: "origin_mismatch" };
62
+
63
+ const sha256 = (value: string): string =>
64
+ new Bun.CryptoHasher("sha256").update(value).digest("hex");
65
+
66
+ const randomHex = (
67
+ length: number,
68
+ randomBytes: (length: number) => Uint8Array
69
+ ): string =>
70
+ [...randomBytes(length)]
71
+ .map((byte) => byte.toString(16).padStart(2, "0"))
72
+ .join("");
73
+
74
+ const defaultRandomBytes = (length: number): Uint8Array =>
75
+ crypto.getRandomValues(new Uint8Array(length));
76
+
77
+ const constantTimeEqual = (left: string, right: string): boolean => {
78
+ if (left.length !== right.length) return false;
79
+ let difference = 0;
80
+ for (let index = 0; index < left.length; index += 1) {
81
+ difference |= left.charCodeAt(index) ^ right.charCodeAt(index);
82
+ }
83
+ return difference === 0;
84
+ };
85
+
86
+ const pairCode = (randomBytes: (length: number) => Uint8Array): string => {
87
+ const bytes = randomBytes(4);
88
+ const value =
89
+ (((bytes[0] ?? 0) << 24) |
90
+ ((bytes[1] ?? 0) << 16) |
91
+ ((bytes[2] ?? 0) << 8) |
92
+ (bytes[3] ?? 0)) >>>
93
+ 0;
94
+ return String(value % 100_000_000).padStart(8, "0");
95
+ };
96
+
97
+ export const isClipperExtensionOrigin = (origin: string): boolean =>
98
+ EXTENSION_ORIGIN_PATTERN.test(origin);
99
+
100
+ export class ClipperPairingService {
101
+ readonly #db: Database;
102
+ readonly #now: () => number;
103
+ readonly #randomBytes: (length: number) => Uint8Array;
104
+ readonly #pairTtlMs: number;
105
+ readonly #grantTtlMs: number;
106
+ readonly #previewTtlMs: number;
107
+ readonly #pairs = new Map<string, PendingPair>();
108
+ readonly #previews = new Map<string, IssuedPreview>();
109
+ readonly #csrfToken: string;
110
+
111
+ constructor(db: Database, options: ClipperPairingOptions = {}) {
112
+ this.#db = db;
113
+ this.#now = options.now ?? Date.now;
114
+ this.#randomBytes = options.randomBytes ?? defaultRandomBytes;
115
+ this.#pairTtlMs = options.pairTtlMs ?? PAIR_TTL_MS;
116
+ this.#grantTtlMs = options.grantTtlMs ?? GRANT_TTL_MS;
117
+ this.#previewTtlMs = options.previewTtlMs ?? PREVIEW_TTL_MS;
118
+ this.#csrfToken = randomHex(32, this.#randomBytes);
119
+ }
120
+
121
+ get csrfToken(): string {
122
+ return this.#csrfToken;
123
+ }
124
+
125
+ start(origin: string): {
126
+ pairId: string;
127
+ pairingCode: string;
128
+ expiresAt: string;
129
+ } {
130
+ if (!isClipperExtensionOrigin(origin)) {
131
+ throw new TypeError("Invalid browser extension origin");
132
+ }
133
+ this.#prune();
134
+ if (this.#pairs.size >= MAX_PENDING_PAIRS) {
135
+ throw new Error("Too many pending browser clipper pairings");
136
+ }
137
+ const pairId = randomHex(32, this.#randomBytes);
138
+ const code = pairCode(this.#randomBytes);
139
+ const expiresAtMs = this.#now() + this.#pairTtlMs;
140
+ this.#pairs.set(pairId, {
141
+ approvalAttempts: 0,
142
+ codeHash: sha256(`${pairId}\0${origin}\0${code}`),
143
+ expiresAtMs,
144
+ origin,
145
+ state: "pending",
146
+ });
147
+ return {
148
+ pairId,
149
+ pairingCode: code,
150
+ expiresAt: new Date(expiresAtMs).toISOString(),
151
+ };
152
+ }
153
+
154
+ approve(
155
+ pairId: string,
156
+ pairingCode: string
157
+ ):
158
+ | { status: "approved"; origin: string; expiresAt: string }
159
+ | { status: "not_found" | "expired" | "invalid_code" | "already_used" } {
160
+ const pair = this.#pairs.get(pairId);
161
+ if (!pair) return { status: "not_found" };
162
+ const nowMs = this.#now();
163
+ if (nowMs >= pair.expiresAtMs) {
164
+ this.#pairs.delete(pairId);
165
+ return { status: "expired" };
166
+ }
167
+ if (pair.state !== "pending") return { status: "already_used" };
168
+ pair.approvalAttempts += 1;
169
+ const candidate = sha256(`${pairId}\0${pair.origin}\0${pairingCode}`);
170
+ if (!constantTimeEqual(candidate, pair.codeHash)) {
171
+ if (pair.approvalAttempts >= MAX_APPROVAL_ATTEMPTS) {
172
+ this.#pairs.delete(pairId);
173
+ }
174
+ return { status: "invalid_code" };
175
+ }
176
+
177
+ const grantId = crypto.randomUUID();
178
+ const token = randomHex(32, this.#randomBytes);
179
+ const expiresAtMs = nowMs + this.#grantTtlMs;
180
+ const created = createClipperGrant(this.#db, {
181
+ id: grantId,
182
+ tokenHash: sha256(token),
183
+ origin: pair.origin,
184
+ createdAtMs: nowMs,
185
+ expiresAtMs,
186
+ });
187
+ if (created.status !== "created") {
188
+ throw new Error("Failed to create browser clipper grant");
189
+ }
190
+ pair.state = "approved";
191
+ pair.grant = { id: grantId, token, expiresAtMs };
192
+ return {
193
+ status: "approved",
194
+ origin: pair.origin,
195
+ expiresAt: new Date(expiresAtMs).toISOString(),
196
+ };
197
+ }
198
+
199
+ poll(pairId: string, origin: string): ClipperPairPollResult {
200
+ const pair = this.#pairs.get(pairId);
201
+ if (!pair) return { status: "not_found" };
202
+ if (pair.origin !== origin) return { status: "origin_mismatch" };
203
+ const nowMs = this.#now();
204
+ if (nowMs >= pair.expiresAtMs) {
205
+ this.#pairs.delete(pairId);
206
+ return { status: "expired" };
207
+ }
208
+ if (pair.state === "pending") {
209
+ return {
210
+ status: "pending",
211
+ expiresAt: new Date(pair.expiresAtMs).toISOString(),
212
+ };
213
+ }
214
+ if (pair.state === "consumed" || !pair.grant) {
215
+ return { status: "consumed" };
216
+ }
217
+ const grant = pair.grant;
218
+ pair.state = "consumed";
219
+ pair.grant = undefined;
220
+ return {
221
+ status: "approved",
222
+ grantId: grant.id,
223
+ grantToken: grant.token,
224
+ expiresAt: new Date(grant.expiresAtMs).toISOString(),
225
+ };
226
+ }
227
+
228
+ authorize(token: string, origin: string): AuthorizeClipperGrantResult {
229
+ return authorizeClipperGrant(this.#db, sha256(token), origin, this.#now());
230
+ }
231
+
232
+ revoke(
233
+ grant: ClipperGrant
234
+ ):
235
+ | { status: "revoked"; revokedAt: string }
236
+ | { status: "already_revoked" | "expired" | "not_found" } {
237
+ const result = revokeClipperGrant(this.#db, grant.id, this.#now());
238
+ if (result.status !== "revoked") return { status: result.status };
239
+ return {
240
+ status: "revoked",
241
+ revokedAt: new Date(
242
+ result.grant.revokedAtMs ?? this.#now()
243
+ ).toISOString(),
244
+ };
245
+ }
246
+
247
+ issuePreview(
248
+ grantId: string,
249
+ previewDigest: string,
250
+ payloadDigest: string
251
+ ): void {
252
+ this.#prune();
253
+ if (this.#previews.size >= MAX_PREVIEWS) {
254
+ const oldest = this.#previews.keys().next().value;
255
+ if (oldest !== undefined) this.#previews.delete(oldest);
256
+ }
257
+ this.#previews.set(`${grantId}:${previewDigest}`, {
258
+ expiresAtMs: this.#now() + this.#previewTtlMs,
259
+ payloadDigest,
260
+ });
261
+ }
262
+
263
+ consumePreview(
264
+ grantId: string,
265
+ previewDigest: string,
266
+ payloadDigest: string
267
+ ): "matched" | "missing" | "expired" | "mismatch" {
268
+ const key = `${grantId}:${previewDigest}`;
269
+ const preview = this.#previews.get(key);
270
+ if (!preview) return "missing";
271
+ this.#previews.delete(key);
272
+ if (this.#now() >= preview.expiresAtMs) return "expired";
273
+ return preview.payloadDigest === payloadDigest ? "matched" : "mismatch";
274
+ }
275
+
276
+ verifyPreview(
277
+ grantId: string,
278
+ previewDigest: string,
279
+ payloadDigest: string
280
+ ): "matched" | "missing" | "expired" | "mismatch" {
281
+ const preview = this.#previews.get(`${grantId}:${previewDigest}`);
282
+ if (!preview) return "missing";
283
+ if (this.#now() >= preview.expiresAtMs) return "expired";
284
+ return preview.payloadDigest === payloadDigest ? "matched" : "mismatch";
285
+ }
286
+
287
+ #prune(): void {
288
+ const nowMs = this.#now();
289
+ for (const [id, pair] of this.#pairs) {
290
+ if (nowMs < pair.expiresAtMs) continue;
291
+ this.#pairs.delete(id);
292
+ }
293
+ for (const [key, preview] of this.#previews) {
294
+ if (nowMs >= preview.expiresAtMs) this.#previews.delete(key);
295
+ }
296
+ }
297
+ }
@@ -0,0 +1,23 @@
1
+ /** Stable closed failures for the browser-clipper transport boundary. */
2
+
3
+ const SECURITY_ERRORS = {
4
+ CLIPPER_ABORTED: { message: "Clipper request aborted", status: 503 },
5
+ CLIPPER_BODY_TOO_LARGE: {
6
+ message: "Clipper request body too large",
7
+ status: 413,
8
+ },
9
+ CLIPPER_BUSY: { message: "Clipper request queue is full", status: 429 },
10
+ CLIPPER_FORBIDDEN: { message: "Forbidden", status: 403 },
11
+ CLIPPER_INVALID_JSON: { message: "Invalid JSON body", status: 400 },
12
+ CLIPPER_RATE_LIMITED: { message: "Too many clipper requests", status: 429 },
13
+ } as const;
14
+
15
+ export function clipperSecurityErrorResponse(
16
+ code: keyof typeof SECURITY_ERRORS
17
+ ): Response {
18
+ const detail = SECURITY_ERRORS[code];
19
+ return Response.json(
20
+ { error: { code, message: detail.message } },
21
+ { status: detail.status }
22
+ );
23
+ }