@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.
- package/README.md +18 -4
- package/assets/skill/SKILL.md +40 -17
- package/assets/skill/recipes/capture-and-file.md +20 -5
- package/browser-extension/artifacts/gno-browser-clipper-v1.26.0.zip +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.26.0.zip.sha256 +1 -0
- package/browser-extension/dist/PRIVACY.md +55 -0
- package/browser-extension/dist/chunk-vn5f663b.js +50 -0
- package/browser-extension/dist/chunk-ydfx5d7p.css +1 -0
- package/browser-extension/dist/content.js +1 -0
- package/browser-extension/dist/manifest.json +25 -0
- package/browser-extension/dist/preview.html +13 -0
- package/browser-extension/dist/service-worker.js +40 -0
- package/package.json +13 -3
- package/spec/cli.md +196 -4
- package/spec/db/schema.sql +102 -1
- package/spec/mcp.md +10 -0
- package/spec/output-schemas/browser-clip-preview.schema.json +83 -0
- package/spec/output-schemas/browser-clip.schema.json +586 -0
- package/spec/output-schemas/capture-receipt.schema.json +22 -1
- package/spec/output-schemas/clipper-csrf.schema.json +12 -0
- package/spec/output-schemas/clipper-error.schema.json +46 -0
- package/spec/output-schemas/clipper-pair-approval.schema.json +17 -0
- package/spec/output-schemas/clipper-pair-start.schema.json +26 -0
- package/spec/output-schemas/clipper-pair-status.schema.json +46 -0
- package/spec/output-schemas/clipper-revoke.schema.json +28 -0
- package/spec/output-schemas/mcp-capture-result.schema.json +12 -1
- package/spec/output-schemas/project-profile-apply.schema.json +209 -0
- package/spec/output-schemas/project-profile-command.schema.json +160 -0
- package/spec/output-schemas/query-diagnose.schema.json +7 -1
- package/spec/output-schemas/setup-profile-result.schema.json +87 -0
- package/spec/project-profile.schema.json +301 -0
- package/src/cli/commands/collection/add.ts +39 -45
- package/src/cli/commands/collection/remove.ts +28 -28
- package/src/cli/commands/collection/rename.ts +55 -73
- package/src/cli/commands/context/add.ts +37 -26
- package/src/cli/commands/context/rm.ts +47 -20
- package/src/cli/commands/init.ts +55 -125
- package/src/cli/commands/models/use.ts +43 -38
- package/src/cli/commands/profile-apply.ts +334 -0
- package/src/cli/commands/profile.ts +409 -0
- package/src/cli/commands/setup-activation.ts +205 -54
- package/src/cli/commands/setup-profile.ts +223 -0
- package/src/cli/commands/setup.ts +3 -0
- package/src/cli/program.ts +110 -9
- package/src/config/index.ts +3 -0
- package/src/config/project-profile.ts +367 -0
- package/src/config/saver.ts +16 -7
- package/src/config/types.ts +42 -0
- package/src/core/browser-clip-provenance.ts +139 -0
- package/src/core/browser-clip.ts +473 -0
- package/src/core/capture-write.ts +5 -0
- package/src/core/capture.ts +75 -18
- package/src/core/config-mutation.ts +138 -76
- package/src/core/config-write-lock.ts +89 -0
- package/src/core/context-identity.ts +16 -0
- package/src/core/context-resolver.ts +2 -12
- package/src/core/file-lock.ts +20 -6
- package/src/core/folder-setup-planning.ts +6 -21
- package/src/core/folder-setup.ts +30 -2
- package/src/core/path-rules.ts +53 -0
- package/src/core/project-affinity-surface.ts +102 -7
- package/src/core/project-profile-apply-state.ts +268 -0
- package/src/core/project-profile-apply-validation.ts +95 -0
- package/src/core/project-profile-apply.ts +408 -0
- package/src/core/project-profile-canonical.ts +71 -0
- package/src/core/project-profile-diff.ts +302 -0
- package/src/core/project-profile-discovery.ts +519 -0
- package/src/core/project-profile-file.ts +37 -0
- package/src/core/project-profile-parser.ts +98 -0
- package/src/core/project-profile.ts +490 -0
- package/src/ingestion/walker.ts +85 -44
- package/src/llm/cache.ts +21 -0
- package/src/serve/capture-service.ts +420 -0
- package/src/serve/clipper-body.ts +62 -0
- package/src/serve/clipper-capture.ts +248 -0
- package/src/serve/clipper-contract.ts +57 -0
- package/src/serve/clipper-idempotency.ts +35 -0
- package/src/serve/clipper-pairing.ts +297 -0
- package/src/serve/clipper-security-errors.ts +23 -0
- package/src/serve/clipper-security.ts +449 -0
- package/src/serve/config-sync.ts +2 -2
- package/src/serve/public/app.tsx +8 -1
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/index.html +1 -0
- package/src/serve/public/lib/clipper-approval.ts +206 -0
- package/src/serve/public/pages/ClipperPairing.tsx +210 -0
- package/src/serve/resident-runtime.ts +1 -0
- package/src/serve/routes/api.ts +20 -115
- package/src/serve/routes/clipper.ts +394 -0
- package/src/serve/server.ts +22 -0
- package/src/store/migrations/020-browser-clipper-security.ts +128 -0
- package/src/store/migrations/021-multi-context-identity.ts +37 -0
- package/src/store/migrations/index.ts +4 -0
- package/src/store/sqlite/adapter.ts +83 -0
- package/src/store/sqlite/clipper-store-types.ts +104 -0
- 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
|
+
}
|