@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,387 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { z } from 'zod';
3
+ import { artifactNextIngressIdSchema } from '../manifest-next-ingress.js';
4
+ import { INGRESS_ROUTE_METHODS } from '../server/ingress-routes.js';
5
+ import { ArtifactAdmissionError } from './admission.js';
6
+ import { PositiveDecisionCache } from './authorization-cache.js';
7
+ import { IngressControlError } from './control.js';
8
+ import { ingressClientBucket } from './ingress-admission.js';
9
+ import { externalSubject, observeUserAgent, signExternalGatewayContext } from './ingress-context.js';
10
+ import { IngressPassTooLargeError, mintPass, verifyPass } from './ingress-pass.js';
11
+ import { INGRESS_RESPONSE_SECURITY_HEADERS } from './ingress-response.js';
12
+ import { ingressCorsHeaders, IngressError, } from './ingress.js';
13
+ /**
14
+ * The authenticated half of the external ingress (design "First call" steps 3-5, "Steady state"
15
+ * steps 4-8, §(d), §(e) "Rate limiting and admission"): pass exchange, logout, and for API routes
16
+ * local pass verification, admission, grant, freshness, concurrency and the signed v2 context.
17
+ * Order matters: nothing a forged credential presents reaches Redis or Atlas, and garbage is only
18
+ * ever counted, never audited (design §(g) "Gateway denials").
19
+ */
20
+ /** Exchange request bodies above this are refused unread past the limit, before admission. */
21
+ export const INGRESS_EXCHANGE_MAX_BODY_BYTES = 1024;
22
+ /** Routes with a body cap at or below this have the body buffered before a concurrency permit. */
23
+ export const INGRESS_BUFFERED_BODY_MAX_BYTES = 1024 * 1024;
24
+ /** A grant-triggered snapshot pull happens at most this often. */
25
+ const POLICY_REFRESH_MIN_INTERVAL_MS = 1_000;
26
+ /** Only GET reads reuse a grant; HEAD never matches a declared route (INGRESS_ROUTE_METHODS). */
27
+ const READ_METHODS = new Set(['GET']);
28
+ /** Pass refusals whose MAC held: an authentic pass refused by a decision, audited. The rest is garbage. */
29
+ const AUTHENTIC_PASS_REFUSALS = new Set(['expired', 'not_yet_valid', 'wrong_origin', 'wrong_environment']);
30
+ const exchangeBodySchema = z.strictObject({ ingress: artifactNextIngressIdSchema });
31
+ export function subjectHashOf(subject) {
32
+ return createHash('sha256').update(subject).digest('hex');
33
+ }
34
+ /**
35
+ * A refused pass (design "Steady state" step 4): always counted; audited only when its MAC held,
36
+ * so a forged pass costs a local HMAC and a counter increment, never an Atlas audit write.
37
+ */
38
+ function passRefused({ reason, authentic, detail = {} }) {
39
+ return new IngressError(401, 'invalid_token', { metric: 'ingress_pass_rejected', ...(authentic ? { audit: { reason, ...detail } } : {}) });
40
+ }
41
+ /** An identity token refused by the structural precheck: garbage, counted and never audited. */
42
+ function tokenRefused() {
43
+ return new IngressError(401, 'invalid_token', { metric: 'ingress_token_rejected' });
44
+ }
45
+ /** The single `Authorization: Bearer <credential>` value, or null. The handler already refused repeats and oversize. */
46
+ function bearer(request) {
47
+ const value = request.headers.authorization;
48
+ const match = typeof value === 'string' ? /^Bearer ([A-Za-z0-9._~+/=-]+)$/i.exec(value) : null;
49
+ return match ? match[1] : null;
50
+ }
51
+ function decodeJsonSegment(segment) {
52
+ try {
53
+ return JSON.parse(Buffer.from(segment, 'base64url').toString('utf8'));
54
+ }
55
+ catch {
56
+ return undefined;
57
+ }
58
+ }
59
+ const jwsHeaderSchema = z.object({ alg: z.string(), kid: z.string().min(1).max(256) });
60
+ const jwsPayloadSchema = z.object({ iss: z.string(), exp: z.number().int().positive().optional() });
61
+ /**
62
+ * The structural precheck before any Redis or Atlas call (design "First call" step 3): three
63
+ * base64url segments, an `alg` among the provider's, a `kid`, and the provider's `iss`. No crypto
64
+ * and no JWKS: Atlas verifies the token. Returns the token's `exp`, which Atlas verified with it.
65
+ */
66
+ export function precheckIdentityToken({ token, provider }) {
67
+ const parts = token.split('.');
68
+ if (parts.length !== 3 || !parts.every((part) => /^[A-Za-z0-9_-]+$/.test(part)))
69
+ return null;
70
+ const header = jwsHeaderSchema.safeParse(decodeJsonSegment(parts[0]));
71
+ const payload = jwsPayloadSchema.safeParse(decodeJsonSegment(parts[1]));
72
+ if (!header.success || !payload.success)
73
+ return null;
74
+ if (!provider.algorithms.includes(header.data.alg) || payload.data.iss !== provider.issuer)
75
+ return null;
76
+ return { exp: payload.data.exp ?? null };
77
+ }
78
+ /** The body, read up to `maxBytes`; beyond it 413 with nothing more read. */
79
+ async function readBounded({ request, maxBytes }) {
80
+ const declared = Number(request.headers['content-length']);
81
+ if (Number.isFinite(declared) && declared > maxBytes)
82
+ throw new IngressError(413, 'payload_too_large');
83
+ const chunks = [];
84
+ let length = 0;
85
+ for await (const chunk of request) {
86
+ const bytes = Buffer.from(chunk);
87
+ length += bytes.length;
88
+ if (length > maxBytes)
89
+ throw new IngressError(413, 'payload_too_large');
90
+ chunks.push(bytes);
91
+ }
92
+ return Buffer.concat(chunks);
93
+ }
94
+ /** A refusal an Atlas control route returned, as the caller sees it; never audited by the gateway. */
95
+ function fromControl(error) {
96
+ if (error instanceof IngressControlError && error.code !== null) {
97
+ return new IngressError(error.status, error.code, error.retryAfterSeconds === undefined ? {} : { retryAfterSeconds: error.retryAfterSeconds });
98
+ }
99
+ if (error instanceof ArtifactAdmissionError)
100
+ return fromAdmission(error);
101
+ // A bare failure, a timeout or an unparseable answer: an outage the caller may retry.
102
+ return new IngressError(503, 'temporarily_unavailable', error instanceof IngressControlError && error.retryAfterSeconds !== undefined ? { retryAfterSeconds: error.retryAfterSeconds } : {});
103
+ }
104
+ /** A rate limit or a Redis outage: a metric, never an audit event (a flood or an outage would become one per request). */
105
+ function fromAdmission(error) {
106
+ return error.status === 429
107
+ ? new IngressError(429, 'rate_limited', { retryAfterSeconds: error.retryAfterSeconds ?? 1, metric: 'admission_rejected' })
108
+ : new IngressError(503, 'temporarily_unavailable', { retryAfterSeconds: error.retryAfterSeconds ?? 2, metric: 'admission_rejected' });
109
+ }
110
+ async function admitted(work) {
111
+ try {
112
+ return await work();
113
+ }
114
+ catch (error) {
115
+ if (error instanceof ArtifactAdmissionError)
116
+ throw fromAdmission(error);
117
+ throw error;
118
+ }
119
+ }
120
+ /** A buffered body as the stream the proxy forwards. */
121
+ async function* replay(body) {
122
+ if (body.length > 0)
123
+ yield body;
124
+ }
125
+ function writeJson({ response, status, body, corsOrigin }) {
126
+ response.writeHead(status, {
127
+ ...INGRESS_RESPONSE_SECURITY_HEADERS,
128
+ ...(body === undefined ? {} : { 'content-type': 'application/json' }),
129
+ ...(corsOrigin ? ingressCorsHeaders(corsOrigin) : {}),
130
+ });
131
+ response.end(body === undefined ? undefined : JSON.stringify(body));
132
+ }
133
+ /** The caller's origin for CORS on a success, or a 403 when this ingress does not allow it. */
134
+ function ingressOrigin({ origin, ingress }) {
135
+ if (origin === null)
136
+ return null;
137
+ if (!ingress.allowedOrigins.includes(origin))
138
+ throw new IngressError(403, 'forbidden', { audit: { reason: 'origin_not_allowed', ingress: ingress.id } });
139
+ return origin;
140
+ }
141
+ function grantCacheKey(parts) {
142
+ return createHash('sha256').update(JSON.stringify(['ingress-grant-v1', ...parts])).digest('hex');
143
+ }
144
+ export function createIngressStages(options) {
145
+ const now = options.now ?? Date.now;
146
+ const seconds = () => Math.floor(now() / 1000);
147
+ const grants = new PositiveDecisionCache({ ttlMs: options.grantCacheTtlMs, now });
148
+ let lastPolicyRefresh = Number.NEGATIVE_INFINITY;
149
+ /** Local pass verification (design "Steady state" step 4): no Redis, no Atlas; failure is 401, uncharged. */
150
+ function verifiedPass({ request, publicOrigin, entry }) {
151
+ const token = bearer(request);
152
+ if (!token)
153
+ throw passRefused({ reason: 'pass_missing', authentic: false });
154
+ const verification = verifyPass({ keyring: options.keyring, token, now: seconds(), origin: publicOrigin, env: options.appEnv });
155
+ if (!verification.ok)
156
+ throw passRefused({ reason: `pass_${verification.reason}`, authentic: AUTHENTIC_PASS_REFUSALS.has(verification.reason) });
157
+ const { payload } = verification;
158
+ const ingress = entry.ingresses.find((candidate) => candidate.id === payload.ingress && candidate.provider === payload.provider);
159
+ if (!ingress || payload.projectId !== entry.projectId)
160
+ throw passRefused({ reason: 'pass_wrong_ingress', authentic: true, detail: { passId: payload.jti } });
161
+ return { pass: payload, kid: verification.kid, ingress };
162
+ }
163
+ async function preflight({ clientIp }) {
164
+ await admitted(() => options.ingressAdmission.preflight({ clientIp }));
165
+ }
166
+ async function exchange(input) {
167
+ const { request, response, host, publicOrigin, entry, clientIp, origin, requestId } = input;
168
+ // Read, bounded, before admission (design §(e) "Limits").
169
+ if (request.headers['content-type']?.split(';', 1)[0]?.trim().toLowerCase() !== 'application/json')
170
+ throw new IngressError(400, 'bad_request');
171
+ const raw = await readBounded({ request, maxBytes: INGRESS_EXCHANGE_MAX_BODY_BYTES });
172
+ let parsedBody;
173
+ try {
174
+ parsedBody = JSON.parse(raw.toString('utf8'));
175
+ }
176
+ catch {
177
+ throw new IngressError(400, 'bad_request');
178
+ }
179
+ const body = exchangeBodySchema.safeParse(parsedBody);
180
+ if (!body.success)
181
+ throw new IngressError(400, 'bad_request');
182
+ const ingress = entry.ingresses.find((candidate) => candidate.id === body.data.ingress);
183
+ if (!ingress)
184
+ throw new IngressError(404, 'not_found');
185
+ const corsOrigin = ingressOrigin({ origin, ingress });
186
+ const token = bearer(request);
187
+ if (!token)
188
+ throw tokenRefused();
189
+ const provider = options.snapshot.providers?.[ingress.provider];
190
+ if (!provider)
191
+ throw new IngressError(503, 'temporarily_unavailable');
192
+ const precheck = precheckIdentityToken({ token, provider });
193
+ if (!precheck)
194
+ throw tokenRefused();
195
+ const release = await admitted(() => options.ingressAdmission.exchange({ clientIp, host }));
196
+ let verified;
197
+ try {
198
+ verified = await options.control.ingressExchange({
199
+ host, ingress: ingress.id, origin, token, clientIp, userAgent: observeUserAgent(request.headers['user-agent']).userAgent, requestId,
200
+ });
201
+ }
202
+ catch (error) {
203
+ throw fromControl(error);
204
+ }
205
+ finally {
206
+ release();
207
+ }
208
+ const { principal } = verified;
209
+ // The context's `sub` is `ext|<provider>|<subject>`, at most 255 characters (design §(f)).
210
+ const denied = (reason) => new IngressError(403, 'exchange_denied', { audit: { reason, ingress: ingress.id, subjectHash: subjectHashOf(principal.subject) } });
211
+ if (externalSubject({ provider: ingress.provider, subject: principal.subject }).length > 255)
212
+ throw denied('subject_too_long');
213
+ const iat = seconds();
214
+ const exp = Math.min(precheck.exp ?? Number.POSITIVE_INFINITY, iat + verified.passTtlSeconds);
215
+ if (exp <= iat)
216
+ throw new IngressError(401, 'invalid_token', { audit: { reason: 'token_expired', ingress: ingress.id } });
217
+ let pass;
218
+ try {
219
+ pass = mintPass({ keyring: options.keyring, payload: {
220
+ v: 1, typ: 'artifact-ingress-pass', env: options.appEnv, origin: publicOrigin, projectId: entry.projectId,
221
+ ingress: ingress.id, provider: ingress.provider, bep: verified.bindingEpoch, ...principal,
222
+ exchangeIp: clientIp, jti: randomUUID(), iat, exp,
223
+ } });
224
+ }
225
+ catch (error) {
226
+ // A principal near the schema maxima never fits: a decided refusal, not an outage to retry.
227
+ if (error instanceof IngressPassTooLargeError)
228
+ throw denied('pass_too_large');
229
+ throw error;
230
+ }
231
+ // `expiresAt` is epoch milliseconds, as the BAP's other token clocks are.
232
+ writeJson({ response, status: 200, body: { token: pass, expiresAt: exp * 1000 }, corsOrigin });
233
+ }
234
+ async function logout(input) {
235
+ const { response, host, clientIp, origin } = input;
236
+ const { pass, ingress } = verifiedPass(input);
237
+ const corsOrigin = ingressOrigin({ origin, ingress });
238
+ const subjectHash = subjectHashOf(pass.subject);
239
+ const release = await admitted(() => options.ingressAdmission.logout({ provider: pass.provider, subjectHash, clientIp }));
240
+ try {
241
+ await options.control.ingressRevoke({ host, ingress: ingress.id, provider: pass.provider, subject: pass.subject, passJti: pass.jti, tokenIssuedAt: pass.tokenIssuedAt });
242
+ }
243
+ catch (error) {
244
+ throw fromControl(error);
245
+ }
246
+ finally {
247
+ release();
248
+ }
249
+ writeJson({ response, status: 204, corsOrigin });
250
+ }
251
+ /** A grant for this exact (route, method), cached ≤ 5 s for reads only, under an `ingress-grant` permit. */
252
+ async function grantFor({ input, pass, subjectHash }) {
253
+ const method = input.request.method ?? 'GET';
254
+ const body = {
255
+ host: input.host, ingress: input.ingress.id, provider: input.ingress.provider, subjectHash, tokenIssuedAt: pass.tokenIssuedAt,
256
+ passJti: pass.jti, method: z.enum(INGRESS_ROUTE_METHODS).parse(method), routeId: input.match.routeId,
257
+ };
258
+ // The snapshot's target is part of the key: after an `artifact dev` restart a refreshed
259
+ // snapshot names the new session, so a read grant cached for the old one must miss.
260
+ const { target } = input.entry;
261
+ const lookup = {
262
+ key: grantCacheKey([body.host, body.ingress, subjectHash, body.passJti, body.routeId, body.method, target.kind === 'development' ? `development:${target.sessionId}` : 'published']),
263
+ load: () => options.ingressAdmission.withPool('ingress-grant', () => options.control.ingressGrant(body)),
264
+ notAfter: (grant) => grant.expiresAt,
265
+ };
266
+ try {
267
+ // Mutations always ask Atlas: revocation, a disabled binding or a new release stop them at once.
268
+ return READ_METHODS.has(method) ? await grants.get(lookup) : await grants.refresh(lookup);
269
+ }
270
+ catch (error) {
271
+ throw fromControl(error);
272
+ }
273
+ }
274
+ /** Pulls the snapshot now, at most once a second. A cache-buster only, never a reason to deny. */
275
+ function refreshSnapshot() {
276
+ if (now() - lastPolicyRefresh < POLICY_REFRESH_MIN_INTERVAL_MS)
277
+ return;
278
+ lastPolicyRefresh = now();
279
+ void options.snapshot.refresh();
280
+ }
281
+ /**
282
+ * The grant's release, or 503 when it does not match the snapshot entry it was asked for: a
283
+ * published grant only for a published target, and a development grant only on a development
284
+ * gateway (its own APP_ENV, never a value from the request or Atlas), for the snapshot's own
285
+ * development session (design §(j.3) steps 3-4). Never proxied over either session id alone:
286
+ * after an `artifact dev` restart inside one poll the two differ, so the request is refused and
287
+ * the snapshot pulled at once.
288
+ */
289
+ function releaseOf({ grant, entry }) {
290
+ const unavailable = () => { refreshSnapshot(); return new IngressError(503, 'temporarily_unavailable'); };
291
+ if (grant.mode === 'published') {
292
+ if (entry.target.kind !== 'published')
293
+ throw unavailable();
294
+ return { mode: 'published', deploymentId: grant.deploymentId };
295
+ }
296
+ if (options.appEnv !== 'development')
297
+ throw new IngressError(503, 'temporarily_unavailable');
298
+ if (entry.target.kind !== 'development' || entry.target.sessionId !== grant.devSessionId)
299
+ throw unavailable();
300
+ return { mode: 'development', devSessionId: grant.devSessionId };
301
+ }
302
+ /** Route freshness (design §(h)): `fresh_auth_seconds` against the pass's sign-in time, else a step-up 401. */
303
+ function requireFreshAuth({ pass, freshness, detail }) {
304
+ if (freshness > 0 && (pass.authTime === null || seconds() - pass.authTime > freshness)) {
305
+ throw new IngressError(401, 'insufficient_user_authentication', {
306
+ wwwAuthenticate: `Bearer error="insufficient_user_authentication", max_age=${freshness}`,
307
+ audit: { reason: 'step_up_required', ...detail },
308
+ });
309
+ }
310
+ }
311
+ /** A grant for this request that a context may still be signed with, and its release, else 503 before signing. */
312
+ async function usableGrant({ input, pass, detail }) {
313
+ const grant = await grantFor({ input, pass, subjectHash: detail.subjectHash });
314
+ if (grant.projectId !== input.entry.projectId || grant.expiresAt <= now())
315
+ throw new IngressError(503, 'temporarily_unavailable');
316
+ if (grant.policyEpoch > (options.snapshot.policyEpoch ?? 0))
317
+ refreshSnapshot();
318
+ const release = releaseOf({ grant, entry: input.entry });
319
+ // A deployment that does not trust the gateway's current kid would refuse the context: 503 before signing.
320
+ if (!grant.trustedExternalKids.includes(options.externalContext.keyId))
321
+ throw new IngressError(503, 'temporarily_unavailable', { audit: { reason: 'untrusted_external_kid', ...detail } });
322
+ return { grant, release };
323
+ }
324
+ async function admit(input) {
325
+ const { request, entry, ingress, match, clientIp, origin, requestId, signedPath } = input;
326
+ const { pass, kid: passKid, ingress: passIngress } = verifiedPass(input);
327
+ // A pass is good for its own ingress's routes only, whatever other ingress the host carries.
328
+ if (passIngress.id !== ingress.id)
329
+ throw passRefused({ reason: 'pass_wrong_ingress', authentic: true, detail: { ingress: ingress.id, passId: pass.jti } });
330
+ const subjectHash = subjectHashOf(pass.subject);
331
+ const detail = { ingress: ingress.id, subjectHash, passId: pass.jti };
332
+ const freshness = match.route.fresh_auth_seconds ?? 0;
333
+ // Freshness needs only the verified pass: refused early, before any Redis count or Atlas grant.
334
+ requireFreshAuth({ pass, freshness, detail });
335
+ await admitted(() => options.ingressAdmission.request({ provider: pass.provider, subjectHash, projectId: entry.projectId, ingressId: ingress.id }));
336
+ let { grant, release } = await usableGrant({ input, pass, detail });
337
+ const maxBodyBytes = match.route.max_body_bytes;
338
+ let body;
339
+ if (maxBodyBytes <= INGRESS_BUFFERED_BODY_MAX_BYTES) {
340
+ body = replay(await readBounded({ request, maxBytes: maxBodyBytes }));
341
+ // A slow upload can outlive the pass, the route's freshness window and the grant: decide all
342
+ // three again before signing, so nothing is forwarded on an authorization that has lapsed.
343
+ if (pass.exp <= seconds())
344
+ throw passRefused({ reason: 'pass_expired', authentic: true, detail: { ingress: ingress.id, passId: pass.jti } });
345
+ requireFreshAuth({ pass, freshness, detail });
346
+ if (grant.expiresAt <= now())
347
+ ({ grant, release } = await usableGrant({ input, pass, detail }));
348
+ }
349
+ // A development session takes the deployment's place in the concurrency keys.
350
+ const releaseId = release.mode === 'published' ? release.deploymentId : release.devSessionId;
351
+ const lease = await admitted(() => options.admission.acquire({ environment: grant.environment, projectId: grant.projectId, deploymentId: releaseId, resources: grant.resources }, { ingress: { id: ingress.id, subjectHash, clientBucket: ingressClientBucket(clientIp) } }));
352
+ let requestContext;
353
+ try {
354
+ requestContext = signExternalGatewayContext({
355
+ privateKey: options.externalContext.privateKey, keyId: options.externalContext.keyId,
356
+ projectId: grant.projectId, environment: grant.environment, release, requestId,
357
+ method: request.method ?? 'GET', path: signedPath, serviceAccountId: grant.serviceAccountId, pass, passKid,
358
+ client: { ip: clientIp, ...observeUserAgent(request.headers['user-agent']), origin }, now: seconds(),
359
+ });
360
+ }
361
+ catch (error) {
362
+ void lease.release().catch(() => undefined);
363
+ throw error;
364
+ }
365
+ const attribution = { actorId: usageActor({ provider: pass.provider, subject: pass.subject, subjectHash }), ingressId: ingress.id, projectId: grant.projectId, environment: grant.environment };
366
+ return {
367
+ target: grant.mode === 'published'
368
+ ? { upstreamUrl: grant.upstreamUrl, upstreamAudience: grant.upstreamAudience, projectId: grant.projectId, deploymentId: grant.deploymentId }
369
+ : { kind: 'development', projectId: grant.projectId, environment: grant.environment, sessionId: grant.devSessionId },
370
+ requestContext,
371
+ timeoutMs: lease.timeoutMs,
372
+ bind: (response, abort) => { lease.bind(response, abort); },
373
+ ...(body ? { body } : {}),
374
+ usage: release.mode === 'published' ? { ...attribution, deploymentId: release.deploymentId } : { ...attribution, devSessionId: release.devSessionId },
375
+ };
376
+ }
377
+ return { preflight, exchange, logout, admit };
378
+ }
379
+ /**
380
+ * The usage actor: the context's `ext|<provider>|<subject>` when Atlas's actor grammar accepts it
381
+ * (no whitespace or control characters, ≤ 255), else the subject's hash, so one odd subject can
382
+ * never fail a whole usage batch.
383
+ */
384
+ export function usageActor({ provider, subject, subjectHash }) {
385
+ const actor = externalSubject({ provider, subject });
386
+ return actor.length <= 255 && /^[^\s\p{Cc}]+$/u.test(actor) ? actor : externalSubject({ provider, subject: `sha256:${subjectHash}` });
387
+ }
@@ -0,0 +1,259 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import type { IngressAuditEvent, IngressSnapshotHost } from '../server/ingress-control-contract.js';
3
+ import { type IngressRouteMatch } from '../server/ingress-routes.js';
4
+ import type { HostedArtifactProxyOptions, proxyHostedArtifact } from './hosted-proxy.js';
5
+ import { type HostedArtifactIngressMode } from './ingress-response.js';
6
+ import type { IngressSnapshotCache } from './ingress-snapshot.js';
7
+ /**
8
+ * The external ingress branch of the gateway (design §(e)): the front door for callers that are
9
+ * not Atlas users, on hosts `ingress-<24 hex>.<runtime domain>`. It shares nothing with the
10
+ * iframe session path: no bootstrap, no session cookie, no `authorize()`, and no 401/404→403 remap.
11
+ * Every check before authentication is local: canonical host, edge header, snapshot lookup,
12
+ * client address, canonical path, declared route and CORS. The pass, admission and grant
13
+ * (`handlers.admit`) come after them, so nothing unauthenticated reaches Redis or Atlas.
14
+ */
15
+ export declare const INGRESS_TOKEN_PATH = "/_sequence/ingress/token";
16
+ export declare const INGRESS_LOGOUT_PATH = "/_sequence/ingress/logout";
17
+ export declare const INGRESS_LIMITS: Readonly<{
18
+ /** A pass is at most 2 KiB; an API (and logout) `Authorization` header above this is refused unread. */
19
+ maxApiAuthorizationBytes: number;
20
+ /** The exchange carries the identity provider's token, which may be larger than a pass. */
21
+ maxExchangeAuthorizationBytes: number;
22
+ maxResponseBytes: number;
23
+ preflightMaxAgeSeconds: 600;
24
+ }>;
25
+ export declare const INGRESS_EXPOSED_HEADERS = "retry-after, x-request-id, www-authenticate, content-disposition";
26
+ export declare const BASE_ALLOWED_REQUEST_HEADERS: readonly ["authorization", "content-type"];
27
+ /** Error contract codes (design §(i)). App responses pass through with their own bodies. */
28
+ export type IngressErrorCode = 'bad_request' | 'invalid_token' | 'insufficient_user_authentication' | 'exchange_denied' | 'forbidden' | 'not_found' | 'request_timeout' | 'too_large' | 'payload_too_large' | 'request_header_fields_too_large' | 'rate_limited' | 'upstream_response_refused' | 'temporarily_unavailable' | 'upstream_timeout';
29
+ /**
30
+ * A refusal the gateway decided itself about a request that is not garbage (edge, client address,
31
+ * CORS, a refused authentic pass, freshness), for the denial audit batch. Garbage (a missing,
32
+ * malformed or forged credential) and admission refusals are a metric only, and refusals an Atlas
33
+ * control route returned are never marked: Atlas audits those itself (design §(g) "Gateway denials").
34
+ */
35
+ export interface IngressDenialDetail {
36
+ /** snake_case, for example `pass_bad_mac` or `origin_not_allowed`. */
37
+ reason: string;
38
+ ingress?: string | null;
39
+ subjectHash?: string;
40
+ passId?: string;
41
+ }
42
+ /**
43
+ * The in-process counters refusals feed (the gateway's onError, aggregated every 10 s): what a
44
+ * forged credential or a flood costs is a counter increment, never an audit write.
45
+ */
46
+ export type IngressRefusalMetric = 'ingress_pass_rejected' | 'ingress_token_rejected' | 'admission_rejected'
47
+ /** Should both stay zero: non-zero means a request reached the gateway around our Cloudflare zone, or Pseudo IPv4 is on. */
48
+ | 'ingress_edge_header_invalid' | 'ingress_client_ip_invalid';
49
+ /** Thrown by an ingress handler; written through the ingress writer, never the session remap. */
50
+ export declare class IngressError extends Error {
51
+ readonly status: number;
52
+ readonly code: IngressErrorCode;
53
+ readonly extra: {
54
+ retryAfterSeconds?: number;
55
+ wwwAuthenticate?: string;
56
+ audit?: IngressDenialDetail;
57
+ metric?: IngressRefusalMetric;
58
+ };
59
+ constructor(status: number, code: IngressErrorCode, extra?: {
60
+ retryAfterSeconds?: number;
61
+ wwwAuthenticate?: string;
62
+ audit?: IngressDenialDetail;
63
+ metric?: IngressRefusalMetric;
64
+ });
65
+ }
66
+ /** Every ingress host's first label starts with this. */
67
+ export declare const INGRESS_HOST_PREFIX = "ingress-";
68
+ /** The raw `Host` exactly as an ingress host must arrive: lowercase, no port, no trailing dot. */
69
+ export declare function isIngressHost({ rawHost, runtimeDomain }: {
70
+ rawHost: string | undefined;
71
+ runtimeDomain: string;
72
+ }): boolean;
73
+ /**
74
+ * Every host the ingress branch owns: any spelling (any case, a port, a trailing dot) of any
75
+ * `ingress-*` name under the runtime domain, canonical or not. Edge allowlists exempt `ingress-*`
76
+ * hosts, so a non-canonical one (`ingress-x.<domain>`, `ingress-<24 hex>.foo.<domain>`) must get the
77
+ * ingress branch's local 404 and never fall into the iframe path: session ticket exchange,
78
+ * bootstrap, authorize and WebSocket upgrades. No application or preview host starts `ingress-`.
79
+ */
80
+ export declare function resemblesIngressHost({ rawHost, runtimeDomain }: {
81
+ rawHost: string | undefined;
82
+ runtimeDomain: string;
83
+ }): boolean;
84
+ /** Every value of a header as sent, so a repeated header is seen (Node keeps only the first of some). */
85
+ export declare function rawHeaderValues(request: IncomingMessage, name: string): string[];
86
+ /**
87
+ * At least this many characters. Shorter configured secrets are ignored, so a misconfigured empty
88
+ * value (`current,` split on commas) can never match an empty header and open the edge check.
89
+ */
90
+ export declare const MIN_EDGE_SECRET_LENGTH = 32;
91
+ /** Every `Authorization` value at most `maxBytes`, and at most one of them: none is ever picked. */
92
+ export declare function hasAcceptableAuthorization({ request, maxBytes }: {
93
+ request: IncomingMessage;
94
+ maxBytes: number;
95
+ }): boolean;
96
+ /** The presented `x-sequence-edge` values match: exactly one, equal in constant time to a usable secret. */
97
+ export declare function edgeValuesMatch({ values, secrets }: {
98
+ values: readonly string[];
99
+ secrets: readonly string[];
100
+ }): boolean;
101
+ /**
102
+ * Exactly one `x-sequence-edge`, equal in constant time to one of the configured secrets (current
103
+ * and next). Only our Cloudflare zone's transform rule injects it, so a request that reached the
104
+ * gateway another way, or through another Cloudflare zone, is refused (threat T17).
105
+ */
106
+ export declare function hasValidEdgeHeader({ request, secrets }: {
107
+ request: IncomingMessage;
108
+ secrets: readonly string[];
109
+ }): boolean;
110
+ /**
111
+ * The client address: exactly one `CF-Connecting-IP` that parses and is not a Pseudo IPv4
112
+ * address, else null (400). Never X-Forwarded-For or the socket address.
113
+ */
114
+ export declare function edgeClientIp(request: IncomingMessage): string | null;
115
+ export type SnapshotIngress = IngressSnapshotHost['ingresses'][number];
116
+ export type IngressRoute = SnapshotIngress['routes'][number];
117
+ /** A matched route and the one ingress on this host that declares it. */
118
+ export interface IngressRouteResolution {
119
+ ingress: SnapshotIngress;
120
+ match: IngressRouteMatch<IngressRoute>;
121
+ }
122
+ /** Exactly one ingress on the host may declare the matched (method, path); ambiguity is no match. */
123
+ export declare function resolveIngressRoute({ entry, method, path }: {
124
+ entry: IngressSnapshotHost;
125
+ method: string;
126
+ path: string;
127
+ }): IngressRouteResolution | null;
128
+ /** The caller's `Origin`, or null when absent. Several `Origin` headers never match an allowed origin. */
129
+ export declare function callerOrigin(request: IncomingMessage): string | null;
130
+ export declare function ingressCorsHeaders(origin: string): Record<string, string>;
131
+ /**
132
+ * Writes a gateway-decided ingress response: the error contract body, `no-store`, the response
133
+ * security headers, and CORS when the caller's origin is allowed, so the page can read the error.
134
+ */
135
+ export declare function writeIngressError({ response, status, code, corsOrigin, retryAfterSeconds, wwwAuthenticate }: {
136
+ response: ServerResponse;
137
+ status: number;
138
+ code: IngressErrorCode | string;
139
+ corsOrigin: string | null;
140
+ retryAfterSeconds?: number;
141
+ wwwAuthenticate?: string;
142
+ }): void;
143
+ /** A request every local check admitted, handed to the pass, admission and grant stage (C6b). */
144
+ export interface IngressRequest {
145
+ request: IncomingMessage;
146
+ response: ServerResponse;
147
+ /** The canonical raw host, and its public origin. */
148
+ host: string;
149
+ publicOrigin: string;
150
+ entry: IngressSnapshotHost;
151
+ clientIp: string;
152
+ /** The caller's allowed `Origin`, or null for a non-browser caller. */
153
+ origin: string | null;
154
+ requestId: string;
155
+ }
156
+ export interface IngressApiRequest extends IngressRequest {
157
+ ingress: SnapshotIngress;
158
+ match: IngressRouteMatch<IngressRoute>;
159
+ /** Canonical path plus raw query: the string the context signs and Atlas re-matches. */
160
+ signedPath: string;
161
+ }
162
+ /**
163
+ * A development session to proxy to over its tunnel. Local development only: the stage admits it
164
+ * only on a gateway with APP_ENV=development, for the snapshot's own development target (design
165
+ * §(j.3) step 3).
166
+ */
167
+ export interface IngressDevelopmentTarget {
168
+ kind: 'development';
169
+ projectId: string;
170
+ environment: string;
171
+ sessionId: string;
172
+ }
173
+ /** Where an admitted request goes: the active deployment, or a development session's tunnel. */
174
+ export type IngressAdmittedTarget = (Omit<HostedArtifactProxyOptions['target'], 'publicOrigin'> & {
175
+ kind?: 'published';
176
+ }) | IngressDevelopmentTarget;
177
+ /** What the admit stage returns: where to proxy, and the signed context. */
178
+ export interface IngressAdmittedRequest {
179
+ target: IngressAdmittedTarget;
180
+ requestContext: string;
181
+ /** The admitted request timeout; the proxy caps it at 55 s. */
182
+ timeoutMs: number;
183
+ /** Ties a concurrency permit to the response's lifetime. */
184
+ bind?: (response: ServerResponse, abort: () => void) => void;
185
+ /** The request body, when the stage buffered it before taking a concurrency permit. */
186
+ body?: AsyncIterable<Uint8Array>;
187
+ /** Attribution for the request's usage observation: a deployment, or a development session. */
188
+ usage?: {
189
+ actorId: string;
190
+ ingressId: string;
191
+ projectId: string;
192
+ environment: string;
193
+ } & ({
194
+ deploymentId: string;
195
+ } | {
196
+ devSessionId: string;
197
+ });
198
+ }
199
+ /**
200
+ * Proxies an admitted ingress request over a development session's tunnel, under the same
201
+ * ingress request sanitising, response policy, limits and error writer as the hosted proxy.
202
+ */
203
+ export type IngressDevelopmentProxy = (input: {
204
+ request: IncomingMessage;
205
+ response: ServerResponse;
206
+ target: IngressDevelopmentTarget;
207
+ /** The ingress host's canonical origin: the app sees it as `x-forwarded-host`, as on a hosted release. */
208
+ publicOrigin: string;
209
+ requestContext: string;
210
+ body?: AsyncIterable<Uint8Array>;
211
+ limits: {
212
+ maxRequestBytes: number;
213
+ maxResponseBytes: number;
214
+ timeoutMs: number;
215
+ };
216
+ ingress: HostedArtifactIngressMode;
217
+ }) => Promise<void>;
218
+ export interface IngressHandlers {
219
+ /** Admission for a preflight (`ingress-preflight`), after the local checks; throws to refuse. Absent: none. */
220
+ preflight?: (input: IngressRequest) => Promise<void>;
221
+ /** `POST /_sequence/ingress/token`; writes its own response. */
222
+ exchange?: (input: IngressRequest) => Promise<void>;
223
+ /** `POST /_sequence/ingress/logout`; writes its own response. */
224
+ logout?: (input: IngressRequest) => Promise<void>;
225
+ /** Pass, admission and grant for an API route; throws IngressError to refuse. */
226
+ admit?: (input: IngressApiRequest) => Promise<IngressAdmittedRequest>;
227
+ }
228
+ export interface ArtifactGatewayIngressOptions {
229
+ /** Current and next `x-sequence-edge` values. */
230
+ edgeSecrets: readonly string[];
231
+ snapshot: Pick<IngressSnapshotCache, 'lookup'>;
232
+ /** The gateway's APP_ENV: a `development` snapshot target is honoured only under `development`. */
233
+ appEnv: string;
234
+ /** Absent stages answer 503 `temporarily_unavailable`. */
235
+ handlers?: IngressHandlers;
236
+ /** Injectable proxy for isolated tests. */
237
+ proxy?: typeof proxyHostedArtifact;
238
+ /** Injectable for tests of the idle-write timeout. */
239
+ idleWriteTimeoutMs?: number;
240
+ /** Injectable for tests of the response cap (default INGRESS_LIMITS.maxResponseBytes). */
241
+ maxResponseBytes?: number;
242
+ /** Receives every refusal the gateway decided itself on a host in the snapshot (the denial audit). */
243
+ onDenial?: (event: IngressAuditEvent) => void;
244
+ }
245
+ /** The union of a host's allowed origins: CORS for errors decided before a route is known. */
246
+ export declare function hostOrigins(entry: IngressSnapshotHost | undefined): ReadonlySet<string>;
247
+ export declare function corsFor(origin: string | null, allowed: ReadonlySet<string>): string | null;
248
+ export declare function requestedHeaders(request: IncomingMessage): string[];
249
+ /** Host, Origin and edge values from the bytes Node had buffered when the header block overflowed. */
250
+ export declare function requestLineHeaders(raw: Buffer | undefined): {
251
+ host?: string;
252
+ origin?: string | null;
253
+ edge: string[];
254
+ };
255
+ export declare function writeIngressRefusal({ response, error, corsOrigin }: {
256
+ response: ServerResponse;
257
+ error: unknown;
258
+ corsOrigin: string | null;
259
+ }): void;