@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.
- package/README.md +26 -12
- package/assets/skill/SKILL.md +37 -19
- package/assets/skill/recipes/capture-and-file.md +20 -5
- package/browser-extension/artifacts/gno-browser-clipper-v1.25.1.zip +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.25.1.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 +141 -0
- package/spec/db/schema.sql +101 -0
- 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/setup-activation-result.schema.json +456 -0
- package/spec/output-schemas/setup-command-result.schema.json +93 -0
- package/spec/output-schemas/setup-receipt.schema.json +258 -0
- package/spec/output-schemas/setup-semantic-receipt.schema.json +195 -0
- package/src/cli/commands/completion/scripts.ts +2 -0
- package/src/cli/commands/embed.ts +7 -2
- package/src/cli/commands/setup-activation.ts +324 -0
- package/src/cli/commands/setup-semantic.ts +591 -0
- package/src/cli/commands/setup.ts +410 -0
- package/src/cli/program.ts +64 -0
- package/src/cli/setup-semantic-worker.ts +177 -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 +94 -64
- package/src/core/file-lock.ts +89 -36
- package/src/core/folder-setup-planning.ts +453 -0
- package/src/core/folder-setup.ts +490 -0
- package/src/core/setup-activation.ts +309 -0
- package/src/core/setup-receipt.ts +321 -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/connectors.ts +29 -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/routes/api.ts +19 -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/index.ts +2 -0
- package/src/store/sqlite/clipper-store-types.ts +104 -0
- 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 : {});
|
package/src/serve/server.ts
CHANGED
|
@@ -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" };
|