@sequenceholdings/artifact-studio 0.5.0 → 0.6.2

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 (65) hide show
  1. package/README.md +43 -0
  2. package/VERSION-PIN.md +27 -0
  3. package/dist/cli.js +1 -1
  4. package/dist/manifest-next-ingress.d.ts +213 -0
  5. package/dist/manifest-next-ingress.js +187 -0
  6. package/dist/manifest-next-reach.d.ts +40 -0
  7. package/dist/manifest-next-reach.js +48 -0
  8. package/dist/manifest-next.d.ts +133 -8
  9. package/dist/manifest-next.js +7 -46
  10. package/dist/manifest-static.d.ts +6 -0
  11. package/dist/manifest-static.js +10 -0
  12. package/dist/manifest.d.ts +67 -0
  13. package/dist/manifest.js +7 -0
  14. package/dist/offline/connector.d.ts +2 -0
  15. package/dist/offline/connector.js +6 -2
  16. package/dist/offline/management-types.d.ts +25 -0
  17. package/dist/offline/management-types.js +1 -0
  18. package/dist/runtime-contracts.js +1 -1
  19. package/dist/sdk.d.ts +8 -1
  20. package/dist/server/api-client.d.ts +27 -0
  21. package/dist/server/api-client.js +39 -0
  22. package/dist/server/broker-contract.d.ts +33 -6
  23. package/dist/server/broker-contract.js +20 -2
  24. package/dist/server/client.d.ts +32 -45
  25. package/dist/server/client.js +72 -101
  26. package/dist/server/connection-retry.d.ts +12 -0
  27. package/dist/server/connection-retry.js +40 -0
  28. package/dist/server/errors.d.ts +37 -2
  29. package/dist/server/errors.js +66 -6
  30. package/dist/server/external-context.d.ts +21 -0
  31. package/dist/server/external-context.js +19 -0
  32. package/dist/server/ingress-control-contract.d.ts +272 -0
  33. package/dist/server/ingress-control-contract.js +196 -0
  34. package/dist/server/ingress-routes.d.ts +76 -0
  35. package/dist/server/ingress-routes.js +150 -0
  36. package/dist/server/internal/signed-token.d.ts +15 -0
  37. package/dist/server/internal/signed-token.js +67 -0
  38. package/dist/server/internal/v1-context.d.ts +18 -0
  39. package/dist/server/internal/v1-context.js +26 -0
  40. package/dist/server/internal/verify-any.d.ts +22 -0
  41. package/dist/server/internal/verify-any.js +61 -0
  42. package/dist/server/logger.d.ts +1 -1
  43. package/dist/server/principal.d.ts +48 -0
  44. package/dist/server/principal.js +39 -0
  45. package/dist/server/processes.d.ts +56 -0
  46. package/dist/server/processes.js +13 -0
  47. package/dist/server/request-context.d.ts +121 -5
  48. package/dist/server/request-context.js +105 -34
  49. package/dist/server/runtime-config.d.ts +1 -1
  50. package/dist/server/testing.d.ts +40 -0
  51. package/dist/server/testing.js +86 -0
  52. package/dist/server.d.ts +9 -4
  53. package/dist/server.js +5 -2
  54. package/dist/templates/nextjs/README.md +9 -0
  55. package/dist/templates/nextjs/package.json +3 -1
  56. package/dist/templates/nextjs/tests/server.test.mjs +25 -0
  57. package/dist/templates/react-vite/package.json +1 -1
  58. package/dist/tunnel/protocol.js +2 -4
  59. package/dist/tunnel/reserved-headers.d.ts +7 -0
  60. package/dist/tunnel/reserved-headers.js +20 -0
  61. package/package.json +19 -3
  62. package/templates/nextjs/README.md +9 -0
  63. package/templates/nextjs/package.json +3 -1
  64. package/templates/nextjs/tests/server.test.mjs +25 -0
  65. package/templates/react-vite/package.json +1 -1
@@ -31,6 +31,8 @@ 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
+ /** A run-events read returns at most this many events per call, as the run's stream drains them. */
35
+ export const ARTIFACT_PROCESS_RUN_EVENTS_LIMIT = 1000;
34
36
  /** Which of the user's human steps to list: parked and waiting on them, or already done. */
35
37
  export const ARTIFACT_PROCESS_TASK_VIEWS = ['active', 'completed'];
36
38
  /** Lattice lists at most this many human steps per call, as its own inbox does. */
@@ -106,10 +108,12 @@ export const artifactServerRequestSchema = z.discriminatedUnion('operation', [
106
108
  }, 'Expected a Storage API path'), body: z.unknown().optional() }).strict(),
107
109
  z.object({ operation: z.literal('function'), mode, id, params: z.record(z.string(), z.unknown()).optional() }).strict(),
