@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
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { BlockList } from 'node:net';
|
|
3
|
+
import { matchIngressRoute } from '../server/ingress-routes.js';
|
|
4
|
+
import { artifactIpAddressSchema } from '../server/request-context.js';
|
|
5
|
+
import { ArtifactAdmissionError } from './admission.js';
|
|
6
|
+
import { INGRESS_RESPONSE_SECURITY_HEADERS } from './ingress-response.js';
|
|
7
|
+
/**
|
|
8
|
+
* The external ingress branch of the gateway (design §(e)): the front door for callers that are
|
|
9
|
+
* not Atlas users, on hosts `ingress-<24 hex>.<runtime domain>`. It shares nothing with the
|
|
10
|
+
* iframe session path: no bootstrap, no session cookie, no `authorize()`, and no 401/404→403 remap.
|
|
11
|
+
* Every check before authentication is local: canonical host, edge header, snapshot lookup,
|
|
12
|
+
* client address, canonical path, declared route and CORS. The pass, admission and grant
|
|
13
|
+
* (`handlers.admit`) come after them, so nothing unauthenticated reaches Redis or Atlas.
|
|
14
|
+
*/
|
|
15
|
+
export const INGRESS_TOKEN_PATH = '/_sequence/ingress/token';
|
|
16
|
+
export const INGRESS_LOGOUT_PATH = '/_sequence/ingress/logout';
|
|
17
|
+
export const INGRESS_LIMITS = Object.freeze({
|
|
18
|
+
/** A pass is at most 2 KiB; an API (and logout) `Authorization` header above this is refused unread. */
|
|
19
|
+
maxApiAuthorizationBytes: 4 * 1024,
|
|
20
|
+
/** The exchange carries the identity provider's token, which may be larger than a pass. */
|
|
21
|
+
maxExchangeAuthorizationBytes: 8 * 1024,
|
|
22
|
+
maxResponseBytes: 32 * 1024 * 1024,
|
|
23
|
+
preflightMaxAgeSeconds: 600,
|
|
24
|
+
});
|
|
25
|
+
export const INGRESS_EXPOSED_HEADERS = 'retry-after, x-request-id, www-authenticate, content-disposition';
|
|
26
|
+
export const BASE_ALLOWED_REQUEST_HEADERS = ['authorization', 'content-type'];
|
|
27
|
+
/** Thrown by an ingress handler; written through the ingress writer, never the session remap. */
|
|
28
|
+
export class IngressError extends Error {
|
|
29
|
+
status;
|
|
30
|
+
code;
|
|
31
|
+
extra;
|
|
32
|
+
constructor(status, code, extra = {}) {
|
|
33
|
+
super(`Ingress request refused: ${code}`);
|
|
34
|
+
this.status = status;
|
|
35
|
+
this.code = code;
|
|
36
|
+
this.extra = extra;
|
|
37
|
+
this.name = 'IngressError';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function escapeRegExp(value) {
|
|
41
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
42
|
+
}
|
|
43
|
+
/** Every ingress host's first label starts with this. */
|
|
44
|
+
export const INGRESS_HOST_PREFIX = 'ingress-';
|
|
45
|
+
/** The raw `Host` exactly as an ingress host must arrive: lowercase, no port, no trailing dot. */
|
|
46
|
+
export function isIngressHost({ rawHost, runtimeDomain }) {
|
|
47
|
+
return rawHost !== undefined && new RegExp(`^ingress-[a-f0-9]{24}\\.${escapeRegExp(runtimeDomain)}$`).test(rawHost);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Every host the ingress branch owns: any spelling (any case, a port, a trailing dot) of any
|
|
51
|
+
* `ingress-*` name under the runtime domain, canonical or not. Edge allowlists exempt `ingress-*`
|
|
52
|
+
* hosts, so a non-canonical one (`ingress-x.<domain>`, `ingress-<24 hex>.foo.<domain>`) must get the
|
|
53
|
+
* ingress branch's local 404 and never fall into the iframe path: session ticket exchange,
|
|
54
|
+
* bootstrap, authorize and WebSocket upgrades. No application or preview host starts `ingress-`.
|
|
55
|
+
*/
|
|
56
|
+
export function resemblesIngressHost({ rawHost, runtimeDomain }) {
|
|
57
|
+
if (rawHost === undefined)
|
|
58
|
+
return false;
|
|
59
|
+
const hostname = rawHost.toLowerCase().replace(/:[0-9]*$/, '').replace(/\.$/, '');
|
|
60
|
+
return hostname.startsWith(INGRESS_HOST_PREFIX) && hostname.endsWith(`.${runtimeDomain}`);
|
|
61
|
+
}
|
|
62
|
+
/** Every value of a header as sent, so a repeated header is seen (Node keeps only the first of some). */
|
|
63
|
+
export function rawHeaderValues(request, name) {
|
|
64
|
+
const values = [];
|
|
65
|
+
for (let index = 0; index + 1 < request.rawHeaders.length; index += 2) {
|
|
66
|
+
if (request.rawHeaders[index].toLowerCase() === name)
|
|
67
|
+
values.push(request.rawHeaders[index + 1]);
|
|
68
|
+
}
|
|
69
|
+
return values;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* At least this many characters. Shorter configured secrets are ignored, so a misconfigured empty
|
|
73
|
+
* value (`current,` split on commas) can never match an empty header and open the edge check.
|
|
74
|
+
*/
|
|
75
|
+
export const MIN_EDGE_SECRET_LENGTH = 32;
|
|
76
|
+
/** Every `Authorization` value at most `maxBytes`, and at most one of them: none is ever picked. */
|
|
77
|
+
export function hasAcceptableAuthorization({ request, maxBytes }) {
|
|
78
|
+
const values = rawHeaderValues(request, 'authorization');
|
|
79
|
+
return values.length <= 1 && values.every((value) => Buffer.byteLength(value) <= maxBytes);
|
|
80
|
+
}
|
|
81
|
+
function digest(value) {
|
|
82
|
+
return createHash('sha256').update(value).digest();
|
|
83
|
+
}
|
|
84
|
+
/** The presented `x-sequence-edge` values match: exactly one, equal in constant time to a usable secret. */
|
|
85
|
+
export function edgeValuesMatch({ values, secrets }) {
|
|
86
|
+
// Only a usable secret can match, so the presented value is at least as long too.
|
|
87
|
+
const usable = secrets.filter((secret) => secret.length >= MIN_EDGE_SECRET_LENGTH);
|
|
88
|
+
if (values.length !== 1)
|
|
89
|
+
return false;
|
|
90
|
+
const presented = digest(values[0]);
|
|
91
|
+
let matched = false;
|
|
92
|
+
// Every secret is compared, so timing does not reveal which one matched.
|
|
93
|
+
for (const secret of usable)
|
|
94
|
+
matched = timingSafeEqual(presented, digest(secret)) || matched;
|
|
95
|
+
return matched;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Exactly one `x-sequence-edge`, equal in constant time to one of the configured secrets (current
|
|
99
|
+
* and next). Only our Cloudflare zone's transform rule injects it, so a request that reached the
|
|
100
|
+
* gateway another way, or through another Cloudflare zone, is refused (threat T17).
|
|
101
|
+
*/
|
|
102
|
+
export function hasValidEdgeHeader({ request, secrets }) {
|
|
103
|
+
return edgeValuesMatch({ values: rawHeaderValues(request, 'x-sequence-edge'), secrets });
|
|
104
|
+
}
|
|
105
|
+
// Cloudflare's Pseudo IPv4 range: an address Cloudflare invents for an IPv6 client, never a real one.
|
|
106
|
+
const PSEUDO_IPV4 = new BlockList();
|
|
107
|
+
PSEUDO_IPV4.addSubnet('240.0.0.0', 4, 'ipv4');
|
|
108
|
+
PSEUDO_IPV4.addSubnet('::ffff:f000:0', 100, 'ipv6');
|
|
109
|
+
/**
|
|
110
|
+
* The client address: exactly one `CF-Connecting-IP` that parses and is not a Pseudo IPv4
|
|
111
|
+
* address, else null (400). Never X-Forwarded-For or the socket address.
|
|
112
|
+
*/
|
|
113
|
+
export function edgeClientIp(request) {
|
|
114
|
+
const values = rawHeaderValues(request, 'cf-connecting-ip');
|
|
115
|
+
if (values.length !== 1)
|
|
116
|
+
return null;
|
|
117
|
+
const value = values[0];
|
|
118
|
+
if (!artifactIpAddressSchema.safeParse(value).success)
|
|
119
|
+
return null;
|
|
120
|
+
const family = value.includes(':') ? 'ipv6' : 'ipv4';
|
|
121
|
+
return PSEUDO_IPV4.check(value, family) ? null : value;
|
|
122
|
+
}
|
|
123
|
+
/** Exactly one ingress on the host may declare the matched (method, path); ambiguity is no match. */
|
|
124
|
+
export function resolveIngressRoute({ entry, method, path }) {
|
|
125
|
+
let found = null;
|
|
126
|
+
for (const ingress of entry.ingresses) {
|
|
127
|
+
const match = matchIngressRoute(ingress.routes, method, path);
|
|
128
|
+
if (!match)
|
|
129
|
+
continue;
|
|
130
|
+
if (found)
|
|
131
|
+
return null;
|
|
132
|
+
found = { ingress, match };
|
|
133
|
+
}
|
|
134
|
+
return found;
|
|
135
|
+
}
|
|
136
|
+
/** The caller's `Origin`, or null when absent. Several `Origin` headers never match an allowed origin. */
|
|
137
|
+
export function callerOrigin(request) {
|
|
138
|
+
const values = rawHeaderValues(request, 'origin');
|
|
139
|
+
if (values.length === 0)
|
|
140
|
+
return null;
|
|
141
|
+
return values.length === 1 ? values[0] : '';
|
|
142
|
+
}
|
|
143
|
+
export function ingressCorsHeaders(origin) {
|
|
144
|
+
return { 'access-control-allow-origin': origin, 'access-control-expose-headers': INGRESS_EXPOSED_HEADERS, vary: 'Origin' };
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Writes a gateway-decided ingress response: the error contract body, `no-store`, the response
|
|
148
|
+
* security headers, and CORS when the caller's origin is allowed, so the page can read the error.
|
|
149
|
+
*/
|
|
150
|
+
export function writeIngressError({ response, status, code, corsOrigin, retryAfterSeconds, wwwAuthenticate }) {
|
|
151
|
+
if (response.headersSent) {
|
|
152
|
+
response.destroy();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
for (const name of response.getHeaderNames())
|
|
156
|
+
if (name !== 'x-request-id')
|
|
157
|
+
response.removeHeader(name);
|
|
158
|
+
const authenticate = wwwAuthenticate ?? (code === 'invalid_token' ? 'Bearer error="invalid_token"' : undefined);
|
|
159
|
+
const retryAfter = retryAfterSeconds ?? (status === 503 ? 2 : undefined);
|
|
160
|
+
response.writeHead(status, {
|
|
161
|
+
...INGRESS_RESPONSE_SECURITY_HEADERS,
|
|
162
|
+
'content-type': 'application/json',
|
|
163
|
+
connection: 'close',
|
|
164
|
+
...(corsOrigin ? ingressCorsHeaders(corsOrigin) : {}),
|
|
165
|
+
...(authenticate ? { 'www-authenticate': authenticate } : {}),
|
|
166
|
+
...(retryAfter !== undefined ? { 'retry-after': String(retryAfter) } : {}),
|
|
167
|
+
});
|
|
168
|
+
response.end(JSON.stringify({ error: code }));
|
|
169
|
+
}
|
|
170
|
+
/** The union of a host's allowed origins: CORS for errors decided before a route is known. */
|
|
171
|
+
export function hostOrigins(entry) {
|
|
172
|
+
return new Set(entry?.ingresses.flatMap((ingress) => ingress.allowedOrigins) ?? []);
|
|
173
|
+
}
|
|
174
|
+
export function corsFor(origin, allowed) {
|
|
175
|
+
return origin !== null && allowed.has(origin) ? origin : null;
|
|
176
|
+
}
|
|
177
|
+
export function requestedHeaders(request) {
|
|
178
|
+
const value = request.headers['access-control-request-headers'];
|
|
179
|
+
if (typeof value !== 'string')
|
|
180
|
+
return [];
|
|
181
|
+
return value.split(',').map((name) => name.trim().toLowerCase()).filter(Boolean);
|
|
182
|
+
}
|
|
183
|
+
/** Host, Origin and edge values from the bytes Node had buffered when the header block overflowed. */
|
|
184
|
+
export function requestLineHeaders(raw) {
|
|
185
|
+
if (!raw)
|
|
186
|
+
return { edge: [] };
|
|
187
|
+
const lines = raw.subarray(0, 64 * 1024).toString('latin1').split('\r\n');
|
|
188
|
+
const values = (name) => lines.filter((line) => line.toLowerCase().startsWith(`${name}:`)).map((line) => line.slice(name.length + 1).trim());
|
|
189
|
+
const hosts = values('host'), origins = values('origin');
|
|
190
|
+
return { host: hosts.length === 1 ? hosts[0] : undefined, origin: origins.length === 1 ? origins[0] : null, edge: values('x-sequence-edge') };
|
|
191
|
+
}
|
|
192
|
+
export function writeIngressRefusal({ response, error, corsOrigin }) {
|
|
193
|
+
if (error instanceof IngressError) {
|
|
194
|
+
writeIngressError({ response, status: error.status, code: error.code, corsOrigin, ...error.extra });
|
|
195
|
+
}
|
|
196
|
+
else if (error instanceof ArtifactAdmissionError) {
|
|
197
|
+
const status = error.status === 429 ? 429 : 503;
|
|
198
|
+
writeIngressError({ response, status, code: status === 429 ? 'rate_limited' : 'temporarily_unavailable', corsOrigin, retryAfterSeconds: error.retryAfterSeconds });
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
// An unexpected failure is an outage the caller may retry with its idempotency key.
|
|
202
|
+
writeIngressError({ response, status: 503, code: 'temporarily_unavailable', corsOrigin });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -109,6 +109,8 @@ async function invokeAdmittedLattice(input) {
|
|
|
109
109
|
});
|
|
110
110
|
const result = await (input.invoke ?? invokeHostedArtifact)({
|
|
111
111
|
upstreamUrl: input.grant.upstreamUrl,
|
|
112
|
+
runtimeTransport: input.grant.runtimeTransport,
|
|
113
|
+
environment: input.grant.environment,
|
|
112
114
|
...(input.grant.upstreamAudience ? { upstreamAudience: input.grant.upstreamAudience } : {}),
|
|
113
115
|
path: input.grant.path,
|
|
114
116
|
body: input.parsed.body ?? {},
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { request as httpsRequest } from 'node:https';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
upstreamAudience?: string;
|
|
2
|
+
import { type HostedRuntimeTarget } from './hosted-proxy.js';
|
|
3
|
+
export interface HostedArtifactInvocation extends HostedRuntimeTarget {
|
|
5
4
|
path: string;
|
|
6
5
|
body: unknown;
|
|
7
6
|
requestContext: string;
|
|
@@ -30,7 +29,7 @@ export interface HostedArtifactInvocation {
|
|
|
30
29
|
}
|
|
31
30
|
/**
|
|
32
31
|
* Buffered POST into a hosted Next.js app. The destination must already be a
|
|
33
|
-
*
|
|
32
|
+
* admitted runtime origin Atlas resolved; this rejects anything else before dialing.
|
|
34
33
|
*/
|
|
35
34
|
export declare function invokeHostedArtifact(input: HostedArtifactInvocation): Promise<{
|
|
36
35
|
status: number;
|
|
@@ -1,34 +1,36 @@
|
|
|
1
1
|
import { request as httpsRequest } from 'node:https';
|
|
2
|
+
import { request as httpRequest } from 'node:http';
|
|
2
3
|
import { assertSafeRequest } from '../tunnel/protocol.js';
|
|
3
4
|
import { isManagedServerInvokePath } from '../server/managed-server-path.js';
|
|
4
|
-
import { HOSTED_ARTIFACT_PROXY_LIMITS,
|
|
5
|
+
import { HOSTED_ARTIFACT_PROXY_LIMITS, resolveHostedTarget, hostedIdentityToken } from './hosted-proxy.js';
|
|
5
6
|
const BODY_CAP = 1024 * 1024;
|
|
6
7
|
/**
|
|
7
8
|
* Buffered POST into a hosted Next.js app. The destination must already be a
|
|
8
|
-
*
|
|
9
|
+
* admitted runtime origin Atlas resolved; this rejects anything else before dialing.
|
|
9
10
|
*/
|
|
10
11
|
export async function invokeHostedArtifact(input) {
|
|
11
12
|
if (!isManagedServerInvokePath(input.path))
|
|
12
13
|
throw new Error('Invalid managed server path');
|
|
13
|
-
const upstream =
|
|
14
|
-
const audience = input.upstreamAudience === undefined ? upstream : cloudRunOrigin(input.upstreamAudience);
|
|
14
|
+
const { upstream, audience } = resolveHostedTarget({ target: input });
|
|
15
15
|
assertSafeRequest('POST', input.path);
|
|
16
16
|
const payload = Buffer.from(JSON.stringify(input.body ?? {}));
|
|
17
17
|
if (payload.byteLength > BODY_CAP)
|
|
18
18
|
throw new Error('Managed server body exceeds 1 MiB');
|
|
19
19
|
const timeoutMs = Math.min(Math.max(input.timeoutMs, 1), HOSTED_ARTIFACT_PROXY_LIMITS.maxTimeoutMs);
|
|
20
20
|
const signal = invocationSignal({ timeoutMs, signal: input.signal });
|
|
21
|
-
const token = await withSignal((input.identityTokenProvider ?? hostedIdentityToken)({ audience: audience.origin, signal }), signal);
|
|
22
|
-
if (!/^[A-Za-z0-9._~-]+$/.test(token) || token.length > 16384)
|
|
23
|
-
throw new Error('Invalid platform token');
|
|
24
21
|
const headers = {
|
|
25
22
|
host: upstream.host,
|
|
26
23
|
'content-type': 'application/json',
|
|
27
24
|
'content-length': String(payload.byteLength),
|
|
28
25
|
'accept-encoding': 'identity',
|
|
29
|
-
'x-serverless-authorization': `Bearer ${token}`,
|
|
30
26
|
'x-sequence-artifact-context': input.requestContext,
|
|
31
27
|
};
|
|
28
|
+
if (audience) {
|
|
29
|
+
const token = await withSignal((input.identityTokenProvider ?? hostedIdentityToken)({ audience: audience.origin, signal }), signal);
|
|
30
|
+
if (!/^[A-Za-z0-9._~-]+$/.test(token) || token.length > 16384)
|
|
31
|
+
throw new Error('Invalid platform token');
|
|
32
|
+
headers['x-serverless-authorization'] = `Bearer ${token}`;
|
|
33
|
+
}
|
|
32
34
|
const hop = { origin: upstream, path: input.path, headers, body: payload, timeoutMs, signal };
|
|
33
35
|
const response = await withSignal(input.dispatch ? input.dispatch(hop) : postCloudRun({ ...hop, dial: input.dial }), signal);
|
|
34
36
|
if (response.body.byteLength > BODY_CAP)
|
|
@@ -100,7 +102,7 @@ function postCloudRun(input) {
|
|
|
100
102
|
stop();
|
|
101
103
|
resolve(value);
|
|
102
104
|
};
|
|
103
|
-
const request = (input.dial ?? httpsRequest)(input.origin, {
|
|
105
|
+
const request = (input.dial ?? (input.origin.protocol === 'http:' ? httpRequest : httpsRequest))(input.origin, {
|
|
104
106
|
method: 'POST',
|
|
105
107
|
path: input.path,
|
|
106
108
|
headers: input.headers,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface HostedRuntimeTarget {
|
|
2
|
+
upstreamUrl: string;
|
|
3
|
+
/** Canonical service audience, used only for the Cloud Run transport. */
|
|
4
|
+
upstreamAudience?: string;
|
|
5
|
+
/** Missing on older control-plane records; these remain Cloud Run targets. */
|
|
6
|
+
runtimeTransport?: 'cloud-run' | 'local-docker';
|
|
7
|
+
environment?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function cloudRunOrigin(value: string): URL;
|
|
10
|
+
/** Local deployments retain hosted authorization and signed contexts; only their private hop differs. */
|
|
11
|
+
export declare function resolveHostedTarget({ target, environment }: {
|
|
12
|
+
target: HostedRuntimeTarget;
|
|
13
|
+
environment?: NodeJS.ProcessEnv;
|
|
14
|
+
}): {
|
|
15
|
+
upstream: URL;
|
|
16
|
+
audience: URL | undefined;
|
|
17
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function cloudRunOrigin(value) {
|
|
2
|
+
const url = new URL(value);
|
|
3
|
+
if (!/^https:\/\/[a-z0-9.-]+\.run\.app\/?$/.test(value) || url.protocol !== 'https:' ||
|
|
4
|
+
url.username || url.password || url.port || url.pathname !== '/' || url.search || url.hash ||
|
|
5
|
+
!url.hostname.split('.').every((part) => /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(part)))
|
|
6
|
+
throw new Error('Invalid hosted deployment origin');
|
|
7
|
+
return url;
|
|
8
|
+
}
|
|
9
|
+
/** Local deployments retain hosted authorization and signed contexts; only their private hop differs. */
|
|
10
|
+
export function resolveHostedTarget({ target, environment = process.env }) {
|
|
11
|
+
if (target.runtimeTransport === undefined || target.runtimeTransport === 'cloud-run') {
|
|
12
|
+
const upstream = cloudRunOrigin(target.upstreamUrl);
|
|
13
|
+
return { upstream, audience: target.upstreamAudience === undefined ? upstream : cloudRunOrigin(target.upstreamAudience) };
|
|
14
|
+
}
|
|
15
|
+
if (target.runtimeTransport !== 'local-docker' || environment.ARTIFACT_NEXT_RUNTIME_PROVIDER !== 'local-docker' ||
|
|
16
|
+
environment.APP_ENV !== 'development' || target.environment !== 'development' || environment.K_SERVICE || environment.VERCEL ||
|
|
17
|
+
!/^dev-[A-Za-z0-9_-]+$/.test(environment.LAKEBASE_BRANCH ?? '') || environment.LAKEBASE_BRANCH === 'dev-cloud-agents' ||
|
|
18
|
+
target.upstreamAudience !== undefined)
|
|
19
|
+
throw new Error('Invalid local deployment transport');
|
|
20
|
+
// Validate the original spelling: URL normalization otherwise admits integer/hex IPs and aliases.
|
|
21
|
+
const match = /^http:\/\/127\.0\.0\.1:([1-9][0-9]{3,4})$/.exec(target.upstreamUrl);
|
|
22
|
+
const port = Number(match?.[1]);
|
|
23
|
+
if (!match || port < 1024 || port > 65535)
|
|
24
|
+
throw new Error('Invalid local deployment origin');
|
|
25
|
+
return { upstream: new URL(target.upstreamUrl), audience: undefined };
|
|
26
|
+
}
|
package/dist/gateway/server.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { ArtifactGatewayControl } from './control.js';
|
|
|
4
4
|
import { type GatewaySessionSameSite } from './session.js';
|
|
5
5
|
import { type ArtifactRequestAdmission } from './admission.js';
|
|
6
6
|
import { type GatewayControlAdmission } from './control-admission.js';
|
|
7
|
+
import { type ArtifactGatewayIngressOptions } from './ingress.js';
|
|
7
8
|
export interface ArtifactGatewayOptions {
|
|
8
9
|
control: ArtifactGatewayControl;
|
|
9
10
|
runtimeDomain: string;
|
|
@@ -25,6 +26,8 @@ export interface ArtifactGatewayOptions {
|
|
|
25
26
|
/** Rate limits `/_sequence/session` and `/_sequence/tunnel` before they reach Atlas. Without it
|
|
26
27
|
* both endpoints fail closed. */
|
|
27
28
|
controlAdmission?: Pick<GatewayControlAdmission, 'admit'>;
|
|
29
|
+
/** External ingress front door (design §(e)). Absent: every `ingress-*` host answers 404. */
|
|
30
|
+
ingress?: ArtifactGatewayIngressOptions;
|
|
28
31
|
/** Reuse of a positive Atlas authorization (default GATEWAY_AUTHORIZATION_TTL_MS; 0 disables). */
|
|
29
32
|
authorizationCacheTtlMs?: number;
|
|
30
33
|
onError?: (event: {
|
package/dist/gateway/server.js
CHANGED
|
@@ -13,6 +13,9 @@ import { gatewayClientAddress } from './control-admission.js';
|
|
|
13
13
|
import { gatewayAuthorizationKey, PositiveDecisionCache, tunnelAuthorizationKey } from './authorization-cache.js';
|
|
14
14
|
import { GatewayUsageReporter } from './usage.js';
|
|
15
15
|
import { artifactNextFrameworkBuildIdSchema } from '../runtime-contracts.js';
|
|
16
|
+
import { createDevelopmentIngressProxy } from './ingress-development.js';
|
|
17
|
+
import { createIngressHandler, handleClientErrors } from './ingress-handler.js';
|
|
18
|
+
import { resemblesIngressHost } from './ingress.js';
|
|
16
19
|
function json(response, status, error) {
|
|
17
20
|
if (response.headersSent) {
|
|
18
21
|
response.destroy();
|
|
@@ -115,6 +118,7 @@ export function createArtifactGateway(options) {
|
|
|
115
118
|
const tunnelAuthorizations = new PositiveDecisionCache({ ttlMs: options.authorizationCacheTtlMs });
|
|
116
119
|
const usage = new GatewayUsageReporter({ send: events => options.control.usage(events), onFailure: failure => options.onError?.(failure) });
|
|
117
120
|
let draining = false;
|
|
121
|
+
const isIngressRequest = (request) => resemblesIngressHost({ rawHost: header(request, 'host'), runtimeDomain: options.runtimeDomain });
|
|
118
122
|
/** Admission for the unauthenticated endpoints; the release frees the in-flight permit. */
|
|
119
123
|
async function admitControlCall(endpoint, request) {
|
|
120
124
|
if (!options.controlAdmission)
|
|
@@ -254,7 +258,16 @@ export function createArtifactGateway(options) {
|
|
|
254
258
|
onTunnelClosed: lease => { if (leases.get(lease.sessionId)?.lease.epoch === lease.epoch)
|
|
255
259
|
leases.delete(lease.sessionId); },
|
|
256
260
|
});
|
|
261
|
+
// A development target is proxied over its session tunnel only by a development gateway (design §(j.3) step 3).
|
|
262
|
+
const proxyDevelopment = options.ingress?.appEnv === 'development' ? createDevelopmentIngressProxy({ broker }) : undefined;
|
|
263
|
+
const ingress = createIngressHandler({ options: options.ingress, runtimeDomain: options.runtimeDomain, protocol: new URL(gatewayOrigin).protocol, isDraining: () => draining, onError: options.onError, usage, proxyDevelopment });
|
|
257
264
|
const server = createServer({ maxHeaderSize: 32 * 1024, requestTimeout: 60_000, headersTimeout: 15_000 }, (request, response) => {
|
|
265
|
+
// Before readiness, bootstrap and requestOrigin(): an ingress host, canonical or not, never
|
|
266
|
+
// reaches a session-path handler or the 401/404→403 remap below (design §(e) "Branch placement").
|
|
267
|
+
if (isIngressRequest(request)) {
|
|
268
|
+
void ingress.handle(request, response);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
258
271
|
const startedAt = new Date().toISOString(), started = performance.now();
|
|
259
272
|
void (async () => {
|
|
260
273
|
if (isReadinessRequest({ request, gatewayOrigin, runtimeDomain: options.runtimeDomain })) {
|
|
@@ -350,6 +363,11 @@ export function createArtifactGateway(options) {
|
|
|
350
363
|
rejectUpgrade(socket, 503);
|
|
351
364
|
return;
|
|
352
365
|
}
|
|
366
|
+
// An ingress answers data requests only: no WebSocket, no tunnel.
|
|
367
|
+
if (isIngressRequest(request)) {
|
|
368
|
+
rejectUpgrade(socket, 404);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
353
371
|
if (request.url === '/_sequence/tunnel') {
|
|
354
372
|
await broker.handleTunnelUpgrade(request, socket, head);
|
|
355
373
|
return;
|
|
@@ -362,6 +380,9 @@ export function createArtifactGateway(options) {
|
|
|
362
380
|
await broker.handleWebSocketUpgrade(request, socket, head, authorized.target.sessionId);
|
|
363
381
|
})().catch(() => rejectUpgrade(socket));
|
|
364
382
|
});
|
|
383
|
+
// Only with the ingress branch configured: without it, Node's own clientError replies stay untouched.
|
|
384
|
+
if (options.ingress)
|
|
385
|
+
handleClientErrors({ server, ingress });
|
|
365
386
|
return {
|
|
366
387
|
server,
|
|
367
388
|
/** Observations recorded but not yet confirmed by Atlas: what a forced exit would lose. */
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
/** Response-header rules for the iframe session path of the hosted proxy (an ingress uses `ingress-response.ts`). */
|
|
3
|
+
export declare function rewriteLocation({ location, upstream, publicOrigin }: {
|
|
4
|
+
location: string;
|
|
5
|
+
upstream: URL;
|
|
6
|
+
publicOrigin: string;
|
|
7
|
+
}): string;
|
|
8
|
+
export declare function mergeFramePolicy({ application, trusted }: {
|
|
9
|
+
application: string | string[];
|
|
10
|
+
trusted: string | string[];
|
|
11
|
+
}): string[];
|
|
12
|
+
export declare function enforceBrowserCachePolicy({ request, response }: {
|
|
13
|
+
request: IncomingMessage;
|
|
14
|
+
response: ServerResponse;
|
|
15
|
+
}): void;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { isSharedCacheDirective, SHARED_CACHE_REFUSAL } from '../tunnel/protocol.js';
|
|
2
|
+
/** Response-header rules for the iframe session path of the hosted proxy (an ingress uses `ingress-response.ts`). */
|
|
3
|
+
export function rewriteLocation({ location, upstream, publicOrigin }) {
|
|
4
|
+
if (!/^(?:https?:)?\/\//i.test(location))
|
|
5
|
+
return location;
|
|
6
|
+
const url = new URL(location, upstream);
|
|
7
|
+
return url.origin === upstream.origin ? `${publicOrigin}${url.pathname}${url.search}${url.hash}` : location;
|
|
8
|
+
}
|
|
9
|
+
export function mergeFramePolicy({ application, trusted }) {
|
|
10
|
+
const policies = Array.isArray(application) ? application : [application];
|
|
11
|
+
const frames = Array.isArray(trusted) ? trusted : [trusted];
|
|
12
|
+
return [...policies.map((policy) => policy.split(';')
|
|
13
|
+
.filter((directive) => directive.trim().split(/\s/, 1)[0].toLowerCase() !== 'frame-ancestors')
|
|
14
|
+
.map((directive) => directive.trim()).filter(Boolean).join('; ')).filter(Boolean), ...frames];
|
|
15
|
+
}
|
|
16
|
+
export function enforceBrowserCachePolicy({ request, response }) {
|
|
17
|
+
const path = (request.url ?? '/').split('?', 1)[0];
|
|
18
|
+
let staticAsset = false;
|
|
19
|
+
try {
|
|
20
|
+
const segments = path.slice(1).split('/').map((segment) => decodeURIComponent(segment));
|
|
21
|
+
staticAsset = path.startsWith('/_next/static/') && /\.(?:js|css)$/.test(path) &&
|
|
22
|
+
segments.every((segment) => segment !== '' && segment !== '.' && segment !== '..' && !/[\\/]/.test(segment));
|
|
23
|
+
}
|
|
24
|
+
catch { /* Malformed or ambiguous asset paths retain the default no-store policy. */ }
|
|
25
|
+
const contentType = response.getHeader('content-type');
|
|
26
|
+
const mediaType = typeof contentType === 'string' ? contentType.split(';', 1)[0].trim().toLowerCase() : undefined;
|
|
27
|
+
const assetType = path.endsWith('.css') ? mediaType === 'text/css' : mediaType === 'text/javascript' || mediaType === 'application/javascript';
|
|
28
|
+
const revalidateAsset = staticAsset && ['GET', 'HEAD'].includes(request.method ?? 'GET') &&
|
|
29
|
+
(response.statusCode === 200 || response.statusCode === 304) &&
|
|
30
|
+
(assetType || (response.statusCode === 304 && contentType === undefined));
|
|
31
|
+
// Every protected reuse must reach authorization. Retain only private static
|
|
32
|
+
// JS/CSS bytes and validators; app output may never opt into browser/CDN TTLs.
|
|
33
|
+
for (const name of response.getHeaderNames()) {
|
|
34
|
+
if (isSharedCacheDirective(name))
|
|
35
|
+
response.removeHeader(name);
|
|
36
|
+
}
|
|
37
|
+
response.setHeader('cache-control', revalidateAsset ? 'private, no-cache' : 'private, no-store');
|
|
38
|
+
for (const [name, value] of Object.entries(SHARED_CACHE_REFUSAL))
|
|
39
|
+
response.setHeader(name, value);
|
|
40
|
+
}
|
package/dist/gateway/usage.d.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
export interface GatewayUsageEvent {
|
|
2
2
|
eventId: string;
|
|
3
3
|
requestId: string;
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* Who the gateway acted for: the session owner Atlas authorized for this request, or for an
|
|
6
|
+
* external ingress request the namespaced external subject (`ext|<provider>|<subject>`).
|
|
7
|
+
*/
|
|
5
8
|
actorId: string;
|
|
9
|
+
/** Present only for an external ingress request. */
|
|
10
|
+
principalKind?: 'external';
|
|
11
|
+
/** The ingress an external request arrived through. */
|
|
12
|
+
ingressId?: string;
|
|
6
13
|
projectId: string;
|
|
7
14
|
deploymentId?: string;
|
|
8
15
|
devSessionId?: string;
|
package/dist/gateway-main.js
CHANGED
|
@@ -7,6 +7,7 @@ import { gatewaySessionSameSite } from './gateway/session.js';
|
|
|
7
7
|
import { RedisTunnelOwnership, RedisTunnelRelay } from './tunnel/redis.js';
|
|
8
8
|
import { ArtifactRequestAdmission } from './gateway/admission.js';
|
|
9
9
|
import { GatewayControlAdmission } from './gateway/control-admission.js';
|
|
10
|
+
import { createIngressRuntime, IngressSecretsError } from './gateway/ingress-runtime.js';
|
|
10
11
|
function required(name) {
|
|
11
12
|
const value = process.env[name];
|
|
12
13
|
if (!value)
|
|
@@ -40,6 +41,8 @@ async function main() {
|
|
|
40
41
|
// A flood of refused requests or forged frames must not become a log flood: count and report every 10 s.
|
|
41
42
|
const refusals = new Map();
|
|
42
43
|
const refuse = (kind) => { refusals.set(kind, (refusals.get(kind) ?? 0) + 1); };
|
|
44
|
+
// Kinds a caller can produce at request rate: counted, never logged one by one.
|
|
45
|
+
const counted = new Set(['admission_rejected', 'ingress_pass_rejected', 'ingress_token_rejected', 'ingress_edge_header_invalid', 'ingress_client_ip_invalid']);
|
|
43
46
|
setInterval(() => {
|
|
44
47
|
for (const [kind, count] of refusals)
|
|
45
48
|
log({ event: 'artifact_gateway.request', kind, count });
|
|
@@ -56,26 +59,43 @@ async function main() {
|
|
|
56
59
|
subscriber.on('ready', () => { if (!stopping)
|
|
57
60
|
void relay.start().catch(() => undefined); });
|
|
58
61
|
const controlUrl = required('ARTIFACT_NEXT_CONTROL_PLANE_URL');
|
|
62
|
+
const controlToken = required('ARTIFACT_NEXT_CONTROL_TOKEN');
|
|
63
|
+
const control = new ArtifactGatewayControl({ url: controlUrl, token: controlToken });
|
|
64
|
+
const contextPrivateKey = required('ARTIFACT_NEXT_CONTEXT_PRIVATE_KEY');
|
|
65
|
+
const contextKeyId = required('ARTIFACT_NEXT_CONTEXT_KEY_ID');
|
|
66
|
+
const admission = new ArtifactRequestAdmission({ redis: command, namespace });
|
|
67
|
+
// The external ingress branch: off (every ingress host 404) unless all four of its secrets are
|
|
68
|
+
// set; the process refuses to boot when they are set but unusable or not independent.
|
|
69
|
+
const ingress = createIngressRuntime({
|
|
70
|
+
env: process.env, control, controlToken, sessionKey, contextPrivateKey, contextKeyId, redis: command, namespace, admission, log,
|
|
71
|
+
onFailure: failure => { if ('count' in failure)
|
|
72
|
+
log({ event: 'artifact_gateway.request', ...failure });
|
|
73
|
+
else
|
|
74
|
+
refuse(failure.kind); },
|
|
75
|
+
});
|
|
59
76
|
const gateway = createArtifactGateway({
|
|
60
|
-
control
|
|
77
|
+
control,
|
|
61
78
|
runtimeDomain: required('ARTIFACT_NEXT_RUNTIME_DOMAIN'),
|
|
62
79
|
parentOrigin: process.env.ARTIFACT_NEXT_PARENT_ORIGIN ?? new URL(controlUrl).origin,
|
|
63
80
|
gatewayOrigin: required('ARTIFACT_NEXT_GATEWAY_URL'),
|
|
64
81
|
sessionKey,
|
|
65
82
|
sessionCookieSameSite: gatewaySessionSameSite({ value: process.env.ARTIFACT_NEXT_SESSION_COOKIE_SAMESITE, appEnv: namespace }),
|
|
66
|
-
contextPrivateKey
|
|
67
|
-
contextKeyId
|
|
83
|
+
contextPrivateKey,
|
|
84
|
+
contextKeyId,
|
|
68
85
|
nodeId, routing: ownership, relay,
|
|
69
|
-
admission
|
|
86
|
+
admission,
|
|
87
|
+
ingress: ingress.ingress,
|
|
70
88
|
controlAdmission: new GatewayControlAdmission({ redis: command, namespace }),
|
|
71
89
|
resolveLease: sessionId => ownership.resolveLease(sessionId),
|
|
72
90
|
renewLease: lease => ownership.renew(lease, nodeId),
|
|
73
91
|
ready: () => clients.every(client => client.status === 'ready') && relay.isReady(),
|
|
74
|
-
onError: event => { if (event.kind
|
|
92
|
+
onError: event => { if (counted.has(event.kind))
|
|
75
93
|
refuse(event.kind);
|
|
76
94
|
else
|
|
77
95
|
log({ event: 'artifact_gateway.request', ...event }); },
|
|
78
96
|
});
|
|
97
|
+
// The first snapshot pull starts before traffic; until it lands, ingress hosts answer 503.
|
|
98
|
+
ingress.start();
|
|
79
99
|
const port = Number(process.env.PORT ?? '8080');
|
|
80
100
|
if (!Number.isSafeInteger(port) || port < 1 || port > 65535)
|
|
81
101
|
throw new Error('Invalid gateway PORT');
|
|
@@ -102,7 +122,8 @@ async function main() {
|
|
|
102
122
|
}, 9_000);
|
|
103
123
|
deadline.unref();
|
|
104
124
|
await gateway.drain();
|
|
105
|
-
|
|
125
|
+
// Audit delivery runs beside tunnel cleanup, never in front of it.
|
|
126
|
+
await Promise.allSettled([ingress.close(), relay.close()]);
|
|
106
127
|
for (const client of clients)
|
|
107
128
|
client.disconnect();
|
|
108
129
|
clearTimeout(deadline);
|
|
@@ -110,7 +131,10 @@ async function main() {
|
|
|
110
131
|
process.once('SIGTERM', () => { void stop(); });
|
|
111
132
|
process.once('SIGINT', () => { void stop(); });
|
|
112
133
|
}
|
|
113
|
-
void main().catch(() => {
|
|
114
|
-
|
|
134
|
+
void main().catch((error) => {
|
|
135
|
+
// Only an ingress-secrets refusal is described: its message names variables, never values, by
|
|
136
|
+
// construction. Other errors (Redis, URLs) may carry connection strings.
|
|
137
|
+
const reason = error instanceof IngressSecretsError ? error.message : undefined;
|
|
138
|
+
process.stderr.write(`${JSON.stringify({ event: 'artifact_gateway.start_failed', ...(reason ? { reason } : {}) })}\n`);
|
|
115
139
|
process.exit(1);
|
|
116
140
|
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { type ArtifactStudioCustomRoles } from './manifest-custom-roles.js';
|
|
3
|
+
/** Additional browser admission rules; neither these rules nor data reach grant access. */
|
|
4
|
+
export declare const artifactNextBrowserDataSchema: z.ZodPrefault<z.ZodObject<{
|
|
5
|
+
operations: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
6
|
+
required_capabilities: z.ZodOptional<z.ZodPreprocess<z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString>>>>;
|
|
7
|
+
read: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
8
|
+
write: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
9
|
+
}, z.core.$strict>>;
|
|
10
|
+
export type ArtifactNextBrowserData = z.infer<typeof artifactNextBrowserDataSchema>;
|
|
11
|
+
/** Restriction references must be backed by this browser's reach and declared artifact pins. */
|
|
12
|
+
export declare function validateBrowserDataAdmission({ data, uses, roles, ctx }: {
|
|
13
|
+
data: ArtifactNextBrowserData;
|
|
14
|
+
uses: readonly string[];
|
|
15
|
+
roles?: ArtifactStudioCustomRoles;
|
|
16
|
+
ctx: z.RefinementCtx;
|
|
17
|
+
}): void;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { capabilityPinsSchema } from './manifest-custom-roles.js';
|
|
3
|
+
import { boundedRef, ormOperationRef, reachShape } from './manifest-next-reach.js';
|
|
4
|
+
const browserOperationRef = boundedRef.refine((ref) => ormOperationRef.test(ref), 'Browser ORM operations name each operation: namespace/ops/Operation');
|
|
5
|
+
/** Additional browser admission rules; neither these rules nor data reach grant access. */
|
|
6
|
+
export const artifactNextBrowserDataSchema = z.strictObject({
|
|
7
|
+
...reachShape.data.unwrap().shape,
|
|
8
|
+
// Preserve absence: existing manifests use table-set admission. An explicit [] denies all.
|
|
9
|
+
operations: z.array(browserOperationRef).max(256).refine((operations) => new Set(operations).size === operations.length, 'Browser ORM operations must be unique').optional(),
|
|
10
|
+
// Keys name source namespaces, before development/preview namespace bindings are applied.
|
|
11
|
+
required_capabilities: z.preprocess((value, ctx) => {
|
|
12
|
+
// Zod records skip this own key before validating keys; reject it rather than silently drop it.
|
|
13
|
+
if (value !== null && typeof value === 'object' && Object.hasOwn(value, '__proto__')) {
|
|
14
|
+
ctx.addIssue({ code: 'custom', path: ['__proto__'], message: 'Capability guard keys are ORM source namespaces' });
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}, z.record(z.string().regex(/^[a-z][a-z0-9_]{0,40}$/, 'Capability guard keys are ORM source namespaces'), capabilityPinsSchema.unwrap().min(1).refine((pins) => new Set(pins).size === pins.length, 'Required browser ORM capabilities must be unique'))).optional(),
|
|
18
|
+
}).prefault({});
|
|
19
|
+
/** Restriction references must be backed by this browser's reach and declared artifact pins. */
|
|
20
|
+
export function validateBrowserDataAdmission({ data, uses, roles, ctx }) {
|
|
21
|
+
const reached = new Set([...data.read, ...data.write].map((ref) => ref.split('/', 1)[0]));
|
|
22
|
+
for (const [index, operation] of (data.operations ?? []).entries()) {
|
|
23
|
+
const namespace = operation.split('/', 1)[0];
|
|
24
|
+
if (!reached.has(namespace)) {
|
|
25
|
+
ctx.addIssue({
|
|
26
|
+
code: 'custom', path: ['capabilities', 'data', 'operations', index],
|
|
27
|
+
message: `${operation} names a namespace that browser capabilities.data.read or write does not reach`,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const declared = new Set([...Object.keys(roles?.capabilities ?? {}), ...uses]);
|
|
32
|
+
for (const [namespace, pins] of Object.entries(data.required_capabilities ?? {})) {
|
|
33
|
+
const path = ['capabilities', 'data', 'required_capabilities', namespace];
|
|
34
|
+
if (!reached.has(namespace)) {
|
|
35
|
+
ctx.addIssue({ code: 'custom', path, message: `${namespace} is not reached by browser capabilities.data.read or write` });
|
|
36
|
+
}
|
|
37
|
+
for (const [index, pin] of pins.entries()) {
|
|
38
|
+
if (!declared.has(pin)) {
|
|
39
|
+
ctx.addIssue({
|
|
40
|
+
code: 'custom', path: [...path, index],
|
|
41
|
+
message: `Required capability "${pin}" must be declared in capabilities.roles.capabilities or capabilities.uses`,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|