@springbrand/eval-harness 0.1.0-alpha.1 → 0.1.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,6 +6,11 @@ creation and routing, Severn owns Submission and canonical Session history,
6
6
  Workspace owns files, and API owns billing settlements. Eval Project, Dataset,
7
7
  Sampling and scoring remain in `eval-platform`.
8
8
 
9
+ The root export also exposes `hasZipSignature`, `inspectStrictZip`,
10
+ `StrictZipError`, `STRICT_ZIP_OOXML_LIMITS` and their `StrictZip*` types from
11
+ the shared ZIP inspector. Consumers supply explicit archive limits; inspection
12
+ does not retain expanded bytes unless `retainBytes` is enabled.
13
+
9
14
  The Eval Run Worker authenticates with a dedicated service token. Its requests
10
15
  include the owning user ID because an SP Inbox is addressed by user. The Chat
11
16
  creation facts and each Submission operation key are immutable. Initialization
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ export { hasZipSignature, inspectStrictZip, StrictZipError, STRICT_ZIP_OOXML_LIMITS, type StrictZipEntry, type StrictZipEntryLimit, type StrictZipErrorCode, type StrictZipInspectOptions, type StrictZipLimits, type StrictZipResult, } from "../../utils/src/index.js";
2
3
  export declare const EVAL_SERVICE_HEADER = "X-Eval-Service-Token";
3
4
  export declare const EVAL_HARNESS_PATH = "/internal/v1/eval-harness";
