@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
@@ -3,6 +3,7 @@ import { z } from 'zod';
3
3
  import { normalizePublicOrigin } from '../tunnel/protocol.js';
4
4
  import { artifactRequestContextSchema } from '../server/request-context.js';
5
5
  import { artifactNextFrameworkBuildIdSchema } from '../runtime-contracts.js';
6
+ import { INGRESS_SNAPSHOT_MAX_BYTES, ingressExchangeResponseSchema, ingressGrantResponseSchema, ingressSnapshotSchema, } from '../server/ingress-control-contract.js';
6
7
  const id = z.string().min(1).max(255);
7
8
  const base = {
8
9
  projectId: id, ownerId: id, environment: id, publicOrigin: z.string().transform(normalizePublicOrigin),
@@ -17,7 +18,7 @@ const base = {
17
18
  };
18
19
  export const gatewayTargetSchema = z.discriminatedUnion('kind', [
19
20
  z.object({ ...base, kind: z.literal('development'), sessionId: id, epoch: z.number().int().positive() }),
20
- z.object({ ...base, kind: z.literal('hosted'), deploymentId: id, frameworkBuildId: artifactNextFrameworkBuildIdSchema, upstreamUrl: z.string().url(), upstreamAudience: z.string().url().optional(), active: z.boolean(), published: z.boolean().optional() }),
21
+ z.object({ ...base, kind: z.literal('hosted'), deploymentId: id, frameworkBuildId: artifactNextFrameworkBuildIdSchema, upstreamUrl: z.string().url(), upstreamAudience: z.string().url().optional(), runtimeTransport: z.enum(['cloud-run', 'local-docker']).optional(), active: z.boolean(), published: z.boolean().optional() }),
21
22
  ]);
22
23
  const bootstrapSchema = z.object({ parentOrigin: z.string().transform(normalizePublicOrigin), channelId: id, nonce: z.string().min(16).max(255) });
23
24
  const usageResultSchema = z.object({ rejected: z.number().int().nonnegative() });
@@ -27,6 +28,7 @@ const latticeInvokeGrantSchema = z.object({
27
28
  frameworkBuildId: artifactNextFrameworkBuildIdSchema,
28
29
  upstreamUrl: z.string().url(),
29
30
  upstreamAudience: z.string().url().optional(),
31
+ runtimeTransport: z.enum(['cloud-run', 'local-docker']).optional(),
30
32
  publicOrigin: z.string().min(1).max(2048),
31
33
  environment: id,
32
34
  principalSub: id,
@@ -46,6 +48,49 @@ export class GatewayControlError extends Error {
46
48
  this.name = 'GatewayControlError';
47
49
  }
48
50
  }
51
+ /** Codes an ingress control route may answer with (design §(i)), and the status each one carries. */
52
+ const INGRESS_CONTROL_CODES = { invalid_token: 401, exchange_denied: 403, not_found: 404, rate_limited: 429, temporarily_unavailable: 503 };
53
+ const INGRESS_ERROR_BODY_MAX_BYTES = 4096;
54
+ function isIngressControlCode(value) {
55
+ return Object.hasOwn(INGRESS_CONTROL_CODES, value);
56
+ }
57
+ /**
58
+ * A refusal an ingress control route decided: its contract code and Atlas's `Retry-After`, so the
59
+ * gateway answers the caller with Atlas's decision instead of inventing one. `code` is null when the
60
+ * response named no contract code matching its status (a misconfigured control token, a bare 500):
61
+ * the gateway then answers 503, never a code that would send the client into a sign-in loop.
62
+ */
63
+ export class IngressControlError extends GatewayControlError {
64
+ code;
65
+ retryAfterSeconds;
66
+ constructor(status, code, retryAfterSeconds) {
67
+ super(status);
68
+ this.code = code;
69
+ this.retryAfterSeconds = retryAfterSeconds;
70
+ this.name = 'IngressControlError';
71
+ }
72
+ }
73
+ /** The contract code of a refused control response, read from at most 4 KiB of its JSON body. */
74
+ async function ingressRefusal(response) {
75
+ const retryAfter = Number(response.headers.get('retry-after'));
76
+ const retryAfterSeconds = Number.isSafeInteger(retryAfter) && retryAfter > 0 && retryAfter <= 3600 ? retryAfter : undefined;
77
+ let code = null;
78
+ try {
79
+ const length = Number(response.headers.get('content-length'));
80
+ const text = length > INGRESS_ERROR_BODY_MAX_BYTES ? '' : (await response.text()).slice(0, INGRESS_ERROR_BODY_MAX_BYTES);
81
+ const parsed = z.object({ error: z.string() }).safeParse(JSON.parse(text));
82
+ const named = parsed.success && isIngressControlCode(parsed.data.error) ? parsed.data.error : null;
83
+ // A code is believed only with the status the contract gives it.
84
+ code = named !== null && INGRESS_CONTROL_CODES[named] === response.status ? named : null;
85
+ }
86
+ catch {
87
+ code = null;
88
+ }
89
+ finally {
90
+ await response.body?.cancel().catch(() => undefined);
91
+ }
92
+ return new IngressControlError(response.status, code, retryAfterSeconds);
93
+ }
49
94
  export class ArtifactGatewayControl {
50
95
  options;
51
96
  origin;
@@ -55,15 +100,19 @@ export class ArtifactGatewayControl {
55
100
  if (options.token.length < 32)
56
101
  throw new Error('Artifact control plane token requires at least 32 characters');
57
102
  }
58
- async call(path, body, timeoutMs = 10_000) {
103
+ call(path, body, timeoutMs = 10_000) {
104
+ return this.send({ path, body, timeoutMs, maxBytes: 128 * 1024 });
105
+ }
106
+ async send({ path, body, timeoutMs, maxBytes, ingress = false }) {
59
107
  const response = await (this.options.fetch ?? fetch)(`${this.origin}/api/artifact-studio/next/control/${path}`, {
60
- method: 'POST', headers: { authorization: `Bearer ${this.options.token}`, 'content-type': 'application/json' },
61
- body: JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs), redirect: 'error',
108
+ method: body === undefined ? 'GET' : 'POST',
109
+ headers: { authorization: `Bearer ${this.options.token}`, ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
110
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(timeoutMs), redirect: 'error',
62
111
  });
63
112
  if (!response.ok)
64
- throw new GatewayControlError(response.status);
113
+ throw ingress ? await ingressRefusal(response) : new GatewayControlError(response.status);
65
114
  const contentLength = Number(response.headers.get('content-length'));
66
- if (contentLength > 128 * 1024) {
115
+ if (contentLength > maxBytes) {
67
116
  await response.body?.cancel();
68
117
  throw new GatewayControlError(502);
69
118
  }
@@ -78,7 +127,7 @@ export class ArtifactGatewayControl {
78
127
  if (done)
79
128
  break;
80
129
  length += value.length;
81
- if (length > 128 * 1024)
130
+ if (length > maxBytes)
82
131
  throw new GatewayControlError(502);
83
132
  chunks.push(value);
84
133
  }
@@ -88,6 +137,26 @@ export class ArtifactGatewayControl {
88
137
  await reader.cancel().catch(() => undefined);
89
138
  }
90
139
  }
140
+ /** Every enabled ingress host (GET, at most 1 MiB); the gateway pulls it on a timer, never per request. */
141
+ async ingressSnapshot() {
142
+ return ingressSnapshotSchema.parse(await this.send({ path: 'ingress-snapshot', timeoutMs: 10_000, maxBytes: INGRESS_SNAPSHOT_MAX_BYTES }));
143
+ }
144
+ /** Verifies an identity provider's token for an ingress; refusals carry Atlas's contract code. */
145
+ async ingressExchange(body) {
146
+ return ingressExchangeResponseSchema.parse(await this.send({ path: 'ingress-exchange', body, timeoutMs: 10_000, maxBytes: 16 * 1024, ingress: true }));
147
+ }
148
+ /** Re-checks one ingress request against the active deployment and returns where to proxy it. */
149
+ async ingressGrant(body) {
150
+ return ingressGrantResponseSchema.parse(await this.send({ path: 'ingress-grant', body, timeoutMs: 10_000, maxBytes: 16 * 1024, ingress: true }));
151
+ }
152
+ /** Logout: revokes every identity provider token the subject was issued up to Atlas's now. */
153
+ async ingressRevoke(body) {
154
+ await this.send({ path: 'ingress-revoke', body, timeoutMs: 10_000, maxBytes: 16 * 1024, ingress: true });
155
+ }
156
+ /** One batch of refusals the gateway decided locally (Atlas audits the ones it decided itself). */
157
+ async ingressAudit(body) {
158
+ await this.send({ path: 'ingress-audit', body, timeoutMs: 5_000, maxBytes: 16 * 1024, ingress: true });
159
+ }
91
160
  /** One idempotent batch; Atlas reports events it refused (for example, a retired target). */
92
161
  async usage(events) {
93
162
  const value = await this.call('usage', { events }, 5_000);
@@ -1,16 +1,17 @@
1
1
  import { type IncomingMessage, type ServerResponse } from 'node:http';
2
+ import { type HostedArtifactIngressMode } from './ingress-response.js';
3
+ import { type HostedRuntimeTarget } from './runtime-transport.js';
4
+ export { cloudRunOrigin, resolveHostedTarget, type HostedRuntimeTarget } from './runtime-transport.js';
2
5
  export declare const HOSTED_ARTIFACT_PROXY_LIMITS: Readonly<{
3
- maxBodyBytes: number;
6
+ maxRequestBytes: number;
7
+ maxResponseBytes: number;
4
8
  timeoutMs: 60000;
5
9
  maxTimeoutMs: 300000;
6
10
  }>;
7
11
  export interface HostedArtifactProxyOptions {
8
12
  request: IncomingMessage;
9
13
  response: ServerResponse;
10
- target: {
11
- upstreamUrl: string;
12
- /** Canonical service URL from the same provider record as the revision URL. */
13
- upstreamAudience?: string;
14
+ target: HostedRuntimeTarget & {
14
15
  publicOrigin: string;
15
16
  projectId: string;
16
17
  deploymentId: string;
@@ -26,16 +27,18 @@ export interface HostedArtifactProxyOptions {
26
27
  resolveUpstream?: (input: {
27
28
  url: URL;
28
29
  }) => URL;
29
- /** Approved request timeouts may exceed the default up to the platform cap. */
30
+ /** Approved request timeouts may exceed the default up to the platform cap (55 s on an ingress). */
30
31
  limits?: {
31
- maxBodyBytes?: number;
32
+ maxRequestBytes?: number;
33
+ maxResponseBytes?: number;
32
34
  timeoutMs?: number;
33
35
  };
36
+ /** Present only for an external ingress request. */
37
+ ingress?: HostedArtifactIngressMode;
34
38
  }
35
39
  export declare function hostedIdentityToken(input: {
36
40
  audience: string;
37
41
  signal: AbortSignal;
38
42
  }): Promise<string>;
39
- export declare function cloudRunOrigin(value: string): URL;
40
43
  /** HTTP-only streaming hop. Authentication, CSRF and release admission happen before calling this. */
41
44
  export declare function proxyHostedArtifact(options: HostedArtifactProxyOptions): Promise<void>;
@@ -3,9 +3,13 @@ import { request as httpsRequest } from 'node:https';
3
3
  import { Duplex, Readable, Transform } from 'node:stream';
4
4
  import { createBrotliDecompress, createGunzip, createInflate } from 'node:zlib';
5
5
  import { GoogleAuth } from 'google-auth-library';
6
- import { assertSafeRequest, isSharedCacheDirective, normalizePublicOrigin, sanitizeRequestHeaders, sanitizeResponseHeaders, SHARED_CACHE_REFUSAL } from '../tunnel/protocol.js';
6
+ import { assertSafeRequest, normalizePublicOrigin, sanitizeRequestHeaders, sanitizeResponseHeaders } from '../tunnel/protocol.js';
7
+ import { enforceBrowserCachePolicy, mergeFramePolicy, rewriteLocation } from './session-response.js';
7
8
  import { createReleaseFormTransform } from '../server/release-forms.js';
8
- export const HOSTED_ARTIFACT_PROXY_LIMITS = Object.freeze({ maxBodyBytes: 32 * 1024 * 1024, timeoutMs: 60_000, maxTimeoutMs: 300_000 });
9
+ import { declaredContentLength, INGRESS_PROXY_ERROR_CODES, INGRESS_PROXY_LIMITS, writeIngressResponseHead } from './ingress-response.js';
10
+ import { resolveHostedTarget } from './runtime-transport.js';
11
+ export { cloudRunOrigin, resolveHostedTarget } from './runtime-transport.js';
12
+ export const HOSTED_ARTIFACT_PROXY_LIMITS = Object.freeze({ maxRequestBytes: 32 * 1024 * 1024, maxResponseBytes: 32 * 1024 * 1024, timeoutMs: 60_000, maxTimeoutMs: 300_000 });
9
13
  const googleAuth = new GoogleAuth();
10
14
  const identityClients = new Map();
11
15
  async function defaultIdentityTokenProvider({ audience }) {
@@ -35,58 +39,12 @@ async function defaultIdentityTokenProvider({ audience }) {
35
39
  export function hostedIdentityToken(input) {
36
40
  return defaultIdentityTokenProvider(input);
37
41
  }
38
- export function cloudRunOrigin(value) {
39
- const url = new URL(value);
40
- if (!/^https:\/\/[a-z0-9.-]+\.run\.app\/?$/.test(value) || url.protocol !== 'https:' ||
41
- url.username || url.password || url.port || url.pathname !== '/' || url.search || url.hash ||
42
- !url.hostname.split('.').every((part) => /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(part)))
43
- throw new Error('Invalid hosted deployment origin');
44
- return url;
45
- }
46
42
  function stripCloudHeaders(headers) {
47
43
  for (const name of Object.keys(headers)) {
48
44
  if (name === 'x-serverless-authorization' || name.startsWith('x-goog-'))
49
45
  delete headers[name];
50
46
  }
51
47
  }
52
- function rewriteLocation({ location, upstream, publicOrigin }) {
53
- if (!/^(?:https?:)?\/\//i.test(location))
54
- return location;
55
- const url = new URL(location, upstream);
56
- return url.origin === upstream.origin ? `${publicOrigin}${url.pathname}${url.search}${url.hash}` : location;
57
- }
58
- function mergeFramePolicy({ application, trusted }) {
59
- const policies = Array.isArray(application) ? application : [application];
60
- const frames = Array.isArray(trusted) ? trusted : [trusted];
61
- return [...policies.map((policy) => policy.split(';')
62
- .filter((directive) => directive.trim().split(/\s/, 1)[0].toLowerCase() !== 'frame-ancestors')
63
- .map((directive) => directive.trim()).filter(Boolean).join('; ')).filter(Boolean), ...frames];
64
- }
65
- function enforceBrowserCachePolicy({ request, response }) {
66
- const path = (request.url ?? '/').split('?', 1)[0];
67
- let staticAsset = false;
68
- try {
69
- const segments = path.slice(1).split('/').map((segment) => decodeURIComponent(segment));
70
- staticAsset = path.startsWith('/_next/static/') && /\.(?:js|css)$/.test(path) &&
71
- segments.every((segment) => segment !== '' && segment !== '.' && segment !== '..' && !/[\\/]/.test(segment));
72
- }
73
- catch { /* Malformed or ambiguous asset paths retain the default no-store policy. */ }
74
- const contentType = response.getHeader('content-type');
75
- const mediaType = typeof contentType === 'string' ? contentType.split(';', 1)[0].trim().toLowerCase() : undefined;
76
- const assetType = path.endsWith('.css') ? mediaType === 'text/css' : mediaType === 'text/javascript' || mediaType === 'application/javascript';
77
- const revalidateAsset = staticAsset && ['GET', 'HEAD'].includes(request.method ?? 'GET') &&
78
- (response.statusCode === 200 || response.statusCode === 304) &&
79
- (assetType || (response.statusCode === 304 && contentType === undefined));
80
- // Every protected reuse must reach authorization. Retain only private static
81
- // JS/CSS bytes and validators; app output may never opt into browser/CDN TTLs.
82
- for (const name of response.getHeaderNames()) {
83
- if (isSharedCacheDirective(name))
84
- response.removeHeader(name);
85
- }
86
- response.setHeader('cache-control', revalidateAsset ? 'private, no-cache' : 'private, no-store');
87
- for (const [name, value] of Object.entries(SHARED_CACHE_REFUSAL))
88
- response.setHeader(name, value);
89
- }
90
48
  /** HTTP-only streaming hop. Authentication, CSRF and release admission happen before calling this. */
91
49
  export function proxyHostedArtifact(options) {
92
50
  return new HostedExchange(options).run();
@@ -94,9 +52,11 @@ export function proxyHostedArtifact(options) {
94
52
  class HostedExchange {
95
53
  options;
96
54
  controller = new AbortController();
97
- maximum;
55
+ requestMaximum;
56
+ responseMaximum;
98
57
  timeoutMs;
99
58
  timer;
59
+ idleWriteTimer;
100
60
  upload;
101
61
  outgoing;
102
62
  incoming;
@@ -108,8 +68,10 @@ class HostedExchange {
108
68
  resolve;
109
69
  constructor(options) {
110
70
  this.options = options;
111
- this.maximum = this.boundLimit(options.limits?.maxBodyBytes, HOSTED_ARTIFACT_PROXY_LIMITS.maxBodyBytes);
112
- this.timeoutMs = this.boundLimit(options.limits?.timeoutMs, HOSTED_ARTIFACT_PROXY_LIMITS.maxTimeoutMs, HOSTED_ARTIFACT_PROXY_LIMITS.timeoutMs);
71
+ this.requestMaximum = this.boundLimit(options.limits?.maxRequestBytes, HOSTED_ARTIFACT_PROXY_LIMITS.maxRequestBytes);
72
+ this.responseMaximum = this.boundLimit(options.limits?.maxResponseBytes, HOSTED_ARTIFACT_PROXY_LIMITS.maxResponseBytes);
73
+ const maxTimeoutMs = options.ingress ? INGRESS_PROXY_LIMITS.maxTimeoutMs : HOSTED_ARTIFACT_PROXY_LIMITS.maxTimeoutMs;
74
+ this.timeoutMs = this.boundLimit(options.limits?.timeoutMs, maxTimeoutMs, Math.min(HOSTED_ARTIFACT_PROXY_LIMITS.timeoutMs, maxTimeoutMs));
113
75
  }
114
76
  boundLimit(value, maximum, fallback = maximum) {
115
77
  return Number.isSafeInteger(value) && value !== undefined && value > 0 ? Math.min(value, maximum) : fallback;
@@ -129,16 +91,15 @@ class HostedExchange {
129
91
  request.once('error', this.finish);
130
92
  this.timer = setTimeout(() => this.fail(504), this.timeoutMs);
131
93
  this.timer.unref();
132
- void this.prepare().catch(() => this.fail(502));
94
+ void this.prepare().catch(() => this.fail(this.unavailableStatus));
133
95
  });
134
96
  }
135
97
  async prepare() {
136
98
  const { request, response, target, requestContext } = this.options;
137
- const upstream = cloudRunOrigin(target.upstreamUrl);
99
+ const { upstream, audience } = resolveHostedTarget({ target });
138
100
  // Cloud Run revision tags select immutable transport destinations, but IAM
139
101
  // requires the canonical service audience. Both are control-plane owned;
140
102
  // never derive this from a browser host, header or query parameter.
141
- const audience = target.upstreamAudience === undefined ? upstream : cloudRunOrigin(target.upstreamAudience);
142
103
  const publicOrigin = normalizePublicOrigin(target.publicOrigin);
143
104
  try {
144
105
  assertSafeRequest(request.method ?? 'GET', request.url ?? '/');
@@ -151,30 +112,41 @@ class HostedExchange {
151
112
  return;
152
113
  }
153
114
  const length = request.headers['content-length'];
154
- if (length !== undefined && (!/^(0|[1-9][0-9]*)$/.test(length) || Number(length) > this.maximum)) {
115
+ if (length !== undefined && (!/^(0|[1-9][0-9]*)$/.test(length) || Number(length) > this.requestMaximum)) {
155
116
  this.fail(413);
156
117
  return;
157
118
  }
158
- const headers = sanitizeRequestHeaders(request.headers, publicOrigin);
159
- stripCloudHeaders(headers);
160
- const provider = this.options.identityTokenProvider ?? defaultIdentityTokenProvider;
161
- const token = await provider({ audience: audience.origin, signal: this.controller.signal });
162
- if (this.stopped)
119
+ let headers;
120
+ try {
121
+ headers = sanitizeRequestHeaders(request.headers, publicOrigin, this.options.ingress ? { mode: 'ingress', appHeaders: this.options.ingress.requestHeaders } : {});
122
+ }
123
+ catch {
124
+ // A header map the schema refuses is the caller's malformed request: an ingress caller must
125
+ // not retry it as an outage (§(i) `bad_request`); a session keeps its 502.
126
+ this.fail(this.options.ingress ? 400 : 502);
163
127
  return;
164
- if (!/^[A-Za-z0-9._~-]+$/.test(token) || token.length > 16384)
165
- throw new Error('Invalid platform token');
128
+ }
129
+ stripCloudHeaders(headers);
130
+ if (audience) {
131
+ const provider = this.options.identityTokenProvider ?? defaultIdentityTokenProvider;
132
+ const token = await provider({ audience: audience.origin, signal: this.controller.signal });
133
+ if (this.stopped)
134
+ return;
135
+ if (!/^[A-Za-z0-9._~-]+$/.test(token) || token.length > 16384)
136
+ throw new Error('Invalid platform token');
137
+ // Cloud Run removes this token's signature before passing it to user code.
138
+ // https://cloud.google.com/run/docs/authenticating/service-to-service
139
+ headers['x-serverless-authorization'] = `Bearer ${token}`;
140
+ }
166
141
  headers.host = upstream.host;
167
142
  // HTML forms receive release markers. Avoid compression when possible;
168
143
  // runtimes that still compress HTML are decoded as a bounded stream below.
169
144
  headers['accept-encoding'] = 'identity';
170
- // Cloud Run removes this token's signature before passing it to user code.
171
- // https://cloud.google.com/run/docs/authenticating/service-to-service
172
- headers['x-serverless-authorization'] = `Bearer ${token}`;
173
145
  headers['x-sequence-artifact-context'] = requestContext;
174
146
  const destination = this.resolveDestination(upstream);
175
147
  const transport = destination.protocol === 'https:' ? httpsRequest : httpRequest;
176
148
  this.outgoing = transport(destination, { method: request.method, path: request.url, headers, maxHeaderSize: 32 * 1024 }, (incoming) => this.receive({ incoming, upstream, publicOrigin }));
177
- this.outgoing.on('error', () => this.fail(502));
149
+ this.outgoing.on('error', () => this.fail(this.unavailableStatus));
178
150
  this.outgoing.once('upgrade', (_incoming, socket) => { socket.destroy(); this.fail(502); });
179
151
  this.outgoing.on('drain', () => { if (!this.stopped)
180
152
  this.upload?.resume(); });
@@ -182,8 +154,16 @@ class HostedExchange {
182
154
  this.upload.on('data', this.sendChunk);
183
155
  this.upload.once('end', () => this.outgoing?.end());
184
156
  this.upload.once('error', () => this.fail(502));
185
- response.on('drain', () => { if (!this.stopped)
186
- this.responseSource?.resume(); });
157
+ response.on('drain', () => {
158
+ clearTimeout(this.idleWriteTimer);
159
+ this.idleWriteTimer = undefined;
160
+ if (!this.stopped)
161
+ this.responseSource?.resume();
162
+ });
163
+ }
164
+ /** An unreachable app is an outage an ingress caller retries; a session keeps its 502. */
165
+ get unavailableStatus() {
166
+ return this.options.ingress ? 503 : 502;
187
167
  }
188
168
  resolveDestination(upstream) {
189
169
  const destination = this.options.resolveUpstream?.({ url: upstream }) ?? upstream;
@@ -197,7 +177,7 @@ class HostedExchange {
197
177
  if (this.stopped)
198
178
  return;
199
179
  this.sentBytes += chunk.byteLength;
200
- if (this.sentBytes > this.maximum) {
180
+ if (this.sentBytes > this.requestMaximum) {
201
181
  this.fail(413);
202
182
  return;
203
183
  }
@@ -211,29 +191,24 @@ class HostedExchange {
211
191
  return;
212
192
  }
213
193
  try {
194
+ // Read before sanitising, which removes content-length as a hop header.
195
+ const declaredLength = declaredContentLength(incoming.headers['content-length']);
214
196
  const headers = sanitizeResponseHeaders(incoming.headers);
215
197
  stripCloudHeaders(headers);
216
- delete headers['x-frame-options'];
217
- if (typeof headers.location === 'string')
218
- headers.location = rewriteLocation({ location: headers.location, upstream, publicOrigin });
219
198
  const response = this.options.response;
220
- this.responseSource = this.prepareResponseSource({ incoming, headers, publicOrigin });
221
- const framePolicy = response.getHeader('content-security-policy');
222
- if (headers['content-security-policy'] && (typeof framePolicy === 'string' || Array.isArray(framePolicy))) {
223
- headers['content-security-policy'] = mergeFramePolicy({ application: headers['content-security-policy'], trusted: framePolicy });
224
- }
225
- response.statusCode = incoming.statusCode ?? 502;
226
- for (const [name, value] of Object.entries(headers)) {
227
- const existing = name === 'set-cookie' ? response.getHeader(name) : undefined;
228
- if (typeof existing === 'string' || Array.isArray(existing)) {
229
- // Rolling gateway leases and application cookies share the response,
230
- // but application cookies were stripped of reserved names above.
231
- response.setHeader(name, [...(Array.isArray(existing) ? existing : [existing]), ...(Array.isArray(value) ? value : [value])]);
199
+ if (this.options.ingress) {
200
+ // Decided on the head, before any body byte; never the HTML release-marker path.
201
+ if (!writeIngressResponseHead({
202
+ response, status: incoming.statusCode ?? 502, method: this.options.request.method ?? 'GET', headers, declaredLength,
203
+ responses: this.options.ingress.responses, maxResponseBytes: this.responseMaximum,
204
+ })) {
205
+ this.fail(502);
206
+ return;
232
207
  }
233
- else
234
- response.setHeader(name, value);
208
+ this.responseSource = incoming;
235
209
  }
236
- enforceBrowserCachePolicy({ request: this.options.request, response });
210
+ else
211
+ this.responseSource = this.writeSessionResponseHead({ incoming, headers, upstream, publicOrigin });
237
212
  this.responseSource.on('data', this.receiveChunk);
238
213
  this.responseSource.once('end', () => response.end());
239
214
  this.responseSource.once('error', () => this.fail(502));
@@ -244,6 +219,30 @@ class HostedExchange {
244
219
  this.fail(502);
245
220
  }
246
221
  }
222
+ writeSessionResponseHead({ incoming, headers, upstream, publicOrigin }) {
223
+ delete headers['x-frame-options'];
224
+ if (typeof headers.location === 'string')
225
+ headers.location = rewriteLocation({ location: headers.location, upstream, publicOrigin });
226
+ const response = this.options.response;
227
+ const source = this.prepareResponseSource({ incoming, headers, publicOrigin });
228
+ const framePolicy = response.getHeader('content-security-policy');
229
+ if (headers['content-security-policy'] && (typeof framePolicy === 'string' || Array.isArray(framePolicy))) {
230
+ headers['content-security-policy'] = mergeFramePolicy({ application: headers['content-security-policy'], trusted: framePolicy });
231
+ }
232
+ response.statusCode = incoming.statusCode ?? 502;
233
+ for (const [name, value] of Object.entries(headers)) {
234
+ const existing = name === 'set-cookie' ? response.getHeader(name) : undefined;
235
+ if (typeof existing === 'string' || Array.isArray(existing)) {
236
+ // Rolling gateway leases and application cookies share the response,
237
+ // but application cookies were stripped of reserved names above.
238
+ response.setHeader(name, [...(Array.isArray(existing) ? existing : [existing]), ...(Array.isArray(value) ? value : [value])]);
239
+ }
240
+ else
241
+ response.setHeader(name, value);
242
+ }
243
+ enforceBrowserCachePolicy({ request: this.options.request, response });
244
+ return source;
245
+ }
247
246
  prepareResponseSource({ incoming, headers, publicOrigin }) {
248
247
  const contentType = headers['content-type'];
249
248
  if (typeof contentType !== 'string' || contentType.split(';', 1)[0].trim().toLowerCase() !== 'text/html')
@@ -269,7 +268,7 @@ class HostedExchange {
269
268
  source = source.pipe(decoder);
270
269
  }
271
270
  let decodedBytes = 0;
272
- const maximum = this.maximum;
271
+ const maximum = this.responseMaximum;
273
272
  const limit = new Transform({ transform(chunk, _encoding, callback) {
274
273
  decodedBytes += chunk.byteLength;
275
274
  if (decodedBytes > maximum)
@@ -287,12 +286,18 @@ class HostedExchange {
287
286
  if (this.stopped)
288
287
  return;
289
288
  this.receivedBytes += chunk.byteLength;
290
- if (this.receivedBytes > this.maximum) {
289
+ if (this.receivedBytes > this.responseMaximum) {
291
290
  this.fail(502);
292
291
  return;
293
292
  }
294
- if (!this.options.response.write(chunk))
295
- this.responseSource?.pause();
293
+ if (this.options.response.write(chunk))
294
+ return;
295
+ this.responseSource?.pause();
296
+ // Backpressure would otherwise hold the admission permit until the request timeout.
297
+ if (this.options.ingress && !this.idleWriteTimer) {
298
+ this.idleWriteTimer = setTimeout(() => this.fail(504), this.options.ingress.idleWriteTimeoutMs ?? INGRESS_PROXY_LIMITS.idleWriteTimeoutMs);
299
+ this.idleWriteTimer.unref();
300
+ }
296
301
  };
297
302
  fail(status) {
298
303
  if (this.stopped)
@@ -303,6 +308,8 @@ class HostedExchange {
303
308
  const response = this.options.response;
304
309
  if (response.headersSent)
305
310
  response.destroy();
311
+ else if (this.options.ingress)
312
+ this.options.ingress.errorWriter({ status, code: INGRESS_PROXY_ERROR_CODES[status] });
306
313
  else {
307
314
  // Do not send application cookies, encodings or redirects with a gateway error.
308
315
  for (const name of response.getHeaderNames()) {
@@ -321,6 +328,7 @@ class HostedExchange {
321
328
  return;
322
329
  this.stopped = true;
323
330
  clearTimeout(this.timer);
331
+ clearTimeout(this.idleWriteTimer);
324
332
  this.controller.abort();
325
333
  this.upload?.off('data', this.sendChunk);
326
334
  this.incoming?.off('data', this.receiveChunk);
@@ -0,0 +1,77 @@
1
+ import { type AdmissionRedis } from './admission.js';
2
+ /**
3
+ * Rate limiting and in-flight pools for the external ingress (design §(e) "Rate limiting and
4
+ * admission"). A separate instance from `GatewayControlAdmission`, with its own in-flight pools,
5
+ * so a flood of borrower exchanges can never return 503 on an Atlas user's `/_sequence/session`.
6
+ * The same fail-closed fixed-window counters: when Redis cannot answer, nothing is admitted.
7
+ */
8
+ export type IngressInFlightPool = 'ingress-public' | 'ingress-grant';
9
+ export interface IngressAdmissionLimits {
10
+ /** Fixed Redis-clock window shared by every class. */
11
+ windowMs: number;
12
+ preflight: {
13
+ perClient: number;
14
+ };
15
+ exchange: {
16
+ perClient: number;
17
+ perHost: number;
18
+ environment: number;
19
+ };
20
+ logout: {
21
+ perSubject: number;
22
+ perClient: number;
23
+ };
24
+ subject: {
25
+ perSubject: number;
26
+ };
27
+ app: {
28
+ perApp: number;
29
+ };
30
+ /** Outstanding Atlas calls one gateway process may hold per pool. */
31
+ pools: Record<IngressInFlightPool, number>;
32
+ }
33
+ /** The design's defaults; bindings may lower them in Atlas, never raise them here. */
34
+ export declare const INGRESS_ADMISSION_LIMITS: Readonly<IngressAdmissionLimits>;
35
+ /**
36
+ * The address an ingress caller is budgeted against: an IPv4 address as is, an IPv6 address as its
37
+ * /48 (design §(e)), since one IPv6 customer commonly controls a /48 or a /56.
38
+ */
39
+ export declare function ingressClientBucket(address: string): string;
40
+ export declare class IngressAdmission {
41
+ private readonly options;
42
+ private readonly namespace;
43
+ private readonly limits;
44
+ private readonly inFlight;
45
+ constructor(options: {
46
+ redis: AdmissionRedis;
47
+ namespace: string;
48
+ limits?: IngressAdmissionLimits;
49
+ });
50
+ /** `ingress-preflight`: per client address only, no in-flight permit (no Atlas call follows). */
51
+ preflight({ clientIp }: {
52
+ clientIp: string;
53
+ }): Promise<void>;
54
+ /** `ingress-exchange`: per client, per host and environment-wide; holds an `ingress-public` permit. */
55
+ exchange({ clientIp, host }: {
56
+ clientIp: string;
57
+ host: string;
58
+ }): Promise<() => void>;
59
+ /** `ingress-logout`: per subject and per client, after the pass verified; holds an `ingress-public` permit. */
60
+ logout({ provider, subjectHash, clientIp }: {
61
+ provider: string;
62
+ subjectHash: string;
63
+ clientIp: string;
64
+ }): Promise<() => void>;
65
+ /** `ingress-subject` and `ingress-app`, on verified pass fields; plain counters, no in-flight permit. */
66
+ request({ provider, subjectHash, projectId, ingressId }: {
67
+ provider: string;
68
+ subjectHash: string;
69
+ projectId: string;
70
+ ingressId: string;
71
+ }): Promise<void>;
72
+ /** Runs one Atlas call under a permit from `pool`; a full pool is 503, never a queue. */
73
+ withPool<T>(pool: IngressInFlightPool, work: () => Promise<T>): Promise<T>;
74
+ private withPermit;
75
+ private take;
76
+ private count;
77
+ }