@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,98 @@
1
+ import { isSharedCacheDirective, SHARED_CACHE_REFUSAL } from '../tunnel/protocol.js';
2
+ /**
3
+ * Response policy for an external ingress (design §(e) "Response policy"). An ingress answers a
4
+ * cross-origin caller that is not an Atlas user, so nothing it returns may become a document, a
5
+ * redirect, a stream, a cookie or a cache entry: the only output is data of a type the route
6
+ * declared. The decision is taken on the status line and headers, before any body byte is sent.
7
+ */
8
+ /**
9
+ * Set on every ingress response, the gateway's own errors included. The shared-cache refusal is
10
+ * the one every protected session response carries, so no zone cache rule can hold borrower data.
11
+ */
12
+ export const INGRESS_RESPONSE_SECURITY_HEADERS = Object.freeze({
13
+ 'content-security-policy': "default-src 'none'; sandbox; frame-ancestors 'none'",
14
+ 'x-content-type-options': 'nosniff',
15
+ 'referrer-policy': 'no-referrer',
16
+ 'cross-origin-resource-policy': 'same-origin',
17
+ 'cache-control': 'private, no-store',
18
+ ...SHARED_CACHE_REFUSAL,
19
+ });
20
+ // The app may not set CORS, cookies, framing or navigation; the gateway owns the security headers
21
+ // above and the request id it generated.
22
+ const STRIPPED_RESPONSE_HEADERS = new Set([
23
+ 'set-cookie', 'clear-site-data', 'x-frame-options', 'refresh', 'x-request-id',
24
+ 'content-security-policy-report-only', ...Object.keys(INGRESS_RESPONSE_SECURITY_HEADERS),
25
+ ]);
26
+ const JSON_TYPES = new Set(['application/json', 'application/problem+json']);
27
+ function mediaType(value) {
28
+ if (typeof value !== 'string')
29
+ return undefined;
30
+ const type = value.split(';', 1)[0].trim().toLowerCase();
31
+ return type === '' ? undefined : type;
32
+ }
33
+ function attachment(disposition) {
34
+ if (typeof disposition !== 'string')
35
+ return 'attachment';
36
+ // Keep the app's parameters (filename) and replace only the disposition type.
37
+ const parameters = disposition.split(';').slice(1).map((part) => part.trim()).filter(Boolean);
38
+ return ['attachment', ...parameters].join('; ');
39
+ }
40
+ /**
41
+ * The upstream's declared body length, read from the raw head: the shared sanitising removes
42
+ * `content-length` as a hop header, so the policy must be handed it explicitly. The same parser
43
+ * the development tunnel uses, so hosted and development ingress judge a head alike.
44
+ */
45
+ export { declaredLength as declaredContentLength } from '../tunnel/external.js';
46
+ /**
47
+ * Decides an upstream response on an ingress route. `headers` are the app's headers after the
48
+ * shared private-header sanitising (no `content-length`); `declaredLength` is the upstream's own
49
+ * `Content-Length`; `responses` are the matched route's declared content types.
50
+ */
51
+ export function applyIngressResponsePolicy({ status, method, headers, declaredLength, responses }) {
52
+ // 304 carries no body and no destination; every other 3xx would navigate a cross-origin caller.
53
+ if (status >= 300 && status < 400 && status !== 304)
54
+ return { allowed: false, reason: 'redirect' };
55
+ const type = mediaType(headers['content-type']);
56
+ if (type === 'text/event-stream')
57
+ return { allowed: false, reason: 'event_stream' };
58
+ if (type === undefined) {
59
+ const empty = status === 204 || status === 304 || method === 'HEAD' || declaredLength === 0;
60
+ if (!empty || headers['content-type'] !== undefined)
61
+ return { allowed: false, reason: 'missing_content_type' };
62
+ }
63
+ else if (type !== 'application/problem+json' && !responses.includes(type)) {
64
+ return { allowed: false, reason: 'content_type' };
65
+ }
66
+ const kept = {};
67
+ for (const [name, value] of Object.entries(headers)) {
68
+ if (STRIPPED_RESPONSE_HEADERS.has(name) || name.startsWith('access-control-') || isSharedCacheDirective(name))
69
+ continue;
70
+ kept[name] = value;
71
+ }
72
+ if (type === undefined || !JSON_TYPES.has(type))
73
+ kept['content-disposition'] = attachment(headers['content-disposition']);
74
+ return { allowed: true, headers: { ...kept, ...INGRESS_RESPONSE_SECURITY_HEADERS } };
75
+ }
76
+ /**
77
+ * Applies the policy to an upstream head and writes the allowed status and headers onto the
78
+ * gateway response. False when the response is refused (502 before any body byte), including a
79
+ * declared length above the ingress response cap.
80
+ */
81
+ export function writeIngressResponseHead({ response, status, method, headers, declaredLength, responses, maxResponseBytes }) {
82
+ const decision = applyIngressResponsePolicy({ status, method, headers, declaredLength, responses });
83
+ if (!decision.allowed || (declaredLength !== null && declaredLength > maxResponseBytes))
84
+ return false;
85
+ response.statusCode = status;
86
+ for (const [name, value] of Object.entries(decision.headers)) {
87
+ // The gateway's `Vary: Origin` (set with CORS) survives an app's own Vary.
88
+ const vary = name === 'vary' ? response.getHeader('vary') : undefined;
89
+ response.setHeader(name, typeof vary === 'string' ? `${vary}, ${Array.isArray(value) ? value.join(', ') : value}` : value);
90
+ }
91
+ return true;
92
+ }
93
+ /** An ingress caller gets a deterministic 504 before its 60 s context expires (design §(e) "Limits"). */
94
+ export const INGRESS_PROXY_LIMITS = Object.freeze({ maxTimeoutMs: 55_000, idleWriteTimeoutMs: 10_000 });
95
+ /** Error contract codes (design §(i)) for the failures the proxy itself decides. */
96
+ export const INGRESS_PROXY_ERROR_CODES = Object.freeze({
97
+ 400: 'bad_request', 413: 'payload_too_large', 502: 'upstream_response_refused', 503: 'temporarily_unavailable', 504: 'upstream_timeout',
98
+ });
@@ -0,0 +1,86 @@
1
+ import { z } from 'zod';
2
+ import type { AdmissionRedis, ArtifactRequestAdmission } from './admission.js';
3
+ import type { ArtifactGatewayControl } from './control.js';
4
+ import { type IngressAuditFailure } from './ingress-audit.js';
5
+ import { type IngressPassKeyring } from './ingress-pass.js';
6
+ import { type ArtifactGatewayIngressOptions } from './ingress.js';
7
+ /**
8
+ * Startup of the external ingress branch (design §(b.1) item 4): its four secrets, the checks
9
+ * that keep them independent of every other gateway secret, and the wiring of snapshot,
10
+ * admission, stages and denial audit. Errors and logs name variables, never values.
11
+ */
12
+ export declare const INGRESS_SECRET_NAMES: readonly ["ARTIFACT_NEXT_EXTERNAL_CONTEXT_PRIVATE_KEY", "ARTIFACT_NEXT_EXTERNAL_CONTEXT_KEY_ID", "ARTIFACT_NEXT_INGRESS_PASS_KEYS", "ARTIFACT_NEXT_INGRESS_EDGE_SECRETS"];
13
+ type IngressSecretName = typeof INGRESS_SECRET_NAMES[number];
14
+ export interface IngressSecrets {
15
+ externalContext: {
16
+ privateKey: string;
17
+ keyId: string;
18
+ };
19
+ keyring: IngressPassKeyring;
20
+ /** Current and, during a rotation, next. */
21
+ edgeSecrets: string[];
22
+ }
23
+ export type IngressSecretsResult =
24
+ /** All four absent (`missing` lists all four) or only some present: the branch stays off. */
25
+ {
26
+ state: 'disabled';
27
+ missing: IngressSecretName[];
28
+ partial: boolean;
29
+ } | {
30
+ state: 'enabled';
31
+ secrets: IngressSecrets;
32
+ };
33
+ /** A refusal to boot: the ingress secrets are present but unusable. Its message names variables only. */
34
+ export declare class IngressSecretsError extends Error {
35
+ constructor(message: string);
36
+ }
37
+ /** `ARTIFACT_NEXT_INGRESS_EDGE_SECRETS`: `{"current":"…","next":"…"|null}`, each at least 32 characters. */
38
+ export declare const ingressEdgeSecretsSchema: z.ZodObject<{
39
+ current: z.ZodString;
40
+ next: z.ZodDefault<z.ZodNullable<z.ZodString>>;
41
+ }, z.core.$strict>;
42
+ /**
43
+ * Reads the four ingress secrets. All absent: disabled (the image can roll before the secrets
44
+ * exist). Some absent: disabled, with `partial` so the caller logs an error naming them. All
45
+ * present: enabled, or IngressSecretsError when any is malformed, when any two of {control token,
46
+ * session key, each pass key, each edge secret} are equal, when the external context key is the
47
+ * browser context key, or when the browser key id is the external one or an `external-` id (the
48
+ * sidecar pairs `external-` kids with v2 contexts only).
49
+ */
50
+ export declare function loadIngressSecrets({ env, controlToken, sessionKey, browserContextPrivateKey, browserContextKeyId }: {
51
+ env: Readonly<Record<string, string | undefined>>;
52
+ controlToken: string;
53
+ sessionKey: string;
54
+ browserContextPrivateKey: string;
55
+ browserContextKeyId: string;
56
+ }): IngressSecretsResult;
57
+ export interface IngressRuntime {
58
+ /** Absent: the branch is disabled and every ingress host answers 404. */
59
+ ingress: ArtifactGatewayIngressOptions | undefined;
60
+ /** Starts the snapshot pulls and the denial audit flushes. */
61
+ start(): void;
62
+ /** Stops them and delivers the buffered refusals. */
63
+ close(): Promise<void>;
64
+ }
65
+ /**
66
+ * The ingress branch for one gateway process, or none. Throws IngressSecretsError (the process
67
+ * must refuse to boot) only when every secret is present and one is unusable.
68
+ */
69
+ export declare function createIngressRuntime({ env, control, controlToken, sessionKey, contextPrivateKey, contextKeyId, redis, namespace, admission, log, onFailure }: {
70
+ env: Readonly<Record<string, string | undefined>>;
71
+ control: ArtifactGatewayControl;
72
+ controlToken: string;
73
+ sessionKey: string;
74
+ contextPrivateKey: string;
75
+ /** ARTIFACT_NEXT_CONTEXT_KEY_ID, the browser context key's id. */
76
+ contextKeyId: string;
77
+ redis: AdmissionRedis;
78
+ /** APP_ENV. */
79
+ namespace: string;
80
+ admission: Pick<ArtifactRequestAdmission, 'acquire'>;
81
+ log: (event: Record<string, unknown>) => void;
82
+ onFailure?: (failure: IngressAuditFailure | {
83
+ kind: 'ingress_snapshot_failed';
84
+ }) => void;
85
+ }): IngressRuntime;
86
+ export {};
@@ -0,0 +1,126 @@
1
+ import { createHash, createPrivateKey, timingSafeEqual } from 'node:crypto';
2
+ import { z } from 'zod';
3
+ import { ingressExternalKidSchema } from '../server/ingress-control-contract.js';
4
+ import { IngressAdmission } from './ingress-admission.js';
5
+ import { IngressDenialAuditor } from './ingress-audit.js';
6
+ import { ingressPassKeyringSchema } from './ingress-pass.js';
7
+ import { IngressSnapshotCache } from './ingress-snapshot.js';
8
+ import { createIngressStages } from './ingress-stages.js';
9
+ import { MIN_EDGE_SECRET_LENGTH } from './ingress.js';
10
+ /**
11
+ * Startup of the external ingress branch (design §(b.1) item 4): its four secrets, the checks
12
+ * that keep them independent of every other gateway secret, and the wiring of snapshot,
13
+ * admission, stages and denial audit. Errors and logs name variables, never values.
14
+ */
15
+ export const INGRESS_SECRET_NAMES = [
16
+ 'ARTIFACT_NEXT_EXTERNAL_CONTEXT_PRIVATE_KEY',
17
+ 'ARTIFACT_NEXT_EXTERNAL_CONTEXT_KEY_ID',
18
+ 'ARTIFACT_NEXT_INGRESS_PASS_KEYS',
19
+ 'ARTIFACT_NEXT_INGRESS_EDGE_SECRETS',
20
+ ];
21
+ /** A refusal to boot: the ingress secrets are present but unusable. Its message names variables only. */
22
+ export class IngressSecretsError extends Error {
23
+ constructor(message) {
24
+ super(message);
25
+ this.name = 'IngressSecretsError';
26
+ }
27
+ }
28
+ /** `ARTIFACT_NEXT_INGRESS_EDGE_SECRETS`: `{"current":"…","next":"…"|null}`, each at least 32 characters. */
29
+ export const ingressEdgeSecretsSchema = z.strictObject({
30
+ current: z.string().min(MIN_EDGE_SECRET_LENGTH).max(1024),
31
+ next: z.string().min(MIN_EDGE_SECRET_LENGTH).max(1024).nullable().default(null),
32
+ });
33
+ function parseJson(value) {
34
+ try {
35
+ return JSON.parse(value);
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ }
41
+ function digest(value) {
42
+ return createHash('sha256').update(value).digest();
43
+ }
44
+ /** Every pair compared in constant time, all of them, so timing reveals neither which nor whether. */
45
+ function anyEqual(values) {
46
+ const digests = values.map(digest);
47
+ let equal = false;
48
+ for (let left = 0; left < digests.length; left++) {
49
+ for (let right = left + 1; right < digests.length; right++)
50
+ equal = timingSafeEqual(digests[left], digests[right]) || equal;
51
+ }
52
+ return equal;
53
+ }
54
+ function privateKeyBytes(pem, name) {
55
+ let key;
56
+ try {
57
+ key = createPrivateKey(pem);
58
+ }
59
+ catch {
60
+ throw new IngressSecretsError(`${name} is not a private key`);
61
+ }
62
+ if (key.asymmetricKeyType !== 'ed25519')
63
+ throw new IngressSecretsError(`${name} must be an Ed25519 key`);
64
+ return key.export({ format: 'der', type: 'pkcs8' });
65
+ }
66
+ /**
67
+ * Reads the four ingress secrets. All absent: disabled (the image can roll before the secrets
68
+ * exist). Some absent: disabled, with `partial` so the caller logs an error naming them. All
69
+ * present: enabled, or IngressSecretsError when any is malformed, when any two of {control token,
70
+ * session key, each pass key, each edge secret} are equal, when the external context key is the
71
+ * browser context key, or when the browser key id is the external one or an `external-` id (the
72
+ * sidecar pairs `external-` kids with v2 contexts only).
73
+ */
74
+ export function loadIngressSecrets({ env, controlToken, sessionKey, browserContextPrivateKey, browserContextKeyId }) {
75
+ const missing = INGRESS_SECRET_NAMES.filter((name) => !env[name]);
76
+ if (missing.length > 0)
77
+ return { state: 'disabled', missing, partial: missing.length < INGRESS_SECRET_NAMES.length };
78
+ const privateKey = env.ARTIFACT_NEXT_EXTERNAL_CONTEXT_PRIVATE_KEY;
79
+ const keyId = env.ARTIFACT_NEXT_EXTERNAL_CONTEXT_KEY_ID;
80
+ if (!ingressExternalKidSchema.safeParse(keyId).success)
81
+ throw new IngressSecretsError('ARTIFACT_NEXT_EXTERNAL_CONTEXT_KEY_ID must start with external-');
82
+ if (browserContextKeyId === keyId || browserContextKeyId.startsWith('external-')) {
83
+ throw new IngressSecretsError('ARTIFACT_NEXT_CONTEXT_KEY_ID must differ from ARTIFACT_NEXT_EXTERNAL_CONTEXT_KEY_ID and must not start with external-');
84
+ }
85
+ const keyring = ingressPassKeyringSchema.safeParse(parseJson(env.ARTIFACT_NEXT_INGRESS_PASS_KEYS));
86
+ if (!keyring.success)
87
+ throw new IngressSecretsError('ARTIFACT_NEXT_INGRESS_PASS_KEYS is malformed');
88
+ const edge = ingressEdgeSecretsSchema.safeParse(parseJson(env.ARTIFACT_NEXT_INGRESS_EDGE_SECRETS));
89
+ if (!edge.success)
90
+ throw new IngressSecretsError('ARTIFACT_NEXT_INGRESS_EDGE_SECRETS is malformed');
91
+ const external = privateKeyBytes(privateKey, 'ARTIFACT_NEXT_EXTERNAL_CONTEXT_PRIVATE_KEY');
92
+ const browser = privateKeyBytes(browserContextPrivateKey, 'ARTIFACT_NEXT_CONTEXT_PRIVATE_KEY');
93
+ const edgeSecrets = [edge.data.current, ...(edge.data.next === null ? [] : [edge.data.next])];
94
+ const independent = [controlToken, sessionKey, ...Object.values(keyring.data.keys), ...edgeSecrets].map((value) => Buffer.from(value, 'utf8'));
95
+ if (anyEqual(independent)) {
96
+ throw new IngressSecretsError('Gateway secrets must be independent: the control token, session key, pass keys and edge secrets must all differ');
97
+ }
98
+ if (anyEqual([external, browser]))
99
+ throw new IngressSecretsError('ARTIFACT_NEXT_EXTERNAL_CONTEXT_PRIVATE_KEY must not be the browser context key');
100
+ return { state: 'enabled', secrets: { externalContext: { privateKey, keyId }, keyring: keyring.data, edgeSecrets } };
101
+ }
102
+ /**
103
+ * The ingress branch for one gateway process, or none. Throws IngressSecretsError (the process
104
+ * must refuse to boot) only when every secret is present and one is unusable.
105
+ */
106
+ export function createIngressRuntime({ env, control, controlToken, sessionKey, contextPrivateKey, contextKeyId, redis, namespace, admission, log, onFailure }) {
107
+ const loaded = loadIngressSecrets({ env, controlToken, sessionKey, browserContextPrivateKey: contextPrivateKey, browserContextKeyId: contextKeyId });
108
+ if (loaded.state === 'disabled') {
109
+ if (loaded.partial)
110
+ log({ event: 'artifact_gateway.ingress_disabled', level: 'error', missing: loaded.missing });
111
+ return { ingress: undefined, start: () => undefined, close: async () => undefined };
112
+ }
113
+ log({ event: 'artifact_gateway.ingress_secrets_checked', message: 'ingress secrets checked' });
114
+ const { secrets } = loaded;
115
+ const snapshot = new IngressSnapshotCache({ load: () => control.ingressSnapshot(), onError: (event) => onFailure?.(event) });
116
+ const auditor = new IngressDenialAuditor({ send: (batch) => control.ingressAudit(batch), onFailure });
117
+ const handlers = createIngressStages({
118
+ control, snapshot, keyring: secrets.keyring, externalContext: secrets.externalContext, admission,
119
+ ingressAdmission: new IngressAdmission({ redis, namespace }), appEnv: namespace,
120
+ });
121
+ return {
122
+ ingress: { edgeSecrets: secrets.edgeSecrets, snapshot, appEnv: namespace, handlers, onDenial: (event) => auditor.record(event) },
123
+ start: () => { snapshot.start(); auditor.start(); },
124
+ close: async () => { snapshot.stop(); await auditor.close(); },
125
+ };
126
+ }
@@ -0,0 +1,54 @@
1
+ import type { IngressSnapshot, IngressSnapshotHost } from '../server/ingress-control-contract.js';
2
+ /**
3
+ * The gateway's local copy of every enabled ingress host (design §(e) "Host snapshot"). It is
4
+ * pulled from `control/ingress-snapshot` on a timer and never on request, so a request for a
5
+ * random hostname costs a map lookup and can never amplify into an Atlas call.
6
+ */
7
+ export declare const INGRESS_SNAPSHOT_INTERVAL_MS = 15000;
8
+ /** Older than this (Atlas unreachable for eight pulls), every ingress request answers 503. */
9
+ export declare const INGRESS_SNAPSHOT_STALE_AFTER_MS = 120000;
10
+ export type IngressSnapshotLookup = {
11
+ state: 'fresh';
12
+ entry: IngressSnapshotHost;
13
+ } | {
14
+ state: 'unknown';
15
+ }
16
+ /** Too old to act on. `entry` is the last known one, for CORS on the 503 only. */
17
+ | {
18
+ state: 'stale';
19
+ entry: IngressSnapshotHost | undefined;
20
+ };
21
+ export interface IngressSnapshotCacheOptions {
22
+ /** Fetches and validates the snapshot (`ArtifactGatewayControl.ingressSnapshot`). */
23
+ load: () => Promise<IngressSnapshot>;
24
+ intervalMs?: number;
25
+ staleAfterMs?: number;
26
+ now?: () => number;
27
+ /** A failed pull keeps the previous snapshot until it goes stale. */
28
+ onError?: (event: {
29
+ kind: 'ingress_snapshot_failed';
30
+ }) => void;
31
+ }
32
+ export declare class IngressSnapshotCache {
33
+ private readonly options;
34
+ private hosts;
35
+ private snapshot;
36
+ private loadedAt;
37
+ private pending;
38
+ private timer;
39
+ private readonly intervalMs;
40
+ private readonly staleAfterMs;
41
+ private readonly now;
42
+ constructor(options: IngressSnapshotCacheOptions);
43
+ /** Pulls now and then every interval; the timer never keeps the process alive. */
44
+ start(): void;
45
+ stop(): void;
46
+ /** Single-flight: concurrent callers share one pull. Never rejects. */
47
+ refresh(): Promise<void>;
48
+ /** `host` must already be the canonical raw Host. A local map lookup; never a network call. */
49
+ lookup(host: string): IngressSnapshotLookup;
50
+ /** The non-secret per-provider summary the exchange precheck reads; undefined until a pull succeeds. */
51
+ get providers(): IngressSnapshot['providers'] | undefined;
52
+ /** The last pulled snapshot's policy epoch; a newer one on a grant asks for a pull now. */
53
+ get policyEpoch(): number | undefined;
54
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The gateway's local copy of every enabled ingress host (design §(e) "Host snapshot"). It is
3
+ * pulled from `control/ingress-snapshot` on a timer and never on request, so a request for a
4
+ * random hostname costs a map lookup and can never amplify into an Atlas call.
5
+ */
6
+ export const INGRESS_SNAPSHOT_INTERVAL_MS = 15_000;
7
+ /** Older than this (Atlas unreachable for eight pulls), every ingress request answers 503. */
8
+ export const INGRESS_SNAPSHOT_STALE_AFTER_MS = 120_000;
9
+ export class IngressSnapshotCache {
10
+ options;
11
+ hosts = new Map();
12
+ snapshot;
13
+ loadedAt;
14
+ pending;
15
+ timer;
16
+ intervalMs;
17
+ staleAfterMs;
18
+ now;
19
+ constructor(options) {
20
+ this.options = options;
21
+ this.intervalMs = options.intervalMs ?? INGRESS_SNAPSHOT_INTERVAL_MS;
22
+ this.staleAfterMs = options.staleAfterMs ?? INGRESS_SNAPSHOT_STALE_AFTER_MS;
23
+ this.now = options.now ?? Date.now;
24
+ }
25
+ /** Pulls now and then every interval; the timer never keeps the process alive. */
26
+ start() {
27
+ if (this.timer)
28
+ return;
29
+ void this.refresh();
30
+ this.timer = setInterval(() => { void this.refresh(); }, this.intervalMs);
31
+ this.timer.unref();
32
+ }
33
+ stop() {
34
+ clearInterval(this.timer);
35
+ this.timer = undefined;
36
+ }
37
+ /** Single-flight: concurrent callers share one pull. Never rejects. */
38
+ refresh() {
39
+ this.pending ??= this.options.load().then((snapshot) => {
40
+ this.snapshot = snapshot;
41
+ this.hosts = new Map(snapshot.hosts.map((entry) => [entry.host, entry]));
42
+ this.loadedAt = this.now();
43
+ }, () => {
44
+ this.options.onError?.({ kind: 'ingress_snapshot_failed' });
45
+ }).finally(() => { this.pending = undefined; });
46
+ return this.pending;
47
+ }
48
+ /** `host` must already be the canonical raw Host. A local map lookup; never a network call. */
49
+ lookup(host) {
50
+ const entry = this.hosts.get(host);
51
+ if (this.loadedAt === undefined || this.now() - this.loadedAt > this.staleAfterMs)
52
+ return { state: 'stale', entry };
53
+ return entry ? { state: 'fresh', entry } : { state: 'unknown' };
54
+ }
55
+ /** The non-secret per-provider summary the exchange precheck reads; undefined until a pull succeeds. */
56
+ get providers() {
57
+ return this.snapshot?.providers;
58
+ }
59
+ /** The last pulled snapshot's policy epoch; a newer one on a grant asks for a pull now. */
60
+ get policyEpoch() {
61
+ return this.snapshot?.policyEpoch;
62
+ }
63
+ }
@@ -0,0 +1,60 @@
1
+ import type { IngressSnapshot } from '../server/ingress-control-contract.js';
2
+ import { type ArtifactRequestAdmission } from './admission.js';
3
+ import { type ArtifactGatewayControl } from './control.js';
4
+ import { type IngressAdmission } from './ingress-admission.js';
5
+ import { type IngressPassKeyring } from './ingress-pass.js';
6
+ import type { IngressSnapshotCache } from './ingress-snapshot.js';
7
+ import { type IngressHandlers } from './ingress.js';
8
+ /**
9
+ * The authenticated half of the external ingress (design "First call" steps 3-5, "Steady state"
10
+ * steps 4-8, §(d), §(e) "Rate limiting and admission"): pass exchange, logout, and for API routes
11
+ * local pass verification, admission, grant, freshness, concurrency and the signed v2 context.
12
+ * Order matters: nothing a forged credential presents reaches Redis or Atlas, and garbage is only
13
+ * ever counted, never audited (design §(g) "Gateway denials").
14
+ */
15
+ /** Exchange request bodies above this are refused unread past the limit, before admission. */
16
+ export declare const INGRESS_EXCHANGE_MAX_BODY_BYTES = 1024;
17
+ /** Routes with a body cap at or below this have the body buffered before a concurrency permit. */
18
+ export declare const INGRESS_BUFFERED_BODY_MAX_BYTES: number;
19
+ export interface IngressStagesOptions {
20
+ control: Pick<ArtifactGatewayControl, 'ingressExchange' | 'ingressGrant' | 'ingressRevoke'>;
21
+ snapshot: Pick<IngressSnapshotCache, 'providers' | 'policyEpoch' | 'refresh'>;
22
+ keyring: IngressPassKeyring;
23
+ /** The gateway's external context key; never the browser one. */
24
+ externalContext: {
25
+ privateKey: string;
26
+ keyId: string;
27
+ };
28
+ /** Hosted request concurrency (ACQUIRE-v2). */
29
+ admission: Pick<ArtifactRequestAdmission, 'acquire'>;
30
+ ingressAdmission: Pick<IngressAdmission, 'preflight' | 'exchange' | 'logout' | 'request' | 'withPool'>;
31
+ /** The gateway's APP_ENV: stamped into every pass and required of every pass it verifies. */
32
+ appEnv: string;
33
+ /** Epoch milliseconds. */
34
+ now?: () => number;
35
+ /** Reuse of a read grant (default 5 s, the authorization cache TTL). */
36
+ grantCacheTtlMs?: number;
37
+ }
38
+ export declare function subjectHashOf(subject: string): string;
39
+ /**
40
+ * The structural precheck before any Redis or Atlas call (design "First call" step 3): three
41
+ * base64url segments, an `alg` among the provider's, a `kid`, and the provider's `iss`. No crypto
42
+ * and no JWKS: Atlas verifies the token. Returns the token's `exp`, which Atlas verified with it.
43
+ */
44
+ export declare function precheckIdentityToken({ token, provider }: {
45
+ token: string;
46
+ provider: IngressSnapshot['providers'][string];
47
+ }): {
48
+ exp: number | null;
49
+ } | null;
50
+ export declare function createIngressStages(options: IngressStagesOptions): Required<Pick<IngressHandlers, 'preflight' | 'exchange' | 'logout' | 'admit'>>;
51
+ /**
52
+ * The usage actor: the context's `ext|<provider>|<subject>` when Atlas's actor grammar accepts it
53
+ * (no whitespace or control characters, ≤ 255), else the subject's hash, so one odd subject can
54
+ * never fail a whole usage batch.
55
+ */
56
+ export declare function usageActor({ provider, subject, subjectHash }: {
57
+ provider: string;
58
+ subject: string;
59
+ subjectHash: string;
60
+ }): string;