@opengeni/contracts 0.15.0 → 0.18.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.
@@ -0,0 +1,339 @@
1
+ import { z } from "zod";
2
+
3
+ /** Hard server-side ceiling for one retained-output response body. */
4
+ export const RETAINED_OUTPUT_MAX_PAGE_BYTES = 1024 * 1024;
5
+ /** Default first page when the client does not send a Range header. */
6
+ export const RETAINED_OUTPUT_DEFAULT_PAGE_BYTES = 256 * 1024;
7
+ /** Receipts are timeline references, never an extensible metadata bag. */
8
+ export const RETAINED_OUTPUT_RECEIPT_MAX_BYTES = 2 * 1024;
9
+
10
+ const encoder = new TextEncoder();
11
+ const LOWERCASE_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
12
+ const LOWERCASE_SHA256 = /^[0-9a-f]{64}$/;
13
+ const CANONICAL_MEDIA_TYPE = /^[a-z0-9][a-z0-9!#$&^_.+-]{0,62}\/[a-z0-9][a-z0-9!#$&^_.+-]{0,62}$/;
14
+ const RETRIEVAL_PATH = /^\/v1\/workspaces\/([0-9a-f-]+)\/artifacts\/([0-9a-f-]+)\/content$/;
15
+
16
+ export const RetainedOutputKind = z.enum([
17
+ "tool_result",
18
+ "assistant_completion",
19
+ "internal_update",
20
+ "event_media",
21
+ "file",
22
+ ]);
23
+ export type RetainedOutputKind = z.infer<typeof RetainedOutputKind>;
24
+
25
+ export const RetainedOutputUnavailableReason = z.enum([
26
+ "not_retained",
27
+ "pending",
28
+ "failed",
29
+ "expired",
30
+ "deleted",
31
+ "missing_storage",
32
+ "storage_write_failed",
33
+ "unsupported",
34
+ ]);
35
+ export type RetainedOutputUnavailableReason = z.infer<typeof RetainedOutputUnavailableReason>;
36
+
37
+ const RetainedOutputUnavailableEvidenceSchema = z
38
+ .object({
39
+ available: z.literal(false),
40
+ reason: RetainedOutputUnavailableReason,
41
+ })
42
+ .strict();
43
+
44
+ export const RetainedArtifactReferenceSchema = z
45
+ .object({
46
+ available: z.literal(true),
47
+ artifactId: z.string().regex(LOWERCASE_UUID),
48
+ kind: RetainedOutputKind,
49
+ contentType: z.string().max(127).regex(CANONICAL_MEDIA_TYPE),
50
+ originalBytes: z.number().int().nonnegative().safe(),
51
+ sha256: z.string().regex(LOWERCASE_SHA256),
52
+ retainedAt: z.string().datetime({ offset: true }),
53
+ retention: z
54
+ .object({
55
+ policy: z.literal("workspace_file"),
56
+ expiresAt: z.null(),
57
+ })
58
+ .strict(),
59
+ retrieval: z
60
+ .object({
61
+ method: z.literal("GET"),
62
+ path: z.string().max(256),
63
+ acceptRanges: z.literal("bytes"),
64
+ maxRangeBytes: z.number().int().positive().max(RETAINED_OUTPUT_MAX_PAGE_BYTES),
65
+ })
66
+ .strict(),
67
+ })
68
+ .strict()
69
+ .superRefine((value, ctx) => {
70
+ const match = RETRIEVAL_PATH.exec(value.retrieval.path);
71
+ if (!match || !LOWERCASE_UUID.test(match[1] ?? "") || match[2] !== value.artifactId) {
72
+ ctx.addIssue({
73
+ code: "custom",
74
+ path: ["retrieval", "path"],
75
+ message: "retrieval path must be a workspace API content path for this artifact",
76
+ });
77
+ }
78
+
79
+ if (encoder.encode(JSON.stringify(value)).byteLength > RETAINED_OUTPUT_RECEIPT_MAX_BYTES) {
80
+ ctx.addIssue({
81
+ code: "custom",
82
+ message: "retained-output receipt exceeds its byte envelope",
83
+ });
84
+ }
85
+ });
86
+
87
+ /**
88
+ * Canonical evidence fact carried by bounded audit/NATS/SSE/UI previews.
89
+ *
90
+ * The available variant is intentionally provider-neutral and closed: no bucket,
91
+ * object key, signed URL, arbitrary metadata, or producer-controlled labels can
92
+ * cross this boundary.
93
+ */
94
+ export const RetainedOutputEvidenceSchema = z.discriminatedUnion("available", [
95
+ RetainedOutputUnavailableEvidenceSchema,
96
+ RetainedArtifactReferenceSchema,
97
+ ]);
98
+ export type RetainedOutputEvidence = z.infer<typeof RetainedOutputEvidenceSchema>;
99
+ export type RetainedArtifactReference = z.infer<typeof RetainedArtifactReferenceSchema>;
100
+ /** @deprecated use RetainedArtifactReference. */
101
+ export type RetainedOutputAvailableEvidence = RetainedArtifactReference;
102
+
103
+ export const RetainedArtifactUnavailableSchema = z
104
+ .object({
105
+ available: z.literal(false),
106
+ artifactId: z.string().regex(LOWERCASE_UUID),
107
+ reason: RetainedOutputUnavailableReason,
108
+ })
109
+ .strict();
110
+ export type RetainedArtifactUnavailable = z.infer<typeof RetainedArtifactUnavailableSchema>;
111
+
112
+ /** Authenticated metadata result for one opaque workspace file identity. */
113
+ export const RetainedArtifactMetadataSchema = z.union([
114
+ RetainedArtifactReferenceSchema,
115
+ RetainedArtifactUnavailableSchema,
116
+ ]);
117
+ export type RetainedArtifactMetadata = z.infer<typeof RetainedArtifactMetadataSchema>;
118
+
119
+ export type RetainedArtifactFileInput = {
120
+ id: string;
121
+ workspaceId: string;
122
+ status: string;
123
+ contentType: string;
124
+ sizeBytes: number;
125
+ sha256: string | null;
126
+ updatedAt: string;
127
+ };
128
+
129
+ /**
130
+ * Convert a ready, integrity-addressed workspace file into the only available
131
+ * retained-output receipt shape. Invalid, pending, or checksum-less files fail
132
+ * closed instead of implying that full evidence is retrievable.
133
+ */
134
+ export function retainedArtifactReferenceFromFile(
135
+ file: RetainedArtifactFileInput,
136
+ kind: RetainedOutputKind = "file",
137
+ ): RetainedArtifactReference | null {
138
+ if (file.status !== "ready" || !file.sha256) return null;
139
+ const value = {
140
+ available: true as const,
141
+ artifactId: file.id,
142
+ kind,
143
+ contentType: canonicalRetainedContentType(file.contentType),
144
+ originalBytes: file.sizeBytes,
145
+ sha256: file.sha256,
146
+ retainedAt: file.updatedAt,
147
+ retention: {
148
+ policy: "workspace_file" as const,
149
+ expiresAt: null,
150
+ },
151
+ retrieval: {
152
+ method: "GET" as const,
153
+ path: `/v1/workspaces/${file.workspaceId}/artifacts/${file.id}/content`,
154
+ acceptRanges: "bytes" as const,
155
+ maxRangeBytes: RETAINED_OUTPUT_MAX_PAGE_BYTES,
156
+ },
157
+ };
158
+ const parsed = RetainedArtifactReferenceSchema.safeParse(value);
159
+ return parsed.success ? parsed.data : null;
160
+ }
161
+
162
+ function canonicalRetainedContentType(value: string): string {
163
+ const mediaType = value.split(";", 1)[0]?.trim().toLowerCase() ?? "";
164
+ return CANONICAL_MEDIA_TYPE.test(mediaType) ? mediaType : "application/octet-stream";
165
+ }
166
+
167
+ /** Parse and clone a trusted server receipt; invalid/untrusted data fails closed. */
168
+ export function validateRetainedOutputEvidence(value: unknown): RetainedOutputEvidence | null {
169
+ const parsed = RetainedOutputEvidenceSchema.safeParse(value);
170
+ return parsed.success ? parsed.data : null;
171
+ }
172
+
173
+ export function retainedOutputUnavailable(
174
+ reason: RetainedOutputUnavailableReason = "not_retained",
175
+ ): RetainedOutputEvidence {
176
+ return { available: false, reason };
177
+ }
178
+
179
+ export type RetainedOutputResolvedRange = {
180
+ kind: "range";
181
+ start: number;
182
+ end: number;
183
+ length: number;
184
+ totalBytes: number;
185
+ status: 200 | 206;
186
+ contentRange: string | null;
187
+ acceptRanges: "bytes";
188
+ };
189
+
190
+ export type RetainedOutputRangeResolution =
191
+ | RetainedOutputResolvedRange
192
+ | {
193
+ kind: "empty";
194
+ length: 0;
195
+ totalBytes: 0;
196
+ status: 200;
197
+ contentRange: null;
198
+ acceptRanges: "bytes";
199
+ }
200
+ | {
201
+ kind: "invalid";
202
+ reason: "malformed" | "multipart_not_supported" | "numeric_overflow" | "range_too_large";
203
+ maxPageBytes: number;
204
+ }
205
+ | {
206
+ kind: "unsatisfiable";
207
+ reason: "empty_artifact" | "start_out_of_bounds" | "end_before_start" | "zero_suffix";
208
+ totalBytes: number;
209
+ contentRange: string;
210
+ };
211
+
212
+ /**
213
+ * Resolve one RFC-style bytes range without ever licensing a response larger
214
+ * than `maxPageBytes`. Multipart ranges and oversized explicit/suffix requests
215
+ * fail closed. An omitted or open-ended range becomes one resumable bounded page.
216
+ */
217
+ export function resolveRetainedOutputRange(
218
+ rangeHeader: string | null | undefined,
219
+ totalBytes: number,
220
+ maxPageBytes = RETAINED_OUTPUT_DEFAULT_PAGE_BYTES,
221
+ ): RetainedOutputRangeResolution {
222
+ assertRangeInputs(totalBytes, maxPageBytes);
223
+
224
+ if (rangeHeader === null || rangeHeader === undefined || rangeHeader === "") {
225
+ if (totalBytes === 0) {
226
+ return {
227
+ kind: "empty",
228
+ length: 0,
229
+ totalBytes: 0,
230
+ status: 200,
231
+ contentRange: null,
232
+ acceptRanges: "bytes",
233
+ };
234
+ }
235
+ const end = Math.min(totalBytes - 1, maxPageBytes - 1);
236
+ const partial = end + 1 < totalBytes;
237
+ return resolvedRange(0, end, totalBytes, partial ? 206 : 200);
238
+ }
239
+
240
+ if (rangeHeader.length > 128 || /[^\x20-\x7e]/.test(rangeHeader)) {
241
+ return invalidRange("malformed", maxPageBytes);
242
+ }
243
+ if (rangeHeader.includes(",")) {
244
+ return invalidRange("multipart_not_supported", maxPageBytes);
245
+ }
246
+ const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader);
247
+ if (!match || (!match[1] && !match[2])) {
248
+ return invalidRange("malformed", maxPageBytes);
249
+ }
250
+
251
+ const first = parseRangeInteger(match[1]);
252
+ const second = parseRangeInteger(match[2]);
253
+ if (first === "overflow" || second === "overflow") {
254
+ return invalidRange("numeric_overflow", maxPageBytes);
255
+ }
256
+
257
+ if (first === null) {
258
+ const suffixLength = second;
259
+ if (suffixLength === null) return invalidRange("malformed", maxPageBytes);
260
+ if (suffixLength === 0) return unsatisfiableRange("zero_suffix", totalBytes);
261
+ if (suffixLength > maxPageBytes) return invalidRange("range_too_large", maxPageBytes);
262
+ if (totalBytes === 0) return unsatisfiableRange("empty_artifact", totalBytes);
263
+ const length = Math.min(suffixLength, totalBytes);
264
+ return resolvedRange(totalBytes - length, totalBytes - 1, totalBytes, 206);
265
+ }
266
+
267
+ if (totalBytes === 0) return unsatisfiableRange("empty_artifact", totalBytes);
268
+ if (first >= totalBytes) return unsatisfiableRange("start_out_of_bounds", totalBytes);
269
+
270
+ if (second === null) {
271
+ const end = Math.min(totalBytes - 1, first + maxPageBytes - 1);
272
+ return resolvedRange(first, end, totalBytes, 206);
273
+ }
274
+ if (second < first) return unsatisfiableRange("end_before_start", totalBytes);
275
+ if (second - first >= maxPageBytes) {
276
+ return invalidRange("range_too_large", maxPageBytes);
277
+ }
278
+ return resolvedRange(first, Math.min(second, totalBytes - 1), totalBytes, 206);
279
+ }
280
+
281
+ function assertRangeInputs(totalBytes: number, maxPageBytes: number): void {
282
+ if (!Number.isSafeInteger(totalBytes) || totalBytes < 0) {
283
+ throw new RangeError("totalBytes must be a nonnegative safe integer");
284
+ }
285
+ if (
286
+ !Number.isSafeInteger(maxPageBytes) ||
287
+ maxPageBytes <= 0 ||
288
+ maxPageBytes > RETAINED_OUTPUT_MAX_PAGE_BYTES
289
+ ) {
290
+ throw new RangeError(
291
+ `maxPageBytes must be an integer between 1 and ${RETAINED_OUTPUT_MAX_PAGE_BYTES}`,
292
+ );
293
+ }
294
+ }
295
+
296
+ function parseRangeInteger(value: string | undefined): number | null | "overflow" {
297
+ if (!value) return null;
298
+ // Avoid both precision loss and work proportional to an adversarial digit run.
299
+ if (value.length > 16) return "overflow";
300
+ const parsed = Number(value);
301
+ return Number.isSafeInteger(parsed) ? parsed : "overflow";
302
+ }
303
+
304
+ function resolvedRange(
305
+ start: number,
306
+ end: number,
307
+ totalBytes: number,
308
+ status: 200 | 206,
309
+ ): RetainedOutputResolvedRange {
310
+ return {
311
+ kind: "range",
312
+ start,
313
+ end,
314
+ length: end - start + 1,
315
+ totalBytes,
316
+ status,
317
+ contentRange: status === 206 ? `bytes ${start}-${end}/${totalBytes}` : null,
318
+ acceptRanges: "bytes",
319
+ };
320
+ }
321
+
322
+ function invalidRange(
323
+ reason: Extract<RetainedOutputRangeResolution, { kind: "invalid" }>["reason"],
324
+ maxPageBytes: number,
325
+ ): RetainedOutputRangeResolution {
326
+ return { kind: "invalid", reason, maxPageBytes };
327
+ }
328
+
329
+ function unsatisfiableRange(
330
+ reason: Extract<RetainedOutputRangeResolution, { kind: "unsatisfiable" }>["reason"],
331
+ totalBytes: number,
332
+ ): RetainedOutputRangeResolution {
333
+ return {
334
+ kind: "unsatisfiable",
335
+ reason,
336
+ totalBytes,
337
+ contentRange: `bytes */${totalBytes}`,
338
+ };
339
+ }