@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.
- package/VERSION-PIN.md +4 -0
- package/dist/cli.js +29 -3
- 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 +9 -0
- package/dist/gateway/server.js +101 -17
- package/dist/gateway/session.d.ts +1 -0
- package/dist/gateway/session.js +2 -1
- package/dist/gateway/usage.d.ts +45 -12
- package/dist/gateway/usage.js +96 -26
- package/dist/gateway-main.js +35 -5
- package/dist/manifest-next.js +13 -1
- package/dist/next/bridge.js +8 -1
- package/dist/next-cache/config.js +31 -4
- package/dist/next-image-config.js +1 -1
- package/dist/server/client.d.ts +16 -2
- package/dist/server/request-context.d.ts +1 -0
- package/dist/server/request-context.js +4 -0
- package/dist/server/runtime-config.d.ts +1 -0
- 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/trusted-install.js +62 -2
- package/dist/tunnel/broker.js +34 -22
- package/dist/tunnel/protocol.d.ts +9 -0
- package/dist/tunnel/protocol.js +31 -17
- 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 +1 -1
- package/templates/nextjs/README.md +6 -0
- package/templates/nextjs/gitignore +9 -0
- 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
|
|
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 });
|
package/dist/server/client.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { storageClient } from '@sequenceholdings/storage';
|
|
2
|
-
import { type
|
|
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:
|
|
43
|
+
manifest: ArtifactOrmOperations;
|
|
30
44
|
}): OperationExecutor;
|
|
31
45
|
asService(): ArtifactServerClient;
|
|
32
46
|
}
|
|
@@ -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
|
|
@@ -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
|
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') {
|
|
@@ -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
|
@@ -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
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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(
|
|
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
|
}
|