@sequenceholdings/artifact-studio 0.7.0 → 0.8.0

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/dist/build.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from 'node:fs';
2
- import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { mkdir, realpath, writeFile } from 'node:fs/promises';
3
3
  import { createRequire } from 'node:module';
4
4
  import { dirname, relative, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
@@ -122,7 +122,9 @@ export async function buildArtifactStudioProject(rootDir, { candidateTokenCompat
122
122
  ? collectCandidateTokenCompatibilityDiagnostics({ files: source.files })
123
123
  : []),
124
124
  ];
125
- const root = source.root;
125
+ // Vite resolves imports to physical paths, including macOS /tmp -> /private/tmp.
126
+ // Give Vite and the source boundary the same physical root.
127
+ const root = await realpath(source.root);
126
128
  const transformedCssModules = new Map();
127
129
  const result = await viteBuild({
128
130
  root,
@@ -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
+ }
@@ -28,6 +28,9 @@ export declare const artifactNextServerCapabilitiesSchema: z.ZodPrefault<z.ZodOb
28
28
  audit: z.ZodOptional<z.ZodObject<{
29
29
  service: z.ZodString;
30
30
  }, z.core.$strict>>;
31
+ groups: z.ZodOptional<z.ZodObject<{
32
+ read: z.ZodBoolean;
33
+ }, z.core.$strict>>;
31
34
  storage: z.ZodOptional<z.ZodRecord<z.ZodUUID, z.ZodEnum<{
32
35
  viewer: "viewer";
33
36
  editor: "editor";
@@ -89,6 +92,9 @@ export declare const artifactNextServerSchema: z.ZodPrefault<z.ZodObject<{
89
92
  audit: z.ZodOptional<z.ZodObject<{
90
93
  service: z.ZodString;
91
94
  }, z.core.$strict>>;
95
+ groups: z.ZodOptional<z.ZodObject<{
96
+ read: z.ZodBoolean;
97
+ }, z.core.$strict>>;
92
98
  storage: z.ZodOptional<z.ZodRecord<z.ZodUUID, z.ZodEnum<{
93
99
  viewer: "viewer";
94
100
  editor: "editor";
@@ -280,6 +286,9 @@ export declare const artifactStudioNextManifestSchema: z.ZodObject<{
280
286
  audit: z.ZodOptional<z.ZodObject<{
281
287
  service: z.ZodString;
282
288
  }, z.core.$strict>>;
289
+ groups: z.ZodOptional<z.ZodObject<{
290
+ read: z.ZodBoolean;
291
+ }, z.core.$strict>>;
283
292
  storage: z.ZodOptional<z.ZodRecord<z.ZodUUID, z.ZodEnum<{
284
293
  viewer: "viewer";
285
294
  editor: "editor";
@@ -401,6 +410,10 @@ export declare const artifactStudioNextManifestSchema: z.ZodObject<{
401
410
  mobile: z.ZodOptional<z.ZodEnum<{
402
411
  responsive: "responsive";
403
412
  }>>;
413
+ status: z.ZodOptional<z.ZodEnum<{
414
+ alpha: "alpha";
415
+ beta: "beta";
416
+ }>>;
404
417
  }, z.core.$strip>>;
405
418
  sidebarIcon: z.ZodOptional<z.ZodString>;
406
419
  sidebarBadge: z.ZodOptional<z.ZodObject<{
@@ -15,9 +15,14 @@ export const ARTIFACT_NEXT_RESOURCE_DEFAULTS = {
15
15
  };
16
16
  export { artifactNextAuditCapabilitySchema };
17
17
  export * from './manifest-next-ingress.js';
18
+ /** Caller-scoped group membership. A boolean, not a list of group ids: the directory decides which groups the signed-in user is in. */
19
+ const artifactNextGroupsCapabilitySchema = z.strictObject({
20
+ read: z.boolean(),
21
+ });
18
22
  export const artifactNextServerCapabilitiesSchema = z.strictObject({
19
23
  ...reachShape,
20
24
  audit: artifactNextAuditCapabilitySchema.optional(),
25
+ groups: artifactNextGroupsCapabilitySchema.optional(),
21
26
  }).prefault({});
22
27
  // Reuse role semantics and validation; only the new manifest rejects unknown keys.
23
28
  const strictCustomRoles = customRolesBlockSchema.safeExtend({
@@ -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<{
@@ -163,6 +167,9 @@ export declare const artifactStudioManifestSchema: z.ZodUnion<readonly [z.ZodObj
163
167
  audit: z.ZodOptional<z.ZodObject<{
164
168
  service: z.ZodString;
165
169
  }, z.core.$strict>>;
170
+ groups: z.ZodOptional<z.ZodObject<{
171
+ read: z.ZodBoolean;
172
+ }, z.core.$strict>>;
166
173
  storage: z.ZodOptional<z.ZodRecord<z.ZodUUID, z.ZodEnum<{
167
174
  viewer: "viewer";
168
175
  editor: "editor";
@@ -284,6 +291,10 @@ export declare const artifactStudioManifestSchema: z.ZodUnion<readonly [z.ZodObj
284
291
  mobile: z.ZodOptional<z.ZodEnum<{
285
292
  responsive: "responsive";
286
293
  }>>;
294
+ status: z.ZodOptional<z.ZodEnum<{
295
+ alpha: "alpha";
296
+ beta: "beta";
297
+ }>>;
287
298
  }, z.core.$strip>>;
288
299
  sidebarIcon: z.ZodOptional<z.ZodString>;
289
300
  sidebarBadge: z.ZodOptional<z.ZodObject<{
@@ -18,6 +18,10 @@ export declare function isArtifactApiPath(path: string): boolean;
18
18
  export declare const ARTIFACT_PROCESS_RUN_STATUSES: readonly ["pending", "running", "completed", "failed", "cancelled", "timed_out"];
19
19
  /** Lattice lists at most this many runs per call. */
20
20
  export declare const ARTIFACT_PROCESS_RUNS_LIMIT = 500;
21
+ /** One page of the caller's own group memberships. */
22
+ export declare const ARTIFACT_GROUP_MEMBERSHIPS_LIMIT = 100;
23
+ /** How far a caller may page their own memberships. The directory is not walked past this. */
24
+ export declare const ARTIFACT_GROUP_MEMBERSHIPS_MAX_PAGE = 100;
21
25
  /** A run page can filter to at most this many entities of one type. */
22
26
  export declare const ARTIFACT_PROCESS_RUN_ENTITIES_LIMIT = 500;
23
27
  /** A run share adds collaborators to at most this many explicitly assigned human nodes. */
@@ -298,6 +302,14 @@ export declare const artifactServerRequestSchema: z.ZodDiscriminatedUnion<[z.Zod
298
302
  group: "group";
299
303
  }>>>;
300
304
  limit: z.ZodOptional<z.ZodNumber>;
305
+ }, z.core.$strict>, z.ZodObject<{
306
+ operation: z.ZodLiteral<"groups_memberships">;
307
+ mode: z.ZodDefault<z.ZodEnum<{
308
+ service: "service";
309
+ user: "user";
310
+ }>>;
311
+ page: z.ZodOptional<z.ZodNumber>;
312
+ limit: z.ZodOptional<z.ZodNumber>;
301
313
  }, z.core.$strict>, z.ZodObject<{
302
314
  operation: z.ZodLiteral<"flags">;
303
315
  mode: z.ZodDefault<z.ZodEnum<{
@@ -31,6 +31,10 @@ const latticeVersion = z.string().min(1).max(128).regex(/^[A-Za-z0-9._-]+$/);
31
31
  export const ARTIFACT_PROCESS_RUN_STATUSES = ['pending', 'running', 'completed', 'failed', 'cancelled', 'timed_out'];
32
32
  /** Lattice lists at most this many runs per call. */
33
33
  export const ARTIFACT_PROCESS_RUNS_LIMIT = 500;
34
+ /** One page of the caller's own group memberships. */
35
+ export const ARTIFACT_GROUP_MEMBERSHIPS_LIMIT = 100;
36
+ /** How far a caller may page their own memberships. The directory is not walked past this. */
37
+ export const ARTIFACT_GROUP_MEMBERSHIPS_MAX_PAGE = 100;
34
38
  /** A run page can filter to at most this many entities of one type. */
35
39
  export const ARTIFACT_PROCESS_RUN_ENTITIES_LIMIT = 500;
36
40
  /** A run share adds collaborators to at most this many explicitly assigned human nodes. */
@@ -225,6 +229,13 @@ export const artifactServerRequestSchema = z.discriminatedUnion('operation', [
225
229
  types: z.array(z.enum(ARTIFACT_PROCESS_PRINCIPAL_TYPES)).min(1).max(2).optional(),
226
230
  limit: z.number().int().min(1).max(ARTIFACT_PROCESS_PRINCIPAL_SEARCH_LIMIT).optional(),
227
231
  }).strict(),
232
+ // The signed-in user's own group memberships. No search, no admin directory, no group-id allowlist:
233
+ // the directory returns the groups this user is in. Service mode is refused by the handler.
234
+ z.object({
235
+ operation: z.literal('groups_memberships'), mode,
236
+ page: z.number().int().min(1).max(ARTIFACT_GROUP_MEMBERSHIPS_MAX_PAGE).optional(),
237
+ limit: z.number().int().min(1).max(ARTIFACT_GROUP_MEMBERSHIPS_LIMIT).optional(),
238
+ }).strict(),
228
239
  // Evaluates feature flags as Atlas evaluates its own: for the user, or with no user in service mode.
229
240
  // A read: it needs no manifest declaration, as the browser bridge's flags.isEnabled needs none.
230
241
  z.object({ operation: z.literal('flags'), mode, names: z.array(flagName).min(1).max(ARTIFACT_FLAGS_PER_REQUEST) }).strict(),
@@ -1,6 +1,7 @@
1
1
  import { storageClient } from '@sequenceholdings/storage';
2
2
  import { type OperationExecutor } from '@sequenceholdings/orm/client';
3
3
  import { type ArtifactAuditAction, type ArtifactServerMode } from './broker-contract.js';
4
+ import { type ArtifactServerGroups } from './groups.js';
4
5
  import { type ArtifactServerProcesses } from './processes.js';
5
6
  import { type ArtifactOrmJsonScalars } from '../orm-json-scalars.js';
6
7
  import { createArtifactLogger, type ArtifactLogRecord } from './logger.js';
@@ -10,6 +11,7 @@ import { createApiClient } from './api-client.js';
10
11
  import { type ArtifactPrincipal } from './principal.js';
11
12
  import { type ArtifactRuntimeConfig, type ArtifactRequestHeaders } from './runtime-config.js';
12
13
  export { ArtifactPrincipalError, ArtifactServerError, INSUFFICIENT_USER_AUTHENTICATION, stepUpResponse } from './errors.js';
14
+ export type * from './groups.js';
13
15
  export type * from './processes.js';
14
16
  export type { ArtifactServerFetchOptions } from './api-client.js';
15
17
  /**
@@ -82,6 +84,7 @@ export interface ArtifactServerClient<TContext extends ArtifactServerContext = A
82
84
  invoke<TOutput = unknown>(id: string, params?: Record<string, unknown>): Promise<TOutput>;
83
85
  };
84
86
  processes: ArtifactServerProcesses;
87
+ groups: ArtifactServerGroups;
85
88
  flags: ArtifactServerFlags;
86
89
  audit: ArtifactServerAudit;
87
90
  orm(input: ArtifactOrmOptions): OperationExecutor;
@@ -3,6 +3,7 @@ import { StorageApiError, storageClient, storageErrorSchema } from '@sequencehol
3
3
  import { OrmOperationError } from '@sequenceholdings/orm/client';
4
4
  import { z } from 'zod';
5
5
  import { artifactOrmIdempotencyKeySchema, artifactServerRequestSchema } from './broker-contract.js';
6
+ import { createArtifactGroupsClient } from './groups.js';
6
7
  import { createArtifactProcessesClient } from './processes.js';
7
8
  import { assertJsonScalarsAcknowledged, ORM_JSON_SCALARS_HEADER } from '../orm-json-scalars.js';
8
9
  import { createArtifactLogger } from './logger.js';
@@ -96,6 +97,7 @@ function buildClient(args) {
96
97
  },
97
98
  },
98
99
  processes: createArtifactProcessesClient({ mode, json }),
100
+ groups: createArtifactGroupsClient({ mode, json }),
99
101
  flags: {
100
102
  evaluate: evaluateFlags,
101
103
  isEnabled: async (flagName) => (await evaluateFlags([flagName]))[flagName] === true,
@@ -0,0 +1,32 @@
1
+ import { type ArtifactServerMode, type ArtifactServerRequest } from './broker-contract.js';
2
+ /** One group the signed-in user belongs to. `userRole` is `admin` when they administer it. */
3
+ export interface ArtifactGroupMembership {
4
+ id: string;
5
+ userRole: 'admin' | 'member';
6
+ memberCount: number;
7
+ }
8
+ export interface ArtifactGroupMembershipsQuery {
9
+ page?: number;
10
+ limit?: number;
11
+ }
12
+ export interface ArtifactGroupMembershipsPage {
13
+ groups: ArtifactGroupMembership[];
14
+ pagination: {
15
+ page: number;
16
+ limit: number;
17
+ totalCount: number;
18
+ totalPages: number;
19
+ };
20
+ }
21
+ export interface ArtifactServerGroups {
22
+ /**
23
+ * The signed-in user's group memberships. Requires `server.capabilities.groups.read`.
24
+ * Atlas decides which groups they are in; this call cannot name groups, search the directory,
25
+ * or read another user's memberships.
26
+ */
27
+ list(query?: ArtifactGroupMembershipsQuery): Promise<ArtifactGroupMembershipsPage>;
28
+ }
29
+ export declare function createArtifactGroupsClient({ mode, json }: {
30
+ mode: ArtifactServerMode;
31
+ json: (operation: ArtifactServerRequest) => Promise<unknown>;
32
+ }): ArtifactServerGroups;
@@ -0,0 +1,28 @@
1
+ import { z } from 'zod';
2
+ import { ARTIFACT_GROUP_MEMBERSHIPS_LIMIT, artifactServerRequestSchema, } from './broker-contract.js';
3
+ import { ArtifactServerError } from './errors.js';
4
+ const membershipsPageSchema = z.object({
5
+ groups: z.array(z.object({
6
+ id: z.string().min(1).max(255),
7
+ userRole: z.enum(['admin', 'member']),
8
+ memberCount: z.number().int().nonnegative(),
9
+ }).strict()),
10
+ pagination: z.object({
11
+ page: z.number().int().min(1),
12
+ limit: z.number().int().min(1).max(ARTIFACT_GROUP_MEMBERSHIPS_LIMIT),
13
+ totalCount: z.number().int().nonnegative(),
14
+ totalPages: z.number().int().nonnegative(),
15
+ }).strict(),
16
+ }).strict();
17
+ export function createArtifactGroupsClient({ mode, json }) {
18
+ return {
19
+ async list(query = {}) {
20
+ const parsed = membershipsPageSchema.safeParse(await json(artifactServerRequestSchema.parse({
21
+ operation: 'groups_memberships', mode, ...query,
22
+ })));
23
+ if (!parsed.success)
24
+ throw new ArtifactServerError(502, 'invalid_response', 'Atlas returned a malformed groups response');
25
+ return parsed.data;
26
+ },
27
+ };
28
+ }
package/dist/server.d.ts CHANGED
@@ -2,8 +2,8 @@ import 'server-only';
2
2
  export { createArtifactServerClient } from './server/client.js';
3
3
  export { ArtifactPrincipalError, ArtifactServerError, INSUFFICIENT_USER_AUTHENTICATION, stepUpResponse } from './server/errors.js';
4
4
  export type { ArtifactAtlasRefusal } from './server/errors.js';
5
- export type { ArtifactAuditEvent, ArtifactOrmOperations, ArtifactOrmOptions, ArtifactProcessAdvance, ArtifactProcessAdvanceResult, ArtifactProcessArtifactRef, ArtifactProcessAssign, ArtifactProcessAssignResult, ArtifactProcessDefinition, ArtifactProcessDescribeQuery, ArtifactProcessEdge, ArtifactProcessHumanNode, ArtifactProcessNode, ArtifactProcessNodeBase, ArtifactProcessNodeState, ArtifactProcessPrincipal, ArtifactProcessPrincipalSearch, ArtifactProcessPrincipals, ArtifactProcessRun, ArtifactProcessRunRecord, ArtifactProcessRunSnapshot, ArtifactProcessRunSummary, ArtifactProcessRunsPage, ArtifactProcessRunsPageQuery, ArtifactProcessRunsQuery, ArtifactProcessRunViewerChange, ArtifactProcessRunViewers, ArtifactProcessStart, ArtifactProcessStepRecord, ArtifactProcessShareRun, ArtifactProcessShareRunResult, ArtifactProcessRunSharingAuthoritiesQuery, ArtifactProcessRunSharingAuthoritiesPage, ArtifactProcessRunSharingAuthority, ArtifactProcessRunSharingDelegate, ArtifactProcessRevokeRunSharingAuthority, ArtifactProcessRevokeRunSharingAuthorityResult, ArtifactProcessTask, ArtifactProcessTasksQuery, ArtifactServerAudit, ArtifactServerClient, ArtifactServerClientOptions, ArtifactServerContext, ArtifactServerFetchOptions, ArtifactServerFlags, ArtifactServerProcesses, } from './server/client.js';
6
- export { ARTIFACT_AUDIT_ACTIONS, ARTIFACT_AUDIT_RESOURCES_PER_EVENT, ARTIFACT_FLAGS_PER_REQUEST, ARTIFACT_PROCESS_PRINCIPAL_SEARCH_LIMIT, ARTIFACT_PROCESS_PRINCIPAL_TYPES, ARTIFACT_PROCESS_PRINCIPALS_PER_CALL, ARTIFACT_PROCESS_RUN_STATUSES, ARTIFACT_PROCESS_RUNS_LIMIT, ARTIFACT_PROCESS_RUN_ENTITIES_LIMIT, ARTIFACT_PROCESS_SHARE_NODES_LIMIT, ARTIFACT_PROCESS_SHARING_AUTHORITIES_LIMIT, ARTIFACT_PROCESS_TASK_VIEWS, ARTIFACT_PROCESS_TASKS_LIMIT, } from './server/broker-contract.js';
5
+ export type { ArtifactAuditEvent, ArtifactOrmOperations, ArtifactOrmOptions, ArtifactProcessAdvance, ArtifactProcessAdvanceResult, ArtifactProcessArtifactRef, ArtifactProcessAssign, ArtifactProcessAssignResult, ArtifactProcessDefinition, ArtifactProcessDescribeQuery, ArtifactProcessEdge, ArtifactProcessHumanNode, ArtifactProcessNode, ArtifactProcessNodeBase, ArtifactProcessNodeState, ArtifactProcessPrincipal, ArtifactProcessPrincipalSearch, ArtifactProcessPrincipals, ArtifactProcessRun, ArtifactProcessRunRecord, ArtifactProcessRunSnapshot, ArtifactProcessRunSummary, ArtifactProcessRunsPage, ArtifactProcessRunsPageQuery, ArtifactProcessRunsQuery, ArtifactProcessRunViewerChange, ArtifactProcessRunViewers, ArtifactProcessStart, ArtifactProcessStepRecord, ArtifactProcessShareRun, ArtifactProcessShareRunResult, ArtifactProcessRunSharingAuthoritiesQuery, ArtifactProcessRunSharingAuthoritiesPage, ArtifactProcessRunSharingAuthority, ArtifactProcessRunSharingDelegate, ArtifactProcessRevokeRunSharingAuthority, ArtifactProcessRevokeRunSharingAuthorityResult, ArtifactProcessTask, ArtifactProcessTasksQuery, ArtifactServerAudit, ArtifactServerClient, ArtifactServerClientOptions, ArtifactServerContext, ArtifactServerFetchOptions, ArtifactServerFlags, ArtifactServerGroups, ArtifactServerProcesses, ArtifactGroupMembership, ArtifactGroupMembershipsPage, ArtifactGroupMembershipsQuery, } from './server/client.js';
6
+ export { ARTIFACT_AUDIT_ACTIONS, ARTIFACT_AUDIT_RESOURCES_PER_EVENT, ARTIFACT_FLAGS_PER_REQUEST, ARTIFACT_GROUP_MEMBERSHIPS_LIMIT, ARTIFACT_GROUP_MEMBERSHIPS_MAX_PAGE, ARTIFACT_PROCESS_PRINCIPAL_SEARCH_LIMIT, ARTIFACT_PROCESS_PRINCIPAL_TYPES, ARTIFACT_PROCESS_PRINCIPALS_PER_CALL, ARTIFACT_PROCESS_RUN_STATUSES, ARTIFACT_PROCESS_RUNS_LIMIT, ARTIFACT_PROCESS_RUN_ENTITIES_LIMIT, ARTIFACT_PROCESS_SHARE_NODES_LIMIT, ARTIFACT_PROCESS_SHARING_AUTHORITIES_LIMIT, ARTIFACT_PROCESS_TASK_VIEWS, ARTIFACT_PROCESS_TASKS_LIMIT, } from './server/broker-contract.js';
7
7
  export type { ArtifactAuditAction, ArtifactProcessPrincipalType, ArtifactProcessRunStatus, ArtifactProcessTaskView, } from './server/broker-contract.js';
8
8
  export { getArtifactRequestContext, readArtifactRuntimeConfig } from './server/runtime-config.js';
9
9
  export type { ArtifactRuntimeConfig, ArtifactRequestHeaders } from './server/runtime-config.js';
package/dist/server.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import 'server-only';
2
2
  export { createArtifactServerClient } from './server/client.js';
3
3
  export { ArtifactPrincipalError, ArtifactServerError, INSUFFICIENT_USER_AUTHENTICATION, stepUpResponse } from './server/errors.js';
4
- export { ARTIFACT_AUDIT_ACTIONS, ARTIFACT_AUDIT_RESOURCES_PER_EVENT, ARTIFACT_FLAGS_PER_REQUEST, ARTIFACT_PROCESS_PRINCIPAL_SEARCH_LIMIT, ARTIFACT_PROCESS_PRINCIPAL_TYPES, ARTIFACT_PROCESS_PRINCIPALS_PER_CALL, ARTIFACT_PROCESS_RUN_STATUSES, ARTIFACT_PROCESS_RUNS_LIMIT, ARTIFACT_PROCESS_RUN_ENTITIES_LIMIT, ARTIFACT_PROCESS_SHARE_NODES_LIMIT, ARTIFACT_PROCESS_SHARING_AUTHORITIES_LIMIT, ARTIFACT_PROCESS_TASK_VIEWS, ARTIFACT_PROCESS_TASKS_LIMIT, } from './server/broker-contract.js';
4
+ export { ARTIFACT_AUDIT_ACTIONS, ARTIFACT_AUDIT_RESOURCES_PER_EVENT, ARTIFACT_FLAGS_PER_REQUEST, ARTIFACT_GROUP_MEMBERSHIPS_LIMIT, ARTIFACT_GROUP_MEMBERSHIPS_MAX_PAGE, ARTIFACT_PROCESS_PRINCIPAL_SEARCH_LIMIT, ARTIFACT_PROCESS_PRINCIPAL_TYPES, ARTIFACT_PROCESS_PRINCIPALS_PER_CALL, ARTIFACT_PROCESS_RUN_STATUSES, ARTIFACT_PROCESS_RUNS_LIMIT, ARTIFACT_PROCESS_RUN_ENTITIES_LIMIT, ARTIFACT_PROCESS_SHARE_NODES_LIMIT, ARTIFACT_PROCESS_SHARING_AUTHORITIES_LIMIT, ARTIFACT_PROCESS_TASK_VIEWS, ARTIFACT_PROCESS_TASKS_LIMIT, } from './server/broker-contract.js';
5
5
  export { getArtifactRequestContext, readArtifactRuntimeConfig } from './server/runtime-config.js';
6
6
  export { ArtifactContextError, getArtifactBrowserBootstrap } from './server/request-context.js';
7
7
  export { verifyArtifactExternalContext } from './server/external-context.js';
@@ -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;
@@ -1,3 +1,4 @@
1
+ import { realpathSync } from 'node:fs';
1
2
  import { isAbsolute, relative, resolve, sep } from 'node:path';
2
3
  /**
3
4
  * Artifact source must be self-contained: allowing a relative import to escape
@@ -7,8 +8,8 @@ import { isAbsolute, relative, resolve, sep } from 'node:path';
7
8
  * allowed because they are external to the artifact by design.
8
9
  */
9
10
  export function rejectOutsideSourcePlugin({ root, trustedExternalRoots, }) {
10
- const rootResolved = resolve(root);
11
- const trustedRoots = trustedExternalRoots.map((trustedRoot) => resolve(trustedRoot));
11
+ const rootResolved = realpathSync(resolve(root));
12
+ const trustedRoots = trustedExternalRoots.map((trustedRoot) => realpathSync(resolve(trustedRoot)));
12
13
  return {
13
14
  name: 'reject-artifact-outside-source',
14
15
  enforce: 'pre',
@@ -17,15 +18,22 @@ export function rejectOutsideSourcePlugin({ root, trustedExternalRoots, }) {
17
18
  if (!resolved || resolved.external || resolved.id.startsWith('\0'))
18
19
  return resolved;
19
20
  const filePath = resolved.id.split('?')[0];
20
- if (!isAbsolute(filePath) || isPathWithin(rootResolved, filePath))
21
+ if (!isAbsolute(filePath))
22
+ return resolved;
23
+ // Vite may return a physical path for a root reached through /tmp or
24
+ // another symlink. Check physical paths on both sides of the boundary;
25
+ // this also prevents an in-root symlink from admitting an outside file.
26
+ const realFilePath = realpathSync(filePath);
27
+ if (isPathWithin(rootResolved, realFilePath))
21
28
  return resolved;
22
29
  const importerPath = importer?.split('?')[0];
23
30
  const importerIsTrustedExternal = importerPath != null &&
24
- (isNodeModulesPath(importerPath) || isTrustedExternalPath(importerPath, trustedRoots));
31
+ (isNodeModulesPath(importerPath) ||
32
+ (isAbsolute(importerPath) && isTrustedExternalPath(realpathSync(importerPath), trustedRoots)));
25
33
  if (isNodeModulesPath(filePath) && (isBareImport(source) || importerIsTrustedExternal)) {
26
34
  return resolved;
27
35
  }
28
- if (isTrustedExternalPath(filePath, trustedRoots))
36
+ if (isTrustedExternalPath(realFilePath, trustedRoots))
29
37
  return resolved;
30
38
  const from = importer
31
39
  ? ` (imported from ${relative(rootResolved, importer.split('?')[0])})`
@@ -46,8 +54,13 @@ function isPathWithin(root, candidate) {
46
54
  function isTrustedExternalPath(filePath, trustedRoots) {
47
55
  return trustedRoots.some((trustedRoot) => isPathWithin(trustedRoot, filePath));
48
56
  }
49
- function isNodeModulesPath(filePath) {
50
- return filePath.includes(`${sep}node_modules${sep}`);
57
+ /**
58
+ * Whether `filePath` is inside a `node_modules` directory. Bundlers pass
59
+ * Windows paths with forward slashes, so compare in that form whatever the
60
+ * platform separator is.
61
+ */
62
+ export function isNodeModulesPath(filePath) {
63
+ return filePath.replaceAll('\\', '/').includes('/node_modules/');
51
64
  }
52
65
  function isBareImport(source) {
53
66
  return !source.startsWith('.') && !isAbsolute(source);
@@ -5,15 +5,18 @@
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": {
12
- "@sequenceholdings/artifact-studio": "^0.7.0",
13
+ "@sequenceholdings/artifact-studio": "^0.8.0",
13
14
  "@sequenceholdings/atlas-ui": "^3.1.1",
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.8.0",
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",
@@ -166,14 +167,13 @@
166
167
  "google-auth-library": "^10.5.0",
167
168
  "server-only": "0.0.1",
168
169
  "parse5": "7.3.0",
169
- "@sequenceholdings/lattice-form-renderer": "0.2.0",
170
170
  "@sequenceholdings/storage": "0.1.1",
171
- "@sequenceholdings/orm": "2.6.0"
171
+ "@sequenceholdings/orm": "2.6.1",
172
+ "@sequenceholdings/lattice-form-renderer": "0.2.0"
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,15 +5,18 @@
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": {
12
- "@sequenceholdings/artifact-studio": "^0.7.0",
13
+ "@sequenceholdings/artifact-studio": "^0.8.0",
13
14
  "@sequenceholdings/atlas-ui": "^3.1.1",
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
+ }