@arnilo/prism-server 0.0.13 → 0.0.15

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.
@@ -0,0 +1,719 @@
1
+ import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
2
+ import { ARTIFACT_CHECKPOINT_NAMESPACE, ArtifactError, artifactCheckpointKey, assertIdentityActive, assertIdentityMatchesOwnership, CheckpointConflictError, } from "@arnilo/prism";
3
+ import { PrismServerError } from "./types.js";
4
+ /** Phase 9 freeze: artifacts/thread 64/256; revisions 32/128; record 8/64 KiB; preview 16/64 KiB;
5
+ * citations 32/128 and 2/8 KiB each; mime 128/512 B; hash 256/1 KiB; delivery TTL 5 min/24 h;
6
+ * delivery token 4/16 KiB. Compare is exactly 2 revisions (hash+metadata only; host renders content). */
7
+ export const DEFAULT_ARTIFACTS_PER_THREAD = 64;
8
+ export const HARD_ARTIFACTS_PER_THREAD = 256;
9
+ export const DEFAULT_ARTIFACT_REVISIONS = 32;
10
+ export const HARD_ARTIFACT_REVISIONS = 128;
11
+ export const DEFAULT_ARTIFACT_RECORD_BYTES = 8 * 1024;
12
+ export const HARD_ARTIFACT_RECORD_BYTES = 64 * 1024;
13
+ export const DEFAULT_ARTIFACT_PREVIEW_BYTES = 16 * 1024;
14
+ export const HARD_ARTIFACT_PREVIEW_BYTES = 64 * 1024;
15
+ export const DEFAULT_ARTIFACT_CITATIONS = 32;
16
+ export const HARD_ARTIFACT_CITATIONS = 128;
17
+ export const DEFAULT_ARTIFACT_CITATION_BYTES = 2 * 1024;
18
+ export const HARD_ARTIFACT_CITATION_BYTES = 8 * 1024;
19
+ export const DEFAULT_ARTIFACT_MIME_BYTES = 128;
20
+ export const HARD_ARTIFACT_MIME_BYTES = 512;
21
+ export const DEFAULT_ARTIFACT_HASH_BYTES = 256;
22
+ export const HARD_ARTIFACT_HASH_BYTES = 1024;
23
+ export const DEFAULT_ARTIFACT_URI_BYTES = 2 * 1024;
24
+ export const HARD_ARTIFACT_URI_BYTES = 8 * 1024;
25
+ export const DEFAULT_ARTIFACT_NOTE_BYTES = 1024;
26
+ export const HARD_ARTIFACT_NOTE_BYTES = 8 * 1024;
27
+ export const DEFAULT_ARTIFACT_TITLE_BYTES = 256;
28
+ export const HARD_ARTIFACT_TITLE_BYTES = 2 * 1024;
29
+ export const DEFAULT_ARTIFACT_LIST_PAGE_LIMIT = 50;
30
+ export const HARD_ARTIFACT_LIST_PAGE_LIMIT = 200;
31
+ export const DEFAULT_DELIVERY_LINK_TTL_SECONDS = 300;
32
+ export const HARD_DELIVERY_LINK_TTL_SECONDS = 24 * 3600;
33
+ export const DEFAULT_DELIVERY_LINK_TOKEN_BYTES = 4 * 1024;
34
+ export const HARD_DELIVERY_LINK_TOKEN_BYTES = 16 * 1024;
35
+ export const DEFAULT_ARTIFACT_REQUEST_BYTES = 64 * 1024;
36
+ export const HARD_ARTIFACT_REQUEST_BYTES = 1024 * 1024;
37
+ export function resolveArtifactLimits(input = {}) {
38
+ return {
39
+ artifactsPerThread: bounded(input.artifactsPerThread, DEFAULT_ARTIFACTS_PER_THREAD, HARD_ARTIFACTS_PER_THREAD, "artifactsPerThread"),
40
+ revisionsPerArtifact: bounded(input.revisionsPerArtifact, DEFAULT_ARTIFACT_REVISIONS, HARD_ARTIFACT_REVISIONS, "revisionsPerArtifact"),
41
+ recordBytes: bounded(input.recordBytes, DEFAULT_ARTIFACT_RECORD_BYTES, HARD_ARTIFACT_RECORD_BYTES, "recordBytes"),
42
+ previewBytes: bounded(input.previewBytes, DEFAULT_ARTIFACT_PREVIEW_BYTES, HARD_ARTIFACT_PREVIEW_BYTES, "previewBytes"),
43
+ citations: bounded(input.citations, DEFAULT_ARTIFACT_CITATIONS, HARD_ARTIFACT_CITATIONS, "citations"),
44
+ citationBytes: bounded(input.citationBytes, DEFAULT_ARTIFACT_CITATION_BYTES, HARD_ARTIFACT_CITATION_BYTES, "citationBytes"),
45
+ mimeBytes: bounded(input.mimeBytes, DEFAULT_ARTIFACT_MIME_BYTES, HARD_ARTIFACT_MIME_BYTES, "mimeBytes"),
46
+ hashBytes: bounded(input.hashBytes, DEFAULT_ARTIFACT_HASH_BYTES, HARD_ARTIFACT_HASH_BYTES, "hashBytes"),
47
+ uriBytes: bounded(input.uriBytes, DEFAULT_ARTIFACT_URI_BYTES, HARD_ARTIFACT_URI_BYTES, "uriBytes"),
48
+ noteBytes: bounded(input.noteBytes, DEFAULT_ARTIFACT_NOTE_BYTES, HARD_ARTIFACT_NOTE_BYTES, "noteBytes"),
49
+ titleBytes: bounded(input.titleBytes, DEFAULT_ARTIFACT_TITLE_BYTES, HARD_ARTIFACT_TITLE_BYTES, "titleBytes"),
50
+ listPageLimit: bounded(input.listPageLimit, DEFAULT_ARTIFACT_LIST_PAGE_LIMIT, HARD_ARTIFACT_LIST_PAGE_LIMIT, "listPageLimit"),
51
+ deliveryLinkTtlSeconds: bounded(input.deliveryLinkTtlSeconds, DEFAULT_DELIVERY_LINK_TTL_SECONDS, HARD_DELIVERY_LINK_TTL_SECONDS, "deliveryLinkTtlSeconds"),
52
+ deliveryLinkTokenBytes: bounded(input.deliveryLinkTokenBytes, DEFAULT_DELIVERY_LINK_TOKEN_BYTES, HARD_DELIVERY_LINK_TOKEN_BYTES, "deliveryLinkTokenBytes"),
53
+ maxRequestBytes: bounded(input.maxRequestBytes, DEFAULT_ARTIFACT_REQUEST_BYTES, HARD_ARTIFACT_REQUEST_BYTES, "maxRequestBytes"),
54
+ };
55
+ }
56
+ const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
57
+ export function createArtifactService(store, options) {
58
+ const limits = resolveArtifactLimits(options.limits);
59
+ if (typeof options.linkSecret !== "string" || options.linkSecret.length === 0) {
60
+ throw new RangeError("createArtifactService requires non-empty linkSecret key material");
61
+ }
62
+ function reviewerRef(input, explicit) {
63
+ if (explicit !== undefined && explicit.length > 0)
64
+ return assertBounded(explicit, limits.noteBytes, "reviewer_too_large");
65
+ const principal = input.identity?.principal;
66
+ if (principal && principal.id)
67
+ return `${principal.kind}:${principal.id}`;
68
+ throw new ArtifactError("A reviewer identity is required for review decisions", "invalid_input");
69
+ }
70
+ async function load(input, threadId, artifactId) {
71
+ assertOwnership(input.ownership);
72
+ input.signal?.throwIfAborted();
73
+ let checkpoint;
74
+ try {
75
+ checkpoint = await store.loadCheckpoint({
76
+ namespace: ARTIFACT_CHECKPOINT_NAMESPACE,
77
+ key: artifactCheckpointKey(assertId(threadId, "threadId"), assertId(artifactId, "artifactId")),
78
+ ...input.ownership,
79
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
80
+ });
81
+ }
82
+ catch (error) {
83
+ // Ownership mismatch fails closed as not-found (never leaks existence).
84
+ if (error instanceof CheckpointConflictError)
85
+ throw new ArtifactError("Artifact not found", "not_found");
86
+ throw error;
87
+ }
88
+ if (!checkpoint)
89
+ throw new ArtifactError("Artifact not found", "not_found");
90
+ return { record: checkpoint.value, version: checkpoint.version };
91
+ }
92
+ // Read-modify-write with checkpoint CAS: concurrent reviewers race on expectedVersion, one
93
+ // wins and the loser surfaces a retryable conflict (no lost approvals). A throw before save
94
+ // persists nothing, so failed updates roll back inherently.
95
+ async function commit(input, threadId, record, expectedVersion) {
96
+ const redacted = options.redactor.redact(record);
97
+ assertRecordBytes(redacted, limits.recordBytes);
98
+ try {
99
+ await store.saveCheckpoint({
100
+ namespace: ARTIFACT_CHECKPOINT_NAMESPACE,
101
+ key: artifactCheckpointKey(threadId, record.id),
102
+ ...input.ownership,
103
+ version: expectedVersion + 1,
104
+ expectedVersion,
105
+ value: redacted,
106
+ category: "artifact",
107
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
108
+ });
109
+ }
110
+ catch (error) {
111
+ if (error instanceof CheckpointConflictError) {
112
+ throw new ArtifactError("Artifact was modified concurrently; retry", "conflict");
113
+ }
114
+ throw error;
115
+ }
116
+ return redacted;
117
+ }
118
+ function buildRevision(input, version) {
119
+ const uri = assertSafeUri(input.uri, limits.uriBytes);
120
+ assertBounded(input.mime, limits.mimeBytes, "mime_too_large");
121
+ assertBounded(input.hash, limits.hashBytes, "hash_too_large");
122
+ if (input.changeNote !== undefined)
123
+ assertBounded(input.changeNote, limits.noteBytes, "change_note_too_large");
124
+ if (input.producerRunId !== undefined)
125
+ assertId(input.producerRunId, "producerRunId");
126
+ const citations = normalizeCitations(input.citations, limits);
127
+ const preview = normalizePreview(input.preview, limits);
128
+ return {
129
+ version,
130
+ uri,
131
+ mime: input.mime,
132
+ hash: input.hash,
133
+ ...(input.changeNote === undefined ? {} : { changeNote: input.changeNote }),
134
+ ...(input.producerRunId === undefined ? {} : { producerRunId: input.producerRunId }),
135
+ ...(citations === undefined ? {} : { citations }),
136
+ ...(preview === undefined ? {} : { preview }),
137
+ createdAt: new Date().toISOString(),
138
+ };
139
+ }
140
+ async function audit(event) {
141
+ if (options.onDecision)
142
+ await options.onDecision(event);
143
+ }
144
+ return {
145
+ async attach(input) {
146
+ assertOwnership(input.ownership);
147
+ input.signal?.throwIfAborted();
148
+ if (input.identity)
149
+ assertIdentityMatchesOwnership(input.identity, input.ownership);
150
+ const threadId = assertId(input.threadId, "threadId");
151
+ const id = input.id === undefined ? `art_${randomUUID()}` : assertId(input.id, "id");
152
+ if (input.id !== undefined) {
153
+ const existing = await this.get({ ...input, threadId, artifactId: id }).catch((error) => {
154
+ if (error instanceof ArtifactError && error.reason === "not_found")
155
+ return undefined;
156
+ throw error;
157
+ });
158
+ if (existing)
159
+ return existing;
160
+ }
161
+ // Enforce the per-thread artifact cap before create.
162
+ const page = await store.listCheckpoints({
163
+ namespace: ARTIFACT_CHECKPOINT_NAMESPACE,
164
+ keyPrefix: `${threadId}:`,
165
+ ...input.ownership,
166
+ limit: limits.artifactsPerThread,
167
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
168
+ });
169
+ if (page.items.length >= limits.artifactsPerThread) {
170
+ throw new ArtifactError("Too many artifacts for this thread", "too_many_artifacts");
171
+ }
172
+ if (input.title !== undefined)
173
+ assertBounded(input.title, limits.titleBytes, "title_too_large");
174
+ const now = new Date().toISOString();
175
+ const record = {
176
+ id,
177
+ threadId,
178
+ ...input.ownership,
179
+ ...(input.title === undefined ? {} : { title: input.title }),
180
+ revisions: [buildRevision(input, 1)],
181
+ approvals: [],
182
+ createdAt: now,
183
+ updatedAt: now,
184
+ };
185
+ const saved = await commit(input, threadId, record, 0);
186
+ await audit({ type: "artifact_attached", artifactId: id, threadId, version: 1, ...(input.identity ? { actor: `${input.identity.principal.kind}:${input.identity.principal.id}` } : {}), timestamp: now });
187
+ return saved;
188
+ },
189
+ async list(input) {
190
+ assertOwnership(input.ownership);
191
+ input.signal?.throwIfAborted();
192
+ const threadId = assertId(input.threadId, "threadId");
193
+ const limit = Math.min(input.limit ?? limits.listPageLimit, limits.listPageLimit);
194
+ if (!Number.isSafeInteger(limit) || limit < 1)
195
+ throw new ArtifactError("limit is invalid", "invalid_input");
196
+ const page = await store.listCheckpoints({
197
+ namespace: ARTIFACT_CHECKPOINT_NAMESPACE,
198
+ keyPrefix: `${threadId}:`,
199
+ ...input.ownership,
200
+ ...(input.cursor === undefined ? {} : { cursor: input.cursor }),
201
+ limit,
202
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
203
+ });
204
+ const items = page.items.map((checkpoint) => checkpoint.value);
205
+ return { items, ...(page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }) };
206
+ },
207
+ get(input) {
208
+ return load(input, input.threadId, input.artifactId).then((loaded) => loaded.record);
209
+ },
210
+ async revise(input) {
211
+ const { record, version } = await load(input, input.threadId, input.artifactId);
212
+ if (record.revisions.length >= limits.revisionsPerArtifact) {
213
+ throw new ArtifactError("Too many revisions for this artifact", "too_many_revisions");
214
+ }
215
+ const previous = record.revisions[record.revisions.length - 1];
216
+ const revision = buildRevision({ ...input, mime: input.mime ?? previous.mime }, previous.version + 1);
217
+ const now = new Date().toISOString();
218
+ const updated = { ...record, revisions: [...record.revisions, revision], updatedAt: now };
219
+ const saved = await commit(input, input.threadId, updated, version);
220
+ await audit({ type: "artifact_revised", artifactId: record.id, threadId: input.threadId, version: revision.version, ...(input.identity ? { actor: `${input.identity.principal.kind}:${input.identity.principal.id}` } : {}), timestamp: now });
221
+ return saved;
222
+ },
223
+ async compare(input) {
224
+ const { record } = await load(input, input.threadId, input.artifactId);
225
+ // Freeze: exactly 2 revisions per compare call; hash+metadata only.
226
+ if (!Number.isSafeInteger(input.from) || !Number.isSafeInteger(input.to) || input.from === input.to) {
227
+ throw new ArtifactError("compare requires two distinct revision numbers", "invalid_input");
228
+ }
229
+ const from = record.revisions.find((revision) => revision.version === input.from);
230
+ const to = record.revisions.find((revision) => revision.version === input.to);
231
+ if (!from || !to)
232
+ throw new ArtifactError("Revision not found", "not_found");
233
+ return {
234
+ artifactId: record.id,
235
+ from,
236
+ to,
237
+ changed: {
238
+ hash: from.hash !== to.hash,
239
+ mime: from.mime !== to.mime,
240
+ uri: from.uri !== to.uri,
241
+ citations: JSON.stringify(from.citations ?? []) !== JSON.stringify(to.citations ?? []),
242
+ },
243
+ };
244
+ },
245
+ async approve(input) {
246
+ return decide(input, "approved");
247
+ },
248
+ async reject(input) {
249
+ return decide(input, "rejected");
250
+ },
251
+ async lastValidated(input) {
252
+ const { record } = await load(input, input.threadId, input.artifactId);
253
+ if (record.lastValidatedVersion === undefined) {
254
+ throw new ArtifactError("Artifact has no validated revision", "not_validated");
255
+ }
256
+ const revision = record.revisions.find((item) => item.version === record.lastValidatedVersion);
257
+ if (!revision)
258
+ throw new ArtifactError("Validated revision not found", "not_found");
259
+ return revision;
260
+ },
261
+ async deliveryLink(input) {
262
+ const { record } = await load(input, input.threadId, input.artifactId);
263
+ const latest = record.revisions[record.revisions.length - 1];
264
+ const version = input.version ?? record.lastValidatedVersion ?? latest?.version;
265
+ if (version === undefined)
266
+ throw new ArtifactError("Artifact has no revisions", "invalid_input");
267
+ if (!record.revisions.some((revision) => revision.version === version)) {
268
+ throw new ArtifactError("Revision not found", "not_found");
269
+ }
270
+ const ttlSeconds = bounded(input.ttlSeconds, limits.deliveryLinkTtlSeconds, limits.deliveryLinkTtlSeconds, "ttlSeconds");
271
+ const now = Date.now();
272
+ const token = {
273
+ artifactId: record.id,
274
+ threadId: input.threadId,
275
+ version,
276
+ ...input.ownership,
277
+ issuedAt: new Date(now).toISOString(),
278
+ expiresAt: new Date(now + ttlSeconds * 1000).toISOString(),
279
+ };
280
+ return { link: signArtifactDeliveryLink(token, options.linkSecret), token };
281
+ },
282
+ };
283
+ async function decide(input, state) {
284
+ const { record, version } = await load(input, input.threadId, input.artifactId);
285
+ if (!Number.isSafeInteger(input.version) || !record.revisions.some((revision) => revision.version === input.version)) {
286
+ throw new ArtifactError("Revision not found", "not_found");
287
+ }
288
+ if (input.note !== undefined)
289
+ assertBounded(input.note, limits.noteBytes, "note_too_large");
290
+ const reviewer = reviewerRef(input, input.reviewer);
291
+ const now = new Date().toISOString();
292
+ const approval = {
293
+ version: input.version,
294
+ state,
295
+ reviewer,
296
+ ...(input.note === undefined ? {} : { note: input.note }),
297
+ decidedAt: now,
298
+ };
299
+ // Replace any prior decision on the same version; approval advances lastValidated,
300
+ // rejection never clears it so the last validated revision stays recoverable.
301
+ const approvals = [...record.approvals.filter((item) => item.version !== input.version), approval];
302
+ const updated = {
303
+ ...record,
304
+ approvals,
305
+ ...(state === "approved" ? { lastValidatedVersion: input.version } : {}),
306
+ updatedAt: now,
307
+ };
308
+ const saved = await commit(input, input.threadId, updated, version);
309
+ await audit({ type: state === "approved" ? "artifact_approved" : "artifact_rejected", artifactId: record.id, threadId: input.threadId, version: input.version, reviewer, timestamp: now });
310
+ return saved;
311
+ }
312
+ }
313
+ /** Sign an expiring delivery token: base64url(payload).base64url(HMAC-SHA256). */
314
+ export function signArtifactDeliveryLink(token, secret) {
315
+ const payload = Buffer.from(JSON.stringify(token), "utf8").toString("base64url");
316
+ const signature = createHmac("sha256", secret).update(payload).digest("base64url");
317
+ return `${payload}.${signature}`;
318
+ }
319
+ /** Verify signature + expiry and parse a delivery link. Fail-closed on any tamper/expiry. */
320
+ export function verifyArtifactDeliveryLink(link, secret, maxBytes = HARD_DELIVERY_LINK_TOKEN_BYTES) {
321
+ if (typeof link !== "string" || link.length === 0)
322
+ throw new ArtifactError("Delivery link is required", "invalid_link");
323
+ if (Buffer.byteLength(link, "utf8") > maxBytes)
324
+ throw new ArtifactError("Delivery link exceeds byte limit", "link_too_large");
325
+ const dot = link.lastIndexOf(".");
326
+ if (dot <= 0 || dot === link.length - 1)
327
+ throw new ArtifactError("Delivery link is invalid", "invalid_link");
328
+ const payload = link.slice(0, dot);
329
+ const signature = link.slice(dot + 1);
330
+ const expected = createHmac("sha256", secret).update(payload).digest("base64url");
331
+ const given = Buffer.from(signature);
332
+ const want = Buffer.from(expected);
333
+ if (given.length !== want.length || !timingSafeEqual(given, want)) {
334
+ throw new ArtifactError("Delivery link signature invalid", "invalid_link");
335
+ }
336
+ let parsed;
337
+ try {
338
+ parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
339
+ }
340
+ catch {
341
+ throw new ArtifactError("Delivery link is invalid", "invalid_link");
342
+ }
343
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
344
+ throw new ArtifactError("Delivery link is invalid", "invalid_link");
345
+ const token = parsed;
346
+ if (typeof token.artifactId !== "string" || typeof token.threadId !== "string" ||
347
+ !Number.isSafeInteger(token.version) || typeof token.issuedAt !== "string" || typeof token.expiresAt !== "string") {
348
+ throw new ArtifactError("Delivery link is invalid", "invalid_link");
349
+ }
350
+ const expiresAt = Date.parse(token.expiresAt);
351
+ if (!Number.isFinite(expiresAt) || expiresAt <= Date.now())
352
+ throw new ArtifactError("Delivery link expired", "link_expired");
353
+ return {
354
+ artifactId: token.artifactId,
355
+ threadId: token.threadId,
356
+ version: token.version,
357
+ ...(typeof token.tenantId === "string" ? { tenantId: token.tenantId } : {}),
358
+ ...(typeof token.accountId === "string" ? { accountId: token.accountId } : {}),
359
+ ...(typeof token.userId === "string" ? { userId: token.userId } : {}),
360
+ issuedAt: token.issuedAt,
361
+ expiresAt: token.expiresAt,
362
+ };
363
+ }
364
+ const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
365
+ /** Framework-free HTTP adapter for one mounted artifact service (default base `/prism/artifacts`). */
366
+ export function createArtifactHandler(options) {
367
+ const base = normalizeBasePath(options.basePath ?? "/prism/artifacts");
368
+ const limits = resolveArtifactLimits(options.limits);
369
+ return async (request) => {
370
+ try {
371
+ const route = parseArtifactRoute(request, base);
372
+ if (!route)
373
+ throw new PrismServerError("Not found", 404, "ERR_PRISM_SERVER_NOT_FOUND");
374
+ let deliveryToken;
375
+ if (route.kind === "download") {
376
+ const link = new URL(request.url).searchParams.get("link");
377
+ if (link === null)
378
+ throw new PrismServerError("link query parameter is required", 400, "ERR_PRISM_SERVER_INPUT");
379
+ deliveryToken = verifyArtifactDeliveryLink(link, options.linkSecret, limits.deliveryLinkTokenBytes);
380
+ }
381
+ const authorization = await options.authorize({
382
+ request,
383
+ operation: route.operation,
384
+ ...(route.threadId === undefined ? {} : { threadId: route.threadId }),
385
+ ...(route.artifactId === undefined ? {} : { artifactId: route.artifactId }),
386
+ ...(deliveryToken === undefined ? {} : { deliveryToken }),
387
+ signal: request.signal,
388
+ });
389
+ if (!authorization)
390
+ throw new PrismServerError("Forbidden", 403, "ERR_PRISM_SERVER_FORBIDDEN");
391
+ if (authorization.identity) {
392
+ assertIdentityActive(authorization.identity);
393
+ assertIdentityMatchesOwnership(authorization.identity, authorization.ownership);
394
+ }
395
+ // Download reauthorizes against the token's ownership; a mismatch fails closed.
396
+ if (deliveryToken && !ownershipMatches(authorization.ownership, deliveryToken)) {
397
+ throw new PrismServerError("Forbidden", 403, "ERR_PRISM_SERVER_FORBIDDEN");
398
+ }
399
+ const input = {
400
+ ownership: authorization.ownership,
401
+ ...(authorization.identity === undefined ? {} : { identity: authorization.identity }),
402
+ signal: request.signal,
403
+ };
404
+ const service = options.service;
405
+ switch (route.kind) {
406
+ case "attach": {
407
+ const body = await readBody(request, limits.maxRequestBytes);
408
+ const record = await service.attach({
409
+ ...input,
410
+ threadId: route.threadId,
411
+ uri: readString(body.uri, "uri"),
412
+ mime: readString(body.mime, "mime"),
413
+ hash: readString(body.hash, "hash"),
414
+ ...(body.id === undefined ? {} : { id: readString(body.id, "id") }),
415
+ ...(body.title === undefined ? {} : { title: readString(body.title, "title") }),
416
+ ...(body.changeNote === undefined ? {} : { changeNote: readString(body.changeNote, "changeNote") }),
417
+ ...(body.producerRunId === undefined ? {} : { producerRunId: readString(body.producerRunId, "producerRunId") }),
418
+ ...(body.citations === undefined ? {} : { citations: readCitations(body.citations) }),
419
+ ...(body.preview === undefined ? {} : { preview: readObject(body.preview, "preview") }),
420
+ });
421
+ return json(options, record, 201);
422
+ }
423
+ case "list": {
424
+ const query = new URL(request.url).searchParams;
425
+ const page = await service.list({
426
+ ...input,
427
+ threadId: route.threadId,
428
+ ...(query.get("cursor") === null ? {} : { cursor: query.get("cursor") ?? undefined }),
429
+ ...(query.get("limit") === null ? {} : { limit: readPositiveInt(query.get("limit"), "limit") }),
430
+ });
431
+ return json(options, page, 200);
432
+ }
433
+ case "get":
434
+ return json(options, await service.get({ ...input, threadId: route.threadId, artifactId: route.artifactId }), 200);
435
+ case "revise": {
436
+ const body = await readBody(request, limits.maxRequestBytes);
437
+ const record = await service.revise({
438
+ ...input,
439
+ threadId: route.threadId,
440
+ artifactId: route.artifactId,
441
+ uri: readString(body.uri, "uri"),
442
+ hash: readString(body.hash, "hash"),
443
+ ...(body.mime === undefined ? {} : { mime: readString(body.mime, "mime") }),
444
+ ...(body.changeNote === undefined ? {} : { changeNote: readString(body.changeNote, "changeNote") }),
445
+ ...(body.producerRunId === undefined ? {} : { producerRunId: readString(body.producerRunId, "producerRunId") }),
446
+ ...(body.citations === undefined ? {} : { citations: readCitations(body.citations) }),
447
+ ...(body.preview === undefined ? {} : { preview: readObject(body.preview, "preview") }),
448
+ });
449
+ return json(options, record, 200);
450
+ }
451
+ case "compare": {
452
+ const body = await readBody(request, limits.maxRequestBytes);
453
+ const result = await service.compare({
454
+ ...input,
455
+ threadId: route.threadId,
456
+ artifactId: route.artifactId,
457
+ from: readPositiveInt(body.from === undefined ? null : String(body.from), "from"),
458
+ to: readPositiveInt(body.to === undefined ? null : String(body.to), "to"),
459
+ });
460
+ return json(options, result, 200);
461
+ }
462
+ case "approve":
463
+ case "reject": {
464
+ const body = await readBody(request, limits.maxRequestBytes);
465
+ const decision = {
466
+ ...input,
467
+ threadId: route.threadId,
468
+ artifactId: route.artifactId,
469
+ version: readPositiveInt(body.version === undefined ? null : String(body.version), "version"),
470
+ ...(body.note === undefined ? {} : { note: readString(body.note, "note") }),
471
+ ...(body.reviewer === undefined ? {} : { reviewer: readString(body.reviewer, "reviewer") }),
472
+ };
473
+ const record = route.kind === "approve" ? await service.approve(decision) : await service.reject(decision);
474
+ return json(options, record, 200);
475
+ }
476
+ case "last-validated": {
477
+ const revision = await service.lastValidated({ ...input, threadId: route.threadId, artifactId: route.artifactId });
478
+ return json(options, revision, 200);
479
+ }
480
+ case "delivery-link": {
481
+ const body = await readBody(request, limits.maxRequestBytes);
482
+ const result = await service.deliveryLink({
483
+ ...input,
484
+ threadId: route.threadId,
485
+ artifactId: route.artifactId,
486
+ ...(body.version === undefined ? {} : { version: readPositiveInt(String(body.version), "version") }),
487
+ ...(body.ttlSeconds === undefined ? {} : { ttlSeconds: readPositiveInt(String(body.ttlSeconds), "ttlSeconds") }),
488
+ });
489
+ return json(options, result, 200);
490
+ }
491
+ case "download": {
492
+ // Token verified + reauthorized above; serve the authorized revision reference only
493
+ // (host fetches the body). Reverify the artifact still has the version.
494
+ const token = deliveryToken;
495
+ const record = await service.get({ ...input, threadId: token.threadId, artifactId: token.artifactId });
496
+ const revision = record.revisions.find((item) => item.version === token.version);
497
+ if (!revision)
498
+ throw new PrismServerError("Revision not found", 404, "ERR_PRISM_SERVER_NOT_FOUND");
499
+ return json(options, { artifactId: record.id, threadId: record.threadId, revision }, 200);
500
+ }
501
+ }
502
+ }
503
+ catch (error) {
504
+ return artifactErrorResponse(error);
505
+ }
506
+ };
507
+ }
508
+ function parseArtifactRoute(request, base) {
509
+ const pathname = new URL(request.url).pathname;
510
+ if (pathname === `${base}/download` && request.method === "GET") {
511
+ return { kind: "download", operation: "artifact.download" };
512
+ }
513
+ if (pathname !== base && !pathname.startsWith(`${base}/`))
514
+ return undefined;
515
+ let parts;
516
+ try {
517
+ parts = pathname.slice(base.length).split("/").filter(Boolean).map(decodeURIComponent);
518
+ }
519
+ catch {
520
+ throw new PrismServerError("Invalid route", 400, "ERR_PRISM_SERVER_ROUTE");
521
+ }
522
+ if (parts.length === 0)
523
+ return undefined;
524
+ const [threadId, artifactId, action] = parts;
525
+ if (!ID_PATTERN.test(threadId) || threadId.length > 128)
526
+ return undefined;
527
+ if (parts.length === 1) {
528
+ if (request.method === "POST")
529
+ return { kind: "attach", operation: "artifact.attach", threadId };
530
+ if (request.method === "GET")
531
+ return { kind: "list", operation: "artifact.list", threadId };
532
+ return undefined;
533
+ }
534
+ if (!ID_PATTERN.test(artifactId) || artifactId.length > 128)
535
+ return undefined;
536
+ if (parts.length === 2) {
537
+ if (request.method === "GET")
538
+ return { kind: "get", operation: "artifact.get", threadId, artifactId };
539
+ return undefined;
540
+ }
541
+ if (parts.length !== 3)
542
+ return undefined;
543
+ if (action === "last-validated" && request.method === "GET")
544
+ return { kind: "last-validated", operation: "artifact.last-validated", threadId, artifactId };
545
+ if (request.method !== "POST")
546
+ return undefined;
547
+ if (action === "revise")
548
+ return { kind: "revise", operation: "artifact.revise", threadId, artifactId };
549
+ if (action === "compare")
550
+ return { kind: "compare", operation: "artifact.compare", threadId, artifactId };
551
+ if (action === "approve")
552
+ return { kind: "approve", operation: "artifact.approve", threadId, artifactId };
553
+ if (action === "reject")
554
+ return { kind: "reject", operation: "artifact.reject", threadId, artifactId };
555
+ if (action === "delivery-link")
556
+ return { kind: "delivery-link", operation: "artifact.delivery-link", threadId, artifactId };
557
+ return undefined;
558
+ }
559
+ function ownershipMatches(scope, token) {
560
+ return scope.tenantId === token.tenantId
561
+ && scope.accountId === token.accountId
562
+ && scope.userId === token.userId;
563
+ }
564
+ function json(options, value, status) {
565
+ const safe = options.redactor?.redact(value) ?? value;
566
+ return new Response(JSON.stringify(safe), { status, headers: JSON_HEADERS });
567
+ }
568
+ function artifactErrorResponse(error) {
569
+ let status = 500;
570
+ let code = "ERR_PRISM_SERVER_INTERNAL";
571
+ let message = "Internal server error";
572
+ if (error instanceof PrismServerError) {
573
+ status = error.status;
574
+ code = error.code;
575
+ message = error.message;
576
+ }
577
+ else if (error instanceof ArtifactError) {
578
+ code = error.code;
579
+ message = error.message;
580
+ status = error.reason === "not_found" || error.reason === "not_validated" ? 404
581
+ : error.reason === "conflict" ? 409
582
+ : error.reason === "ownership" ? 403
583
+ : error.reason === "link_expired" ? 410
584
+ : error.reason === "too_many_artifacts" || error.reason === "too_many_revisions" ? 422
585
+ : error.reason === "invalid_link" ? 401
586
+ : 400;
587
+ }
588
+ else if (error instanceof RangeError) {
589
+ status = 400;
590
+ code = "ERR_PRISM_SERVER_INPUT";
591
+ message = error.message;
592
+ }
593
+ else if (error && typeof error === "object" && "code" in error && error.code === "ERR_PRISM_IDENTITY") {
594
+ status = 403;
595
+ code = "ERR_PRISM_SERVER_FORBIDDEN";
596
+ message = "Forbidden";
597
+ }
598
+ else if (error instanceof DOMException && error.name === "AbortError") {
599
+ status = 499;
600
+ code = "ERR_PRISM_SERVER_ABORTED";
601
+ message = "Request aborted";
602
+ }
603
+ return new Response(JSON.stringify({ error: { code, message } }), { status, headers: JSON_HEADERS });
604
+ }
605
+ function normalizeBasePath(value) {
606
+ if (!value.startsWith("/") || value.includes("?") || value.includes("#"))
607
+ throw new RangeError("basePath must be an absolute URL path");
608
+ const normalized = value.length > 1 ? value.replace(/\/+$/, "") : value;
609
+ if (normalized === "/")
610
+ throw new RangeError("basePath cannot expose the URL root");
611
+ return normalized;
612
+ }
613
+ function assertOwnership(ownership) {
614
+ if (![ownership.tenantId, ownership.accountId, ownership.userId].some((v) => typeof v === "string" && v.length > 0)) {
615
+ throw new ArtifactError("Ownership is required", "ownership");
616
+ }
617
+ }
618
+ function assertId(value, name) {
619
+ if (typeof value !== "string" || value.length === 0 || value.length > 128 || !ID_PATTERN.test(value)) {
620
+ throw new ArtifactError(`${name} is invalid`, "invalid_id");
621
+ }
622
+ return value;
623
+ }
624
+ function assertBounded(value, maxBytes, reason) {
625
+ if (Buffer.byteLength(value, "utf8") > maxBytes)
626
+ throw new ArtifactError(`Value exceeds ${maxBytes} bytes`, reason);
627
+ return value;
628
+ }
629
+ function assertRecordBytes(record, maxBytes) {
630
+ if (Buffer.byteLength(JSON.stringify(record), "utf8") > maxBytes) {
631
+ throw new ArtifactError(`Artifact record exceeds ${maxBytes} bytes`, "record_too_large");
632
+ }
633
+ }
634
+ /** Reject local filesystem references so paths never enter records/events/exports. */
635
+ function assertSafeUri(value, maxBytes) {
636
+ if (typeof value !== "string" || value.length === 0)
637
+ throw new ArtifactError("uri is required", "invalid_input");
638
+ assertBounded(value, maxBytes, "uri_too_large");
639
+ const lower = value.toLowerCase();
640
+ if (lower.startsWith("file:") || lower.startsWith("/") || /^[a-z]:[\\/]/.test(lower)) {
641
+ throw new ArtifactError("uri must be a host-owned reference, not a local filesystem path", "unsafe_uri");
642
+ }
643
+ return value;
644
+ }
645
+ function normalizeCitations(citations, limits) {
646
+ if (citations === undefined)
647
+ return undefined;
648
+ if (!Array.isArray(citations))
649
+ throw new ArtifactError("citations must be an array", "invalid_input");
650
+ if (citations.length > limits.citations)
651
+ throw new ArtifactError(`Too many citations (max ${limits.citations})`, "too_many_citations");
652
+ return citations.map((citation) => {
653
+ if (!citation || typeof citation.uri !== "string" || citation.uri.length === 0) {
654
+ throw new ArtifactError("citation.uri is required", "invalid_input");
655
+ }
656
+ assertBounded(JSON.stringify(citation), limits.citationBytes, "citation_too_large");
657
+ return {
658
+ uri: assertSafeUri(citation.uri, limits.uriBytes),
659
+ ...(citation.title === undefined ? {} : { title: assertBounded(citation.title, limits.citationBytes, "citation_too_large") }),
660
+ ...(citation.kind === undefined ? {} : { kind: assertBounded(citation.kind, 128, "citation_too_large") }),
661
+ };
662
+ });
663
+ }
664
+ function normalizePreview(preview, limits) {
665
+ if (preview === undefined)
666
+ return undefined;
667
+ if (!preview || typeof preview !== "object" || Array.isArray(preview))
668
+ throw new ArtifactError("preview must be an object", "invalid_input");
669
+ if (Buffer.byteLength(JSON.stringify(preview), "utf8") > limits.previewBytes) {
670
+ throw new ArtifactError(`Preview metadata exceeds ${limits.previewBytes} bytes`, "preview_too_large");
671
+ }
672
+ return preview;
673
+ }
674
+ function bounded(value, fallback, cap, name) {
675
+ const resolved = value ?? fallback;
676
+ if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > cap) {
677
+ throw new RangeError(`${name} must be a positive safe integer <= ${cap}`);
678
+ }
679
+ return resolved;
680
+ }
681
+ async function readBody(request, maxBytes) {
682
+ const text = await request.text();
683
+ if (Buffer.byteLength(text, "utf8") > maxBytes) {
684
+ throw new PrismServerError("Request body too large", 413, "ERR_PRISM_SERVER_BODY_LIMIT");
685
+ }
686
+ if (text.length === 0)
687
+ return {};
688
+ try {
689
+ const value = JSON.parse(text);
690
+ if (!value || typeof value !== "object" || Array.isArray(value))
691
+ throw new Error("object");
692
+ return value;
693
+ }
694
+ catch {
695
+ throw new PrismServerError("Invalid JSON object body", 400, "ERR_PRISM_SERVER_BODY");
696
+ }
697
+ }
698
+ function readString(value, name) {
699
+ if (typeof value !== "string" || value.length === 0)
700
+ throw new PrismServerError(`${name} must be a string`, 400, "ERR_PRISM_SERVER_INPUT");
701
+ return value;
702
+ }
703
+ function readObject(value, name) {
704
+ if (!value || typeof value !== "object" || Array.isArray(value))
705
+ throw new PrismServerError(`${name} must be an object`, 400, "ERR_PRISM_SERVER_INPUT");
706
+ return value;
707
+ }
708
+ function readCitations(value) {
709
+ if (!Array.isArray(value))
710
+ throw new PrismServerError("citations must be an array", 400, "ERR_PRISM_SERVER_INPUT");
711
+ return value;
712
+ }
713
+ function readPositiveInt(value, name) {
714
+ const parsed = value === null ? NaN : Number(value);
715
+ if (!Number.isSafeInteger(parsed) || parsed < 1)
716
+ throw new PrismServerError(`${name} must be a positive safe integer`, 400, "ERR_PRISM_SERVER_INPUT");
717
+ return parsed;
718
+ }
719
+ //# sourceMappingURL=artifacts.js.map