@sequenceholdings/artifact-studio 0.4.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/README.md +35 -1
- package/VERSION-PIN.md +29 -0
- package/dist/active-deploy-policy.d.ts +9 -3
- package/dist/active-deploy-policy.js +20 -8
- package/dist/artifact-env-bindings.d.ts +9 -0
- package/dist/artifact-env-bindings.js +32 -0
- package/dist/build.js +6 -0
- package/dist/cli.js +62 -13
- package/dist/deployment-validation.d.ts +2 -0
- package/dist/deployment-validation.js +2 -0
- package/dist/dev-link.js +28 -2
- package/dist/env-binding-scan.d.ts +10 -0
- package/dist/env-binding-scan.js +179 -0
- package/dist/env-bindings.d.ts +52 -0
- package/dist/env-bindings.js +180 -0
- package/dist/gateway/control.d.ts +33 -0
- package/dist/gateway/control.js +29 -0
- package/dist/gateway/hosted-proxy.d.ts +5 -0
- package/dist/gateway/hosted-proxy.js +4 -1
- package/dist/gateway/lattice-invoke.d.ts +48 -0
- package/dist/gateway/lattice-invoke.js +245 -0
- package/dist/gateway/lattice-upstream.d.ts +38 -0
- package/dist/gateway/lattice-upstream.js +136 -0
- package/dist/gateway/server.js +2 -3
- package/dist/gateway/session.d.ts +3 -1
- package/dist/gateway/session.js +10 -3
- package/dist/manifest-next.d.ts +55 -0
- package/dist/manifest-next.js +35 -2
- package/dist/manifest-static.d.ts +35 -0
- package/dist/manifest-static.js +12 -0
- package/dist/manifest.d.ts +48 -0
- package/dist/next-build.d.ts +2 -1
- package/dist/next-build.js +12 -7
- package/dist/offline/cache.d.ts +4 -0
- package/dist/offline/cache.js +7 -0
- package/dist/offline/connector.d.ts +28 -0
- package/dist/offline/connector.js +122 -0
- package/dist/offline/crypto.d.ts +5 -0
- package/dist/offline/crypto.js +24 -0
- package/dist/offline/index.d.ts +8 -0
- package/dist/offline/index.js +7 -0
- package/dist/offline/indexed-db.d.ts +10 -0
- package/dist/offline/indexed-db.js +104 -0
- package/dist/offline/management-types.d.ts +25 -0
- package/dist/offline/management-types.js +1 -0
- package/dist/offline/outbox.d.ts +7 -0
- package/dist/offline/outbox.js +46 -0
- package/dist/offline/replay.d.ts +25 -0
- package/dist/offline/replay.js +192 -0
- package/dist/offline/secure-store.d.ts +6 -0
- package/dist/offline/secure-store.js +51 -0
- package/dist/offline/types.d.ts +162 -0
- package/dist/offline/types.js +1 -0
- package/dist/orm-json-scalars.d.ts +21 -0
- package/dist/orm-json-scalars.js +21 -0
- package/dist/runtime-contracts.d.ts +7 -7
- package/dist/runtime-contracts.js +1 -1
- package/dist/sdk-orm.d.ts +7 -1
- package/dist/sdk-orm.js +12 -5
- package/dist/sdk.d.ts +134 -1
- package/dist/server/api-client.d.ts +26 -0
- package/dist/server/api-client.js +38 -0
- package/dist/server/broker-contract.d.ts +191 -5
- package/dist/server/broker-contract.js +142 -1
- package/dist/server/client.d.ts +82 -47
- package/dist/server/client.js +138 -59
- package/dist/server/connection-retry.d.ts +12 -0
- package/dist/server/connection-retry.js +40 -0
- package/dist/server/errors.d.ts +61 -0
- package/dist/server/errors.js +89 -0
- 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/managed-server-path.d.ts +6 -0
- package/dist/server/managed-server-path.js +14 -0
- package/dist/server/principal.d.ts +48 -0
- package/dist/server/principal.js +39 -0
- package/dist/server/processes.d.ts +290 -0
- package/dist/server/processes.js +56 -0
- package/dist/server/request-context.d.ts +121 -3
- package/dist/server/request-context.js +111 -33
- package/dist/server/testing.d.ts +40 -0
- package/dist/server/testing.js +86 -0
- package/dist/server.d.ts +13 -4
- package/dist/server.js +6 -2
- package/dist/source-resolver.d.ts +9 -2
- package/dist/source-resolver.js +55 -8
- package/dist/templates/nextjs/AGENTS.md +10 -0
- package/dist/templates/nextjs/CLAUDE.md +1 -0
- package/dist/templates/nextjs/README.md +88 -0
- package/dist/templates/nextjs/package.json +3 -1
- package/dist/templates/nextjs/tests/server.test.mjs +25 -0
- package/dist/templates/react-vite/AGENTS.md +8 -88
- package/dist/templates/react-vite/CLAUDE.md +0 -6
- package/dist/templates/react-vite/package.json +1 -1
- package/dist/templates/react-vite/src/App.tsx +37 -14
- package/dist/templates/react-vite/src/styles.css +10 -0
- package/package.json +15 -3
- package/templates/nextjs/AGENTS.md +10 -0
- package/templates/nextjs/CLAUDE.md +1 -0
- package/templates/nextjs/README.md +88 -0
- package/templates/nextjs/package.json +3 -1
- package/templates/nextjs/tests/server.test.mjs +25 -0
- package/templates/react-vite/AGENTS.md +8 -88
- package/templates/react-vite/CLAUDE.md +0 -6
- package/templates/react-vite/package.json +1 -1
- package/templates/react-vite/src/App.tsx +37 -14
- package/templates/react-vite/src/styles.css +10 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export declare const INSUFFICIENT_USER_AUTHENTICATION = "insufficient_user_authentication";
|
|
2
|
+
/**
|
|
3
|
+
* What Atlas's error envelope said about a refusal, when the response carried one. Atlas routes
|
|
4
|
+
* answer `{ detail, code? }`: `detail` is the human-readable reason, `code` the route's machine
|
|
5
|
+
* code when it has one (`RUN_TERMINAL`, `EDGE_ID_MISMATCH`, `WORKFLOW_NOT_RUNNING`, …), so an
|
|
6
|
+
* application can branch on a refusal the way a caller of the route itself would.
|
|
7
|
+
*/
|
|
8
|
+
export interface ArtifactAtlasRefusal {
|
|
9
|
+
code: string | null;
|
|
10
|
+
detail: string | null;
|
|
11
|
+
}
|
|
12
|
+
export interface ArtifactServerErrorDetails {
|
|
13
|
+
reason?: string;
|
|
14
|
+
maxAge?: number;
|
|
15
|
+
atlas?: ArtifactAtlasRefusal;
|
|
16
|
+
}
|
|
17
|
+
export declare class ArtifactServerError extends Error {
|
|
18
|
+
readonly status: number;
|
|
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;
|
|
24
|
+
/** Atlas's own account of the refusal; both null when the response had no readable envelope. */
|
|
25
|
+
readonly 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');
|
|
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>;
|
|
39
|
+
/**
|
|
40
|
+
* A refused broker call, keeping what Atlas said: the message carries the route's `detail`
|
|
41
|
+
* and `error.atlas` its machine `code`, so a consumer can branch on a Lattice refusal
|
|
42
|
+
* (`EDGE_ID_MISMATCH`, `WORKFLOW_NOT_RUNNING`, …) the way a caller of the route itself would.
|
|
43
|
+
*/
|
|
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 }: {
|
|
54
|
+
response: Response;
|
|
55
|
+
operation: string;
|
|
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;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** Errors the server SDK raises, and the step-up (RFC 9470) mapping a handler returns. */
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
export const INSUFFICIENT_USER_AUTHENTICATION = 'insufficient_user_authentication';
|
|
4
|
+
const NO_REFUSAL = { code: null, detail: null };
|
|
5
|
+
export class ArtifactServerError extends Error {
|
|
6
|
+
status;
|
|
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;
|
|
12
|
+
/** Atlas's own account of the refusal; both null when the response had no readable envelope. */
|
|
13
|
+
atlas;
|
|
14
|
+
constructor(status, code, message, details = {}) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.status = status;
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.name = 'ArtifactServerError';
|
|
19
|
+
this.reason = details.reason;
|
|
20
|
+
this.maxAge = details.maxAge;
|
|
21
|
+
this.atlas = details.atlas ?? NO_REFUSAL;
|
|
22
|
+
}
|
|
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
|
+
}
|
|
51
|
+
/** Atlas answers a refusal as `{ detail, code? }`; anything else (a proxy page, an empty body) reads as no envelope. */
|
|
52
|
+
const atlasRefusalSchema = z.object({ detail: z.string().optional(), code: z.string().optional() });
|
|
53
|
+
/**
|
|
54
|
+
* A refused broker call, keeping what Atlas said: the message carries the route's `detail`
|
|
55
|
+
* and `error.atlas` its machine `code`, so a consumer can branch on a Lattice refusal
|
|
56
|
+
* (`EDGE_ID_MISMATCH`, `WORKFLOW_NOT_RUNNING`, …) the way a caller of the route itself would.
|
|
57
|
+
*/
|
|
58
|
+
export function platformRequestError({ status, body, operation }) {
|
|
59
|
+
const envelope = atlasRefusalSchema.safeParse(body);
|
|
60
|
+
const detail = envelope.success ? envelope.data.detail ?? null : null;
|
|
61
|
+
const code = envelope.success ? envelope.data.code ?? null : null;
|
|
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
|
+
});
|
|
89
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type ArtifactGatewayPublicKeys, type RequestContextV2External } from './request-context.js';
|
|
2
|
+
/** The ingresses (manifest `server.ingress` names) whose external callers a handler accepts. */
|
|
3
|
+
export interface ArtifactExternalAccept {
|
|
4
|
+
external: readonly string[];
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Verifies an external (v2) request context for a handler that explicitly accepts external
|
|
8
|
+
* callers. Unlike the v1 verifier, `method` and `path` are mandatory: an external context is
|
|
9
|
+
* bound to the one route it was signed for. Refuses a v1 context (`external_context_required`)
|
|
10
|
+
* and a context from an ingress the handler did not name (`external_ingress_not_accepted`).
|
|
11
|
+
*/
|
|
12
|
+
export declare function verifyArtifactExternalContext({ token, publicKeys, projectId, environment, method, path, accept, now }: {
|
|
13
|
+
token: string | null;
|
|
14
|
+
publicKeys: ArtifactGatewayPublicKeys;
|
|
15
|
+
projectId: string;
|
|
16
|
+
environment: string;
|
|
17
|
+
method: string;
|
|
18
|
+
path: string;
|
|
19
|
+
accept: ArtifactExternalAccept;
|
|
20
|
+
now?: number;
|
|
21
|
+
}): RequestContextV2External;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { ArtifactContextError } from './request-context.js';
|
|
2
|
+
import { verifyAnyArtifactContext } from './internal/verify-any.js';
|
|
3
|
+
/**
|
|
4
|
+
* Verifies an external (v2) request context for a handler that explicitly accepts external
|
|
5
|
+
* callers. Unlike the v1 verifier, `method` and `path` are mandatory: an external context is
|
|
6
|
+
* bound to the one route it was signed for. Refuses a v1 context (`external_context_required`)
|
|
7
|
+
* and a context from an ingress the handler did not name (`external_ingress_not_accepted`).
|
|
8
|
+
*/
|
|
9
|
+
export function verifyArtifactExternalContext({ token, publicKeys, projectId, environment, method, path, accept, now }) {
|
|
10
|
+
if (!method || !path)
|
|
11
|
+
throw new ArtifactContextError();
|
|
12
|
+
// TODO(C1): canonicalizeIngressPath(path) at the path comparison in internal/verify-any.ts, which the broker shares.
|
|
13
|
+
const context = verifyAnyArtifactContext({ token, publicKeys, projectId, environment, method, path, now });
|
|
14
|
+
if (context.v !== 2)
|
|
15
|
+
throw new ArtifactContextError('external_context_required');
|
|
16
|
+
if (!accept.external.includes(context.principal.ingress))
|
|
17
|
+
throw new ArtifactContextError('external_ingress_not_accepted');
|
|
18
|
+
return context;
|
|
19
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Kids of keys that sign external (v2) contexts carry this prefix, and only those kids do. */
|
|
2
|
+
export declare const ARTIFACT_EXTERNAL_CONTEXT_KID_PREFIX = "external-";
|
|
3
|
+
/**
|
|
4
|
+
* True when the token names an external key or claims the external version. Unverified: only the
|
|
5
|
+
* SDK's own refusal and verifier dispatch may use it, never a trust decision, so it stays internal.
|
|
6
|
+
*/
|
|
7
|
+
export declare function isExternalContextToken(token: string | null): boolean;
|
|
8
|
+
/** Verifies the EdDSA signature against the pinned key set and returns the still-untyped payload. */
|
|
9
|
+
export declare function verifySignedContext({ token, publicKeys }: {
|
|
10
|
+
token: string | null;
|
|
11
|
+
publicKeys: Readonly<Record<string, string>>;
|
|
12
|
+
}): {
|
|
13
|
+
kid: string;
|
|
14
|
+
payload: unknown;
|
|
15
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createPublicKey, verify } from 'node:crypto';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
/**
|
|
4
|
+
* Signature handling shared by every Artifact context verifier. It decides nothing about which
|
|
5
|
+
* context versions a caller accepts: the public v1 verifier, the external verifier and the
|
|
6
|
+
* broker's dispatching verifier each apply their own version and kid rules on top of it.
|
|
7
|
+
*
|
|
8
|
+
* Lives under `server/internal/`, which the package exports map does not expose.
|
|
9
|
+
*/
|
|
10
|
+
const MAX_CONTEXT_TOKEN_LENGTH = 32768;
|
|
11
|
+
/** Kids of keys that sign external (v2) contexts carry this prefix, and only those kids do. */
|
|
12
|
+
export const ARTIFACT_EXTERNAL_CONTEXT_KID_PREFIX = 'external-';
|
|
13
|
+
const headerSchema = z.object({ alg: z.literal('EdDSA'), typ: z.literal('JWT'), kid: z.string().min(1).max(255) }).strict();
|
|
14
|
+
class SignedContextError extends Error {
|
|
15
|
+
constructor() { super('Invalid signed Artifact context'); this.name = 'SignedContextError'; }
|
|
16
|
+
}
|
|
17
|
+
function decodeSegment(value) {
|
|
18
|
+
if (!/^[A-Za-z0-9_-]+$/.test(value))
|
|
19
|
+
throw new SignedContextError();
|
|
20
|
+
return JSON.parse(Buffer.from(value, 'base64url').toString('utf8'));
|
|
21
|
+
}
|
|
22
|
+
function splitToken(token) {
|
|
23
|
+
if (!token || token.length > MAX_CONTEXT_TOKEN_LENGTH)
|
|
24
|
+
throw new SignedContextError();
|
|
25
|
+
const parts = token.split('.');
|
|
26
|
+
if (parts.length !== 3)
|
|
27
|
+
throw new SignedContextError();
|
|
28
|
+
const [header, payload, signature] = parts;
|
|
29
|
+
return [header, payload, signature];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Reads the kid and the version claim WITHOUT verifying the signature. Use it only to refuse
|
|
33
|
+
* early or to choose which verifier runs; never to trust a claim. Null when the token is malformed.
|
|
34
|
+
*/
|
|
35
|
+
function peekSignedContext(token) {
|
|
36
|
+
try {
|
|
37
|
+
const [encodedHeader, encodedPayload] = splitToken(token);
|
|
38
|
+
const header = headerSchema.parse(decodeSegment(encodedHeader));
|
|
39
|
+
const payload = decodeSegment(encodedPayload);
|
|
40
|
+
const v = payload !== null && typeof payload === 'object' && 'v' in payload ? payload.v : undefined;
|
|
41
|
+
return { kid: header.kid, v };
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* True when the token names an external key or claims the external version. Unverified: only the
|
|
49
|
+
* SDK's own refusal and verifier dispatch may use it, never a trust decision, so it stays internal.
|
|
50
|
+
*/
|
|
51
|
+
export function isExternalContextToken(token) {
|
|
52
|
+
const peeked = peekSignedContext(token);
|
|
53
|
+
return peeked !== null && (peeked.kid.startsWith(ARTIFACT_EXTERNAL_CONTEXT_KID_PREFIX) || peeked.v === 2);
|
|
54
|
+
}
|
|
55
|
+
/** Verifies the EdDSA signature against the pinned key set and returns the still-untyped payload. */
|
|
56
|
+
export function verifySignedContext({ token, publicKeys }) {
|
|
57
|
+
const [encodedHeader, encodedPayload, signature] = splitToken(token);
|
|
58
|
+
const header = headerSchema.parse(decodeSegment(encodedHeader));
|
|
59
|
+
const pem = Object.hasOwn(publicKeys, header.kid) ? publicKeys[header.kid] : undefined;
|
|
60
|
+
if (!pem || !/^[A-Za-z0-9_-]+$/.test(signature))
|
|
61
|
+
throw new SignedContextError();
|
|
62
|
+
const key = createPublicKey(pem);
|
|
63
|
+
if (key.asymmetricKeyType !== 'ed25519' ||
|
|
64
|
+
!verify(null, Buffer.from(`${encodedHeader}.${encodedPayload}`), key, Buffer.from(signature, 'base64url')))
|
|
65
|
+
throw new SignedContextError();
|
|
66
|
+
return { kid: header.kid, payload: decodeSegment(encodedPayload) };
|
|
67
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type ArtifactRequestContext } from '../request-context.js';
|
|
2
|
+
/**
|
|
3
|
+
* The v1 rules that apply once a signature has been verified and the caller has applied its own
|
|
4
|
+
* kid and version rules: schema, then the project, environment, clock and route bindings. Every
|
|
5
|
+
* failure is an ArtifactContextError. Internal: it trusts `payload`, so it must only ever see a
|
|
6
|
+
* payload whose signature was just verified.
|
|
7
|
+
*
|
|
8
|
+
* Shared by the public v1 verifier and the broker's dispatching verifier, so each applies its own
|
|
9
|
+
* gate in front of it and pays for one signature check.
|
|
10
|
+
*/
|
|
11
|
+
export declare function parseV1Context({ payload, projectId, environment, method, path, now }: {
|
|
12
|
+
payload: unknown;
|
|
13
|
+
projectId: string;
|
|
14
|
+
environment: string;
|
|
15
|
+
method?: string;
|
|
16
|
+
path?: string;
|
|
17
|
+
now: number;
|
|
18
|
+
}): ArtifactRequestContext;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { ArtifactContextError, artifactRequestContextSchema } from '../request-context.js';
|
|
2
|
+
/**
|
|
3
|
+
* The v1 rules that apply once a signature has been verified and the caller has applied its own
|
|
4
|
+
* kid and version rules: schema, then the project, environment, clock and route bindings. Every
|
|
5
|
+
* failure is an ArtifactContextError. Internal: it trusts `payload`, so it must only ever see a
|
|
6
|
+
* payload whose signature was just verified.
|
|
7
|
+
*
|
|
8
|
+
* Shared by the public v1 verifier and the broker's dispatching verifier, so each applies its own
|
|
9
|
+
* gate in front of it and pays for one signature check.
|
|
10
|
+
*/
|
|
11
|
+
export function parseV1Context({ payload, projectId, environment, method, path, now }) {
|
|
12
|
+
const parsed = artifactRequestContextSchema.safeParse(payload);
|
|
13
|
+
if (!parsed.success)
|
|
14
|
+
throw new ArtifactContextError();
|
|
15
|
+
const context = parsed.data;
|
|
16
|
+
if (context.projectId !== projectId || context.environment !== environment ||
|
|
17
|
+
context.exp <= now || context.iat > now + 5 ||
|
|
18
|
+
(method !== undefined && context.method !== method.toUpperCase()) || (path !== undefined && context.path !== path))
|
|
19
|
+
throw new ArtifactContextError();
|
|
20
|
+
Object.freeze(context.bridge);
|
|
21
|
+
Object.freeze(context.lattice);
|
|
22
|
+
Object.freeze(context.capabilities);
|
|
23
|
+
if (context.taskRoutes)
|
|
24
|
+
Object.freeze(context.taskRoutes);
|
|
25
|
+
return Object.freeze(context);
|
|
26
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type ArtifactGatewayPublicKeys, type ArtifactRequestContext, type RequestContextV2External } from '../request-context.js';
|
|
2
|
+
export type AnyArtifactRequestContext = ArtifactRequestContext | RequestContextV2External;
|
|
3
|
+
/**
|
|
4
|
+
* Version-dispatching verifier for platform code that must accept both Atlas-user (v1) and
|
|
5
|
+
* external (v2) contexts, such as the broker. Applications never get this: it is not exported
|
|
6
|
+
* from `./server` or `./server/context`, and the exports map does not expose `server/internal`.
|
|
7
|
+
*
|
|
8
|
+
* Kid and version are paired both ways: an `external-` kid must sign a v2 context, and a v2
|
|
9
|
+
* context must be signed by an `external-` kid. This pairing is the only thing standing between
|
|
10
|
+
* an `external-` kid and the v1 rules: the v1 branch goes straight to the shared v1 parser, not
|
|
11
|
+
* through the public verifier's own external gate. A v2 context in `development` mode is refused
|
|
12
|
+
* until development leases can admit it. The signature is checked once per call.
|
|
13
|
+
*/
|
|
14
|
+
export declare function verifyAnyArtifactContext({ token, publicKeys, projectId, environment, method, path, now }: {
|
|
15
|
+
token: string | null;
|
|
16
|
+
publicKeys: ArtifactGatewayPublicKeys;
|
|
17
|
+
projectId: string;
|
|
18
|
+
environment: string;
|
|
19
|
+
method?: string;
|
|
20
|
+
path?: string;
|
|
21
|
+
now?: number;
|
|
22
|
+
}): AnyArtifactRequestContext;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { ArtifactContextError, requestContextV2ExternalSchema, } from '../request-context.js';
|
|
2
|
+
import { ARTIFACT_EXTERNAL_CONTEXT_KID_PREFIX, verifySignedContext } from './signed-token.js';
|
|
3
|
+
import { parseV1Context } from './v1-context.js';
|
|
4
|
+
/**
|
|
5
|
+
* Version-dispatching verifier for platform code that must accept both Atlas-user (v1) and
|
|
6
|
+
* external (v2) contexts, such as the broker. Applications never get this: it is not exported
|
|
7
|
+
* from `./server` or `./server/context`, and the exports map does not expose `server/internal`.
|
|
8
|
+
*
|
|
9
|
+
* Kid and version are paired both ways: an `external-` kid must sign a v2 context, and a v2
|
|
10
|
+
* context must be signed by an `external-` kid. This pairing is the only thing standing between
|
|
11
|
+
* an `external-` kid and the v1 rules: the v1 branch goes straight to the shared v1 parser, not
|
|
12
|
+
* through the public verifier's own external gate. A v2 context in `development` mode is refused
|
|
13
|
+
* until development leases can admit it. The signature is checked once per call.
|
|
14
|
+
*/
|
|
15
|
+
export function verifyAnyArtifactContext({ token, publicKeys, projectId, environment, method, path, now = Math.floor(Date.now() / 1000) }) {
|
|
16
|
+
let kid;
|
|
17
|
+
let payload;
|
|
18
|
+
try {
|
|
19
|
+
({ kid, payload } = verifySignedContext({ token, publicKeys }));
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
throw new ArtifactContextError();
|
|
23
|
+
}
|
|
24
|
+
const external = kid.startsWith(ARTIFACT_EXTERNAL_CONTEXT_KID_PREFIX);
|
|
25
|
+
const claimsV2 = payload !== null && typeof payload === 'object' && 'v' in payload && payload.v === 2;
|
|
26
|
+
if (external !== claimsV2)
|
|
27
|
+
throw new ArtifactContextError();
|
|
28
|
+
if (!claimsV2)
|
|
29
|
+
return parseV1Context({ payload, projectId, environment, method, path, now });
|
|
30
|
+
const parsed = requestContextV2ExternalSchema.safeParse(payload);
|
|
31
|
+
if (!parsed.success)
|
|
32
|
+
throw new ArtifactContextError();
|
|
33
|
+
const context = parsed.data;
|
|
34
|
+
if (context.mode !== 'published')
|
|
35
|
+
throw new ArtifactContextError('external_mode_not_accepted');
|
|
36
|
+
if (context.projectId !== projectId || context.environment !== environment ||
|
|
37
|
+
context.exp <= now || context.iat > now + 5 ||
|
|
38
|
+
// TODO(C1): canonicalizeIngressPath(path) at this comparison (external-context.ts passes the raw route path).
|
|
39
|
+
(method !== undefined && context.method !== method.toUpperCase()) || (path !== undefined && context.path !== path))
|
|
40
|
+
throw new ArtifactContextError();
|
|
41
|
+
return freezeExternalContext(context);
|
|
42
|
+
}
|
|
43
|
+
function freezeExternalContext(context) {
|
|
44
|
+
const { principal } = context;
|
|
45
|
+
Object.freeze(principal.pass);
|
|
46
|
+
Object.freeze(principal.client);
|
|
47
|
+
if (principal.amr)
|
|
48
|
+
Object.freeze(principal.amr);
|
|
49
|
+
Object.freeze(principal);
|
|
50
|
+
Object.freeze(context.crit);
|
|
51
|
+
Object.freeze(context.ext);
|
|
52
|
+
return Object.freeze(context);
|
|
53
|
+
}
|
package/dist/server/logger.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ export interface ArtifactLogRecord extends ArtifactLogMetadata {
|
|
|
17
17
|
}
|
|
18
18
|
/** Structured events deliberately exclude request bodies, URLs, arbitrary messages and error objects. */
|
|
19
19
|
export declare function createArtifactLogger({ context, sink }: {
|
|
20
|
-
context: ArtifactRequestContext
|
|
20
|
+
context: Pick<ArtifactRequestContext, 'projectId' | 'requestId' | 'deploymentId' | 'devSessionId'>;
|
|
21
21
|
sink?: (record: ArtifactLogRecord) => void;
|
|
22
22
|
}): {
|
|
23
23
|
info: (event: string, metadata?: ArtifactLogMetadata) => void;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exact origin-relative path a Lattice `managed_server` node may POST.
|
|
3
|
+
* No wildcards, query strings, or traversal. Keep this identical to
|
|
4
|
+
* `atlas/lattice/src/define/managed-server-path.ts`.
|
|
5
|
+
*/
|
|
6
|
+
export declare function isManagedServerInvokePath(path: string): boolean;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exact origin-relative path a Lattice `managed_server` node may POST.
|
|
3
|
+
* No wildcards, query strings, or traversal. Keep this identical to
|
|
4
|
+
* `atlas/lattice/src/define/managed-server-path.ts`.
|
|
5
|
+
*/
|
|
6
|
+
export function isManagedServerInvokePath(path) {
|
|
7
|
+
if (path.length < 2 || path.length > 512 || !path.startsWith('/') || path.includes('//')) {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
if (/[?#\\]/.test(path))
|
|
11
|
+
return false;
|
|
12
|
+
const segments = path.slice(1).split('/');
|
|
13
|
+
return segments.every((segment) => segment !== '.' && segment !== '..' && /^[A-Za-z0-9._~-]+$/.test(segment));
|
|
14
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type ArtifactRequestContext, type RequestContextV2External } from './request-context.js';
|
|
2
|
+
/** A caller admitted through a manifest-declared ingress, as the gateway verified it. */
|
|
3
|
+
export interface ArtifactExternalPrincipal {
|
|
4
|
+
readonly kind: 'external';
|
|
5
|
+
/** Routing label of the ingress that admitted the caller. Not an authorization attribute. */
|
|
6
|
+
readonly ingress: string;
|
|
7
|
+
readonly provider: string;
|
|
8
|
+
readonly issuer: string;
|
|
9
|
+
/** The identity provider's raw subject (not `ext|`-namespaced). */
|
|
10
|
+
readonly subject: string;
|
|
11
|
+
/** Normalized and verified. */
|
|
12
|
+
readonly email: string;
|
|
13
|
+
readonly emailVerified: true;
|
|
14
|
+
readonly authTime: number | null;
|
|
15
|
+
readonly amr: readonly string[] | null;
|
|
16
|
+
readonly tokenIssuedAt: number;
|
|
17
|
+
readonly client: {
|
|
18
|
+
readonly ip: string;
|
|
19
|
+
/** Observed, not proven: the browser chooses it. */
|
|
20
|
+
readonly userAgent: string;
|
|
21
|
+
readonly userAgentSha256: string;
|
|
22
|
+
readonly origin: string | null;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/** Who this request acts for. Only `external` callers come through an ingress. */
|
|
26
|
+
export type ArtifactPrincipal = {
|
|
27
|
+
readonly kind: 'atlas_user';
|
|
28
|
+
readonly sub: string;
|
|
29
|
+
} | {
|
|
30
|
+
readonly kind: 'platform_service';
|
|
31
|
+
readonly sub: string;
|
|
32
|
+
} | ArtifactExternalPrincipal;
|
|
33
|
+
export declare function principalFromContext(context: ArtifactRequestContext | RequestContextV2External): ArtifactPrincipal;
|
|
34
|
+
/** The evidence variables an external caller's verified sign-in supplies. */
|
|
35
|
+
export interface ArtifactExternalEvidence {
|
|
36
|
+
authSubject: string;
|
|
37
|
+
actorEmail: string;
|
|
38
|
+
authTime: number | null;
|
|
39
|
+
authMethods: readonly string[] | null;
|
|
40
|
+
ipAddress: string;
|
|
41
|
+
userAgent: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The sanctioned mapping from a verified external principal to evidence variables. On
|
|
45
|
+
* manifest-declared evidence operations the broker overwrites (or refuses) these from the same
|
|
46
|
+
* principal, so a mistake here cannot misattribute evidence there.
|
|
47
|
+
*/
|
|
48
|
+
export declare function externalEvidence(principal: ArtifactPrincipal): ArtifactExternalEvidence;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { ARTIFACT_SERVICE_ACCOUNT_SUB_PREFIX } from './request-context.js';
|
|
2
|
+
export function principalFromContext(context) {
|
|
3
|
+
if (context.v === 1) {
|
|
4
|
+
return Object.freeze(context.sub.startsWith(ARTIFACT_SERVICE_ACCOUNT_SUB_PREFIX)
|
|
5
|
+
? { kind: 'platform_service', sub: context.sub }
|
|
6
|
+
: { kind: 'atlas_user', sub: context.sub });
|
|
7
|
+
}
|
|
8
|
+
const { principal } = context;
|
|
9
|
+
return Object.freeze({
|
|
10
|
+
kind: 'external',
|
|
11
|
+
ingress: principal.ingress,
|
|
12
|
+
provider: principal.provider,
|
|
13
|
+
issuer: principal.issuer,
|
|
14
|
+
subject: principal.subject,
|
|
15
|
+
email: principal.email,
|
|
16
|
+
emailVerified: principal.emailVerified,
|
|
17
|
+
authTime: principal.authTime,
|
|
18
|
+
amr: principal.amr,
|
|
19
|
+
tokenIssuedAt: principal.tokenIssuedAt,
|
|
20
|
+
client: Object.freeze({ ...principal.client }),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The sanctioned mapping from a verified external principal to evidence variables. On
|
|
25
|
+
* manifest-declared evidence operations the broker overwrites (or refuses) these from the same
|
|
26
|
+
* principal, so a mistake here cannot misattribute evidence there.
|
|
27
|
+
*/
|
|
28
|
+
export function externalEvidence(principal) {
|
|
29
|
+
if (principal.kind !== 'external')
|
|
30
|
+
throw new TypeError('externalEvidence requires an external principal');
|
|
31
|
+
return {
|
|
32
|
+
authSubject: principal.subject,
|
|
33
|
+
actorEmail: principal.email,
|
|
34
|
+
authTime: principal.authTime,
|
|
35
|
+
authMethods: principal.amr,
|
|
36
|
+
ipAddress: principal.client.ip,
|
|
37
|
+
userAgent: principal.client.userAgent,
|
|
38
|
+
};
|
|
39
|
+
}
|