@sequenceholdings/artifact-studio 0.6.3 → 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,259 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import type { IngressAuditEvent, IngressSnapshotHost } from '../server/ingress-control-contract.js';
3
+ import { type IngressRouteMatch } from '../server/ingress-routes.js';
4
+ import type { HostedArtifactProxyOptions, proxyHostedArtifact } from './hosted-proxy.js';
5
+ import { type HostedArtifactIngressMode } from './ingress-response.js';
6
+ import type { IngressSnapshotCache } from './ingress-snapshot.js';
7
+ /**
8
+ * The external ingress branch of the gateway (design §(e)): the front door for callers that are
9
+ * not Atlas users, on hosts `ingress-<24 hex>.<runtime domain>`. It shares nothing with the
10
+ * iframe session path: no bootstrap, no session cookie, no `authorize()`, and no 401/404→403 remap.
11
+ * Every check before authentication is local: canonical host, edge header, snapshot lookup,
12
+ * client address, canonical path, declared route and CORS. The pass, admission and grant
13
+ * (`handlers.admit`) come after them, so nothing unauthenticated reaches Redis or Atlas.
14
+ */
15
+ export declare const INGRESS_TOKEN_PATH = "/_sequence/ingress/token";
16
+ export declare const INGRESS_LOGOUT_PATH = "/_sequence/ingress/logout";
17
+ export declare const INGRESS_LIMITS: Readonly<{
18
+ /** A pass is at most 2 KiB; an API (and logout) `Authorization` header above this is refused unread. */
19
+ maxApiAuthorizationBytes: number;
20
+ /** The exchange carries the identity provider's token, which may be larger than a pass. */
21
+ maxExchangeAuthorizationBytes: number;
22
+ maxResponseBytes: number;
23
+ preflightMaxAgeSeconds: 600;
24
+ }>;
25
+ export declare const INGRESS_EXPOSED_HEADERS = "retry-after, x-request-id, www-authenticate, content-disposition";
26
+ export declare const BASE_ALLOWED_REQUEST_HEADERS: readonly ["authorization", "content-type"];
27
+ /** Error contract codes (design §(i)). App responses pass through with their own bodies. */
28
+ export type IngressErrorCode = 'bad_request' | 'invalid_token' | 'insufficient_user_authentication' | 'exchange_denied' | 'forbidden' | 'not_found' | 'request_timeout' | 'too_large' | 'payload_too_large' | 'request_header_fields_too_large' | 'rate_limited' | 'upstream_response_refused' | 'temporarily_unavailable' | 'upstream_timeout';
29
+ /**
30
+ * A refusal the gateway decided itself about a request that is not garbage (edge, client address,
31
+ * CORS, a refused authentic pass, freshness), for the denial audit batch. Garbage (a missing,
32
+ * malformed or forged credential) and admission refusals are a metric only, and refusals an Atlas
33
+ * control route returned are never marked: Atlas audits those itself (design §(g) "Gateway denials").
34
+ */
35
+ export interface IngressDenialDetail {
36
+ /** snake_case, for example `pass_bad_mac` or `origin_not_allowed`. */
37
+ reason: string;
38
+ ingress?: string | null;
39
+ subjectHash?: string;
40
+ passId?: string;
41
+ }
42
+ /**
43
+ * The in-process counters refusals feed (the gateway's onError, aggregated every 10 s): what a
44
+ * forged credential or a flood costs is a counter increment, never an audit write.
45
+ */
46
+ export type IngressRefusalMetric = 'ingress_pass_rejected' | 'ingress_token_rejected' | 'admission_rejected'
47
+ /** Should both stay zero: non-zero means a request reached the gateway around our Cloudflare zone, or Pseudo IPv4 is on. */
48
+ | 'ingress_edge_header_invalid' | 'ingress_client_ip_invalid';
49
+ /** Thrown by an ingress handler; written through the ingress writer, never the session remap. */
50
+ export declare class IngressError extends Error {
51
+ readonly status: number;
52
+ readonly code: IngressErrorCode;
53
+ readonly extra: {
54
+ retryAfterSeconds?: number;
55
+ wwwAuthenticate?: string;
56
+ audit?: IngressDenialDetail;
57
+ metric?: IngressRefusalMetric;
58
+ };
59
+ constructor(status: number, code: IngressErrorCode, extra?: {
60
+ retryAfterSeconds?: number;
61
+ wwwAuthenticate?: string;
62
+ audit?: IngressDenialDetail;
63
+ metric?: IngressRefusalMetric;
64
+ });
65
+ }
66
+ /** Every ingress host's first label starts with this. */
67
+ export declare const INGRESS_HOST_PREFIX = "ingress-";
68
+ /** The raw `Host` exactly as an ingress host must arrive: lowercase, no port, no trailing dot. */
69
+ export declare function isIngressHost({ rawHost, runtimeDomain }: {
70
+ rawHost: string | undefined;
71
+ runtimeDomain: string;
72
+ }): boolean;
73
+ /**
74
+ * Every host the ingress branch owns: any spelling (any case, a port, a trailing dot) of any
75
+ * `ingress-*` name under the runtime domain, canonical or not. Edge allowlists exempt `ingress-*`
76
+ * hosts, so a non-canonical one (`ingress-x.<domain>`, `ingress-<24 hex>.foo.<domain>`) must get the
77
+ * ingress branch's local 404 and never fall into the iframe path: session ticket exchange,
78
+ * bootstrap, authorize and WebSocket upgrades. No application or preview host starts `ingress-`.
79
+ */
80
+ export declare function resemblesIngressHost({ rawHost, runtimeDomain }: {
81
+ rawHost: string | undefined;
82
+ runtimeDomain: string;
83
+ }): boolean;
84
+ /** Every value of a header as sent, so a repeated header is seen (Node keeps only the first of some). */
85
+ export declare function rawHeaderValues(request: IncomingMessage, name: string): string[];
86
+ /**
87
+ * At least this many characters. Shorter configured secrets are ignored, so a misconfigured empty
88
+ * value (`current,` split on commas) can never match an empty header and open the edge check.
89
+ */
90
+ export declare const MIN_EDGE_SECRET_LENGTH = 32;
91
+ /** Every `Authorization` value at most `maxBytes`, and at most one of them: none is ever picked. */
92
+ export declare function hasAcceptableAuthorization({ request, maxBytes }: {
93
+ request: IncomingMessage;
94
+ maxBytes: number;
95
+ }): boolean;
96
+ /** The presented `x-sequence-edge` values match: exactly one, equal in constant time to a usable secret. */
97
+ export declare function edgeValuesMatch({ values, secrets }: {
98
+ values: readonly string[];
99
+ secrets: readonly string[];
100
+ }): boolean;
101
+ /**
102
+ * Exactly one `x-sequence-edge`, equal in constant time to one of the configured secrets (current
103
+ * and next). Only our Cloudflare zone's transform rule injects it, so a request that reached the
104
+ * gateway another way, or through another Cloudflare zone, is refused (threat T17).
105
+ */
106
+ export declare function hasValidEdgeHeader({ request, secrets }: {
107
+ request: IncomingMessage;
108
+ secrets: readonly string[];
109
+ }): boolean;
110
+ /**
111
+ * The client address: exactly one `CF-Connecting-IP` that parses and is not a Pseudo IPv4
112
+ * address, else null (400). Never X-Forwarded-For or the socket address.
113
+ */
114
+ export declare function edgeClientIp(request: IncomingMessage): string | null;
115
+ export type SnapshotIngress = IngressSnapshotHost['ingresses'][number];
116
+ export type IngressRoute = SnapshotIngress['routes'][number];
117
+ /** A matched route and the one ingress on this host that declares it. */
118
+ export interface IngressRouteResolution {
119
+ ingress: SnapshotIngress;
120
+ match: IngressRouteMatch<IngressRoute>;
121
+ }
122
+ /** Exactly one ingress on the host may declare the matched (method, path); ambiguity is no match. */
123
+ export declare function resolveIngressRoute({ entry, method, path }: {
124
+ entry: IngressSnapshotHost;
125
+ method: string;
126
+ path: string;
127
+ }): IngressRouteResolution | null;
128
+ /** The caller's `Origin`, or null when absent. Several `Origin` headers never match an allowed origin. */
129
+ export declare function callerOrigin(request: IncomingMessage): string | null;
130
+ export declare function ingressCorsHeaders(origin: string): Record<string, string>;
131
+ /**
132
+ * Writes a gateway-decided ingress response: the error contract body, `no-store`, the response
133
+ * security headers, and CORS when the caller's origin is allowed, so the page can read the error.
134
+ */
135
+ export declare function writeIngressError({ response, status, code, corsOrigin, retryAfterSeconds, wwwAuthenticate }: {
136
+ response: ServerResponse;
137
+ status: number;
138
+ code: IngressErrorCode | string;
139
+ corsOrigin: string | null;
140
+ retryAfterSeconds?: number;
141
+ wwwAuthenticate?: string;
142
+ }): void;
143
+ /** A request every local check admitted, handed to the pass, admission and grant stage (C6b). */
144
+ export interface IngressRequest {
145
+ request: IncomingMessage;
146
+ response: ServerResponse;
147
+ /** The canonical raw host, and its public origin. */
148
+ host: string;
149
+ publicOrigin: string;
150
+ entry: IngressSnapshotHost;
151
+ clientIp: string;
152
+ /** The caller's allowed `Origin`, or null for a non-browser caller. */
153
+ origin: string | null;
154
+ requestId: string;
155
+ }
156
+ export interface IngressApiRequest extends IngressRequest {
157
+ ingress: SnapshotIngress;
158
+ match: IngressRouteMatch<IngressRoute>;
159
+ /** Canonical path plus raw query: the string the context signs and Atlas re-matches. */
160
+ signedPath: string;
161
+ }
162
+ /**
163
+ * A development session to proxy to over its tunnel. Local development only: the stage admits it
164
+ * only on a gateway with APP_ENV=development, for the snapshot's own development target (design
165
+ * §(j.3) step 3).
166
+ */
167
+ export interface IngressDevelopmentTarget {
168
+ kind: 'development';
169
+ projectId: string;
170
+ environment: string;
171
+ sessionId: string;
172
+ }
173
+ /** Where an admitted request goes: the active deployment, or a development session's tunnel. */
174
+ export type IngressAdmittedTarget = (Omit<HostedArtifactProxyOptions['target'], 'publicOrigin'> & {
175
+ kind?: 'published';
176
+ }) | IngressDevelopmentTarget;
177
+ /** What the admit stage returns: where to proxy, and the signed context. */
178
+ export interface IngressAdmittedRequest {
179
+ target: IngressAdmittedTarget;
180
+ requestContext: string;
181
+ /** The admitted request timeout; the proxy caps it at 55 s. */
182
+ timeoutMs: number;
183
+ /** Ties a concurrency permit to the response's lifetime. */
184
+ bind?: (response: ServerResponse, abort: () => void) => void;
185
+ /** The request body, when the stage buffered it before taking a concurrency permit. */
186
+ body?: AsyncIterable<Uint8Array>;
187
+ /** Attribution for the request's usage observation: a deployment, or a development session. */
188
+ usage?: {
189
+ actorId: string;
190
+ ingressId: string;
191
+ projectId: string;
192
+ environment: string;
193
+ } & ({
194
+ deploymentId: string;
195
+ } | {
196
+ devSessionId: string;
197
+ });
198
+ }
199
+ /**
200
+ * Proxies an admitted ingress request over a development session's tunnel, under the same
201
+ * ingress request sanitising, response policy, limits and error writer as the hosted proxy.
202
+ */
203
+ export type IngressDevelopmentProxy = (input: {
204
+ request: IncomingMessage;
205
+ response: ServerResponse;
206
+ target: IngressDevelopmentTarget;
207
+ /** The ingress host's canonical origin: the app sees it as `x-forwarded-host`, as on a hosted release. */
208
+ publicOrigin: string;
209
+ requestContext: string;
210
+ body?: AsyncIterable<Uint8Array>;
211
+ limits: {
212
+ maxRequestBytes: number;
213
+ maxResponseBytes: number;
214
+ timeoutMs: number;
215
+ };
216
+ ingress: HostedArtifactIngressMode;
217
+ }) => Promise<void>;
218
+ export interface IngressHandlers {
219
+ /** Admission for a preflight (`ingress-preflight`), after the local checks; throws to refuse. Absent: none. */
220
+ preflight?: (input: IngressRequest) => Promise<void>;
221
+ /** `POST /_sequence/ingress/token`; writes its own response. */
222
+ exchange?: (input: IngressRequest) => Promise<void>;
223
+ /** `POST /_sequence/ingress/logout`; writes its own response. */
224
+ logout?: (input: IngressRequest) => Promise<void>;
225
+ /** Pass, admission and grant for an API route; throws IngressError to refuse. */
226
+ admit?: (input: IngressApiRequest) => Promise<IngressAdmittedRequest>;
227
+ }
228
+ export interface ArtifactGatewayIngressOptions {
229
+ /** Current and next `x-sequence-edge` values. */
230
+ edgeSecrets: readonly string[];
231
+ snapshot: Pick<IngressSnapshotCache, 'lookup'>;
232
+ /** The gateway's APP_ENV: a `development` snapshot target is honoured only under `development`. */
233
+ appEnv: string;
234
+ /** Absent stages answer 503 `temporarily_unavailable`. */
235
+ handlers?: IngressHandlers;
236
+ /** Injectable proxy for isolated tests. */
237
+ proxy?: typeof proxyHostedArtifact;
238
+ /** Injectable for tests of the idle-write timeout. */
239
+ idleWriteTimeoutMs?: number;
240
+ /** Injectable for tests of the response cap (default INGRESS_LIMITS.maxResponseBytes). */
241
+ maxResponseBytes?: number;
242
+ /** Receives every refusal the gateway decided itself on a host in the snapshot (the denial audit). */
243
+ onDenial?: (event: IngressAuditEvent) => void;
244
+ }
245
+ /** The union of a host's allowed origins: CORS for errors decided before a route is known. */
246
+ export declare function hostOrigins(entry: IngressSnapshotHost | undefined): ReadonlySet<string>;
247
+ export declare function corsFor(origin: string | null, allowed: ReadonlySet<string>): string | null;
248
+ export declare function requestedHeaders(request: IncomingMessage): string[];
249
+ /** Host, Origin and edge values from the bytes Node had buffered when the header block overflowed. */
250
+ export declare function requestLineHeaders(raw: Buffer | undefined): {
251
+ host?: string;
252
+ origin?: string | null;
253
+ edge: string[];
254
+ };
255
+ export declare function writeIngressRefusal({ response, error, corsOrigin }: {
256
+ response: ServerResponse;
257
+ error: unknown;
258
+ corsOrigin: string | null;
259
+ }): void;
@@ -0,0 +1,204 @@
1
+ import { createHash, timingSafeEqual } from 'node:crypto';
2
+ import { BlockList } from 'node:net';
3
+ import { matchIngressRoute } from '../server/ingress-routes.js';
4
+ import { artifactIpAddressSchema } from '../server/request-context.js';
5
+ import { ArtifactAdmissionError } from './admission.js';
6
+ import { INGRESS_RESPONSE_SECURITY_HEADERS } from './ingress-response.js';
7
+ /**
8
+ * The external ingress branch of the gateway (design §(e)): the front door for callers that are
9
+ * not Atlas users, on hosts `ingress-<24 hex>.<runtime domain>`. It shares nothing with the
10
+ * iframe session path: no bootstrap, no session cookie, no `authorize()`, and no 401/404→403 remap.
11
+ * Every check before authentication is local: canonical host, edge header, snapshot lookup,
12
+ * client address, canonical path, declared route and CORS. The pass, admission and grant
13
+ * (`handlers.admit`) come after them, so nothing unauthenticated reaches Redis or Atlas.
14
+ */
15
+ export const INGRESS_TOKEN_PATH = '/_sequence/ingress/token';
16
+ export const INGRESS_LOGOUT_PATH = '/_sequence/ingress/logout';
17
+ export const INGRESS_LIMITS = Object.freeze({
18
+ /** A pass is at most 2 KiB; an API (and logout) `Authorization` header above this is refused unread. */
19
+ maxApiAuthorizationBytes: 4 * 1024,
20
+ /** The exchange carries the identity provider's token, which may be larger than a pass. */
21
+ maxExchangeAuthorizationBytes: 8 * 1024,
22
+ maxResponseBytes: 32 * 1024 * 1024,
23
+ preflightMaxAgeSeconds: 600,
24
+ });
25
+ export const INGRESS_EXPOSED_HEADERS = 'retry-after, x-request-id, www-authenticate, content-disposition';
26
+ export const BASE_ALLOWED_REQUEST_HEADERS = ['authorization', 'content-type'];
27
+ /** Thrown by an ingress handler; written through the ingress writer, never the session remap. */
28
+ export class IngressError extends Error {
29
+ status;
30
+ code;
31
+ extra;
32
+ constructor(status, code, extra = {}) {
33
+ super(`Ingress request refused: ${code}`);
34
+ this.status = status;
35
+ this.code = code;
36
+ this.extra = extra;
37
+ this.name = 'IngressError';
38
+ }
39
+ }
40
+ function escapeRegExp(value) {
41
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
42
+ }
43
+ /** Every ingress host's first label starts with this. */
44
+ export const INGRESS_HOST_PREFIX = 'ingress-';
45
+ /** The raw `Host` exactly as an ingress host must arrive: lowercase, no port, no trailing dot. */
46
+ export function isIngressHost({ rawHost, runtimeDomain }) {
47
+ return rawHost !== undefined && new RegExp(`^ingress-[a-f0-9]{24}\\.${escapeRegExp(runtimeDomain)}$`).test(rawHost);
48
+ }
49
+ /**
50
+ * Every host the ingress branch owns: any spelling (any case, a port, a trailing dot) of any
51
+ * `ingress-*` name under the runtime domain, canonical or not. Edge allowlists exempt `ingress-*`
52
+ * hosts, so a non-canonical one (`ingress-x.<domain>`, `ingress-<24 hex>.foo.<domain>`) must get the
53
+ * ingress branch's local 404 and never fall into the iframe path: session ticket exchange,
54
+ * bootstrap, authorize and WebSocket upgrades. No application or preview host starts `ingress-`.
55
+ */
56
+ export function resemblesIngressHost({ rawHost, runtimeDomain }) {
57
+ if (rawHost === undefined)
58
+ return false;
59
+ const hostname = rawHost.toLowerCase().replace(/:[0-9]*$/, '').replace(/\.$/, '');
60
+ return hostname.startsWith(INGRESS_HOST_PREFIX) && hostname.endsWith(`.${runtimeDomain}`);
61
+ }
62
+ /** Every value of a header as sent, so a repeated header is seen (Node keeps only the first of some). */
63
+ export function rawHeaderValues(request, name) {
64
+ const values = [];
65
+ for (let index = 0; index + 1 < request.rawHeaders.length; index += 2) {
66
+ if (request.rawHeaders[index].toLowerCase() === name)
67
+ values.push(request.rawHeaders[index + 1]);
68
+ }
69
+ return values;
70
+ }
71
+ /**
72
+ * At least this many characters. Shorter configured secrets are ignored, so a misconfigured empty
73
+ * value (`current,` split on commas) can never match an empty header and open the edge check.
74
+ */
75
+ export const MIN_EDGE_SECRET_LENGTH = 32;
76
+ /** Every `Authorization` value at most `maxBytes`, and at most one of them: none is ever picked. */
77
+ export function hasAcceptableAuthorization({ request, maxBytes }) {
78
+ const values = rawHeaderValues(request, 'authorization');
79
+ return values.length <= 1 && values.every((value) => Buffer.byteLength(value) <= maxBytes);
80
+ }
81
+ function digest(value) {
82
+ return createHash('sha256').update(value).digest();
83
+ }
84
+ /** The presented `x-sequence-edge` values match: exactly one, equal in constant time to a usable secret. */
85
+ export function edgeValuesMatch({ values, secrets }) {
86
+ // Only a usable secret can match, so the presented value is at least as long too.
87
+ const usable = secrets.filter((secret) => secret.length >= MIN_EDGE_SECRET_LENGTH);
88
+ if (values.length !== 1)
89
+ return false;
90
+ const presented = digest(values[0]);
91
+ let matched = false;
92
+ // Every secret is compared, so timing does not reveal which one matched.
93
+ for (const secret of usable)
94
+ matched = timingSafeEqual(presented, digest(secret)) || matched;
95
+ return matched;
96
+ }
97
+ /**
98
+ * Exactly one `x-sequence-edge`, equal in constant time to one of the configured secrets (current
99
+ * and next). Only our Cloudflare zone's transform rule injects it, so a request that reached the
100
+ * gateway another way, or through another Cloudflare zone, is refused (threat T17).
101
+ */
102
+ export function hasValidEdgeHeader({ request, secrets }) {
103
+ return edgeValuesMatch({ values: rawHeaderValues(request, 'x-sequence-edge'), secrets });
104
+ }
105
+ // Cloudflare's Pseudo IPv4 range: an address Cloudflare invents for an IPv6 client, never a real one.
106
+ const PSEUDO_IPV4 = new BlockList();
107
+ PSEUDO_IPV4.addSubnet('240.0.0.0', 4, 'ipv4');
108
+ PSEUDO_IPV4.addSubnet('::ffff:f000:0', 100, 'ipv6');
109
+ /**
110
+ * The client address: exactly one `CF-Connecting-IP` that parses and is not a Pseudo IPv4
111
+ * address, else null (400). Never X-Forwarded-For or the socket address.
112
+ */
113
+ export function edgeClientIp(request) {
114
+ const values = rawHeaderValues(request, 'cf-connecting-ip');
115
+ if (values.length !== 1)
116
+ return null;
117
+ const value = values[0];
118
+ if (!artifactIpAddressSchema.safeParse(value).success)
119
+ return null;
120
+ const family = value.includes(':') ? 'ipv6' : 'ipv4';
121
+ return PSEUDO_IPV4.check(value, family) ? null : value;
122
+ }
123
+ /** Exactly one ingress on the host may declare the matched (method, path); ambiguity is no match. */
124
+ export function resolveIngressRoute({ entry, method, path }) {
125
+ let found = null;
126
+ for (const ingress of entry.ingresses) {
127
+ const match = matchIngressRoute(ingress.routes, method, path);
128
+ if (!match)
129
+ continue;
130
+ if (found)
131
+ return null;
132
+ found = { ingress, match };
133
+ }
134
+ return found;
135
+ }
136
+ /** The caller's `Origin`, or null when absent. Several `Origin` headers never match an allowed origin. */
137
+ export function callerOrigin(request) {
138
+ const values = rawHeaderValues(request, 'origin');
139
+ if (values.length === 0)
140
+ return null;
141
+ return values.length === 1 ? values[0] : '';
142
+ }
143
+ export function ingressCorsHeaders(origin) {
144
+ return { 'access-control-allow-origin': origin, 'access-control-expose-headers': INGRESS_EXPOSED_HEADERS, vary: 'Origin' };
145
+ }
146
+ /**
147
+ * Writes a gateway-decided ingress response: the error contract body, `no-store`, the response
148
+ * security headers, and CORS when the caller's origin is allowed, so the page can read the error.
149
+ */
150
+ export function writeIngressError({ response, status, code, corsOrigin, retryAfterSeconds, wwwAuthenticate }) {
151
+ if (response.headersSent) {
152
+ response.destroy();
153
+ return;
154
+ }
155
+ for (const name of response.getHeaderNames())
156
+ if (name !== 'x-request-id')
157
+ response.removeHeader(name);
158
+ const authenticate = wwwAuthenticate ?? (code === 'invalid_token' ? 'Bearer error="invalid_token"' : undefined);
159
+ const retryAfter = retryAfterSeconds ?? (status === 503 ? 2 : undefined);
160
+ response.writeHead(status, {
161
+ ...INGRESS_RESPONSE_SECURITY_HEADERS,
162
+ 'content-type': 'application/json',
163
+ connection: 'close',
164
+ ...(corsOrigin ? ingressCorsHeaders(corsOrigin) : {}),
165
+ ...(authenticate ? { 'www-authenticate': authenticate } : {}),
166
+ ...(retryAfter !== undefined ? { 'retry-after': String(retryAfter) } : {}),
167
+ });
168
+ response.end(JSON.stringify({ error: code }));
169
+ }
170
+ /** The union of a host's allowed origins: CORS for errors decided before a route is known. */
171
+ export function hostOrigins(entry) {
172
+ return new Set(entry?.ingresses.flatMap((ingress) => ingress.allowedOrigins) ?? []);
173
+ }
174
+ export function corsFor(origin, allowed) {
175
+ return origin !== null && allowed.has(origin) ? origin : null;
176
+ }
177
+ export function requestedHeaders(request) {
178
+ const value = request.headers['access-control-request-headers'];
179
+ if (typeof value !== 'string')
180
+ return [];
181
+ return value.split(',').map((name) => name.trim().toLowerCase()).filter(Boolean);
182
+ }
183
+ /** Host, Origin and edge values from the bytes Node had buffered when the header block overflowed. */
184
+ export function requestLineHeaders(raw) {
185
+ if (!raw)
186
+ return { edge: [] };
187
+ const lines = raw.subarray(0, 64 * 1024).toString('latin1').split('\r\n');
188
+ const values = (name) => lines.filter((line) => line.toLowerCase().startsWith(`${name}:`)).map((line) => line.slice(name.length + 1).trim());
189
+ const hosts = values('host'), origins = values('origin');
190
+ return { host: hosts.length === 1 ? hosts[0] : undefined, origin: origins.length === 1 ? origins[0] : null, edge: values('x-sequence-edge') };
191
+ }
192
+ export function writeIngressRefusal({ response, error, corsOrigin }) {
193
+ if (error instanceof IngressError) {
194
+ writeIngressError({ response, status: error.status, code: error.code, corsOrigin, ...error.extra });
195
+ }
196
+ else if (error instanceof ArtifactAdmissionError) {
197
+ const status = error.status === 429 ? 429 : 503;
198
+ writeIngressError({ response, status, code: status === 429 ? 'rate_limited' : 'temporarily_unavailable', corsOrigin, retryAfterSeconds: error.retryAfterSeconds });
199
+ }
200
+ else {
201
+ // An unexpected failure is an outage the caller may retry with its idempotency key.
202
+ writeIngressError({ response, status: 503, code: 'temporarily_unavailable', corsOrigin });
203
+ }
204
+ }
@@ -109,6 +109,8 @@ async function invokeAdmittedLattice(input) {
109
109
  });
