@arnilo/prism-server 0.0.96 → 0.1.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/CHANGELOG.md +128 -1
- package/README.md +25 -2
- package/dist/artifact-bodies-s3.d.ts +75 -0
- package/dist/artifact-bodies-s3.js +104 -0
- package/dist/artifact-bodies.d.ts +70 -0
- package/dist/artifact-bodies.js +391 -0
- package/dist/artifacts.d.ts +207 -0
- package/dist/artifacts.js +784 -0
- package/dist/conversations.d.ts +149 -0
- package/dist/conversations.js +558 -0
- package/dist/deployment.d.ts +31 -0
- package/dist/deployment.js +49 -0
- package/dist/drain.d.ts +23 -0
- package/dist/drain.js +57 -0
- package/dist/handler.js +161 -19
- package/dist/health.d.ts +20 -0
- package/dist/health.js +103 -0
- package/dist/index.d.ts +17 -3
- package/dist/index.js +8 -1
- package/dist/limits.d.ts +24 -0
- package/dist/limits.js +18 -0
- package/dist/rate-limit.d.ts +26 -0
- package/dist/rate-limit.js +41 -0
- package/dist/replay.d.ts +31 -0
- package/dist/replay.js +144 -0
- package/dist/types.d.ts +21 -3
- package/dist/types.js +3 -1
- package/package.json +8 -3
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reference S3-compatible artifact body store (Phase 11 / 0.0.28): hand-rolled SigV4
|
|
3
|
+
* presigning over native fetch + WebCrypto, path-style addressing, single-chunk PUT with
|
|
4
|
+
* exact Content-Length and x-amz-content-sha256 = verified SHA-256 hex (no chunked transfer).
|
|
5
|
+
* Implements the core `ArtifactBodyStore` contract; hosts may substitute any store.
|
|
6
|
+
*
|
|
7
|
+
* Security posture: ownership verified on every operation; size/hash/MIME verified on put
|
|
8
|
+
* and get (fail closed); delete refuses under legal hold (host `isHeld` callback) and is
|
|
9
|
+
* idempotent; credentials only via the host resolver; bucket/path/key never appear in
|
|
10
|
+
* errors, telemetry, or artifact records (the object key is derived from the ref).
|
|
11
|
+
*/
|
|
12
|
+
import { ArtifactBodyStoreError } from "@arnilo/prism";
|
|
13
|
+
import { presignV4, sha256Hex, signRequestV4 } from "./artifact-bodies-s3.js";
|
|
14
|
+
/** Phase 11 freeze: body 64 MiB/512 MiB; concurrent transfers 4/16; presign TTL 10 min/24 h; ref 256 B/1 KiB. */
|
|
15
|
+
export const DEFAULT_ARTIFACT_BODY_LIMITS = {
|
|
16
|
+
maxBodyBytes: 64 * 1024 * 1024,
|
|
17
|
+
maxConcurrentTransfers: 4,
|
|
18
|
+
presignTtlMs: 10 * 60 * 1000,
|
|
19
|
+
maxRefBytes: 256,
|
|
20
|
+
};
|
|
21
|
+
export const HARD_ARTIFACT_BODY_LIMITS = {
|
|
22
|
+
maxBodyBytes: 512 * 1024 * 1024,
|
|
23
|
+
maxConcurrentTransfers: 16,
|
|
24
|
+
presignTtlMs: 24 * 3600 * 1000,
|
|
25
|
+
maxRefBytes: 1024,
|
|
26
|
+
};
|
|
27
|
+
export function resolveArtifactBodyLimits(input = {}) {
|
|
28
|
+
const resolved = {
|
|
29
|
+
maxBodyBytes: DEFAULT_ARTIFACT_BODY_LIMITS.maxBodyBytes,
|
|
30
|
+
maxConcurrentTransfers: DEFAULT_ARTIFACT_BODY_LIMITS.maxConcurrentTransfers,
|
|
31
|
+
presignTtlMs: DEFAULT_ARTIFACT_BODY_LIMITS.presignTtlMs,
|
|
32
|
+
maxRefBytes: DEFAULT_ARTIFACT_BODY_LIMITS.maxRefBytes,
|
|
33
|
+
};
|
|
34
|
+
for (const key of Object.keys(DEFAULT_ARTIFACT_BODY_LIMITS)) {
|
|
35
|
+
const value = input[key];
|
|
36
|
+
if (value === undefined)
|
|
37
|
+
continue;
|
|
38
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > HARD_ARTIFACT_BODY_LIMITS[key]) {
|
|
39
|
+
throw new ArtifactBodyStoreError(`${key} must be a positive safe integer at or below the hard cap`, "STORE");
|
|
40
|
+
}
|
|
41
|
+
resolved[key] = value;
|
|
42
|
+
}
|
|
43
|
+
return resolved;
|
|
44
|
+
}
|
|
45
|
+
/** Typed S3 adapter failure; `code` is one of the frozen ERR_PRISM_S3_* codes. */
|
|
46
|
+
export class S3ArtifactBodyError extends Error {
|
|
47
|
+
reason;
|
|
48
|
+
code;
|
|
49
|
+
constructor(message, reason) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.reason = reason;
|
|
52
|
+
this.name = "S3ArtifactBodyError";
|
|
53
|
+
this.code = `ERR_PRISM_S3_${reason}`;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
|
|
57
|
+
const HASH_PATTERN = /^[0-9a-f]{64}$/i;
|
|
58
|
+
const BUCKET_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/;
|
|
59
|
+
const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
|
60
|
+
/** Deterministic object key derived from the ref; bucket/path/key never enter artifact records. */
|
|
61
|
+
export function s3ObjectKey(ref) {
|
|
62
|
+
return `prism-artifacts/${ref.tenantId}/${ref.threadId}/${ref.artifactId}/${ref.version}`;
|
|
63
|
+
}
|
|
64
|
+
function validateRef(ref, limits) {
|
|
65
|
+
if (![ref.tenantId, ref.accountId, ref.userId].some((v) => typeof v === "string" && v.length > 0)) {
|
|
66
|
+
throw new ArtifactBodyStoreError("Ownership is required on every body reference", "OWNERSHIP");
|
|
67
|
+
}
|
|
68
|
+
for (const [name, value] of [
|
|
69
|
+
["threadId", ref.threadId],
|
|
70
|
+
["artifactId", ref.artifactId],
|
|
71
|
+
]) {
|
|
72
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 128 || !ID_PATTERN.test(value)) {
|
|
73
|
+
throw new ArtifactBodyStoreError(`${name} is invalid`, "STORE");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!Number.isSafeInteger(ref.version) || ref.version < 1) {
|
|
77
|
+
throw new ArtifactBodyStoreError("version must be a positive safe integer", "STORE");
|
|
78
|
+
}
|
|
79
|
+
if (!Number.isSafeInteger(ref.size) || ref.size < 0 || ref.size > limits.maxBodyBytes) {
|
|
80
|
+
throw new ArtifactBodyStoreError("size must be a non-negative safe integer at or below maxBodyBytes", "STORE");
|
|
81
|
+
}
|
|
82
|
+
if (typeof ref.hash !== "string" || !HASH_PATTERN.test(ref.hash)) {
|
|
83
|
+
throw new ArtifactBodyStoreError("hash must be a 64-char SHA-256 hex digest", "STORE");
|
|
84
|
+
}
|
|
85
|
+
if (typeof ref.mime !== "string" || ref.mime.length === 0 || Buffer.byteLength(ref.mime, "utf8") > 512) {
|
|
86
|
+
throw new ArtifactBodyStoreError("mime must be a non-empty string at or below 512 bytes", "STORE");
|
|
87
|
+
}
|
|
88
|
+
if (Buffer.byteLength(JSON.stringify(ref), "utf8") > limits.maxRefBytes) {
|
|
89
|
+
throw new ArtifactBodyStoreError("body reference exceeds maxRefBytes", "STORE");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function validateEndpoint(endpoint) {
|
|
93
|
+
let url;
|
|
94
|
+
try {
|
|
95
|
+
url = new URL(endpoint);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
throw new ArtifactBodyStoreError("endpoint must be an absolute URL", "STORE");
|
|
99
|
+
}
|
|
100
|
+
const loopback = LOOPBACK.has(url.hostname);
|
|
101
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
|
|
102
|
+
throw new ArtifactBodyStoreError("endpoint must be https (http only for loopback hosts)", "STORE");
|
|
103
|
+
}
|
|
104
|
+
if (url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "") {
|
|
105
|
+
throw new ArtifactBodyStoreError("endpoint must not carry credentials, query, or fragment", "STORE");
|
|
106
|
+
}
|
|
107
|
+
return url;
|
|
108
|
+
}
|
|
109
|
+
async function readBoundedStream(stream, maxBytes) {
|
|
110
|
+
const reader = stream.getReader();
|
|
111
|
+
const chunks = [];
|
|
112
|
+
let total = 0;
|
|
113
|
+
for (;;) {
|
|
114
|
+
const { done, value } = await reader.read();
|
|
115
|
+
if (done)
|
|
116
|
+
break;
|
|
117
|
+
total += value.byteLength;
|
|
118
|
+
if (total > maxBytes) {
|
|
119
|
+
await reader.cancel();
|
|
120
|
+
throw new ArtifactBodyStoreError("body exceeds maxBodyBytes", "SIZE_MISMATCH");
|
|
121
|
+
}
|
|
122
|
+
chunks.push(value);
|
|
123
|
+
}
|
|
124
|
+
const out = new Uint8Array(total);
|
|
125
|
+
let offset = 0;
|
|
126
|
+
for (const chunk of chunks) {
|
|
127
|
+
out.set(chunk, offset);
|
|
128
|
+
offset += chunk.byteLength;
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
function createSemaphore(max) {
|
|
133
|
+
let active = 0;
|
|
134
|
+
const waiters = [];
|
|
135
|
+
return {
|
|
136
|
+
async run(fn) {
|
|
137
|
+
if (active >= max)
|
|
138
|
+
await new Promise((resolve) => waiters.push(resolve));
|
|
139
|
+
active += 1;
|
|
140
|
+
try {
|
|
141
|
+
return await fn();
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
active -= 1;
|
|
145
|
+
waiters.shift()?.();
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/** Reference S3-compatible ArtifactBodyStore (AWS S3, MinIO, Cloudflare R2). */
|
|
151
|
+
export function createS3ArtifactBodyStore(options) {
|
|
152
|
+
const limits = resolveArtifactBodyLimits(options.limits);
|
|
153
|
+
const base = validateEndpoint(options.endpoint);
|
|
154
|
+
if (typeof options.bucket !== "string" || !BUCKET_PATTERN.test(options.bucket)) {
|
|
155
|
+
throw new ArtifactBodyStoreError("bucket must match [A-Za-z0-9][A-Za-z0-9._-]{0,62}", "STORE");
|
|
156
|
+
}
|
|
157
|
+
const region = options.region ?? "us-east-1";
|
|
158
|
+
if (typeof region !== "string" || region.length === 0 || region.length > 64) {
|
|
159
|
+
throw new ArtifactBodyStoreError("region must be a non-empty string at or below 64 bytes", "STORE");
|
|
160
|
+
}
|
|
161
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
162
|
+
const now = options.now ?? Date.now;
|
|
163
|
+
const semaphore = createSemaphore(limits.maxConcurrentTransfers);
|
|
164
|
+
async function resolveCredentials() {
|
|
165
|
+
let credentials;
|
|
166
|
+
try {
|
|
167
|
+
credentials = await options.credentials();
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
throw new S3ArtifactBodyError(`credential resolution failed: ${error instanceof Error ? error.message : "unknown error"}`, "CREDENTIALS");
|
|
171
|
+
}
|
|
172
|
+
if (typeof credentials?.accessKeyId !== "string" ||
|
|
173
|
+
credentials.accessKeyId.length === 0 ||
|
|
174
|
+
typeof credentials?.secretAccessKey !== "string" ||
|
|
175
|
+
credentials.secretAccessKey.length === 0) {
|
|
176
|
+
throw new S3ArtifactBodyError("credentials must provide non-empty accessKeyId and secretAccessKey", "CREDENTIALS");
|
|
177
|
+
}
|
|
178
|
+
return credentials;
|
|
179
|
+
}
|
|
180
|
+
function amzDate() {
|
|
181
|
+
return new Date(now())
|
|
182
|
+
.toISOString()
|
|
183
|
+
.replace(/[-:]/g, "")
|
|
184
|
+
.replace(/\.\d{3}/, "");
|
|
185
|
+
}
|
|
186
|
+
function objectPath(ref) {
|
|
187
|
+
return `/${options.bucket}/${s3ObjectKey(ref)
|
|
188
|
+
.split("/")
|
|
189
|
+
.map((segment) => encodeURIComponent(segment))
|
|
190
|
+
.join("/")}`;
|
|
191
|
+
}
|
|
192
|
+
async function presignGet(ref, ttlMs, signal) {
|
|
193
|
+
const credentials = await resolveCredentials();
|
|
194
|
+
const date = amzDate();
|
|
195
|
+
const expiresSeconds = Math.ceil(ttlMs / 1000);
|
|
196
|
+
let query;
|
|
197
|
+
try {
|
|
198
|
+
query = await presignV4({
|
|
199
|
+
method: "GET",
|
|
200
|
+
path: objectPath(ref),
|
|
201
|
+
headers: { host: base.host },
|
|
202
|
+
payloadHash: "UNSIGNED-PAYLOAD",
|
|
203
|
+
region,
|
|
204
|
+
service: "s3",
|
|
205
|
+
amzDate: date,
|
|
206
|
+
expiresSeconds,
|
|
207
|
+
accessKeyId: credentials.accessKeyId,
|
|
208
|
+
secretAccessKey: credentials.secretAccessKey,
|
|
209
|
+
...(credentials.sessionToken === undefined ? {} : { sessionToken: credentials.sessionToken }),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
throw new S3ArtifactBodyError(`presign failed: ${error instanceof Error ? error.message : "unknown error"}`, "PRESIGN");
|
|
214
|
+
}
|
|
215
|
+
signal?.throwIfAborted();
|
|
216
|
+
return `${base.origin}${objectPath(ref)}?${query}`;
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
async put(ref, body, transferOptions) {
|
|
220
|
+
validateRef(ref, limits);
|
|
221
|
+
const bytes = body instanceof Uint8Array ? body : await readBoundedStream(body, limits.maxBodyBytes);
|
|
222
|
+
if (bytes.byteLength > limits.maxBodyBytes) {
|
|
223
|
+
throw new ArtifactBodyStoreError("body exceeds maxBodyBytes", "SIZE_MISMATCH");
|
|
224
|
+
}
|
|
225
|
+
if (bytes.byteLength !== ref.size) {
|
|
226
|
+
throw new ArtifactBodyStoreError(`body size ${bytes.byteLength} does not match ref size ${ref.size}`, "SIZE_MISMATCH");
|
|
227
|
+
}
|
|
228
|
+
const hash = await sha256Hex(bytes);
|
|
229
|
+
if (hash !== ref.hash.toLowerCase()) {
|
|
230
|
+
throw new ArtifactBodyStoreError("body SHA-256 does not match the reference hash", "HASH_MISMATCH");
|
|
231
|
+
}
|
|
232
|
+
const payload = options.kms ? await options.kms("encrypt", bytes) : bytes;
|
|
233
|
+
const payloadHash = await sha256Hex(payload);
|
|
234
|
+
const credentials = await resolveCredentials();
|
|
235
|
+
const date = amzDate();
|
|
236
|
+
const path = objectPath(ref);
|
|
237
|
+
const headers = {
|
|
238
|
+
host: base.host,
|
|
239
|
+
"content-type": ref.mime,
|
|
240
|
+
"x-amz-content-sha256": payloadHash,
|
|
241
|
+
};
|
|
242
|
+
const signed = await signRequestV4({
|
|
243
|
+
method: "PUT",
|
|
244
|
+
path,
|
|
245
|
+
headers,
|
|
246
|
+
signedHeaders: ["host", "content-type", "x-amz-content-sha256"],
|
|
247
|
+
payloadHash,
|
|
248
|
+
region,
|
|
249
|
+
service: "s3",
|
|
250
|
+
amzDate: date,
|
|
251
|
+
accessKeyId: credentials.accessKeyId,
|
|
252
|
+
secretAccessKey: credentials.secretAccessKey,
|
|
253
|
+
...(credentials.sessionToken === undefined ? {} : { sessionToken: credentials.sessionToken }),
|
|
254
|
+
});
|
|
255
|
+
try {
|
|
256
|
+
await semaphore.run(async () => {
|
|
257
|
+
// host is signed but not sent explicitly: fetch sets it from the URL.
|
|
258
|
+
const requestHeaders = { ...signed.headers };
|
|
259
|
+
delete requestHeaders.host;
|
|
260
|
+
const response = await fetchImpl(`${base.origin}${path}`, {
|
|
261
|
+
method: "PUT",
|
|
262
|
+
headers: requestHeaders,
|
|
263
|
+
body: payload,
|
|
264
|
+
...(transferOptions?.signal === undefined ? {} : { signal: transferOptions.signal }),
|
|
265
|
+
});
|
|
266
|
+
if (!response.ok) {
|
|
267
|
+
throw new S3ArtifactBodyError(`upload failed with HTTP ${response.status}`, "UPLOAD");
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
if (error instanceof S3ArtifactBodyError)
|
|
273
|
+
throw error;
|
|
274
|
+
throw new S3ArtifactBodyError(`upload failed: ${error instanceof Error ? error.message : "unknown error"}`, "UPLOAD");
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
async get(ref, transferOptions) {
|
|
278
|
+
validateRef(ref, limits);
|
|
279
|
+
const url = await presignGet(ref, limits.presignTtlMs, transferOptions?.signal);
|
|
280
|
+
let response;
|
|
281
|
+
try {
|
|
282
|
+
response = await semaphore.run(async () => fetchImpl(url, { method: "GET", ...(transferOptions?.signal === undefined ? {} : { signal: transferOptions.signal }) }));
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
throw new S3ArtifactBodyError(`download failed: ${error instanceof Error ? error.message : "unknown error"}`, "DOWNLOAD");
|
|
286
|
+
}
|
|
287
|
+
if (!response.ok)
|
|
288
|
+
throw new S3ArtifactBodyError(`download failed with HTTP ${response.status}`, "DOWNLOAD");
|
|
289
|
+
// Fast-fail size/MIME checks (skipped for the stored size when kms is set: the stored
|
|
290
|
+
// bytes are ciphertext, so size is verified on the decrypted plaintext below).
|
|
291
|
+
if (!options.kms) {
|
|
292
|
+
const contentLength = response.headers.get("content-length");
|
|
293
|
+
if (contentLength === null || Number(contentLength) !== ref.size) {
|
|
294
|
+
throw new ArtifactBodyStoreError("download size does not match the reference size", "SIZE_MISMATCH");
|
|
295
|
+
}
|
|
296
|
+
const contentType = response.headers.get("content-type");
|
|
297
|
+
if (contentType === null || contentType !== ref.mime) {
|
|
298
|
+
throw new ArtifactBodyStoreError("download MIME type does not match the reference MIME", "MIME_MISMATCH");
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
let bytes;
|
|
302
|
+
try {
|
|
303
|
+
bytes = await readBoundedStream(response.body ?? new ReadableStream(), limits.maxBodyBytes);
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
if (error instanceof ArtifactBodyStoreError)
|
|
307
|
+
throw error;
|
|
308
|
+
throw new S3ArtifactBodyError(`download failed: ${error instanceof Error ? error.message : "unknown error"}`, "DOWNLOAD");
|
|
309
|
+
}
|
|
310
|
+
if (!options.kms && bytes.byteLength !== ref.size) {
|
|
311
|
+
throw new ArtifactBodyStoreError(`download size ${bytes.byteLength} does not match ref size ${ref.size}`, "SIZE_MISMATCH");
|
|
312
|
+
}
|
|
313
|
+
const plaintext = options.kms ? await options.kms("decrypt", bytes) : bytes;
|
|
314
|
+
if (plaintext.byteLength !== ref.size) {
|
|
315
|
+
throw new ArtifactBodyStoreError(`download size ${plaintext.byteLength} does not match ref size ${ref.size}`, "SIZE_MISMATCH");
|
|
316
|
+
}
|
|
317
|
+
const hash = await sha256Hex(plaintext);
|
|
318
|
+
if (hash !== ref.hash.toLowerCase()) {
|
|
319
|
+
throw new ArtifactBodyStoreError("download SHA-256 does not match the reference hash", "HASH_MISMATCH");
|
|
320
|
+
}
|
|
321
|
+
return new ReadableStream({
|
|
322
|
+
start(controller) {
|
|
323
|
+
controller.enqueue(plaintext);
|
|
324
|
+
controller.close();
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
},
|
|
328
|
+
async delete(ref, transferOptions) {
|
|
329
|
+
validateRef(ref, limits);
|
|
330
|
+
if (options.isHeld) {
|
|
331
|
+
let held;
|
|
332
|
+
try {
|
|
333
|
+
held = await options.isHeld(ref);
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
throw new ArtifactBodyStoreError(`hold check failed: ${error instanceof Error ? error.message : "unknown error"}`, "STORE");
|
|
337
|
+
}
|
|
338
|
+
if (held)
|
|
339
|
+
throw new ArtifactBodyStoreError("delete refused: resource is under legal hold", "HELD");
|
|
340
|
+
}
|
|
341
|
+
const credentials = await resolveCredentials();
|
|
342
|
+
const date = amzDate();
|
|
343
|
+
const path = objectPath(ref);
|
|
344
|
+
let query;
|
|
345
|
+
try {
|
|
346
|
+
query = await presignV4({
|
|
347
|
+
method: "DELETE",
|
|
348
|
+
path,
|
|
349
|
+
headers: { host: base.host },
|
|
350
|
+
payloadHash: "UNSIGNED-PAYLOAD",
|
|
351
|
+
region,
|
|
352
|
+
service: "s3",
|
|
353
|
+
amzDate: date,
|
|
354
|
+
expiresSeconds: Math.ceil(limits.presignTtlMs / 1000),
|
|
355
|
+
accessKeyId: credentials.accessKeyId,
|
|
356
|
+
secretAccessKey: credentials.secretAccessKey,
|
|
357
|
+
...(credentials.sessionToken === undefined ? {} : { sessionToken: credentials.sessionToken }),
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
throw new S3ArtifactBodyError(`presign failed: ${error instanceof Error ? error.message : "unknown error"}`, "PRESIGN");
|
|
362
|
+
}
|
|
363
|
+
try {
|
|
364
|
+
await semaphore.run(async () => {
|
|
365
|
+
const response = await fetchImpl(`${base.origin}${path}?${query}`, {
|
|
366
|
+
method: "DELETE",
|
|
367
|
+
...(transferOptions?.signal === undefined ? {} : { signal: transferOptions.signal }),
|
|
368
|
+
});
|
|
369
|
+
// 204 and 404 are both success: body delete is idempotent.
|
|
370
|
+
if (response.status !== 204 && response.status !== 404) {
|
|
371
|
+
throw new S3ArtifactBodyError(`delete failed with HTTP ${response.status}`, "DELETE");
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
if (error instanceof S3ArtifactBodyError)
|
|
377
|
+
throw error;
|
|
378
|
+
throw new S3ArtifactBodyError(`delete failed: ${error instanceof Error ? error.message : "unknown error"}`, "DELETE");
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
async presign(ref, presignOptions) {
|
|
382
|
+
validateRef(ref, limits);
|
|
383
|
+
const ttlMs = presignOptions?.ttlMs ?? limits.presignTtlMs;
|
|
384
|
+
if (!Number.isSafeInteger(ttlMs) || ttlMs < 1 || ttlMs > limits.presignTtlMs) {
|
|
385
|
+
throw new ArtifactBodyStoreError("presign TTL must be a positive safe integer at or below presignTtlMs", "STORE");
|
|
386
|
+
}
|
|
387
|
+
return presignGet(ref, ttlMs, presignOptions?.signal);
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
//# sourceMappingURL=artifact-bodies.js.map
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { type AgentIdentity, type ArtifactBodyStore, type ArtifactCitation, type ArtifactDeliveryToken, type ArtifactRecord, type ArtifactRevision, type CheckpointStore, type OwnershipScope, type PersistencePage, type SecretRedactor } from "@arnilo/prism";
|
|
2
|
+
import type { PrismRequestHandler, PrismServerAuthorization } from "./types.js";
|
|
3
|
+
/** Phase 9 freeze: artifacts/thread 64/256; revisions 32/128; record 8/64 KiB; preview 16/64 KiB;
|
|
4
|
+
* citations 32/128 and 2/8 KiB each; mime 128/512 B; hash 256/1 KiB; delivery TTL 5 min/24 h;
|
|
5
|
+
* delivery token 4/16 KiB. Compare is exactly 2 revisions (hash+metadata only; host renders content). */
|
|
6
|
+
export declare const DEFAULT_ARTIFACTS_PER_THREAD = 64;
|
|
7
|
+
export declare const HARD_ARTIFACTS_PER_THREAD = 256;
|
|
8
|
+
export declare const DEFAULT_ARTIFACT_REVISIONS = 32;
|
|
9
|
+
export declare const HARD_ARTIFACT_REVISIONS = 128;
|
|
10
|
+
export declare const DEFAULT_ARTIFACT_RECORD_BYTES: number;
|
|
11
|
+
export declare const HARD_ARTIFACT_RECORD_BYTES: number;
|
|
12
|
+
export declare const DEFAULT_ARTIFACT_PREVIEW_BYTES: number;
|
|
13
|
+
export declare const HARD_ARTIFACT_PREVIEW_BYTES: number;
|
|
14
|
+
export declare const DEFAULT_ARTIFACT_CITATIONS = 32;
|
|
15
|
+
export declare const HARD_ARTIFACT_CITATIONS = 128;
|
|
16
|
+
export declare const DEFAULT_ARTIFACT_CITATION_BYTES: number;
|
|
17
|
+
export declare const HARD_ARTIFACT_CITATION_BYTES: number;
|
|
18
|
+
export declare const DEFAULT_ARTIFACT_MIME_BYTES = 128;
|
|
19
|
+
export declare const HARD_ARTIFACT_MIME_BYTES = 512;
|
|
20
|
+
export declare const DEFAULT_ARTIFACT_HASH_BYTES = 256;
|
|
21
|
+
export declare const HARD_ARTIFACT_HASH_BYTES = 1024;
|
|
22
|
+
export declare const DEFAULT_ARTIFACT_URI_BYTES: number;
|
|
23
|
+
export declare const HARD_ARTIFACT_URI_BYTES: number;
|
|
24
|
+
export declare const DEFAULT_ARTIFACT_NOTE_BYTES = 1024;
|
|
25
|
+
export declare const HARD_ARTIFACT_NOTE_BYTES: number;
|
|
26
|
+
export declare const DEFAULT_ARTIFACT_TITLE_BYTES = 256;
|
|
27
|
+
export declare const HARD_ARTIFACT_TITLE_BYTES: number;
|
|
28
|
+
export declare const DEFAULT_ARTIFACT_LIST_PAGE_LIMIT = 50;
|
|
29
|
+
export declare const HARD_ARTIFACT_LIST_PAGE_LIMIT = 200;
|
|
30
|
+
export declare const DEFAULT_DELIVERY_LINK_TTL_SECONDS = 300;
|
|
31
|
+
export declare const HARD_DELIVERY_LINK_TTL_SECONDS: number;
|
|
32
|
+
export declare const DEFAULT_DELIVERY_LINK_TOKEN_BYTES: number;
|
|
33
|
+
export declare const HARD_DELIVERY_LINK_TOKEN_BYTES: number;
|
|
34
|
+
export declare const DEFAULT_ARTIFACT_REQUEST_BYTES: number;
|
|
35
|
+
export declare const HARD_ARTIFACT_REQUEST_BYTES: number;
|
|
36
|
+
export interface ArtifactLimits {
|
|
37
|
+
readonly artifactsPerThread?: number;
|
|
38
|
+
readonly revisionsPerArtifact?: number;
|
|
39
|
+
readonly recordBytes?: number;
|
|
40
|
+
readonly previewBytes?: number;
|
|
41
|
+
readonly citations?: number;
|
|
42
|
+
readonly citationBytes?: number;
|
|
43
|
+
readonly mimeBytes?: number;
|
|
44
|
+
readonly hashBytes?: number;
|
|
45
|
+
readonly uriBytes?: number;
|
|
46
|
+
readonly noteBytes?: number;
|
|
47
|
+
readonly titleBytes?: number;
|
|
48
|
+
readonly listPageLimit?: number;
|
|
49
|
+
readonly deliveryLinkTtlSeconds?: number;
|
|
50
|
+
readonly deliveryLinkTokenBytes?: number;
|
|
51
|
+
readonly maxRequestBytes?: number;
|
|
52
|
+
}
|
|
53
|
+
export interface ResolvedArtifactLimits {
|
|
54
|
+
readonly artifactsPerThread: number;
|
|
55
|
+
readonly revisionsPerArtifact: number;
|
|
56
|
+
readonly recordBytes: number;
|
|
57
|
+
readonly previewBytes: number;
|
|
58
|
+
readonly citations: number;
|
|
59
|
+
readonly citationBytes: number;
|
|
60
|
+
readonly mimeBytes: number;
|
|
61
|
+
readonly hashBytes: number;
|
|
62
|
+
readonly uriBytes: number;
|
|
63
|
+
readonly noteBytes: number;
|
|
64
|
+
readonly titleBytes: number;
|
|
65
|
+
readonly listPageLimit: number;
|
|
66
|
+
readonly deliveryLinkTtlSeconds: number;
|
|
67
|
+
readonly deliveryLinkTokenBytes: number;
|
|
68
|
+
readonly maxRequestBytes: number;
|
|
69
|
+
}
|
|
70
|
+
export declare function resolveArtifactLimits(input?: ArtifactLimits): ResolvedArtifactLimits;
|
|
71
|
+
export interface ArtifactServiceInput {
|
|
72
|
+
readonly ownership: OwnershipScope;
|
|
73
|
+
readonly identity?: AgentIdentity;
|
|
74
|
+
readonly signal?: AbortSignal;
|
|
75
|
+
}
|
|
76
|
+
export interface ArtifactAttachInput extends ArtifactServiceInput {
|
|
77
|
+
readonly threadId: string;
|
|
78
|
+
readonly uri: string;
|
|
79
|
+
readonly mime: string;
|
|
80
|
+
readonly hash: string;
|
|
81
|
+
/** Expected body byte length; required when a blob store is wired for delivery. */
|
|
82
|
+
readonly size?: number;
|
|
83
|
+
/** Explicit id makes attach idempotent (get-or-create). Generated when omitted. */
|
|
84
|
+
readonly id?: string;
|
|
85
|
+
readonly title?: string;
|
|
86
|
+
readonly changeNote?: string;
|
|
87
|
+
readonly producerRunId?: string;
|
|
88
|
+
readonly citations?: readonly ArtifactCitation[];
|
|
89
|
+
readonly preview?: Readonly<Record<string, unknown>>;
|
|
90
|
+
}
|
|
91
|
+
export interface ArtifactListInput extends ArtifactServiceInput {
|
|
92
|
+
readonly threadId: string;
|
|
93
|
+
readonly cursor?: string;
|
|
94
|
+
readonly limit?: number;
|
|
95
|
+
}
|
|
96
|
+
export interface ArtifactRefInput extends ArtifactServiceInput {
|
|
97
|
+
readonly threadId: string;
|
|
98
|
+
readonly artifactId: string;
|
|
99
|
+
}
|
|
100
|
+
export interface ArtifactReviseInput extends ArtifactRefInput {
|
|
101
|
+
readonly uri: string;
|
|
102
|
+
/** Defaults to the previous revision's mime when omitted. */
|
|
103
|
+
readonly mime?: string;
|
|
104
|
+
readonly hash: string;
|
|
105
|
+
/** Expected body byte length; required when a blob store is wired for delivery. */
|
|
106
|
+
readonly size?: number;
|
|
107
|
+
readonly changeNote?: string;
|
|
108
|
+
readonly producerRunId?: string;
|
|
109
|
+
readonly citations?: readonly ArtifactCitation[];
|
|
110
|
+
readonly preview?: Readonly<Record<string, unknown>>;
|
|
111
|
+
}
|
|
112
|
+
export interface ArtifactCompareInput extends ArtifactRefInput {
|
|
113
|
+
readonly from: number;
|
|
114
|
+
readonly to: number;
|
|
115
|
+
}
|
|
116
|
+
export interface ArtifactCompareResult {
|
|
117
|
+
readonly artifactId: string;
|
|
118
|
+
readonly from: ArtifactRevision;
|
|
119
|
+
readonly to: ArtifactRevision;
|
|
120
|
+
/** Hash+metadata-bounded change flags; the host renders content bodies. */
|
|
121
|
+
readonly changed: {
|
|
122
|
+
readonly hash: boolean;
|
|
123
|
+
readonly mime: boolean;
|
|
124
|
+
readonly uri: boolean;
|
|
125
|
+
readonly citations: boolean;
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
export interface ArtifactDecisionInput extends ArtifactRefInput {
|
|
129
|
+
readonly version: number;
|
|
130
|
+
readonly note?: string;
|
|
131
|
+
/** Redacted reviewer ref; derived from identity when omitted. */
|
|
132
|
+
readonly reviewer?: string;
|
|
133
|
+
}
|
|
134
|
+
export interface ArtifactDeliveryInput extends ArtifactRefInput {
|
|
135
|
+
/** Defaults to the last validated revision, else the latest. */
|
|
136
|
+
readonly version?: number;
|
|
137
|
+
readonly ttlSeconds?: number;
|
|
138
|
+
}
|
|
139
|
+
export interface ArtifactDeliveryResult {
|
|
140
|
+
readonly link: string;
|
|
141
|
+
readonly token: ArtifactDeliveryToken;
|
|
142
|
+
/** Presigned blob-store delivery URL; present only when a body store is wired. */
|
|
143
|
+
readonly url?: string;
|
|
144
|
+
}
|
|
145
|
+
export type ArtifactDecisionEvent = {
|
|
146
|
+
readonly type: "artifact_attached" | "artifact_revised";
|
|
147
|
+
readonly artifactId: string;
|
|
148
|
+
readonly threadId: string;
|
|
149
|
+
readonly version: number;
|
|
150
|
+
readonly actor?: string;
|
|
151
|
+
readonly timestamp: string;
|
|
152
|
+
} | {
|
|
153
|
+
readonly type: "artifact_approved" | "artifact_rejected";
|
|
154
|
+
readonly artifactId: string;
|
|
155
|
+
readonly threadId: string;
|
|
156
|
+
readonly version: number;
|
|
157
|
+
readonly reviewer: string;
|
|
158
|
+
readonly timestamp: string;
|
|
159
|
+
};
|
|
160
|
+
export interface CreateArtifactServiceOptions {
|
|
161
|
+
/** Required: records are redacted before persist and on every response. */
|
|
162
|
+
readonly redactor: SecretRedactor;
|
|
163
|
+
/** Host HMAC key material for signing/verifying delivery links. */
|
|
164
|
+
readonly linkSecret: string;
|
|
165
|
+
readonly limits?: ArtifactLimits;
|
|
166
|
+
/** Optional blob store: delivery links then resolve through `bodies.presign`. */
|
|
167
|
+
readonly bodies?: ArtifactBodyStore;
|
|
168
|
+
/** Audit seam (redacted refs only); hosts bridge to @arnilo/prism-policy. */
|
|
169
|
+
readonly onDecision?: (event: ArtifactDecisionEvent) => void | Promise<void>;
|
|
170
|
+
}
|
|
171
|
+
export interface ArtifactService {
|
|
172
|
+
attach(input: ArtifactAttachInput): Promise<ArtifactRecord>;
|
|
173
|
+
list(input: ArtifactListInput): Promise<PersistencePage<ArtifactRecord>>;
|
|
174
|
+
get(input: ArtifactRefInput): Promise<ArtifactRecord>;
|
|
175
|
+
revise(input: ArtifactReviseInput): Promise<ArtifactRecord>;
|
|
176
|
+
compare(input: ArtifactCompareInput): Promise<ArtifactCompareResult>;
|
|
177
|
+
approve(input: ArtifactDecisionInput): Promise<ArtifactRecord>;
|
|
178
|
+
reject(input: ArtifactDecisionInput): Promise<ArtifactRecord>;
|
|
179
|
+
lastValidated(input: ArtifactRefInput): Promise<ArtifactRevision>;
|
|
180
|
+
deliveryLink(input: ArtifactDeliveryInput): Promise<ArtifactDeliveryResult>;
|
|
181
|
+
}
|
|
182
|
+
export declare function createArtifactService(store: CheckpointStore, options: CreateArtifactServiceOptions): ArtifactService;
|
|
183
|
+
/** Sign an expiring delivery token: base64url(payload).base64url(HMAC-SHA256). */
|
|
184
|
+
export declare function signArtifactDeliveryLink(token: ArtifactDeliveryToken, secret: string): string;
|
|
185
|
+
/** Verify signature + expiry and parse a delivery link. Fail-closed on any tamper/expiry. */
|
|
186
|
+
export declare function verifyArtifactDeliveryLink(link: string, secret: string, maxBytes?: number): ArtifactDeliveryToken;
|
|
187
|
+
export type ArtifactOperation = "artifact.attach" | "artifact.list" | "artifact.get" | "artifact.revise" | "artifact.compare" | "artifact.approve" | "artifact.reject" | "artifact.last-validated" | "artifact.delivery-link" | "artifact.download";
|
|
188
|
+
export interface ArtifactAuthorizationInput {
|
|
189
|
+
readonly request: Request;
|
|
190
|
+
readonly operation: ArtifactOperation;
|
|
191
|
+
readonly threadId?: string;
|
|
192
|
+
readonly artifactId?: string;
|
|
193
|
+
/** Present for download: the verified delivery token to reauthorize against. */
|
|
194
|
+
readonly deliveryToken?: ArtifactDeliveryToken;
|
|
195
|
+
readonly signal: AbortSignal;
|
|
196
|
+
}
|
|
197
|
+
export type ArtifactAuthorizer = (input: ArtifactAuthorizationInput) => false | PrismServerAuthorization | Promise<false | PrismServerAuthorization>;
|
|
198
|
+
export interface CreateArtifactHandlerOptions {
|
|
199
|
+
readonly service: ArtifactService;
|
|
200
|
+
readonly authorize: ArtifactAuthorizer;
|
|
201
|
+
readonly linkSecret: string;
|
|
202
|
+
readonly basePath?: string;
|
|
203
|
+
readonly redactor?: SecretRedactor;
|
|
204
|
+
readonly limits?: ArtifactLimits;
|
|
205
|
+
}
|
|
206
|
+
/** Framework-free HTTP adapter for one mounted artifact service (default base `/prism/artifacts`). */
|
|
207
|
+
export declare function createArtifactHandler(options: CreateArtifactHandlerOptions): PrismRequestHandler;
|