@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.
Files changed (46) hide show
  1. package/README.md +26 -1
  2. package/dist/cli.js +4 -6
  3. package/dist/gateway/abort.d.ts +6 -0
  4. package/dist/gateway/abort.js +16 -0
  5. package/dist/gateway/authorization-cache.d.ts +24 -0
  6. package/dist/gateway/authorization-cache.js +21 -3
  7. package/dist/gateway/bootstrap-page.d.ts +16 -0
  8. package/dist/gateway/bootstrap-page.js +42 -0
  9. package/dist/gateway/control-config.d.ts +21 -0
  10. package/dist/gateway/control-config.js +18 -0
  11. package/dist/gateway/control.d.ts +61 -0
  12. package/dist/gateway/control.js +86 -7
  13. package/dist/gateway/hosted-proxy.d.ts +3 -0
  14. package/dist/gateway/hosted-proxy.js +12 -2
  15. package/dist/gateway/release-admission.d.ts +5 -0
  16. package/dist/gateway/release-admission.js +19 -0
  17. package/dist/gateway/server.d.ts +4 -1
  18. package/dist/gateway/server.js +35 -42
  19. package/dist/gateway/session.d.ts +9 -8
  20. package/dist/gateway/session.js +9 -11
  21. package/dist/gateway/static-files.d.ts +66 -0
  22. package/dist/gateway/static-files.js +112 -0
  23. package/dist/gateway/usage.d.ts +13 -0
  24. package/dist/gateway/usage.js +18 -0
  25. package/dist/gateway-main.js +8 -5
  26. package/dist/git-clone.d.ts +1 -3
  27. package/dist/git-clone.js +6 -8
  28. package/dist/manifest-next.d.ts +4 -0
  29. package/dist/manifest-static.d.ts +8 -0
  30. package/dist/manifest-static.js +5 -0
  31. package/dist/manifest.d.ts +8 -0
  32. package/dist/runtime-contracts.d.ts +8 -0
  33. package/dist/runtime-contracts.js +10 -0
  34. package/dist/sdk-orm.d.ts +3 -3
  35. package/dist/source-boundary.d.ts +6 -0
  36. package/dist/source-boundary.js +7 -2
  37. package/dist/source-resolver.js +7 -16
  38. package/dist/templates/nextjs/README.md +7 -0
  39. package/dist/templates/react-vite/package.json +5 -2
  40. package/dist/templates/react-vite/tsconfig.json +17 -0
  41. package/dist/trusted-install.d.ts +9 -2
  42. package/dist/trusted-install.js +9 -2
  43. package/package.json +4 -4
  44. package/templates/nextjs/README.md +7 -0
  45. package/templates/react-vite/package.json +5 -2
  46. package/templates/react-vite/tsconfig.json +17 -0
@@ -1,4 +1,4 @@
1
- import { createPrivateKey, randomBytes, randomUUID } from 'node:crypto';
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
- /** The bootstrap ticket travels in a fragment and a POST body, never a request URL or log. The page
77
- * exchanges it only inside the Atlas frame; browsers without Fetch Metadata are covered by the script check. */
78
- function bootstrapPage({ response, parentOrigin }) {
79
- const nonce = randomBytes(24).toString('base64');
80
- response.writeHead(200, {
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 target = safeMethod ? await authorizations.get(lookup) : await authorizations.refresh(lookup);
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
- const result = { session, target, requestId, 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({
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 { session, target, requestContext, requestId } = await authorize(request);
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
- const renewed = renewGatewaySession({ session, authorizedExpiresAt: target.expiresAt });
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 admitted = await options.admission.acquire({ environment: target.environment, projectId: target.projectId, deploymentId: target.deploymentId, resources: target.manifest?.server.resources });
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` everywhere Atlas and the runtime domain are one site, as every deployed environment is.
42
- * `None` is for the local stack only: Atlas on http://localhost is cross-site to
43
- * https://*.apps.localhost, so browsers drop a `Strict` cookie inside the preview iframe.
44
- * Partitioned keeps the relaxed cookie to Atlas's top-level site.
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
- /** Reads ARTIFACT_NEXT_SESSION_COOKIE_SAMESITE; only a development gateway may relax the cookie. */
48
- export declare function gatewaySessionSameSite({ value, appEnv }: {
49
- value: string | undefined;
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;
@@ -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 &&
@@ -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
- /** Reads ARTIFACT_NEXT_SESSION_COOKIE_SAMESITE; only a development gateway may relax the cookie. */
43
- export function gatewaySessionSameSite({ value, appEnv }) {
44
- if (value === undefined || value === '' || value === 'strict')
45
- return 'Strict';
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
+ }
@@ -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';
@@ -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
  /**
@@ -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 controlUrl = required('ARTIFACT_NEXT_CONTROL_PLANE_URL');
63
+ const controlConfig = resolveGatewayControlConfig({ env: process.env, identityToken: hostedIdentityToken });
62
64
  const controlToken = required('ARTIFACT_NEXT_CONTROL_TOKEN');
63
- const control = new ArtifactGatewayControl({ url: controlUrl, token: controlToken });
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: required('ARTIFACT_NEXT_RUNTIME_DOMAIN'),
79
- parentOrigin: process.env.ARTIFACT_NEXT_PARENT_ORIGIN ?? new URL(controlUrl).origin,
81
+ runtimeDomain,
82
+ parentOrigin: controlConfig.parentOrigin,
80
83
  gatewayOrigin: required('ARTIFACT_NEXT_GATEWAY_URL'),
81
84
  sessionKey,
82
- sessionCookieSameSite: gatewaySessionSameSite({ value: process.env.ARTIFACT_NEXT_SESSION_COOKIE_SAMESITE, appEnv: namespace }),
85
+ sessionCookieSameSite: gatewaySessionSameSite({ parentOrigin: controlConfig.parentOrigin, runtimeDomain }),
83
86
  contextPrivateKey,
84
87
  contextKeyId,
85
88
  nodeId, routing: ownership, relay,
@@ -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
- /** PAT used as Basic-auth password (username `git`). */
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 PAT
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 PAT separately.
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: '1',
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
  }
@@ -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<{