@arnilo/prism-server 0.0.27 → 0.1.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/CHANGELOG.md +11 -0
- 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 +9 -1
- package/dist/artifacts.js +39 -3
- package/package.json +7 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.0] - 2026-08-09
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
- Released with exact 0.1.0 graph.
|
|
7
|
+
|
|
8
|
+
## [0.0.28] - 2026-08-08
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `createArtifactService` accepts an optional `bodies: ArtifactBodyStore` (core contract from `@arnilo/prism`): when wired, `deliveryLink` resolves through `bodies.presign` and returns an additional bounded-TTL presigned `url` beside the signed link/token; revisions without a recorded `size` fail closed at delivery. `attach`/`revise` accept an optional `size` (validated non-negative safe integer) recorded on the revision.
|
|
12
|
+
- New `@arnilo/prism-server/artifact-bodies` subpath: `createS3ArtifactBodyStore` — reference S3-compatible `ArtifactBodyStore` (AWS S3, MinIO, Cloudflare R2) with hand-rolled SigV4 presigning over native fetch + WebCrypto (validated against the official AWS sig-v4-test-suite get-vanilla vector), path-style addressing, single-chunk PUT with exact Content-Length and verified `x-amz-content-sha256`, ownership verification on every operation, size/SHA-256/MIME verification on put and get (fail closed), legal-hold-aware idempotent delete (host `isHeld` callback), host-resolved credentials (never inline), optional host-owned client-side KMS callback, bounded concurrent transfers, and no bucket/path/key disclosure in errors. `S3ArtifactBodyError` carries frozen `ERR_PRISM_S3_*` codes; limits `maxBodyBytes` 64 MiB/512 MiB, `maxConcurrentTransfers` 4/16, `presignTtlMs` 10 min/24 h, `maxRefBytes` 256 B/1 KiB.
|
|
13
|
+
|
|
3
14
|
## [0.0.27] - 2026-08-07
|
|
4
15
|
|
|
5
16
|
### Changed
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hand-rolled AWS Signature V4 over native WebCrypto (crypto.subtle) — no SDK dependency.
|
|
3
|
+
* Validated against the official AWS sig-v4-test-suite get-vanilla vector in
|
|
4
|
+
* `src/__tests__/artifact-bodies.test.ts`. S3-specific presigning (query-string auth,
|
|
5
|
+
* UNSIGNED-PAYLOAD for GET/DELETE, signed x-amz-content-sha256 for single-chunk PUT)
|
|
6
|
+
* lives in `artifact-bodies.ts`; this module is the generic algorithm.
|
|
7
|
+
*/
|
|
8
|
+
/** RFC 3986 percent-encoding as required by SigV4 (encodeURIComponent leaves !'()* unencoded). */
|
|
9
|
+
export declare function awsUriEncode(value: string): string;
|
|
10
|
+
/** SHA-256 hex digest over WebCrypto. */
|
|
11
|
+
export declare function sha256Hex(data: Uint8Array): Promise<string>;
|
|
12
|
+
/** Canonical query string: sorted keys, RFC 3986 encoded, `key=value` joined with `&`. */
|
|
13
|
+
export declare function canonicalQueryString(query: Readonly<Record<string, string>>): string;
|
|
14
|
+
/** Canonical headers block: lowercase name, trimmed/collapsed value, trailing newline. */
|
|
15
|
+
export declare function canonicalHeaders(headers: Readonly<Record<string, string>>, signedHeaders: readonly string[]): string;
|
|
16
|
+
export interface SigV4SignInput {
|
|
17
|
+
readonly method: string;
|
|
18
|
+
/** Pre-encoded path (e.g. `/bucket/key`); used verbatim. */
|
|
19
|
+
readonly path: string;
|
|
20
|
+
readonly query?: Readonly<Record<string, string>>;
|
|
21
|
+
/** Lowercase header names. */
|
|
22
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
23
|
+
/** Subset of header names, sorted; the caller decides what to sign. */
|
|
24
|
+
readonly signedHeaders: readonly string[];
|
|
25
|
+
/** Hex SHA-256 of the payload, or "UNSIGNED-PAYLOAD". */
|
|
26
|
+
readonly payloadHash: string;
|
|
27
|
+
readonly region: string;
|
|
28
|
+
readonly service: string;
|
|
29
|
+
/** `YYYYMMDDTHHMMSSZ`. */
|
|
30
|
+
readonly amzDate: string;
|
|
31
|
+
readonly accessKeyId: string;
|
|
32
|
+
readonly secretAccessKey: string;
|
|
33
|
+
readonly sessionToken?: string;
|
|
34
|
+
}
|
|
35
|
+
export interface SigV4Signature {
|
|
36
|
+
readonly signature: string;
|
|
37
|
+
readonly canonicalRequest: string;
|
|
38
|
+
}
|
|
39
|
+
/** Full SigV4 pipeline: canonical request -> string-to-sign -> derived-key HMAC. */
|
|
40
|
+
export declare function signV4(input: SigV4SignInput): Promise<SigV4Signature>;
|
|
41
|
+
export interface SigV4PresignInput {
|
|
42
|
+
readonly method: string;
|
|
43
|
+
readonly path: string;
|
|
44
|
+
readonly query?: Readonly<Record<string, string>>;
|
|
45
|
+
/** Lowercase header names; only `host` is signed for presigned URLs. */
|
|
46
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
47
|
+
readonly payloadHash: string;
|
|
48
|
+
readonly region: string;
|
|
49
|
+
readonly service: string;
|
|
50
|
+
readonly amzDate: string;
|
|
51
|
+
readonly expiresSeconds: number;
|
|
52
|
+
readonly accessKeyId: string;
|
|
53
|
+
readonly secretAccessKey: string;
|
|
54
|
+
readonly sessionToken?: string;
|
|
55
|
+
}
|
|
56
|
+
/** Presign: returns the full query string (X-Amz-* params + X-Amz-Signature) for a URL. */
|
|
57
|
+
export declare function presignV4(input: SigV4PresignInput): Promise<string>;
|
|
58
|
+
export interface SigV4RequestSignInput {
|
|
59
|
+
readonly method: string;
|
|
60
|
+
readonly path: string;
|
|
61
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
62
|
+
/** Headers to sign (subset of `headers`, sorted); the caller sends exactly these. */
|
|
63
|
+
readonly signedHeaders: readonly string[];
|
|
64
|
+
readonly payloadHash: string;
|
|
65
|
+
readonly region: string;
|
|
66
|
+
readonly service: string;
|
|
67
|
+
readonly amzDate: string;
|
|
68
|
+
readonly accessKeyId: string;
|
|
69
|
+
readonly secretAccessKey: string;
|
|
70
|
+
readonly sessionToken?: string;
|
|
71
|
+
}
|
|
72
|
+
/** Sign a direct request: returns headers including x-amz-date, x-amz-content-sha256, and Authorization. */
|
|
73
|
+
export declare function signRequestV4(input: SigV4RequestSignInput): Promise<{
|
|
74
|
+
readonly headers: Record<string, string>;
|
|
75
|
+
}>;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hand-rolled AWS Signature V4 over native WebCrypto (crypto.subtle) — no SDK dependency.
|
|
3
|
+
* Validated against the official AWS sig-v4-test-suite get-vanilla vector in
|
|
4
|
+
* `src/__tests__/artifact-bodies.test.ts`. S3-specific presigning (query-string auth,
|
|
5
|
+
* UNSIGNED-PAYLOAD for GET/DELETE, signed x-amz-content-sha256 for single-chunk PUT)
|
|
6
|
+
* lives in `artifact-bodies.ts`; this module is the generic algorithm.
|
|
7
|
+
*/
|
|
8
|
+
/** RFC 3986 percent-encoding as required by SigV4 (encodeURIComponent leaves !'()* unencoded). */
|
|
9
|
+
export function awsUriEncode(value) {
|
|
10
|
+
return encodeURIComponent(value).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
11
|
+
}
|
|
12
|
+
/** SHA-256 hex digest over WebCrypto. */
|
|
13
|
+
export async function sha256Hex(data) {
|
|
14
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
15
|
+
return Buffer.from(digest).toString("hex");
|
|
16
|
+
}
|
|
17
|
+
async function hmacSha256(key, data) {
|
|
18
|
+
const imported = await crypto.subtle.importKey("raw", key, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
19
|
+
const signature = await crypto.subtle.sign("HMAC", imported, data);
|
|
20
|
+
return new Uint8Array(signature);
|
|
21
|
+
}
|
|
22
|
+
/** Canonical query string: sorted keys, RFC 3986 encoded, `key=value` joined with `&`. */
|
|
23
|
+
export function canonicalQueryString(query) {
|
|
24
|
+
return Object.keys(query)
|
|
25
|
+
.sort()
|
|
26
|
+
.map((key) => `${awsUriEncode(key)}=${awsUriEncode(query[key])}`)
|
|
27
|
+
.join("&");
|
|
28
|
+
}
|
|
29
|
+
/** Canonical headers block: lowercase name, trimmed/collapsed value, trailing newline. */
|
|
30
|
+
export function canonicalHeaders(headers, signedHeaders) {
|
|
31
|
+
return signedHeaders.map((name) => `${name}:${(headers[name] ?? "").trim().replace(/\s+/g, " ")}\n`).join("");
|
|
32
|
+
}
|
|
33
|
+
/** Full SigV4 pipeline: canonical request -> string-to-sign -> derived-key HMAC. */
|
|
34
|
+
export async function signV4(input) {
|
|
35
|
+
const date = input.amzDate.slice(0, 8);
|
|
36
|
+
const query = canonicalQueryString(input.query ?? {});
|
|
37
|
+
const headers = canonicalHeaders(input.headers, input.signedHeaders);
|
|
38
|
+
const signedNames = input.signedHeaders.join(";");
|
|
39
|
+
const canonicalRequest = `${input.method}\n${input.path}\n${query}\n${headers}\n${signedNames}\n${input.payloadHash}`;
|
|
40
|
+
const scope = `${date}/${input.region}/${input.service}/aws4_request`;
|
|
41
|
+
const stringToSign = `AWS4-HMAC-SHA256\n${input.amzDate}\n${scope}\n${await sha256Hex(new TextEncoder().encode(canonicalRequest))}`;
|
|
42
|
+
const kDate = await hmacSha256(new TextEncoder().encode(`AWS4${input.secretAccessKey}`), new TextEncoder().encode(date));
|
|
43
|
+
const kRegion = await hmacSha256(kDate, new TextEncoder().encode(input.region));
|
|
44
|
+
const kService = await hmacSha256(kRegion, new TextEncoder().encode(input.service));
|
|
45
|
+
const kSigning = await hmacSha256(kService, new TextEncoder().encode("aws4_request"));
|
|
46
|
+
const signature = Buffer.from(await hmacSha256(kSigning, new TextEncoder().encode(stringToSign))).toString("hex");
|
|
47
|
+
return { signature, canonicalRequest };
|
|
48
|
+
}
|
|
49
|
+
/** Presign: returns the full query string (X-Amz-* params + X-Amz-Signature) for a URL. */
|
|
50
|
+
export async function presignV4(input) {
|
|
51
|
+
const date = input.amzDate.slice(0, 8);
|
|
52
|
+
const scope = `${date}/${input.region}/${input.service}/aws4_request`;
|
|
53
|
+
const signedHeaders = ["host"];
|
|
54
|
+
const query = {
|
|
55
|
+
"X-Amz-Algorithm": "AWS4-HMAC-SHA256",
|
|
56
|
+
"X-Amz-Credential": `${input.accessKeyId}/${scope}`,
|
|
57
|
+
"X-Amz-Date": input.amzDate,
|
|
58
|
+
"X-Amz-Expires": String(input.expiresSeconds),
|
|
59
|
+
"X-Amz-SignedHeaders": signedHeaders.join(";"),
|
|
60
|
+
...(input.sessionToken === undefined ? {} : { "X-Amz-Security-Token": input.sessionToken }),
|
|
61
|
+
...input.query,
|
|
62
|
+
};
|
|
63
|
+
const { signature } = await signV4({
|
|
64
|
+
method: input.method,
|
|
65
|
+
path: input.path,
|
|
66
|
+
query,
|
|
67
|
+
headers: input.headers,
|
|
68
|
+
signedHeaders,
|
|
69
|
+
payloadHash: input.payloadHash,
|
|
70
|
+
region: input.region,
|
|
71
|
+
service: input.service,
|
|
72
|
+
amzDate: input.amzDate,
|
|
73
|
+
accessKeyId: input.accessKeyId,
|
|
74
|
+
secretAccessKey: input.secretAccessKey,
|
|
75
|
+
sessionToken: input.sessionToken,
|
|
76
|
+
});
|
|
77
|
+
return canonicalQueryString({ ...query, "X-Amz-Signature": signature });
|
|
78
|
+
}
|
|
79
|
+
/** Sign a direct request: returns headers including x-amz-date, x-amz-content-sha256, and Authorization. */
|
|
80
|
+
export async function signRequestV4(input) {
|
|
81
|
+
const date = input.amzDate.slice(0, 8);
|
|
82
|
+
const scope = `${date}/${input.region}/${input.service}/aws4_request`;
|
|
83
|
+
const headers = {
|
|
84
|
+
...input.headers,
|
|
85
|
+
"x-amz-date": input.amzDate,
|
|
86
|
+
"x-amz-content-sha256": input.payloadHash,
|
|
87
|
+
};
|
|
88
|
+
const { signature } = await signV4({
|
|
89
|
+
method: input.method,
|
|
90
|
+
path: input.path,
|
|
91
|
+
headers,
|
|
92
|
+
signedHeaders: input.signedHeaders,
|
|
93
|
+
payloadHash: input.payloadHash,
|
|
94
|
+
region: input.region,
|
|
95
|
+
service: input.service,
|
|
96
|
+
amzDate: input.amzDate,
|
|
97
|
+
accessKeyId: input.accessKeyId,
|
|
98
|
+
secretAccessKey: input.secretAccessKey,
|
|
99
|
+
sessionToken: input.sessionToken,
|
|
100
|
+
});
|
|
101
|
+
headers.authorization = `AWS4-HMAC-SHA256 Credential=${input.accessKeyId}/${scope}, SignedHeaders=${input.signedHeaders.join(";")}, Signature=${signature}`;
|
|
102
|
+
return { headers };
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=artifact-bodies-s3.js.map
|
|
@@ -0,0 +1,70 @@
|
|
|
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 { type ArtifactBodyRef, type ArtifactBodyStore } from "@arnilo/prism";
|
|
13
|
+
/** Host-resolved S3 credentials; never inline, never logged. */
|
|
14
|
+
export interface S3Credentials {
|
|
15
|
+
readonly accessKeyId: string;
|
|
16
|
+
readonly secretAccessKey: string;
|
|
17
|
+
/** Optional session token (STS); included in the signature scope when present. */
|
|
18
|
+
readonly sessionToken?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface ArtifactBodyLimits {
|
|
21
|
+
readonly maxBodyBytes?: number;
|
|
22
|
+
readonly maxConcurrentTransfers?: number;
|
|
23
|
+
readonly presignTtlMs?: number;
|
|
24
|
+
readonly maxRefBytes?: number;
|
|
25
|
+
}
|
|
26
|
+
export interface ResolvedArtifactBodyLimits {
|
|
27
|
+
readonly maxBodyBytes: number;
|
|
28
|
+
readonly maxConcurrentTransfers: number;
|
|
29
|
+
readonly presignTtlMs: number;
|
|
30
|
+
readonly maxRefBytes: number;
|
|
31
|
+
}
|
|
32
|
+
/** Phase 11 freeze: body 64 MiB/512 MiB; concurrent transfers 4/16; presign TTL 10 min/24 h; ref 256 B/1 KiB. */
|
|
33
|
+
export declare const DEFAULT_ARTIFACT_BODY_LIMITS: ResolvedArtifactBodyLimits;
|
|
34
|
+
export declare const HARD_ARTIFACT_BODY_LIMITS: ResolvedArtifactBodyLimits;
|
|
35
|
+
export declare function resolveArtifactBodyLimits(input?: ArtifactBodyLimits): ResolvedArtifactBodyLimits;
|
|
36
|
+
export interface S3ArtifactBodyStoreOptions {
|
|
37
|
+
/** Absolute base URL of the S3-compatible endpoint (https; http only for loopback hosts). */
|
|
38
|
+
readonly endpoint: string;
|
|
39
|
+
/** Bucket name (path-style addressing; no slashes). */
|
|
40
|
+
readonly bucket: string;
|
|
41
|
+
/** SigV4 region scope; default "us-east-1". */
|
|
42
|
+
readonly region?: string;
|
|
43
|
+
/** Host resolver for credentials; never inline keys. */
|
|
44
|
+
readonly credentials: () => S3Credentials | Promise<S3Credentials>;
|
|
45
|
+
/**
|
|
46
|
+
* Optional host-owned client-side encryption: `encrypt` runs before upload, `decrypt`
|
|
47
|
+
* after download. The ref hash/size always refer to the plaintext; the stored bytes are
|
|
48
|
+
* the ciphertext (so size is verified on the decrypted plaintext when kms is set).
|
|
49
|
+
*/
|
|
50
|
+
readonly kms?: (op: "encrypt" | "decrypt", body: Uint8Array) => Promise<Uint8Array>;
|
|
51
|
+
/** Optional legal-hold check; delete refuses (ERR_PRISM_ARTIFACT_BODY_HELD) when held. */
|
|
52
|
+
readonly isHeld?: (ref: ArtifactBodyRef) => boolean | Promise<boolean>;
|
|
53
|
+
readonly limits?: ArtifactBodyLimits;
|
|
54
|
+
/** Test seam; defaults to global fetch. */
|
|
55
|
+
readonly fetch?: typeof fetch;
|
|
56
|
+
/** Test seam; defaults to Date.now. */
|
|
57
|
+
readonly now?: () => number;
|
|
58
|
+
}
|
|
59
|
+
/** Frozen S3 adapter failure reasons. */
|
|
60
|
+
export type S3ArtifactBodyErrorCode = "PRESIGN" | "UPLOAD" | "DOWNLOAD" | "DELETE" | "CREDENTIALS";
|
|
61
|
+
/** Typed S3 adapter failure; `code` is one of the frozen ERR_PRISM_S3_* codes. */
|
|
62
|
+
export declare class S3ArtifactBodyError extends Error {
|
|
63
|
+
readonly reason: S3ArtifactBodyErrorCode;
|
|
64
|
+
readonly code: `ERR_PRISM_S3_${S3ArtifactBodyErrorCode}`;
|
|
65
|
+
constructor(message: string, reason: S3ArtifactBodyErrorCode);
|
|
66
|
+
}
|
|
67
|
+
/** Deterministic object key derived from the ref; bucket/path/key never enter artifact records. */
|
|
68
|
+
export declare function s3ObjectKey(ref: ArtifactBodyRef): string;
|
|
69
|
+
/** Reference S3-compatible ArtifactBodyStore (AWS S3, MinIO, Cloudflare R2). */
|
|
70
|
+
export declare function createS3ArtifactBodyStore(options: S3ArtifactBodyStoreOptions): ArtifactBodyStore;
|
|
@@ -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
|
package/dist/artifacts.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type AgentIdentity, type ArtifactCitation, type ArtifactDeliveryToken, type ArtifactRecord, type ArtifactRevision, type CheckpointStore, type OwnershipScope, type PersistencePage, type SecretRedactor } from "@arnilo/prism";
|
|
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
2
|
import type { PrismRequestHandler, PrismServerAuthorization } from "./types.js";
|
|
3
3
|
/** Phase 9 freeze: artifacts/thread 64/256; revisions 32/128; record 8/64 KiB; preview 16/64 KiB;
|
|
4
4
|
* citations 32/128 and 2/8 KiB each; mime 128/512 B; hash 256/1 KiB; delivery TTL 5 min/24 h;
|
|
@@ -78,6 +78,8 @@ export interface ArtifactAttachInput extends ArtifactServiceInput {
|
|
|
78
78
|
readonly uri: string;
|
|
79
79
|
readonly mime: string;
|
|
80
80
|
readonly hash: string;
|
|
81
|
+
/** Expected body byte length; required when a blob store is wired for delivery. */
|
|
82
|
+
readonly size?: number;
|
|
81
83
|
/** Explicit id makes attach idempotent (get-or-create). Generated when omitted. */
|
|
82
84
|
readonly id?: string;
|
|
83
85
|
readonly title?: string;
|
|
@@ -100,6 +102,8 @@ export interface ArtifactReviseInput extends ArtifactRefInput {
|
|
|
100
102
|
/** Defaults to the previous revision's mime when omitted. */
|
|
101
103
|
readonly mime?: string;
|
|
102
104
|
readonly hash: string;
|
|
105
|
+
/** Expected body byte length; required when a blob store is wired for delivery. */
|
|
106
|
+
readonly size?: number;
|
|
103
107
|
readonly changeNote?: string;
|
|
104
108
|
readonly producerRunId?: string;
|
|
105
109
|
readonly citations?: readonly ArtifactCitation[];
|
|
@@ -135,6 +139,8 @@ export interface ArtifactDeliveryInput extends ArtifactRefInput {
|
|
|
135
139
|
export interface ArtifactDeliveryResult {
|
|
136
140
|
readonly link: string;
|
|
137
141
|
readonly token: ArtifactDeliveryToken;
|
|
142
|
+
/** Presigned blob-store delivery URL; present only when a body store is wired. */
|
|
143
|
+
readonly url?: string;
|
|
138
144
|
}
|
|
139
145
|
export type ArtifactDecisionEvent = {
|
|
140
146
|
readonly type: "artifact_attached" | "artifact_revised";
|
|
@@ -157,6 +163,8 @@ export interface CreateArtifactServiceOptions {
|
|
|
157
163
|
/** Host HMAC key material for signing/verifying delivery links. */
|
|
158
164
|
readonly linkSecret: string;
|
|
159
165
|
readonly limits?: ArtifactLimits;
|
|
166
|
+
/** Optional blob store: delivery links then resolve through `bodies.presign`. */
|
|
167
|
+
readonly bodies?: ArtifactBodyStore;
|
|
160
168
|
/** Audit seam (redacted refs only); hosts bridge to @arnilo/prism-policy. */
|
|
161
169
|
readonly onDecision?: (event: ArtifactDecisionEvent) => void | Promise<void>;
|
|
162
170
|
}
|
package/dist/artifacts.js
CHANGED
|
@@ -119,6 +119,9 @@ export function createArtifactService(store, options) {
|
|
|
119
119
|
const uri = assertSafeUri(input.uri, limits.uriBytes);
|
|
120
120
|
assertBounded(input.mime, limits.mimeBytes, "mime_too_large");
|
|
121
121
|
assertBounded(input.hash, limits.hashBytes, "hash_too_large");
|
|
122
|
+
if (input.size !== undefined && (!Number.isSafeInteger(input.size) || input.size < 0)) {
|
|
123
|
+
throw new ArtifactError("size is invalid", "invalid_input");
|
|
124
|
+
}
|
|
122
125
|
if (input.changeNote !== undefined)
|
|
123
126
|
assertBounded(input.changeNote, limits.noteBytes, "change_note_too_large");
|
|
124
127
|
if (input.producerRunId !== undefined)
|
|
@@ -130,6 +133,7 @@ export function createArtifactService(store, options) {
|
|
|
130
133
|
uri,
|
|
131
134
|
mime: input.mime,
|
|
132
135
|
hash: input.hash,
|
|
136
|
+
...(input.size === undefined ? {} : { size: input.size }),
|
|
133
137
|
...(input.changeNote === undefined ? {} : { changeNote: input.changeNote }),
|
|
134
138
|
...(input.producerRunId === undefined ? {} : { producerRunId: input.producerRunId }),
|
|
135
139
|
...(citations === undefined ? {} : { citations }),
|
|
@@ -278,9 +282,9 @@ export function createArtifactService(store, options) {
|
|
|
278
282
|
const version = input.version ?? record.lastValidatedVersion ?? latest?.version;
|
|
279
283
|
if (version === undefined)
|
|
280
284
|
throw new ArtifactError("Artifact has no revisions", "invalid_input");
|
|
281
|
-
|
|
285
|
+
const revision = record.revisions.find((item) => item.version === version);
|
|
286
|
+
if (!revision)
|
|
282
287
|
throw new ArtifactError("Revision not found", "not_found");
|
|
283
|
-
}
|
|
284
288
|
const ttlSeconds = bounded(input.ttlSeconds, limits.deliveryLinkTtlSeconds, limits.deliveryLinkTtlSeconds, "ttlSeconds");
|
|
285
289
|
const now = Date.now();
|
|
286
290
|
const token = {
|
|
@@ -291,7 +295,31 @@ export function createArtifactService(store, options) {
|
|
|
291
295
|
issuedAt: new Date(now).toISOString(),
|
|
292
296
|
expiresAt: new Date(now + ttlSeconds * 1000).toISOString(),
|
|
293
297
|
};
|
|
294
|
-
|
|
298
|
+
const result = { link: signArtifactDeliveryLink(token, options.linkSecret), token };
|
|
299
|
+
if (options.bodies) {
|
|
300
|
+
// Delivery resolves through the blob store: presign the exact revision's body.
|
|
301
|
+
// A revision without a recorded size cannot be addressed; fail closed.
|
|
302
|
+
if (revision.size === undefined) {
|
|
303
|
+
throw new ArtifactError("Revision has no recorded size; cannot resolve a body reference", "invalid_input");
|
|
304
|
+
}
|
|
305
|
+
const ref = {
|
|
306
|
+
artifactId: record.id,
|
|
307
|
+
threadId: input.threadId,
|
|
308
|
+
version,
|
|
309
|
+
mime: revision.mime,
|
|
310
|
+
size: revision.size,
|
|
311
|
+
// Artifact hashes conventionally carry a `sha256:` prefix; the body
|
|
312
|
+
// ref contract is bare 64-char hex, so normalize before addressing.
|
|
313
|
+
hash: revision.hash.startsWith("sha256:") ? revision.hash.slice("sha256:".length) : revision.hash,
|
|
314
|
+
...input.ownership,
|
|
315
|
+
};
|
|
316
|
+
const url = await options.bodies.presign(ref, {
|
|
317
|
+
ttlMs: ttlSeconds * 1000,
|
|
318
|
+
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
319
|
+
});
|
|
320
|
+
return { ...result, url };
|
|
321
|
+
}
|
|
322
|
+
return result;
|
|
295
323
|
},
|
|
296
324
|
};
|
|
297
325
|
async function decide(input, state) {
|
|
@@ -435,6 +463,7 @@ export function createArtifactHandler(options) {
|
|
|
435
463
|
uri: readString(body.uri, "uri"),
|
|
436
464
|
mime: readString(body.mime, "mime"),
|
|
437
465
|
hash: readString(body.hash, "hash"),
|
|
466
|
+
...(body.size === undefined ? {} : { size: readNonNegativeInt(String(body.size), "size") }),
|
|
438
467
|
...(body.id === undefined ? {} : { id: readString(body.id, "id") }),
|
|
439
468
|
...(body.title === undefined ? {} : { title: readString(body.title, "title") }),
|
|
440
469
|
...(body.changeNote === undefined ? {} : { changeNote: readString(body.changeNote, "changeNote") }),
|
|
@@ -464,6 +493,7 @@ export function createArtifactHandler(options) {
|
|
|
464
493
|
artifactId: route.artifactId,
|
|
465
494
|
uri: readString(body.uri, "uri"),
|
|
466
495
|
hash: readString(body.hash, "hash"),
|
|
496
|
+
...(body.size === undefined ? {} : { size: readNonNegativeInt(String(body.size), "size") }),
|
|
467
497
|
...(body.mime === undefined ? {} : { mime: readString(body.mime, "mime") }),
|
|
468
498
|
...(body.changeNote === undefined ? {} : { changeNote: readString(body.changeNote, "changeNote") }),
|
|
469
499
|
...(body.producerRunId === undefined ? {} : { producerRunId: readString(body.producerRunId, "producerRunId") }),
|
|
@@ -745,4 +775,10 @@ function readPositiveInt(value, name) {
|
|
|
745
775
|
throw new PrismServerError(`${name} must be a positive safe integer`, 400, "ERR_PRISM_SERVER_INPUT");
|
|
746
776
|
return parsed;
|
|
747
777
|
}
|
|
778
|
+
function readNonNegativeInt(value, name) {
|
|
779
|
+
const parsed = Number(value);
|
|
780
|
+
if (!Number.isSafeInteger(parsed) || parsed < 0)
|
|
781
|
+
throw new PrismServerError(`${name} must be a non-negative safe integer`, 400, "ERR_PRISM_SERVER_INPUT");
|
|
782
|
+
return parsed;
|
|
783
|
+
}
|
|
748
784
|
//# sourceMappingURL=artifacts.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arnilo/prism-server",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "Optional framework-free Web Request-to-Response handler for explicitly selected Prism agents and workflows.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
".": {
|
|
10
10
|
"types": "./dist/index.d.ts",
|
|
11
11
|
"default": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./artifact-bodies": {
|
|
14
|
+
"types": "./dist/artifact-bodies.d.ts",
|
|
15
|
+
"default": "./dist/artifact-bodies.js"
|
|
12
16
|
}
|
|
13
17
|
},
|
|
14
18
|
"files": [
|
|
@@ -25,8 +29,8 @@
|
|
|
25
29
|
"pack:dry-run": "npm pack --dry-run"
|
|
26
30
|
},
|
|
27
31
|
"peerDependencies": {
|
|
28
|
-
"@arnilo/prism": "0.0
|
|
29
|
-
"@arnilo/prism-workflows": "0.0
|
|
32
|
+
"@arnilo/prism": "0.1.0",
|
|
33
|
+
"@arnilo/prism-workflows": "0.1.0"
|
|
30
34
|
},
|
|
31
35
|
"devDependencies": {
|
|
32
36
|
"@arnilo/prism": "file:../..",
|