@sequenceholdings/artifact-studio 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/VERSION-PIN.md +4 -0
  2. package/dist/cli.js +29 -3
  3. package/dist/gateway/admission.d.ts +2 -1
  4. package/dist/gateway/admission.js +3 -1
  5. package/dist/gateway/authorization-cache.d.ts +52 -0
  6. package/dist/gateway/authorization-cache.js +111 -0
  7. package/dist/gateway/control-admission.d.ts +48 -0
  8. package/dist/gateway/control-admission.js +127 -0
  9. package/dist/gateway/control.d.ts +4 -1
  10. package/dist/gateway/control.js +5 -2
  11. package/dist/gateway/hosted-proxy.js +4 -7
  12. package/dist/gateway/server.d.ts +9 -0
  13. package/dist/gateway/server.js +101 -17
  14. package/dist/gateway/session.d.ts +1 -0
  15. package/dist/gateway/session.js +2 -1
  16. package/dist/gateway/usage.d.ts +45 -12
  17. package/dist/gateway/usage.js +96 -26
  18. package/dist/gateway-main.js +35 -5
  19. package/dist/manifest-next.js +13 -1
  20. package/dist/next/bridge.js +8 -1
  21. package/dist/next-cache/config.js +31 -4
  22. package/dist/next-image-config.js +1 -1
  23. package/dist/server/client.d.ts +16 -2
  24. package/dist/server/request-context.d.ts +1 -0
  25. package/dist/server/request-context.js +4 -0
  26. package/dist/server/runtime-config.d.ts +1 -0
  27. package/dist/server.d.ts +1 -1
  28. package/dist/templates/nextjs/README.md +6 -0
  29. package/dist/templates/nextjs/gitignore +9 -0
  30. package/dist/templates/nextjs/pnpm-workspace.yaml +14 -0
  31. package/dist/trusted-install.js +62 -2
  32. package/dist/tunnel/broker.js +34 -22
  33. package/dist/tunnel/protocol.d.ts +9 -0
  34. package/dist/tunnel/protocol.js +31 -17
  35. package/dist/tunnel/redis-auth.d.ts +37 -0
  36. package/dist/tunnel/redis-auth.js +92 -0
  37. package/dist/tunnel/redis-types.d.ts +5 -0
  38. package/dist/tunnel/redis.d.ts +5 -2
  39. package/dist/tunnel/redis.js +19 -6
  40. package/package.json +1 -1
  41. package/templates/nextjs/README.md +6 -0
  42. package/templates/nextjs/gitignore +9 -0
  43. package/templates/nextjs/pnpm-workspace.yaml +14 -0
@@ -1,10 +1,12 @@
1
1
  import { readFile, readdir, rename, writeFile } from 'node:fs/promises';
