@sequenceholdings/artifact-studio 0.6.7 → 0.7.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/README.md +26 -1
- package/dist/cli.js +4 -6
- package/dist/gateway/abort.d.ts +6 -0
- package/dist/gateway/abort.js +16 -0
- package/dist/gateway/authorization-cache.d.ts +24 -0
- package/dist/gateway/authorization-cache.js +21 -3
- package/dist/gateway/bootstrap-page.d.ts +16 -0
- package/dist/gateway/bootstrap-page.js +42 -0
- package/dist/gateway/control-config.d.ts +21 -0
- package/dist/gateway/control-config.js +18 -0
- package/dist/gateway/control.d.ts +61 -0
- package/dist/gateway/control.js +86 -7
- package/dist/gateway/hosted-proxy.d.ts +3 -0
- package/dist/gateway/hosted-proxy.js +12 -2
- package/dist/gateway/release-admission.d.ts +5 -0
- package/dist/gateway/release-admission.js +19 -0
- package/dist/gateway/server.d.ts +4 -1
- package/dist/gateway/server.js +35 -42
- package/dist/gateway/session.d.ts +9 -8
- package/dist/gateway/session.js +9 -11
- package/dist/gateway/static-files.d.ts +66 -0
- package/dist/gateway/static-files.js +112 -0
- package/dist/gateway/usage.d.ts +13 -0
- package/dist/gateway/usage.js +18 -0
- package/dist/gateway-main.js +8 -5
- package/dist/git-clone.d.ts +1 -3
- package/dist/git-clone.js +6 -8
- package/dist/manifest-next.d.ts +4 -0
- package/dist/manifest-static.d.ts +8 -0
- package/dist/manifest-static.js +5 -0
- package/dist/manifest.d.ts +8 -0
- package/dist/runtime-contracts.d.ts +8 -0
- package/dist/runtime-contracts.js +10 -0
- package/dist/sdk-orm.d.ts +3 -3
- package/dist/source-boundary.d.ts +6 -0
- package/dist/source-boundary.js +7 -2
- package/dist/source-resolver.js +7 -16
- package/dist/templates/nextjs/README.md +7 -0
- package/dist/templates/react-vite/package.json +5 -2
- package/dist/templates/react-vite/tsconfig.json +17 -0
- package/dist/trusted-install.d.ts +9 -2
- package/dist/trusted-install.js +9 -2
- package/package.json +4 -4
- package/templates/nextjs/README.md +7 -0
- package/templates/react-vite/package.json +5 -2
- package/templates/react-vite/tsconfig.json +17 -0
package/dist/gateway/server.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createPrivateKey,
|
|
1
|
+
import { createPrivateKey, randomUUID } from 'node:crypto';
|
|
2
2
|
import { createServer } from 'node:http';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { ArtifactTunnelBroker } from '../tunnel/index.js';
|
|
@@ -6,16 +6,17 @@ import { assertSafeRequest, normalizePublicOrigin } from '../tunnel/protocol.js'
|
|
|
6
6
|
import { GatewayControlError } from './control.js';
|
|
7
7
|
import { decodeGatewaySession, gatewaySessionCookies, renewGatewaySession, signGatewayContext } from './session.js';
|
|
8
8
|
import { proxyHostedArtifact } from './hosted-proxy.js';
|
|
9
|
+
import { bootstrapPage } from './bootstrap-page.js';
|
|
9
10
|
import { routeSequenceRequest } from './lattice-invoke.js';
|
|
10
|
-
import { admitHostedMutation, ReleaseAdmissionError } from './release-admission.js';
|
|
11
|
+
import { admitHostedMutation, ReleaseAdmissionError, releaseHints } from './release-admission.js';
|
|
11
12
|
import { ArtifactAdmissionError } from './admission.js';
|
|
12
13
|
import { gatewayClientAddress } from './control-admission.js';
|
|
13
14
|
import { gatewayAuthorizationKey, PositiveDecisionCache, tunnelAuthorizationKey } from './authorization-cache.js';
|
|
14
|
-
import { GatewayUsageReporter } from './usage.js';
|
|
15
|
-
import { artifactNextFrameworkBuildIdSchema } from '../runtime-contracts.js';
|
|
15
|
+
import { gatewayElapsedMs, GatewayUsageReporter, snapshotGatewayTimings } from './usage.js';
|
|
16
16
|
import { createDevelopmentIngressProxy } from './ingress-development.js';
|
|
17
17
|
import { createIngressHandler, handleClientErrors } from './ingress-handler.js';
|
|
18
18
|
import { resemblesIngressHost } from './ingress.js';
|
|
19
|
+
import { serveStaticTarget, StaticArtifactFiles } from './static-files.js';
|
|
19
20
|
function json(response, status, error) {
|
|
20
21
|
if (response.headersSent) {
|
|
21
22
|
response.destroy();
|
|
@@ -41,24 +42,6 @@ function header(request, key) {
|
|
|
41
42
|
function isServiceWorkerScript(request) {
|
|
42
43
|
return request.headers['service-worker'] !== undefined || request.headers['sec-fetch-dest'] === 'serviceworker';
|
|
43
44
|
}
|
|
44
|
-
function releaseHints(request) {
|
|
45
|
-
function uniqueHeader(name) {
|
|
46
|
-
const value = request.headers[name];
|
|
47
|
-
if (value === undefined)
|
|
48
|
-
return undefined;
|
|
49
|
-
const count = request.rawHeaders.filter((_, index) => index % 2 === 0 && request.rawHeaders[index].toLowerCase() === name).length;
|
|
50
|
-
if (typeof value !== 'string' || count !== 1 || !artifactNextFrameworkBuildIdSchema.safeParse(value).success)
|
|
51
|
-
throw new ReleaseAdmissionError('conflicting-release');
|
|
52
|
-
return value;
|
|
53
|
-
}
|
|
54
|
-
const deploymentId = uniqueHeader('x-sequence-artifact-release');
|
|
55
|
-
const frameworkHeader = uniqueHeader('x-deployment-id');
|
|
56
|
-
const query = new URL(request.url ?? '/', 'https://gateway.invalid').searchParams.getAll('dpl');
|
|
57
|
-
if (query.length > 1 || (query.length === 1 && !artifactNextFrameworkBuildIdSchema.safeParse(query[0]).success) ||
|
|
58
|
-
(frameworkHeader && query.length === 1 && frameworkHeader !== query[0]))
|
|
59
|
-
throw new ReleaseAdmissionError('conflicting-release');
|
|
60
|
-
return { deploymentId, frameworkBuildId: frameworkHeader ?? query[0] };
|
|
61
|
-
}
|
|
62
45
|
async function boundedJson(request) {
|
|
63
46
|
if (header(request, 'content-type')?.split(';', 1)[0] !== 'application/json')
|
|
64
47
|
throw new GatewayControlError(400);
|
|
@@ -73,16 +56,11 @@ async function boundedJson(request) {
|
|
|
73
56
|
}
|
|
74
57
|
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
75
58
|
}
|
|
76
|
-
/**
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer',
|
|
82
|
-
'x-content-type-options': 'nosniff',
|
|
83
|
-
'content-security-policy': `default-src 'none'; script-src 'nonce-${nonce}'; connect-src 'self'; frame-ancestors ${parentOrigin}; base-uri 'none'; form-action 'none'`,
|
|
84
|
-
});
|
|
85
|
-
response.end(`<!doctype html><meta charset="utf-8"><title>Connecting application</title><p id="status">Connecting application…</p><script nonce="${nonce}">(() => {const ticket=new URLSearchParams(location.hash.slice(1)).get('ticket');history.replaceState(null,'',location.pathname);if(!ticket||window.top===window){document.getElementById('status').textContent='Open this application from Atlas.';return;}fetch('/_sequence/session',{method:'POST',credentials:'same-origin',headers:{'content-type':'application/json'},body:JSON.stringify({ticket})}).then(r=>{if(!r.ok)throw new Error();location.replace('/');}).catch(()=>{document.getElementById('status').textContent='This preview expired. Open it again from Atlas.';});})();</script>`);
|
|
59
|
+
/** Active use renews the session cookie within its admission bound. */
|
|
60
|
+
function renewCookie({ options, request, response, session, target }) {
|
|
61
|
+
const renewed = renewGatewaySession({ session, authorizedExpiresAt: target.expiresAt });
|
|
62
|
+
if (renewed)
|
|
63
|
+
response.setHeader('set-cookie', gatewaySessionCookies({ session: renewed, key: options.sessionKey, cookie: header(request, 'cookie'), renewal: true, sameSite: options.sessionCookieSameSite }));
|
|
86
64
|
}
|
|
87
65
|
/** Probes reach the container by its own address; an application host keeps its own /health route. */
|
|
88
66
|
function isReadinessRequest({ request, gatewayOrigin, runtimeDomain }) {
|
|
@@ -119,6 +97,7 @@ export function createArtifactGateway(options) {
|
|
|
119
97
|
const usage = new GatewayUsageReporter({ send: events => options.control.usage(events), onFailure: failure => options.onError?.(failure) });
|
|
120
98
|
let draining = false;
|
|
121
99
|
const isIngressRequest = (request) => resemblesIngressHost({ rawHost: header(request, 'host'), runtimeDomain: options.runtimeDomain });
|
|
100
|
+
const staticFiles = options.staticFiles ?? new StaticArtifactFiles({ fetch: ({ deploymentId, path }) => options.control.staticFile({ deploymentId, path, parentOrigin }) });
|
|
122
101
|
/** Admission for the unauthenticated endpoints; the release frees the in-flight permit. */
|
|
123
102
|
async function admitControlCall(endpoint, request) {
|
|
124
103
|
if (!options.controlAdmission)
|
|
@@ -132,15 +111,18 @@ export function createArtifactGateway(options) {
|
|
|
132
111
|
const { ticket } = z.object({ ticket: z.string().regex(/^[A-Za-z0-9_-]{43}$/) }).strict().parse(await boundedJson(request));
|
|
133
112
|
// Anyone can post a ticket: rate limit and admit before the exchange reaches Atlas.
|
|
134
113
|
const release = await admitControlCall('session', request);
|
|
135
|
-
let session;
|
|
114
|
+
let session, target;
|
|
115
|
+
const asked = authorizations.reserve();
|
|
136
116
|
try {
|
|
137
|
-
({ session } = await options.control.bootstrap(ticket));
|
|
117
|
+
({ session, target } = await options.control.bootstrap(ticket));
|
|
138
118
|
}
|
|
139
119
|
finally {
|
|
140
120
|
release();
|
|
141
121
|
}
|
|
142
122
|
if (session.publicOrigin !== origin || session.bridge.parentOrigin !== parentOrigin)
|
|
143
123
|
throw new GatewayControlError(403);
|
|
124
|
+
// Atlas just authorized this session: the document load that follows reuses it, not a second call.
|
|
125
|
+
authorizations.seed({ key: gatewayAuthorizationKey(session), value: target, asked, notAfter: value => value.expiresAt });
|
|
144
126
|
response.setHeader('set-cookie', gatewaySessionCookies({ session, key: options.sessionKey, cookie: header(request, 'cookie'), sameSite: options.sessionCookieSameSite }));
|
|
145
127
|
json(response, 200, 'connected');
|
|
146
128
|
}
|
|
@@ -186,9 +168,12 @@ export function createArtifactGateway(options) {
|
|
|
186
168
|
// same session and target for up to the cache TTL (never past the target's own expiry). Mutations
|
|
187
169
|
// always ask Atlas, so revocation, suspension and a changed activation stop them immediately.
|
|
188
170
|
const lookup = { key: gatewayAuthorizationKey(session), load: () => options.control.authorize(session), notAfter: (value) => value.expiresAt };
|
|
189
|
-
const
|
|
171
|
+
const decision = safeMethod
|
|
172
|
+
? authorizations.getObserved(lookup) : { promise: authorizations.refresh(lookup), source: 'refresh' };
|
|
173
|
+
const target = await decision.promise;
|
|
190
174
|
const requestId = randomUUID();
|
|
191
|
-
|
|
175
|
+
// A static deployment has no server of its own to read a request context.
|
|
176
|
+
const result = { session, target, requestId, authorizationCache: decision.source, requestContext: target.kind === 'static' ? '' : signGatewayContext({
|
|
192
177
|
session: { ...session, lattice: target.lattice, taskRoutes: target.taskRoutes }, method, path: request.url ?? '/', privateKey: options.contextPrivateKey, keyId: options.contextKeyId,
|
|
193
178
|
capabilities: target.capabilities, serviceAccountId: target.serviceAccountId, requestId,
|
|
194
179
|
}) };
|
|
@@ -308,7 +293,14 @@ export function createArtifactGateway(options) {
|
|
|
308
293
|
request.url = url.pathname + url.search;
|
|
309
294
|
}
|
|
310
295
|
}
|
|
311
|
-
const
|
|
296
|
+
const authorizationStarted = performance.now();
|
|
297
|
+
const { session, target, requestContext, requestId, authorizationCache } = await authorize(request);
|
|
298
|
+
// Immutable files Atlas already authorized this session for: no admission, context or usage.
|
|
299
|
+
if (target.kind === 'static') {
|
|
300
|
+
await serveStaticTarget({ request, response, target, files: staticFiles, renew: () => renewCookie({ options, request, response, session, target }), parentOrigin: session.bridge.parentOrigin });
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const gatewayTimings = { authorizationMs: gatewayElapsedMs(authorizationStarted), authorizationCache };
|
|
312
304
|
let reported = false;
|
|
313
305
|
const report = () => {
|
|
314
306
|
if (reported)
|
|
@@ -321,6 +313,7 @@ export function createArtifactGateway(options) {
|
|
|
321
313
|
measurements: { requestCount: 1, durationMs: Math.max(0, Math.round(performance.now() - started)) },
|
|
322
314
|
outcome: !response.writableFinished ? 'cancelled' : response.statusCode >= 400 ? 'error' : 'success',
|
|
323
315
|
httpStatus: response.statusCode,
|
|
316
|
+
gatewayTimings: snapshotGatewayTimings(gatewayTimings),
|
|
324
317
|
});
|
|
325
318
|
};
|
|
326
319
|
response.once('finish', report);
|
|
@@ -329,9 +322,7 @@ export function createArtifactGateway(options) {
|
|
|
329
322
|
report();
|
|
330
323
|
return;
|
|
331
324
|
}
|
|
332
|
-
|
|
333
|
-
if (renewed)
|
|
334
|
-
response.setHeader('set-cookie', gatewaySessionCookies({ session: renewed, key: options.sessionKey, cookie: header(request, 'cookie'), renewal: true, sameSite: options.sessionCookieSameSite }));
|
|
325
|
+
renewCookie({ options, request, response, session, target });
|
|
335
326
|
// Authorization precedes every response, including Next static assets and cached pages.
|
|
336
327
|
response.setHeader('content-security-policy', `frame-ancestors ${session.bridge.parentOrigin}`);
|
|
337
328
|
response.setHeader('referrer-policy', 'same-origin');
|
|
@@ -342,12 +333,14 @@ export function createArtifactGateway(options) {
|
|
|
342
333
|
}
|
|
343
334
|
if (!options.admission)
|
|
344
335
|
throw new ArtifactAdmissionError(503, 'admission_unavailable');
|
|
345
|
-
const
|
|
336
|
+
const admissionStarted = performance.now();
|
|
337
|
+
const admitted = await options.admission.acquire({ environment: target.environment, projectId: target.projectId, deploymentId: target.deploymentId, resources: target.manifest?.server.resources })
|
|
338
|
+
.finally(() => { gatewayTimings.admissionMs = gatewayElapsedMs(admissionStarted); });
|
|
346
339
|
admitted.bind(response, () => response.destroy());
|
|
347
340
|
if (response.destroyed)
|
|
348
341
|
return;
|
|
349
342
|
const { body } = await admitHostedMutation({ request, expectedDeploymentId: target.deploymentId, expectedFrameworkBuildId: target.frameworkBuildId, active: target.active, published: session.published });
|
|
350
|
-
await proxyHostedArtifact({ request, response, target, requestContext, body, limits: { timeoutMs: admitted.timeoutMs } });
|
|
343
|
+
await proxyHostedArtifact({ request, response, target, requestContext, body, limits: { timeoutMs: admitted.timeoutMs }, gatewayTimings });
|
|
351
344
|
})().catch(error => {
|
|
352
345
|
options.onError?.({ kind: error instanceof ReleaseAdmissionError ? 'release_mismatch' : error instanceof GatewayControlError ? 'authorization_denied'
|
|
353
346
|
: error instanceof ArtifactAdmissionError ? 'admission_rejected' : 'request_failed' });
|
|
@@ -4,6 +4,7 @@ export declare function gatewayReleaseCookieName(deploymentId: string): string;
|
|
|
4
4
|
declare const sessionSchema: z.ZodObject<{
|
|
5
5
|
version: z.ZodLiteral<1>;
|
|
6
6
|
kind: z.ZodEnum<{
|
|
7
|
+
static: "static";
|
|
7
8
|
development: "development";
|
|
8
9
|
hosted: "hosted";
|
|
9
10
|
}>;
|
|
@@ -38,16 +39,16 @@ export declare function renewGatewaySession({ session, authorizedExpiresAt, now
|
|
|
38
39
|
now?: number;
|
|
39
40
|
}): GatewaySession | null;
|
|
40
41
|
/**
|
|
41
|
-
* `Strict`
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* Partitioned
|
|
42
|
+
* `Strict` when every app origin sits under Atlas's own HTTPS host, so the iframe is same-site with
|
|
43
|
+
* its parent. Anywhere else — app origins on seqholdingsusercontent.com, or the local stack's
|
|
44
|
+
* http://localhost Atlas — a browser drops a `Strict` cookie inside the Atlas iframe, so the cookie
|
|
45
|
+
* is `SameSite=None; Partitioned`: the browser keys it to Atlas's top-level site and never sends it
|
|
46
|
+
* under any other. Cross-site writes are refused on `Origin` either way, not on SameSite.
|
|
45
47
|
*/
|
|
46
48
|
export type GatewaySessionSameSite = 'Strict' | 'None';
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
appEnv: string;
|
|
49
|
+
export declare function gatewaySessionSameSite({ parentOrigin, runtimeDomain }: {
|
|
50
|
+
parentOrigin: string;
|
|
51
|
+
runtimeDomain: string;
|
|
51
52
|
}): GatewaySessionSameSite;
|
|
52
53
|
export declare function gatewaySessionCookies({ session, key, cookie, renewal, now, sameSite }: {
|
|
53
54
|
session: GatewaySession;
|
package/dist/gateway/session.js
CHANGED
|
@@ -17,7 +17,7 @@ catch {
|
|
|
17
17
|
} });
|
|
18
18
|
const id = z.string().min(1).max(255);
|
|
19
19
|
const sessionSchema = z.object({
|
|
20
|
-
version: z.literal(1), kind: z.enum(['development', 'hosted']),
|
|
20
|
+
version: z.literal(1), kind: z.enum(['development', 'hosted', 'static']),
|
|
21
21
|
sessionId: id.nullable(), deploymentId: id.nullable(),
|
|
22
22
|
frameworkBuildId: artifactNextFrameworkBuildIdSchema.nullable(), projectId: id, ownerId: id,
|
|
23
23
|
environment: id, publicOrigin: origin, expiresAt: z.number().int().positive(), maxExpiresAt: z.number().int().positive().optional(), published: z.boolean(),
|
|
@@ -26,7 +26,10 @@ const sessionSchema = z.object({
|
|
|
26
26
|
taskRoutes: artifactRequestContextSchema.shape.taskRoutes,
|
|
27
27
|
}).strict().refine(value => value.kind === 'development'
|
|
28
28
|
? value.sessionId !== null && value.deploymentId === null && value.frameworkBuildId === null && !value.published
|
|
29
|
-
|
|
29
|
+
// A static deployment has no Next build: its assets are named by the deployment itself.
|
|
30
|
+
: value.kind === 'static'
|
|
31
|
+
? value.sessionId === null && value.deploymentId !== null && value.frameworkBuildId === null
|
|
32
|
+
: value.sessionId === null && value.deploymentId !== null && value.frameworkBuildId !== null);
|
|
30
33
|
function sameSessionScope(left, right) {
|
|
31
34
|
return left.ownerId === right.ownerId && left.projectId === right.projectId &&
|
|
32
35
|
left.environment === right.environment && left.publicOrigin === right.publicOrigin &&
|
|
@@ -39,15 +42,10 @@ export function renewGatewaySession({ session, authorizedExpiresAt, now = Date.n
|
|
|
39
42
|
const expiresAt = Math.min(authorizedExpiresAt, session.maxExpiresAt ?? session.expiresAt);
|
|
40
43
|
return expiresAt > session.expiresAt ? { ...session, expiresAt } : null;
|
|
41
44
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (value !== 'none')
|
|
47
|
-
throw new Error('ARTIFACT_NEXT_SESSION_COOKIE_SAMESITE must be "strict" or "none"');
|
|
48
|
-
if (appEnv !== 'development')
|
|
49
|
-
throw new Error('ARTIFACT_NEXT_SESSION_COOKIE_SAMESITE=none is only for a local development gateway');
|
|
50
|
-
return 'None';
|
|
45
|
+
export function gatewaySessionSameSite({ parentOrigin, runtimeDomain }) {
|
|
46
|
+
const parent = new URL(parentOrigin);
|
|
47
|
+
const underParent = runtimeDomain === parent.hostname || runtimeDomain.endsWith(`.${parent.hostname}`);
|
|
48
|
+
return parent.protocol === 'https:' && underParent ? 'Strict' : 'None';
|
|
51
49
|
}
|
|
52
50
|
function cookieAttributes(sameSite) {
|
|
53
51
|
return `Path=/; HttpOnly; Secure; ${sameSite === 'None' ? 'SameSite=None; Partitioned' : 'SameSite=Strict'}`;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { type GatewayTarget, type StaticArtifactFile } from './control.js';
|
|
3
|
+
/** Default byte budget for cached static files on one gateway replica (the gateway runs with 1 GiB). */
|
|
4
|
+
export declare const STATIC_CACHE_DEFAULT_BYTES: number;
|
|
5
|
+
/**
|
|
6
|
+
* Files of static deployments, cached by deployment and path. A deployment's files never change,
|
|
7
|
+
* so an entry is valid until evicted (least recently used, bounded by bytes). Concurrent misses for
|
|
8
|
+
* one file share a single fetch. The page is never cached: Atlas renders it, and its bridge script
|
|
9
|
+
* follows Atlas releases.
|
|
10
|
+
*/
|
|
11
|
+
export declare class StaticArtifactFiles {
|
|
12
|
+
private readonly options;
|
|
13
|
+
private readonly entries;
|
|
14
|
+
private readonly inflight;
|
|
15
|
+
private bytes;
|
|
16
|
+
constructor(options: {
|
|
17
|
+
fetch: (input: {
|
|
18
|
+
deploymentId: string;
|
|
19
|
+
path: string;
|
|
20
|
+
}) => Promise<StaticArtifactFile>;
|
|
21
|
+
maxBytes?: number;
|
|
22
|
+
});
|
|
23
|
+
get cachedBytes(): number;
|
|
24
|
+
get({ deploymentId, path }: {
|
|
25
|
+
deploymentId: string;
|
|
26
|
+
path: string;
|
|
27
|
+
}): Promise<StaticArtifactFile & {
|
|
28
|
+
etag: string;
|
|
29
|
+
}>;
|
|
30
|
+
private remember;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The policy a static application runs under: the sandbox's former `<meta>` policy, now a header
|
|
34
|
+
* on the application's own origin (so `'self'` is the application, never Atlas), plus what a meta
|
|
35
|
+
* tag cannot express: `frame-ancestors` keeps the page inside Atlas.
|
|
36
|
+
*/
|
|
37
|
+
export declare function staticContentSecurityPolicy(parentOrigin: string): string;
|
|
38
|
+
/**
|
|
39
|
+
* Where a static request is served from: `/_artifact/<deployment>/<file>` for assets, the page for
|
|
40
|
+
* every other path (in-app routes live in the hash or the page's own history). Null: not this
|
|
41
|
+
* deployment's file.
|
|
42
|
+
*/
|
|
43
|
+
export declare function staticFileForRequest({ url, deploymentId }: {
|
|
44
|
+
url: string;
|
|
45
|
+
deploymentId: string;
|
|
46
|
+
}): string | null;
|
|
47
|
+
/** Writes one authorized static file: GET or HEAD, revalidated on every use (each request is authorized). */
|
|
48
|
+
export declare function sendStaticFile({ request, response, file, parentOrigin }: {
|
|
49
|
+
request: IncomingMessage;
|
|
50
|
+
response: ServerResponse;
|
|
51
|
+
file: StaticArtifactFile & {
|
|
52
|
+
etag: string;
|
|
53
|
+
};
|
|
54
|
+
parentOrigin: string;
|
|
55
|
+
}): void;
|
|
56
|
+
/** Answers one request for an authorized static target: reads only, and only this deployment's files. */
|
|
57
|
+
export declare function serveStaticTarget({ request, response, target, files, renew, parentOrigin }: {
|
|
58
|
+
request: IncomingMessage;
|
|
59
|
+
response: ServerResponse;
|
|
60
|
+
target: Extract<GatewayTarget, {
|
|
61
|
+
kind: 'static';
|
|
62
|
+
}>;
|
|
63
|
+
files: Pick<StaticArtifactFiles, 'get'>;
|
|
64
|
+
renew: () => void;
|
|
65
|
+
parentOrigin: string;
|
|
66
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { GatewayControlError, STATIC_PAGE } from './control.js';
|
|
3
|
+
/** Default byte budget for cached static files on one gateway replica (the gateway runs with 1 GiB). */
|
|
4
|
+
export const STATIC_CACHE_DEFAULT_BYTES = 192 * 1024 * 1024;
|
|
5
|
+
/**
|
|
6
|
+
* Files of static deployments, cached by deployment and path. A deployment's files never change,
|
|
7
|
+
* so an entry is valid until evicted (least recently used, bounded by bytes). Concurrent misses for
|
|
8
|
+
* one file share a single fetch. The page is never cached: Atlas renders it, and its bridge script
|
|
9
|
+
* follows Atlas releases.
|
|
10
|
+
*/
|
|
11
|
+
export class StaticArtifactFiles {
|
|
12
|
+
options;
|
|
13
|
+
entries = new Map();
|
|
14
|
+
inflight = new Map();
|
|
15
|
+
bytes = 0;
|
|
16
|
+
constructor(options) {
|
|
17
|
+
this.options = options;
|
|
18
|
+
}
|
|
19
|
+
get cachedBytes() { return this.bytes; }
|
|
20
|
+
async get({ deploymentId, path }) {
|
|
21
|
+
if (path === STATIC_PAGE)
|
|
22
|
+
return withEtag(await this.options.fetch({ deploymentId, path }));
|
|
23
|
+
const key = `${deploymentId}/${path}`;
|
|
24
|
+
const hit = this.entries.get(key);
|
|
25
|
+
if (hit) {
|
|
26
|
+
// Refresh recency: a Map iterates in insertion order, so re-insert at the end.
|
|
27
|
+
this.entries.delete(key);
|
|
28
|
+
this.entries.set(key, hit);
|
|
29
|
+
return hit;
|
|
30
|
+
}
|
|
31
|
+
const pending = this.inflight.get(key);
|
|
32
|
+
if (pending)
|
|
33
|
+
return pending;
|
|
34
|
+
const load = this.options.fetch({ deploymentId, path }).then(file => {
|
|
35
|
+
const entry = withEtag(file);
|
|
36
|
+
this.remember(key, entry);
|
|
37
|
+
return entry;
|
|
38
|
+
}).finally(() => { this.inflight.delete(key); });
|
|
39
|
+
this.inflight.set(key, load);
|
|
40
|
+
return load;
|
|
41
|
+
}
|
|
42
|
+
remember(key, entry) {
|
|
43
|
+
const limit = this.options.maxBytes ?? STATIC_CACHE_DEFAULT_BYTES;
|
|
44
|
+
if (entry.body.length > limit)
|
|
45
|
+
return;
|
|
46
|
+
this.entries.set(key, entry);
|
|
47
|
+
this.bytes += entry.body.length;
|
|
48
|
+
for (const [oldest, value] of this.entries) {
|
|
49
|
+
if (this.bytes <= limit)
|
|
50
|
+
break;
|
|
51
|
+
this.entries.delete(oldest);
|
|
52
|
+
this.bytes -= value.body.length;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function withEtag(file) {
|
|
57
|
+
return { ...file, etag: file.etag ?? `"${createHash('sha256').update(file.body).digest('base64url')}"` };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The policy a static application runs under: the sandbox's former `<meta>` policy, now a header
|
|
61
|
+
* on the application's own origin (so `'self'` is the application, never Atlas), plus what a meta
|
|
62
|
+
* tag cannot express: `frame-ancestors` keeps the page inside Atlas.
|
|
63
|
+
*/
|
|
64
|
+
export function staticContentSecurityPolicy(parentOrigin) {
|
|
65
|
+
return [
|
|
66
|
+
"default-src 'none'", "script-src 'self' 'unsafe-inline' 'unsafe-eval'", "style-src 'self' 'unsafe-inline'",
|
|
67
|
+
"img-src 'self' data: blob: https:", "font-src 'self' data:", "connect-src 'self' blob:", "worker-src 'self' blob:",
|
|
68
|
+
"object-src blob:", "frame-src blob:", `frame-ancestors ${parentOrigin}`, "base-uri 'none'", "form-action 'none'",
|
|
69
|
+
].join('; ');
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Where a static request is served from: `/_artifact/<deployment>/<file>` for assets, the page for
|
|
73
|
+
* every other path (in-app routes live in the hash or the page's own history). Null: not this
|
|
74
|
+
* deployment's file.
|
|
75
|
+
*/
|
|
76
|
+
export function staticFileForRequest({ url, deploymentId }) {
|
|
77
|
+
const pathname = new URL(url, 'https://static.invalid').pathname;
|
|
78
|
+
const prefix = '/_artifact/';
|
|
79
|
+
if (!pathname.startsWith(prefix))
|
|
80
|
+
return STATIC_PAGE;
|
|
81
|
+
const [requested, ...rest] = pathname.slice(prefix.length).split('/');
|
|
82
|
+
if (requested !== deploymentId)
|
|
83
|
+
return null;
|
|
84
|
+
const file = rest.join('/');
|
|
85
|
+
return /^(?:bundle\.js|sourcemap\.json|chunks\/[A-Za-z0-9._-]{1,200})$/.test(file) ? file : null;
|
|
86
|
+
}
|
|
87
|
+
/** Writes one authorized static file: GET or HEAD, revalidated on every use (each request is authorized). */
|
|
88
|
+
export function sendStaticFile({ request, response, file, parentOrigin }) {
|
|
89
|
+
const headers = {
|
|
90
|
+
'content-type': file.contentType, etag: file.etag, 'cache-control': 'private, no-cache',
|
|
91
|
+
'content-security-policy': staticContentSecurityPolicy(parentOrigin),
|
|
92
|
+
'x-content-type-options': 'nosniff', 'referrer-policy': 'same-origin',
|
|
93
|
+
};
|
|
94
|
+
if (request.headers['if-none-match'] === file.etag) {
|
|
95
|
+
response.writeHead(304, headers);
|
|
96
|
+
response.end();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
response.writeHead(200, { ...headers, 'content-length': String(file.body.length) });
|
|
100
|
+
response.end(request.method === 'HEAD' ? undefined : file.body);
|
|
101
|
+
}
|
|
102
|
+
/** Answers one request for an authorized static target: reads only, and only this deployment's files. */
|
|
103
|
+
export async function serveStaticTarget({ request, response, target, files, renew, parentOrigin }) {
|
|
104
|
+
if (!['GET', 'HEAD'].includes(request.method ?? 'GET'))
|
|
105
|
+
throw new GatewayControlError(405);
|
|
106
|
+
const path = staticFileForRequest({ url: request.url ?? '/', deploymentId: target.deploymentId });
|
|
107
|
+
if (!path)
|
|
108
|
+
throw new GatewayControlError(404);
|
|
109
|
+
const file = await files.get({ deploymentId: target.deploymentId, path });
|
|
110
|
+
renew();
|
|
111
|
+
sendStaticFile({ request, response, file, parentOrigin });
|
|
112
|
+
}
|
package/dist/gateway/usage.d.ts
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
/** Closed, non-sensitive phase observations. An absent phase did not finish before reporting. */
|
|
2
|
+
export interface GatewayTimings {
|
|
3
|
+
authorizationMs?: number;
|
|
4
|
+
authorizationCache?: 'hit' | 'shared' | 'load' | 'refresh';
|
|
5
|
+
admissionMs?: number;
|
|
6
|
+
identityTokenMs?: number;
|
|
7
|
+
/** Transport and app work until response headers, not pure app execution time. */
|
|
8
|
+
upstreamHeadersMs?: number;
|
|
9
|
+
}
|
|
10
|
+
export declare function gatewayElapsedMs(started: number): number;
|
|
11
|
+
/** Detach the once-only usage event from work that can settle after cancellation. */
|
|
12
|
+
export declare function snapshotGatewayTimings(timings: GatewayTimings): Readonly<GatewayTimings>;
|
|
1
13
|
export interface GatewayUsageEvent {
|
|
2
14
|
eventId: string;
|
|
3
15
|
requestId: string;
|
|
@@ -22,6 +34,7 @@ export interface GatewayUsageEvent {
|
|
|
22
34
|
};
|
|
23
35
|
outcome: 'success' | 'error' | 'cancelled';
|
|
24
36
|
httpStatus: number;
|
|
37
|
+
gatewayTimings?: Readonly<GatewayTimings>;
|
|
25
38
|
}
|
|
26
39
|
export type GatewayUsageFailure = {
|
|
27
40
|
kind: 'usage_queue_full' | 'usage_delivery_failed' | 'usage_delivery_rejected' | 'usage_events_rejected' | 'usage_lost_on_shutdown';
|
package/dist/gateway/usage.js
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
const MAX_GATEWAY_TIMING_MS = 600_000;
|
|
2
|
+
export function gatewayElapsedMs(started) {
|
|
3
|
+
return Math.min(MAX_GATEWAY_TIMING_MS, Math.max(0, Math.round(performance.now() - started)));
|
|
4
|
+
}
|
|
5
|
+
/** Detach the once-only usage event from work that can settle after cancellation. */
|
|
6
|
+
export function snapshotGatewayTimings(timings) {
|
|
7
|
+
const snapshot = {};
|
|
8
|
+
const durationKeys = ['authorizationMs', 'admissionMs', 'identityTokenMs', 'upstreamHeadersMs'];
|
|
9
|
+
for (const key of durationKeys) {
|
|
10
|
+
const value = timings[key];
|
|
11
|
+
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= MAX_GATEWAY_TIMING_MS)
|
|
12
|
+
snapshot[key] = value;
|
|
13
|
+
}
|
|
14
|
+
const source = timings.authorizationCache;
|
|
15
|
+
if (source === 'hit' || source === 'shared' || source === 'load' || source === 'refresh')
|
|
16
|
+
snapshot.authorizationCache = source;
|
|
17
|
+
return Object.freeze(snapshot);
|
|
18
|
+
}
|
|
1
19
|
export const GATEWAY_USAGE_LIMITS = Object.freeze({ capacity: 10_000, maxBatch: 100, flushIntervalMs: 1_000, concurrency: 4 });
|
|
2
20
|
const ATTEMPTS = 3;
|
|
3
21
|
/**
|
package/dist/gateway-main.js
CHANGED
|
@@ -2,6 +2,8 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { isIP } from 'node:net';
|
|
3
3
|
import { Redis } from 'ioredis';
|
|
4
4
|
import { ArtifactGatewayControl } from './gateway/control.js';
|
|
5
|
+
import { resolveGatewayControlConfig } from './gateway/control-config.js';
|
|
6
|
+
import { hostedIdentityToken } from './gateway/hosted-proxy.js';
|
|
5
7
|
import { createArtifactGateway } from './gateway/server.js';
|
|
6
8
|
import { gatewaySessionSameSite } from './gateway/session.js';
|
|
7
9
|
import { RedisTunnelOwnership, RedisTunnelRelay } from './tunnel/redis.js';
|
|
@@ -58,9 +60,9 @@ async function main() {
|
|
|
58
60
|
let stopping = false;
|
|
59
61
|
subscriber.on('ready', () => { if (!stopping)
|
|
60
62
|
void relay.start().catch(() => undefined); });
|
|
61
|
-
const
|
|
63
|
+
const controlConfig = resolveGatewayControlConfig({ env: process.env, identityToken: hostedIdentityToken });
|
|
62
64
|
const controlToken = required('ARTIFACT_NEXT_CONTROL_TOKEN');
|
|
63
|
-
const control = new ArtifactGatewayControl({ url:
|
|
65
|
+
const control = new ArtifactGatewayControl({ url: controlConfig.url, token: controlToken, identity: controlConfig.identity });
|
|
64
66
|
const contextPrivateKey = required('ARTIFACT_NEXT_CONTEXT_PRIVATE_KEY');
|
|
65
67
|
const contextKeyId = required('ARTIFACT_NEXT_CONTEXT_KEY_ID');
|
|
66
68
|
const admission = new ArtifactRequestAdmission({ redis: command, namespace });
|
|
@@ -73,13 +75,14 @@ async function main() {
|
|
|
73
75
|
else
|
|
74
76
|
refuse(failure.kind); },
|
|
75
77
|
});
|
|
78
|
+
const runtimeDomain = required('ARTIFACT_NEXT_RUNTIME_DOMAIN');
|
|
76
79
|
const gateway = createArtifactGateway({
|
|
77
80
|
control,
|
|
78
|
-
runtimeDomain
|
|
79
|
-
parentOrigin:
|
|
81
|
+
runtimeDomain,
|
|
82
|
+
parentOrigin: controlConfig.parentOrigin,
|
|
80
83
|
gatewayOrigin: required('ARTIFACT_NEXT_GATEWAY_URL'),
|
|
81
84
|
sessionKey,
|
|
82
|
-
sessionCookieSameSite: gatewaySessionSameSite({
|
|
85
|
+
sessionCookieSameSite: gatewaySessionSameSite({ parentOrigin: controlConfig.parentOrigin, runtimeDomain }),
|
|
83
86
|
contextPrivateKey,
|
|
84
87
|
contextKeyId,
|
|
85
88
|
nodeId, routing: ownership, relay,
|
package/dist/git-clone.d.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
/** Read the git PAT from the environment (`ATLAS_GIT_PAT`), trimmed. */
|
|
2
|
-
export declare function resolveGitPatFromEnv(): string | undefined;
|
|
3
1
|
/** Full or abbreviated hex SHAs — not valid as `git clone --branch`. */
|
|
4
2
|
export declare function looksLikeCommitSha(ref: string): boolean;
|
|
5
3
|
/**
|
|
@@ -20,7 +18,7 @@ export type RunGitClone = (args: {
|
|
|
20
18
|
* default branch in full, then check the SHA out.
|
|
21
19
|
*/
|
|
22
20
|
depth?: number;
|
|
23
|
-
/**
|
|
21
|
+
/** Login access token used as Basic-auth password (username `git`). */
|
|
24
22
|
pat: string;
|
|
25
23
|
/** Hard ceiling before the git child is killed (default 5m). */
|
|
26
24
|
timeoutMs?: number;
|
package/dist/git-clone.js
CHANGED
|
@@ -5,20 +5,15 @@
|
|
|
5
5
|
* re-exports it from `src/repos/git-clone.ts` so the `repos` tool and the
|
|
6
6
|
* artifact `--repo` deploy path share one clone path.
|
|
7
7
|
*
|
|
8
|
-
* Credentials are supplied via an ephemeral GIT_ASKPASS script so the
|
|
8
|
+
* Credentials are supplied via an ephemeral GIT_ASKPASS script so the token
|
|
9
9
|
* never lands in `remote.origin.url` or the git argv. Callers pass a clean
|
|
10
|
-
* HTTPS clone URL plus the
|
|
10
|
+
* HTTPS clone URL plus the login token separately.
|
|
11
11
|
*/
|
|
12
12
|
import { spawn } from 'node:child_process';
|
|
13
13
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
14
14
|
import { tmpdir } from 'node:os';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
import { createScrubbedChildEnv } from './child-environment.js';
|
|
17
|
-
/** Read the git PAT from the environment (`ATLAS_GIT_PAT`), trimmed. */
|
|
18
|
-
export function resolveGitPatFromEnv() {
|
|
19
|
-
const value = process.env.ATLAS_GIT_PAT?.trim();
|
|
20
|
-
return value || undefined;
|
|
21
|
-
}
|
|
22
17
|
/** Full or abbreviated hex SHAs — not valid as `git clone --branch`. */
|
|
23
18
|
export function looksLikeCommitSha(ref) {
|
|
24
19
|
return /^[0-9a-f]{7,40}$/i.test(ref);
|
|
@@ -151,9 +146,12 @@ export async function withGitAskpass({ pat, fn, }) {
|
|
|
151
146
|
GIT_TERMINAL_PROMPT: '0',
|
|
152
147
|
GIT_CONFIG_NOSYSTEM: '1',
|
|
153
148
|
SEQ_STUDIO_GIT_ASKPASS_PASSWORD: pat,
|
|
154
|
-
GIT_CONFIG_COUNT: '
|
|
149
|
+
GIT_CONFIG_COUNT: '2',
|
|
155
150
|
GIT_CONFIG_KEY_0: 'credential.helper',
|
|
156
151
|
GIT_CONFIG_VALUE_0: '',
|
|
152
|
+
// Never redirect a platform credential, even to another path on this host.
|
|
153
|
+
GIT_CONFIG_KEY_1: 'http.followRedirects',
|
|
154
|
+
GIT_CONFIG_VALUE_1: 'false',
|
|
157
155
|
},
|
|
158
156
|
}));
|
|
159
157
|
}
|
package/dist/manifest-next.d.ts
CHANGED
|
@@ -401,6 +401,10 @@ export declare const artifactStudioNextManifestSchema: z.ZodObject<{
|
|
|
401
401
|
mobile: z.ZodOptional<z.ZodEnum<{
|
|
402
402
|
responsive: "responsive";
|
|
403
403
|
}>>;
|
|
404
|
+
status: z.ZodOptional<z.ZodEnum<{
|
|
405
|
+
alpha: "alpha";
|
|
406
|
+
beta: "beta";
|
|
407
|
+
}>>;
|
|
404
408
|
}, z.core.$strip>>;
|
|
405
409
|
sidebarIcon: z.ZodOptional<z.ZodString>;
|
|
406
410
|
sidebarBadge: z.ZodOptional<z.ZodObject<{
|
|
@@ -12,6 +12,10 @@ export declare const artifactPresentationSchema: z.ZodObject<{
|
|
|
12
12
|
mobile: z.ZodOptional<z.ZodEnum<{
|
|
13
13
|
responsive: "responsive";
|
|
14
14
|
}>>;
|
|
15
|
+
status: z.ZodOptional<z.ZodEnum<{
|
|
16
|
+
alpha: "alpha";
|
|
17
|
+
beta: "beta";
|
|
18
|
+
}>>;
|
|
15
19
|
}, z.core.$strip>;
|
|
16
20
|
export type ArtifactPresentation = z.infer<typeof artifactPresentationSchema>;
|
|
17
21
|
export declare const artifactOfflineCapabilitySchema: z.ZodObject<{
|
|
@@ -95,6 +99,10 @@ export declare const artifactStudioStaticManifestSchema: z.ZodObject<{
|
|
|
95
99
|
mobile: z.ZodOptional<z.ZodEnum<{
|
|
96
100
|
responsive: "responsive";
|
|
97
101
|
}>>;
|
|
102
|
+
status: z.ZodOptional<z.ZodEnum<{
|
|
103
|
+
alpha: "alpha";
|
|
104
|
+
beta: "beta";
|
|
105
|
+
}>>;
|
|
98
106
|
}, z.core.$strip>>;
|
|
99
107
|
offline: z.ZodOptional<z.ZodObject<{
|
|
100
108
|
mode: z.ZodEnum<{
|
package/dist/manifest-static.js
CHANGED
|
@@ -16,6 +16,11 @@ export const sidebarBadgeSchema = z.object({
|
|
|
16
16
|
});
|
|
17
17
|
export const artifactPresentationSchema = z.object({
|
|
18
18
|
mobile: z.enum(['responsive']).optional(),
|
|
19
|
+
/**
|
|
20
|
+
* Lifecycle stage the Atlas shell shows as an Alpha/Beta badge beside the
|
|
21
|
+
* app's name. Omit for GA. Mirrors `App.status` for native apps.
|
|
22
|
+
*/
|
|
23
|
+
status: z.enum(['alpha', 'beta']).optional(),
|
|
19
24
|
});
|
|
20
25
|
export const artifactOfflineCapabilitySchema = z.object({
|
|
21
26
|
mode: z.enum(['read', 'read-write']),
|
package/dist/manifest.d.ts
CHANGED
|
@@ -69,6 +69,10 @@ export declare const artifactStudioManifestSchema: z.ZodUnion<readonly [z.ZodObj
|
|
|
69
69
|
mobile: z.ZodOptional<z.ZodEnum<{
|
|
70
70
|
responsive: "responsive";
|
|
71
71
|
}>>;
|
|
72
|
+
status: z.ZodOptional<z.ZodEnum<{
|
|
73
|
+
alpha: "alpha";
|
|
74
|
+
beta: "beta";
|
|
75
|
+
}>>;
|
|
72
76
|
}, z.core.$strip>>;
|
|
73
77
|
offline: z.ZodOptional<z.ZodObject<{
|
|
74
78
|
mode: z.ZodEnum<{
|
|
@@ -284,6 +288,10 @@ export declare const artifactStudioManifestSchema: z.ZodUnion<readonly [z.ZodObj
|
|
|
284
288
|
mobile: z.ZodOptional<z.ZodEnum<{
|
|
285
289
|
responsive: "responsive";
|
|
286
290
|
}>>;
|
|
291
|
+
status: z.ZodOptional<z.ZodEnum<{
|
|
292
|
+
alpha: "alpha";
|
|
293
|
+
beta: "beta";
|
|
294
|
+
}>>;
|
|
287
295
|
}, z.core.$strip>>;
|
|
288
296
|
sidebarIcon: z.ZodOptional<z.ZodString>;
|
|
289
297
|
sidebarBadge: z.ZodOptional<z.ZodObject<{
|