@sequenceholdings/artifact-studio 0.3.0 → 0.4.0
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 +37 -0
- package/VERSION-PIN.md +4 -0
- package/dist/build.js +1 -0
- package/dist/cli.js +34 -4
- package/dist/dev-link.js +1 -1
- package/dist/gateway/admission.d.ts +2 -1
- package/dist/gateway/admission.js +3 -1
- package/dist/gateway/authorization-cache.d.ts +52 -0
- package/dist/gateway/authorization-cache.js +111 -0
- package/dist/gateway/control-admission.d.ts +48 -0
- package/dist/gateway/control-admission.js +127 -0
- package/dist/gateway/control.d.ts +4 -1
- package/dist/gateway/control.js +5 -2
- package/dist/gateway/hosted-proxy.js +4 -7
- package/dist/gateway/server.d.ts +12 -0
- package/dist/gateway/server.js +102 -18
- package/dist/gateway/session.d.ts +15 -1
- package/dist/gateway/session.js +19 -4
- package/dist/gateway/usage.d.ts +45 -12
- package/dist/gateway/usage.js +96 -26
- package/dist/gateway-main.js +37 -5
- package/dist/loopback.d.ts +8 -0
- package/dist/loopback.js +13 -0
- package/dist/manifest-custom-roles.d.ts +11 -1
- package/dist/manifest-custom-roles.js +13 -5
- package/dist/manifest-next.js +13 -1
- package/dist/next/bridge.js +8 -1
- package/dist/next/protocol.js +2 -1
- package/dist/next-cache/config.js +31 -4
- package/dist/next-dev.js +2 -1
- package/dist/next-image-config.js +1 -1
- package/dist/next-project.js +2 -1
- package/dist/project.js +3 -3
- package/dist/role-definitions-notice.d.ts +7 -0
- package/dist/role-definitions-notice.js +19 -0
- package/dist/runtime-contracts.js +2 -1
- package/dist/sandbox-lint.js +1 -1
- package/dist/sdk-orm.d.ts +18 -0
- package/dist/sdk-orm.js +21 -0
- package/dist/sdk.d.ts +1 -0
- package/dist/sdk.js +1 -0
- package/dist/server/client.d.ts +16 -2
- package/dist/server/request-context.d.ts +1 -0
- package/dist/server/request-context.js +6 -1
- package/dist/server/runtime-config.d.ts +1 -0
- package/dist/server/runtime-config.js +2 -1
- package/dist/server.d.ts +1 -1
- package/dist/templates/nextjs/README.md +6 -0
- package/dist/templates/nextjs/gitignore +9 -0
- package/dist/templates/nextjs/pnpm-workspace.yaml +14 -0
- package/dist/templates/react-vite/AGENTS.md +1 -1
- package/dist/templates/react-vite/package.json +1 -1
- package/dist/trusted-install.js +62 -2
- package/dist/tunnel/broker.js +34 -22
- package/dist/tunnel/client.js +10 -3
- package/dist/tunnel/protocol.d.ts +9 -0
- package/dist/tunnel/protocol.js +33 -18
- package/dist/tunnel/redis-auth.d.ts +37 -0
- package/dist/tunnel/redis-auth.js +92 -0
- package/dist/tunnel/redis-types.d.ts +5 -0
- package/dist/tunnel/redis.d.ts +5 -2
- package/dist/tunnel/redis.js +19 -6
- package/package.json +2 -2
- package/templates/nextjs/README.md +6 -0
- package/templates/nextjs/gitignore +9 -0
- package/templates/nextjs/pnpm-workspace.yaml +14 -0
- package/templates/react-vite/AGENTS.md +1 -1
- package/templates/react-vite/package.json +1 -1
package/dist/trusted-install.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync, readdirSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { load as parseYaml } from 'js-yaml';
|
|
3
4
|
import { classifyLockfileOrigin } from './lockfile-origin.js';
|
|
4
5
|
export class ArtifactInstallTrustError extends Error {
|
|
5
6
|
name = 'ArtifactInstallTrustError';
|
|
@@ -20,6 +21,21 @@ const ALLOWED_TARBALL_PREFIXES = [
|
|
|
20
21
|
'https://libraries.cgr.dev/javascript/',
|
|
21
22
|
'https://libraries.cgr.dev/javascript-upstream/',
|
|
22
23
|
];
|
|
24
|
+
/**
|
|
25
|
+
* Exact off-registry tarballs remote installs accept, each tied to one
|
|
26
|
+
* dependency name and one integrity hash the lockfile must pin. SheetJS ships
|
|
27
|
+
* 0.20.x only from its CDN; the registry stops at 0.18.5, which has known
|
|
28
|
+
* parser vulnerabilities.
|
|
29
|
+
*/
|
|
30
|
+
const PINNED_TARBALLS = new Map([
|
|
31
|
+
[
|
|
32
|
+
'https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz',
|
|
33
|
+
{
|
|
34
|
+
name: 'xlsx',
|
|
35
|
+
integrity: 'sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==',
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
]);
|
|
23
39
|
const EXCLUDED_DIRS = new Set(['node_modules', '.git']);
|
|
24
40
|
/** Platform peers the CLI aliases at build time — must not be prod-installed remotely. */
|
|
25
41
|
export const REMOTE_INSTALL_PLATFORM_PACKAGES = new Set([
|
|
@@ -73,10 +89,15 @@ function assertNoPackageManagerSelection(pkg) {
|
|
|
73
89
|
}
|
|
74
90
|
}
|
|
75
91
|
}
|
|
76
|
-
function
|
|
92
|
+
function isPinnedTarballSpec(name, spec) {
|
|
93
|
+
return typeof spec === 'string' && PINNED_TARBALLS.get(spec)?.name === name;
|
|
94
|
+
}
|
|
95
|
+
function assertRegistryOnlySpecMap(map, label, { allowPinnedTarballs = false } = {}) {
|
|
77
96
|
if (!map || typeof map !== 'object')
|
|
78
97
|
return;
|
|
79
98
|
for (const [name, spec] of Object.entries(map)) {
|
|
99
|
+
if (allowPinnedTarballs && isPinnedTarballSpec(name, spec))
|
|
100
|
+
continue;
|
|
80
101
|
if (isNonRegistrySpec(spec)) {
|
|
81
102
|
throw new ArtifactInstallTrustError(`${label} "${name}" uses a non-registry spec ("${String(spec)}") — only registry semver ranges are allowed`);
|
|
82
103
|
}
|
|
@@ -114,7 +135,7 @@ function assertRegistryOnlyPackageJson(dir) {
|
|
|
114
135
|
throw new ArtifactInstallTrustError('package.json is not valid JSON');
|
|
115
136
|
}
|
|
116
137
|
for (const section of DEPENDENCY_SECTIONS) {
|
|
117
|
-
assertRegistryOnlySpecMap(pkg[section], 'Dependency');
|
|
138
|
+
assertRegistryOnlySpecMap(pkg[section], 'Dependency', { allowPinnedTarballs: true });
|
|
118
139
|
}
|
|
119
140
|
assertRegistryOnlySpecMap(pkg.resolutions, 'Resolution override');
|
|
120
141
|
assertNoPackageManagerSelection(pkg);
|
|
@@ -148,6 +169,42 @@ function normalizeYamlScalar(raw) {
|
|
|
148
169
|
}
|
|
149
170
|
return trimmed;
|
|
150
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* Check resolved `packages` entries, not lockfile text, so a comment or a YAML
|
|
174
|
+
* form the text scan misses cannot stand in for a real resolution.
|
|
175
|
+
*/
|
|
176
|
+
function assertParsedPackageResolutions(lockfileText) {
|
|
177
|
+
let doc;
|
|
178
|
+
try {
|
|
179
|
+
doc = parseYaml(lockfileText);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
throw new ArtifactInstallTrustError('pnpm-lock.yaml is not valid YAML');
|
|
183
|
+
}
|
|
184
|
+
const packages = doc && typeof doc === 'object' ? doc.packages : undefined;
|
|
185
|
+
if (!packages || typeof packages !== 'object')
|
|
186
|
+
return;
|
|
187
|
+
for (const [key, entry] of Object.entries(packages)) {
|
|
188
|
+
const resolution = entry && typeof entry === 'object'
|
|
189
|
+
? entry.resolution
|
|
190
|
+
: undefined;
|
|
191
|
+
if (!resolution || typeof resolution !== 'object')
|
|
192
|
+
continue;
|
|
193
|
+
const { tarball, integrity } = resolution;
|
|
194
|
+
if (typeof tarball !== 'string')
|
|
195
|
+
continue;
|
|
196
|
+
const pinned = PINNED_TARBALLS.get(tarball);
|
|
197
|
+
if (pinned) {
|
|
198
|
+
if (!key.startsWith(`${pinned.name}@`) || integrity !== pinned.integrity) {
|
|
199
|
+
throw new ArtifactInstallTrustError(`pnpm-lock.yaml must pin integrity ${pinned.integrity} for ${pinned.name} from ${tarball} — regenerate the lockfile`);
|
|
200
|
+
}
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (!ALLOWED_TARBALL_PREFIXES.some((prefix) => tarball.startsWith(prefix))) {
|
|
204
|
+
throw new ArtifactInstallTrustError(`pnpm-lock.yaml tarball resolution "${tarball}" is not from the Chainguard registry`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
151
208
|
function assertChainguardLockfile(dir) {
|
|
152
209
|
const lockfileText = readFileSync(join(dir, 'pnpm-lock.yaml'), 'utf8');
|
|
153
210
|
if (classifyLockfileOrigin(lockfileText) !== 'chainguard') {
|
|
@@ -159,10 +216,13 @@ function assertChainguardLockfile(dir) {
|
|
|
159
216
|
}
|
|
160
217
|
for (const match of lockfileText.matchAll(LOCKFILE_TARBALL_RE)) {
|
|
161
218
|
const tarball = normalizeYamlScalar(match[2] ?? '');
|
|
219
|
+
if (PINNED_TARBALLS.has(tarball))
|
|
220
|
+
continue;
|
|
162
221
|
if (!ALLOWED_TARBALL_PREFIXES.some((prefix) => tarball.startsWith(prefix))) {
|
|
163
222
|
throw new ArtifactInstallTrustError(`pnpm-lock.yaml tarball resolution "${tarball}" is not from the Chainguard registry`);
|
|
164
223
|
}
|
|
165
224
|
}
|
|
225
|
+
assertParsedPackageResolutions(lockfileText);
|
|
166
226
|
}
|
|
167
227
|
/**
|
|
168
228
|
* Reject install-control files and non-registry dependency specs before a remote
|
package/dist/tunnel/broker.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { WebSocketServer } from 'ws';
|
|
2
2
|
import { TunnelPeer, tunnelLimits } from './peer.js';
|
|
3
|
-
import { assertSafeRequest, encodeTunnelFrame, normalizePublicOrigin, sanitizeRequestHeaders, sanitizeResponseHeaders, TUNNEL_PROTOCOL } from './protocol.js';
|
|
3
|
+
import { assertSafeRequest, encodeTunnelFrame, isSharedCacheDirective, normalizePublicOrigin, sanitizeRequestHeaders, sanitizeResponseHeaders, SHARED_CACHE_REFUSAL, TUNNEL_PROTOCOL } from './protocol.js';
|
|
4
4
|
import { forwardWebSocketMessages, MessageBudget, permittedCloseCode, sendWebSocket, WebSocketMessage, writeChunk } from './io.js';
|
|
5
|
+
const UPGRADE_REASONS = { 403: 'Forbidden', 429: 'Too Many Requests', 503: 'Service Unavailable' };
|
|
5
6
|
function rejectUpgrade(socket, status) {
|
|
6
|
-
socket.end(`HTTP/1.1 ${status} ${status
|
|
7
|
+
socket.end(`HTTP/1.1 ${status} ${UPGRADE_REASONS[status] ?? 'Service Unavailable'}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
|
|
8
|
+
}
|
|
9
|
+
/** Admission refusals keep their status so clients back off; anything else is an authorization failure. */
|
|
10
|
+
function upgradeFailureStatus(error) {
|
|
11
|
+
const status = typeof error === 'object' && error !== null && 'status' in error ? error.status : undefined;
|
|
12
|
+
return status === 429 || status === 503 ? status : 403;
|
|
7
13
|
}
|
|
8
14
|
function rejectHttp(response, status) {
|
|
9
15
|
if (response.headersSent) {
|
|
@@ -13,6 +19,29 @@ function rejectHttp(response, status) {
|
|
|
13
19
|
response.writeHead(status, { 'cache-control': 'no-store', 'content-type': 'application/json' });
|
|
14
20
|
response.end(JSON.stringify({ error: status === 403 ? 'Preview access denied' : 'Development server unavailable' }));
|
|
15
21
|
}
|
|
22
|
+
/** A development process cannot relax the gateway's embedding, response or cache policy. */
|
|
23
|
+
function developmentResponseHeaders({ response, headers }) {
|
|
24
|
+
// Multiple CSP policies are intersected by browsers, preserving app CSP.
|
|
25
|
+
const platformCsp = response.getHeader('content-security-policy');
|
|
26
|
+
const applicationCsp = headers['content-security-policy'];
|
|
27
|
+
if (platformCsp)
|
|
28
|
+
headers['content-security-policy'] = [
|
|
29
|
+
...(Array.isArray(platformCsp) ? platformCsp : [String(platformCsp)]),
|
|
30
|
+
...(applicationCsp === undefined ? [] : Array.isArray(applicationCsp) ? applicationCsp : [applicationCsp]),
|
|
31
|
+
];
|
|
32
|
+
for (const name of ['x-content-type-options', 'referrer-policy']) {
|
|
33
|
+
const value = response.getHeader(name);
|
|
34
|
+
if (value !== undefined)
|
|
35
|
+
headers[name] = String(value);
|
|
36
|
+
}
|
|
37
|
+
// The hosted rule: no CDN or proxy may reuse the output without passing authorization again.
|
|
38
|
+
for (const name of Object.keys(headers))
|
|
39
|
+
if (isSharedCacheDirective(name))
|
|
40
|
+
delete headers[name];
|
|
41
|
+
Object.assign(headers, { 'cache-control': 'no-store', ...SHARED_CACHE_REFUSAL });
|
|
42
|
+
preserveGatewayCookies({ response, headers });
|
|
43
|
+
return headers;
|
|
44
|
+
}
|
|
16
45
|
function preserveGatewayCookies({ response, headers }) {
|
|
17
46
|
const gateway = response.getHeader('set-cookie');
|
|
18
47
|
const application = headers['set-cookie'];
|
|
@@ -112,8 +141,8 @@ export class ArtifactTunnelBroker {
|
|
|
112
141
|
});
|
|
113
142
|
socket.resume();
|
|
114
143
|
}
|
|
115
|
-
catch {
|
|
116
|
-
rejectUpgrade(socket,
|
|
144
|
+
catch (error) {
|
|
145
|
+
rejectUpgrade(socket, upgradeFailureStatus(error));
|
|
117
146
|
if (claimed && lease)
|
|
118
147
|
await this.options.routing.release(lease.sessionId, lease.epoch, this.options.nodeId).catch(() => undefined);
|
|
119
148
|
}
|
|
@@ -304,24 +333,7 @@ export class ArtifactTunnelBroker {
|
|
|
304
333
|
if (!stream.response || stream.receivedHeaders)
|
|
305
334
|
throw new Error('Unexpected response');
|
|
306
335
|
stream.receivedHeaders = true;
|
|
307
|
-
|
|
308
|
-
// A development process cannot relax the gateway's embedding policy.
|
|
309
|
-
// Multiple CSP policies are intersected by browsers, preserving app CSP.
|
|
310
|
-
const platformCsp = stream.response.getHeader('content-security-policy');
|
|
311
|
-
const applicationCsp = headers['content-security-policy'];
|
|
312
|
-
if (platformCsp)
|
|
313
|
-
headers['content-security-policy'] = [
|
|
314
|
-
...(Array.isArray(platformCsp) ? platformCsp : [String(platformCsp)]),
|
|
315
|
-
...(applicationCsp === undefined ? [] : Array.isArray(applicationCsp) ? applicationCsp : [applicationCsp]),
|
|
316
|
-
];
|
|
317
|
-
for (const name of ['x-content-type-options', 'referrer-policy']) {
|
|
318
|
-
const value = stream.response.getHeader(name);
|
|
319
|
-
if (value !== undefined)
|
|
320
|
-
headers[name] = String(value);
|
|
321
|
-
}
|
|
322
|
-
headers['cache-control'] = 'no-store';
|
|
323
|
-
preserveGatewayCookies({ response: stream.response, headers });
|
|
324
|
-
stream.response.writeHead(frame.status, headers);
|
|
336
|
+
stream.response.writeHead(frame.status, developmentResponseHeaders({ response: stream.response, headers: sanitizeResponseHeaders(frame.headers) }));
|
|
325
337
|
return;
|
|
326
338
|
}
|
|
327
339
|
if (frame.type === 'data') {
|
package/dist/tunnel/client.js
CHANGED
|
@@ -3,14 +3,21 @@ import WebSocket from 'ws';
|
|
|
3
3
|
import { TunnelPeer, tunnelLimits } from './peer.js';
|
|
4
4
|
import { assertSafeRequest, normalizePublicOrigin, parseTunnelFrame, sanitizeRequestHeaders, sanitizeResponseHeaders, TUNNEL_PROTOCOL } from './protocol.js';
|
|
5
5
|
import { forwardWebSocketMessages, MessageBudget, permittedCloseCode, sendWebSocket, WebSocketMessage, writeChunk } from './io.js';
|
|
6
|
+
import { isLoopbackHostname } from '../loopback.js';
|
|
6
7
|
/** One connection/epoch only. The caller obtains a fresh lease to reconnect;
|
|
7
8
|
* no HTTP request or mutation is ever retained or replayed. */
|
|
8
9
|
export async function connectLocalTunnel(options) {
|
|
9
10
|
const endpoint = new URL(options.url);
|
|
10
|
-
const insecureLocal = endpoint.protocol === 'ws:' &&
|
|
11
|
+
const insecureLocal = endpoint.protocol === 'ws:' && isLoopbackHostname(endpoint.hostname) && options.allowInsecureLoopback;
|
|
11
12
|
if ((endpoint.protocol !== 'wss:' && !insecureLocal) || endpoint.username || endpoint.password || endpoint.hash) {
|
|
12
13
|
throw new Error('Tunnel endpoint must use authenticated WSS');
|
|
13
14
|
}
|
|
15
|
+
// A cleartext tunnel carries the bearer credential, so a localhost name is dialed at the loopback
|
|
16
|
+
// address browsers use for it, never through a resolver that could route it elsewhere. The Host
|
|
17
|
+
// header keeps the name the gateway validates.
|
|
18
|
+
const dial = new URL(endpoint);
|
|
19
|
+
if (insecureLocal && !['127.0.0.1', '[::1]'].includes(endpoint.hostname))
|
|
20
|
+
dial.hostname = '127.0.0.1';
|
|
14
21
|
if (!Number.isSafeInteger(options.localPort) || options.localPort < 1 || options.localPort > 65535)
|
|
15
22
|
throw new Error('Invalid loopback port');
|
|
16
23
|
if (!Number.isSafeInteger(options.epoch) || options.epoch < 1 || !options.sessionId || !options.token || /[\r\n]/.test(options.token))
|
|
@@ -18,8 +25,8 @@ export async function connectLocalTunnel(options) {
|
|
|
18
25
|
const publicOrigin = normalizePublicOrigin(options.publicOrigin);
|
|
19
26
|
const limits = tunnelLimits(options.limits);
|
|
20
27
|
options.signal?.throwIfAborted();
|
|
21
|
-
const socket = new WebSocket(
|
|
22
|
-
headers: { authorization: `Bearer ${options.token}`, 'x-artifact-session-id': options.sessionId, 'x-artifact-tunnel-epoch': String(options.epoch) },
|
|
28
|
+
const socket = new WebSocket(dial, [TUNNEL_PROTOCOL], {
|
|
29
|
+
headers: { host: endpoint.host, authorization: `Bearer ${options.token}`, 'x-artifact-session-id': options.sessionId, 'x-artifact-tunnel-epoch': String(options.epoch) },
|
|
23
30
|
maxPayload: limits.maxFrameBytes,
|
|
24
31
|
perMessageDeflate: false,
|
|
25
32
|
followRedirects: false,
|
|
@@ -65,5 +65,14 @@ export declare function encodeTunnelFrame(frame: TunnelFrame): string;
|
|
|
65
65
|
export declare function normalizePublicOrigin(value: string): string;
|
|
66
66
|
/** Rebuild trusted routing headers; no Atlas bearer/session crosses into app code. */
|
|
67
67
|
export declare function sanitizeRequestHeaders(headers: HeaderInput, publicOrigin: string): TunnelHeaders;
|
|
68
|
+
/** Response headers through which application output could opt into CDN or proxy reuse. */
|
|
69
|
+
export declare function isSharedCacheDirective(name: string): boolean;
|
|
70
|
+
/** Sent in their place on every protected response, so each reuse must pass gateway authorization again. */
|
|
71
|
+
export declare const SHARED_CACHE_REFUSAL: Readonly<{
|
|
72
|
+
'cdn-cache-control': "no-store";
|
|
73
|
+
'surrogate-control': "no-store";
|
|
74
|
+
pragma: "no-cache";
|
|
75
|
+
expires: "0";
|
|
76
|
+
}>;
|
|
68
77
|
export declare function sanitizeResponseHeaders(headers: HeaderInput): TunnelHeaders;
|
|
69
78
|
export {};
|
package/dist/tunnel/protocol.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
+
import { isLoopbackHostname } from '../loopback.js';
|
|
3
4
|
/** Version 1 is negotiated by the tunnel connection, not by individual streams. */
|
|
4
5
|
export const TUNNEL_PROTOCOL = 'artifact-tunnel.v1';
|
|
5
6
|
export const DEFAULT_TUNNEL_LIMITS = Object.freeze({
|
|
@@ -26,21 +27,29 @@ const HOP_HEADERS = new Set([
|
|
|
26
27
|
'proxy-connection', 'te', 'trailer', 'transfer-encoding', 'upgrade',
|
|
27
28
|
'content-length', 'expect',
|
|
28
29
|
]);
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
30
|
+
const MAX_HEADER_BYTES = 32 * 1024;
|
|
31
|
+
function headerMapSchema({ maxValueLength }) {
|
|
32
|
+
const value = z.string().max(maxValueLength).regex(HEADER_VALUE);
|
|
33
|
+
return z.record(z.string().max(256).regex(HEADER_TOKEN), z.union([value, z.array(value).min(1).max(32)])).superRefine((headers, context) => {
|
|
34
|
+
const names = new Set();
|
|
35
|
+
let bytes = 0;
|
|
36
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
37
|
+
if (names.has(name.toLowerCase())) {
|
|
38
|
+
context.addIssue({ code: 'custom', message: 'Duplicate case-insensitive header' });
|
|
39
|
+
}
|
|
40
|
+
names.add(name.toLowerCase());
|
|
41
|
+
bytes += Buffer.byteLength(name) + Buffer.byteLength(Array.isArray(value) ? value.join(',') : value);
|
|
36
42
|
}
|
|
37
|
-
names.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
});
|
|
43
|
+
if (names.size > 128 || bytes > MAX_HEADER_BYTES) {
|
|
44
|
+
context.addIssue({ code: 'custom', message: 'Tunnel headers exceed their size limit' });
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
// A frame carries the gateway's signed request context, which the hosted proxy accepts up to 16 KiB.
|
|
49
|
+
const headersSchema = headerMapSchema({ maxValueLength: 16 * 1024 });
|
|
50
|
+
// A browser request still holds the gateway's own session cookies, which are removed before any
|
|
51
|
+
// frame is built, so they must not use up the application's per-value allowance.
|
|
52
|
+
const browserRequestHeadersSchema = headerMapSchema({ maxValueLength: MAX_HEADER_BYTES });
|
|
44
53
|
/** Enforce origin-form paths before a URL parser can reinterpret a destination. */
|
|
45
54
|
export function assertSafeRequest(method, path) {
|
|
46
55
|
if (!METHODS.has(method))
|
|
@@ -119,15 +128,15 @@ export function encodeTunnelFrame(frame) {
|
|
|
119
128
|
}
|
|
120
129
|
export function normalizePublicOrigin(value) {
|
|
121
130
|
const url = new URL(value);
|
|
122
|
-
const isLoopback =
|
|
131
|
+
const isLoopback = isLoopbackHostname(url.hostname);
|
|
123
132
|
if (!/^https?:\/\/[^/?#@\\\s]+\/?$/i.test(value) || url.username || url.password || url.search || url.hash ||
|
|
124
133
|
url.pathname !== '/' || (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLoopback)))
|
|
125
134
|
throw new Error('Tunnel public origin must be an HTTPS origin or HTTP loopback origin');
|
|
126
135
|
return url.origin;
|
|
127
136
|
}
|
|
128
|
-
function normalizedHeaders(headers) {
|
|
137
|
+
function normalizedHeaders(headers, schema = headersSchema) {
|
|
129
138
|
const present = Object.fromEntries(Object.entries(headers).filter(([, value]) => value !== undefined));
|
|
130
|
-
return new Map(Object.entries(
|
|
139
|
+
return new Map(Object.entries(schema.parse(present)).map(([name, value]) => [name.toLowerCase(), value]));
|
|
131
140
|
}
|
|
132
141
|
function removePrivateHeaders(headers) {
|
|
133
142
|
const connection = headers.get('connection');
|
|
@@ -144,7 +153,7 @@ function removePrivateHeaders(headers) {
|
|
|
144
153
|
/** Rebuild trusted routing headers; no Atlas bearer/session crosses into app code. */
|
|
145
154
|
export function sanitizeRequestHeaders(headers, publicOrigin) {
|
|
146
155
|
const origin = normalizePublicOrigin(publicOrigin);
|
|
147
|
-
const normalized = normalizedHeaders(headers);
|
|
156
|
+
const normalized = normalizedHeaders(headers, browserRequestHeadersSchema);
|
|
148
157
|
const suppliedOrigin = normalized.get('origin');
|
|
149
158
|
if (suppliedOrigin !== undefined && suppliedOrigin !== origin)
|
|
150
159
|
throw new Error('Tunnel request origin mismatch');
|
|
@@ -180,6 +189,12 @@ function constrainCookie(cookie) {
|
|
|
180
189
|
return [assignment.trim(), ...attributes.map((attribute) => attribute.trim())
|
|
181
190
|
.filter((attribute) => attribute && attribute.split('=', 1)[0].trim().toLowerCase() !== 'domain')].join('; ');
|
|
182
191
|
}
|
|
192
|
+
/** Response headers through which application output could opt into CDN or proxy reuse. */
|
|
193
|
+
export function isSharedCacheDirective(name) {
|
|
194
|
+
return /(?:^|-)cdn-cache-control$/.test(name) || ['surrogate-control', 'x-accel-expires', 'expires', 'age'].includes(name);
|
|
195
|
+
}
|
|
196
|
+
/** Sent in their place on every protected response, so each reuse must pass gateway authorization again. */
|
|
197
|
+
export const SHARED_CACHE_REFUSAL = Object.freeze({ 'cdn-cache-control': 'no-store', 'surrogate-control': 'no-store', pragma: 'no-cache', expires: '0' });
|
|
183
198
|
export function sanitizeResponseHeaders(headers) {
|
|
184
199
|
const normalized = normalizedHeaders(headers);
|
|
185
200
|
removePrivateHeaders(normalized);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type Envelope } from './redis-types.js';
|
|
2
|
+
/** Frames older (or further in the future) than this are replays or come from a stalled node. */
|
|
3
|
+
export declare const RELAY_FRAME_MAX_AGE_MS = 30000;
|
|
4
|
+
/** An envelope quotes a frame as a JSON string: at most double its size, plus fixed fields and the MAC. */
|
|
5
|
+
export declare const RELAY_MESSAGE_MAX_BYTES: number;
|
|
6
|
+
/**
|
|
7
|
+
* The relay's frame key: an HKDF-SHA256 subkey of the gateway-only session key, bound to one
|
|
8
|
+
* environment. Atlas and every other Redis client lack that key, so they cannot forge frames, and
|
|
9
|
+
* the session key itself is never used as a MAC key for anything but cookies.
|
|
10
|
+
*/
|
|
11
|
+
export declare function deriveRelayFrameKey({ secret, namespace }: {
|
|
12
|
+
secret: string;
|
|
13
|
+
namespace: string;
|
|
14
|
+
}): Buffer;
|
|
15
|
+
/**
|
|
16
|
+
* Authenticates relay messages: the MAC binds the destination channel, so a captured frame cannot be
|
|
17
|
+
* redirected to another node, and each source's strictly increasing sequence plus a freshness window
|
|
18
|
+
* rejects replays. A single publisher connection per node keeps Redis delivery in sequence order.
|
|
19
|
+
*/
|
|
20
|
+
export declare class RelayFrameAuthenticator {
|
|
21
|
+
private readonly key;
|
|
22
|
+
private readonly now;
|
|
23
|
+
private sequence;
|
|
24
|
+
private readonly peers;
|
|
25
|
+
constructor(key: Buffer, now?: () => number);
|
|
26
|
+
seal({ channel, envelope }: {
|
|
27
|
+
channel: string;
|
|
28
|
+
envelope: Envelope;
|
|
29
|
+
}): string;
|
|
30
|
+
/** Null for any frame that is oversized, unauthenticated, stale, replayed or malformed. */
|
|
31
|
+
open({ channel, message }: {
|
|
32
|
+
channel: string;
|
|
33
|
+
message: string;
|
|
34
|
+
}): Envelope | null;
|
|
35
|
+
/** Forgotten peers can only send frames newer than the freshness window, which rejects replays. */
|
|
36
|
+
private forgetIdlePeers;
|
|
37
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createHmac, hkdfSync, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { DEFAULT_TUNNEL_LIMITS } from './protocol.js';
|
|
4
|
+
import { envelopeSchema } from './redis-types.js';
|
|
5
|
+
const WIRE_VERSION = 'v1';
|
|
6
|
+
/** Frames older (or further in the future) than this are replays or come from a stalled node. */
|
|
7
|
+
export const RELAY_FRAME_MAX_AGE_MS = 30_000;
|
|
8
|
+
/** An envelope quotes a frame as a JSON string: at most double its size, plus fixed fields and the MAC. */
|
|
9
|
+
export const RELAY_MESSAGE_MAX_BYTES = DEFAULT_TUNNEL_LIMITS.maxFrameBytes * 2 + 4096;
|
|
10
|
+
const PEER_RETENTION_MS = RELAY_FRAME_MAX_AGE_MS * 4;
|
|
11
|
+
const MAX_TRACKED_PEERS = 1024;
|
|
12
|
+
const wireSchema = z.object({
|
|
13
|
+
sequence: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
|
|
14
|
+
sentAt: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
|
|
15
|
+
envelope: envelopeSchema,
|
|
16
|
+
}).strict();
|
|
17
|
+
/**
|
|
18
|
+
* The relay's frame key: an HKDF-SHA256 subkey of the gateway-only session key, bound to one
|
|
19
|
+
* environment. Atlas and every other Redis client lack that key, so they cannot forge frames, and
|
|
20
|
+
* the session key itself is never used as a MAC key for anything but cookies.
|
|
21
|
+
*/
|
|
22
|
+
export function deriveRelayFrameKey({ secret, namespace }) {
|
|
23
|
+
if (Buffer.byteLength(secret) < 32)
|
|
24
|
+
throw new Error('Tunnel relay authentication requires a gateway secret of at least 32 bytes');
|
|
25
|
+
return Buffer.from(hkdfSync('sha256', secret, 'atlas-artifact-next', `artifact-next/tunnel-relay/v1:${namespace}`, 32));
|
|
26
|
+
}
|
|
27
|
+
function mac({ key, channel, body }) {
|
|
28
|
+
return createHmac('sha256', key).update(`${WIRE_VERSION}\n${channel}\n${body}`).digest();
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Authenticates relay messages: the MAC binds the destination channel, so a captured frame cannot be
|
|
32
|
+
* redirected to another node, and each source's strictly increasing sequence plus a freshness window
|
|
33
|
+
* rejects replays. A single publisher connection per node keeps Redis delivery in sequence order.
|
|
34
|
+
*/
|
|
35
|
+
export class RelayFrameAuthenticator {
|
|
36
|
+
key;
|
|
37
|
+
now;
|
|
38
|
+
sequence = 0;
|
|
39
|
+
peers = new Map();
|
|
40
|
+
constructor(key, now = Date.now) {
|
|
41
|
+
this.key = key;
|
|
42
|
+
this.now = now;
|
|
43
|
+
if (key.length !== 32)
|
|
44
|
+
throw new Error('Invalid tunnel relay frame key');
|
|
45
|
+
}
|
|
46
|
+
seal({ channel, envelope }) {
|
|
47
|
+
const body = JSON.stringify({ sequence: ++this.sequence, sentAt: this.now(), envelope });
|
|
48
|
+
return `${WIRE_VERSION}.${mac({ key: this.key, channel, body }).toString('base64url')}.${body}`;
|
|
49
|
+
}
|
|
50
|
+
/** Null for any frame that is oversized, unauthenticated, stale, replayed or malformed. */
|
|
51
|
+
open({ channel, message }) {
|
|
52
|
+
if (Buffer.byteLength(message) > RELAY_MESSAGE_MAX_BYTES || !message.startsWith(`${WIRE_VERSION}.`))
|
|
53
|
+
return null;
|
|
54
|
+
const separator = message.indexOf('.', WIRE_VERSION.length + 1);
|
|
55
|
+
if (separator < 0)
|
|
56
|
+
return null;
|
|
57
|
+
const signature = message.slice(WIRE_VERSION.length + 1, separator), body = message.slice(separator + 1);
|
|
58
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(signature))
|
|
59
|
+
return null;
|
|
60
|
+
const expected = mac({ key: this.key, channel, body }), actual = Buffer.from(signature, 'base64url');
|
|
61
|
+
if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
|
|
62
|
+
return null;
|
|
63
|
+
let wire;
|
|
64
|
+
try {
|
|
65
|
+
wire = wireSchema.parse(JSON.parse(body));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const now = this.now();
|
|
71
|
+
if (Math.abs(now - wire.sentAt) > RELAY_FRAME_MAX_AGE_MS)
|
|
72
|
+
return null;
|
|
73
|
+
const source = wire.envelope.source;
|
|
74
|
+
const peer = this.peers.get(source);
|
|
75
|
+
if (peer && wire.sequence <= peer.sequence)
|
|
76
|
+
return null;
|
|
77
|
+
this.peers.delete(source);
|
|
78
|
+
this.peers.set(source, { sequence: wire.sequence, seenAt: now });
|
|
79
|
+
this.forgetIdlePeers(now);
|
|
80
|
+
return wire.envelope;
|
|
81
|
+
}
|
|
82
|
+
/** Forgotten peers can only send frames newer than the freshness window, which rejects replays. */
|
|
83
|
+
forgetIdlePeers(now) {
|
|
84
|
+
if (this.peers.size <= MAX_TRACKED_PEERS)
|
|
85
|
+
return;
|
|
86
|
+
for (const [source, peer] of this.peers) {
|
|
87
|
+
if (now - peer.seenAt <= PEER_RETENTION_MS)
|
|
88
|
+
break;
|
|
89
|
+
this.peers.delete(source);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -67,4 +67,9 @@ export interface RedisTunnelRelayOptions {
|
|
|
67
67
|
publisher: RedisTunnelPublisher;
|
|
68
68
|
subscriber: RedisTunnelSubscriber;
|
|
69
69
|
connectTimeoutMs?: number;
|
|
70
|
+
/** Gateway-only key material shared by every replica (the session key). Frames are authenticated
|
|
71
|
+
* with an HKDF subkey of it; the value itself never leaves the process. */
|
|
72
|
+
secret: string;
|
|
73
|
+
/** Called for each relay message dropped as unauthenticated, stale, replayed or oversized. */
|
|
74
|
+
onRejectedFrame?: () => void;
|
|
70
75
|
}
|
package/dist/tunnel/redis.d.ts
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import type { TunnelLease } from './broker.js';
|
|
2
2
|
import type { TunnelRelay, TunnelSocket } from './transport.js';
|
|
3
|
-
import {
|
|
3
|
+
import type { RedisTunnelRelayOptions } from './redis-types.js';
|
|
4
4
|
export { RedisTunnelOwnership } from './redis-ownership.js';
|
|
5
5
|
export type { RedisTunnelCommands } from './redis-ownership.js';
|
|
6
6
|
export type { RedisTunnelPublisher, RedisTunnelSubscriber, RedisTunnelRelayOptions } from './redis-types.js';
|
|
7
7
|
/** Internal Redis channels contain authorized application traffic. Deploy them
|
|
8
|
-
* on the environment's private, authenticated Redis and disable command replay.
|
|
8
|
+
* on the environment's private, authenticated Redis and disable command replay.
|
|
9
|
+
* Every message is authenticated with a gateway-only key: other Redis clients
|
|
10
|
+
* can neither inject, redirect nor replay tunnel frames. */
|
|
9
11
|
export declare class RedisTunnelRelay implements TunnelRelay {
|
|
10
12
|
private readonly options;
|
|
11
13
|
private started;
|
|
12
14
|
private closed;
|
|
13
15
|
private readonly attached;
|
|
14
16
|
private readonly connected;
|
|
17
|
+
private readonly frames;
|
|
15
18
|
private publishing;
|
|
16
19
|
private receiving;
|
|
17
20
|
private outgoingBytes;
|
package/dist/tunnel/redis.js
CHANGED
|
@@ -2,16 +2,19 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { DEFAULT_TUNNEL_LIMITS, parseTunnelFrame } from './protocol.js';
|
|
3
3
|
import { redisIdentifier as identifier, redisNamespaceSchema as namespaceSchema, redisLeaseSchema as leaseSchema } from './redis-ownership.js';
|
|
4
4
|
import { RelaySocket } from './redis-socket.js';
|
|
5
|
-
import {
|
|
5
|
+
import { deriveRelayFrameKey, RelayFrameAuthenticator } from './redis-auth.js';
|
|
6
6
|
export { RedisTunnelOwnership } from './redis-ownership.js';
|
|
7
7
|
/** Internal Redis channels contain authorized application traffic. Deploy them
|
|
8
|
-
* on the environment's private, authenticated Redis and disable command replay.
|
|
8
|
+
* on the environment's private, authenticated Redis and disable command replay.
|
|
9
|
+
* Every message is authenticated with a gateway-only key: other Redis clients
|
|
10
|
+
* can neither inject, redirect nor replay tunnel frames. */
|
|
9
11
|
export class RedisTunnelRelay {
|
|
10
12
|
options;
|
|
11
13
|
started = false;
|
|
12
14
|
closed = false;
|
|
13
15
|
attached = new Map();
|
|
14
16
|
connected = new Map();
|
|
17
|
+
frames;
|
|
15
18
|
publishing = Promise.resolve();
|
|
16
19
|
receiving = Promise.resolve();
|
|
17
20
|
outgoingBytes = 0;
|
|
@@ -24,6 +27,7 @@ export class RedisTunnelRelay {
|
|
|
24
27
|
namespaceSchema.parse(options.namespace);
|
|
25
28
|
if (options.namespace !== options.ownership.namespace || Object.is(options.publisher, options.subscriber))
|
|
26
29
|
throw new Error('Separate, environment-scoped Redis clients are required');
|
|
30
|
+
this.frames = new RelayFrameAuthenticator(deriveRelayFrameKey({ secret: options.secret, namespace: options.namespace }));
|
|
27
31
|
if (options.connectTimeoutMs !== undefined && (!Number.isSafeInteger(options.connectTimeoutMs) || options.connectTimeoutMs < 1 || options.connectTimeoutMs > 60_000))
|
|
28
32
|
throw new Error('Invalid tunnel connect timeout');
|
|
29
33
|
const policy = options.publisher.options;
|
|
@@ -93,8 +97,15 @@ export class RedisTunnelRelay {
|
|
|
93
97
|
onMessage = (channel, raw) => {
|
|
94
98
|
if (!this.started || channel !== this.channel(this.options.nodeId))
|
|
95
99
|
return;
|
|
100
|
+
// Authenticate before anything else: a message without a valid MAC, fresh timestamp and new
|
|
101
|
+
// sequence is dropped without resetting live traffic, so a Redis client cannot inject or DoS.
|
|
102
|
+
const envelope = this.frames.open({ channel, message: raw });
|
|
103
|
+
if (!envelope) {
|
|
104
|
+
this.options.onRejectedFrame?.();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
96
107
|
const bytes = Buffer.byteLength(raw);
|
|
97
|
-
if (
|
|
108
|
+
if (this.incomingBytes + bytes > DEFAULT_TUNNEL_LIMITS.maxBufferedBytes) {
|
|
98
109
|
this.onRelayOverload();
|
|
99
110
|
return;
|
|
100
111
|
}
|
|
@@ -102,11 +113,13 @@ export class RedisTunnelRelay {
|
|
|
102
113
|
const generation = this.generation;
|
|
103
114
|
this.receiving = this.receiving.then(async () => {
|
|
104
115
|
if (this.started && generation === this.generation)
|
|
105
|
-
await this.receive(
|
|
116
|
+
await this.receive(envelope);
|
|
106
117
|
}).catch(() => this.onRelayOverload()).finally(() => { this.incomingBytes -= bytes; });
|
|
107
118
|
};
|
|
108
119
|
publish(nodeId, envelope) {
|
|
109
|
-
const
|
|
120
|
+
const channel = this.channel(nodeId);
|
|
121
|
+
// Sealed in call order, so each destination sees this node's sequence numbers ascending.
|
|
122
|
+
const payload = this.frames.seal({ channel, envelope });
|
|
110
123
|
const bytes = Buffer.byteLength(payload);
|
|
111
124
|
if (this.outgoingBytes + bytes > DEFAULT_TUNNEL_LIMITS.maxBufferedBytes)
|
|
112
125
|
return Promise.reject(new Error('Tunnel relay publish queue exceeded'));
|
|
@@ -115,7 +128,7 @@ export class RedisTunnelRelay {
|
|
|
115
128
|
const sending = this.publishing.then(async () => {
|
|
116
129
|
if (envelope.type !== 'close' && (generation !== this.generation || !this.started || this.closed))
|
|
117
130
|
throw new Error('Tunnel relay generation changed');
|
|
118
|
-
if (await this.options.publisher.publish(
|
|
131
|
+
if (await this.options.publisher.publish(channel, payload) < 1)
|
|
119
132
|
throw new Error('Tunnel node is offline');
|
|
120
133
|
});
|
|
121
134
|
this.publishing = sending.catch(() => undefined).finally(() => { this.outgoingBytes -= bytes; });
|
package/package.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"publishConfig": {
|
|
10
10
|
"access": "public"
|
|
11
11
|
},
|
|
12
|
-
"version": "0.
|
|
12
|
+
"version": "0.4.0",
|
|
13
13
|
"description": "SDK types and CLI library for building Artifact Studio apps. Driven via `seq-studio artifact <sub>` (@sequenceholdings/studio-cli), which runs the `./cli` runCli export.",
|
|
14
14
|
"type": "module",
|
|
15
15
|
"exports": {
|
|
@@ -148,7 +148,7 @@
|
|
|
148
148
|
"parse5": "7.3.0",
|
|
149
149
|
"@sequenceholdings/lattice-form-renderer": "0.1.3",
|
|
150
150
|
"@sequenceholdings/storage": "0.1.0",
|
|
151
|
-
"@sequenceholdings/orm": "2.1.
|
|
151
|
+
"@sequenceholdings/orm": "2.1.1"
|
|
152
152
|
},
|
|
153
153
|
"devDependencies": {
|
|
154
154
|
"@types/js-yaml": "^4.0.9",
|
|
@@ -71,6 +71,12 @@ only the capabilities the application needs, then configure authorized resource
|
|
|
71
71
|
bindings in Atlas. Local development uses explicit nonproduction resources and
|
|
72
72
|
never falls back to production when a binding is missing.
|
|
73
73
|
|
|
74
|
+
ORM access is declared per table and per mutation: list `<namespace>/<table>`
|
|
75
|
+
under `data.read` for every table a query reads (queries that follow
|
|
76
|
+
platform-user relations also read `<namespace>/platformUsers`). Under
|
|
77
|
+
`data.write`, use `<namespace>/ops/<Operation>` for each persisted mutation the
|
|
78
|
+
app runs, or `<namespace>/<table>` for table-level coverage.
|
|
79
|
+
|
|
74
80
|
Managed secrets stay in hosted runtimes. For local integration tests, use your
|
|
75
81
|
own test credentials, mocks or an existing governed function. Use a hosted
|
|
76
82
|
preview for application code that needs a managed secret. Never put secrets in
|
|
@@ -2,7 +2,21 @@ packages:
|
|
|
2
2
|
- '.'
|
|
3
3
|
|
|
4
4
|
minimumReleaseAge: 10080
|
|
5
|
+
# The Sequence SDK chain is published from the Studio repo's audited release
|
|
6
|
+
# pipeline, so a new artifact-studio release must install without waiting out
|
|
7
|
+
# the seven-day quarantine. Scoped to the exact first-party packages a fresh
|
|
8
|
+
# scaffold resolves, not @sequenceholdings/*.
|
|
9
|
+
minimumReleaseAgeExclude:
|
|
10
|
+
- '@sequenceholdings/artifact-studio'
|
|
11
|
+
- '@sequenceholdings/atlas-ui'
|
|
12
|
+
- '@sequenceholdings/custom-roles-manifest'
|
|
13
|
+
- '@sequenceholdings/lattice-form-renderer'
|
|
14
|
+
- '@sequenceholdings/orm'
|
|
15
|
+
- '@sequenceholdings/storage'
|
|
5
16
|
strictDepBuilds: true
|
|
6
17
|
allowBuilds:
|
|
7
18
|
'@tailwindcss/oxide': false
|
|
19
|
+
# Reached through the SDK's static builder and ORM tooling, never run by a
|
|
20
|
+
# Next.js app; its platform binary installs as an optional dependency.
|
|
21
|
+
esbuild: false
|
|
8
22
|
sharp: false
|