@sequenceholdings/artifact-studio 0.7.0 → 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.
@@ -21,6 +21,14 @@ export function bootstrapLandingUrl(route, origin) {
21
21
  return root;
22
22
  }
23
23
  }
24
+ /**
25
+ * Transparent while it connects, so the frame keeps the host's own background through the exchange
26
+ * and the redirect (a white page reading "Connecting" flashed between Atlas and the app). Only a
27
+ * failure is shown, on its own light card so it reads over either host theme.
28
+ */
29
+ const BOOTSTRAP_STYLE = 'html,body{margin:0;height:100%;background:transparent}'
30
+ + 'body{display:flex;align-items:center;justify-content:center;font:14px/1.4 system-ui,sans-serif}'
31
+ + '#status:empty{display:none}#status{margin:16px;padding:12px 16px;border-radius:8px;background:#fff;color:#27272a;box-shadow:0 1px 3px rgba(0,0,0,.2)}';
24
32
  /** The bootstrap ticket travels in a fragment and a POST body, never a request URL or log. The page
25
33
  * exchanges it only inside the Atlas frame; browsers without Fetch Metadata are covered by the script check. */
26
34
  export function bootstrapPage({ response, parentOrigin }) {
@@ -28,7 +36,7 @@ export function bootstrapPage({ response, parentOrigin }) {
28
36
  response.writeHead(200, {
29
37
  'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer',
30
38
  'x-content-type-options': 'nosniff',
31
- 'content-security-policy': `default-src 'none'; script-src 'nonce-${nonce}'; connect-src 'self'; frame-ancestors ${parentOrigin}; base-uri 'none'; form-action 'none'`,
39
+ 'content-security-policy': `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; frame-ancestors ${parentOrigin}; base-uri 'none'; form-action 'none'`,
32
40
  });
33
- response.end(`<!doctype html><meta charset="utf-8"><title>Connecting application</title><p id="status">Connecting application…</p><script nonce="${nonce}">(() => {const landing=${bootstrapLandingUrl.toString()};const fragment=new URLSearchParams(location.hash.slice(1));const ticket=fragment.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(landing(fragment.get('route'),location.origin));}).catch(()=>{document.getElementById('status').textContent='This preview expired. Open it again from Atlas.';});})();</script>`);
41
+ response.end(`<!doctype html><meta charset="utf-8"><title>Opening application</title><style nonce="${nonce}">${BOOTSTRAP_STYLE}</style><p id="status" role="status"></p><script nonce="${nonce}">(() => {const landing=${bootstrapLandingUrl.toString()};const fragment=new URLSearchParams(location.hash.slice(1));const ticket=fragment.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(landing(fragment.get('route'),location.origin));}).catch(()=>{document.getElementById('status').textContent='This preview expired. Open it again from Atlas.';});})();</script>`);
34
42
  }
@@ -67,6 +67,36 @@ export declare const gatewayTargetSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
67
67
  }, z.core.$strip>;
68
68
  }, z.core.$strip>;
69
69
  }, z.core.$strip>>;
70
+ }, z.core.$strip>, z.ZodObject<{
71
+ kind: z.ZodLiteral<"static">;
72
+ deploymentId: z.ZodString;
73
+ contentHash: z.ZodString;
74
+ active: z.ZodBoolean;
75
+ published: z.ZodOptional<z.ZodBoolean>;
76
+ projectId: z.ZodString;
77
+ ownerId: z.ZodString;
78
+ environment: z.ZodString;
79
+ publicOrigin: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
80
+ expiresAt: z.ZodNumber;
81
+ maxExpiresAt: z.ZodOptional<z.ZodNumber>;
82
+ capabilities: z.ZodDefault<z.ZodArray<z.ZodString>>;
83
+ serviceAccountId: z.ZodDefault<z.ZodNullable<z.ZodString>>;
84
+ lattice: z.ZodOptional<z.ZodObject<{
85
+ runId: z.ZodNullable<z.ZodString>;
86
+ nodeId: z.ZodNullable<z.ZodString>;
87
+ stepId: z.ZodNullable<z.ZodString>;
88
+ processId: z.ZodNullable<z.ZodString>;
89
+ }, z.core.$strict>>;
90
+ taskRoutes: z.ZodOptional<z.ZodArray<z.ZodString>>;
91
+ manifest: z.ZodOptional<z.ZodObject<{
92
+ server: z.ZodObject<{
93
+ resources: z.ZodObject<{
94
+ concurrency: z.ZodNumber;
95
+ max_instances: z.ZodNumber;
96
+ timeout_seconds: z.ZodNumber;
97
+ }, z.core.$strip>;
98
+ }, z.core.$strip>;
99
+ }, z.core.$strip>>;
70
100
  }, z.core.$strip>], "kind">;
