@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,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared resident capture planning and write execution.
|
|
3
|
+
*
|
|
4
|
+
* Browser clipping and the ordinary REST capture route both use this service so
|
|
5
|
+
* collision, provenance, atomic-write, sync, and receipt semantics cannot drift.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// node:fs/promises structure operations have no Bun equivalent.
|
|
9
|
+
import { mkdir } from "node:fs/promises";
|
|
10
|
+
// node:path has no Bun path utilities.
|
|
11
|
+
import { basename, dirname, join as pathJoin } from "node:path";
|
|
12
|
+
|
|
13
|
+
import type { Collection, Config } from "../config/types";
|
|
14
|
+
import type { PreparedBrowserClip } from "../core/browser-clip";
|
|
15
|
+
import type { CaptureInput, CapturePlan, CaptureSource } from "../core/capture";
|
|
16
|
+
import type { JobManager } from "../core/job-manager";
|
|
17
|
+
import type { SqliteAdapter } from "../store/sqlite/adapter";
|
|
18
|
+
import type { ClipperIdempotencyPlan } from "../store/sqlite/clipper-store-types";
|
|
19
|
+
import type { DocumentEventBus } from "./doc-events";
|
|
20
|
+
import type { EmbedScheduler } from "./embed-scheduler";
|
|
21
|
+
import type { CollectionWatchService } from "./watch-service";
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
buildCaptureReceipt,
|
|
25
|
+
extractCaptureSourceFromFrontmatter,
|
|
26
|
+
hashCaptureContent,
|
|
27
|
+
listCaptureDiskRelPaths,
|
|
28
|
+
planCapture,
|
|
29
|
+
} from "../core/capture";
|
|
30
|
+
import { writeCapturePlanFile } from "../core/capture-write";
|
|
31
|
+
import { recordContentMutation } from "../core/mutation-generations";
|
|
32
|
+
import {
|
|
33
|
+
type CollectionSyncResult,
|
|
34
|
+
defaultSyncService,
|
|
35
|
+
type SyncResult,
|
|
36
|
+
withContentTypeRules,
|
|
37
|
+
} from "../ingestion";
|
|
38
|
+
import { stripFrontmatter } from "../ingestion/frontmatter";
|
|
39
|
+
import { startJob } from "./jobs";
|
|
40
|
+
|
|
41
|
+
export interface ResidentCaptureContext {
|
|
42
|
+
config: Config;
|
|
43
|
+
scheduler: EmbedScheduler | null;
|
|
44
|
+
eventBus: DocumentEventBus | null;
|
|
45
|
+
watchService: CollectionWatchService | null;
|
|
46
|
+
jobManager?: JobManager;
|
|
47
|
+
markContentMutation?: () => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type ResidentCapturePlanResult =
|
|
51
|
+
| {
|
|
52
|
+
ok: true;
|
|
53
|
+
collection: Collection;
|
|
54
|
+
fullPath: string;
|
|
55
|
+
plan: CapturePlan;
|
|
56
|
+
}
|
|
57
|
+
| {
|
|
58
|
+
ok: false;
|
|
59
|
+
code: "NOT_FOUND" | "RUNTIME" | "VALIDATION";
|
|
60
|
+
message: string;
|
|
61
|
+
status: number;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const listCollectionRelPaths = async (
|
|
65
|
+
store: Pick<SqliteAdapter, "listDocuments">,
|
|
66
|
+
collection: string
|
|
67
|
+
): Promise<string[]> => {
|
|
68
|
+
const result = await store.listDocuments(collection);
|
|
69
|
+
if (!result.ok) throw new Error(result.error.message);
|
|
70
|
+
return result.value.map((entry) => entry.relPath);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const readCandidateClipIdentity = async (
|
|
74
|
+
collection: Collection,
|
|
75
|
+
relPath: string
|
|
76
|
+
): Promise<string | null> => {
|
|
77
|
+
const file = Bun.file(pathJoin(collection.path, relPath));
|
|
78
|
+
if (!(await file.exists())) return null;
|
|
79
|
+
try {
|
|
80
|
+
return (
|
|
81
|
+
extractCaptureSourceFromFrontmatter(await file.text()).browserClip
|
|
82
|
+
?.clipIdentity ?? null
|
|
83
|
+
);
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const planResidentCapture = async (
|
|
90
|
+
context: ResidentCaptureContext,
|
|
91
|
+
store: SqliteAdapter,
|
|
92
|
+
input: CaptureInput,
|
|
93
|
+
now?: Date
|
|
94
|
+
): Promise<ResidentCapturePlanResult> => {
|
|
95
|
+
const collection = context.config.collections.find(
|
|
96
|
+
(candidate) =>
|
|
97
|
+
candidate.name.toLowerCase() === input.collection.toLowerCase()
|
|
98
|
+
);
|
|
99
|
+
if (!collection) {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
code: "NOT_FOUND",
|
|
103
|
+
message: `Collection not found: ${input.collection}`,
|
|
104
|
+
status: 404,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let existingRelPaths: string[];
|
|
109
|
+
let diskRelPaths: string[];
|
|
110
|
+
try {
|
|
111
|
+
existingRelPaths = await listCollectionRelPaths(store, collection.name);
|
|
112
|
+
diskRelPaths = await listCaptureDiskRelPaths(collection.path);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
code: "RUNTIME",
|
|
117
|
+
message: error instanceof Error ? error.message : String(error),
|
|
118
|
+
status: 500,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const normalizedInput = { ...input, collection: collection.name };
|
|
124
|
+
let plan = planCapture({
|
|
125
|
+
input: normalizedInput,
|
|
126
|
+
existingRelPaths,
|
|
127
|
+
diskRelPaths,
|
|
128
|
+
now,
|
|
129
|
+
});
|
|
130
|
+
if (plan.provenanceConflict && plan.source.browserClip !== undefined) {
|
|
131
|
+
const identity = await readCandidateClipIdentity(
|
|
132
|
+
collection,
|
|
133
|
+
plan.relPath
|
|
134
|
+
);
|
|
135
|
+
plan = planCapture({
|
|
136
|
+
input: normalizedInput,
|
|
137
|
+
existingRelPaths,
|
|
138
|
+
diskRelPaths,
|
|
139
|
+
existingProvenanceByRelPath:
|
|
140
|
+
identity === null ? new Map() : new Map([[plan.relPath, identity]]),
|
|
141
|
+
now,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
ok: true,
|
|
146
|
+
collection,
|
|
147
|
+
fullPath: pathJoin(collection.path, plan.relPath),
|
|
148
|
+
plan,
|
|
149
|
+
};
|
|
150
|
+
} catch (error) {
|
|
151
|
+
return {
|
|
152
|
+
ok: false,
|
|
153
|
+
code: "VALIDATION",
|
|
154
|
+
message: error instanceof Error ? error.message : String(error),
|
|
155
|
+
status: 409,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const syncResidentCollection = async (
|
|
161
|
+
context: ResidentCaptureContext,
|
|
162
|
+
collection: Collection,
|
|
163
|
+
store: SqliteAdapter,
|
|
164
|
+
syncCollection: typeof defaultSyncService.syncCollection
|
|
165
|
+
): Promise<CollectionSyncResult> => {
|
|
166
|
+
const result = await syncCollection(
|
|
167
|
+
collection,
|
|
168
|
+
store,
|
|
169
|
+
withContentTypeRules({ runUpdateCmd: false }, context.config)
|
|
170
|
+
);
|
|
171
|
+
recordContentMutation(result, context.markContentMutation);
|
|
172
|
+
return result;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
export const executeResidentCapturePlan = async (
|
|
176
|
+
context: ResidentCaptureContext,
|
|
177
|
+
store: SqliteAdapter,
|
|
178
|
+
planned: Extract<ResidentCapturePlanResult, { ok: true }>,
|
|
179
|
+
dependencies: {
|
|
180
|
+
syncCollection?: typeof defaultSyncService.syncCollection;
|
|
181
|
+
} = {}
|
|
182
|
+
): Promise<{ body: unknown; status: number }> => {
|
|
183
|
+
const { collection, fullPath, plan } = planned;
|
|
184
|
+
if (plan.provenanceConflict) {
|
|
185
|
+
return {
|
|
186
|
+
body: buildCaptureReceipt({
|
|
187
|
+
plan,
|
|
188
|
+
absPath: fullPath,
|
|
189
|
+
sync: {
|
|
190
|
+
status: "skipped",
|
|
191
|
+
reason:
|
|
192
|
+
"Existing capture has absent or different browser provenance.",
|
|
193
|
+
},
|
|
194
|
+
}),
|
|
195
|
+
status: 409,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
if (plan.openedExisting) {
|
|
199
|
+
const existingDocument = await store.getDocument(
|
|
200
|
+
collection.name,
|
|
201
|
+
plan.relPath
|
|
202
|
+
);
|
|
203
|
+
if (!existingDocument.ok) {
|
|
204
|
+
throw new Error(existingDocument.error.message);
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
body: buildCaptureReceipt({
|
|
208
|
+
plan,
|
|
209
|
+
absPath: fullPath,
|
|
210
|
+
docid: existingDocument.value?.docid,
|
|
211
|
+
sync: existingDocument.value
|
|
212
|
+
? { status: "completed" }
|
|
213
|
+
: {
|
|
214
|
+
status: "skipped",
|
|
215
|
+
reason: "Existing file is not indexed yet.",
|
|
216
|
+
},
|
|
217
|
+
}),
|
|
218
|
+
status: 200,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
223
|
+
context.watchService?.suppress(fullPath);
|
|
224
|
+
await writeCapturePlanFile(plan, fullPath);
|
|
225
|
+
const gnoUri = `gno://${collection.name}/${plan.relPath}`;
|
|
226
|
+
const syncCollection =
|
|
227
|
+
dependencies.syncCollection ??
|
|
228
|
+
defaultSyncService.syncCollection.bind(defaultSyncService);
|
|
229
|
+
const jobResult = await startJob(
|
|
230
|
+
"sync",
|
|
231
|
+
async (): Promise<SyncResult> => {
|
|
232
|
+
const result = await syncResidentCollection(
|
|
233
|
+
context,
|
|
234
|
+
collection,
|
|
235
|
+
store,
|
|
236
|
+
syncCollection
|
|
237
|
+
);
|
|
238
|
+
context.scheduler?.notifySyncComplete([plan.relPath]);
|
|
239
|
+
context.eventBus?.emit({
|
|
240
|
+
type: "document-changed",
|
|
241
|
+
uri: gnoUri,
|
|
242
|
+
collection: collection.name,
|
|
243
|
+
relPath: plan.relPath,
|
|
244
|
+
origin: "create",
|
|
245
|
+
changedAt: new Date().toISOString(),
|
|
246
|
+
});
|
|
247
|
+
return {
|
|
248
|
+
collections: [result],
|
|
249
|
+
totalDurationMs: result.durationMs,
|
|
250
|
+
totalFilesProcessed: result.filesProcessed,
|
|
251
|
+
totalFilesAdded: result.filesAdded,
|
|
252
|
+
totalFilesUpdated: result.filesUpdated,
|
|
253
|
+
totalFilesErrored: result.filesErrored,
|
|
254
|
+
totalFilesSkipped: result.filesSkipped,
|
|
255
|
+
};
|
|
256
|
+
},
|
|
257
|
+
context.jobManager
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
body: buildCaptureReceipt({
|
|
262
|
+
plan,
|
|
263
|
+
absPath: fullPath,
|
|
264
|
+
sync: jobResult.ok
|
|
265
|
+
? {
|
|
266
|
+
status: "pending",
|
|
267
|
+
jobId: jobResult.jobId,
|
|
268
|
+
reason: "Sync job started; poll /api/jobs/:id for status.",
|
|
269
|
+
}
|
|
270
|
+
: {
|
|
271
|
+
status: "skipped",
|
|
272
|
+
jobId: jobResult.activeJobId,
|
|
273
|
+
reason: "Sync skipped because another job is running.",
|
|
274
|
+
error: jobResult.error,
|
|
275
|
+
},
|
|
276
|
+
}),
|
|
277
|
+
status: 202,
|
|
278
|
+
};
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
export const browserClipIdempotencyPlan = (
|
|
282
|
+
planned: Extract<ResidentCapturePlanResult, { ok: true }>
|
|
283
|
+
): ClipperIdempotencyPlan => {
|
|
284
|
+
const browserClip = planned.plan.source.browserClip;
|
|
285
|
+
if (!browserClip) {
|
|
286
|
+
throw new Error("Browser capture plan is missing browser provenance");
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
collection: planned.plan.collection,
|
|
290
|
+
relPath: planned.plan.relPath,
|
|
291
|
+
collisionPolicyResult: planned.plan.collisionPolicyResult,
|
|
292
|
+
contentHash: planned.plan.contentHash,
|
|
293
|
+
clipIdentity: browserClip.clipIdentity,
|
|
294
|
+
};
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
export const browserClipIdempotencyPlansMatch = (
|
|
298
|
+
left: ClipperIdempotencyPlan,
|
|
299
|
+
right: ClipperIdempotencyPlan
|
|
300
|
+
): boolean =>
|
|
301
|
+
left.collection === right.collection &&
|
|
302
|
+
left.relPath === right.relPath &&
|
|
303
|
+
left.collisionPolicyResult === right.collisionPolicyResult &&
|
|
304
|
+
left.contentHash === right.contentHash &&
|
|
305
|
+
left.clipIdentity === right.clipIdentity;
|
|
306
|
+
|
|
307
|
+
export type ResidentCaptureRecoveryResult =
|
|
308
|
+
| { status: "recovered"; body: unknown; statusCode: number }
|
|
309
|
+
| {
|
|
310
|
+
status: "execute";
|
|
311
|
+
planned: Extract<ResidentCapturePlanResult, { ok: true }>;
|
|
312
|
+
}
|
|
313
|
+
| { status: "conflict"; message: string };
|
|
314
|
+
|
|
315
|
+
const recoveredCapturePlan = (
|
|
316
|
+
prepared: PreparedBrowserClip,
|
|
317
|
+
persisted: ClipperIdempotencyPlan,
|
|
318
|
+
storedSource: CaptureSource
|
|
319
|
+
): CapturePlan => ({
|
|
320
|
+
collection: persisted.collection,
|
|
321
|
+
relPath: persisted.relPath,
|
|
322
|
+
filename: basename(persisted.relPath),
|
|
323
|
+
content: prepared.captureInput.content ?? prepared.preview.body,
|
|
324
|
+
body: prepared.preview.body,
|
|
325
|
+
contentHash: persisted.contentHash,
|
|
326
|
+
title: prepared.payload.title,
|
|
327
|
+
tags: prepared.preview.tags,
|
|
328
|
+
source: storedSource,
|
|
329
|
+
openedExisting: persisted.collisionPolicyResult === "opened_existing",
|
|
330
|
+
createdWithSuffix: persisted.collisionPolicyResult === "created_with_suffix",
|
|
331
|
+
provenanceConflict: persisted.collisionPolicyResult === "conflict",
|
|
332
|
+
collisionPolicy: prepared.payload.destination.collisionPolicy,
|
|
333
|
+
collisionPolicyResult: persisted.collisionPolicyResult,
|
|
334
|
+
overwrite: persisted.collisionPolicyResult === "overwritten",
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Reconcile a pending clipper claim without ever choosing a new destination.
|
|
339
|
+
* A matching exact file proves the atomic write landed; otherwise execution is
|
|
340
|
+
* allowed only when a fresh plan is byte-for-byte identical to the saved plan.
|
|
341
|
+
*/
|
|
342
|
+
export const recoverPendingResidentBrowserClip = async (
|
|
343
|
+
context: ResidentCaptureContext,
|
|
344
|
+
store: SqliteAdapter,
|
|
345
|
+
prepared: PreparedBrowserClip,
|
|
346
|
+
persisted: ClipperIdempotencyPlan
|
|
347
|
+
): Promise<ResidentCaptureRecoveryResult> => {
|
|
348
|
+
const collection = context.config.collections.find(
|
|
349
|
+
(candidate) => candidate.name === persisted.collection
|
|
350
|
+
);
|
|
351
|
+
if (!collection) {
|
|
352
|
+
return {
|
|
353
|
+
status: "conflict",
|
|
354
|
+
message: "Saved browser capture collection is no longer configured",
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const fullPath = pathJoin(collection.path, persisted.relPath);
|
|
359
|
+
const file = Bun.file(fullPath);
|
|
360
|
+
if (await file.exists()) {
|
|
361
|
+
try {
|
|
362
|
+
const storedContent = await file.text();
|
|
363
|
+
const source = extractCaptureSourceFromFrontmatter(storedContent);
|
|
364
|
+
const browserClip = source.browserClip;
|
|
365
|
+
if (
|
|
366
|
+
source.kind === "web" &&
|
|
367
|
+
typeof source.capturedAt === "string" &&
|
|
368
|
+
browserClip?.clipIdentity === persisted.clipIdentity &&
|
|
369
|
+
browserClip.finalBodyHash === persisted.contentHash &&
|
|
370
|
+
browserClip.previewDigest === prepared.preview.digest &&
|
|
371
|
+
hashCaptureContent(stripFrontmatter(storedContent)) ===
|
|
372
|
+
persisted.contentHash
|
|
373
|
+
) {
|
|
374
|
+
const storedSource: CaptureSource = {
|
|
375
|
+
...source,
|
|
376
|
+
kind: source.kind,
|
|
377
|
+
capturedAt: source.capturedAt,
|
|
378
|
+
browserClip,
|
|
379
|
+
};
|
|
380
|
+
const plan = recoveredCapturePlan(prepared, persisted, storedSource);
|
|
381
|
+
return {
|
|
382
|
+
status: "recovered",
|
|
383
|
+
body: buildCaptureReceipt({
|
|
384
|
+
plan,
|
|
385
|
+
absPath: fullPath,
|
|
386
|
+
sync: {
|
|
387
|
+
status: "skipped",
|
|
388
|
+
reason:
|
|
389
|
+
"Recovered a completed atomic write after receipt persistence was interrupted.",
|
|
390
|
+
},
|
|
391
|
+
}),
|
|
392
|
+
statusCode:
|
|
393
|
+
persisted.collisionPolicyResult === "opened_existing" ? 200 : 202,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
} catch {
|
|
397
|
+
// A malformed or unrelated exact file is handled as plan drift below.
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const replanned = await planResidentCapture(
|
|
402
|
+
context,
|
|
403
|
+
store,
|
|
404
|
+
prepared.captureInput
|
|
405
|
+
);
|
|
406
|
+
if (
|
|
407
|
+
!replanned.ok ||
|
|
408
|
+
!browserClipIdempotencyPlansMatch(
|
|
409
|
+
browserClipIdempotencyPlan(replanned),
|
|
410
|
+
persisted
|
|
411
|
+
)
|
|
412
|
+
) {
|
|
413
|
+
return {
|
|
414
|
+
status: "conflict",
|
|
415
|
+
message:
|
|
416
|
+
"Pending browser capture cannot be recovered because its destination changed",
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
return { status: "execute", planned: replanned };
|
|
420
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Bounded UTF-8 JSON reader for the browser-clipper boundary. */
|
|
2
|
+
|
|
3
|
+
import { clipperSecurityErrorResponse } from "./clipper-security-errors";
|
|
4
|
+
|
|
5
|
+
export type ClipperBodyReadResult =
|
|
6
|
+
| { ok: true; value: unknown }
|
|
7
|
+
| { ok: false; response: Response };
|
|
8
|
+
|
|
9
|
+
export async function readClipperBoundedJson(
|
|
10
|
+
request: Request,
|
|
11
|
+
maxBytes: number
|
|
12
|
+
): Promise<ClipperBodyReadResult> {
|
|
13
|
+
const declaredLength = request.headers.get("content-length");
|
|
14
|
+
if (
|
|
15
|
+
declaredLength !== null &&
|
|
16
|
+
(!/^\d+$/.test(declaredLength) || Number(declaredLength) > maxBytes)
|
|
17
|
+
) {
|
|
18
|
+
return {
|
|
19
|
+
ok: false,
|
|
20
|
+
response: clipperSecurityErrorResponse("CLIPPER_BODY_TOO_LARGE"),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const reader = request.body?.getReader();
|
|
25
|
+
if (!reader) {
|
|
26
|
+
return {
|
|
27
|
+
ok: false,
|
|
28
|
+
response: clipperSecurityErrorResponse("CLIPPER_INVALID_JSON"),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const chunks: Uint8Array[] = [];
|
|
32
|
+
let total = 0;
|
|
33
|
+
while (true) {
|
|
34
|
+
const result = await reader.read();
|
|
35
|
+
if (result.done) break;
|
|
36
|
+
total += result.value.byteLength;
|
|
37
|
+
if (total > maxBytes) {
|
|
38
|
+
await reader.cancel();
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
response: clipperSecurityErrorResponse("CLIPPER_BODY_TOO_LARGE"),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
chunks.push(result.value);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const bytes = new Uint8Array(total);
|
|
48
|
+
let offset = 0;
|
|
49
|
+
for (const chunk of chunks) {
|
|
50
|
+
bytes.set(chunk, offset);
|
|
51
|
+
offset += chunk.byteLength;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
55
|
+
return { ok: true, value: JSON.parse(text) as unknown };
|
|
56
|
+
} catch {
|
|
57
|
+
return {
|
|
58
|
+
ok: false,
|
|
59
|
+
response: clipperSecurityErrorResponse("CLIPPER_INVALID_JSON"),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
}
|