@sequenceholdings/artifact-studio 0.6.4 → 0.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +183 -6
  2. package/VERSION-PIN.md +56 -1
  3. package/dist/gateway/admission.d.ts +23 -1
  4. package/dist/gateway/admission.js +30 -13
  5. package/dist/gateway/control.d.ts +40 -0
  6. package/dist/gateway/control.js +76 -7
  7. package/dist/gateway/hosted-proxy.d.ts +11 -8
  8. package/dist/gateway/hosted-proxy.js +99 -91
  9. package/dist/gateway/ingress-admission.d.ts +77 -0
  10. package/dist/gateway/ingress-admission.js +150 -0
  11. package/dist/gateway/ingress-audit.d.ts +46 -0
  12. package/dist/gateway/ingress-audit.js +84 -0
  13. package/dist/gateway/ingress-context.d.ts +57 -0
  14. package/dist/gateway/ingress-context.js +57 -0
  15. package/dist/gateway/ingress-development.d.ts +12 -0
  16. package/dist/gateway/ingress-development.js +23 -0
  17. package/dist/gateway/ingress-handler.d.ts +44 -0
  18. package/dist/gateway/ingress-handler.js +320 -0
  19. package/dist/gateway/ingress-pass.d.ts +83 -0
  20. package/dist/gateway/ingress-pass.js +143 -0
  21. package/dist/gateway/ingress-response.d.ts +74 -0
  22. package/dist/gateway/ingress-response.js +98 -0
  23. package/dist/gateway/ingress-runtime.d.ts +86 -0
  24. package/dist/gateway/ingress-runtime.js +126 -0
  25. package/dist/gateway/ingress-snapshot.d.ts +54 -0
  26. package/dist/gateway/ingress-snapshot.js +63 -0
  27. package/dist/gateway/ingress-stages.d.ts +60 -0
  28. package/dist/gateway/ingress-stages.js +387 -0
  29. package/dist/gateway/ingress.d.ts +259 -0
  30. package/dist/gateway/ingress.js +204 -0
  31. package/dist/gateway/lattice-invoke.js +2 -0
  32. package/dist/gateway/lattice-upstream.d.ts +3 -4
  33. package/dist/gateway/lattice-upstream.js +11 -9
  34. package/dist/gateway/runtime-transport.d.ts +17 -0
  35. package/dist/gateway/runtime-transport.js +26 -0
  36. package/dist/gateway/server.d.ts +3 -0
  37. package/dist/gateway/server.js +21 -0
  38. package/dist/gateway/session-response.d.ts +15 -0
  39. package/dist/gateway/session-response.js +40 -0
  40. package/dist/gateway/usage.d.ts +8 -1
  41. package/dist/gateway-main.js +32 -8
  42. package/dist/manifest-next-ingress.d.ts +5 -2
  43. package/dist/manifest-next-ingress.js +27 -2
  44. package/dist/manifest-next.d.ts +20 -6
  45. package/dist/manifest-next.js +13 -5
  46. package/dist/manifest.d.ts +7 -2
  47. package/dist/next-project.d.ts +4 -0
  48. package/dist/sdk.d.ts +12 -3
  49. package/dist/server/broker-contract.d.ts +62 -0
  50. package/dist/server/broker-contract.js +43 -1
  51. package/dist/server/client.js +38 -13
  52. package/dist/server/external-context.d.ts +8 -1
  53. package/dist/server/external-context.js +7 -2
  54. package/dist/server/internal/v1-context.js +17 -1
  55. package/dist/server/internal/verify-any.d.ts +11 -2
  56. package/dist/server/internal/verify-any.js +11 -3
  57. package/dist/server/process-run-sharing.d.ts +86 -0
  58. package/dist/server/process-run-sharing.js +29 -0
  59. package/dist/server/process-runs-page.d.ts +39 -0
  60. package/dist/server/process-runs-page.js +10 -0
  61. package/dist/server/processes.d.ts +8 -9
  62. package/dist/server/processes.js +4 -0
  63. package/dist/server/request-context.js +10 -3
  64. package/dist/server/runtime-config.d.ts +10 -1
  65. package/dist/server/runtime-config.js +11 -1
  66. package/dist/server/testing.d.ts +1 -1
  67. package/dist/server.d.ts +2 -2
  68. package/dist/server.js +1 -1
  69. package/dist/tunnel/broker.d.ts +18 -0
  70. package/dist/tunnel/broker.js +175 -17
  71. package/dist/tunnel/client.d.ts +13 -0
  72. package/dist/tunnel/client.js +40 -2
  73. package/dist/tunnel/external.d.ts +51 -0
  74. package/dist/tunnel/external.js +10 -0
  75. package/dist/tunnel/index.d.ts +1 -1
  76. package/dist/tunnel/protocol.d.ts +20 -2
  77. package/dist/tunnel/protocol.js +42 -6
  78. package/package.json +4 -4
