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