@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.
- package/VERSION-PIN.md +20 -0
- package/dist/cli.js +1 -1
- package/dist/offline/connector.d.ts +2 -0
- package/dist/offline/connector.js +6 -2
- package/dist/offline/management-types.d.ts +25 -0
- package/dist/offline/management-types.js +1 -0
- package/dist/runtime-contracts.js +1 -1
- package/dist/sdk.d.ts +8 -1
- package/dist/server/api-client.d.ts +26 -0
- package/dist/server/api-client.js +38 -0
- package/dist/server/client.d.ts +32 -45
- package/dist/server/client.js +41 -85
- package/dist/server/connection-retry.d.ts +12 -0
- package/dist/server/connection-retry.js +40 -0
- package/dist/server/errors.d.ts +37 -2
- package/dist/server/errors.js +66 -6
- package/dist/server/external-context.d.ts +21 -0
- package/dist/server/external-context.js +19 -0
- package/dist/server/internal/signed-token.d.ts +15 -0
- package/dist/server/internal/signed-token.js +67 -0
- package/dist/server/internal/v1-context.d.ts +18 -0
- package/dist/server/internal/v1-context.js +26 -0
- package/dist/server/internal/verify-any.d.ts +22 -0
- package/dist/server/internal/verify-any.js +53 -0
- package/dist/server/logger.d.ts +1 -1
- package/dist/server/principal.d.ts +48 -0
- package/dist/server/principal.js +39 -0
- package/dist/server/request-context.d.ts +115 -3
- package/dist/server/request-context.js +101 -32
- package/dist/server/testing.d.ts +40 -0
- package/dist/server/testing.js +86 -0
- package/dist/server.d.ts +9 -4
- package/dist/server.js +5 -2
- package/dist/templates/nextjs/package.json +3 -1
- package/dist/templates/nextjs/tests/server.test.mjs +25 -0
- package/dist/templates/react-vite/package.json +1 -1
- package/package.json +11 -3
- package/templates/nextjs/package.json +3 -1
- package/templates/nextjs/tests/server.test.mjs +25 -0
- 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
|
|
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(
|
|
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(
|
|
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
|
|
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
|
+
}
|
package/dist/server/client.d.ts
CHANGED
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
import { storageClient } from '@sequenceholdings/storage';
|
|
2
2
|
import { type OperationExecutor } from '@sequenceholdings/orm/client';
|
|
3
|
-
import type
|
|
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
|
|
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
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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>>;
|
package/dist/server/client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/server/errors.d.ts
CHANGED
|
@@ -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,
|
|
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({
|
|
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;
|