@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.
- package/README.md +183 -6
- package/VERSION-PIN.md +56 -1
- package/dist/gateway/admission.d.ts +23 -1
- package/dist/gateway/admission.js +30 -13
- package/dist/gateway/control.d.ts +40 -0
- package/dist/gateway/control.js +76 -7
- package/dist/gateway/hosted-proxy.d.ts +11 -8
- package/dist/gateway/hosted-proxy.js +99 -91
- package/dist/gateway/ingress-admission.d.ts +77 -0
- package/dist/gateway/ingress-admission.js +150 -0
- package/dist/gateway/ingress-audit.d.ts +46 -0
- package/dist/gateway/ingress-audit.js +84 -0
- package/dist/gateway/ingress-context.d.ts +57 -0
- package/dist/gateway/ingress-context.js +57 -0
- package/dist/gateway/ingress-development.d.ts +12 -0
- package/dist/gateway/ingress-development.js +23 -0
- package/dist/gateway/ingress-handler.d.ts +44 -0
- package/dist/gateway/ingress-handler.js +320 -0
- package/dist/gateway/ingress-pass.d.ts +83 -0
- package/dist/gateway/ingress-pass.js +143 -0
- package/dist/gateway/ingress-response.d.ts +74 -0
- package/dist/gateway/ingress-response.js +98 -0
- package/dist/gateway/ingress-runtime.d.ts +86 -0
- package/dist/gateway/ingress-runtime.js +126 -0
- package/dist/gateway/ingress-snapshot.d.ts +54 -0
- package/dist/gateway/ingress-snapshot.js +63 -0
- package/dist/gateway/ingress-stages.d.ts +60 -0
- package/dist/gateway/ingress-stages.js +387 -0
- package/dist/gateway/ingress.d.ts +259 -0
- package/dist/gateway/ingress.js +204 -0
- package/dist/gateway/lattice-invoke.js +2 -0
- package/dist/gateway/lattice-upstream.d.ts +3 -4
- package/dist/gateway/lattice-upstream.js +11 -9
- package/dist/gateway/runtime-transport.d.ts +17 -0
- package/dist/gateway/runtime-transport.js +26 -0
- package/dist/gateway/server.d.ts +3 -0
- package/dist/gateway/server.js +21 -0
- package/dist/gateway/session-response.d.ts +15 -0
- package/dist/gateway/session-response.js +40 -0
- package/dist/gateway/usage.d.ts +8 -1
- package/dist/gateway-main.js +32 -8
- package/dist/manifest-next-ingress.d.ts +5 -2
- package/dist/manifest-next-ingress.js +27 -2
- package/dist/manifest-next.d.ts +20 -6
- package/dist/manifest-next.js +13 -5
- package/dist/manifest.d.ts +7 -2
- package/dist/next-project.d.ts +4 -0
- package/dist/sdk.d.ts +12 -3
- package/dist/server/broker-contract.d.ts +62 -0
- package/dist/server/broker-contract.js +43 -1
- package/dist/server/client.js +38 -13
- package/dist/server/external-context.d.ts +8 -1
- package/dist/server/external-context.js +7 -2
- package/dist/server/internal/v1-context.js +17 -1
- package/dist/server/internal/verify-any.d.ts +11 -2
- package/dist/server/internal/verify-any.js +11 -3
- package/dist/server/process-run-sharing.d.ts +86 -0
- package/dist/server/process-run-sharing.js +29 -0
- package/dist/server/process-runs-page.d.ts +39 -0
- package/dist/server/process-runs-page.js +10 -0
- package/dist/server/processes.d.ts +8 -9
- package/dist/server/processes.js +4 -0
- package/dist/server/request-context.js +10 -3
- package/dist/server/runtime-config.d.ts +10 -1
- package/dist/server/runtime-config.js +11 -1
- package/dist/server/testing.d.ts +1 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.js +1 -1
- package/dist/tunnel/broker.d.ts +18 -0
- package/dist/tunnel/broker.js +175 -17
- package/dist/tunnel/client.d.ts +13 -0
- package/dist/tunnel/client.js +40 -2
- package/dist/tunnel/external.d.ts +51 -0
- package/dist/tunnel/external.js +10 -0
- package/dist/tunnel/index.d.ts +1 -1
- package/dist/tunnel/protocol.d.ts +20 -2
- package/dist/tunnel/protocol.js +42 -6
- package/package.json +4 -4
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* The ingress pass (design §(d)): `sqx1.<kid>.<b64url(payload)>.<b64url(mac)>`, minted by the
|
|
4
|
+
* gateway after Atlas verified an identity provider's token, and verified locally on every API
|
|
5
|
+
* call with no Redis and no Atlas. Its MAC key is derived from the gateway-only pass keyring with
|
|
6
|
+
* a salt of its own, so neither the control token (Atlas holds it) nor the session key (it signs
|
|
7
|
+
* iframe cookies raw) can produce or verify a pass.
|
|
8
|
+
*/
|
|
9
|
+
export declare const INGRESS_PASS_PREFIX = "sqx1";
|
|
10
|
+
export declare const INGRESS_PASS_MAX_BYTES = 2048;
|
|
11
|
+
/** Never `atlas-artifact-next`, the control-token salt Atlas derives with (design §(b.1) item 5). */
|
|
12
|
+
export declare const INGRESS_PASS_HKDF_SALT = "artifact-next-gateway-ingress-pass";
|
|
13
|
+
export declare const INGRESS_PASS_HKDF_INFO = "sqx1";
|
|
14
|
+
/** A pass issued up to this many seconds in the future (gateway replicas' clock skew) still verifies. */
|
|
15
|
+
export declare const INGRESS_PASS_IAT_SKEW_SECONDS = 5;
|
|
16
|
+
export declare const INGRESS_PASS_KEYRING_MAX_KEYS = 3;
|
|
17
|
+
/** At least 32 random bytes, base64-encoded. */
|
|
18
|
+
export declare const INGRESS_PASS_KEY_MIN_LENGTH = 43;
|
|
19
|
+
/** `ARTIFACT_NEXT_INGRESS_PASS_KEYS`: `{"current":"k2","keys":{"k1":"…","k2":"…"}}`. */
|
|
20
|
+
export declare const ingressPassKeyringSchema: z.ZodObject<{
|
|
21
|
+
current: z.ZodString;
|
|
22
|
+
keys: z.ZodRecord<z.ZodString, z.ZodString>;
|
|
23
|
+
}, z.core.$strict>;
|
|
24
|
+
export type IngressPassKeyring = z.infer<typeof ingressPassKeyringSchema>;
|
|
25
|
+
/** Strict: a pass carrying any other key is refused. */
|
|
26
|
+
export declare const ingressPassPayloadSchema: z.ZodObject<{
|
|
27
|
+
v: z.ZodLiteral<1>;
|
|
28
|
+
typ: z.ZodLiteral<"artifact-ingress-pass">;
|
|
29
|
+
env: z.ZodString;
|
|
30
|
+
origin: z.ZodString;
|
|
31
|
+
projectId: z.ZodString;
|
|
32
|
+
ingress: z.ZodString;
|
|
33
|
+
provider: z.ZodString;
|
|
34
|
+
bep: z.ZodNumber;
|
|
35
|
+
issuer: z.ZodString;
|
|
36
|
+
subject: z.ZodString;
|
|
37
|
+
clientId: z.ZodString;
|
|
38
|
+
email: z.ZodString;
|
|
39
|
+
emailVerified: z.ZodLiteral<true>;
|
|
40
|
+
authTime: z.ZodNullable<z.ZodNumber>;
|
|
41
|
+
amr: z.ZodNullable<z.ZodArray<z.ZodString>>;
|
|
42
|
+
tokenIssuedAt: z.ZodNumber;
|
|
43
|
+
exchangeIp: z.ZodUnion<readonly [z.ZodIPv4, z.ZodIPv6]>;
|
|
44
|
+
jti: z.ZodUUID;
|
|
45
|
+
iat: z.ZodNumber;
|
|
46
|
+
exp: z.ZodNumber;
|
|
47
|
+
}, z.core.$strict>;
|
|
48
|
+
export type IngressPassPayload = z.infer<typeof ingressPassPayloadSchema>;
|
|
49
|
+
/** `K_kid = HKDF-SHA256(keyring[kid], salt, info, 32)`; never the raw keyring value. */
|
|
50
|
+
export declare function derivePassKey(secret: string): Buffer;
|
|
51
|
+
/** A valid payload whose pass would exceed INGRESS_PASS_MAX_BYTES: a refusal, never retryable. */
|
|
52
|
+
export declare class IngressPassTooLargeError extends Error {
|
|
53
|
+
constructor();
|
|
54
|
+
}
|
|
55
|
+
/** Mints a pass with the keyring's current key. The payload is validated first; `exp` is the caller's. */
|
|
56
|
+
export declare function mintPass({ keyring, payload }: {
|
|
57
|
+
keyring: IngressPassKeyring;
|
|
58
|
+
payload: IngressPassPayload;
|
|
59
|
+
}): string;
|
|
60
|
+
export type IngressPassRefusal = 'malformed' | 'unknown_kid' | 'bad_mac' | 'invalid_payload' | 'expired' | 'not_yet_valid' | 'wrong_origin' | 'wrong_environment';
|
|
61
|
+
export type IngressPassVerification = {
|
|
62
|
+
ok: true;
|
|
63
|
+
payload: IngressPassPayload;
|
|
64
|
+
kid: string;
|
|
65
|
+
} | {
|
|
66
|
+
ok: false;
|
|
67
|
+
reason: IngressPassRefusal;
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Verifies a pass locally: format and size, a keyring kid, a constant-time MAC, then the strict
|
|
71
|
+
* payload, `exp > now`, `iat ≤ now + 5`, the minting host's origin and this gateway's APP_ENV.
|
|
72
|
+
* The payload is parsed only after the MAC holds. Checking the ingress and provider against the
|
|
73
|
+
* matched route is the caller's.
|
|
74
|
+
*/
|
|
75
|
+
export declare function verifyPass({ keyring, token, now, origin, env }: {
|
|
76
|
+
keyring: IngressPassKeyring;
|
|
77
|
+
token: string;
|
|
78
|
+
/** Epoch seconds. */
|
|
79
|
+
now: number;
|
|
80
|
+
/** The public origin of the host the request arrived on. */
|
|
81
|
+
origin: string;
|
|
82
|
+
env: string;
|
|
83
|
+
}): IngressPassVerification;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { createHmac, hkdfSync, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { artifactNextIngressIdSchema, artifactNextIngressProviderRefSchema } from '../manifest-next-ingress.js';
|
|
4
|
+
import { INGRESS_MAX_PASS_SECONDS } from '../server/ingress-control-contract.js';
|
|
5
|
+
import { artifactExternalPrincipalSchema, artifactIpAddressSchema } from '../server/request-context.js';
|
|
6
|
+
/**
|
|
7
|
+
* The ingress pass (design §(d)): `sqx1.<kid>.<b64url(payload)>.<b64url(mac)>`, minted by the
|
|
8
|
+
* gateway after Atlas verified an identity provider's token, and verified locally on every API
|
|
9
|
+
* call with no Redis and no Atlas. Its MAC key is derived from the gateway-only pass keyring with
|
|
10
|
+
* a salt of its own, so neither the control token (Atlas holds it) nor the session key (it signs
|
|
11
|
+
* iframe cookies raw) can produce or verify a pass.
|
|
12
|
+
*/
|
|
13
|
+
export const INGRESS_PASS_PREFIX = 'sqx1';
|
|
14
|
+
export const INGRESS_PASS_MAX_BYTES = 2048;
|
|
15
|
+
/** Never `atlas-artifact-next`, the control-token salt Atlas derives with (design §(b.1) item 5). */
|
|
16
|
+
export const INGRESS_PASS_HKDF_SALT = 'artifact-next-gateway-ingress-pass';
|
|
17
|
+
export const INGRESS_PASS_HKDF_INFO = 'sqx1';
|
|
18
|
+
/** A pass issued up to this many seconds in the future (gateway replicas' clock skew) still verifies. */
|
|
19
|
+
export const INGRESS_PASS_IAT_SKEW_SECONDS = 5;
|
|
20
|
+
export const INGRESS_PASS_KEYRING_MAX_KEYS = 3;
|
|
21
|
+
/** At least 32 random bytes, base64-encoded. */
|
|
22
|
+
export const INGRESS_PASS_KEY_MIN_LENGTH = 43;
|
|
23
|
+
const passKidSchema = z.string().regex(/^[A-Za-z0-9_-]{1,32}$/, 'Expected a pass key id of letters, digits, _ or -');
|
|
24
|
+
const passKeySchema = z.string().min(INGRESS_PASS_KEY_MIN_LENGTH).max(1024);
|
|
25
|
+
/** `ARTIFACT_NEXT_INGRESS_PASS_KEYS`: `{"current":"k2","keys":{"k1":"…","k2":"…"}}`. */
|
|
26
|
+
export const ingressPassKeyringSchema = z.strictObject({
|
|
27
|
+
current: passKidSchema,
|
|
28
|
+
keys: z.record(passKidSchema, passKeySchema),
|
|
29
|
+
}).superRefine((keyring, ctx) => {
|
|
30
|
+
const kids = Object.keys(keyring.keys);
|
|
31
|
+
if (kids.length < 1 || kids.length > INGRESS_PASS_KEYRING_MAX_KEYS)
|
|
32
|
+
ctx.addIssue({ code: 'custom', path: ['keys'], message: `Expected 1-${INGRESS_PASS_KEYRING_MAX_KEYS} pass keys` });
|
|
33
|
+
if (!Object.hasOwn(keyring.keys, keyring.current))
|
|
34
|
+
ctx.addIssue({ code: 'custom', path: ['current'], message: 'The current pass key id must name a key' });
|
|
35
|
+
});
|
|
36
|
+
const principal = artifactExternalPrincipalSchema.shape;
|
|
37
|
+
const epochSeconds = z.number().int().nonnegative();
|
|
38
|
+
/** Strict: a pass carrying any other key is refused. */
|
|
39
|
+
export const ingressPassPayloadSchema = z.strictObject({
|
|
40
|
+
v: z.literal(1),
|
|
41
|
+
typ: z.literal('artifact-ingress-pass'),
|
|
42
|
+
env: z.string().min(1).max(64),
|
|
43
|
+
/** The ingress host's public origin; a pass verifies only on the host that minted it. */
|
|
44
|
+
origin: z.string().min(1).max(300),
|
|
45
|
+
projectId: z.string().min(1).max(255),
|
|
46
|
+
ingress: artifactNextIngressIdSchema,
|
|
47
|
+
provider: artifactNextIngressProviderRefSchema,
|
|
48
|
+
/** The binding epoch at exchange; informational only. */
|
|
49
|
+
bep: z.number().int().nonnegative(),
|
|
50
|
+
issuer: principal.issuer,
|
|
51
|
+
subject: principal.subject,
|
|
52
|
+
clientId: principal.clientId,
|
|
53
|
+
email: principal.email,
|
|
54
|
+
emailVerified: principal.emailVerified,
|
|
55
|
+
authTime: principal.authTime,
|
|
56
|
+
amr: principal.amr,
|
|
57
|
+
/** The identity provider's `iat`, which Atlas verified; revocation compares it, never the pass `iat`. */
|
|
58
|
+
tokenIssuedAt: principal.tokenIssuedAt,
|
|
59
|
+
exchangeIp: artifactIpAddressSchema,
|
|
60
|
+
jti: z.uuid(),
|
|
61
|
+
iat: epochSeconds,
|
|
62
|
+
exp: epochSeconds,
|
|
63
|
+
}).refine((payload) => payload.exp > payload.iat && payload.exp - payload.iat <= INGRESS_MAX_PASS_SECONDS, 'Invalid pass lifetime');
|
|
64
|
+
/** `K_kid = HKDF-SHA256(keyring[kid], salt, info, 32)`; never the raw keyring value. */
|
|
65
|
+
export function derivePassKey(secret) {
|
|
66
|
+
return Buffer.from(hkdfSync('sha256', Buffer.from(secret, 'utf8'), INGRESS_PASS_HKDF_SALT, INGRESS_PASS_HKDF_INFO, 32));
|
|
67
|
+
}
|
|
68
|
+
function mac({ key, kid, payload }) {
|
|
69
|
+
return createHmac('sha256', key).update(`${INGRESS_PASS_PREFIX}.${kid}.${payload}`).digest();
|
|
70
|
+
}
|
|
71
|
+
/** Derived keys per keyring, so every verification costs one HMAC and no HKDF. */
|
|
72
|
+
const derivedKeys = new WeakMap();
|
|
73
|
+
function keysOf(keyring) {
|
|
74
|
+
let keys = derivedKeys.get(keyring);
|
|
75
|
+
if (!keys) {
|
|
76
|
+
keys = new Map(Object.entries(keyring.keys).map(([kid, secret]) => [kid, derivePassKey(secret)]));
|
|
77
|
+
derivedKeys.set(keyring, keys);
|
|
78
|
+
}
|
|
79
|
+
return keys;
|
|
80
|
+
}
|
|
81
|
+
/** A valid payload whose pass would exceed INGRESS_PASS_MAX_BYTES: a refusal, never retryable. */
|
|
82
|
+
export class IngressPassTooLargeError extends Error {
|
|
83
|
+
constructor() {
|
|
84
|
+
super('Ingress pass exceeds its size limit');
|
|
85
|
+
this.name = 'IngressPassTooLargeError';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** Mints a pass with the keyring's current key. The payload is validated first; `exp` is the caller's. */
|
|
89
|
+
export function mintPass({ keyring, payload }) {
|
|
90
|
+
const parsed = ingressPassPayloadSchema.parse(payload);
|
|
91
|
+
const kid = keyring.current;
|
|
92
|
+
const key = keysOf(keyring).get(kid);
|
|
93
|
+
if (!key)
|
|
94
|
+
throw new Error('The current pass key is missing');
|
|
95
|
+
const encoded = Buffer.from(JSON.stringify(parsed)).toString('base64url');
|
|
96
|
+
const token = `${INGRESS_PASS_PREFIX}.${kid}.${encoded}.${mac({ key, kid, payload: encoded }).toString('base64url')}`;
|
|
97
|
+
if (Buffer.byteLength(token) > INGRESS_PASS_MAX_BYTES)
|
|
98
|
+
throw new IngressPassTooLargeError();
|
|
99
|
+
return token;
|
|
100
|
+
}
|
|
101
|
+
const SEGMENT = /^[A-Za-z0-9_-]+$/;
|
|
102
|
+
/**
|
|
103
|
+
* Verifies a pass locally: format and size, a keyring kid, a constant-time MAC, then the strict
|
|
104
|
+
* payload, `exp > now`, `iat ≤ now + 5`, the minting host's origin and this gateway's APP_ENV.
|
|
105
|
+
* The payload is parsed only after the MAC holds. Checking the ingress and provider against the
|
|
106
|
+
* matched route is the caller's.
|
|
107
|
+
*/
|
|
108
|
+
export function verifyPass({ keyring, token, now, origin, env }) {
|
|
109
|
+
if (Buffer.byteLength(token) > INGRESS_PASS_MAX_BYTES)
|
|
110
|
+
return { ok: false, reason: 'malformed' };
|
|
111
|
+
const parts = token.split('.');
|
|
112
|
+
if (parts.length !== 4 || parts[0] !== INGRESS_PASS_PREFIX)
|
|
113
|
+
return { ok: false, reason: 'malformed' };
|
|
114
|
+
const [, kid, payload, presented] = parts;
|
|
115
|
+
if (!passKidSchema.safeParse(kid).success || !SEGMENT.test(payload) || !SEGMENT.test(presented))
|
|
116
|
+
return { ok: false, reason: 'malformed' };
|
|
117
|
+
const key = keysOf(keyring).get(kid);
|
|
118
|
+
if (!key)
|
|
119
|
+
return { ok: false, reason: 'unknown_kid' };
|
|
120
|
+
const expected = mac({ key, kid, payload });
|
|
121
|
+
const actual = Buffer.from(presented, 'base64url');
|
|
122
|
+
if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
|
|
123
|
+
return { ok: false, reason: 'bad_mac' };
|
|
124
|
+
let decoded;
|
|
125
|
+
try {
|
|
126
|
+
decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return { ok: false, reason: 'invalid_payload' };
|
|
130
|
+
}
|
|
131
|
+
const parsed = ingressPassPayloadSchema.safeParse(decoded);
|
|
132
|
+
if (!parsed.success)
|
|
133
|
+
return { ok: false, reason: 'invalid_payload' };
|
|
134
|
+
if (parsed.data.exp <= now)
|
|
135
|
+
return { ok: false, reason: 'expired' };
|
|
136
|
+
if (parsed.data.iat > now + INGRESS_PASS_IAT_SKEW_SECONDS)
|
|
137
|
+
return { ok: false, reason: 'not_yet_valid' };
|
|
138
|
+
if (parsed.data.origin !== origin)
|
|
139
|
+
return { ok: false, reason: 'wrong_origin' };
|
|
140
|
+
if (parsed.data.env !== env)
|
|
141
|
+
return { ok: false, reason: 'wrong_environment' };
|
|
142
|
+
return { ok: true, payload: parsed.data, kid };
|
|
143
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ServerResponse } from 'node:http';
|
|
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 declare const INGRESS_RESPONSE_SECURITY_HEADERS: Readonly<Record<string, string>>;
|
|
13
|
+
export type IngressResponseRefusal = 'redirect' | 'event_stream' | 'content_type' | 'missing_content_type';
|
|
14
|
+
export type IngressResponseDecision = {
|
|
15
|
+
allowed: true;
|
|
16
|
+
headers: Record<string, string | string[]>;
|
|
17
|
+
} | {
|
|
18
|
+
allowed: false;
|
|
19
|
+
reason: IngressResponseRefusal;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* The upstream's declared body length, read from the raw head: the shared sanitising removes
|
|
23
|
+
* `content-length` as a hop header, so the policy must be handed it explicitly. The same parser
|
|
24
|
+
* the development tunnel uses, so hosted and development ingress judge a head alike.
|
|
25
|
+
*/
|
|
26
|
+
export { declaredLength as declaredContentLength } from '../tunnel/external.js';
|
|
27
|
+
/**
|
|
28
|
+
* Decides an upstream response on an ingress route. `headers` are the app's headers after the
|
|
29
|
+
* shared private-header sanitising (no `content-length`); `declaredLength` is the upstream's own
|
|
30
|
+
* `Content-Length`; `responses` are the matched route's declared content types.
|
|
31
|
+
*/
|
|
32
|
+
export declare function applyIngressResponsePolicy({ status, method, headers, declaredLength, responses }: {
|
|
33
|
+
status: number;
|
|
34
|
+
method: string;
|
|
35
|
+
headers: Readonly<Record<string, string | string[]>>;
|
|
36
|
+
declaredLength: number | null;
|
|
37
|
+
responses: readonly string[];
|
|
38
|
+
}): IngressResponseDecision;
|
|
39
|
+
/**
|
|
40
|
+
* Applies the policy to an upstream head and writes the allowed status and headers onto the
|
|
41
|
+
* gateway response. False when the response is refused (502 before any body byte), including a
|
|
42
|
+
* declared length above the ingress response cap.
|
|
43
|
+
*/
|
|
44
|
+
export declare function writeIngressResponseHead({ response, status, method, headers, declaredLength, responses, maxResponseBytes }: {
|
|
45
|
+
response: ServerResponse;
|
|
46
|
+
status: number;
|
|
47
|
+
method: string;
|
|
48
|
+
headers: Readonly<Record<string, string | string[]>>;
|
|
49
|
+
declaredLength: number | null;
|
|
50
|
+
responses: readonly string[];
|
|
51
|
+
maxResponseBytes: number;
|
|
52
|
+
}): boolean;
|
|
53
|
+
/** An ingress caller gets a deterministic 504 before its 60 s context expires (design §(e) "Limits"). */
|
|
54
|
+
export declare const INGRESS_PROXY_LIMITS: Readonly<{
|
|
55
|
+
maxTimeoutMs: 55000;
|
|
56
|
+
idleWriteTimeoutMs: 10000;
|
|
57
|
+
}>;
|
|
58
|
+
export type IngressProxyErrorStatus = 400 | 413 | 502 | 503 | 504;
|
|
59
|
+
/** Error contract codes (design §(i)) for the failures the proxy itself decides. */
|
|
60
|
+
export declare const INGRESS_PROXY_ERROR_CODES: Readonly<Record<IngressProxyErrorStatus, string>>;
|
|
61
|
+
/** External ingress mode (design §(e)): cross-origin data only, under the matched route's policy. */
|
|
62
|
+
export interface HostedArtifactIngressMode {
|
|
63
|
+
/** The matched route's declared response content types. */
|
|
64
|
+
responses: readonly string[];
|
|
65
|
+
/** The matched ingress's declared request headers, forwarded on top of the fixed allowlist. */
|
|
66
|
+
requestHeaders: readonly string[];
|
|
67
|
+
/** Writes the gateway's error-contract JSON with the caller's CORS headers. */
|
|
68
|
+
errorWriter: (input: {
|
|
69
|
+
status: IngressProxyErrorStatus;
|
|
70
|
+
code: string;
|
|
71
|
+
}) => void;
|
|
72
|
+
/** Destroys a response whose writes stay blocked this long (default INGRESS_PROXY_LIMITS.idleWriteTimeoutMs). */
|
|
73
|
+
idleWriteTimeoutMs?: number;
|
|
74
|
+
}
|
|
@@ -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
|
+
}
|