108
110
  // Starts a run of a declared process, as `POST /api/lattice/runs` does: the application names the
109
- // run, passes its input and may seed who works each human node. The start node, version and
110
- // engine stay with the process and its owning routes.
111
+ // run, passes its input, may seed who works each human node and may enter the graph at a node
112
+ // other than the process's start (the route's `start_node_id`). The version and engine stay with
113
+ // the process and its owning routes.
111
114
  z.object({
112
115
  operation: z.literal('lattice'), mode, process: latticeId, runId: latticeId.optional(),
116
+ startNodeId: z.string().min(1).max(255).regex(/^[A-Za-z0-9._-]+$/).optional(),
113
117
  title: z.string().max(500).optional(),
114
118
  entity: z.object({ type: entityRef, id: entityRef }).strict().optional(),
115
119
  metadata: z.record(z.string(), z.unknown()).optional(),
@@ -127,6 +131,14 @@ export const artifactServerRequestSchema = z.discriminatedUnion('operation', [
127
131
  // Reads one run the user may view, as `GET /api/lattice/runs/[runId]` does: the run, its steps, the
128
132
  // collapsed per-node states and the event cursor to resume its stream from.
129
133
  z.object({ operation: z.literal('lattice_run'), mode, runId: latticeId }).strict(),
134
+ // A page of a run's events after a cursor, oldest first, as `GET /api/lattice/runs/[runId]/stream`
135
+ // sends them: `since` is the cursor `lattice_run` returned, or the one the previous page returned.
136
+ // Polling with it sees every event exactly once. The user's Viewer relation on the run decides.
137
+ z.object({
138
+ operation: z.literal('lattice_run_events'), mode, runId: latticeId,
139
+ since: z.string().min(1).max(512).optional(),
140
+ limit: z.number().int().min(1).max(ARTIFACT_PROCESS_RUN_EVENTS_LIMIT).optional(),
141
+ }).strict(),
130
142
  // Describes a declared process — the applied template its runs are filled in from: nodes, edges and
131
143
  // what a person working each human step is shown. The active version, or the one a run pinned.
132
144
  z.object({ operation: z.literal('lattice_process'), mode, process: latticeId, version: latticeVersion.optional() }).strict(),
@@ -150,6 +162,12 @@ export const artifactServerRequestSchema = z.discriminatedUnion('operation', [
150
162
  // The user's run `owner` relation decides; the node's assignee allowlist still applies.
151
163
  z.object({ operation: z.literal('lattice_assign'), mode, runId: latticeId, stepId: latticeId, ...principalSet }).strict()
152
164
  .refine(namesPrincipals, PRINCIPAL_SET_MESSAGE),
165
+ // Cancels a run of a process declared under `processes.act`, with its forked lineage, as
166
+ // `POST /api/lattice/runs/[runId]/cancel` does. The user's Invoker relation on the run decides.
167
+ z.object({ operation: z.literal('lattice_cancel'), mode, runId: latticeId }).strict(),
168
+ // Resets a generating agent step without cancelling its run, as `POST .../steps/[stepId]/reset`
169
+ // does: the step settles and the run re-parks at the node's reset edge. Invoker-gated.
170
+ z.object({ operation: z.literal('lattice_reset_step'), mode, runId: latticeId, stepId: latticeId }).strict(),
153
171
  // The explicit viewer grants on a run, for its owner.
154
172
  z.object({ operation: z.literal('lattice_viewers'), mode, runId: latticeId }).strict(),
155
173
  // Shares a run with users and groups (grants `viewer`), or takes a share back. Owner-gated;
@@ -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})`);
@@ -153,6 +170,8 @@ async function storageApiError(response) {
153
170
  const envelope = storageErrorSchema.safeParse(await response.json().catch(() => undefined));
154
171
  return new StorageApiError(response.status, envelope.success ? envelope.data.error.code : 'request_failed', envelope.success ? envelope.data.error.message : `Storage request failed (${response.status})`);
155
172
  }
173
+ /** How long Atlas has to answer a broker call; for a stream, to send its first byte of headers. */
174
+ const BROKER_RESPONSE_TIMEOUT_MS = 60_000;
156
175
  /**
157
176
  * Broker operations that change nothing in Atlas. An ORM call's caller decides from its manifest
158
177
  * kind; function invokes and process starts can act, and audit delivery resends on its own terms.
@@ -168,40 +187,11 @@ function isReadOperation(operation) {
168
187
  return false;
169
188
  }
170
189
  }
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 }) {
204
- const send = async (input, signal) => {
190
+ function createBrokerTransport({ context, principal, token, getRuntime, fetcher, log }) {
191
+ const send = async (input, signal, stream = false) => {
192
+ // The one choke point for every broker operation: an external caller never acts as a user.
193
+ if (input.mode === 'user' && principal.kind === 'external')
194
+ throw new ArtifactPrincipalError('external_requires_service');
205
195
  if (context.exp <= Math.floor(Date.now() / 1000))
206
196
  throw new ArtifactServerError(401, 'request_context_expired', 'Artifact request context expired; begin a new request');
207
197
  const operation = artifactServerRequestSchema.parse(input);
@@ -211,19 +201,32 @@ function createBrokerTransport({ context, token, getRuntime, fetcher, log }) {
211
201
  if (Buffer.byteLength(body, 'utf8') > maxBodyBytes)
212
202
  throw new ArtifactServerError(413, 'request_too_large', 'Server SDK requests are limited to 1 MiB; use Storage uploads for files');
213
203
  const runtime = await getRuntime();
214
- return fetcher(`${runtime.atlasUrl}${ARTIFACT_SERVER_REQUEST_PATH}`, {
215
- method: 'POST',
216
- headers: {
217
- 'Content-Type': 'application/json',
218
- [ARTIFACT_RUNTIME_TOKEN_HEADER]: runtime.sessionToken,
219
- [ARTIFACT_REQUEST_CONTEXT_HEADER]: token,
220
- },
221
- body,
222
- cache: 'no-store',
223
- redirect: 'error',
224
- credentials: 'omit',
225
- signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(60_000)]) : AbortSignal.timeout(60_000),
226
- });
204
+ // The timeout covers the whole call, body included, except for a stream: its timer is cleared
205
+ // once Atlas answers, so a long body isn't cut off mid-read.
206
+ const expiry = new AbortController();
207
+ const timer = setTimeout(() => expiry.abort(new DOMException('Atlas did not answer in time', 'TimeoutError')), BROKER_RESPONSE_TIMEOUT_MS);
208
+ if (!stream)
209
+ timer.unref?.();
210
+ const timeout = expiry.signal;
211
+ try {
212
+ return await fetcher(`${runtime.atlasUrl}${ARTIFACT_SERVER_REQUEST_PATH}`, {
213
+ method: 'POST',
214
+ headers: {
215
+ 'Content-Type': 'application/json',
216
+ [ARTIFACT_RUNTIME_TOKEN_HEADER]: runtime.sessionToken,
217
+ [ARTIFACT_REQUEST_CONTEXT_HEADER]: token,
218
+ },
219
+ body,
220
+ cache: 'no-store',
221
+ redirect: 'error',
222
+ credentials: 'omit',
223
+ signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
224
+ });
225
+ }
226
+ finally {
227
+ if (stream)
228
+ clearTimeout(timer);
229
+ }
227
230
  };
228
231
  /**
229
232
  * Sends a read again when its connection to Atlas failed before Atlas answered: such failures
@@ -231,10 +234,10 @@ function createBrokerTransport({ context, token, getRuntime, fetcher, log }) {
231
234
  * again, because a reset can come after Atlas acted on it. A response, whatever its status, is
232
235
  * Atlas's answer and is returned as is.
233
236
  */
234
- return async (input, { signal, replaySafe = isReadOperation(input) } = {}) => {
237
+ return async (input, { signal, replaySafe = isReadOperation(input), stream = false } = {}) => {
235
238
  for (let attempt = 1;; attempt++) {
236
239
  try {
237
- return await send(input, signal);
240
+ return await send(input, signal, stream);
238
241
  }
239
242
  catch (error) {
240
243
  const code = replaySafe && attempt < reconnect.attempts ? transientConnectionCode(error) : null;
@@ -248,35 +251,3 @@ function createBrokerTransport({ context, token, getRuntime, fetcher, log }) {
248
251
  }
249
252
  };
250
253
  }
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;
@@ -1,18 +1,53 @@
1
+ /** Errors the server SDK raises, and the step-up (RFC 9470) mapping a handler returns. */
1
2
  import { z } from 'zod';
3
+ export const INSUFFICIENT_USER_AUTHENTICATION = 'insufficient_user_authentication';
2
4
  const NO_REFUSAL = { code: null, detail: null };
3
5
  export class ArtifactServerError extends Error {
4
6
  status;
5
7
  code;
8
+ /** A named refusal a handler can act on, such as `insufficient_user_authentication`. */
9
+ reason;
10
+ /** With `insufficient_user_authentication`: the sign-in age, in seconds, the operation requires. */
11
+ maxAge;
6
12
  /** Atlas's own account of the refusal; both null when the response had no readable envelope. */
7
13
  atlas;
8
- constructor(status, code, message, atlas = NO_REFUSAL) {
14
+ constructor(status, code, message, details = {}) {
9
15
  super(message);
10
16
  this.status = status;
11
17
  this.code = code;
12
18
  this.name = 'ArtifactServerError';
13
- this.atlas = atlas;
19
+ this.reason = details.reason;
20
+ this.maxAge = details.maxAge;
21
+ this.atlas = details.atlas ?? NO_REFUSAL;
14
22
  }
15
23
  }
24
+ /** A user-mode call made for a caller that may act only through `asService()`. */
25
+ export class ArtifactPrincipalError extends ArtifactServerError {
26
+ constructor(reason) {
27
+ super(403, reason, 'External callers reach platform services only through asService()', { reason });
28
+ this.name = 'ArtifactPrincipalError';
29
+ }
30
+ }
31
+ /**
32
+ * The step-up refusal in a broker 401 body (`{reason|error: 'insufficient_user_authentication',
33
+ * maxAge|max_age}`), or null. A missing or malformed age asks for a fresh sign-in (0).
34
+ */
35
+ export function stepUpError(status, body) {
36
+ if (status !== 401 || body === null || typeof body !== 'object')
37
+ return null;
38
+ const named = ('reason' in body && body.reason === INSUFFICIENT_USER_AUTHENTICATION) ||
39
+ ('error' in body && body.error === INSUFFICIENT_USER_AUTHENTICATION);
40
+ if (!named)
41
+ return null;
42
+ const age = 'maxAge' in body ? body.maxAge : 'max_age' in body ? body.max_age : undefined;
43
+ const maxAge = typeof age === 'number' && Number.isSafeInteger(age) && age >= 0 ? age : 0;
44
+ return new ArtifactServerError(401, INSUFFICIENT_USER_AUTHENTICATION, 'A more recent sign-in is required', { reason: INSUFFICIENT_USER_AUTHENTICATION, maxAge });
45
+ }
46
+ export async function stepUpErrorFromResponse(response) {
47
+ if (response.status !== 401)
48
+ return null;
49
+ return stepUpError(response.status, await response.json().catch(() => null));
50
+ }
16
51
  /** Atlas answers a refusal as `{ detail, code? }`; anything else (a proxy page, an empty body) reads as no envelope. */
17
52
  const atlasRefusalSchema = z.object({ detail: z.string().optional(), code: z.string().optional() });
18
53
  /**
@@ -20,10 +55,35 @@ const atlasRefusalSchema = z.object({ detail: z.string().optional(), code: z.str
20
55
  * and `error.atlas` its machine `code`, so a consumer can branch on a Lattice refusal
21
56
  * (`EDGE_ID_MISMATCH`, `WORKFLOW_NOT_RUNNING`, …) the way a caller of the route itself would.
22
57
  */
23
- export async function platformRequestError({ response, operation }) {
24
- const envelope = atlasRefusalSchema.safeParse(await response.json().catch(() => undefined));
58
+ export function platformRequestError({ status, body, operation }) {
59
+ const envelope = atlasRefusalSchema.safeParse(body);
25
60
  const detail = envelope.success ? envelope.data.detail ?? null : null;
26
61
  const code = envelope.success ? envelope.data.code ?? null : null;
27
- const message = `Atlas rejected the ${operation} request (${response.status})${detail ? `: ${detail}` : ''}`;
28
- return new ArtifactServerError(response.status, 'platform_request_failed', message, { code, detail });
62
+ const message = `Atlas rejected the ${operation} request (${status})${detail ? `: ${detail}` : ''}`;
63
+ return new ArtifactServerError(status, 'platform_request_failed', message, { atlas: { code, detail } });
64
+ }
65
+ /**
66
+ * The error for a refused broker response: a step-up refusal keeps its reason and maxAge, any other
67
+ * refusal keeps Atlas's `{ detail, code }`. The body is read once, so neither reading starves the other.
68
+ */
69
+ export async function refusalFromResponse({ response, operation }) {
70
+ const body = await response.json().catch(() => undefined);
71
+ return stepUpError(response.status, body) ?? platformRequestError({ status: response.status, body, operation });
72
+ }
73
+ /**
74
+ * The RFC 9470 response for a step-up refusal, for the app to return as is: the caller signs in
75
+ * again with `max_age` and retries.
76
+ */
77
+ export function stepUpResponse(error) {
78
+ if (error.reason !== INSUFFICIENT_USER_AUTHENTICATION)
79
+ throw new TypeError('stepUpResponse requires an insufficient_user_authentication error');
80
+ const maxAge = error.maxAge !== undefined && Number.isSafeInteger(error.maxAge) && error.maxAge >= 0 ? error.maxAge : 0;
81
+ return new Response(JSON.stringify({ error: INSUFFICIENT_USER_AUTHENTICATION, max_age: maxAge }), {
82
+ status: 401,
83
+ headers: {
84
+ 'content-type': 'application/json',
85
+ 'cache-control': 'no-store',
86
+ 'www-authenticate': `Bearer error="${INSUFFICIENT_USER_AUTHENTICATION}", max_age=${maxAge}`,
87
+ },
88
+ });
29
89
  }