@sequenceholdings/artifact-studio 0.5.0 → 0.6.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 (40) hide show
  1. package/VERSION-PIN.md +20 -0
  2. package/dist/cli.js +1 -1
  3. package/dist/offline/connector.d.ts +2 -0
  4. package/dist/offline/connector.js +6 -2
  5. package/dist/offline/management-types.d.ts +25 -0
  6. package/dist/offline/management-types.js +1 -0
  7. package/dist/runtime-contracts.js +1 -1
  8. package/dist/sdk.d.ts +8 -1
  9. package/dist/server/api-client.d.ts +26 -0
  10. package/dist/server/api-client.js +38 -0
  11. package/dist/server/client.d.ts +32 -45
  12. package/dist/server/client.js +41 -85
  13. package/dist/server/connection-retry.d.ts +12 -0
  14. package/dist/server/connection-retry.js +40 -0
  15. package/dist/server/errors.d.ts +37 -2
  16. package/dist/server/errors.js +66 -6
  17. package/dist/server/external-context.d.ts +21 -0
  18. package/dist/server/external-context.js +19 -0
  19. package/dist/server/internal/signed-token.d.ts +15 -0
  20. package/dist/server/internal/signed-token.js +67 -0
  21. package/dist/server/internal/v1-context.d.ts +18 -0
  22. package/dist/server/internal/v1-context.js +26 -0
  23. package/dist/server/internal/verify-any.d.ts +22 -0
  24. package/dist/server/internal/verify-any.js +53 -0
  25. package/dist/server/logger.d.ts +1 -1
  26. package/dist/server/principal.d.ts +48 -0
  27. package/dist/server/principal.js +39 -0
  28. package/dist/server/request-context.d.ts +115 -3
  29. package/dist/server/request-context.js +101 -32
  30. package/dist/server/testing.d.ts +40 -0
  31. package/dist/server/testing.js +86 -0
  32. package/dist/server.d.ts +9 -4
  33. package/dist/server.js +5 -2
  34. package/dist/templates/nextjs/package.json +3 -1
  35. package/dist/templates/nextjs/tests/server.test.mjs +25 -0
  36. package/dist/templates/react-vite/package.json +1 -1
  37. package/package.json +11 -3
  38. package/templates/nextjs/package.json +3 -1
  39. package/templates/nextjs/tests/server.test.mjs +25 -0
  40. package/templates/react-vite/package.json +1 -1
package/VERSION-PIN.md CHANGED
@@ -21,6 +21,26 @@ Artifact Studio 0.3.1 (Studio CLI 0.2.2) verifies the per-request `jti` that the
21
21
  gateway now signs into every request context. The 0.3.0 context schema predates
22
22
  that claim and rejects those contexts, so Next.js applications need 0.3.1 or newer.
23
23
 