110
110
  const result = await (input.invoke ?? invokeHostedArtifact)({
111
111
  upstreamUrl: input.grant.upstreamUrl,
112
+ runtimeTransport: input.grant.runtimeTransport,
113
+ environment: input.grant.environment,
112
114
  ...(input.grant.upstreamAudience ? { upstreamAudience: input.grant.upstreamAudience } : {}),
113
115
  path: input.grant.path,
114
116
  body: input.parsed.body ?? {},
@@ -1,7 +1,6 @@
1
1
  import { request as httpsRequest } from 'node:https';
2
- export interface HostedArtifactInvocation {
3
- upstreamUrl: string;
4
- upstreamAudience?: string;
2
+ import { type HostedRuntimeTarget } from './hosted-proxy.js';
3
+ export interface HostedArtifactInvocation extends HostedRuntimeTarget {
5
4
  path: string;
6
5
  body: unknown;
7
6
  requestContext: string;
@@ -30,7 +29,7 @@ export interface HostedArtifactInvocation {
30
29
  }
31
30
  /**
32
31
  * Buffered POST into a hosted Next.js app. The destination must already be a
33
- * Cloud Run origin Atlas resolved; this rejects anything else before dialing.
32
+ * admitted runtime origin Atlas resolved; this rejects anything else before dialing.
34
33
  */
35
34
  export declare function invokeHostedArtifact(input: HostedArtifactInvocation): Promise<{
36
35
  status: number;
@@ -1,34 +1,36 @@
1
1
  import { request as httpsRequest } from 'node:https';
2
+ import { request as httpRequest } from 'node:http';
2
3
  import { assertSafeRequest } from '../tunnel/protocol.js';
3
4
  import { isManagedServerInvokePath } from '../server/managed-server-path.js';
4
- import { HOSTED_ARTIFACT_PROXY_LIMITS, cloudRunOrigin, hostedIdentityToken } from './hosted-proxy.js';
5
+ import { HOSTED_ARTIFACT_PROXY_LIMITS, resolveHostedTarget, hostedIdentityToken } from './hosted-proxy.js';
5
6
  const BODY_CAP = 1024 * 1024;
6
7
  /**
7
8
  * Buffered POST into a hosted Next.js app. The destination must already be a
8
- * Cloud Run origin Atlas resolved; this rejects anything else before dialing.
9
+ * admitted runtime origin Atlas resolved; this rejects anything else before dialing.
9
10
  */
10
11
  export async function invokeHostedArtifact(input) {
11
12
  if (!isManagedServerInvokePath(input.path))
12
13
  throw new Error('Invalid managed server path');
13
- const upstream = cloudRunOrigin(input.upstreamUrl);
14
- const audience = input.upstreamAudience === undefined ? upstream : cloudRunOrigin(input.upstreamAudience);
14
+ const { upstream, audience } = resolveHostedTarget({ target: input });
15
15
  assertSafeRequest('POST', input.path);
16
16
  const payload = Buffer.from(JSON.stringify(input.body ?? {}));
17
17
  if (payload.byteLength > BODY_CAP)
18
18
  throw new Error('Managed server body exceeds 1 MiB');
19
19
  const timeoutMs = Math.min(Math.max(input.timeoutMs, 1), HOSTED_ARTIFACT_PROXY_LIMITS.maxTimeoutMs);
20
20
  const signal = invocationSignal({ timeoutMs, signal: input.signal });
21
- const token = await withSignal((input.identityTokenProvider ?? hostedIdentityToken)({ audience: audience.origin, signal }), signal);
22
- if (!/^[A-Za-z0-9._~-]+$/.test(token) || token.length > 16384)
23
- throw new Error('Invalid platform token');
24
21
  const headers = {
25
22
  host: upstream.host,
26
23
  'content-type': 'application/json',
27
24
  'content-length': String(payload.byteLength),
28
25
  'accept-encoding': 'identity',
29
- 'x-serverless-authorization': `Bearer ${token}`,
30
26
  'x-sequence-artifact-context': input.requestContext,
31
27
  };
28
+ if (audience) {
29
+ const token = await withSignal((input.identityTokenProvider ?? hostedIdentityToken)({ audience: audience.origin, signal }), signal);
30
+ if (!/^[A-Za-z0-9._~-]+$/.test(token) || token.length > 16384)
31
+ throw new Error('Invalid platform token');
32
+ headers['x-serverless-authorization'] = `Bearer ${token}`;
33
+ }
32
34
  const hop = { origin: upstream, path: input.path, headers, body: payload, timeoutMs, signal };
33
35
  const response = await withSignal(input.dispatch ? input.dispatch(hop) : postCloudRun({ ...hop, dial: input.dial }), signal);
34
36
  if (response.body.byteLength > BODY_CAP)
@@ -100,7 +102,7 @@ function postCloudRun(input) {
100
102
  stop();
101
103
  resolve(value);
102
104
  };
103
- const request = (input.dial ?? httpsRequest)(input.origin, {
105
+ const request = (input.dial ?? (input.origin.protocol === 'http:' ? httpRequest : httpsRequest))(input.origin, {
104
106
  method: 'POST',
105
107
  path: input.path,
106
108
  headers: input.headers,
@@ -0,0 +1,17 @@
1
+ export interface HostedRuntimeTarget {
2
+ upstreamUrl: string;
3
+ /** Canonical service audience, used only for the Cloud Run transport. */
4
+ upstreamAudience?: string;
5
+ /** Missing on older control-plane records; these remain Cloud Run targets. */
6
+ runtimeTransport?: 'cloud-run' | 'local-docker';
7
+ environment?: string;
8
+ }
9
+ export declare function cloudRunOrigin(value: string): URL;
10
+ /** Local deployments retain hosted authorization and signed contexts; only their private hop differs. */
11
+ export declare function resolveHostedTarget({ target, environment }: {
12
+ target: HostedRuntimeTarget;
13
+ environment?: NodeJS.ProcessEnv;
14
+ }): {
15
+ upstream: URL;
16
+ audience: URL | undefined;
17
+ };
@@ -0,0 +1,26 @@
1
+ export function cloudRunOrigin(value) {
2
+ const url = new URL(value);
3
+ if (!/^https:\/\/[a-z0-9.-]+\.run\.app\/?$/.test(value) || url.protocol !== 'https:' ||
4
+ url.username || url.password || url.port || url.pathname !== '/' || url.search || url.hash ||
5
+ !url.hostname.split('.').every((part) => /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(part)))
6
+ throw new Error('Invalid hosted deployment origin');
7
+ return url;
8
+ }
9
+ /** Local deployments retain hosted authorization and signed contexts; only their private hop differs. */
10
+ export function resolveHostedTarget({ target, environment = process.env }) {
11
+ if (target.runtimeTransport === undefined || target.runtimeTransport === 'cloud-run') {
12
+ const upstream = cloudRunOrigin(target.upstreamUrl);
13
+ return { upstream, audience: target.upstreamAudience === undefined ? upstream : cloudRunOrigin(target.upstreamAudience) };
14
+ }
15
+ if (target.runtimeTransport !== 'local-docker' || environment.ARTIFACT_NEXT_RUNTIME_PROVIDER !== 'local-docker' ||
16
+ environment.APP_ENV !== 'development' || target.environment !== 'development' || environment.K_SERVICE || environment.VERCEL ||
17
+ !/^dev-[A-Za-z0-9_-]+$/.test(environment.LAKEBASE_BRANCH ?? '') || environment.LAKEBASE_BRANCH === 'dev-cloud-agents' ||
18
+ target.upstreamAudience !== undefined)
19
+ throw new Error('Invalid local deployment transport');
20
+ // Validate the original spelling: URL normalization otherwise admits integer/hex IPs and aliases.
21
+ const match = /^http:\/\/127\.0\.0\.1:([1-9][0-9]{3,4})$/.exec(target.upstreamUrl);
22
+ const port = Number(match?.[1]);
23
+ if (!match || port < 1024 || port > 65535)
24
+ throw new Error('Invalid local deployment origin');
25
+ return { upstream: new URL(target.upstreamUrl), audience: undefined };
26
+ }
@@ -4,6 +4,7 @@ import { ArtifactGatewayControl } from './control.js';
4
4
  import { type GatewaySessionSameSite } from './session.js';
5
5
  import { type ArtifactRequestAdmission } from './admission.js';
6
6
  import { type GatewayControlAdmission } from './control-admission.js';
7
+ import { type ArtifactGatewayIngressOptions } from './ingress.js';
7
8
  export interface ArtifactGatewayOptions {
8
9
  control: ArtifactGatewayControl;
9
10
  runtimeDomain: string;
@@ -25,6 +26,8 @@ export interface ArtifactGatewayOptions {
25
26
  /** Rate limits `/_sequence/session` and `/_sequence/tunnel` before they reach Atlas. Without it
26
27
  * both endpoints fail closed. */
27
28
  controlAdmission?: Pick<GatewayControlAdmission, 'admit'>;
29
+ /** External ingress front door (design §(e)). Absent: every `ingress-*` host answers 404. */
30
+ ingress?: ArtifactGatewayIngressOptions;
28
31
  /** Reuse of a positive Atlas authorization (default GATEWAY_AUTHORIZATION_TTL_MS; 0 disables). */
29
32
  authorizationCacheTtlMs?: number;
30
33
  onError?: (event: {