@serve.zone/interfaces 28.4.0 → 29.0.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,395 @@
1
+ import type {
2
+ IImmutableContainerInvocationPlatformEvidenceV1,
3
+ IImmutableContainerInvocationV1,
4
+ TSha256Digest,
5
+ } from './immutableimage.js';
6
+ import {
7
+ buildDigestPinnedImageReference,
8
+ isSha256Digest,
9
+ validateImmutableContainerInvocation,
10
+ verifyImmutableContainerInvocationDigest,
11
+ } from './immutableimage.js';
12
+ import type { IRuntimeAssignmentV1, IRuntimeAuthorityReferenceV1 } from './runtimeassignment.js';
13
+ import { snapshotRuntimeAssignmentV1, validateRuntimeAssignmentV1 } from './runtimeassignment.js';
14
+ import { canonicalizeStrictJson, createCanonicalJsonSha256Hex } from '../private/canonicaljson.js';
15
+
16
+ /** A container target only; declaration does not publish a host or gateway port. */
17
+ export interface IRuntimeConfigTargetPortV1 {
18
+ name: string;
19
+ port: number;
20
+ /** Inclusive; equal to port for a single port. */
21
+ portEnd: number;
22
+ protocol: 'tcp' | 'udp';
23
+ }
24
+
25
+ export interface IRuntimeConfigReadinessTimingV1 {
26
+ /** Measured from the evidenced container start; no probes before this delay. */
27
+ initialDelayMs: number;
28
+ /** Failures during this startup grace cannot mark the workload unhealthy. */
29
+ startupGraceMs: number;
30
+ /** Probe start-to-start interval; probes must never overlap. */
31
+ intervalMs: number;
32
+ /** Per-probe deadline, including connection, TLS and HTTP response headers. */
33
+ timeoutMs: number;
34
+ /** Consecutive successful probes needed before ready becomes true. */
35
+ successThreshold: number;
36
+ /** Consecutive failures after startup grace needed to declare unhealthy. */
37
+ failureThreshold: number;
38
+ }
39
+
40
+ export type TRuntimeConfigReadinessV1 = IRuntimeConfigReadinessTimingV1 & (
41
+ | { kind: 'process' }
42
+ | { kind: 'tcp'; targetPort: string; port: number }
43
+ | {
44
+ kind: 'http';
45
+ targetPort: string;
46
+ port: number;
47
+ scheme: 'http' | 'https';
48
+ /**
49
+ * Host header name and HTTPS SNI/certificate hostname. Connect only to the
50
+ * evidenced container endpoint, never resolve this name as a probe target.
51
+ * HTTPS verifies system trust and this hostname; no insecure TLS mode.
52
+ */
53
+ host: string;
54
+ /** Canonical origin-form request target, including any nonsecret query. */
55
+ path: string;
56
+ /** Sorted unique final HTTP response status codes. */
57
+ expectedStatusCodes: number[];
58
+ /** A redirect response is evaluated as received; it cannot redirect probes. */
59
+ followRedirects: false;
60
+ }
61
+ );
62
+
63
+ export interface IRuntimeConfigLogPolicyV1 {
64
+ /** Maximum UTF-8 bytes in one captured stdout/stderr line. */
65
+ maximumLineBytes: number;
66
+ /** Maximum bytes waiting for delivery; overflow requires explicit evidence. */
67
+ maximumBufferedBytes: number;
68
+ /** Retention of archived logs; durable payloads belong to SmartBucket. */
69
+ retentionMs: number;
70
+ }
71
+
72
+ /**
73
+ * Controller-owned resolved nonsecret execution intent. OCI command, effective
74
+ * environment, working directory and identity policy must be resolved before
75
+ * sealing; consumers must not choose resource limits or identity policies.
76
+ * Secrets, network identity and storage claims remain separately assignment-bound
77
+ * owner references. This document contains no registry credentials or host paths.
78
+ */
79
+ export interface IRuntimeConfigV1 extends IRuntimeAuthorityReferenceV1 {
80
+ schemaVersion: 1;
81
+ organizationId: string;
82
+ serviceId: string;
83
+ image: {
84
+ registryHost: string;
85
+ repository: string;
86
+ /** Root descriptor digest, which may be an OCI platform index. */
87
+ imageDigest: TSha256Digest;
88
+ invocation: IImmutableContainerInvocationV1;
89
+ };
90
+ /** Complete effective public environment; secret values must never be inlined. */
91
+ environment: Record<string, string>;
92
+ workingDirectory: string;
93
+ /** null explicitly requests no CPU quota; zero and omitted values are invalid. */
94
+ cpuMillis: number | null;
95
+ memoryBytes: number;
96
+ readonlyRootfs: boolean;
97
+ /**
98
+ * Both identity fields must be numbers or both must be null. The null pair
99
+ * explicitly selects the pinned platform image's OCI User, including primary
100
+ * and supplemental group resolution against its immutable root filesystem.
101
+ * An absent OCI User retains the image/runtime specification's root identity.
102
+ * The execution owner must delegate that resolution to the qualified runtime;
103
+ * it must not guess a GID for a UID-only or named image user.
104
+ */
105
+ runAsUser: number | null;
106
+ /** Numeric override paired with runAsUser, or null for the image identity policy. */
107
+ runAsGroup: number | null;
108
+ /** Sorted unique names, with no overlapping ranges for the same transport. */
109
+ targetPorts: IRuntimeConfigTargetPortV1[];
110
+ readiness: TRuntimeConfigReadinessV1;
111
+ /**
112
+ * SmartData owns log metadata and SmartBucket owns archived payloads. This
113
+ * policy does not authorize persistent local files. Any CRI log capture uses
114
+ * disposable process-local transport artifacts and must evidence bounded
115
+ * buffering, backpressure and lost data; it must not silently truncate logs.
116
+ */
117
+ logs: IRuntimeConfigLogPolicyV1;
118
+ }
119
+
120
+ export interface IRuntimeConfigAssignmentBindingV1 {
121
+ config: IRuntimeConfigV1;
122
+ assignment: IRuntimeAssignmentV1;
123
+ platformEvidence: IImmutableContainerInvocationPlatformEvidenceV1;
124
+ }
125
+
126
+ export const runtimeConfigV1Contract = Object.freeze({
127
+ digestDomain: 'serve.zone/runtime-config-v1',
128
+ maximumCanonicalBytes: 262_144,
129
+ maximumEnvironmentEntries: 128,
130
+ maximumTargetPorts: 64,
131
+ maximumReadinessDurationMs: 3_600_000,
132
+ maximumLogRetentionMs: 31_536_000_000,
133
+ } as const);
134
+
135
+ const fail: (messageArg: string) => never = (messageArg) => { throw new Error(messageArg); };
136
+ const encoder = new TextEncoder();
137
+ const isInteger = (valueArg: unknown, minimumArg: number, maximumArg: number): valueArg is number =>
138
+ typeof valueArg === 'number' && Number.isSafeInteger(valueArg) && !Object.is(valueArg, -0)
139
+ && valueArg >= minimumArg && valueArg <= maximumArg;
140
+ const isIdentifier = (valueArg: unknown): valueArg is string =>
141
+ typeof valueArg === 'string' && /^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/.test(valueArg);
142
+ const isDigest = (valueArg: unknown): valueArg is TSha256Digest =>
143
+ typeof valueArg === 'string' && isSha256Digest(valueArg);
144
+ const isPortName = (valueArg: unknown): valueArg is string =>
145
+ typeof valueArg === 'string' && /^[a-z0-9][a-z0-9_.-]{0,62}$/.test(valueArg);
146
+ const isExactRecord = (
147
+ valueArg: unknown, keysArg: readonly string[],
148
+ ): valueArg is Record<string, unknown> => {
149
+ if (!valueArg || typeof valueArg !== 'object' || Array.isArray(valueArg)) return false;
150
+ const keys = Object.keys(valueArg);
151
+ return keys.length === keysArg.length && keys.every((keyArg) => keysArg.includes(keyArg));
152
+ };
153
+
154
+ /**
155
+ * Bound traversal before canonicalization and copy descriptor values so input
156
+ * getters/toJSON never run. The canonical helper supplies scalar Unicode and
157
+ * safe-integer validation. Detached output is obtained before any async work.
158
+ */
159
+ const snapshotBoundedJson = (valueArg: unknown): unknown => {
160
+ let nodes = 0;
161
+ let stringBytes = 0;
162
+ const ancestors = new Set<object>();
163
+ const accountString = (value: string): void => {
164
+ if (value.length > runtimeConfigV1Contract.maximumCanonicalBytes) fail('runtime config string is oversized');
165
+ stringBytes += encoder.encode(value).byteLength;
166
+ if (stringBytes > runtimeConfigV1Contract.maximumCanonicalBytes) fail('runtime config is oversized');
167
+ };
168
+ const copy = (value: unknown, depth: number): unknown => {
169
+ if (++nodes > 8192 || depth > 12) fail('runtime config exceeds structural bounds');
170
+ if (typeof value === 'string') { accountString(value); return value; }
171
+ if (value === null || typeof value === 'boolean' || typeof value === 'number') return value;
172
+ if (!value || typeof value !== 'object') fail('runtime config contains a non-JSON value');
173
+ if (ancestors.has(value)) fail('runtime config contains a cycle');
174
+ ancestors.add(value);
175
+ try {
176
+ const array = Array.isArray(value);
177
+ const prototype = Object.getPrototypeOf(value);
178
+ if (array ? prototype !== Array.prototype : prototype !== Object.prototype && prototype !== null) {
179
+ fail('runtime config requires plain records and arrays');
180
+ }
181
+ const keys = Reflect.ownKeys(value);
182
+ if (keys.length > 257) fail('runtime config object exceeds key bounds');
183
+ if (array) {
184
+ const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length');
185
+ const length = lengthDescriptor?.value;
186
+ if (!isInteger(length, 0, 256) || keys.length !== length + 1) {
187
+ fail('runtime config requires bounded dense arrays');
188
+ }
189
+ const result: unknown[] = [];
190
+ for (let index = 0; index < length; index++) {
191
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
192
+ if (!descriptor || !descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) {
193
+ fail('runtime config array contains a missing or non-data entry');
194
+ }
195
+ result.push(copy(descriptor.value, depth + 1));
196
+ }
197
+ return result;
198
+ }
199
+ const result: Record<string, unknown> = Object.create(null);
200
+ for (const key of keys) {
201
+ if (typeof key !== 'string') fail('runtime config contains a symbol key');
202
+ accountString(key);
203
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
204
+ if (!descriptor || !descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) {
205
+ fail('runtime config requires enumerable data properties');
206
+ }
207
+ result[key] = copy(descriptor.value, depth + 1);
208
+ }
209
+ return result;
210
+ } finally { ancestors.delete(value); }
211
+ };
212
+ const canonical = canonicalizeStrictJson(copy(valueArg, 0), fail);
213
+ if (encoder.encode(canonical).byteLength > runtimeConfigV1Contract.maximumCanonicalBytes) {
214
+ fail('runtime config canonical JSON is oversized');
215
+ }
216
+ return JSON.parse(canonical);
217
+ };
218
+
219
+ const timingKeys = ['initialDelayMs', 'startupGraceMs', 'intervalMs', 'timeoutMs',
220
+ 'successThreshold', 'failureThreshold'] as const;
221
+ const assertReadiness = (valueArg: unknown, portsArg: IRuntimeConfigTargetPortV1[]): void => {
222
+ if (!valueArg || typeof valueArg !== 'object') fail('runtime config readiness is required');
223
+ const value = valueArg as Record<string, unknown>;
224
+ const keys = value.kind === 'process' ? ['kind', ...timingKeys]
225
+ : value.kind === 'tcp' ? ['kind', ...timingKeys, 'targetPort', 'port']
226
+ : value.kind === 'http' ? ['kind', ...timingKeys, 'targetPort', 'port',
227
+ 'scheme', 'host', 'path', 'expectedStatusCodes', 'followRedirects'] : [];
228
+ if (keys.length === 0 || !isExactRecord(value, keys)
229
+ || !isInteger(value.initialDelayMs, 0, runtimeConfigV1Contract.maximumReadinessDurationMs)
230
+ || !isInteger(value.startupGraceMs, 0, runtimeConfigV1Contract.maximumReadinessDurationMs)
231
+ || !isInteger(value.intervalMs, 100, runtimeConfigV1Contract.maximumReadinessDurationMs)
232
+ || !isInteger(value.timeoutMs, 1, 60_000)
233
+ || !isInteger(value.successThreshold, 1, 100)
234
+ || !isInteger(value.failureThreshold, 1, 100)
235
+ || value.timeoutMs > value.intervalMs || value.initialDelayMs > value.startupGraceMs) {
236
+ fail('runtime config readiness must use explicit bounded timing and exact schema');
237
+ }
238
+ if (value.kind === 'process') return;
239
+ const target = portsArg.find((portArg) => portArg.name === value.targetPort);
240
+ if (!isPortName(value.targetPort) || !target || target.protocol !== 'tcp'
241
+ || !isInteger(value.port, target.port, target.portEnd)) {
242
+ fail('runtime config readiness must select a declared TCP target and port');
243
+ }
244
+ if (value.kind === 'tcp') return;
245
+ if ((value.scheme !== 'http' && value.scheme !== 'https') || value.followRedirects !== false
246
+ || typeof value.host !== 'string' || value.host.length > 253
247
+ || !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/.test(value.host)
248
+ || typeof value.path !== 'string' || value.path.length > 4096
249
+ || !value.path.startsWith('/') || value.path.startsWith('//')
250
+ || /[^\x21-\x7e]|[\\#]/.test(value.path)
251
+ || /%(?![0-9A-F]{2})/.test(value.path)
252
+ || !Array.isArray(value.expectedStatusCodes) || value.expectedStatusCodes.length === 0
253
+ || value.expectedStatusCodes.length > 32) {
254
+ fail('runtime config HTTP readiness must use an exact bounded request policy');
255
+ }
256
+ const parsed = new URL(value.path, 'http://runtime.invalid');
257
+ if (`${parsed.pathname}${parsed.search}` !== value.path) fail('runtime config HTTP path must be canonical');
258
+ let previousCode = 0;
259
+ for (const code of value.expectedStatusCodes) {
260
+ if (!isInteger(code, 200, 599) || code <= previousCode) {
261
+ fail('runtime config HTTP status expectations must be sorted unique final codes');
262
+ }
263
+ previousCode = code;
264
+ }
265
+ };
266
+
267
+ const assertConfig = (valueArg: unknown): IRuntimeConfigV1 => {
268
+ if (!isExactRecord(valueArg, ['schemaVersion', 'id', 'generation', 'organizationId', 'serviceId',
269
+ 'image', 'environment', 'workingDirectory', 'cpuMillis', 'memoryBytes', 'readonlyRootfs',
270
+ 'runAsUser', 'runAsGroup', 'targetPorts', 'readiness', 'logs', 'digest'])
271
+ || valueArg.schemaVersion !== 1 || !isIdentifier(valueArg.id)
272
+ || !isInteger(valueArg.generation, 1, Number.MAX_SAFE_INTEGER)
273
+ || !isIdentifier(valueArg.organizationId) || !isIdentifier(valueArg.serviceId)
274
+ || !isDigest(valueArg.digest)) fail('runtime config must use its exact v1 schema');
275
+ const image = valueArg.image;
276
+ if (!isExactRecord(image, ['registryHost', 'repository', 'imageDigest', 'invocation'])
277
+ || typeof image.registryHost !== 'string' || image.registryHost.length > 253
278
+ || typeof image.repository !== 'string' || image.repository.length > 255
279
+ || !isDigest(image.imageDigest)
280
+ || validateImmutableContainerInvocation(image.invocation).length !== 0) {
281
+ fail('runtime config image must contain exact immutable invocation evidence');
282
+ }
283
+ buildDigestPinnedImageReference(image.registryHost as string, image.repository as string,
284
+ image.imageDigest as TSha256Digest);
285
+ const environment = valueArg.environment;
286
+ if (!environment || typeof environment !== 'object' || Array.isArray(environment)
287
+ || Object.keys(environment).length > runtimeConfigV1Contract.maximumEnvironmentEntries) {
288
+ fail('runtime config requires an explicit bounded nonsecret environment');
289
+ }
290
+ for (const [key, value] of Object.entries(environment)) {
291
+ if (!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(key) || typeof value !== 'string'
292
+ || value.includes('\u0000') || encoder.encode(value).byteLength > 8192) {
293
+ fail('runtime config environment must contain bounded string entries');
294
+ }
295
+ }
296
+ const directory = valueArg.workingDirectory;
297
+ if (typeof directory !== 'string' || !directory.startsWith('/')
298
+ || encoder.encode(directory).byteLength > 4096 || /[\u0000-\u001f\u007f]/.test(directory)
299
+ || (directory !== '/' && directory.split('/').slice(1).some((partArg) => !partArg || partArg === '.' || partArg === '..'))
300
+ || !(valueArg.cpuMillis === null || isInteger(valueArg.cpuMillis, 1, 1_000_000))
301
+ || !isInteger(valueArg.memoryBytes, 16 * 1024 * 1024, 2 ** 50)
302
+ || typeof valueArg.readonlyRootfs !== 'boolean'
303
+ || !((valueArg.runAsUser === null && valueArg.runAsGroup === null)
304
+ || (isInteger(valueArg.runAsUser, 0, 2 ** 31 - 1) && isInteger(valueArg.runAsGroup, 0, 2 ** 31 - 1)))) {
305
+ fail('runtime config requires canonical directory and explicit resource and identity policies');
306
+ }
307
+ const ports = valueArg.targetPorts;
308
+ if (!Array.isArray(ports) || ports.length > runtimeConfigV1Contract.maximumTargetPorts) {
309
+ fail('runtime config target ports must be a bounded array');
310
+ }
311
+ let previousName = '';
312
+ const validatedPorts: IRuntimeConfigTargetPortV1[] = [];
313
+ for (const port of ports) {
314
+ if (!isExactRecord(port, ['name', 'port', 'portEnd', 'protocol'])
315
+ || !isPortName(port.name) || port.name <= previousName
316
+ || !isInteger(port.port, 1, 65535) || !isInteger(port.portEnd, port.port, 65535)
317
+ || (port.protocol !== 'tcp' && port.protocol !== 'udp')
318
+ || validatedPorts.some((previousArg) => previousArg.protocol === port.protocol
319
+ && (port.port as number) <= previousArg.portEnd && (port.portEnd as number) >= previousArg.port)) {
320
+ fail('runtime config target ports require sorted unique names and nonoverlapping transport ranges');
321
+ }
322
+ previousName = port.name;
323
+ validatedPorts.push(port as unknown as IRuntimeConfigTargetPortV1);
324
+ }
325
+ assertReadiness(valueArg.readiness, validatedPorts);
326
+ const logs = valueArg.logs;
327
+ if (!isExactRecord(logs, ['maximumLineBytes', 'maximumBufferedBytes', 'retentionMs'])
328
+ || !isInteger(logs.maximumLineBytes, 1, 1_048_576)
329
+ || !isInteger(logs.maximumBufferedBytes, 1, 67_108_864)
330
+ || logs.maximumBufferedBytes < logs.maximumLineBytes
331
+ || !isInteger(logs.retentionMs, 1, runtimeConfigV1Contract.maximumLogRetentionMs)) {
332
+ fail('runtime config logs require explicit bounded line, buffer and retention limits');
333
+ }
334
+ return valueArg as unknown as IRuntimeConfigV1;
335
+ };
336
+
337
+ /**
338
+ * Detached exact-schema snapshot for synchronous persistence assertions. Digest
339
+ * integrity, scope authorization, admission and runtime readiness are not implied.
340
+ */
341
+ export const snapshotRuntimeConfigV1 = (valueArg: unknown): IRuntimeConfigV1 =>
342
+ assertConfig(snapshotBoundedJson(valueArg));
343
+
344
+ /** Domain-separated canonical JSON excludes only the config's own digest. */
345
+ export const computeRuntimeConfigV1Digest = async (configArg: IRuntimeConfigV1): Promise<TSha256Digest> => {
346
+ const { digest: _digest, ...payload } = snapshotRuntimeConfigV1(configArg);
347
+ return `sha256:${await createCanonicalJsonSha256Hex(canonicalizeStrictJson({
348
+ domain: runtimeConfigV1Contract.digestDomain, payload,
349
+ }, fail), fail)}`;
350
+ };
351
+
352
+ const validateSnapshot = async (configArg: IRuntimeConfigV1): Promise<string[]> => {
353
+ if (!await verifyImmutableContainerInvocationDigest(configArg.image.invocation)) {
354
+ return ['runtime config immutable invocation digest does not match'];
355
+ }
356
+ return configArg.digest === await computeRuntimeConfigV1Digest(configArg)
357
+ ? [] : ['runtime config digest does not match'];
358
+ };
359
+
360
+ export const validateRuntimeConfigV1 = async (valueArg: unknown): Promise<string[]> => {
361
+ try { return await validateSnapshot(snapshotRuntimeConfigV1(valueArg)); }
362
+ catch { return ['runtime config must use its exact canonical v1 schema']; }
363
+ };
364
+
365
+ /**
366
+ * Bind valid content to one exact assignment and select its platform evidence.
367
+ * No assignment digest is embedded in the config, avoiding circular hashes.
368
+ * This proves content consistency only: the caller must authenticate the
369
+ * controller, durably admit the attempt, resolve other owner references, and
370
+ * verify runtime and readiness evidence before effects or route publication.
371
+ */
372
+ export const bindRuntimeConfigV1ToAssignment = async (
373
+ configArg: unknown, assignmentArg: unknown,
374
+ ): Promise<IRuntimeConfigAssignmentBindingV1> => {
375
+ const config = snapshotRuntimeConfigV1(configArg);
376
+ const assignment = snapshotRuntimeAssignmentV1(snapshotBoundedJson(assignmentArg));
377
+ if ((await validateSnapshot(config)).length !== 0
378
+ || (await validateRuntimeAssignmentV1(assignment)).length !== 0) {
379
+ fail('runtime config binding requires valid config, invocation and assignment digests');
380
+ }
381
+ const reference = assignment.workload.config;
382
+ if (reference.id !== config.id || reference.generation !== config.generation
383
+ || reference.digest !== config.digest || assignment.organizationId !== config.organizationId
384
+ || assignment.serviceId !== config.serviceId
385
+ || assignment.workload.registryHost !== config.image.registryHost
386
+ || assignment.workload.repository !== config.image.repository
387
+ || assignment.workload.imageDigest !== config.image.imageDigest) {
388
+ fail('runtime config does not match the exact assignment reference, ownership and image');
389
+ }
390
+ const platformEvidence = config.image.invocation.evidence.find(
391
+ (entryArg) => entryArg.platform === assignment.workload.platform,
392
+ );
393
+ if (!platformEvidence) fail('runtime config lacks exact assignment platform evidence');
394
+ return { config, assignment, platformEvidence };
395
+ };