24
+ Artifact Studio 0.6.0 (Studio CLI 0.4.2) adds external request contexts (v2)
25
+ for callers that are not Atlas users, admitted through a manifest-declared
26
+ ingress. 0.5.0 was never published; 0.6.0 also ships its server `flags`,
27
+ `audit` and `jsonScalars` surface and the Lattice `processes.read` reads
28
+ (`listRuns`, `getRun`, `describe`, `listTasks`) and `processes.act` writes
29
+ (`advance`, `assign`, run viewers, `searchPrincipals`). The public verifiers
30
+ (`verifyArtifactRequestContext`, `getArtifactRequestContext`) stay v1-only and
31
+ refuse a v2 context, or any token signed with an `external-` kid, with reason
32
+ `external_context_not_accepted`. A handler accepts external callers only
33
+ through `verifyArtifactExternalContext` or `createArtifactServerClient({ accept })`,
34
+ and an external principal reaches platform services only through `asService()`.
35
+ `@sequenceholdings/artifact-studio/server/testing` signs test contexts.
36
+ 0.6.0 also carries `error.atlas` (`{ code, detail }`) on a refused broker call
37
+ (#13398); a step-up 401 still surfaces as `insufficient_user_authentication`.
38
+
39
+ Context version policy: a context verifier rejects any unknown top-level or
40
+ `principal` key unless it sits under `ext` or is named in `crit` (and `crit`
41
+ names only extensions the verifier understands; today none, so it is empty).
42
+ Security-relevant additions bump `v`; they are never added as optional keys.
43
+
24
44
  The Artifact mobile-pattern source adds new atlas-ui exports. Roll that surface
25
45
  out in dependency order: publish atlas-ui from its changeset, update the exact
26
46
  `@sequenceholdings/atlas-ui` dependency in `artifact-studio/package.json`,
package/dist/cli.js CHANGED
@@ -197,7 +197,7 @@ async function initCommand(args) {
197
197
  console.log(`[seq-studio] initialized ${dir}`);
198
198
  console.log('AGENTS.md points to your environment\'s live docs; CLAUDE.md imports it.');
199
199
  console.log(template === 'nextjs'
200
- ? 'Next: cd into the app, run pnpm install, then seq-studio login && seq-studio artifact dev --env <env>. Commit pnpm-lock.yaml before deploying. No cloud credentials are required.'
200
+ ? 'Next: use Node.js 24, cd into the app and run pnpm install, then pnpm run lint && pnpm run type-check && pnpm test. Commit pnpm-lock.yaml before deploying. seq-studio login && seq-studio artifact dev --env <env> starts development; repository CI and hosted builds use Buildkite. No cloud credentials are required.'
201
201
  : 'Next: seq-studio login && seq-studio artifact dev --env <env> (see: seq-studio envs list)');
202
202
  return 0;
203
203
  }
@@ -2,9 +2,11 @@ import type { SequenceOfflineRequest, SequenceOfflineRequestConnector } from '..
2
2
  import type { OfflineReplayEngine } from './replay.js';
3
3
  import type { OfflineCache, OfflineMutationPolicy, OfflineReadPolicy } from './types.js';
4
4
  export declare class OfflineDataUnavailableError extends Error {
5
+ readonly path: string;
5
6
  constructor(path: string);
6
7
  }
7
8
  export declare class OfflineWriteBlockedError extends Error {
9
+ readonly path: string;
8
10
  constructor(path: string);
9
11
  }
10
12
  export interface ArtifactOfflineConnector {
@@ -1,13 +1,17 @@
1
1
  export class OfflineDataUnavailableError extends Error {
2
+ path;
2
3
  constructor(path) {
3
- super(`No current offline data is available for ${path}`);
4
+ super('No offline data available');
4
5
  this.name = 'OfflineDataUnavailableError';
6
+ this.path = path;
5
7
  }
6
8
  }
7
9
  export class OfflineWriteBlockedError extends Error {
10
+ path;
8
11
  constructor(path) {
9
- super(`Reconnect to complete this action: ${path}`);
12
+ super('Reconnect to complete this action');
10
13
  this.name = 'OfflineWriteBlockedError';
14
+ this.path = path;
11
15
  }
12
16
  }
13
17
  function canUseCachedFallback(response) {
@@ -0,0 +1,25 @@
1
+ export interface SequenceOfflineManagementStatus {
2
+ supported: boolean;
3
+ enrolled: boolean;
4
+ online: boolean;
5
+ pending: number;
6
+ failed: number;
7
+ syncing: boolean;
8
+ lastSyncAt: number | null;
9
+ itemCount?: number;
10
+ itemLabel?: string;
11
+ }
12
+ export interface SequenceOfflineManagementIssue {
13
+ id: string;
14
+ label: string;
15
+ message: string;
16
+ }
17
+ export interface SequenceOfflineManagement {
18
+ status(): Promise<SequenceOfflineManagementStatus>;
19
+ setEnrolled(enrolled: boolean): Promise<void>;
20
+ sync(): Promise<void>;
21
+ issues(): Promise<SequenceOfflineManagementIssue[]>;
22
+ retry(id: string): Promise<void>;
23
+ discard(id: string): Promise<void>;
24
+ subscribe(listener: () => void): () => void;
25
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -20,7 +20,7 @@ const imageReferenceSchema = z.string().max(1024).regex(/^[a-z0-9]+(?:[.-][a-z0-
20
20
  const excludedSourceSegments = new Set([
21
21
  '.git', 'node_modules', '.next', '.npmrc', '.netrc', '.aws', '.ssh', '.config',
22
22
  'dist', '.artifact-studio', '.git-credentials', '.pypirc', '.azure', '.gcloud', '.doppler',
23
- '.turbo', 'coverage', '.docker',
23
+ '.turbo', 'coverage', '.docker', '.seq-next-output.json',
24
24
  ]);
25
25
  /** Shared upload exclusion policy. Content scanning and filesystem containment remain separate checks. */
26
26
  export function isExcludedArtifactSourcePath({ path }) {
package/dist/sdk.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { SequenceOfflineManagement } from './offline/management-types.js';
2
+ export type { SequenceOfflineManagement, SequenceOfflineManagementStatus, SequenceOfflineManagementIssue } from './offline/management-types.js';
1
3
  import { type StartStorageUpload, type StorageUploadSession } from '@sequenceholdings/storage';
2
4
  export { createArtifactOrmExecutor, type ArtifactOrmExecutorConfig } from './sdk-orm.js';
3
5
  export type { ArtifactOrmJsonScalars } from './orm-json-scalars.js';
@@ -78,6 +80,9 @@ export interface SequenceOfflineRequestConnector {
78
80
  }): void | (() => void);
79
81
  }
80
82
  export interface SequenceOffline {
83
+ registerManagement(adapter: SequenceOfflineManagement): () => void;
84
+ openSettings(): Promise<void>;
85
+ isManagementSession(): boolean;
81
86
  /**
82
87
  * Install the artifact's request policy beneath seq.api. Returns a disposer
83
88
  * that restores the network-only default when it still owns registration.
@@ -395,7 +400,9 @@ export declare function createArtifactStorageClient(sdk?: SequenceArtifactSdk):
395
400
  size: number | null;
396
401
  contentType: string | null;
397
402
  }>;
398
- createFolder: (name: string, parentId?: string | null, { idempotencyKey, }?: {
403
+ createFolder: (name: string, parentId
404
+ /** The specific step run (the assignee's task) — the step-scoped id reviewers hold access to. */
405
+ ? /** The specific step run (the assignee's task) — the step-scoped id reviewers hold access to. */: string | null, { idempotencyKey, }?: {
399
406
  idempotencyKey?: string;
400
407
  }) => Promise<{
401
408
  id: string;
@@ -0,0 +1,26 @@
1
+ import type { SequenceApiFetchResponse } from '../sdk.js';
2
+ import { type ArtifactServerMode, type ArtifactServerRequest } from './broker-contract.js';
3
+ export interface ArtifactServerFetchOptions {
4
+ method?: 'GET' | 'HEAD' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
5
+ body?: unknown;
6
+ signal?: AbortSignal;
7
+ }
8
+ /** Sends one broker operation; the client's transport (retries, the external-principal gate). */
9
+ export type ArtifactBrokerRequest = (operation: ArtifactServerRequest, options?: {
10
+ signal?: AbortSignal;
11
+ }) => Promise<Response>;
12
+ /** `client.api`: Atlas routes reached through the broker as `api` operations. */
13
+ export declare function createApiClient({ mode, request, json }: {
14
+ mode: ArtifactServerMode;
15
+ request: ArtifactBrokerRequest;
16
+ json: (operation: ArtifactServerRequest, signal?: AbortSignal) => Promise<unknown>;
17
+ }): {
18
+ request: (path: string, options?: ArtifactServerFetchOptions) => Promise<Response>;
19
+ fetch<TBody = unknown>(path: string, options?: ArtifactServerFetchOptions): Promise<SequenceApiFetchResponse<TBody>>;
20
+ stream(path: string, options?: ArtifactServerFetchOptions): Promise<ReadableStream<Uint8Array<ArrayBuffer>>>;
21
+ get: <T = unknown>(path: string) => Promise<T>;
22
+ post: <T = unknown>(path: string, body?: unknown) => Promise<T>;
23
+ put: <T = unknown>(path: string, body?: unknown) => Promise<T>;
24
+ patch: <T = unknown>(path: string, body?: unknown) => Promise<T>;
25
+ delete: <T = unknown>(path: string, body?: unknown) => Promise<T>;
26
+ };
@@ -0,0 +1,38 @@
1
+ import { artifactServerRequestSchema } from './broker-contract.js';
2
+ import { ArtifactServerError, stepUpErrorFromResponse } from './errors.js';
3
+ const responseHeaders = ['content-type', 'content-disposition', 'content-length', 'retry-after'];
4
+ /** `client.api`: Atlas routes reached through the broker as `api` operations. */
5
+ export function createApiClient({ mode, request, json }) {
6
+ const operation = (path, options = {}) => artifactServerRequestSchema.parse({ operation: 'api', mode, method: options.method ?? 'GET', path, body: options.body });
7
+ const fetchResponse = (path, options = {}) => request(operation(path, options), { signal: options.signal });
8
+ const typedJson = async (path, options = {}) => {
9
+ // Explicit caller result types match the existing browser SDK; the server validates endpoint schemas.
10
+ return await json(operation(path, options), options.signal);
11
+ };
12
+ return {
13
+ request: fetchResponse,
14
+ async fetch(path, options = {}) {
15
+ const response = await fetchResponse(path, options);
16
+ const contentType = response.headers.get('content-type') ?? '';
17
+ const body = response.status === 204 ? undefined : contentType.includes('json') ? await response.json() : await response.arrayBuffer();
18
+ // The caller declares the endpoint's response type, consistent with SequenceApiFetchResponse.
19
+ return { ok: response.ok, status: response.status, body: body, headers: Object.fromEntries(responseHeaders.flatMap(key => {
20
+ const value = response.headers.get(key);
21
+ return value === null ? [] : [[key, value]];
22
+ })) };
23
+ },
24
+ async stream(path, options = {}) {
25
+ const response = await fetchResponse(path, options);
26
+ if (!response.ok || !response.body) {
27
+ throw await stepUpErrorFromResponse(response) ??
28
+ new ArtifactServerError(response.status, 'stream_unavailable', 'Atlas did not return a readable response');
29
+ }
30
+ return response.body;
31
+ },
32
+ get: (path) => typedJson(path),
33
+ post: (path, body) => typedJson(path, { method: 'POST', body }),
34
+ put: (path, body) => typedJson(path, { method: 'PUT', body }),
35
+ patch: (path, body) => typedJson(path, { method: 'PATCH', body }),
36
+ delete: (path, body) => typedJson(path, { method: 'DELETE', body }),
37
+ };
38
+ }
@@ -1,19 +1,17 @@
1
1
  import { storageClient } from '@sequenceholdings/storage';
2
2
  import { type OperationExecutor } from '@sequenceholdings/orm/client';
3
- import type { SequenceApiFetchResponse } from '../sdk.js';
4
- import { type ArtifactAuditAction, type ArtifactServerMode, type ArtifactServerRequest } from './broker-contract.js';
3
+ import { type ArtifactAuditAction, type ArtifactServerMode } from './broker-contract.js';
5
4
  import { type ArtifactServerProcesses } from './processes.js';
6
5
  import { type ArtifactOrmJsonScalars } from '../orm-json-scalars.js';
7
6
  import { createArtifactLogger, type ArtifactLogRecord } from './logger.js';
8
- import { type ArtifactRequestContext } from './request-context.js';
7
+ import { type ArtifactRequestContext, type RequestContextV2External } from './request-context.js';
8
+ import { type ArtifactExternalAccept } from './external-context.js';
9
+ import { createApiClient } from './api-client.js';
10
+ import { type ArtifactPrincipal } from './principal.js';
9
11
  import { type ArtifactRuntimeConfig, type ArtifactRequestHeaders } from './runtime-config.js';
10
- export { ArtifactServerError } from './errors.js';
12
+ export { ArtifactPrincipalError, ArtifactServerError, INSUFFICIENT_USER_AUTHENTICATION, stepUpResponse } from './errors.js';
11
13
  export type * from './processes.js';
12
- export interface ArtifactServerFetchOptions {
13
- method?: 'GET' | 'HEAD' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
14
- body?: unknown;
15
- signal?: AbortSignal;
16
- }
14
+ export type { ArtifactServerFetchOptions } from './api-client.js';
17
15
  /**
18
16
  * The namespace's generated operations.manifest.json. Only each operation's persisted hash
19
17
  * is read, so the file can be passed as imported: a JSON import types `kind` as string.
@@ -71,8 +69,11 @@ export interface ArtifactAuditEvent {
71
69
  export interface ArtifactServerAudit {
72
70
  record(event: ArtifactAuditEvent): Promise<void>;
73
71
  }
74
- export interface ArtifactServerClient {
75
- context: ArtifactRequestContext;
72
+ export type ArtifactServerContext = ArtifactRequestContext | RequestContextV2External;
73
+ export interface ArtifactServerClient<TContext extends ArtifactServerContext = ArtifactRequestContext> {
74
+ context: TContext;
75
+ /** Who the request acts for. An `external` principal reaches platform services only through `asService()`. */
76
+ principal: ArtifactPrincipal;
76
77
  mode: ArtifactServerMode;
77
78
  log: ReturnType<typeof createArtifactLogger>;
78
79
  api: ReturnType<typeof createApiClient>;
@@ -84,46 +85,32 @@ export interface ArtifactServerClient {
84
85
  flags: ArtifactServerFlags;
85
86
  audit: ArtifactServerAudit;
86
87
  orm(input: ArtifactOrmOptions): OperationExecutor;
87
- asService(): ArtifactServerClient;
88
+ asService(): ArtifactServerClient<TContext>;
88
89
  }
89
- /** One instance per incoming request. Never cache it at module scope. */
90
- export declare function createArtifactServerClient({ headers, runtime, fetch: fetcher, method, path, logSink }: {
90
+ export interface ArtifactServerClientOptions {
91
91
  headers: ArtifactRequestHeaders;
92
92
  /** Optional explicit platform config; otherwise reads SEQUENCE_ARTIFACT_RUNTIME_FILE. */
93
93
  runtime?: ArtifactRuntimeConfig;
94
94
  fetch?: typeof globalThis.fetch;
95
+ /** Required, with `path`, to accept an external caller. */
95
96
  method?: string;
96
97
  path?: string;
97
98
  logSink?: (record: ArtifactLogRecord) => void;
98
- }): Promise<ArtifactServerClient>;
99
- type ClientArguments = {
100
- context: ArtifactRequestContext;
101
- token: string;
102
- getRuntime: () => Promise<ArtifactRuntimeConfig>;
103
- fetcher: typeof globalThis.fetch;
104
- mode: ArtifactServerMode;
105
- logSink?: (record: ArtifactLogRecord) => void;
106
- };
107
- type BrokerTransport = ReturnType<typeof createBrokerTransport>;
108
- interface BrokerCallOptions {
109
- signal?: AbortSignal;
110
- /** Whether sending the call twice is harmless. Defaults to the operation's own answer (isReadOperation). */
111
- replaySafe?: boolean;
112
99
  }
113
- declare function createBrokerTransport({ context, token, getRuntime, fetcher, log }: ClientArguments & {
114
- log: ReturnType<typeof createArtifactLogger>;
115
- }): (input: ArtifactServerRequest, { signal, replaySafe }?: BrokerCallOptions) => Promise<Response>;
116
- declare function createApiClient({ mode, request, json }: {
117
- mode: ArtifactServerMode;
118
- request: BrokerTransport;
119
- json: (operation: ArtifactServerRequest, signal?: AbortSignal) => Promise<unknown>;
120
- }): {
121
- request: (path: string, options?: ArtifactServerFetchOptions) => Promise<Response>;
122
- fetch<TBody = unknown>(path: string, options?: ArtifactServerFetchOptions): Promise<SequenceApiFetchResponse<TBody>>;
123
- stream(path: string, options?: ArtifactServerFetchOptions): Promise<ReadableStream<Uint8Array<ArrayBuffer>>>;
124
- get: <T = unknown>(path: string) => Promise<T>;
125
- post: <T = unknown>(path: string, body?: unknown) => Promise<T>;
126
- put: <T = unknown>(path: string, body?: unknown) => Promise<T>;
127
- patch: <T = unknown>(path: string, body?: unknown) => Promise<T>;
128
- delete: <T = unknown>(path: string, body?: unknown) => Promise<T>;
129
- };
100
+ /**
101
+ * One instance per incoming request. Never cache it at module scope.
102
+ *
103
+ * Accepts Atlas-user and platform-service contexts. A handler that also serves external callers
104
+ * names their ingresses in `accept`; its client then carries an external context, whose
105
+ * `principal` reaches platform services only through `asService()`.
106
+ */
107
+ export declare function createArtifactServerClient(options: ArtifactServerClientOptions & {
108
+ accept?: undefined;
109
+ }): Promise<ArtifactServerClient>;
110
+ export declare function createArtifactServerClient(options: ArtifactServerClientOptions & {
111
+ accept: ArtifactExternalAccept;
112
+ }): Promise<ArtifactServerClient<ArtifactServerContext>>;
113
+ /** For a helper that forwards an optional `accept`: the context may be either version. */
114
+ export declare function createArtifactServerClient(options: ArtifactServerClientOptions & {
115
+ accept?: ArtifactExternalAccept;
116
+ }): Promise<ArtifactServerClient<ArtifactServerContext>>;
@@ -3,36 +3,40 @@ import { StorageApiError, storageClient, storageErrorSchema } from '@sequencehol
3
3
  import { OrmOperationError } from '@sequenceholdings/orm/client';
4
4
  import { z } from 'zod';
5
5
  import { artifactServerRequestSchema } from './broker-contract.js';
6
- import { ArtifactServerError, platformRequestError } from './errors.js';
7
6
  import { createArtifactProcessesClient } from './processes.js';
8
7
  import { assertJsonScalarsAcknowledged, ORM_JSON_SCALARS_HEADER } from '../orm-json-scalars.js';
9
8
  import { createArtifactLogger } from './logger.js';
10
9
  import { ARTIFACT_REQUEST_CONTEXT_HEADER, ARTIFACT_RUNTIME_TOKEN_HEADER, ARTIFACT_SERVER_REQUEST_PATH, verifyArtifactRequestContext } from './request-context.js';
10
+ import { verifyArtifactExternalContext } from './external-context.js';
11
+ import { isExternalContextToken } from './internal/signed-token.js';
12
+ import { createApiClient } from './api-client.js';
13
+ import { pause, transientConnectionCode } from './connection-retry.js';
14
+ import { principalFromContext } from './principal.js';
11
15
  import { artifactRuntimeConfigSchema, readArtifactRuntimeConfig } from './runtime-config.js';
12
- export { ArtifactServerError } from './errors.js';
16
+ import { ArtifactPrincipalError, ArtifactServerError, refusalFromResponse, stepUpError, stepUpErrorFromResponse } from './errors.js';
17
+ export { ArtifactPrincipalError, ArtifactServerError, INSUFFICIENT_USER_AUTHENTICATION, stepUpResponse } from './errors.js';
13
18
  const graphqlResponseSchema = z.object({
14
19
  data: z.unknown().optional(),
15
20
  errors: z.array(z.object({ message: z.string(), extensions: z.object({ code: z.string().optional() }).passthrough().optional() })).optional(),
16
21
  });
17
22
  const flagsResponseSchema = z.object({ flags: z.record(z.string(), z.boolean()) });
18
- const responseHeaders = ['content-type', 'content-disposition', 'content-length', 'retry-after'];
19
23
  const maxBodyBytes = 1024 * 1024;
20
24
  /** A read whose connection failed is sent at most three times, pausing up to 100 ms, then up to 200 ms. */
21
25
  const reconnect = { attempts: 3, baseDelayMs: 100 };
22
- /**
23
- * Socket and DNS errors after which Atlas never answered, and which clear by themselves: the
24
- * route to Atlas was briefly gone (an egress NAT out of ports drops new connections, which Node
25
- * reports as EHOSTUNREACH), Atlas's edge refused or reset the connection, or a lookup failed
26
- * transiently. Timeouts aren't here: a call that waited out its timeout shouldn't wait it out again.
27
- */
28
- const transientConnectionCodes = new Set(['ECONNREFUSED', 'ECONNRESET', 'EHOSTUNREACH', 'ENETUNREACH', 'EADDRNOTAVAIL', 'EPIPE', 'EAI_AGAIN', 'UND_ERR_SOCKET']);
29
- /** One instance per incoming request. Never cache it at module scope. */
30
- export async function createArtifactServerClient({ headers, runtime, fetch: fetcher = globalThis.fetch, method, path, logSink }) {
26
+ export async function createArtifactServerClient({ headers, runtime, fetch: fetcher = globalThis.fetch, method, path, logSink, accept }) {
31
27
  const config = runtime ? artifactRuntimeConfigSchema.parse(runtime) : await readArtifactRuntimeConfig();
32
28
  if (!config)
33
29
  throw new ArtifactServerError(401, 'runtime_not_connected', 'Run seq-studio artifact dev to connect platform services, or use a hosted preview.');
34
30
  const token = headers.get(ARTIFACT_REQUEST_CONTEXT_HEADER);
35
- const context = verifyArtifactRequestContext({ token, publicKeys: config.publicKeys, projectId: config.projectId, environment: config.environment, method, path });
31
+ let context;
32
+ if (accept && isExternalContextToken(token)) {
33
+ if (!method || !path)
34
+ throw new ArtifactServerError(401, 'invalid_request_context', 'An external request context is verified only against the route method and path');
35
+ context = verifyArtifactExternalContext({ token, publicKeys: config.publicKeys, projectId: config.projectId, environment: config.environment, method, path, accept });
36
+ }
37
+ else {
38
+ context = verifyArtifactRequestContext({ token, publicKeys: config.publicKeys, projectId: config.projectId, environment: config.environment, method, path });
39
+ }
36
40
  if (!token || (config.devSessionId && context.devSessionId !== config.devSessionId) ||
37
41
  (config.deploymentId && context.deploymentId !== config.deploymentId))
38
42
  throw new ArtifactServerError(401, 'invalid_runtime_session', 'Artifact request does not belong to this runtime');
@@ -42,24 +46,27 @@ export async function createArtifactServerClient({ headers, runtime, fetch: fetc
42
46
  throw new ArtifactServerError(401, 'runtime_changed', 'Artifact runtime disconnected or changed; reload the preview');
43
47
  return current;
44
48
  };
45
- return buildClient({ context, token, getRuntime, fetcher, mode: 'user', logSink });
49
+ return buildClient({ context, principal: principalFromContext(context), token, getRuntime, fetcher, mode: 'user', logSink });
46
50
  }
47
51
  function buildClient(args) {
48
- const { context, mode, logSink } = args;
52
+ const { context, principal, mode, logSink } = args;
49
53
  const log = createArtifactLogger({ context, sink: logSink });
50
54
  const request = createBrokerTransport({ ...args, log });
51
55
  const json = async (operation, signal) => {
52
56
  const response = await request(operation, { signal });
57
+ // One read of the body: a step-up refusal keeps reason and maxAge, any other keeps Atlas's { detail, code }.
53
58
  if (!response.ok)
54
- throw await platformRequestError({ response, operation: operation.operation });
59
+ throw await refusalFromResponse({ response, operation: operation.operation });
55
60
  return response.status === 204 ? undefined : response.json();
56
61
  };
57
62
  // storageClient retries replay-safe calls on 503 permissions_pending / storage_busy, so Storage
58
63
  // failures keep the envelope's status and code rather than collapsing into platform_request_failed.
59
64
  const storageJson = async (operation) => {
60
65
  const response = await request(operation);
61
- if (!response.ok)
62
- throw await storageApiError(response);
66
+ if (!response.ok) {
67
+ // A step-up refusal keeps its reason and maxAge; storageApiError reads the body, hence the clone.
68
+ throw (response.status === 401 ? await stepUpErrorFromResponse(response.clone()) : null) ?? await storageApiError(response);
69
+ }
63
70
  return response.status === 204 ? undefined : response.json();
64
71
  };
65
72
  const evaluateFlags = async (flagNames) => {
@@ -72,6 +79,7 @@ function buildClient(args) {
72
79
  };
73
80
  return {
74
81
  context,
82
+ principal,
75
83
  mode,
76
84
  log,
77
85
  api: createApiClient({ mode, request, json }),
@@ -97,7 +105,11 @@ function buildClient(args) {
97
105
  const response = await request(artifactServerRequestSchema.parse({ operation: 'orm', mode, namespace, op: hash, variables, ...(jsonScalars ? { jsonScalars } : {}) }),
98
106
  // Only an operation the manifest lists as a query is known to change nothing.
99
107
  { replaySafe: listed?.kind === 'query' });
100
- const parsed = graphqlResponseSchema.safeParse(await response.json().catch(() => null));
108
+ const body = await response.json().catch(() => null);
109
+ const stepUp = stepUpError(response.status, body);
110
+ if (stepUp)
111
+ throw stepUp;
112
+ const parsed = graphqlResponseSchema.safeParse(body);
101
113
  if (!parsed.success)
102
114
  throw new OrmOperationError('Malformed Data API response', response.status, 'invalid_response');
103
115
  const first = parsed.data.errors?.[0];
@@ -139,7 +151,12 @@ async function recordAuditEvent({ request, mode, event }) {
139
151
  failure = new ArtifactServerError(503, 'audit_unavailable', 'Atlas did not answer the audit request');
140
152
  continue;
141
153
  }
142
- await response.body?.cancel();
154
+ // A step-up refusal is the broker's answer, not a lost delivery: never resent.
155
+ const stepUp = await stepUpErrorFromResponse(response);
156
+ if (stepUp)
157
+ throw stepUp;
158
+ if (!response.bodyUsed)
159
+ await response.body?.cancel();
143
160
  if (response.ok)
144
161
  return;
145
162
  failure = new ArtifactServerError(response.status, response.status === 503 ? 'audit_unavailable' : 'platform_request_failed', `Atlas rejected the audit request (${response.status})`);
@@ -168,40 +185,11 @@ function isReadOperation(operation) {
168
185
  return false;
169
186
  }
170
187
  }
171
- /**
172
- * The transient connection code behind a failed fetch, or null. Node's fetch throws
173
- * `TypeError: fetch failed` with the socket error as its cause. When every address of the host
174
- * failed to connect, that cause is an AggregateError carrying the first address's code.
175
- */
176
- function transientConnectionCode(error) {
177
- let current = error;
178
- for (let depth = 0; depth < 8 && typeof current === 'object' && current !== null; depth++) {
179
- if ('code' in current && typeof current.code === 'string' && transientConnectionCodes.has(current.code))
180
- return current.code;
181
- current = 'cause' in current ? current.cause : undefined;
182
- }
183
- return null;
184
- }
185
- /** Resolves after `ms`, or rejects with the caller's abort reason as soon as it aborts. */
186
- function pause({ ms, signal }) {
187
- return new Promise((resolve, reject) => {
188
- if (signal?.aborted) {
189
- reject(signal.reason);
190
- return;
191
- }
192
- const aborted = () => {
193
- clearTimeout(timer);
194
- reject(signal?.reason);
195
- };
196
- const timer = setTimeout(() => {
197
- signal?.removeEventListener('abort', aborted);
198
- resolve();
199
- }, ms);
200
- signal?.addEventListener('abort', aborted, { once: true });
201
- });
202
- }
203
- function createBrokerTransport({ context, token, getRuntime, fetcher, log }) {
188
+ function createBrokerTransport({ context, principal, token, getRuntime, fetcher, log }) {
204
189
  const send = async (input, signal) => {
190
+ // The one choke point for every broker operation: an external caller never acts as a user.
191
+ if (input.mode === 'user' && principal.kind === 'external')
192
+ throw new ArtifactPrincipalError('external_requires_service');
205
193
  if (context.exp <= Math.floor(Date.now() / 1000))
206
194
  throw new ArtifactServerError(401, 'request_context_expired', 'Artifact request context expired; begin a new request');
207
195
  const operation = artifactServerRequestSchema.parse(input);
@@ -248,35 +236,3 @@ function createBrokerTransport({ context, token, getRuntime, fetcher, log }) {
248
236
  }
249
237
  };
250
238
  }
251
- function createApiClient({ mode, request, json }) {
252
- const operation = (path, options = {}) => artifactServerRequestSchema.parse({ operation: 'api', mode, method: options.method ?? 'GET', path, body: options.body });
253
- const fetchResponse = (path, options = {}) => request(operation(path, options), { signal: options.signal });
254
- const typedJson = async (path, options = {}) => {
255
- // Explicit caller result types match the existing browser SDK; the server validates endpoint schemas.
256
- return await json(operation(path, options), options.signal);
257
- };
258
- return {
259
- request: fetchResponse,
260
- async fetch(path, options = {}) {
261
- const response = await fetchResponse(path, options);
262
- const contentType = response.headers.get('content-type') ?? '';
263
- const body = response.status === 204 ? undefined : contentType.includes('json') ? await response.json() : await response.arrayBuffer();
264
- // The caller declares the endpoint's response type, consistent with SequenceApiFetchResponse.
265
- return { ok: response.ok, status: response.status, body: body, headers: Object.fromEntries(responseHeaders.flatMap(key => {
266
- const value = response.headers.get(key);
267
- return value === null ? [] : [[key, value]];
268
- })) };
269
- },
270
- async stream(path, options = {}) {
271
- const response = await fetchResponse(path, options);
272
- if (!response.ok || !response.body)
273
- throw new ArtifactServerError(response.status, 'stream_unavailable', 'Atlas did not return a readable response');
274
- return response.body;
275
- },
276
- get: (path) => typedJson(path),
277
- post: (path, body) => typedJson(path, { method: 'POST', body }),
278
- put: (path, body) => typedJson(path, { method: 'PUT', body }),
279
- patch: (path, body) => typedJson(path, { method: 'PATCH', body }),
280
- delete: (path, body) => typedJson(path, { method: 'DELETE', body }),
281
- };
282
- }
@@ -0,0 +1,12 @@
1
+ /** Recognising a lost connection to Atlas, and waiting before a broker read is resent. */
2
+ /**
3
+ * The transient connection code behind a failed fetch, or null. Node's fetch throws
4
+ * `TypeError: fetch failed` with the socket error as its cause. When every address of the host
5
+ * failed to connect, that cause is an AggregateError carrying the first address's code.
6
+ */
7
+ export declare function transientConnectionCode(error: unknown): string | null;
8
+ /** Resolves after `ms`, or rejects with the caller's abort reason as soon as it aborts. */
9
+ export declare function pause({ ms, signal }: {
10
+ ms: number;
11
+ signal?: AbortSignal;
12
+ }): Promise<void>;
@@ -0,0 +1,40 @@
1
+ /** Recognising a lost connection to Atlas, and waiting before a broker read is resent. */
2
+ /**
3
+ * Socket and DNS errors after which Atlas never answered, and which clear by themselves: the
4
+ * route to Atlas was briefly gone (an egress NAT out of ports drops new connections, which Node
5
+ * reports as EHOSTUNREACH), Atlas's edge refused or reset the connection, or a lookup failed
6
+ * transiently. Timeouts aren't here: a call that waited out its timeout shouldn't wait it out again.
7
+ */
8
+ const transientConnectionCodes = new Set(['ECONNREFUSED', 'ECONNRESET', 'EHOSTUNREACH', 'ENETUNREACH', 'EADDRNOTAVAIL', 'EPIPE', 'EAI_AGAIN', 'UND_ERR_SOCKET']);
9
+ /**
10
+ * The transient connection code behind a failed fetch, or null. Node's fetch throws
11
+ * `TypeError: fetch failed` with the socket error as its cause. When every address of the host
12
+ * failed to connect, that cause is an AggregateError carrying the first address's code.
13
+ */
14
+ export function transientConnectionCode(error) {
15
+ let current = error;
16
+ for (let depth = 0; depth < 8 && typeof current === 'object' && current !== null; depth++) {
17
+ if ('code' in current && typeof current.code === 'string' && transientConnectionCodes.has(current.code))
18
+ return current.code;
19
+ current = 'cause' in current ? current.cause : undefined;
20
+ }
21
+ return null;
22
+ }
23
+ /** Resolves after `ms`, or rejects with the caller's abort reason as soon as it aborts. */
24
+ export function pause({ ms, signal }) {
25
+ return new Promise((resolve, reject) => {
26
+ if (signal?.aborted) {
27
+ reject(signal.reason);
28
+ return;
29
+ }
30
+ const aborted = () => {
31
+ clearTimeout(timer);
32
+ reject(signal?.reason);
33
+ };
34
+ const timer = setTimeout(() => {
35
+ signal?.removeEventListener('abort', aborted);
36
+ resolve();
37
+ }, ms);
38
+ signal?.addEventListener('abort', aborted, { once: true });
39
+ });
40
+ }
@@ -1,3 +1,4 @@
1
+ export declare const INSUFFICIENT_USER_AUTHENTICATION = "insufficient_user_authentication";
1
2
  /**
2
3
  * What Atlas's error envelope said about a refusal, when the response carried one. Atlas routes
3
4
  * answer `{ detail, code? }`: `detail` is the human-readable reason, `code` the route's machine
@@ -8,19 +9,53 @@ export interface ArtifactAtlasRefusal {
8
9
  code: string | null;
9
10
  detail: string | null;
10
11
  }
12
+ export interface ArtifactServerErrorDetails {
13
+ reason?: string;
14
+ maxAge?: number;
15
+ atlas?: ArtifactAtlasRefusal;
16
+ }
11
17
  export declare class ArtifactServerError extends Error {
12
18
  readonly status: number;
13
19
  readonly code: string;
20
+ /** A named refusal a handler can act on, such as `insufficient_user_authentication`. */
21
+ readonly reason: string | undefined;
22
+ /** With `insufficient_user_authentication`: the sign-in age, in seconds, the operation requires. */
23
+ readonly maxAge: number | undefined;
14
24
  /** Atlas's own account of the refusal; both null when the response had no readable envelope. */
15
25
  readonly atlas: ArtifactAtlasRefusal;
16
- constructor(status: number, code: string, message: string, atlas?: ArtifactAtlasRefusal);
26
+ constructor(status: number, code: string, message: string, details?: ArtifactServerErrorDetails);
27
+ }
28
+ /** A user-mode call made for a caller that may act only through `asService()`. */
29
+ export declare class ArtifactPrincipalError extends ArtifactServerError {
30
+ readonly reason: 'external_requires_service';
31
+ constructor(reason: 'external_requires_service');
17
32
  }
33
+ /**
34
+ * The step-up refusal in a broker 401 body (`{reason|error: 'insufficient_user_authentication',
35
+ * maxAge|max_age}`), or null. A missing or malformed age asks for a fresh sign-in (0).
36
+ */
37
+ export declare function stepUpError(status: number, body: unknown): ArtifactServerError | null;
38
+ export declare function stepUpErrorFromResponse(response: Response): Promise<ArtifactServerError | null>;
18
39
  /**
19
40
  * A refused broker call, keeping what Atlas said: the message carries the route's `detail`
20
41
  * and `error.atlas` its machine `code`, so a consumer can branch on a Lattice refusal
21
42
  * (`EDGE_ID_MISMATCH`, `WORKFLOW_NOT_RUNNING`, …) the way a caller of the route itself would.
22
43
  */
23
- export declare function platformRequestError({ response, operation }: {
44
+ export declare function platformRequestError({ status, body, operation }: {
45
+ status: number;
46
+ body: unknown;
47
+ operation: string;
48
+ }): ArtifactServerError;
49
+ /**
50
+ * The error for a refused broker response: a step-up refusal keeps its reason and maxAge, any other
51
+ * refusal keeps Atlas's `{ detail, code }`. The body is read once, so neither reading starves the other.
52
+ */
53
+ export declare function refusalFromResponse({ response, operation }: {
24
54
  response: Response;
25
55
  operation: string;
26
56
  }): Promise<ArtifactServerError>;
57
+ /**
58
+ * The RFC 9470 response for a step-up refusal, for the app to return as is: the caller signs in
59
+ * again with `max_age` and retries.
60
+ */
61
+ export declare function stepUpResponse(error: ArtifactServerError): Response;