4
5
  export declare const evalEnsureChatSchema: z.ZodObject<{
@@ -231,4 +232,3 @@ export type EvalEvidenceFileReceipt = {
231
232
  export declare function isEvalSha256(value: string | null): value is string;
232
233
  /** Compare creation and operation facts without depending on JSON property order. */
233
234
  export declare function stableEvalFacts(value: unknown): string;
234
- export {};
@@ -0,0 +1,266 @@
1
+ import { z } from "zod";
2
+ import { canonicalJsonStringify, sha256Hex } from "../../utils/src/index.js";
3
+ export { hasZipSignature, inspectStrictZip, StrictZipError, STRICT_ZIP_OOXML_LIMITS, } from "../../utils/src/index.js";
4
+ export const EVAL_SERVICE_HEADER = "X-Eval-Service-Token";
5
+ export const EVAL_HARNESS_PATH = "/internal/v1/eval-harness";
6
+ const id = z.string().trim().min(1);
7
+ const uuid = z.uuid();
8
+ export const evalEnsureChatSchema = z.strictObject({
9
+ chatId: uuid,
10
+ role: z.enum(["subject", "evaluator"]),
11
+ framework: z.literal("sp"),
12
+ samplingId: id,
13
+ projectId: id,
14
+ agentConfig: z.strictObject({ ownerUserId: uuid, userAgentId: uuid }),
15
+ /** Only an Evaluator carries it; the Evaluator still owns an ordinary Chat and its own Space. */
16
+ evaluationId: id.optional(),
17
+ runtime: z.strictObject({ timeoutMs: z.number().finite().positive().optional() }).optional(),
18
+ }).superRefine((value, context) => {
19
+ if ((value.role === "evaluator") !== (value.evaluationId !== undefined)) {
20
+ context.addIssue({ code: "custom", path: ["evaluationId"], message: "Evaluation ID must match role" });
21
+ }
22
+ });
23
+ export const EVAL_INITIAL_FILE_MAX_BYTES = 5_000_000;
24
+ export const EVAL_INITIALIZATION_MAX_BYTES = 20_000_000;
25
+ export const EVAL_MATERIAL_MAX_FILES = 500;
26
+ export const EVAL_SESSION_CONTENT_MAX_CHARS = 2_000_000;
27
+ export const EVAL_SESSION_MAX_ENTRIES = 10_000;
28
+ export const EVAL_OUTPUT_SCHEMA_PATH = "/evaluation-output.schema.json";
29
+ export const EVAL_INITIAL_MATERIALS_ROOT = "/initial-materials";
30
+ /** Subject Session JSON and Evaluator Schema JSON can expand to six bytes per character. */
31
+ export const EVAL_INITIALIZATION_BODY_MAX_BYTES = Math.max(Math.ceil(EVAL_INITIALIZATION_MAX_BYTES * 4 / 3) + EVAL_SESSION_CONTENT_MAX_CHARS * 6, Math.ceil((EVAL_INITIALIZATION_MAX_BYTES - EVAL_INITIAL_FILE_MAX_BYTES) * 4 / 3) + EVAL_INITIAL_FILE_MAX_BYTES * 6) + 1_000_000;
32
+ const base64Bytes = (value) => value.length / 4 * 3 - (value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0);
33
+ const utf8Bytes = (value) => new TextEncoder().encode(value).byteLength;
34
+ /** Decoded bytes of validated Material files, counted exactly as the initialization budget counts them. */
35
+ export const evalMaterialFilesByteSize = (files) => files.reduce((sum, file) => sum + base64Bytes(file.contentBase64), 0);
36
+ const tooBig = (context, maximum, message, path = []) => context.addIssue({ code: "too_big", origin: "file", maximum, inclusive: true, path, message });
37
+ const maxFileBytes = (bytes) => (value, context) => {
38
+ if (bytes(value) > EVAL_INITIAL_FILE_MAX_BYTES)
39
+ tooBig(context, EVAL_INITIAL_FILE_MAX_BYTES, "File is too large");
40
+ };
41
+ const evalMaterialFileSchema = z.strictObject({
42
+ path: z.string().refine((path) => evalPathParts(path) !== null, "Invalid initial material path"),
43
+ mediaType: id,
44
+ contentBase64: z.base64().superRefine(maxFileBytes(base64Bytes)),
45
+ });
46
+ export const evalMaterialFilesSchema = z.array(evalMaterialFileSchema).max(EVAL_MATERIAL_MAX_FILES).superRefine((files, context) => {
47
+ const seen = new Set();
48
+ files.forEach(({ path }, index) => {
49
+ if (seen.has(path))
50
+ context.addIssue({ code: "custom", path: [index, "path"], message: "Duplicate material path" });
51
+ seen.add(path);
52
+ });
53
+ });
54
+ const evalInitialMaterialSchema = z.strictObject({
55
+ bundleId: id,
56
+ materialId: id,
57
+ files: evalMaterialFilesSchema,
58
+ });
59
+ export const evalInitialContextSchema = z.strictObject({
60
+ format: z.enum(["json", "jsonl"]),
61
+ content: z.string().min(1),
62
+ });
63
+ export const evalInitializationSchema = z.strictObject({
64
+ idempotencyKey: id,
65
+ data: z.record(z.string(), z.unknown()).and(z.object({ userPrompt: z.string().trim().min(1) })),
66
+ initialSession: z.strictObject({
67
+ bundleId: id,
68
+ sessionId: id,
69
+ context: evalInitialContextSchema.optional(),
70
+ }).optional(),
71
+ initialMaterials: z.array(evalInitialMaterialSchema).min(1).optional(),
72
+ evaluationOutputSchema: z.strictObject({
73
+ content: z.string().min(1).superRefine(maxFileBytes(utf8Bytes)),
74
+ }).optional(),
75
+ }).superRefine((value, context) => {
76
+ const session = value.data.initialSession;
77
+ const refs = value.data.initialMaterials;
78
+ const materials = value.initialMaterials;
79
+ if (session ? !value.initialSession || session.bundleId !== value.initialSession.bundleId ||
80
+ session.sessionId !== value.initialSession.sessionId : value.initialSession !== undefined) {
81
+ context.addIssue({ code: "custom", path: ["initialSession"], message: "Session content must match the Data reference" });
82
+ }
83
+ if (refs === undefined ? materials !== undefined : !Array.isArray(refs) || refs.length !== materials?.length ||
84
+ materials.some((material, index) => {
85
+ const ref = refs[index];
86
+ return ref?.bundleId !== material.bundleId || ref?.materialId !== material.materialId;
87
+ })) {
88
+ context.addIssue({ code: "custom", path: ["initialMaterials"], message: "Material content must match the Data reference" });
89
+ }
90
+ const seen = new Set();
91
+ materials?.forEach(({ bundleId, materialId }, index) => {
92
+ const key = JSON.stringify([bundleId, materialId]);
93
+ if (seen.has(key))
94
+ context.addIssue({ code: "custom", path: ["initialMaterials", index], message: "Duplicate material" });
95
+ seen.add(key);
96
+ });
97
+ const total = (materials ?? []).reduce((sum, material) => sum + evalMaterialFilesByteSize(material.files), value.evaluationOutputSchema ? utf8Bytes(value.evaluationOutputSchema.content) : 0);
98
+ if (total > EVAL_INITIALIZATION_MAX_BYTES) {
99
+ tooBig(context, EVAL_INITIALIZATION_MAX_BYTES, "Initialization files are too large");
100
+ }
101
+ });
102
+ export const evalInitializationResultSchema = z.strictObject({
103
+ manifestHash: id,
104
+ target: z.literal("workspace"),
105
+ materials: z.array(z.strictObject({ bundleId: id, materialId: id, rootPath: z.string().startsWith("/") })),
106
+ evaluationOutputSchemaPath: z.literal(EVAL_OUTPUT_SCHEMA_PATH).exactOptional(),
107
+ });
108
+ export const evalSubmitSchema = z.strictObject({
109
+ idempotencyKey: id,
110
+ prompt: z.string().trim().min(1),
111
+ unattended: z.literal(true),
112
+ runtime: z.strictObject({ timeoutMs: z.number().finite().positive().optional() }).optional(),
113
+ });
114
+ export const evalBillingResourcesSchema = z.strictObject({
115
+ usageEvents: z.array(z.record(z.string(), z.unknown())),
116
+ billingSettlements: z.array(z.record(z.string(), z.unknown())),
117
+ });
118
+ export function projectEvalSubmission(input) {
119
+ const normalizedStatus = {
120
+ pending: "queued",
121
+ running: "running",
122
+ completed: "completed",
123
+ error: "failed",
124
+ skipped: "failed",
125
+ aborted: "interrupted",
126
+ }[input.status];
127
+ if (!normalizedStatus)
128
+ throw new Error("Unknown SP Submission status");
129
+ return { ...input, normalizedStatus: normalizedStatus };
130
+ }
131
+ /** JSON stores one canonical snapshot; JSONL stores one canonical entry per line. */
132
+ export function readEvalCanonicalContext(input) {
133
+ if (input.content.length > EVAL_SESSION_CONTENT_MAX_CHARS)
134
+ throw new Error("Initial context is too large");
135
+ const parsed = input.format === "json"
136
+ ? JSON.parse(input.content)
137
+ : { entries: input.content.split(/\r?\n/u).filter((line) => line.trim()).map((line) => JSON.parse(line)) };
138
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.entries)) {
139
+ throw new Error("Initial context must contain canonical entries");
140
+ }
141
+ const entries = parsed.entries;
142
+ if (entries.length > EVAL_SESSION_MAX_ENTRIES)
143
+ throw new Error("Initial context has too many entries");
144
+ const seen = new Set();
145
+ for (const entry of entries) {
146
+ if (!entry || typeof entry !== "object")
147
+ throw new Error("Invalid canonical context entry");
148
+ const row = entry;
149
+ if (typeof row.id !== "string" || !row.id || seen.has(row.id) ||
150
+ !Number.isSafeInteger(row.createdAt) || Number(row.createdAt) < 0 ||
151
+ !row.message || typeof row.message !== "object" || Array.isArray(row.message) ||
152
+ !["user", "assistant", "toolResult"].includes(row.message.role ?? "")) {
153
+ throw new Error("Invalid or duplicate canonical context entry");
154
+ }
155
+ seen.add(row.id);
156
+ }
157
+ if (entries.length > 0 && entries[0].message.role !== "user") {
158
+ throw new Error("Initial context must begin with a user message");
159
+ }
160
+ return { entries: entries };
161
+ }
162
+ /** The one file an Evaluator writes its scored result to, in its own Space. */
163
+ export const EVAL_RESULT_FILE = "result.json";
164
+ export const EVAL_RESULT_PATH = `/output/${EVAL_RESULT_FILE}`;
165
+ export const EVAL_RESULT_MAX_BYTES = 1_000_000;
166
+ /** Reads the result file as raw UTF-8 text; any byte order mark and formatting are kept. */
167
+ export async function readEvalStructuredOutput(path, bytes) {
168
+ if (bytes.byteLength > EVAL_RESULT_MAX_BYTES)
169
+ throw new Error(`${EVAL_RESULT_FILE} exceeds ${EVAL_RESULT_MAX_BYTES} bytes`);
170
+ let content;
171
+ try {
172
+ content = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
173
+ }
174
+ catch {
175
+ throw new Error(`${EVAL_RESULT_FILE} is not valid UTF-8`);
176
+ }
177
+ const contentHash = `sha256:${await sha256Hex(bytes)}`;
178
+ return { path, content, contentHash };
179
+ }
180
+ export function evalMaterialRoot(bundleId, materialId) {
181
+ return `${EVAL_INITIAL_MATERIALS_ROOT}/${evalPathSegment(bundleId)}/${evalPathSegment(materialId)}`;
182
+ }
183
+ function evalPathSegment(value) {
184
+ return encodeURIComponent(value).replaceAll(".", "%2E");
185
+ }
186
+ /** Absolute paths below a root; `.`/`..`, `.git`, backslashes and NUL never reach the Space. */
187
+ function evalPathParts(path) {
188
+ const parts = path.split("/").slice(1);
189
+ if (!path.startsWith("/") || parts.length === 0 || parts.some((part) => !part || part === "." || part === ".." || part === ".git" || part.includes("\\") || part.includes("\0"))) {
190
+ return null;
191
+ }
192
+ return parts;
193
+ }
194
+ export function evalMaterialPath(root, path) {
195
+ const parts = evalPathParts(path);
196
+ if (!parts)
197
+ throw new Error("Invalid initial material path");
198
+ return `${root}/${parts.join("/")}`;
199
+ }
200
+ /*
201
+ * Evidence: a Subject's artifacts and complete trajectory are exported once, then imported into
202
+ * the Evaluator's own Space under `/evidence`. Neither Chat ever reads the other's Space.
203
+ */
204
+ /** Where imported evidence lives in the Evaluator's own Space. */
205
+ export const EVAL_EVIDENCE_ROOT = "/evidence";
206
+ /** Bundle-relative paths, used by the import route; evalEvidencePath returns their Workspace paths. */
207
+ export const EVAL_EVIDENCE_PATHS = {
208
+ subjectInputs: "/subject-inputs",
209
+ artifacts: "/artifacts",
210
+ trajectory: "/trajectory",
211
+ manifest: "/manifest.json",
212
+ };
213
+ /** One exported or imported file crosses a single Durable Object RPC as bytes. */
214
+ export const EVAL_EVIDENCE_FILE_MAX_BYTES = 25_000_000;
215
+ export const EVAL_EVIDENCE_TRAJECTORY_PAGE_MAX = 200;
216
+ export const EVAL_EVIDENCE_SHA256_HEADER = "X-Eval-Content-Sha256";
217
+ /** One Subject artifact; its bytes are read separately so the inventory stays small. */
218
+ export const evalEvidenceArtifactSchema = z.strictObject({
219
+ path: z.string().startsWith("/").min(2),
220
+ size: z.number().int().nonnegative(),
221
+ mediaType: id,
222
+ });
223
+ export const evalEvidenceArtifactsSchema = z.strictObject({
224
+ artifacts: z.array(evalEvidenceArtifactSchema),
225
+ });
226
+ export function readEvalTrajectoryPageQuery(params) {
227
+ const afterSeq = Number(params.get("afterSeq") ?? "0");
228
+ const limit = Number(params.get("limit") ?? String(EVAL_EVIDENCE_TRAJECTORY_PAGE_MAX));
229
+ if (!Number.isSafeInteger(afterSeq) || afterSeq < 0 ||
230
+ !Number.isSafeInteger(limit) || limit < 1 || limit > EVAL_EVIDENCE_TRAJECTORY_PAGE_MAX) {
231
+ throw new Error("Invalid trajectory page");
232
+ }
233
+ return { afterSeq, limit };
234
+ }
235
+ /** A Space path for one evidence file; `relative` keeps the bundle's own layout below `/evidence`. */
236
+ export function evalEvidencePath(relative) {
237
+ const parts = evalPathParts(relative);
238
+ if (!parts || (relative !== EVAL_EVIDENCE_PATHS.manifest &&
239
+ ![EVAL_EVIDENCE_PATHS.subjectInputs, EVAL_EVIDENCE_PATHS.artifacts, EVAL_EVIDENCE_PATHS.trajectory]
240
+ .some((root) => relative.startsWith(`${root}/`))))
241
+ throw new Error("Invalid evidence path");
242
+ return `${EVAL_EVIDENCE_ROOT}/${parts.join("/")}`;
243
+ }
244
+ /** Original Subject inputs stay separate from produced artifacts and the Evaluator's own materials. */
245
+ export function evalSubjectInputEvidencePath(bundleId, materialId, path) {
246
+ return evalMaterialPath(`${EVAL_EVIDENCE_PATHS.subjectInputs}/${evalPathSegment(bundleId)}/${evalPathSegment(materialId)}`, path);
247
+ }
248
+ export function evalArtifactEvidencePath(path) {
249
+ return evalMaterialPath(EVAL_EVIDENCE_PATHS.artifacts, path);
250
+ }
251
+ /** Positive part numbers preserve the complete raw JSONL trajectory's order. */
252
+ export function evalTrajectoryEvidencePath(part) {
253
+ if (!Number.isSafeInteger(part) || part < 1)
254
+ throw new Error("Invalid trajectory part");
255
+ return `${EVAL_EVIDENCE_PATHS.trajectory}/events-${String(part).padStart(6, "0")}.jsonl`;
256
+ }
257
+ export function isEvalSha256(value) {
258
+ return value !== null && /^[0-9a-f]{64}$/u.test(value);
259
+ }
260
+ /** Compare creation and operation facts without depending on JSON property order. */
261
+ export function stableEvalFacts(value) {
262
+ const facts = canonicalJsonStringify(value);
263
+ if (facts === undefined)
264
+ throw new TypeError("Eval facts must be JSON");
265
+ return facts;
266
+ }
@@ -0,0 +1,12 @@
1
+ export declare const ATTACHMENT_MAX_COUNT = 8;
2
+ export declare const ATTACHMENT_MAX_BYTES: number;
3
+ export declare const ATTACHMENT_MAX_TOTAL_BYTES: number;
4
+ export declare const ATTACHMENT_ID_PATTERN: string;
5
+ export type AttachmentCdnUrl = string;
6
+ export declare function isAttachmentId(value: unknown): value is string;
7
+ export declare function attachmentCdnUrl(publicOrigin: string, attachmentId: string): AttachmentCdnUrl;
8
+ export declare function parseAttachmentCdnUrl(value: unknown): {
9
+ attachmentId: string;
10
+ } | null;
11
+ export declare function isAttachmentCdnUrl(value: unknown): value is AttachmentCdnUrl;
12
+ export declare function isStrictUtf8(bytes: Uint8Array): boolean;
@@ -0,0 +1,50 @@
1
+ export const ATTACHMENT_MAX_COUNT = 8;
2
+ export const ATTACHMENT_MAX_BYTES = 20 * 1024 * 1024;
3
+ export const ATTACHMENT_MAX_TOTAL_BYTES = 80 * 1024 * 1024;
4
+ const ATTACHMENT_ID_BODY = String.raw `\d{2}-(?:0[1-9]|1[0-2])-[A-Za-z0-9_-]{16}`;
5
+ export const ATTACHMENT_ID_PATTERN = `^${ATTACHMENT_ID_BODY}$`;
6
+ const ATTACHMENT_PATH = new RegExp(`^/attachments/(${ATTACHMENT_ID_BODY})$`, "u");
7
+ export function isAttachmentId(value) {
8
+ return typeof value === "string" && ATTACHMENT_PATH.test(`/attachments/${value}`);
9
+ }
10
+ export function attachmentCdnUrl(publicOrigin, attachmentId) {
11
+ if (!isAttachmentId(attachmentId)) {
12
+ throw new Error("Invalid Attachment");
13
+ }
14
+ const url = new URL(`/attachments/${attachmentId}`, publicOrigin);
15
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
16
+ throw new Error("Invalid Attachment");
17
+ }
18
+ return url.href;
19
+ }
20
+ export function parseAttachmentCdnUrl(value) {
21
+ if (typeof value !== "string")
22
+ return null;
23
+ let url;
24
+ try {
25
+ url = new URL(value);
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ if (url.protocol !== "https:" && url.protocol !== "http:")
31
+ return null;
32
+ if (url.search || url.hash)
33
+ return null;
34
+ const match = url.pathname.match(ATTACHMENT_PATH);
35
+ return match
36
+ ? { attachmentId: match[1] }
37
+ : null;
38
+ }
39
+ export function isAttachmentCdnUrl(value) {
40
+ return parseAttachmentCdnUrl(value) !== null;
41
+ }
42
+ export function isStrictUtf8(bytes) {
43
+ try {
44
+ new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes);
45
+ return true;
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ }
@@ -0,0 +1,3 @@
1
+ export declare function encodeBase64(bytes: Uint8Array, urlSafe?: boolean): string;
2
+ export declare function decodeBase64(value: string, urlSafe?: boolean): Uint8Array<ArrayBuffer>;
3
+ export declare function hexToBase64(hex: string): string;
@@ -0,0 +1,27 @@
1
+ const CHUNK_SIZE = 0x8000;
2
+ export function encodeBase64(bytes, urlSafe = false) {
3
+ let binary = "";
4
+ for (let index = 0; index < bytes.length; index += CHUNK_SIZE) {
5
+ binary += String.fromCharCode(...bytes.subarray(index, index + CHUNK_SIZE));
6
+ }
7
+ const encoded = btoa(binary);
8
+ return urlSafe
9
+ ? encoded.replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "")
10
+ : encoded;
11
+ }
12
+ export function decodeBase64(value, urlSafe = false) {
13
+ const normalized = urlSafe
14
+ ? value.replaceAll("-", "+").replaceAll("_", "/")
15
+ : value;
16
+ const binary = atob(normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="));
17
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
18
+ }
19
+ export function hexToBase64(hex) {
20
+ if (!/^(?:[a-f\d]{2})+$/iu.test(hex))
21
+ throw new TypeError("Invalid hexadecimal bytes");
22
+ const bytes = new Uint8Array(hex.length / 2);
23
+ for (let index = 0; index < bytes.length; index += 1) {
24
+ bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16);
25
+ }
26
+ return encodeBase64(bytes);
27
+ }
@@ -0,0 +1,5 @@
1
+ export declare function sha256Hex(value: string | Uint8Array): Promise<string>;
2
+ export declare function hashFileSet(files: readonly {
3
+ path: string;
4
+ bytes: Uint8Array;
5
+ }[]): Promise<string>;
@@ -0,0 +1,27 @@
1
+ export async function sha256Hex(value) {
2
+ const bytes = typeof value === "string" ? new TextEncoder().encode(value) : value;
3
+ const buffer = new ArrayBuffer(bytes.byteLength);
4
+ new Uint8Array(buffer).set(bytes);
5
+ const digest = await crypto.subtle.digest("SHA-256", buffer);
6
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
7
+ }
8
+ export function hashFileSet(files) {
9
+ const encoder = new TextEncoder();
10
+ const entries = [...files]
11
+ .sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
12
+ .map((file) => ({ path: encoder.encode(file.path), bytes: file.bytes }));
13
+ const framed = new Uint8Array(entries.reduce((total, entry) => total + 8 + entry.path.byteLength + entry.bytes.byteLength, 0));
14
+ const view = new DataView(framed.buffer);
15
+ let offset = 0;
16
+ for (const entry of entries) {
17
+ view.setUint32(offset, entry.path.byteLength);
18
+ offset += 4;
19
+ framed.set(entry.path, offset);
20
+ offset += entry.path.byteLength;
21
+ view.setUint32(offset, entry.bytes.byteLength);
22
+ offset += 4;
23
+ framed.set(entry.bytes, offset);
24
+ offset += entry.bytes.byteLength;
25
+ }
26
+ return sha256Hex(framed);
27
+ }
@@ -0,0 +1 @@
1
+ export declare function isUniqueConstraintViolation(error: unknown): boolean;
@@ -0,0 +1,9 @@
1
+ export function isUniqueConstraintViolation(error) {
2
+ for (let current = error; current instanceof Error; current = current.cause) {
3
+ if (/UNIQUE constraint failed/i.test(current.message))
4
+ return true;
5
+ if (/duplicate key value violates unique constraint/i.test(current.message))
6
+ return true;
7
+ }
8
+ return false;
9
+ }
@@ -0,0 +1,2 @@
1
+ /** Escapes text for a place where markup would otherwise be read as markup. */
2
+ export declare function escapeHtml(value: string): string;
@@ -0,0 +1,9 @@
1
+ /** Escapes text for a place where markup would otherwise be read as markup. */
2
+ export function escapeHtml(value) {
3
+ return value
4
+ .replace(/&/gu, "&amp;")
5
+ .replace(/</gu, "&lt;")
6
+ .replace(/>/gu, "&gt;")
7
+ .replace(/"/gu, "&quot;")
8
+ .replace(/'/gu, "&#39;");
9
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Whether a capture is blank, and what it was judged against.
3
+ *
4
+ * `bytes` and `threshold` are reported either way, because a caller that
5
+ * retries a blank capture has to be able to say what it actually saw.
6
+ */
7
+ export type BlankScreenshotVerdict = {
8
+ readonly blank: boolean;
9
+ readonly bytes: number;
10
+ readonly threshold: number;
11
+ };
12
+ export type BlankScreenshotOptions = {
13
+ /** Captured area in pixels: the threshold has to scale with it. */
14
+ readonly pixels: number;
15
+ readonly bytesPerPixel?: number;
16
+ };
17
+ /**
18
+ * Decide whether an encoded screenshot is blank, from its size alone.
19
+ *
20
+ * A page that has not painted yet still screenshots successfully, so a capture
21
+ * cannot be trusted just because the browser returned one. What separates the
22
+ * two is how much an encoder can throw away: a uniform surface collapses to a
23
+ * couple of kilobytes while even a heading and a background colour do not.
24
+ *
25
+ * Deliberately does not look at the bytes' entropy. That is the obvious second
26
+ * signal and it does not work here: a blank webp's compressed tail measures
27
+ * ~4.97 bits per byte, higher than pages full of content, because the entropy
28
+ * of a compressed stream says nothing about the image it encodes.
29
+ *
30
+ * Deliberately does not decode the image either — the point is to run inside a
31
+ * Worker right after the capture, without paying to inflate the pixels.
32
+ */
33
+ export declare function detectBlankScreenshot(bytes: Uint8Array, options: BlankScreenshotOptions): BlankScreenshotVerdict;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Halfway between a blank capture and the thinnest real one, measured on
3
+ * Cloudflare Browser Rendering at 1280x720, webp quality 76:
4
+ *
5
+ * | page | bytes | bytes/pixel |
6
+ * | --------------------------- | ------ | ----------- |
7
+ * | about:blank, empty body | 2_182 | 0.00237 |
8
+ * | solid background + heading | 3_524 | 0.00382 |
9
+ * | gradient hero | 4_628 | 0.00502 |
10
+ * | page of body copy | 47_224 | 0.05124 |
11
+ */
12
+ const DEFAULT_BYTES_PER_PIXEL = 0.0031;
13
+ /** A capture below this is truncated or empty at any viewport. */
14
+ const ABSOLUTE_MIN_BYTES = 1_024;
15
+ /**
16
+ * Decide whether an encoded screenshot is blank, from its size alone.
17
+ *
18
+ * A page that has not painted yet still screenshots successfully, so a capture
19
+ * cannot be trusted just because the browser returned one. What separates the
20
+ * two is how much an encoder can throw away: a uniform surface collapses to a
21
+ * couple of kilobytes while even a heading and a background colour do not.
22
+ *
23
+ * Deliberately does not look at the bytes' entropy. That is the obvious second
24
+ * signal and it does not work here: a blank webp's compressed tail measures
25
+ * ~4.97 bits per byte, higher than pages full of content, because the entropy
26
+ * of a compressed stream says nothing about the image it encodes.
27
+ *
28
+ * Deliberately does not decode the image either — the point is to run inside a
29
+ * Worker right after the capture, without paying to inflate the pixels.
30
+ */
31
+ export function detectBlankScreenshot(bytes, options) {
32
+ const perPixel = options.bytesPerPixel ?? DEFAULT_BYTES_PER_PIXEL;
33
+ const threshold = Math.max(ABSOLUTE_MIN_BYTES, Math.round(Math.max(0, options.pixels) * perPixel));
34
+ return { blank: bytes.byteLength < threshold, bytes: bytes.byteLength, threshold };
35
+ }
@@ -0,0 +1,12 @@
1
+ export { canonicalJsonBytes, canonicalJsonStringify, equalJson } from "./json.js";
2
+ export { attachmentCdnUrl, ATTACHMENT_ID_PATTERN, ATTACHMENT_MAX_BYTES, ATTACHMENT_MAX_COUNT, ATTACHMENT_MAX_TOTAL_BYTES, isAttachmentCdnUrl, isAttachmentId, isStrictUtf8, parseAttachmentCdnUrl, type AttachmentCdnUrl, } from "./attachment.js";
3
+ export { hasZipSignature, inspectStrictZip, StrictZipError, STRICT_ZIP_OOXML_LIMITS, type StrictZipEntry, type StrictZipEntryLimit, type StrictZipErrorCode, type StrictZipInspectOptions, type StrictZipLimits, type StrictZipResult, } from "./strict-zip.js";
4
+ export { inspectSafeOoxml, OoxmlPackageError, resolveOoxmlRelationshipPart, safeOoxmlExternalHyperlinkUrl, type OoxmlKind, type OoxmlPackageErrorCode, } from "./ooxml.js";
5
+ export { decodeBase64, encodeBase64, hexToBase64 } from "./base64.js";
6
+ export { hashFileSet, sha256Hex } from "./content-hash.js";
7
+ export { escapeHtml } from "./html.js";
8
+ export { classifyIpAddress, type IpAddressScope } from "./ip-address.js";
9
+ export { isUniqueConstraintViolation } from "./database.js";
10
+ export { serializedSize } from "./serialized-size.js";
11
+ export { detectBlankScreenshot, type BlankScreenshotOptions, type BlankScreenshotVerdict, } from "./image.js";
12
+ export { createId, deterministicUuid, isUuid, UUID_PATTERN } from "./uuid.js";
@@ -0,0 +1,12 @@
1
+ export { canonicalJsonBytes, canonicalJsonStringify, equalJson } from "./json.js";
2
+ export { attachmentCdnUrl, ATTACHMENT_ID_PATTERN, ATTACHMENT_MAX_BYTES, ATTACHMENT_MAX_COUNT, ATTACHMENT_MAX_TOTAL_BYTES, isAttachmentCdnUrl, isAttachmentId, isStrictUtf8, parseAttachmentCdnUrl, } from "./attachment.js";
3
+ export { hasZipSignature, inspectStrictZip, StrictZipError, STRICT_ZIP_OOXML_LIMITS, } from "./strict-zip.js";
4
+ export { inspectSafeOoxml, OoxmlPackageError, resolveOoxmlRelationshipPart, safeOoxmlExternalHyperlinkUrl, } from "./ooxml.js";
5
+ export { decodeBase64, encodeBase64, hexToBase64 } from "./base64.js";
6
+ export { hashFileSet, sha256Hex } from "./content-hash.js";
7
+ export { escapeHtml } from "./html.js";
8
+ export { classifyIpAddress } from "./ip-address.js";
9
+ export { isUniqueConstraintViolation } from "./database.js";
10
+ export { serializedSize } from "./serialized-size.js";
11
+ export { detectBlankScreenshot, } from "./image.js";
12
+ export { createId, deterministicUuid, isUuid, UUID_PATTERN } from "./uuid.js";
@@ -0,0 +1,6 @@
1
+ export type IpAddressScope = "public" | "non-public";
2
+ /**
3
+ * Classifies an IP literal without treating DNS names as addresses. IPv4-mapped
4
+ * IPv6 is normalized before classification so it cannot bypass IPv4 policy.
5
+ */
6
+ export declare function classifyIpAddress(value: string): IpAddressScope | undefined;
@@ -0,0 +1,11 @@
1
+ import ipaddr from "ipaddr.js";
2
+ /**
3
+ * Classifies an IP literal without treating DNS names as addresses. IPv4-mapped
4
+ * IPv6 is normalized before classification so it cannot bypass IPv4 policy.
5
+ */
6
+ export function classifyIpAddress(value) {
7
+ const literal = value.replace(/^\[|\]$/gu, "");
8
+ if (!ipaddr.isValid(literal))
9
+ return undefined;
10
+ return ipaddr.process(literal).range() === "unicast" ? "public" : "non-public";
11
+ }
@@ -0,0 +1,6 @@
1
+ /** Serializes JSON with recursively sorted object keys while preserving array order. */
2
+ export declare function canonicalJsonStringify(value: unknown): string | undefined;
3
+ /** Encodes canonical JSON and rejects values that JSON cannot represent at the root. */
4
+ export declare function canonicalJsonBytes(value: unknown): Uint8Array;
5
+ /** JSON persistence ignores object key order and omits undefined object fields. */
6
+ export declare function equalJson(left: unknown, right: unknown): boolean;
@@ -0,0 +1,18 @@
1
+ /** Serializes JSON with recursively sorted object keys while preserving array order. */
2
+ export function canonicalJsonStringify(value) {
3
+ return JSON.stringify(value, (_key, item) => item !== null && typeof item === "object" && !Array.isArray(item)
4
+ ? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0))
5
+ : item);
6
+ }
7
+ /** Encodes canonical JSON and rejects values that JSON cannot represent at the root. */
8
+ export function canonicalJsonBytes(value) {
9
+ const serialized = canonicalJsonStringify(value);
10
+ if (serialized === undefined) {
11
+ throw new TypeError("Value cannot be represented as JSON");
12
+ }
13
+ return new TextEncoder().encode(serialized);
14
+ }
15
+ /** JSON persistence ignores object key order and omits undefined object fields. */
16
+ export function equalJson(left, right) {
17
+ return canonicalJsonStringify(left) === canonicalJsonStringify(right);
18
+ }
@@ -0,0 +1,14 @@
1
+ import { type StrictZipEntry, type StrictZipResult } from "./strict-zip.js";
2
+ export type OoxmlKind = "docx" | "xlsx" | "pptx";
3
+ export type OoxmlPackageErrorCode = "unsafe-content" | "missing-part";
4
+ export declare class OoxmlPackageError extends Error {
5
+ readonly code: OoxmlPackageErrorCode;
6
+ constructor(message: string, code: OoxmlPackageErrorCode);
7
+ }
8
+ export declare function inspectSafeOoxml(bytes: Uint8Array, kind: OoxmlKind, options?: {
9
+ signal?: AbortSignal;
10
+ allowExternalHyperlinks?: boolean;
11
+ onPart?: (entry: Required<StrictZipEntry>) => void | Promise<void>;
12
+ }): Promise<StrictZipResult>;
13
+ export declare function safeOoxmlExternalHyperlinkUrl(target: string | null | undefined): string | null;
14
+ export declare function resolveOoxmlRelationshipPart(sourcePart: string, target: string): string | null;