@sequenceholdings/artifact-studio 0.6.4 → 0.6.7

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.
Files changed (80) hide show
  1. package/README.md +254 -10
  2. package/VERSION-PIN.md +78 -1
  3. package/dist/gateway/admission.d.ts +23 -1
  4. package/dist/gateway/admission.js +30 -13
  5. package/dist/gateway/control.d.ts +40 -0
  6. package/dist/gateway/control.js +76 -7
  7. package/dist/gateway/hosted-proxy.d.ts +11 -8
  8. package/dist/gateway/hosted-proxy.js +99 -91
  9. package/dist/gateway/ingress-admission.d.ts +77 -0
  10. package/dist/gateway/ingress-admission.js +150 -0
  11. package/dist/gateway/ingress-audit.d.ts +46 -0
  12. package/dist/gateway/ingress-audit.js +84 -0
  13. package/dist/gateway/ingress-context.d.ts +57 -0
  14. package/dist/gateway/ingress-context.js +57 -0
  15. package/dist/gateway/ingress-development.d.ts +12 -0
  16. package/dist/gateway/ingress-development.js +23 -0
  17. package/dist/gateway/ingress-handler.d.ts +44 -0
  18. package/dist/gateway/ingress-handler.js +320 -0
  19. package/dist/gateway/ingress-pass.d.ts +83 -0
  20. package/dist/gateway/ingress-pass.js +143 -0
  21. package/dist/gateway/ingress-response.d.ts +74 -0
  22. package/dist/gateway/ingress-response.js +98 -0
  23. package/dist/gateway/ingress-runtime.d.ts +86 -0
  24. package/dist/gateway/ingress-runtime.js +126 -0
  25. package/dist/gateway/ingress-snapshot.d.ts +54 -0
  26. package/dist/gateway/ingress-snapshot.js +63 -0
  27. package/dist/gateway/ingress-stages.d.ts +60 -0
  28. package/dist/gateway/ingress-stages.js +387 -0
  29. package/dist/gateway/ingress.d.ts +259 -0
  30. package/dist/gateway/ingress.js +204 -0
  31. package/dist/gateway/lattice-invoke.js +2 -0
  32. package/dist/gateway/lattice-upstream.d.ts +3 -4
  33. package/dist/gateway/lattice-upstream.js +11 -9
  34. package/dist/gateway/runtime-transport.d.ts +17 -0
  35. package/dist/gateway/runtime-transport.js +26 -0
  36. package/dist/gateway/server.d.ts +3 -0
  37. package/dist/gateway/server.js +21 -0
  38. package/dist/gateway/session-response.d.ts +15 -0
  39. package/dist/gateway/session-response.js +40 -0
  40. package/dist/gateway/usage.d.ts +8 -1
  41. package/dist/gateway-main.js +32 -8
  42. package/dist/manifest-next-browser-data.d.ts +17 -0
  43. package/dist/manifest-next-browser-data.js +46 -0
  44. package/dist/manifest-next-ingress.d.ts +5 -2
  45. package/dist/manifest-next-ingress.js +27 -2
  46. package/dist/manifest-next.d.ts +27 -10
  47. package/dist/manifest-next.js +16 -5
  48. package/dist/manifest.d.ts +13 -6
  49. package/dist/next-project.d.ts +4 -0
  50. package/dist/sdk.d.ts +12 -3
  51. package/dist/server/broker-contract.d.ts +62 -0
  52. package/dist/server/broker-contract.js +43 -1
  53. package/dist/server/client.js +38 -13
  54. package/dist/server/external-context.d.ts +8 -1
  55. package/dist/server/external-context.js +7 -2
  56. package/dist/server/internal/v1-context.js +17 -1
  57. package/dist/server/internal/verify-any.d.ts +25 -2
  58. package/dist/server/internal/verify-any.js +38 -4
  59. package/dist/server/process-run-sharing.d.ts +86 -0
  60. package/dist/server/process-run-sharing.js +29 -0
  61. package/dist/server/process-runs-page.d.ts +39 -0
  62. package/dist/server/process-runs-page.js +10 -0
  63. package/dist/server/processes.d.ts +8 -9
  64. package/dist/server/processes.js +4 -0
  65. package/dist/server/request-context.js +10 -3
  66. package/dist/server/runtime-config.d.ts +10 -1
  67. package/dist/server/runtime-config.js +11 -1
  68. package/dist/server/testing.d.ts +1 -1
  69. package/dist/server.d.ts +2 -2
  70. package/dist/server.js +1 -1
  71. package/dist/tunnel/broker.d.ts +18 -0
  72. package/dist/tunnel/broker.js +175 -17
  73. package/dist/tunnel/client.d.ts +13 -0
  74. package/dist/tunnel/client.js +40 -2
  75. package/dist/tunnel/external.d.ts +51 -0
  76. package/dist/tunnel/external.js +10 -0
  77. package/dist/tunnel/index.d.ts +1 -1
  78. package/dist/tunnel/protocol.d.ts +20 -2
  79. package/dist/tunnel/protocol.js +42 -6
  80. package/package.json +5 -5