71
101
  export type GatewayTarget = z.infer<typeof gatewayTargetSchema>;
72
102
  declare const latticeInvokeGrantSchema: z.ZodObject<{
@@ -91,6 +121,15 @@ declare const latticeInvokeGrantSchema: z.ZodObject<{
91
121
  }, z.core.$strict>;
92
122
  }, z.core.$strict>;
93
123
  export type LatticeInvokeGrant = z.infer<typeof latticeInvokeGrantSchema>;
124
+ /** Paths a static deployment serves: its page, entry bundle, source map and lazy chunks. */
125
+ export declare const STATIC_PAGE = "index.html";
126
+ /** The static bundle ceiling (30 MiB) plus headroom; a larger file is a platform fault, never served. */
127
+ export declare const STATIC_FILE_MAX_BYTES: number;
128
+ export interface StaticArtifactFile {
129
+ body: Buffer;
130
+ contentType: string;
131
+ etag?: string;
132
+ }
94
133
  export declare class GatewayControlError extends Error {
95
134
  readonly status: number;
96
135
  constructor(status: number);
@@ -156,6 +195,16 @@ export declare class ArtifactGatewayControl {
156
195
  target: GatewayTarget;
157
196
  }>;
158
197
  authorize(session: GatewaySession): Promise<GatewayTarget>;
198
+ /**
199
+ * One immutable file of a static deployment (the page, entry bundle, source map or a chunk), raw
200
+ * bytes up to the static bundle ceiling. The gateway authorized the browser's session first; this
201
+ * call carries only the gateway's own credential.
202
+ */
203
+ staticFile({ deploymentId, path, parentOrigin }: {
204
+ deploymentId: string;
205
+ path: string;
206
+ parentOrigin: string;
207
+ }): Promise<StaticArtifactFile>;
159
208
  /** SHA-256 both sides so a wrong bearer still takes constant time. */
160
209
  tokenMatches(presented: string): boolean;
161
210
  latticeInvoke(body: {
@@ -20,6 +20,8 @@ const base = {
20
20
  export const gatewayTargetSchema = z.discriminatedUnion('kind', [
21
21
  z.object({ ...base, kind: z.literal('development'), sessionId: id, epoch: z.number().int().positive() }),
22
22
  z.object({ ...base, kind: z.literal('hosted'), deploymentId: id, frameworkBuildId: artifactNextFrameworkBuildIdSchema, upstreamUrl: z.string().url(), upstreamAudience: z.string().url().optional(), runtimeTransport: z.enum(['cloud-run', 'local-docker']).optional(), active: z.boolean(), published: z.boolean().optional() }),
23
+ // A static (React/Vite) deployment: the gateway serves its immutable files itself, fetched from Atlas.
24
+ z.object({ ...base, kind: z.literal('static'), deploymentId: id, contentHash: z.string().min(1).max(128), active: z.boolean(), published: z.boolean().optional() }),
23
25
  ]);
24
26
  const bootstrapSchema = z.object({ parentOrigin: z.string().transform(normalizePublicOrigin), channelId: id, nonce: z.string().min(16).max(255) });
25
27
  const usageResultSchema = z.object({ rejected: z.number().int().nonnegative() });
@@ -41,6 +43,39 @@ const latticeInvokeGrantSchema = z.object({
41
43
  timeout_seconds: z.number().int().min(1).max(300),
42
44
  }).strict(),
43
45
  }).strict();
46
+ /** Paths a static deployment serves: its page, entry bundle, source map and lazy chunks. */
47
+ export const STATIC_PAGE = 'index.html';
48
+ const STATIC_FILE_PATH = /^(?:index\.html|bundle\.js|sourcemap\.json|chunks\/[A-Za-z0-9._-]{1,200})$/;
49
+ /** The static bundle ceiling (30 MiB) plus headroom; a larger file is a platform fault, never served. */
50
+ export const STATIC_FILE_MAX_BYTES = 32 * 1024 * 1024;
51
+ const STATIC_CONTENT_TYPES = new Set(['text/html', 'application/javascript', 'text/javascript', 'application/json', 'text/css']);
52
+ /** Reads at most `maxBytes` of a response body, refusing (502) anything larger. */
53
+ async function readBounded(response, maxBytes) {
54
+ if (Number(response.headers.get('content-length')) > maxBytes) {
55
+ await response.body?.cancel().catch(() => undefined);
56
+ throw new GatewayControlError(502);
57
+ }
58
+ const reader = response.body?.getReader();
59
+ if (!reader)
60
+ throw new GatewayControlError(502);
61
+ const chunks = [];
62
+ let length = 0;
63
+ try {
64
+ for (;;) {
65
+ const { done, value } = await reader.read();
66
+ if (done)
67
+ break;
68
+ length += value.length;
69
+ if (length > maxBytes)
70
+ throw new GatewayControlError(502);
71
+ chunks.push(value);
72
+ }
73
+ return Buffer.concat(chunks);
74
+ }
75
+ finally {
76
+ await reader.cancel().catch(() => undefined);
77
+ }
78
+ }
44
79
  export class GatewayControlError extends Error {
45
80
  status;
46
81
  constructor(status) {
@@ -186,11 +221,11 @@ export class ArtifactGatewayControl {
186
221
  version: 1, kind: target.kind, projectId: target.projectId, ownerId: target.ownerId,
187
222
  environment: target.environment, publicOrigin: target.publicOrigin,
188
223
  sessionId: target.kind === 'development' ? target.sessionId : null,
189
- deploymentId: target.kind === 'hosted' ? target.deploymentId : null,
224
+ deploymentId: target.kind === 'development' ? null : target.deploymentId,
190
225
  frameworkBuildId: target.kind === 'hosted' ? target.frameworkBuildId : null,
191
226
  expiresAt: Math.min(target.expiresAt, target.maxExpiresAt ?? target.expiresAt, Date.now() + 8 * 60 * 60_000),
192
227
  maxExpiresAt: Math.min(target.maxExpiresAt ?? (target.kind === 'development' ? target.expiresAt : Date.now() + 8 * 60 * 60_000), Date.now() + 8 * 60 * 60_000),
193
- published: target.kind === 'hosted' && (target.published ?? target.active), bridge,
228
+ published: target.kind !== 'development' && (target.published ?? target.active), bridge,
194
229
  ...(target.lattice ? { lattice: target.lattice } : {}),
195
230
  ...(target.taskRoutes ? { taskRoutes: target.taskRoutes } : {}),
196
231
  };
@@ -206,10 +241,37 @@ export class ArtifactGatewayControl {
206
241
  const target = gatewayTargetSchema.parse(value);
207
242
  if (target.kind !== session.kind || target.projectId !== session.projectId || target.ownerId !== session.ownerId ||
208
243
  target.environment !== session.environment || target.publicOrigin !== session.publicOrigin || target.expiresAt <= Date.now() ||
209
- (target.kind === 'development' ? target.sessionId !== session.sessionId : target.deploymentId !== session.deploymentId || target.frameworkBuildId !== session.frameworkBuildId))
244
+ (target.kind === 'development' ? target.sessionId !== session.sessionId
245
+ : target.deploymentId !== session.deploymentId || (target.kind === 'hosted' && target.frameworkBuildId !== session.frameworkBuildId)))
210
246
  throw new GatewayControlError(403);
211
247
  return target;
212
248
  }
249
+ /**
250
+ * One immutable file of a static deployment (the page, entry bundle, source map or a chunk), raw
251
+ * bytes up to the static bundle ceiling. The gateway authorized the browser's session first; this
252
+ * call carries only the gateway's own credential.
253
+ */
254
+ async staticFile({ deploymentId, path, parentOrigin }) {
255
+ if (!STATIC_FILE_PATH.test(path) || !/^[A-Za-z0-9_-]{1,128}$/.test(deploymentId))
256
+ throw new GatewayControlError(404);
257
+ const signal = AbortSignal.timeout(15_000);
258
+ const credential = await this.credential(signal);
259
+ const query = path === STATIC_PAGE ? `?parentOrigin=${encodeURIComponent(parentOrigin)}` : '';
260
+ const response = await (this.options.fetch ?? fetch)(`${this.origin}${ARTIFACT_NEXT_CONTROL_PATH}/static/${deploymentId}/${path}${query}`, {
261
+ headers: { authorization: `Bearer ${credential}` }, signal, redirect: 'error',
262
+ });
263
+ if (!response.ok) {
264
+ await response.body?.cancel().catch(() => undefined);
265
+ throw new GatewayControlError(response.status);
266
+ }
267
+ const contentType = response.headers.get('content-type') ?? '';
268
+ if (!STATIC_CONTENT_TYPES.has(contentType.split(';', 1)[0].trim().toLowerCase())) {
269
+ await response.body?.cancel().catch(() => undefined);
270
+ throw new GatewayControlError(502);
271
+ }
272
+ const body = await readBounded(response, STATIC_FILE_MAX_BYTES);
273
+ return { body, contentType, etag: response.headers.get('etag') ?? undefined };
274
+ }
213
275
  /** SHA-256 both sides so a wrong bearer still takes constant time. */
214
276
  tokenMatches(presented) {
215
277
  if (presented.length === 0 || presented.length > 1024)
@@ -20,4 +20,9 @@ export declare function admitHostedMutation({ request, expectedDeploymentId, exp
20
20
  }): Promise<{
21
21
  body?: AsyncIterable<Uint8Array>;
22
22
  }>;
23
+ /** The release a request names: the deployment header, or the Next build id (header or `dpl` query), never conflicting. */
24
+ export declare function releaseHints(request: IncomingMessage): {
25
+ deploymentId?: string;
26
+ frameworkBuildId?: string;
27
+ };
23
28
  export {};
@@ -257,3 +257,22 @@ export async function admitHostedMutation({ request, expectedDeploymentId, expec
257
257
  }
258
258
  return { body: continuation() };
259
259
  }
260
+ /** The release a request names: the deployment header, or the Next build id (header or `dpl` query), never conflicting. */
261
+ export function releaseHints(request) {
262
+ function uniqueHeader(name) {
263
+ const value = request.headers[name];
264
+ if (value === undefined)
265
+ return undefined;
266
+ const count = request.rawHeaders.filter((_, index) => index % 2 === 0 && request.rawHeaders[index].toLowerCase() === name).length;
267
+ if (typeof value !== 'string' || count !== 1 || !artifactNextFrameworkBuildIdSchema.safeParse(value).success)
268
+ throw new ReleaseAdmissionError('conflicting-release');
269
+ return value;
270
+ }
271
+ const deploymentId = uniqueHeader('x-sequence-artifact-release');
272
+ const frameworkHeader = uniqueHeader('x-deployment-id');
273
+ const query = new URL(request.url ?? '/', 'https://gateway.invalid').searchParams.getAll('dpl');
274
+ if (query.length > 1 || (query.length === 1 && !artifactNextFrameworkBuildIdSchema.safeParse(query[0]).success) ||
275
+ (frameworkHeader && query.length === 1 && frameworkHeader !== query[0]))
276
+ throw new ReleaseAdmissionError('conflicting-release');
277
+ return { deploymentId, frameworkBuildId: frameworkHeader ?? query[0] };
278
+ }
@@ -5,6 +5,7 @@ import { type GatewaySessionSameSite } from './session.js';
5
5
  import { type ArtifactRequestAdmission } from './admission.js';
6
6
  import { type GatewayControlAdmission } from './control-admission.js';
7
7
  import { type ArtifactGatewayIngressOptions } from './ingress.js';
8
+ import { StaticArtifactFiles } from './static-files.js';
8
9
  export interface ArtifactGatewayOptions {
9
10
  control: ArtifactGatewayControl;
10
11
  runtimeDomain: string;
@@ -28,6 +29,8 @@ export interface ArtifactGatewayOptions {
28
29
  controlAdmission?: Pick<GatewayControlAdmission, 'admit'>;
29
30
  /** External ingress front door (design §(e)). Absent: every `ingress-*` host answers 404. */
30
31
  ingress?: ArtifactGatewayIngressOptions;
32
+ /** Static deployments' files (default: fetched from Atlas, cached by deployment within the default byte budget). */
33
+ staticFiles?: Pick<StaticArtifactFiles, 'get'>;
31
34
  /** Reuse of a positive Atlas authorization (default GATEWAY_AUTHORIZATION_TTL_MS; 0 disables). */
32
35
  authorizationCacheTtlMs?: number;
33
36
  onError?: (event: {
@@ -8,15 +8,15 @@ import { decodeGatewaySession, gatewaySessionCookies, renewGatewaySession, signG
8
8
  import { proxyHostedArtifact } from './hosted-proxy.js';
9
9
  import { bootstrapPage } from './bootstrap-page.js';
10
10
  import { routeSequenceRequest } from './lattice-invoke.js';
11
- import { admitHostedMutation, ReleaseAdmissionError } from './release-admission.js';
11
+ import { admitHostedMutation, ReleaseAdmissionError, releaseHints } from './release-admission.js';
12
12
  import { ArtifactAdmissionError } from './admission.js';
13
13
  import { gatewayClientAddress } from './control-admission.js';
14
14
  import { gatewayAuthorizationKey, PositiveDecisionCache, tunnelAuthorizationKey } from './authorization-cache.js';
15
15
  import { gatewayElapsedMs, GatewayUsageReporter, snapshotGatewayTimings } from './usage.js';
16
- import { artifactNextFrameworkBuildIdSchema } from '../runtime-contracts.js';
17
16
  import { createDevelopmentIngressProxy } from './ingress-development.js';
18
17
  import { createIngressHandler, handleClientErrors } from './ingress-handler.js';
19
18
  import { resemblesIngressHost } from './ingress.js';
19
+ import { serveStaticTarget, StaticArtifactFiles } from './static-files.js';
20
20
  function json(response, status, error) {
21
21
  if (response.headersSent) {
22
22
  response.destroy();
@@ -42,24 +42,6 @@ function header(request, key) {
42
42
  function isServiceWorkerScript(request) {
43
43
  return request.headers['service-worker'] !== undefined || request.headers['sec-fetch-dest'] === 'serviceworker';
44
44
  }
45
- function releaseHints(request) {
46
- function uniqueHeader(name) {
47
- const value = request.headers[name];
48
- if (value === undefined)
49
- return undefined;
50
- const count = request.rawHeaders.filter((_, index) => index % 2 === 0 && request.rawHeaders[index].toLowerCase() === name).length;
51
- if (typeof value !== 'string' || count !== 1 || !artifactNextFrameworkBuildIdSchema.safeParse(value).success)
52
- throw new ReleaseAdmissionError('conflicting-release');
53
- return value;
54
- }
55
- const deploymentId = uniqueHeader('x-sequence-artifact-release');
56
- const frameworkHeader = uniqueHeader('x-deployment-id');
57
- const query = new URL(request.url ?? '/', 'https://gateway.invalid').searchParams.getAll('dpl');
58
- if (query.length > 1 || (query.length === 1 && !artifactNextFrameworkBuildIdSchema.safeParse(query[0]).success) ||
59
- (frameworkHeader && query.length === 1 && frameworkHeader !== query[0]))
60
- throw new ReleaseAdmissionError('conflicting-release');
61
- return { deploymentId, frameworkBuildId: frameworkHeader ?? query[0] };
62
- }
63
45
  async function boundedJson(request) {
64
46
  if (header(request, 'content-type')?.split(';', 1)[0] !== 'application/json')
65
47
  throw new GatewayControlError(400);
@@ -74,6 +56,12 @@ async function boundedJson(request) {
74
56
  }
75
57
  return JSON.parse(Buffer.concat(chunks).toString('utf8'));
76
58
  }
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 }));
64
+ }
77
65
  /** Probes reach the container by its own address; an application host keeps its own /health route. */
78
66
  function isReadinessRequest({ request, gatewayOrigin, runtimeDomain }) {
79
67
  if (request.method !== 'GET')
@@ -109,6 +97,7 @@ export function createArtifactGateway(options) {
109
97
  const usage = new GatewayUsageReporter({ send: events => options.control.usage(events), onFailure: failure => options.onError?.(failure) });
110
98
  let draining = false;
111
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 }) });
112
101
  /** Admission for the unauthenticated endpoints; the release frees the in-flight permit. */
113
102
  async function admitControlCall(endpoint, request) {
114
103
  if (!options.controlAdmission)
@@ -183,7 +172,8 @@ export function createArtifactGateway(options) {
183
172
  ? authorizations.getObserved(lookup) : { promise: authorizations.refresh(lookup), source: 'refresh' };
184
173
  const target = await decision.promise;
185
174
  const requestId = randomUUID();
186
- const result = { session, target, requestId, authorizationCache: decision.source, requestContext: signGatewayContext({
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({
187
177
  session: { ...session, lattice: target.lattice, taskRoutes: target.taskRoutes }, method, path: request.url ?? '/', privateKey: options.contextPrivateKey, keyId: options.contextKeyId,
188
178
  capabilities: target.capabilities, serviceAccountId: target.serviceAccountId, requestId,
189
179
  }) };
@@ -305,6 +295,11 @@ export function createArtifactGateway(options) {
305
295
  }
306
296
  const authorizationStarted = performance.now();
307
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
+ }
308
303
  const gatewayTimings = { authorizationMs: gatewayElapsedMs(authorizationStarted), authorizationCache };
309
304
  let reported = false;
310
305
  const report = () => {
@@ -327,9 +322,7 @@ export function createArtifactGateway(options) {
327
322
  report();
328
323
  return;
329
324
  }
330
- const renewed = renewGatewaySession({ session, authorizedExpiresAt: target.expiresAt });
331
- if (renewed)
332
- 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 });
333
326
  // Authorization precedes every response, including Next static assets and cached pages.
334
327
  response.setHeader('content-security-policy', `frame-ancestors ${session.bridge.parentOrigin}`);
335
328
  response.setHeader('referrer-policy', 'same-origin');
@@ -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
  }>;
@@ -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
- : value.sessionId === null && value.deploymentId !== null && value.frameworkBuildId !== null);
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 &&
@@ -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
+ }
@@ -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<{
@@ -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']),
@@ -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<{
@@ -11,3 +11,9 @@ export interface ArtifactSourceBoundaryOptions {
11
11
  * allowed because they are external to the artifact by design.
12
12
  */
13
13
  export declare function rejectOutsideSourcePlugin({ root, trustedExternalRoots, }: ArtifactSourceBoundaryOptions): Plugin;
14
+ /**
15
+ * Whether `filePath` is inside a `node_modules` directory. Bundlers pass
16
+ * Windows paths with forward slashes, so compare in that form whatever the
17
+ * platform separator is.
18
+ */
19
+ export declare function isNodeModulesPath(filePath: string): boolean;
@@ -46,8 +46,13 @@ function isPathWithin(root, candidate) {
46
46
  function isTrustedExternalPath(filePath, trustedRoots) {
47
47
  return trustedRoots.some((trustedRoot) => isPathWithin(trustedRoot, filePath));
48
48
  }
49
- function isNodeModulesPath(filePath) {
50
- return filePath.includes(`${sep}node_modules${sep}`);
49
+ /**
50
+ * Whether `filePath` is inside a `node_modules` directory. Bundlers pass
51
+ * Windows paths with forward slashes, so compare in that form whatever the
52
+ * platform separator is.
53
+ */
54
+ export function isNodeModulesPath(filePath) {
55
+ return filePath.replaceAll('\\', '/').includes('/node_modules/');
51
56
  }
52
57
  function isBareImport(source) {
53
58
  return !source.startsWith('.') && !isAbsolute(source);
@@ -5,7 +5,8 @@
5
5
  "scripts": {
6
6
  "dev": "vite",
7
7
  "build": "vite build",
8
- "preview": "vite preview"
8
+ "preview": "vite preview",
9
+ "type-check": "tsc --noEmit"
9
10
  },
10
11
  "dependencies": {},
11
12
  "devDependencies": {
@@ -14,6 +15,8 @@
14
15
  "@tailwindcss/postcss": "^4.2.2",
15
16
  "@tailwindcss/vite": "^4.2.2",
16
17
  "@tanstack/react-query": "^4.36.1",
18
+ "@types/react": "^18.3.31",
19
+ "@types/react-dom": "^18.3.7",
17
20
  "@vitejs/plugin-react": "^5.0.0",
18
21
  "react": "^18.2.0",
19
22
  "react-dom": "^18.2.0",
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "skipLibCheck": true,
6
+ "strict": true,
7
+ "noEmit": true,
8
+ "esModuleInterop": true,
9
+ "module": "esnext",
10
+ "moduleResolution": "bundler",
11
+ "resolveJsonModule": true,
12
+ "isolatedModules": true,
13
+ "jsx": "react-jsx",
14
+ "types": ["vite/client"]
15
+ },
16
+ "include": ["src", "vite.config.ts"]
17
+ }
package/package.json CHANGED
@@ -9,7 +9,7 @@
9
9
  "publishConfig": {
10
10
  "access": "public"
11
11
  },
12
- "version": "0.7.0",
12
+ "version": "0.7.1",
13
13
  "description": "SDK types and CLI library for building Artifact Studio apps. Driven via `seq-studio artifact <sub>` (@sequenceholdings/studio-cli), which runs the `./cli` runCli export.",
14
14
  "type": "module",
15
15
  "exports": {
@@ -159,6 +159,7 @@
159
159
  "sonner": "^2.0.7",
160
160
  "tailwindcss": "^4.1.8",
161
161
  "tw-animate-css": "^1.4.0",
162
+ "typescript": "^5.6.0",
162
163
  "vite": "8.0.16",
163
164
  "zod": "^4.1.13",
164
165
  "ws": "^8.21.0",
@@ -168,12 +169,11 @@
168
169
  "parse5": "7.3.0",
169
170
  "@sequenceholdings/lattice-form-renderer": "0.2.0",
170
171
  "@sequenceholdings/storage": "0.1.1",
171
- "@sequenceholdings/orm": "2.6.0"
172
+ "@sequenceholdings/orm": "2.6.1"
172
173
  },
173
174
  "devDependencies": {
174
175
  "@types/js-yaml": "^4.0.9",
175
176
  "@types/node": "^22.0.0",
176
- "typescript": "^5.6.0",
177
177
  "vitest": "^4.1.11",
178
178
  "@types/ws": "^8.18.1"
179
179
  },
@@ -5,7 +5,8 @@
5
5
  "scripts": {
6
6
  "dev": "vite",
7
7
  "build": "vite build",
8
- "preview": "vite preview"
8
+ "preview": "vite preview",
9
+ "type-check": "tsc --noEmit"
9
10
  },
10
11
  "dependencies": {},
11
12
  "devDependencies": {
@@ -14,6 +15,8 @@
14
15
  "@tailwindcss/postcss": "^4.2.2",
15
16
  "@tailwindcss/vite": "^4.2.2",
16
17
  "@tanstack/react-query": "^4.36.1",
18
+ "@types/react": "^18.3.31",
19
+ "@types/react-dom": "^18.3.7",
17
20
  "@vitejs/plugin-react": "^5.0.0",
18
21
  "react": "^18.2.0",
19
22
  "react-dom": "^18.2.0",
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "skipLibCheck": true,
6
+ "strict": true,
7
+ "noEmit": true,
8
+ "esModuleInterop": true,
9
+ "module": "esnext",
10
+ "moduleResolution": "bundler",
11
+ "resolveJsonModule": true,
12
+ "isolatedModules": true,
13
+ "jsx": "react-jsx",
14
+ "types": ["vite/client"]
15
+ },
16
+ "include": ["src", "vite.config.ts"]
17
+ }