@sequenceholdings/artifact-studio 0.6.4 → 0.6.6

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 (78) hide show
  1. package/README.md +183 -6
  2. package/VERSION-PIN.md +56 -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-ingress.d.ts +5 -2
  43. package/dist/manifest-next-ingress.js +27 -2
  44. package/dist/manifest-next.d.ts +20 -6
  45. package/dist/manifest-next.js +13 -5
  46. package/dist/manifest.d.ts +7 -2
  47. package/dist/next-project.d.ts +4 -0
  48. package/dist/sdk.d.ts +12 -3
  49. package/dist/server/broker-contract.d.ts +62 -0
  50. package/dist/server/broker-contract.js +43 -1
  51. package/dist/server/client.js +38 -13
  52. package/dist/server/external-context.d.ts +8 -1
  53. package/dist/server/external-context.js +7 -2
  54. package/dist/server/internal/v1-context.js +17 -1
  55. package/dist/server/internal/verify-any.d.ts +11 -2
  56. package/dist/server/internal/verify-any.js +11 -3
  57. package/dist/server/process-run-sharing.d.ts +86 -0
  58. package/dist/server/process-run-sharing.js +29 -0
  59. package/dist/server/process-runs-page.d.ts +39 -0
  60. package/dist/server/process-runs-page.js +10 -0
  61. package/dist/server/processes.d.ts +8 -9
  62. package/dist/server/processes.js +4 -0
  63. package/dist/server/request-context.js +10 -3
  64. package/dist/server/runtime-config.d.ts +10 -1
  65. package/dist/server/runtime-config.js +11 -1
  66. package/dist/server/testing.d.ts +1 -1
  67. package/dist/server.d.ts +2 -2
  68. package/dist/server.js +1 -1
  69. package/dist/tunnel/broker.d.ts +18 -0
  70. package/dist/tunnel/broker.js +175 -17
  71. package/dist/tunnel/client.d.ts +13 -0
  72. package/dist/tunnel/client.js +40 -2
  73. package/dist/tunnel/external.d.ts +51 -0
  74. package/dist/tunnel/external.js +10 -0
  75. package/dist/tunnel/index.d.ts +1 -1
  76. package/dist/tunnel/protocol.d.ts +20 -2
  77. package/dist/tunnel/protocol.js +42 -6
  78. package/package.json +4 -4