@@ -0,0 +1,320 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { canonicalizeIngressPath, signedIngressPath } from '../server/ingress-routes.js';
3
+ import { ArtifactAdmissionError } from './admission.js';
4
+ import { proxyHostedArtifact } from './hosted-proxy.js';
5
+ import { BASE_ALLOWED_REQUEST_HEADERS, callerOrigin, corsFor, edgeClientIp, edgeValuesMatch, hasAcceptableAuthorization, hasValidEdgeHeader, hostOrigins, INGRESS_LIMITS, INGRESS_LOGOUT_PATH, INGRESS_TOKEN_PATH, ingressCorsHeaders, IngressError, isIngressHost, requestedHeaders, requestLineHeaders, resolveIngressRoute, writeIngressError, writeIngressRefusal, } from './ingress.js';
6
+ import { INGRESS_RESPONSE_SECURITY_HEADERS } from './ingress-response.js';
7
+ /**
8
+ * The ingress request pipeline (design §(e), "Steady state" steps 1-3 and 9). Order matters:
9
+ * canonical host, edge header, snapshot, client address, canonical path, declared route and
10
+ * CORS are all local, so nothing unauthenticated reaches Redis or Atlas before `handlers.admit`.
11
+ */
12
+ export function createIngressHandler({ options, runtimeDomain, protocol, isDraining, onError, usage, proxyDevelopment }) {
13
+ /**
14
+ * The snapshot entry for a canonical host. A development target outside development is unknown
15
+ * whether the snapshot is fresh or stale, so a stale one never lends its loopback origins to CORS.
16
+ */
17
+ function knownEntry(host) {
18
+ if (!options)
19
+ return { state: 'unknown' };
20
+ const found = options.snapshot.lookup(host);
21
+ if (found.state !== 'unknown' && found.entry?.target.kind === 'development' && options.appEnv !== 'development')
22
+ return { state: 'unknown' };
23
+ return found;
24
+ }
25
+ // What `clientError` needs to answer a request timeout on a request already being handled.
26
+ const inFlight = new WeakMap();
27
+ function track(request, response) {
28
+ const socket = request.socket;
29
+ inFlight.set(socket, { request, response });
30
+ const forget = () => { if (inFlight.get(socket)?.response === response)
31
+ inFlight.delete(socket); };
32
+ response.once('finish', forget);
33
+ response.once('close', forget);
34
+ }
35
+ /** Every check that needs no credential, in the design's order. Throws IngressError. */
36
+ function checkLocally({ request, response, requestId, cors }) {
37
+ const host = request.headers.host;
38
+ // Non-canonical spellings are refused before any lookup.
39
+ if (!options || host === undefined || !isIngressHost({ rawHost: host, runtimeDomain }))
40
+ throw new IngressError(404, 'not_found');
41
+ if (isDraining())
42
+ throw new IngressError(503, 'temporarily_unavailable');
43
+ if (!hasValidEdgeHeader({ request, secrets: options.edgeSecrets }))
44
+ throw new IngressError(404, 'not_found', { audit: { reason: 'edge_header_invalid' }, metric: 'ingress_edge_header_invalid' });
45
+ cors.edgeVerified = true;
46
+ const origin = callerOrigin(request);
47
+ const found = knownEntry(host);
48
+ if (found.state === 'unknown')
49
+ throw new IngressError(404, 'not_found');
50
+ cors.origin = corsFor(origin, hostOrigins(found.entry));
51
+ if (found.state === 'stale')
52
+ throw new IngressError(503, 'temporarily_unavailable');
53
+ const clientIp = edgeClientIp(request);
54
+ if (clientIp === null)
55
+ throw new IngressError(400, 'bad_request', { audit: { reason: 'client_ip_invalid' }, metric: 'ingress_client_ip_invalid' });
56
+ const canonical = canonicalizeIngressPath(request.url ?? '');
57
+ if (!canonical)
58
+ throw new IngressError(404, 'not_found');
59
+ return { request, response, host, publicOrigin: `${protocol}//${host}`, entry: found.entry, clientIp, origin, requestId, canonical, method: request.method ?? 'GET' };
60
+ }
61
+ /** Only token and logout exist under /_sequence/ on an ingress host; no session-path endpoint is reachable. */
62
+ async function sequenceEndpoint(checked) {
63
+ const { canonical, method, origin, entry, request, response } = checked;
64
+ const handler = canonical.path === INGRESS_TOKEN_PATH ? options?.handlers?.exchange
65
+ : canonical.path === INGRESS_LOGOUT_PATH ? options?.handlers?.logout : null;
66
+ if (handler === null)
67
+ throw new IngressError(404, 'not_found');
68
+ const allowed = hostOrigins(entry);
69
+ if (method === 'OPTIONS') {
70
+ await options?.handlers?.preflight?.(checked);
71
+ preflight({ request, response, origin, allowed, methods: ['POST'], headers: BASE_ALLOWED_REQUEST_HEADERS });
72
+ return;
73
+ }
74
+ if (method !== 'POST')
75
+ throw new IngressError(404, 'not_found');
76
+ if (origin !== null && !allowed.has(origin))
77
+ throw forbidden('origin_not_allowed');
78
+ // The exchange carries the identity provider's token (≤ 8 KiB), logout a pass (≤ 4 KiB); a
79
+ // repeated header is refused, so no handler ever reads Node's first value of several.
80
+ const exchange = canonical.path === INGRESS_TOKEN_PATH;
81
+ const maxBytes = exchange ? INGRESS_LIMITS.maxExchangeAuthorizationBytes : INGRESS_LIMITS.maxApiAuthorizationBytes;
82
+ if (!hasAcceptableAuthorization({ request, maxBytes }))
83
+ throw new IngressError(401, 'invalid_token', { metric: exchange ? 'ingress_token_rejected' : 'ingress_pass_rejected' });
84
+ if (!handler)
85
+ throw new IngressError(503, 'temporarily_unavailable');
86
+ await handler(checked);
87
+ }
88
+ async function routePreflight(checked) {
89
+ const { request, response, entry, canonical, origin } = checked;
90
+ await options?.handlers?.preflight?.(checked);
91
+ const requested = request.headers['access-control-request-method'];
92
+ const resolved = typeof requested === 'string' ? resolveIngressRoute({ entry, method: requested, path: canonical.path }) : null;
93
+ if (!resolved)
94
+ throw forbidden('preflight_refused');
95
+ preflight({
96
+ request, response, origin, allowed: new Set(resolved.ingress.allowedOrigins), methods: resolved.match.route.methods,
97
+ headers: [...BASE_ALLOWED_REQUEST_HEADERS, ...resolved.ingress.headers],
98
+ });
99
+ }
100
+ async function routeRequest(checked, cors) {
101
+ const { request, response, entry, canonical, method, origin } = checked;
102
+ const resolved = resolveIngressRoute({ entry, method, path: canonical.path });
103
+ if (!resolved)
104
+ throw new IngressError(404, 'not_found');
105
+ // From here CORS follows the matched ingress's own origins.
106
+ cors.origin = corsFor(origin, new Set(resolved.ingress.allowedOrigins));
107
+ if (origin !== null && cors.origin === null)
108
+ throw forbidden('origin_not_allowed', resolved.ingress.id);
109
+ // At most one Authorization header, at most 4 KiB: refused unread, before any Redis or Atlas call.
110
+ if (!hasAcceptableAuthorization({ request, maxBytes: INGRESS_LIMITS.maxApiAuthorizationBytes }))
111
+ throw new IngressError(401, 'invalid_token', { metric: 'ingress_pass_rejected' });
112
+ if (!options?.handlers?.admit)
113
+ throw new IngressError(503, 'temporarily_unavailable');
114
+ const admitted = await options.handlers.admit({ ...checked, ingress: resolved.ingress, match: resolved.match, signedPath: signedIngressPath(canonical) });
115
+ const corsOrigin = cors.origin;
116
+ if (corsOrigin)
117
+ for (const [name, value] of Object.entries(ingressCorsHeaders(corsOrigin)))
118
+ response.setHeader(name, value);
119
+ observe({ response, admitted, requestId: checked.requestId });
120
+ admitted.bind?.(response, () => timeOutAdmitted({ response, corsOrigin }));
121
+ if (response.destroyed)
122
+ return;
123
+ await forwardAdmitted({ options, proxyDevelopment, checked, resolved, admitted, corsOrigin });
124
+ }
125
+ /** Handles one request on a host `resemblesIngressHost` matched. */
126
+ async function handle(request, response) {
127
+ const requestId = randomUUID();
128
+ response.setHeader('x-request-id', requestId);
129
+ track(request, response);
130
+ const cors = { origin: null, edgeVerified: false };
131
+ try {
132
+ const checked = checkLocally({ request, response, requestId, cors });
133
+ if (checked.canonical.path.startsWith('/_sequence/'))
134
+ await sequenceEndpoint(checked);
135
+ else if (checked.method === 'OPTIONS')
136
+ await routePreflight(checked);
137
+ else
138
+ await routeRequest(checked, cors);
139
+ }
140
+ catch (error) {
141
+ if (error instanceof ArtifactAdmissionError)
142
+ onError?.({ kind: 'admission_rejected' });
143
+ else if (error instanceof IngressError) {
144
+ if (error.extra.metric)
145
+ onError?.({ kind: error.extra.metric });
146
+ }
147
+ else
148
+ onError?.({ kind: 'ingress_request_failed' });
149
+ recordDenial({ request, error, edgeVerified: cors.edgeVerified });
150
+ // A refused origin or preflight never carries CORS headers (design §(e)).
151
+ writeIngressRefusal({ response, error, corsOrigin: error instanceof IngressError && error.code === 'forbidden' ? null : cors.origin });
152
+ }
153
+ }
154
+ /**
155
+ * Reports a refusal the gateway decided itself to the denial audit, only on a canonical host in
156
+ * the snapshot: random hostnames never become audit records. The client address is recorded only
157
+ * when the edge header verified: otherwise `CF-Connecting-IP` is whatever the caller chose.
158
+ */
159
+ function recordDenial({ request, error, edgeVerified }) {
160
+ if (!options?.onDenial || !(error instanceof IngressError) || !error.extra.audit)
161
+ return;
162
+ const host = request.headers.host;
163
+ if (host === undefined || !isIngressHost({ rawHost: host, runtimeDomain }) || knownEntry(host).state === 'unknown')
164
+ return;
165
+ const { reason, ingress = null, subjectHash, passId } = error.extra.audit;
166
+ options.onDenial({
167
+ ts: Date.now(), host, ingress, reason, status: error.status, clientIp: edgeVerified ? edgeClientIp(request) : null,
168
+ ...(subjectHash ? { subjectHash } : {}), ...(passId ? { passId } : {}),
169
+ });
170
+ }
171
+ /** One usage observation per proxied request, attributed to the external principal (`ext|…`). */
172
+ function observe({ response, admitted, requestId }) {
173
+ const attribution = admitted.usage;
174
+ if (!usage || !attribution)
175
+ return;
176
+ const startedAt = new Date().toISOString(), started = performance.now();
177
+ let reported = false;
178
+ const report = () => {
179
+ if (reported)
180
+ return;
181
+ reported = true;
182
+ usage.record({
183
+ eventId: `gateway:${requestId}`, requestId, actorId: attribution.actorId, principalKind: 'external', ingressId: attribution.ingressId,
184
+ projectId: attribution.projectId, environment: attribution.environment,
185
+ ...('devSessionId' in attribution ? { devSessionId: attribution.devSessionId } : { deploymentId: attribution.deploymentId }),
186
+ startedAt, endedAt: new Date().toISOString(),
187
+ measurements: { requestCount: 1, durationMs: Math.max(0, Math.round(performance.now() - started)) },
188
+ outcome: !response.writableFinished ? 'cancelled' : response.statusCode >= 400 ? 'error' : 'success',
189
+ httpStatus: response.statusCode,
190
+ });
191
+ };
192
+ response.once('finish', report);
193
+ response.once('close', report);
194
+ }
195
+ /** Preflights are answered locally and never forwarded; a refusal carries no CORS headers. */
196
+ function preflight({ request, response, origin, allowed, methods, headers }) {
197
+ const method = request.headers['access-control-request-method'];
198
+ if (origin === null || !allowed.has(origin) || typeof method !== 'string' || !methods.includes(method) ||
199
+ !requestedHeaders(request).every((name) => headers.includes(name)))
200
+ throw forbidden('preflight_refused');
201
+ response.writeHead(204, {
202
+ ...INGRESS_RESPONSE_SECURITY_HEADERS,
203
+ 'access-control-allow-origin': origin,
204
+ 'access-control-allow-methods': methods.join(', '),
205
+ 'access-control-allow-headers': headers.join(', '),
206
+ 'access-control-max-age': String(INGRESS_LIMITS.preflightMaxAgeSeconds),
207
+ vary: 'Origin, Access-Control-Request-Method, Access-Control-Request-Headers',
208
+ });
209
+ response.end();
210
+ }
211
+ function forbidden(reason, ingress) {
212
+ return new IngressError(403, 'forbidden', { audit: { reason, ...(ingress ? { ingress } : {}) } });
213
+ }
214
+ /** Host, Origin and edge validity of the request a Node-level error belongs to. */
215
+ function attribute({ socket, rawPacket, secrets }) {
216
+ const current = inFlight.get(socket);
217
+ if (current) {
218
+ const { request, response } = current;
219
+ return { host: request.headers.host, origin: callerOrigin(request), edge: hasValidEdgeHeader({ request, secrets }), response };
220
+ }
221
+ const parsed = requestLineHeaders(rawPacket);
222
+ return { host: parsed.host, origin: parsed.origin ?? null, edge: edgeValuesMatch({ values: parsed.edge, secrets }), response: undefined };
223
+ }
224
+ /**
225
+ * `clientError` for a canonical ingress host: a CORS-bearing JSON 408 or 431 instead of Node's
226
+ * bare status line, so the caller can read it. Like every other ingress response it requires the
227
+ * edge header, so a request that bypassed the zone learns nothing about hosts or origins. Returns
228
+ * false when the socket is not an ingress request this handler can attribute, so the caller
229
+ * writes Node's default.
230
+ */
231
+ function clientError({ error, socket }) {
232
+ const status = error.code === 'HPE_HEADER_OVERFLOW' ? 431 : error.code === 'ERR_HTTP_REQUEST_TIMEOUT' ? 408 : undefined;
233
+ if (!options || status === undefined || !socket.writable)
234
+ return false;
235
+ const parsed = attribute({ socket, rawPacket: error.rawPacket, secrets: options.edgeSecrets });
236
+ if (parsed.host === undefined || !isIngressHost({ rawHost: parsed.host, runtimeDomain }) || !parsed.edge)
237
+ return false;
238
+ if (parsed.response?.headersSent) {
239
+ socket.destroy();
240
+ return true;
241
+ }
242
+ const found = knownEntry(parsed.host);
243
+ const entry = found.state === 'unknown' ? undefined : found.entry;
244
+ const cors = corsFor(parsed.origin, hostOrigins(entry));
245
+ const code = status === 431 ? 'request_header_fields_too_large' : 'request_timeout';
246
+ const body = JSON.stringify({ error: code });
247
+ const headers = {
248
+ ...INGRESS_RESPONSE_SECURITY_HEADERS, 'content-type': 'application/json', 'content-length': String(Buffer.byteLength(body)),
249
+ connection: 'close', ...(cors ? ingressCorsHeaders(cors) : {}),
250
+ };
251
+ const reason = status === 431 ? 'Request Header Fields Too Large' : 'Request Timeout';
252
+ socket.end(`HTTP/1.1 ${status} ${reason}\r\n${Object.entries(headers).map(([name, value]) => `${name}: ${value}`).join('\r\n')}\r\n\r\n${body}`);
253
+ return true;
254
+ }
255
+ return { handle, clientError };
256
+ }
257
+ /**
258
+ * The admitted request's budget ran out (the admission timer starts before the proxy's, so it
259
+ * fires first). Before any response byte the caller gets the error contract's 504, which it can
260
+ * read; after the head the response is destroyed. Either way the response closes, and both
261
+ * proxies stop the upstream on close.
262
+ */
263
+ function timeOutAdmitted({ response, corsOrigin }) {
264
+ // writeIngressError destroys a response whose head was already sent.
265
+ writeIngressError({ response, status: 504, code: 'upstream_timeout', corsOrigin });
266
+ }
267
+ /**
268
+ * Proxies an admitted request under the matched route's limits and response policy: to the
269
+ * active deployment, or for a development target over its session tunnel. Only a development
270
+ * gateway does the latter (design §(j.3) step 3); the stage refused it first, and the permit is
271
+ * already bound, so a refusal here releases it with the response.
272
+ */
273
+ async function forwardAdmitted({ options, proxyDevelopment, checked, resolved, admitted, corsOrigin }) {
274
+ const { request, response } = checked;
275
+ const limits = { maxRequestBytes: resolved.match.route.max_body_bytes, maxResponseBytes: options.maxResponseBytes ?? INGRESS_LIMITS.maxResponseBytes, timeoutMs: admitted.timeoutMs };
276
+ const ingress = {
277
+ responses: resolved.match.route.responses,
278
+ requestHeaders: resolved.ingress.headers,
279
+ idleWriteTimeoutMs: options.idleWriteTimeoutMs,
280
+ errorWriter: ({ status, code }) => writeIngressError({ response, status, code, corsOrigin }),
281
+ };
282
+ const { target, requestContext, body } = admitted;
283
+ if (target.kind === 'development') {
284
+ if (options.appEnv !== 'development' || !proxyDevelopment)
285
+ throw new IngressError(503, 'temporarily_unavailable');
286
+ await proxyDevelopment({ request, response, target, publicOrigin: checked.publicOrigin, requestContext, body, limits, ingress });
287
+ return;
288
+ }
289
+ await (options.proxy ?? proxyHostedArtifact)({ request, response, requestContext, body, target: { ...target, publicOrigin: checked.publicOrigin }, limits, ingress });
290
+ }
291
+ /**
292
+ * Node's own `clientError` replies, kept for every request the ingress handler does not answer.
293
+ * Like Node, nothing is written once the in-flight response has started.
294
+ */
295
+ function defaultClientError({ error, socket, inFlight }) {
296
+ if (socket.writable && !inFlight?.headersSent) {
297
+ const line = error.code === 'HPE_HEADER_OVERFLOW' ? '431 Request Header Fields Too Large'
298
+ : error.code === 'HPE_CHUNK_EXTENSIONS_OVERFLOW' ? '413 Payload Too Large'
299
+ : error.code === 'ERR_HTTP_REQUEST_TIMEOUT' ? '408 Request Timeout' : '400 Bad Request';
300
+ socket.write(`HTTP/1.1 ${line}\r\nConnection: close\r\n\r\n`);
301
+ }
302
+ socket.destroy(error);
303
+ }
304
+ /**
305
+ * Installs `clientError` on the gateway server: an attributable ingress request gets the ingress
306
+ * reply, everything else Node's own. Tracks the response in flight on each socket for the check.
307
+ */
308
+ export function handleClientErrors({ server, ingress }) {
309
+ const inFlight = new WeakMap();
310
+ server.on('request', (request, response) => {
311
+ const socket = request.socket;
312
+ inFlight.set(socket, response);
313
+ response.once('finish', () => { if (inFlight.get(socket) === response)
314
+ inFlight.delete(socket); });
315
+ });
316
+ server.on('clientError', (error, socket) => {
317
+ if (!ingress.clientError({ error, socket }))
318
+ defaultClientError({ error, socket, inFlight: inFlight.get(socket) });
319
+ });
320
+ }
@@ -0,0 +1,83 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * The ingress pass (design §(d)): `sqx1.<kid>.<b64url(payload)>.<b64url(mac)>`, minted by the
4
+ * gateway after Atlas verified an identity provider's token, and verified locally on every API
5
+ * call with no Redis and no Atlas. Its MAC key is derived from the gateway-only pass keyring with
6
+ * a salt of its own, so neither the control token (Atlas holds it) nor the session key (it signs
7
+ * iframe cookies raw) can produce or verify a pass.
8
+ */
9
+ export declare const INGRESS_PASS_PREFIX = "sqx1";
10
+ export declare const INGRESS_PASS_MAX_BYTES = 2048;
11
+ /** Never `atlas-artifact-next`, the control-token salt Atlas derives with (design §(b.1) item 5). */
12
+ export declare const INGRESS_PASS_HKDF_SALT = "artifact-next-gateway-ingress-pass";
13
+ export declare const INGRESS_PASS_HKDF_INFO = "sqx1";
14
+ /** A pass issued up to this many seconds in the future (gateway replicas' clock skew) still verifies. */
15
+ export declare const INGRESS_PASS_IAT_SKEW_SECONDS = 5;
16
+ export declare const INGRESS_PASS_KEYRING_MAX_KEYS = 3;
17
+ /** At least 32 random bytes, base64-encoded. */
18
+ export declare const INGRESS_PASS_KEY_MIN_LENGTH = 43;
19
+ /** `ARTIFACT_NEXT_INGRESS_PASS_KEYS`: `{"current":"k2","keys":{"k1":"…","k2":"…"}}`. */
20
+ export declare const ingressPassKeyringSchema: z.ZodObject<{
21
+ current: z.ZodString;
22
+ keys: z.ZodRecord<z.ZodString, z.ZodString>;
23
+ }, z.core.$strict>;
24
+ export type IngressPassKeyring = z.infer<typeof ingressPassKeyringSchema>;
25
+ /** Strict: a pass carrying any other key is refused. */
26
+ export declare const ingressPassPayloadSchema: z.ZodObject<{
27
+ v: z.ZodLiteral<1>;
28
+ typ: z.ZodLiteral<"artifact-ingress-pass">;
29
+ env: z.ZodString;
30
+ origin: z.ZodString;
31
+ projectId: z.ZodString;
32
+ ingress: z.ZodString;
33
+ provider: z.ZodString;
34
+ bep: z.ZodNumber;
35
+ issuer: z.ZodString;
36
+ subject: z.ZodString;
37
+ clientId: z.ZodString;
38
+ email: z.ZodString;
39
+ emailVerified: z.ZodLiteral<true>;
40
+ authTime: z.ZodNullable<z.ZodNumber>;
41
+ amr: z.ZodNullable<z.ZodArray<z.ZodString>>;
42
+ tokenIssuedAt: z.ZodNumber;
43
+ exchangeIp: z.ZodUnion<readonly [z.ZodIPv4, z.ZodIPv6]>;
44
+ jti: z.ZodUUID;
45
+ iat: z.ZodNumber;
46
+ exp: z.ZodNumber;
47
+ }, z.core.$strict>;
48
+ export type IngressPassPayload = z.infer<typeof ingressPassPayloadSchema>;
49
+ /** `K_kid = HKDF-SHA256(keyring[kid], salt, info, 32)`; never the raw keyring value. */
50
+ export declare function derivePassKey(secret: string): Buffer;
51
+ /** A valid payload whose pass would exceed INGRESS_PASS_MAX_BYTES: a refusal, never retryable. */
52
+ export declare class IngressPassTooLargeError extends Error {
53
+ constructor();
54
+ }
55
+ /** Mints a pass with the keyring's current key. The payload is validated first; `exp` is the caller's. */
56
+ export declare function mintPass({ keyring, payload }: {
57
+ keyring: IngressPassKeyring;
58
+ payload: IngressPassPayload;
59
+ }): string;
60
+ export type IngressPassRefusal = 'malformed' | 'unknown_kid' | 'bad_mac' | 'invalid_payload' | 'expired' | 'not_yet_valid' | 'wrong_origin' | 'wrong_environment';
61
+ export type IngressPassVerification = {
62
+ ok: true;
63
+ payload: IngressPassPayload;
64
+ kid: string;
65
+ } | {
66
+ ok: false;
67
+ reason: IngressPassRefusal;
68
+ };
69
+ /**
70
+ * Verifies a pass locally: format and size, a keyring kid, a constant-time MAC, then the strict
71
+ * payload, `exp > now`, `iat ≤ now + 5`, the minting host's origin and this gateway's APP_ENV.
72
+ * The payload is parsed only after the MAC holds. Checking the ingress and provider against the
73
+ * matched route is the caller's.
74
+ */
75
+ export declare function verifyPass({ keyring, token, now, origin, env }: {
76
+ keyring: IngressPassKeyring;
77
+ token: string;
78
+ /** Epoch seconds. */
79
+ now: number;
80
+ /** The public origin of the host the request arrived on. */
81
+ origin: string;
82
+ env: string;
83
+ }): IngressPassVerification;
@@ -0,0 +1,143 @@
1
+ import { createHmac, hkdfSync, timingSafeEqual } from 'node:crypto';
2
+ import { z } from 'zod';
3
+ import { artifactNextIngressIdSchema, artifactNextIngressProviderRefSchema } from '../manifest-next-ingress.js';
4
+ import { INGRESS_MAX_PASS_SECONDS } from '../server/ingress-control-contract.js';
5
+ import { artifactExternalPrincipalSchema, artifactIpAddressSchema } from '../server/request-context.js';
6
+ /**
7
+ * The ingress pass (design §(d)): `sqx1.<kid>.<b64url(payload)>.<b64url(mac)>`, minted by the
8
+ * gateway after Atlas verified an identity provider's token, and verified locally on every API
9
+ * call with no Redis and no Atlas. Its MAC key is derived from the gateway-only pass keyring with
10
+ * a salt of its own, so neither the control token (Atlas holds it) nor the session key (it signs
11
+ * iframe cookies raw) can produce or verify a pass.
12
+ */
13
+ export const INGRESS_PASS_PREFIX = 'sqx1';
14
+ export const INGRESS_PASS_MAX_BYTES = 2048;
15
+ /** Never `atlas-artifact-next`, the control-token salt Atlas derives with (design §(b.1) item 5). */
16
+ export const INGRESS_PASS_HKDF_SALT = 'artifact-next-gateway-ingress-pass';
17
+ export const INGRESS_PASS_HKDF_INFO = 'sqx1';
18
+ /** A pass issued up to this many seconds in the future (gateway replicas' clock skew) still verifies. */
19
+ export const INGRESS_PASS_IAT_SKEW_SECONDS = 5;
20
+ export const INGRESS_PASS_KEYRING_MAX_KEYS = 3;
21
+ /** At least 32 random bytes, base64-encoded. */
22
+ export const INGRESS_PASS_KEY_MIN_LENGTH = 43;
23
+ const passKidSchema = z.string().regex(/^[A-Za-z0-9_-]{1,32}$/, 'Expected a pass key id of letters, digits, _ or -');
24
+ const passKeySchema = z.string().min(INGRESS_PASS_KEY_MIN_LENGTH).max(1024);
25
+ /** `ARTIFACT_NEXT_INGRESS_PASS_KEYS`: `{"current":"k2","keys":{"k1":"…","k2":"…"}}`. */
26
+ export const ingressPassKeyringSchema = z.strictObject({
27
+ current: passKidSchema,
28
+ keys: z.record(passKidSchema, passKeySchema),
29
+ }).superRefine((keyring, ctx) => {
30
+ const kids = Object.keys(keyring.keys);
31
+ if (kids.length < 1 || kids.length > INGRESS_PASS_KEYRING_MAX_KEYS)
32
+ ctx.addIssue({ code: 'custom', path: ['keys'], message: `Expected 1-${INGRESS_PASS_KEYRING_MAX_KEYS} pass keys` });
33
+ if (!Object.hasOwn(keyring.keys, keyring.current))
34
+ ctx.addIssue({ code: 'custom', path: ['current'], message: 'The current pass key id must name a key' });
35
+ });
36
+ const principal = artifactExternalPrincipalSchema.shape;
37
+ const epochSeconds = z.number().int().nonnegative();
38
+ /** Strict: a pass carrying any other key is refused. */
39
+ export const ingressPassPayloadSchema = z.strictObject({
40
+ v: z.literal(1),
41
+ typ: z.literal('artifact-ingress-pass'),
42
+ env: z.string().min(1).max(64),
43
+ /** The ingress host's public origin; a pass verifies only on the host that minted it. */
44
+ origin: z.string().min(1).max(300),
45
+ projectId: z.string().min(1).max(255),
46
+ ingress: artifactNextIngressIdSchema,
47
+ provider: artifactNextIngressProviderRefSchema,
48
+ /** The binding epoch at exchange; informational only. */
49
+ bep: z.number().int().nonnegative(),
50
+ issuer: principal.issuer,
51
+ subject: principal.subject,
52
+ clientId: principal.clientId,
53
+ email: principal.email,
54
+ emailVerified: principal.emailVerified,
55
+ authTime: principal.authTime,
56
+ amr: principal.amr,
57
+ /** The identity provider's `iat`, which Atlas verified; revocation compares it, never the pass `iat`. */
58
+ tokenIssuedAt: principal.tokenIssuedAt,
59
+ exchangeIp: artifactIpAddressSchema,
60
+ jti: z.uuid(),
61
+ iat: epochSeconds,
62
+ exp: epochSeconds,
63
+ }).refine((payload) => payload.exp > payload.iat && payload.exp - payload.iat <= INGRESS_MAX_PASS_SECONDS, 'Invalid pass lifetime');
64
+ /** `K_kid = HKDF-SHA256(keyring[kid], salt, info, 32)`; never the raw keyring value. */
65
+ export function derivePassKey(secret) {
66
+ return Buffer.from(hkdfSync('sha256', Buffer.from(secret, 'utf8'), INGRESS_PASS_HKDF_SALT, INGRESS_PASS_HKDF_INFO, 32));
67
+ }
68
+ function mac({ key, kid, payload }) {
69
+ return createHmac('sha256', key).update(`${INGRESS_PASS_PREFIX}.${kid}.${payload}`).digest();
70
+ }
71
+ /** Derived keys per keyring, so every verification costs one HMAC and no HKDF. */
72
+ const derivedKeys = new WeakMap();
73
+ function keysOf(keyring) {
74
+ let keys = derivedKeys.get(keyring);
75
+ if (!keys) {
76
+ keys = new Map(Object.entries(keyring.keys).map(([kid, secret]) => [kid, derivePassKey(secret)]));
77
+ derivedKeys.set(keyring, keys);
78
+ }
79
+ return keys;
80
+ }
81
+ /** A valid payload whose pass would exceed INGRESS_PASS_MAX_BYTES: a refusal, never retryable. */
82
+ export class IngressPassTooLargeError extends Error {
83
+ constructor() {
84
+ super('Ingress pass exceeds its size limit');
85
+ this.name = 'IngressPassTooLargeError';
86
+ }
87
+ }
88
+ /** Mints a pass with the keyring's current key. The payload is validated first; `exp` is the caller's. */
89
+ export function mintPass({ keyring, payload }) {
90
+ const parsed = ingressPassPayloadSchema.parse(payload);
91
+ const kid = keyring.current;
92
+ const key = keysOf(keyring).get(kid);
93
+ if (!key)
94
+ throw new Error('The current pass key is missing');
95
+ const encoded = Buffer.from(JSON.stringify(parsed)).toString('base64url');
96
+ const token = `${INGRESS_PASS_PREFIX}.${kid}.${encoded}.${mac({ key, kid, payload: encoded }).toString('base64url')}`;
97
+ if (Buffer.byteLength(token) > INGRESS_PASS_MAX_BYTES)
98
+ throw new IngressPassTooLargeError();
99
+ return token;
100
+ }
101
+ const SEGMENT = /^[A-Za-z0-9_-]+$/;
102
+ /**
103
+ * Verifies a pass locally: format and size, a keyring kid, a constant-time MAC, then the strict
104
+ * payload, `exp > now`, `iat ≤ now + 5`, the minting host's origin and this gateway's APP_ENV.
105
+ * The payload is parsed only after the MAC holds. Checking the ingress and provider against the
106
+ * matched route is the caller's.
107
+ */
108
+ export function verifyPass({ keyring, token, now, origin, env }) {
109
+ if (Buffer.byteLength(token) > INGRESS_PASS_MAX_BYTES)
110
+ return { ok: false, reason: 'malformed' };
111
+ const parts = token.split('.');
112
+ if (parts.length !== 4 || parts[0] !== INGRESS_PASS_PREFIX)
113
+ return { ok: false, reason: 'malformed' };
114
+ const [, kid, payload, presented] = parts;
115
+ if (!passKidSchema.safeParse(kid).success || !SEGMENT.test(payload) || !SEGMENT.test(presented))
116
+ return { ok: false, reason: 'malformed' };
117
+ const key = keysOf(keyring).get(kid);
118
+ if (!key)
119
+ return { ok: false, reason: 'unknown_kid' };
120
+ const expected = mac({ key, kid, payload });
121
+ const actual = Buffer.from(presented, 'base64url');
122
+ if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
123
+ return { ok: false, reason: 'bad_mac' };
124
+ let decoded;
125
+ try {
126
+ decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
127
+ }
128
+ catch {
129
+ return { ok: false, reason: 'invalid_payload' };
130
+ }
131
+ const parsed = ingressPassPayloadSchema.safeParse(decoded);
132
+ if (!parsed.success)
133
+ return { ok: false, reason: 'invalid_payload' };
134
+ if (parsed.data.exp <= now)
135
+ return { ok: false, reason: 'expired' };
136
+ if (parsed.data.iat > now + INGRESS_PASS_IAT_SKEW_SECONDS)
137
+ return { ok: false, reason: 'not_yet_valid' };
138
+ if (parsed.data.origin !== origin)
139
+ return { ok: false, reason: 'wrong_origin' };
140
+ if (parsed.data.env !== env)
141
+ return { ok: false, reason: 'wrong_environment' };
142
+ return { ok: true, payload: parsed.data, kid };
143
+ }
@@ -0,0 +1,74 @@
1
+ import type { ServerResponse } from 'node:http';
2
+ /**
3
+ * Response policy for an external ingress (design §(e) "Response policy"). An ingress answers a
4
+ * cross-origin caller that is not an Atlas user, so nothing it returns may become a document, a
5
+ * redirect, a stream, a cookie or a cache entry: the only output is data of a type the route
6
+ * declared. The decision is taken on the status line and headers, before any body byte is sent.
7
+ */
8
+ /**
9
+ * Set on every ingress response, the gateway's own errors included. The shared-cache refusal is
10
+ * the one every protected session response carries, so no zone cache rule can hold borrower data.
11
+ */
12
+ export declare const INGRESS_RESPONSE_SECURITY_HEADERS: Readonly<Record<string, string>>;
13
+ export type IngressResponseRefusal = 'redirect' | 'event_stream' | 'content_type' | 'missing_content_type';
14
+ export type IngressResponseDecision = {
15
+ allowed: true;
16
+ headers: Record<string, string | string[]>;
17
+ } | {
18
+ allowed: false;
19
+ reason: IngressResponseRefusal;
20
+ };
21
+ /**
22
+ * The upstream's declared body length, read from the raw head: the shared sanitising removes
23
+ * `content-length` as a hop header, so the policy must be handed it explicitly. The same parser
24
+ * the development tunnel uses, so hosted and development ingress judge a head alike.
25
+ */
26
+ export { declaredLength as declaredContentLength } from '../tunnel/external.js';
27
+ /**
28
+ * Decides an upstream response on an ingress route. `headers` are the app's headers after the
29
+ * shared private-header sanitising (no `content-length`); `declaredLength` is the upstream's own
30
+ * `Content-Length`; `responses` are the matched route's declared content types.
31
+ */
32
+ export declare function applyIngressResponsePolicy({ status, method, headers, declaredLength, responses }: {
33
+ status: number;
34
+ method: string;
35
+ headers: Readonly<Record<string, string | string[]>>;
36
+ declaredLength: number | null;
37
+ responses: readonly string[];
38
+ }): IngressResponseDecision;
39
+ /**
40
+ * Applies the policy to an upstream head and writes the allowed status and headers onto the
41
+ * gateway response. False when the response is refused (502 before any body byte), including a
42
+ * declared length above the ingress response cap.
43
+ */
44
+ export declare function writeIngressResponseHead({ response, status, method, headers, declaredLength, responses, maxResponseBytes }: {
45
+ response: ServerResponse;
46
+ status: number;
47
+ method: string;
48
+ headers: Readonly<Record<string, string | string[]>>;
49
+ declaredLength: number | null;
50
+ responses: readonly string[];
51
+ maxResponseBytes: number;
52
+ }): boolean;
53
+ /** An ingress caller gets a deterministic 504 before its 60 s context expires (design §(e) "Limits"). */
54
+ export declare const INGRESS_PROXY_LIMITS: Readonly<{
55
+ maxTimeoutMs: 55000;
56
+ idleWriteTimeoutMs: 10000;
57
+ }>;
58
+ export type IngressProxyErrorStatus = 400 | 413 | 502 | 503 | 504;
59
+ /** Error contract codes (design §(i)) for the failures the proxy itself decides. */
60
+ export declare const INGRESS_PROXY_ERROR_CODES: Readonly<Record<IngressProxyErrorStatus, string>>;
61
+ /** External ingress mode (design §(e)): cross-origin data only, under the matched route's policy. */
62
+ export interface HostedArtifactIngressMode {
63
+ /** The matched route's declared response content types. */
64
+ responses: readonly string[];
65
+ /** The matched ingress's declared request headers, forwarded on top of the fixed allowlist. */
66
+ requestHeaders: readonly string[];
67
+ /** Writes the gateway's error-contract JSON with the caller's CORS headers. */
68
+ errorWriter: (input: {
69
+ status: IngressProxyErrorStatus;
70
+ code: string;
71
+ }) => void;
72
+ /** Destroys a response whose writes stay blocked this long (default INGRESS_PROXY_LIMITS.idleWriteTimeoutMs). */
73
+ idleWriteTimeoutMs?: number;
74
+ }