@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,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: {
@@ -13,6 +13,9 @@ import { gatewayClientAddress } from './control-admission.js';
13
13
  import { gatewayAuthorizationKey, PositiveDecisionCache, tunnelAuthorizationKey } from './authorization-cache.js';
14
14
  import { GatewayUsageReporter } from './usage.js';
15
15
  import { artifactNextFrameworkBuildIdSchema } from '../runtime-contracts.js';
16
+ import { createDevelopmentIngressProxy } from './ingress-development.js';
17
+ import { createIngressHandler, handleClientErrors } from './ingress-handler.js';
18
+ import { resemblesIngressHost } from './ingress.js';
16
19
  function json(response, status, error) {
17
20
  if (response.headersSent) {
18
21
  response.destroy();
@@ -115,6 +118,7 @@ export function createArtifactGateway(options) {
115
118
  const tunnelAuthorizations = new PositiveDecisionCache({ ttlMs: options.authorizationCacheTtlMs });
116
119
  const usage = new GatewayUsageReporter({ send: events => options.control.usage(events), onFailure: failure => options.onError?.(failure) });
117
120
  let draining = false;
121
+ const isIngressRequest = (request) => resemblesIngressHost({ rawHost: header(request, 'host'), runtimeDomain: options.runtimeDomain });
118
122
  /** Admission for the unauthenticated endpoints; the release frees the in-flight permit. */
119
123
  async function admitControlCall(endpoint, request) {
120
124
  if (!options.controlAdmission)
@@ -254,7 +258,16 @@ export function createArtifactGateway(options) {
254
258
  onTunnelClosed: lease => { if (leases.get(lease.sessionId)?.lease.epoch === lease.epoch)
255
259
  leases.delete(lease.sessionId); },
256
260
  });
261
+ // A development target is proxied over its session tunnel only by a development gateway (design §(j.3) step 3).
262
+ const proxyDevelopment = options.ingress?.appEnv === 'development' ? createDevelopmentIngressProxy({ broker }) : undefined;
263
+ const ingress = createIngressHandler({ options: options.ingress, runtimeDomain: options.runtimeDomain, protocol: new URL(gatewayOrigin).protocol, isDraining: () => draining, onError: options.onError, usage, proxyDevelopment });
257
264
  const server = createServer({ maxHeaderSize: 32 * 1024, requestTimeout: 60_000, headersTimeout: 15_000 }, (request, response) => {
265
+ // Before readiness, bootstrap and requestOrigin(): an ingress host, canonical or not, never
266
+ // reaches a session-path handler or the 401/404→403 remap below (design §(e) "Branch placement").
267
+ if (isIngressRequest(request)) {
268
+ void ingress.handle(request, response);
269
+ return;
270
+ }
258
271
  const startedAt = new Date().toISOString(), started = performance.now();
259
272
  void (async () => {
260
273
  if (isReadinessRequest({ request, gatewayOrigin, runtimeDomain: options.runtimeDomain })) {
@@ -350,6 +363,11 @@ export function createArtifactGateway(options) {
350
363
  rejectUpgrade(socket, 503);
351
364
  return;
352
365
  }
366
+ // An ingress answers data requests only: no WebSocket, no tunnel.
367
+ if (isIngressRequest(request)) {
368
+ rejectUpgrade(socket, 404);
369
+ return;
370
+ }
353
371
  if (request.url === '/_sequence/tunnel') {
354
372
  await broker.handleTunnelUpgrade(request, socket, head);
355
373
  return;
@@ -362,6 +380,9 @@ export function createArtifactGateway(options) {
362
380
  await broker.handleWebSocketUpgrade(request, socket, head, authorized.target.sessionId);
363
381
  })().catch(() => rejectUpgrade(socket));
364
382
  });
383
+ // Only with the ingress branch configured: without it, Node's own clientError replies stay untouched.
384
+ if (options.ingress)
385
+ handleClientErrors({ server, ingress });
365
386
  return {
366
387
  server,
367
388
  /** Observations recorded but not yet confirmed by Atlas: what a forced exit would lose. */
@@ -0,0 +1,15 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ /** Response-header rules for the iframe session path of the hosted proxy (an ingress uses `ingress-response.ts`). */
3
+ export declare function rewriteLocation({ location, upstream, publicOrigin }: {
4
+ location: string;
5
+ upstream: URL;
6
+ publicOrigin: string;
7
+ }): string;
8
+ export declare function mergeFramePolicy({ application, trusted }: {
9
+ application: string | string[];
10
+ trusted: string | string[];
11
+ }): string[];
12
+ export declare function enforceBrowserCachePolicy({ request, response }: {
13
+ request: IncomingMessage;
14
+ response: ServerResponse;
15
+ }): void;
@@ -0,0 +1,40 @@
1
+ import { isSharedCacheDirective, SHARED_CACHE_REFUSAL } from '../tunnel/protocol.js';
2
+ /** Response-header rules for the iframe session path of the hosted proxy (an ingress uses `ingress-response.ts`). */
3
+ export function rewriteLocation({ location, upstream, publicOrigin }) {
4
+ if (!/^(?:https?:)?\/\//i.test(location))
5
+ return location;
6
+ const url = new URL(location, upstream);
7
+ return url.origin === upstream.origin ? `${publicOrigin}${url.pathname}${url.search}${url.hash}` : location;
8
+ }
9
+ export function mergeFramePolicy({ application, trusted }) {
10
+ const policies = Array.isArray(application) ? application : [application];
11
+ const frames = Array.isArray(trusted) ? trusted : [trusted];
12
+ return [...policies.map((policy) => policy.split(';')
13
+ .filter((directive) => directive.trim().split(/\s/, 1)[0].toLowerCase() !== 'frame-ancestors')
14
+ .map((directive) => directive.trim()).filter(Boolean).join('; ')).filter(Boolean), ...frames];
15
+ }
16
+ export function enforceBrowserCachePolicy({ request, response }) {
17
+ const path = (request.url ?? '/').split('?', 1)[0];
18
+ let staticAsset = false;
19
+ try {
20
+ const segments = path.slice(1).split('/').map((segment) => decodeURIComponent(segment));
21
+ staticAsset = path.startsWith('/_next/static/') && /\.(?:js|css)$/.test(path) &&
22
+ segments.every((segment) => segment !== '' && segment !== '.' && segment !== '..' && !/[\\/]/.test(segment));
23
+ }
24
+ catch { /* Malformed or ambiguous asset paths retain the default no-store policy. */ }
25
+ const contentType = response.getHeader('content-type');
26
+ const mediaType = typeof contentType === 'string' ? contentType.split(';', 1)[0].trim().toLowerCase() : undefined;
27
+ const assetType = path.endsWith('.css') ? mediaType === 'text/css' : mediaType === 'text/javascript' || mediaType === 'application/javascript';
28
+ const revalidateAsset = staticAsset && ['GET', 'HEAD'].includes(request.method ?? 'GET') &&
29
+ (response.statusCode === 200 || response.statusCode === 304) &&
30
+ (assetType || (response.statusCode === 304 && contentType === undefined));
31
+ // Every protected reuse must reach authorization. Retain only private static
32
+ // JS/CSS bytes and validators; app output may never opt into browser/CDN TTLs.
33
+ for (const name of response.getHeaderNames()) {
34
+ if (isSharedCacheDirective(name))
35
+ response.removeHeader(name);
36
+ }
37
+ response.setHeader('cache-control', revalidateAsset ? 'private, no-cache' : 'private, no-store');
38
+ for (const [name, value] of Object.entries(SHARED_CACHE_REFUSAL))
39
+ response.setHeader(name, value);
40
+ }
@@ -1,8 +1,15 @@
1
1
  export interface GatewayUsageEvent {
2
2
  eventId: string;
3
3
  requestId: string;
4
- /** The user the gateway acted for: the session owner Atlas authorized for this request. */
4
+ /**
5
+ * Who the gateway acted for: the session owner Atlas authorized for this request, or for an
6
+ * external ingress request the namespaced external subject (`ext|<provider>|<subject>`).
7
+ */
5
8
  actorId: string;
9
+ /** Present only for an external ingress request. */
10
+ principalKind?: 'external';
11
+ /** The ingress an external request arrived through. */
12
+ ingressId?: string;
6
13
  projectId: string;
7
14
  deploymentId?: string;
8
15
  devSessionId?: string;
@@ -7,6 +7,7 @@ import { gatewaySessionSameSite } from './gateway/session.js';
7
7
  import { RedisTunnelOwnership, RedisTunnelRelay } from './tunnel/redis.js';
8
8
  import { ArtifactRequestAdmission } from './gateway/admission.js';
9
9
  import { GatewayControlAdmission } from './gateway/control-admission.js';
10
+ import { createIngressRuntime, IngressSecretsError } from './gateway/ingress-runtime.js';
10
11
  function required(name) {
11
12
  const value = process.env[name];
12
13
  if (!value)
@@ -40,6 +41,8 @@ async function main() {
40
41
  // A flood of refused requests or forged frames must not become a log flood: count and report every 10 s.
41
42
  const refusals = new Map();
42
43
  const refuse = (kind) => { refusals.set(kind, (refusals.get(kind) ?? 0) + 1); };
44
+ // Kinds a caller can produce at request rate: counted, never logged one by one.
45
+ const counted = new Set(['admission_rejected', 'ingress_pass_rejected', 'ingress_token_rejected', 'ingress_edge_header_invalid', 'ingress_client_ip_invalid']);
43
46
  setInterval(() => {
44
47
  for (const [kind, count] of refusals)
45
48
  log({ event: 'artifact_gateway.request', kind, count });
@@ -56,26 +59,43 @@ async function main() {
56
59
  subscriber.on('ready', () => { if (!stopping)
57
60
  void relay.start().catch(() => undefined); });
58
61
  const controlUrl = required('ARTIFACT_NEXT_CONTROL_PLANE_URL');
62
+ const controlToken = required('ARTIFACT_NEXT_CONTROL_TOKEN');
63
+ const control = new ArtifactGatewayControl({ url: controlUrl, token: controlToken });
64
+ const contextPrivateKey = required('ARTIFACT_NEXT_CONTEXT_PRIVATE_KEY');
65
+ const contextKeyId = required('ARTIFACT_NEXT_CONTEXT_KEY_ID');
66
+ const admission = new ArtifactRequestAdmission({ redis: command, namespace });
67
+ // The external ingress branch: off (every ingress host 404) unless all four of its secrets are
68
+ // set; the process refuses to boot when they are set but unusable or not independent.
69
+ const ingress = createIngressRuntime({
70
+ env: process.env, control, controlToken, sessionKey, contextPrivateKey, contextKeyId, redis: command, namespace, admission, log,
71
+ onFailure: failure => { if ('count' in failure)
72
+ log({ event: 'artifact_gateway.request', ...failure });
73
+ else
74
+ refuse(failure.kind); },
75
+ });
59
76
  const gateway = createArtifactGateway({
60
- control: new ArtifactGatewayControl({ url: controlUrl, token: required('ARTIFACT_NEXT_CONTROL_TOKEN') }),
77
+ control,
61
78
  runtimeDomain: required('ARTIFACT_NEXT_RUNTIME_DOMAIN'),
62
79
  parentOrigin: process.env.ARTIFACT_NEXT_PARENT_ORIGIN ?? new URL(controlUrl).origin,
63
80
  gatewayOrigin: required('ARTIFACT_NEXT_GATEWAY_URL'),
64
81
  sessionKey,
65
82
  sessionCookieSameSite: gatewaySessionSameSite({ value: process.env.ARTIFACT_NEXT_SESSION_COOKIE_SAMESITE, appEnv: namespace }),
66
- contextPrivateKey: required('ARTIFACT_NEXT_CONTEXT_PRIVATE_KEY'),
67
- contextKeyId: required('ARTIFACT_NEXT_CONTEXT_KEY_ID'),
83
+ contextPrivateKey,
84
+ contextKeyId,
68
85
  nodeId, routing: ownership, relay,
69
- admission: new ArtifactRequestAdmission({ redis: command, namespace }),
86
+ admission,
87
+ ingress: ingress.ingress,
70
88
  controlAdmission: new GatewayControlAdmission({ redis: command, namespace }),
71
89
  resolveLease: sessionId => ownership.resolveLease(sessionId),
72
90
  renewLease: lease => ownership.renew(lease, nodeId),
73
91
  ready: () => clients.every(client => client.status === 'ready') && relay.isReady(),
74
- onError: event => { if (event.kind === 'admission_rejected')
92
+ onError: event => { if (counted.has(event.kind))
75
93
  refuse(event.kind);
76
94
  else
77
95
  log({ event: 'artifact_gateway.request', ...event }); },
78
96
  });
97
+ // The first snapshot pull starts before traffic; until it lands, ingress hosts answer 503.
98
+ ingress.start();
79
99
  const port = Number(process.env.PORT ?? '8080');
80
100
  if (!Number.isSafeInteger(port) || port < 1 || port > 65535)
81
101
  throw new Error('Invalid gateway PORT');
@@ -102,7 +122,8 @@ async function main() {
102
122
  }, 9_000);
103
123
  deadline.unref();
104
124
  await gateway.drain();
105
- await relay.close();
125
+ // Audit delivery runs beside tunnel cleanup, never in front of it.
126
+ await Promise.allSettled([ingress.close(), relay.close()]);
106
127
  for (const client of clients)
107
128
  client.disconnect();
108
129
  clearTimeout(deadline);
@@ -110,7 +131,10 @@ async function main() {
110
131
  process.once('SIGTERM', () => { void stop(); });
111
132
  process.once('SIGINT', () => { void stop(); });
112
133
  }
113
- void main().catch(() => {
114
- process.stderr.write(`${JSON.stringify({ event: 'artifact_gateway.start_failed' })}\n`);
134
+ void main().catch((error) => {
135
+ // Only an ingress-secrets refusal is described: its message names variables, never values, by
136
+ // construction. Other errors (Redis, URLs) may carry connection strings.
137
+ const reason = error instanceof IngressSecretsError ? error.message : undefined;
138
+ process.stderr.write(`${JSON.stringify({ event: 'artifact_gateway.start_failed', ...(reason ? { reason } : {}) })}\n`);
115
139
  process.exit(1);
116
140
  });
@@ -0,0 +1,17 @@
1
+ import { z } from 'zod';
2
+ import { type ArtifactStudioCustomRoles } from './manifest-custom-roles.js';
3
+ /** Additional browser admission rules; neither these rules nor data reach grant access. */
4
+ export declare const artifactNextBrowserDataSchema: z.ZodPrefault<z.ZodObject<{
5
+ operations: z.ZodOptional<z.ZodArray<z.ZodString>>;
6
+ required_capabilities: z.ZodOptional<z.ZodPreprocess<z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString>>>>;
7
+ read: z.ZodDefault<z.ZodArray<z.ZodString>>;
8
+ write: z.ZodDefault<z.ZodArray<z.ZodString>>;
9
+ }, z.core.$strict>>;
10
+ export type ArtifactNextBrowserData = z.infer<typeof artifactNextBrowserDataSchema>;
11
+ /** Restriction references must be backed by this browser's reach and declared artifact pins. */
12
+ export declare function validateBrowserDataAdmission({ data, uses, roles, ctx }: {
13
+ data: ArtifactNextBrowserData;
14
+ uses: readonly string[];
15
+ roles?: ArtifactStudioCustomRoles;
16
+ ctx: z.RefinementCtx;
17
+ }): void;
@@ -0,0 +1,46 @@
1
+ import { z } from 'zod';
2
+ import { capabilityPinsSchema } from './manifest-custom-roles.js';
3
+ import { boundedRef, ormOperationRef, reachShape } from './manifest-next-reach.js';
4
+ const browserOperationRef = boundedRef.refine((ref) => ormOperationRef.test(ref), 'Browser ORM operations name each operation: namespace/ops/Operation');
5
+ /** Additional browser admission rules; neither these rules nor data reach grant access. */
6
+ export const artifactNextBrowserDataSchema = z.strictObject({
7
+ ...reachShape.data.unwrap().shape,
8
+ // Preserve absence: existing manifests use table-set admission. An explicit [] denies all.
9
+ operations: z.array(browserOperationRef).max(256).refine((operations) => new Set(operations).size === operations.length, 'Browser ORM operations must be unique').optional(),
10
+ // Keys name source namespaces, before development/preview namespace bindings are applied.
11
+ required_capabilities: z.preprocess((value, ctx) => {
12
+ // Zod records skip this own key before validating keys; reject it rather than silently drop it.
13
+ if (value !== null && typeof value === 'object' && Object.hasOwn(value, '__proto__')) {
14
+ ctx.addIssue({ code: 'custom', path: ['__proto__'], message: 'Capability guard keys are ORM source namespaces' });
15
+ }
16
+ return value;
17
+ }, z.record(z.string().regex(/^[a-z][a-z0-9_]{0,40}$/, 'Capability guard keys are ORM source namespaces'), capabilityPinsSchema.unwrap().min(1).refine((pins) => new Set(pins).size === pins.length, 'Required browser ORM capabilities must be unique'))).optional(),
18
+ }).prefault({});
19
+ /** Restriction references must be backed by this browser's reach and declared artifact pins. */
20
+ export function validateBrowserDataAdmission({ data, uses, roles, ctx }) {
21
+ const reached = new Set([...data.read, ...data.write].map((ref) => ref.split('/', 1)[0]));
22
+ for (const [index, operation] of (data.operations ?? []).entries()) {
23
+ const namespace = operation.split('/', 1)[0];
24
+ if (!reached.has(namespace)) {
25
+ ctx.addIssue({
26
+ code: 'custom', path: ['capabilities', 'data', 'operations', index],
27
+ message: `${operation} names a namespace that browser capabilities.data.read or write does not reach`,
28
+ });
29
+ }
30
+ }
31
+ const declared = new Set([...Object.keys(roles?.capabilities ?? {}), ...uses]);
32
+ for (const [namespace, pins] of Object.entries(data.required_capabilities ?? {})) {
33
+ const path = ['capabilities', 'data', 'required_capabilities', namespace];
34
+ if (!reached.has(namespace)) {
35
+ ctx.addIssue({ code: 'custom', path, message: `${namespace} is not reached by browser capabilities.data.read or write` });
36
+ }
37
+ for (const [index, pin] of pins.entries()) {
38
+ if (!declared.has(pin)) {
39
+ ctx.addIssue({
40
+ code: 'custom', path: [...path, index],
41
+ message: `Required capability "${pin}" must be declared in capabilities.roles.capabilities or capabilities.uses`,
42
+ });
43
+ }
44
+ }
45
+ }
46
+ }