2
- import { join } from 'node:path';
2
+ import { dirname, join, resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import { load as parseYaml } from 'js-yaml';
5
+ import { isNextArtifactManifest, parseArtifactStudioManifest } from '../manifest.js';
4
6
  import { resolveArtifactNextImages } from '../next-image-config.js';
5
7
  export { artifactNextCacheBindingSchema, verifyArtifactNextCacheReadiness } from './runtime.js';
6
8
  const MARKER = '// Generated by Atlas Artifact Studio; original configuration is preserved.';
7
- function configured(config) {
9
+ function configured(config, egress) {
8
10
  const deploymentId = process.env.ARTIFACT_NEXT_BUILD_ID;
9
11
  if (deploymentId !== undefined && (deploymentId.length < 1 || deploymentId.length > 128 || /[^A-Za-z0-9_-]/.test(deploymentId)))
10
12
  throw new Error('Invalid platform build identifier');
@@ -25,7 +27,6 @@ function configured(config) {
25
27
  || Object.entries(handlers).some(([key, value]) => !['default', 'remote'].includes(key) || value !== components))) {
26
28
  throw new Error('Artifact Studio owns the Next cacheHandlers configuration');
27
29
  }
28
- const egress = JSON.parse(process.env.ARTIFACT_NEXT_ALLOWED_EGRESS ?? '[]');
29
30
  if (!Array.isArray(egress) || !egress.every((host) => typeof host === 'string'))
30
31
  throw new Error('Invalid Artifact image egress policy');
31
32
  const images = resolveArtifactNextImages({ images: Reflect.get(config, 'images'), egressHosts: egress });
@@ -34,6 +35,32 @@ function configured(config) {
34
35
  cacheHandler: incremental, cacheHandlers: { default: components, remote: components },
35
36
  images: { ...images, customCacheHandler: true } };
36
37
  }
38
+ /**
39
+ * seq-studio and the hosted build supply the application's egress policy. A plain local
40
+ * `next build` or `next dev` reads the same declaration from the application's manifest.
41
+ */
42
+ async function allowedEgress(config) {
43
+ const supplied = process.env.ARTIFACT_NEXT_ALLOWED_EGRESS;
44
+ if (supplied !== undefined)
45
+ return JSON.parse(supplied);
46
+ const images = Reflect.get(config, 'images');
47
+ const patterns = typeof images === 'object' && images !== null ? Reflect.get(images, 'remotePatterns') : undefined;
48
+ if (process.env.K_SERVICE || !Array.isArray(patterns) || patterns.length === 0)
49
+ return [];
50
+ return declaredEgress({ projectRoot: process.cwd() });
51
+ }
52
+ /** The nearest artifact.bundle.yml, when its app_root is this project; the manifest may sit above app_root. */
53
+ async function declaredEgress({ projectRoot }) {
54
+ for (let directory = projectRoot;; directory = dirname(directory)) {
55
+ const text = await readFile(join(directory, 'artifact.bundle.yml'), 'utf8').catch(() => undefined);
56
+ if (text !== undefined) {
57
+ const manifest = parseArtifactStudioManifest(parseYaml(text));
58
+ return isNextArtifactManifest(manifest) && resolve(directory, manifest.artifact.app_root) === projectRoot ? manifest.server.egress : [];
59
+ }
60
+ if (dirname(directory) === directory)
61
+ return [];
62
+ }
63
+ }
37
64
  /** Always compiled into standalone output; runtime mode is selected only at boot. */
38
65
  export function withArtifactNextConfig(input) {
39
66
  return async (phase, context) => {
@@ -48,7 +75,7 @@ export function withArtifactNextConfig(input) {
48
75
  const config = typeof input === 'function' ? await input(phase, context) : input;
49
76
  if (!config || typeof config !== 'object' || Array.isArray(config))
50
77
  throw new Error('Next configuration must return an object');
51
- return configured(config);
78
+ return configured(config, await allowedEgress(config));
52
79
  };
53
80
  }
54
81
  /** Runs only inside the isolated compile sandbox, never in the control-plane worker. */
@@ -66,7 +66,7 @@ function resolveRemotePattern({ pattern, index, egressHosts }) {
66
66
  throw new Error(`${label} requires an exact public HTTPS hostname on port 443; wildcard, IP and local hosts are unsupported`);
67
67
  }
68
68
  if (!egressHosts.includes(value.hostname)) {
69
- throw new Error(`${label} hostname must be declared in server.egress; run seq-studio artifact dev/build to supply the application's image policy`);
69
+ throw new Error(`${label} hostname ${value.hostname} must be declared in the application's server.egress in artifact.bundle.yml`);
70
70
  }
71
71
  const pathname = remotePatternPathname({ pathname: value.pathname ?? '/**', label });
72
72
  const search = remotePatternSearch({ search: value.search, label });
@@ -1,5 +1,5 @@
1
1
  import { storageClient } from '@sequenceholdings/storage';
2
- import { type OperationsManifest, type OperationExecutor } from '@sequenceholdings/orm/client';
2
+ import { type OperationExecutor } from '@sequenceholdings/orm/client';
3
3
  import type { SequenceApiFetchResponse } from '../sdk.js';
4
4
  import { type ArtifactServerMode, type ArtifactServerRequest } from './broker-contract.js';
5
5
  import { createArtifactLogger, type ArtifactLogRecord } from './logger.js';
@@ -15,6 +15,20 @@ export interface ArtifactServerFetchOptions {
15
15
  body?: unknown;
16
16
  signal?: AbortSignal;
17
17
  }
18
+ /**
19
+ * The namespace's generated operations.manifest.json. Only each operation's persisted hash
20
+ * is read, so the file can be passed as imported: a JSON import types `kind` as string.
21
+ */
22
+ export interface ArtifactOrmOperations {
23
+ readonly manifestVersion?: number;
24
+ readonly schemaHash?: string;
25
+ readonly opsHash?: string;
26
+ readonly operations: Readonly<Record<string, {
27
+ readonly hash: string;
28
+ readonly kind?: string;
29
+ readonly requires?: unknown;
30
+ }>>;
31
+ }
18
32
  export interface ArtifactServerClient {
19
33
  context: ArtifactRequestContext;
20
34
  mode: ArtifactServerMode;
@@ -26,7 +40,7 @@ export interface ArtifactServerClient {
26
40
  };
27
41
  orm(input: {
28
42
  namespace: string;
29
- manifest: OperationsManifest;
43
+ manifest: ArtifactOrmOperations;
30
44
  }): OperationExecutor;
31
45
  asService(): ArtifactServerClient;
32
46
  }
@@ -20,6 +20,7 @@ export declare const artifactRequestContextSchema: z.ZodObject<{
20
20
  deploymentId: z.ZodNullable<z.ZodString>;
21
21
  devSessionId: z.ZodNullable<z.ZodString>;
22
22
  requestId: z.ZodString;
23
+ jti: z.ZodUUID;
23
24
  method: z.ZodEnum<{
24
25
  GET: "GET";
25
26
  POST: "POST";
@@ -26,7 +26,11 @@ export const artifactRequestContextSchema = z.object({
26
26
  mode: z.enum(['development', 'preview', 'published']),
27
27
  deploymentId: id.nullable(),
28
28
  devSessionId: id.nullable(),
29
+ /** Correlates logs, usage and audit records for the originating request. */
29
30
  requestId: id,
31
+ /** Unique per signed context. Atlas budgets delegated calls per jti until the context expires. */
32
+ jti: z.uuid(),
33
+ /** The originating browser request's method. Atlas only delegates reads for GET, HEAD and OPTIONS. */
30
34
  method: z.enum(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']),
31
35
  path: z.string().min(1).max(8192).refine(value => value.startsWith('/') &&
32
36
  !value.startsWith('//') && !/[\\\r\n\u0000#]/.test(value), 'Expected an origin-relative request path'),
@@ -31,6 +31,7 @@ export declare function getArtifactRequestContext({ headers, runtime, method, pa
31
31
  deploymentId: string | null;
32
32
  devSessionId: string | null;
33
33
  requestId: string;
34
+ jti: string;
34
35
  method: "GET" | "POST" | "DELETE" | "HEAD" | "PUT" | "PATCH" | "OPTIONS";
35
36
  path: string;
36
37
  iat: number;
package/dist/server.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import 'server-only';
2
2
  export { createArtifactServerClient, ArtifactServerError } from './server/client.js';
3
- export type { ArtifactServerClient, ArtifactServerFetchOptions } from './server/client.js';
3
+ export type { ArtifactOrmOperations, ArtifactServerClient, ArtifactServerFetchOptions } from './server/client.js';
4
4
  export { getArtifactRequestContext, readArtifactRuntimeConfig } from './server/runtime-config.js';
5
5
  export type { ArtifactRuntimeConfig, ArtifactRequestHeaders } from './server/runtime-config.js';
6
6
  export { getArtifactBrowserBootstrap } from './server/request-context.js';
@@ -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
@@ -0,0 +1,9 @@
1
+ node_modules/
2
+ .next/
3
+ next-env.d.ts
4
+ .artifact/
5
+ .artifact-studio/
6
+ .env*
7
+ *.tsbuildinfo
8
+ coverage/
9
+ .DS_Store
@@ -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
@@ -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 assertRegistryOnlySpecMap(map, label) {
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
@@ -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 === 403 ? 'Forbidden' : 'Service Unavailable'}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
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, 403);
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
- const headers = sanitizeResponseHeaders(frame.headers);
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') {
@@ -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 {};
@@ -26,21 +26,29 @@ const HOP_HEADERS = new Set([
26
26
  'proxy-connection', 'te', 'trailer', 'transfer-encoding', 'upgrade',
27
27
  'content-length', 'expect',
28
28
  ]);
29
- const headerValueSchema = z.string().max(8192).regex(HEADER_VALUE);
30
- const headersSchema = z.record(z.string().max(256).regex(HEADER_TOKEN), z.union([headerValueSchema, z.array(headerValueSchema).min(1).max(32)])).superRefine((headers, context) => {
31
- const names = new Set();
32
- let bytes = 0;
33
- for (const [name, value] of Object.entries(headers)) {
34
- if (names.has(name.toLowerCase())) {
35
- context.addIssue({ code: 'custom', message: 'Duplicate case-insensitive header' });
29
+ const MAX_HEADER_BYTES = 32 * 1024;
30
+ function headerMapSchema({ maxValueLength }) {
31
+ const value = z.string().max(maxValueLength).regex(HEADER_VALUE);
32
+ return z.record(z.string().max(256).regex(HEADER_TOKEN), z.union([value, z.array(value).min(1).max(32)])).superRefine((headers, context) => {
33
+ const names = new Set();
34
+ let bytes = 0;
35
+ for (const [name, value] of Object.entries(headers)) {
36
+ if (names.has(name.toLowerCase())) {
37
+ context.addIssue({ code: 'custom', message: 'Duplicate case-insensitive header' });
38
+ }
39
+ names.add(name.toLowerCase());
40
+ bytes += Buffer.byteLength(name) + Buffer.byteLength(Array.isArray(value) ? value.join(',') : value);
36
41
  }
37
- names.add(name.toLowerCase());
38
- bytes += Buffer.byteLength(name) + Buffer.byteLength(Array.isArray(value) ? value.join(',') : value);
39
- }
40
- if (names.size > 128 || bytes > 32 * 1024) {
41
- context.addIssue({ code: 'custom', message: 'Tunnel headers exceed their size limit' });
42
- }
43
- });
42
+ if (names.size > 128 || bytes > MAX_HEADER_BYTES) {
43
+ context.addIssue({ code: 'custom', message: 'Tunnel headers exceed their size limit' });
44
+ }
45
+ });
46
+ }
47
+ // A frame carries the gateway's signed request context, which the hosted proxy accepts up to 16 KiB.
48
+ const headersSchema = headerMapSchema({ maxValueLength: 16 * 1024 });
49
+ // A browser request still holds the gateway's own session cookies, which are removed before any
50
+ // frame is built, so they must not use up the application's per-value allowance.
51
+ const browserRequestHeadersSchema = headerMapSchema({ maxValueLength: MAX_HEADER_BYTES });
44
52
  /** Enforce origin-form paths before a URL parser can reinterpret a destination. */
45
53
  export function assertSafeRequest(method, path) {
46
54
  if (!METHODS.has(method))
@@ -125,9 +133,9 @@ export function normalizePublicOrigin(value) {
125
133
  throw new Error('Tunnel public origin must be an HTTPS origin or HTTP loopback origin');
126
134
  return url.origin;
127
135
  }
128
- function normalizedHeaders(headers) {
136
+ function normalizedHeaders(headers, schema = headersSchema) {
129
137
  const present = Object.fromEntries(Object.entries(headers).filter(([, value]) => value !== undefined));
130
- return new Map(Object.entries(headersSchema.parse(present)).map(([name, value]) => [name.toLowerCase(), value]));
138
+ return new Map(Object.entries(schema.parse(present)).map(([name, value]) => [name.toLowerCase(), value]));
131
139
  }
132
140
  function removePrivateHeaders(headers) {
133
141
  const connection = headers.get('connection');
@@ -144,7 +152,7 @@ function removePrivateHeaders(headers) {
144
152
  /** Rebuild trusted routing headers; no Atlas bearer/session crosses into app code. */
145
153
  export function sanitizeRequestHeaders(headers, publicOrigin) {
146
154
  const origin = normalizePublicOrigin(publicOrigin);
147
- const normalized = normalizedHeaders(headers);
155
+ const normalized = normalizedHeaders(headers, browserRequestHeadersSchema);
148
156
  const suppliedOrigin = normalized.get('origin');
149
157
  if (suppliedOrigin !== undefined && suppliedOrigin !== origin)
150
158
  throw new Error('Tunnel request origin mismatch');
@@ -180,6 +188,12 @@ function constrainCookie(cookie) {
180
188
  return [assignment.trim(), ...attributes.map((attribute) => attribute.trim())
181
189
  .filter((attribute) => attribute && attribute.split('=', 1)[0].trim().toLowerCase() !== 'domain')].join('; ');
182
190
  }
191
+ /** Response headers through which application output could opt into CDN or proxy reuse. */
192
+ export function isSharedCacheDirective(name) {
193
+ return /(?:^|-)cdn-cache-control$/.test(name) || ['surrogate-control', 'x-accel-expires', 'expires', 'age'].includes(name);
194
+ }
195
+ /** Sent in their place on every protected response, so each reuse must pass gateway authorization again. */
196
+ export const SHARED_CACHE_REFUSAL = Object.freeze({ 'cdn-cache-control': 'no-store', 'surrogate-control': 'no-store', pragma: 'no-cache', expires: '0' });
183
197
  export function sanitizeResponseHeaders(headers) {
184
198
  const normalized = normalizedHeaders(headers);
185
199
  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
  }