@@ -0,0 +1,84 @@
1
+ import { INGRESS_AUDIT_BATCH_MAX_EVENTS } from '../server/ingress-control-contract.js';
2
+ export const INGRESS_AUDIT_FLUSH_INTERVAL_MS = 5_000;
3
+ /** Refusals buffered between flushes; past this they are dropped and counted, never queued without bound. */
4
+ export const INGRESS_AUDIT_CAPACITY = 5_000;
5
+ /** Shutdown spends at most this on the final batch, so audit delivery never holds up tunnel cleanup. */
6
+ export const INGRESS_AUDIT_CLOSE_BUDGET_MS = 2_000;
7
+ /**
8
+ * Batches the refusals the gateway decided itself (edge, client address, CORS, a refused authentic
9
+ * pass, freshness) to `control/ingress-audit` every 5 s, at most 500 per call. Garbage and
10
+ * admission refusals are counted elsewhere and never reach it. Refusals an Atlas control
11
+ * route returned are never recorded here: Atlas already audited them. Best effort and bounded: a
12
+ * refusal flood can cost Atlas at most one batch per interval per gateway process, and a failed
13
+ * delivery is counted, not retried into a backlog.
14
+ */
15
+ export class IngressDenialAuditor {
16
+ options;
17
+ buffer = [];
18
+ dropped = 0;
19
+ timer;
20
+ flushing;
21
+ intervalMs;
22
+ capacity;
23
+ constructor(options) {
24
+ this.options = options;
25
+ this.intervalMs = options.intervalMs ?? INGRESS_AUDIT_FLUSH_INTERVAL_MS;
26
+ this.capacity = options.capacity ?? INGRESS_AUDIT_CAPACITY;
27
+ }
28
+ record(event) {
29
+ if (this.buffer.length >= this.capacity) {
30
+ this.dropped++;
31
+ return;
32
+ }
33
+ this.buffer.push(event);
34
+ }
35
+ /** Flushes every interval; the timer never keeps the process alive. */
36
+ start() {
37
+ if (this.timer)
38
+ return;
39
+ this.timer = setInterval(() => { void this.flush(); }, this.intervalMs);
40
+ this.timer.unref();
41
+ }
42
+ /** One batch of at most 500 refusals, single-flight. Never rejects. */
43
+ flush() {
44
+ this.flushing ??= this.deliver().finally(() => { this.flushing = undefined; });
45
+ return this.flushing;
46
+ }
47
+ /**
48
+ * Stops the timer and makes one final delivery attempt within the close budget: a slow or failing
49
+ * Atlas costs shutdown at most that long. Whatever is still buffered is dropped and counted.
50
+ */
51
+ async close() {
52
+ clearInterval(this.timer);
53
+ this.timer = undefined;
54
+ let expire;
55
+ const budget = new Promise((resolve) => { expire = setTimeout(resolve, this.options.closeBudgetMs ?? INGRESS_AUDIT_CLOSE_BUDGET_MS); });
56
+ try {
57
+ await Promise.race([(this.flushing ?? Promise.resolve()).then(() => this.flush()), budget]);
58
+ }
59
+ finally {
60
+ clearTimeout(expire);
61
+ }
62
+ this.dropped += this.buffer.length;
63
+ this.buffer = [];
64
+ this.reportDropped();
65
+ }
66
+ async deliver() {
67
+ this.reportDropped();
68
+ const events = this.buffer.splice(0, INGRESS_AUDIT_BATCH_MAX_EVENTS);
69
+ if (events.length === 0)
70
+ return;
71
+ try {
72
+ await this.options.send({ events });
73
+ }
74
+ catch {
75
+ this.options.onFailure?.({ kind: 'ingress_audit_delivery_failed', count: events.length });
76
+ }
77
+ }
78
+ reportDropped() {
79
+ if (!this.dropped)
80
+ return;
81
+ this.options.onFailure?.({ kind: 'ingress_audit_dropped', count: this.dropped });
82
+ this.dropped = 0;
83
+ }
84
+ }
@@ -0,0 +1,57 @@
1
+ import type { IngressPassPayload } from './ingress-pass.js';
2
+ /** What the gateway records of a caller's `User-Agent` (design §(f)): ≤ 512 characters, no control characters. */
3
+ export declare const OBSERVED_USER_AGENT_MAX_LENGTH = 512;
4
+ /**
5
+ * The user agent as the context carries it: control characters stripped, then truncated. The
6
+ * SHA-256 is of the header exactly as sent, so a truncated value still fingerprints the caller.
7
+ */
8
+ export declare function observeUserAgent(raw: string | undefined): {
9
+ userAgent: string;
10
+ userAgentSha256: string;
11
+ };
12
+ /** The namespaced subject an external context and usage carry; ≤ 255 characters or refused at exchange. */
13
+ export declare function externalSubject({ provider, subject }: {
14
+ provider: string;
15
+ subject: string;
16
+ }): string;
17
+ /**
18
+ * What an external context runs against: a published deployment, or (local development only,
19
+ * design §(j.3) step 5) the development session whose tunnel the gateway proxies over.
20
+ */
21
+ export type ExternalContextRelease = {
22
+ mode: 'published';
23
+ deploymentId: string;
24
+ } | {
25
+ mode: 'development';
26
+ devSessionId: string;
27
+ };
28
+ /**
29
+ * Signs the external (v2) context for one admitted ingress request (design §(f), "Steady state"
30
+ * step 8), with the gateway's external key, never the browser one. Lifetime ≤ 60 s. Validated
31
+ * against the strict v2 schema before signing, so the gateway never emits a context the SDK,
32
+ * sidecar or broker would refuse.
33
+ */
34
+ export declare function signExternalGatewayContext({ privateKey, keyId, projectId, environment, release, requestId, method, path, serviceAccountId, pass, passKid, client, now, ttlSeconds, }: {
35
+ privateKey: string;
36
+ keyId: string;
37
+ projectId: string;
38
+ environment: string;
39
+ release: ExternalContextRelease;
40
+ /** Gateway-generated, never client-supplied. */
41
+ requestId: string;
42
+ method: string;
43
+ /** The canonical path plus raw query, as matched. */
44
+ path: string;
45
+ /** The grant's ingress service account. */
46
+ serviceAccountId: string;
47
+ pass: IngressPassPayload;
48
+ passKid: string;
49
+ client: {
50
+ ip: string;
51
+ userAgent: string;
52
+ userAgentSha256: string;
53
+ origin: string | null;
54
+ };
55
+ now?: number;
56
+ ttlSeconds?: number;
57
+ }): string;
@@ -0,0 +1,57 @@
1
+ import { createHash, createPrivateKey, randomUUID, sign } from 'node:crypto';
2
+ import { ARTIFACT_EXTERNAL_CONTEXT_KID_PREFIX, ARTIFACT_EXTERNAL_CONTEXT_MAX_AGE_SECONDS, requestContextV2ExternalSchema, } from '../server/request-context.js';
3
+ /** What the gateway records of a caller's `User-Agent` (design §(f)): ≤ 512 characters, no control characters. */
4
+ export const OBSERVED_USER_AGENT_MAX_LENGTH = 512;
5
+ /**
6
+ * The user agent as the context carries it: control characters stripped, then truncated. The
7
+ * SHA-256 is of the header exactly as sent, so a truncated value still fingerprints the caller.
8
+ */
9
+ export function observeUserAgent(raw) {
10
+ const value = raw ?? '';
11
+ const stripped = value.replace(/[\u0000-\u001f\u007f]/g, '');
12
+ // Truncate on a code point boundary, never splitting a surrogate pair.
13
+ let userAgent = '';
14
+ for (const character of stripped) {
15
+ if (userAgent.length + character.length > OBSERVED_USER_AGENT_MAX_LENGTH)
16
+ break;
17
+ userAgent += character;
18
+ }
19
+ return { userAgent, userAgentSha256: createHash('sha256').update(value).digest('hex') };
20
+ }
21
+ /** The namespaced subject an external context and usage carry; ≤ 255 characters or refused at exchange. */
22
+ export function externalSubject({ provider, subject }) {
23
+ return `ext|${provider}|${subject}`;
24
+ }
25
+ /**
26
+ * Signs the external (v2) context for one admitted ingress request (design §(f), "Steady state"
27
+ * step 8), with the gateway's external key, never the browser one. Lifetime ≤ 60 s. Validated
28
+ * against the strict v2 schema before signing, so the gateway never emits a context the SDK,
29
+ * sidecar or broker would refuse.
30
+ */
31
+ export function signExternalGatewayContext({ privateKey, keyId, projectId, environment, release, requestId, method, path, serviceAccountId, pass, passKid, client, now = Math.floor(Date.now() / 1000), ttlSeconds = ARTIFACT_EXTERNAL_CONTEXT_MAX_AGE_SECONDS, }) {
32
+ if (!keyId.startsWith(ARTIFACT_EXTERNAL_CONTEXT_KID_PREFIX))
33
+ throw new Error('External contexts are signed only with an external- key');
34
+ const context = requestContextV2ExternalSchema.parse({
35
+ v: 2, iss: 'atlas-artifact-gateway', aud: `artifact:${projectId}`,
36
+ sub: externalSubject({ provider: pass.provider, subject: pass.subject }),
37
+ projectId, environment, mode: release.mode,
38
+ deploymentId: release.mode === 'published' ? release.deploymentId : null,
39
+ devSessionId: release.mode === 'development' ? release.devSessionId : null,
40
+ requestId, jti: randomUUID(), method, path, iat: now, exp: now + ttlSeconds, serviceAccountId,
41
+ principal: {
42
+ kind: 'external', ingress: pass.ingress, provider: pass.provider, issuer: pass.issuer, subject: pass.subject,
43
+ clientId: pass.clientId, email: pass.email, emailVerified: pass.emailVerified, authTime: pass.authTime, amr: pass.amr,
44
+ tokenIssuedAt: pass.tokenIssuedAt,
45
+ pass: { id: pass.jti, kid: passKid, issuedAt: pass.iat, exchangeIp: pass.exchangeIp },
46
+ client,
47
+ },
48
+ crit: [], ext: {},
49
+ });
50
+ const key = createPrivateKey(privateKey);
51
+ if (key.asymmetricKeyType !== 'ed25519')
52
+ throw new Error('External context key must be Ed25519');
53
+ const header = Buffer.from(JSON.stringify({ alg: 'EdDSA', typ: 'JWT', kid: keyId })).toString('base64url');
54
+ const payload = Buffer.from(JSON.stringify(context)).toString('base64url');
55
+ const signed = `${header}.${payload}`;
56
+ return `${signed}.${sign(null, Buffer.from(signed), key).toString('base64url')}`;
57
+ }
@@ -0,0 +1,12 @@
1
+ import type { ArtifactTunnelBroker } from '../tunnel/index.js';
2
+ import type { IngressDevelopmentProxy } from './ingress.js';
3
+ /**
4
+ * The ingress proxy for a development target (design §(j.3) step 3): the admitted request goes
5
+ * over the development session's tunnel instead of to a hosted deployment, under the hosted
6
+ * ingress proxy's rules: ingress request sanitising, the route's response policy decided on the
7
+ * head, the request and response caps, the idle-write timeout and the error-contract writer.
8
+ * The gateway wires it only when its own APP_ENV is `development`.
9
+ */
10
+ export declare function createDevelopmentIngressProxy({ broker }: {
11
+ broker: Pick<ArtifactTunnelBroker, 'handleExternalHttp'>;
12
+ }): IngressDevelopmentProxy;
@@ -0,0 +1,23 @@
1
+ import { INGRESS_PROXY_ERROR_CODES, INGRESS_PROXY_LIMITS, writeIngressResponseHead } from './ingress-response.js';
2
+ /**
3
+ * The ingress proxy for a development target (design §(j.3) step 3): the admitted request goes
4
+ * over the development session's tunnel instead of to a hosted deployment, under the hosted
5
+ * ingress proxy's rules: ingress request sanitising, the route's response policy decided on the
6
+ * head, the request and response caps, the idle-write timeout and the error-contract writer.
7
+ * The gateway wires it only when its own APP_ENV is `development`.
8
+ */
9
+ export function createDevelopmentIngressProxy({ broker }) {
10
+ return ({ request, response, target, publicOrigin, requestContext, body, limits, ingress }) => broker.handleExternalHttp({
11
+ request, response, requestContext, body, publicOrigin,
12
+ sessionId: target.sessionId, projectId: target.projectId, environment: target.environment,
13
+ appHeaders: ingress.requestHeaders,
14
+ maxRequestBytes: limits.maxRequestBytes,
15
+ maxResponseBytes: limits.maxResponseBytes,
16
+ timeoutMs: Math.min(limits.timeoutMs, INGRESS_PROXY_LIMITS.maxTimeoutMs),
17
+ idleWriteTimeoutMs: ingress.idleWriteTimeoutMs ?? INGRESS_PROXY_LIMITS.idleWriteTimeoutMs,
18
+ writeHead: ({ status, headers, declaredLength }) => writeIngressResponseHead({
19
+ response, status, method: request.method ?? 'GET', headers, declaredLength, responses: ingress.responses, maxResponseBytes: limits.maxResponseBytes,
20
+ }),
21
+ fail: (status) => ingress.errorWriter({ status, code: INGRESS_PROXY_ERROR_CODES[status] }),
22
+ });
23
+ }
@@ -0,0 +1,44 @@
1
+ import type { IncomingMessage, Server, ServerResponse } from 'node:http';
2
+ import type { Duplex } from 'node:stream';
3
+ import { type ArtifactGatewayIngressOptions, type IngressDevelopmentProxy } from './ingress.js';
4
+ import type { GatewayUsageReporter } from './usage.js';
5
+ /**
6
+ * The ingress request pipeline (design §(e), "Steady state" steps 1-3 and 9). Order matters:
7
+ * canonical host, edge header, snapshot, client address, canonical path, declared route and
8
+ * CORS are all local, so nothing unauthenticated reaches Redis or Atlas before `handlers.admit`.
9
+ */
10
+ export declare function createIngressHandler({ options, runtimeDomain, protocol, isDraining, onError, usage, proxyDevelopment }: {
11
+ /** Absent: the ingress branch is disabled and every ingress host answers 404. */
12
+ options: ArtifactGatewayIngressOptions | undefined;
13
+ runtimeDomain: string;
14
+ /** `https:` in every deployed environment; `http:` only for a loopback development gateway. */
15
+ protocol: string;
16
+ isDraining: () => boolean;
17
+ /** Counts refusal metrics and failures that are not a decided refusal (the gateway's own onError). */
18
+ onError?: (event: {
19
+ kind: string;
20
+ }) => void;
21
+ /** Records one observation per proxied ingress request, attributed to the external principal. */
22
+ usage?: Pick<GatewayUsageReporter, 'record'>;
23
+ /**
24
+ * Proxies over a development session's tunnel. Used only when `options.appEnv` is `development`
25
+ * (design §(j.3) step 3); anywhere else a development target is never proxied.
26
+ */
27
+ proxyDevelopment?: IngressDevelopmentProxy;
28
+ }): {
29
+ handle: (request: IncomingMessage, response: ServerResponse) => Promise<void>;
30
+ clientError: ({ error, socket }: {
31
+ error: NodeJS.ErrnoException & {
32
+ rawPacket?: Buffer;
33
+ };
34
+ socket: Duplex;
35
+ }) => boolean;
36
+ };
37
+ /**
38
+ * Installs `clientError` on the gateway server: an attributable ingress request gets the ingress
39
+ * reply, everything else Node's own. Tracks the response in flight on each socket for the check.
40
+ */
41
+ export declare function handleClientErrors({ server, ingress }: {
42
+ server: Server;
43
+ ingress: Pick<ReturnType<typeof createIngressHandler>, 'clientError'>;
44
+ }): void;
@@ -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
+ }