@@ -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
+ }
@@ -0,0 +1,150 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { isIPv6 } from 'node:net';
3
+ import { z } from 'zod';
4
+ import { redisNamespaceSchema } from '../tunnel/redis-ownership.js';
5
+ import { ArtifactAdmissionError } from './admission.js';
6
+ /** The design's defaults; bindings may lower them in Atlas, never raise them here. */
7
+ export const INGRESS_ADMISSION_LIMITS = Object.freeze({
8
+ windowMs: 10_000,
9
+ // Sized for many borrowers behind one carrier-grade NAT; no Atlas call follows a preflight.
10
+ preflight: Object.freeze({ perClient: 600 }),
11
+ exchange: Object.freeze({ perClient: 30, perHost: 300, environment: 3000 }),
12
+ logout: Object.freeze({ perSubject: 5, perClient: 30 }),
13
+ subject: Object.freeze({ perSubject: 60 }),
14
+ app: Object.freeze({ perApp: 2000 }),
15
+ pools: Object.freeze({ 'ingress-public': 32, 'ingress-grant': 64 }),
16
+ });
17
+ const limit = z.number().int().min(1).max(1_000_000);
18
+ const limitsSchema = z.strictObject({
19
+ windowMs: z.number().int().min(1_000).max(3_600_000),
20
+ preflight: z.strictObject({ perClient: limit }),
21
+ exchange: z.strictObject({ perClient: limit, perHost: limit, environment: limit }),
22
+ logout: z.strictObject({ perSubject: limit, perClient: limit }),
23
+ subject: z.strictObject({ perSubject: limit }),
24
+ app: z.strictObject({ perApp: limit }),
25
+ pools: z.strictObject({ 'ingress-public': z.number().int().min(1).max(10_000), 'ingress-grant': z.number().int().min(1).max(10_000) }),
26
+ });
27
+ // Every key of one call shares the `{namespace}` hash tag, so the script stays single-slot on Redis
28
+ // Cluster. Limits are ARGV[1..n], the window ARGV[n+1]. A refused attempt consumes nothing, so one
29
+ // noisy client cannot spend a host's or the environment's budget.
30
+ const ADMIT = `-- artifact-gateway-ingress-admission-v1
31
+ local window = tonumber(ARGV[#KEYS + 1])
32
+ for index, key in ipairs(KEYS) do
33
+ if tonumber(redis.call('GET', key) or '0') >= tonumber(ARGV[index]) then
34
+ local ttl = redis.call('PTTL', key)
35
+ if ttl < 0 then
36
+ redis.call('PEXPIRE', key, window)
37
+ ttl = window
38
+ end
39
+ return {0, ttl}
40
+ end
41
+ end
42
+ for _, key in ipairs(KEYS) do
43
+ if redis.call('INCR', key) == 1 or redis.call('PTTL', key) < 0 then redis.call('PEXPIRE', key, window) end
44
+ end
45
+ return {1, 0}`;
46
+ const replySchema = z.tuple([z.number().int().min(0).max(1), z.number().int().min(0)]);
47
+ /** A bounded, opaque Redis key segment: no caller-chosen bytes reach a key. */
48
+ function keyPart(...values) {
49
+ return createHash('sha256').update(values.join('\u0000')).digest('hex').slice(0, 32);
50
+ }
51
+ /**
52
+ * The address an ingress caller is budgeted against: an IPv4 address as is, an IPv6 address as its
53
+ * /48 (design §(e)), since one IPv6 customer commonly controls a /48 or a /56.
54
+ */
55
+ export function ingressClientBucket(address) {
56
+ const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(address);
57
+ if (mapped)
58
+ return mapped[1];
59
+ if (!isIPv6(address))
60
+ return address;
61
+ const [head = '', tail] = address.toLowerCase().split('::');
62
+ const leading = head ? head.split(':') : [];
63
+ const trailing = tail ? tail.split(':') : [];
64
+ const groups = tail === undefined ? leading : [...leading, ...Array(Math.max(0, 8 - leading.length - trailing.length)).fill('0'), ...trailing];
65
+ return `${groups.slice(0, 3).map((group) => group.padStart(4, '0')).join(':')}::/48`;
66
+ }
67
+ export class IngressAdmission {
68
+ options;
69
+ namespace;
70
+ limits;
71
+ inFlight = { 'ingress-public': 0, 'ingress-grant': 0 };
72
+ constructor(options) {
73
+ this.options = options;
74
+ this.namespace = redisNamespaceSchema.parse(options.namespace);
75
+ this.limits = limitsSchema.parse(options.limits ?? INGRESS_ADMISSION_LIMITS);
76
+ const policy = options.redis.options;
77
+ if (policy.enableOfflineQueue !== false || policy.autoResendUnfulfilledCommands !== false || policy.maxRetriesPerRequest !== 0) {
78
+ throw new Error('Ingress admission Redis must disable offline queues, command replay and retries');
79
+ }
80
+ }
81
+ /** `ingress-preflight`: per client address only, no in-flight permit (no Atlas call follows). */
82
+ async preflight({ clientIp }) {
83
+ await this.count({ name: 'preflight', counters: [[`client:${keyPart(ingressClientBucket(clientIp))}`, this.limits.preflight.perClient]] });
84
+ }
85
+ /** `ingress-exchange`: per client, per host and environment-wide; holds an `ingress-public` permit. */
86
+ async exchange({ clientIp, host }) {
87
+ const { perClient, perHost, environment } = this.limits.exchange;
88
+ return this.withPermit('ingress-public', () => this.count({ name: 'exchange', counters: [
89
+ [`client:${keyPart(ingressClientBucket(clientIp))}`, perClient], [`host:${keyPart(host)}`, perHost], ['environment', environment],
90
+ ] }));
91
+ }
92
+ /** `ingress-logout`: per subject and per client, after the pass verified; holds an `ingress-public` permit. */
93
+ async logout({ provider, subjectHash, clientIp }) {
94
+ const { perSubject, perClient } = this.limits.logout;
95
+ return this.withPermit('ingress-public', () => this.count({ name: 'logout', counters: [
96
+ [`subject:${keyPart(provider, subjectHash)}`, perSubject], [`client:${keyPart(ingressClientBucket(clientIp))}`, perClient],
97
+ ] }));
98
+ }
99
+ /** `ingress-subject` and `ingress-app`, on verified pass fields; plain counters, no in-flight permit. */
100
+ async request({ provider, subjectHash, projectId, ingressId }) {
101
+ await this.count({ name: 'request', counters: [
102
+ [`subject:${keyPart(provider, subjectHash)}`, this.limits.subject.perSubject], [`app:${keyPart(projectId, ingressId)}`, this.limits.app.perApp],
103
+ ] });
104
+ }
105
+ /** Runs one Atlas call under a permit from `pool`; a full pool is 503, never a queue. */
106
+ async withPool(pool, work) {
107
+ const release = this.take(pool);
108
+ try {
109
+ return await work();
110
+ }
111
+ finally {
112
+ release();
113
+ }
114
+ }
115
+ async withPermit(pool, admit) {
116
+ const release = this.take(pool);
117
+ try {
118
+ await admit();
119
+ return release;
120
+ }
121
+ catch (error) {
122
+ release();
123
+ throw error;
124
+ }
125
+ }
126
+ take(pool) {
127
+ if (this.inFlight[pool] >= this.limits.pools[pool])
128
+ throw new ArtifactAdmissionError(503, 'admission_unavailable');
129
+ this.inFlight[pool]++;
130
+ let released = false;
131
+ return () => {
132
+ if (released)
133
+ return;
134
+ released = true;
135
+ this.inFlight[pool]--;
136
+ };
137
+ }
138
+ async count({ name, counters }) {
139
+ const prefix = `artifact-gateway-ingress:{${this.namespace}}:${name}`;
140
+ let reply;
141
+ try {
142
+ reply = replySchema.parse(await this.options.redis.eval(ADMIT, counters.length, ...counters.map(([key]) => `${prefix}:${key}`), ...counters.map(([, maximum]) => maximum), this.limits.windowMs));
143
+ }
144
+ catch {
145
+ throw new ArtifactAdmissionError(503, 'admission_unavailable');
146
+ }
147
+ if (reply[0] !== 1)
148
+ throw new ArtifactAdmissionError(429, 'capacity_exceeded', Math.max(1, Math.ceil(reply[1] / 1000)));
149
+ }
150
+ }
@@ -0,0 +1,46 @@
1
+ import { type IngressAuditBatch, type IngressAuditEvent } from '../server/ingress-control-contract.js';
2
+ export declare const INGRESS_AUDIT_FLUSH_INTERVAL_MS = 5000;
3
+ /** Refusals buffered between flushes; past this they are dropped and counted, never queued without bound. */
4
+ export declare const INGRESS_AUDIT_CAPACITY = 5000;
5
+ /** Shutdown spends at most this on the final batch, so audit delivery never holds up tunnel cleanup. */
6
+ export declare const INGRESS_AUDIT_CLOSE_BUDGET_MS = 2000;
7
+ export type IngressAuditFailure = {
8
+ kind: 'ingress_audit_dropped' | 'ingress_audit_delivery_failed';
9
+ count: number;
10
+ };
11
+ /**
12
+ * Batches the refusals the gateway decided itself (edge, client address, CORS, a refused authentic
13
+ * pass, freshness) to `control/ingress-audit` every 5 s, at most 500 per call. Garbage and
14
+ * admission refusals are counted elsewhere and never reach it. Refusals an Atlas control
15
+ * route returned are never recorded here: Atlas already audited them. Best effort and bounded: a
16
+ * refusal flood can cost Atlas at most one batch per interval per gateway process, and a failed
17
+ * delivery is counted, not retried into a backlog.
18
+ */
19
+ export declare class IngressDenialAuditor {
20
+ private readonly options;
21
+ private buffer;
22
+ private dropped;
23
+ private timer;
24
+ private flushing;
25
+ private readonly intervalMs;
26
+ private readonly capacity;
27
+ constructor(options: {
28
+ send: (batch: IngressAuditBatch) => Promise<void>;
29
+ onFailure?: (failure: IngressAuditFailure) => void;
30
+ intervalMs?: number;
31
+ capacity?: number;
32
+ closeBudgetMs?: number;
33
+ });
34
+ record(event: IngressAuditEvent): void;
35
+ /** Flushes every interval; the timer never keeps the process alive. */
36
+ start(): void;
37
+ /** One batch of at most 500 refusals, single-flight. Never rejects. */
38
+ flush(): Promise<void>;
39
+ /**
40
+ * Stops the timer and makes one final delivery attempt within the close budget: a slow or failing
41
+ * Atlas costs shutdown at most that long. Whatever is still buffered is dropped and counted.
42
+ */
43
+ close(): Promise<void>;
44
+ private deliver;
45
+ private reportDropped;
46
+ }