@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.
- package/README.md +43 -0
- package/VERSION-PIN.md +27 -0
- package/dist/cli.js +1 -1
- package/dist/manifest-next-ingress.d.ts +213 -0
- package/dist/manifest-next-ingress.js +187 -0
- package/dist/manifest-next-reach.d.ts +40 -0
- package/dist/manifest-next-reach.js +48 -0
- package/dist/manifest-next.d.ts +133 -8
- package/dist/manifest-next.js +7 -46
- package/dist/manifest-static.d.ts +6 -0
- package/dist/manifest-static.js +10 -0
- package/dist/manifest.d.ts +67 -0
- package/dist/manifest.js +7 -0
- 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 +27 -0
- package/dist/server/api-client.js +39 -0
- package/dist/server/broker-contract.d.ts +33 -6
- package/dist/server/broker-contract.js +20 -2
- package/dist/server/client.d.ts +32 -45
- package/dist/server/client.js +72 -101
- 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/ingress-control-contract.d.ts +272 -0
- package/dist/server/ingress-control-contract.js +196 -0
- package/dist/server/ingress-routes.d.ts +76 -0
- package/dist/server/ingress-routes.js +150 -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 +61 -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/processes.d.ts +56 -0
- package/dist/server/processes.js +13 -0
- package/dist/server/request-context.d.ts +121 -5
- package/dist/server/request-context.js +105 -34
- package/dist/server/runtime-config.d.ts +1 -1
- 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/README.md +9 -0
- 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/dist/tunnel/protocol.js +2 -4
- package/dist/tunnel/reserved-headers.d.ts +7 -0
- package/dist/tunnel/reserved-headers.js +20 -0
- package/package.json +19 -3
- package/templates/nextjs/README.md +9 -0
- package/templates/nextjs/package.json +3 -1
- package/templates/nextjs/tests/server.test.mjs +25 -0
- package/templates/react-vite/package.json +1 -1
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { isManagedServerInvokePath } from './managed-server-path.js';
|
|
2
|
+
/**
|
|
3
|
+
* Inbound route grammar for an Artifact Next external ingress (design §(a) "Paths").
|
|
4
|
+
*
|
|
5
|
+
* One module owns the three steps every component must agree on: the gateway canonicalises the
|
|
6
|
+
* raw request target and matches it, Atlas re-matches the signed path on the active manifest, and
|
|
7
|
+
* the SDK binds the context to the route it serves. The static wildcard matcher
|
|
8
|
+
* (`manifest-static.ts`) is deliberately not used: it prefix-matches, and a route must not.
|
|
9
|
+
*/
|
|
10
|
+
export const INGRESS_ROUTE_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
11
|
+
/** Longest raw request target the canonicaliser looks at, the signed context path's own cap. */
|
|
12
|
+
export const MAX_INGRESS_PATH_LENGTH = 8192;
|
|
13
|
+
const MAX_TEMPLATE_LENGTH = 512;
|
|
14
|
+
/** A request-time placeholder value: one segment, never `.` or `..`, no encoding. */
|
|
15
|
+
const PLACEHOLDER_VALUE = /^(?!\.{1,2}$)[A-Za-z0-9._~-]{1,128}$/;
|
|
16
|
+
const PLACEHOLDER_NAME = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
|
|
17
|
+
// Characters the signed context path refuses, or that a request target can only carry by mistake.
|
|
18
|
+
const FORBIDDEN_TARGET_CHARACTERS = /[\\#\s\x00-\x1f\x7f]/;
|
|
19
|
+
/**
|
|
20
|
+
* Canonicalises a raw origin-form request target (`/path?query`). Returns `null`, so the caller
|
|
21
|
+
* answers 404, when the pathname is not already canonical: it differs from what the URL parser
|
|
22
|
+
* makes of it, contains an empty, `.` or `..` segment (a trailing slash is an empty segment), or
|
|
23
|
+
* contains `%`; or when the query is not already in the form the URL parser serializes it to.
|
|
24
|
+
* The query string is never used for matching and is returned verbatim.
|
|
25
|
+
*/
|
|
26
|
+
export function canonicalizeIngressPath(raw) {
|
|
27
|
+
if (typeof raw !== 'string' || raw.length === 0 || raw.length > MAX_INGRESS_PATH_LENGTH)
|
|
28
|
+
return null;
|
|
29
|
+
if (FORBIDDEN_TARGET_CHARACTERS.test(raw))
|
|
30
|
+
return null;
|
|
31
|
+
const separator = raw.indexOf('?');
|
|
32
|
+
const path = separator === -1 ? raw : raw.slice(0, separator);
|
|
33
|
+
const query = separator === -1 ? '' : raw.slice(separator + 1);
|
|
34
|
+
if (!path.startsWith('/') || path.startsWith('//') || path.includes('%'))
|
|
35
|
+
return null;
|
|
36
|
+
let url;
|
|
37
|
+
try {
|
|
38
|
+
url = new URL(raw, 'http://ingress.invalid');
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
if (url.pathname !== path)
|
|
44
|
+
return null;
|
|
45
|
+
// A handler sees only the URL parser's serialization of the query (NextRequest builds a URL), so a
|
|
46
|
+
// raw query the parser would re-encode yields a signed path the handler can never reproduce.
|
|
47
|
+
if (url.search.replace(/^\?/, '') !== query)
|
|
48
|
+
return null;
|
|
49
|
+
if (!path.slice(1).split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..'))
|
|
50
|
+
return null;
|
|
51
|
+
return { path, query };
|
|
52
|
+
}
|
|
53
|
+
/** The single string the gateway signs, Atlas re-matches and the SDK binds: path plus raw query. */
|
|
54
|
+
export function signedIngressPath({ path, query }) {
|
|
55
|
+
return query === '' ? path : `${path}?${query}`;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Parses a declared route template such as `/api/borrower/documents/{documentId}/notes`.
|
|
59
|
+
* Literal segments use the Lattice `managed_server` path grammar; a `{name}` placeholder is a
|
|
60
|
+
* whole segment. Returns `null` for anything else: no `/api/` prefix, a wildcard, `%`, a query,
|
|
61
|
+
* a trailing slash, an empty, `.` or `..` segment, or a repeated placeholder name.
|
|
62
|
+
*/
|
|
63
|
+
export function parseRouteTemplate(template) {
|
|
64
|
+
if (typeof template !== 'string' || template.length > MAX_TEMPLATE_LENGTH)
|
|
65
|
+
return null;
|
|
66
|
+
if (!template.startsWith('/api/') || /[*%?#]/.test(template))
|
|
67
|
+
return null;
|
|
68
|
+
const names = new Set();
|
|
69
|
+
const segments = [];
|
|
70
|
+
for (const segment of template.slice(1).split('/')) {
|
|
71
|
+
const placeholder = /^\{([^{}]*)\}$/.exec(segment);
|
|
72
|
+
if (placeholder) {
|
|
73
|
+
const name = placeholder[1];
|
|
74
|
+
if (!PLACEHOLDER_NAME.test(name) || names.has(name))
|
|
75
|
+
return null;
|
|
76
|
+
names.add(name);
|
|
77
|
+
segments.push({ kind: 'placeholder', name });
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
if (!isManagedServerInvokePath(`/${segment}`))
|
|
81
|
+
return null;
|
|
82
|
+
segments.push({ kind: 'literal', value: segment });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { template, segments };
|
|
86
|
+
}
|
|
87
|
+
export function ingressRouteId({ method, template }) {
|
|
88
|
+
return `${method} ${template}`;
|
|
89
|
+
}
|
|
90
|
+
function matchTemplate(parsed, parts) {
|
|
91
|
+
if (parsed.segments.length !== parts.length)
|
|
92
|
+
return null;
|
|
93
|
+
// No prototype: a lookup of a name the route does not declare is undefined, never an inherited function.
|
|
94
|
+
const params = Object.create(null);
|
|
95
|
+
for (const [index, segment] of parsed.segments.entries()) {
|
|
96
|
+
const part = parts[index];
|
|
97
|
+
if (segment.kind === 'literal') {
|
|
98
|
+
if (segment.value !== part)
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
if (!PLACEHOLDER_VALUE.test(part))
|
|
103
|
+
return null;
|
|
104
|
+
params[segment.name] = part;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return params;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Exact-matches a method and a canonical pathname (from `canonicalizeIngressPath(...).path`, never
|
|
111
|
+
* the query) against declared routes. Methods compare exactly. An unparseable declaration never
|
|
112
|
+
* matches, and a path that two declarations both match returns `null`: the manifest refuses
|
|
113
|
+
* overlapping routes, so ambiguity here means the declarations did not come from a valid manifest.
|
|
114
|
+
*/
|
|
115
|
+
export function matchIngressRoute(routes, method, canonicalPath) {
|
|
116
|
+
if (!INGRESS_ROUTE_METHODS.includes(method))
|
|
117
|
+
return null;
|
|
118
|
+
const canonical = canonicalizeIngressPath(canonicalPath);
|
|
119
|
+
if (!canonical || canonical.query !== '' || canonicalPath.includes('?'))
|
|
120
|
+
return null;
|
|
121
|
+
const parts = canonical.path.slice(1).split('/');
|
|
122
|
+
let found = null;
|
|
123
|
+
for (const route of routes) {
|
|
124
|
+
if (!route.methods.includes(method))
|
|
125
|
+
continue;
|
|
126
|
+
const parsed = parseRouteTemplate(route.path);
|
|
127
|
+
if (!parsed)
|
|
128
|
+
continue;
|
|
129
|
+
const params = matchTemplate(parsed, parts);
|
|
130
|
+
if (!params)
|
|
131
|
+
continue;
|
|
132
|
+
if (found)
|
|
133
|
+
return null;
|
|
134
|
+
found = { routeId: ingressRouteId({ method, template: route.path }), params: Object.freeze(params), route };
|
|
135
|
+
}
|
|
136
|
+
return found;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Whether one request path could match both templates: same segment count, and at every position
|
|
140
|
+
* either equal literals or at least one placeholder. Conservative: a placeholder is treated as
|
|
141
|
+
* matching any literal opposite it.
|
|
142
|
+
*/
|
|
143
|
+
export function ingressRouteTemplatesOverlap({ left, right }) {
|
|
144
|
+
if (left.segments.length !== right.segments.length)
|
|
145
|
+
return false;
|
|
146
|
+
return left.segments.every((segment, index) => {
|
|
147
|
+
const other = right.segments[index];
|
|
148
|
+
return segment.kind === 'placeholder' || other.kind === 'placeholder' || segment.value === other.value;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -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,61 @@
|
|
|
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
|
+
import { canonicalizeIngressPath, signedIngressPath } from '../ingress-routes.js';
|
|
5
|
+
/**
|
|
6
|
+
* Version-dispatching verifier for platform code that must accept both Atlas-user (v1) and
|
|
7
|
+
* external (v2) contexts, such as the broker. Applications never get this: it is not exported
|
|
8
|
+
* from `./server` or `./server/context`, and the exports map does not expose `server/internal`.
|
|
9
|
+
*
|
|
10
|
+
* Kid and version are paired both ways: an `external-` kid must sign a v2 context, and a v2
|
|
11
|
+
* context must be signed by an `external-` kid. This pairing is the only thing standing between
|
|
12
|
+
* an `external-` kid and the v1 rules: the v1 branch goes straight to the shared v1 parser, not
|
|
13
|
+
* through the public verifier's own external gate. A v2 context in `development` mode is refused
|
|
14
|
+
* until development leases can admit it. The signature is checked once per call.
|
|
15
|
+
*/
|
|
16
|
+
export function verifyAnyArtifactContext({ token, publicKeys, projectId, environment, method, path, now = Math.floor(Date.now() / 1000) }) {
|
|
17
|
+
let kid;
|
|
18
|
+
let payload;
|
|
19
|
+
try {
|
|
20
|
+
({ kid, payload } = verifySignedContext({ token, publicKeys }));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
throw new ArtifactContextError();
|
|
24
|
+
}
|
|
25
|
+
const external = kid.startsWith(ARTIFACT_EXTERNAL_CONTEXT_KID_PREFIX);
|
|
26
|
+
const claimsV2 = payload !== null && typeof payload === 'object' && 'v' in payload && payload.v === 2;
|
|
27
|
+
if (external !== claimsV2)
|
|
28
|
+
throw new ArtifactContextError();
|
|
29
|
+
if (!claimsV2)
|
|
30
|
+
return parseV1Context({ payload, projectId, environment, method, path, now });
|
|
31
|
+
const parsed = requestContextV2ExternalSchema.safeParse(payload);
|
|
32
|
+
if (!parsed.success)
|
|
33
|
+
throw new ArtifactContextError();
|
|
34
|
+
const context = parsed.data;
|
|
35
|
+
if (context.mode !== 'published')
|
|
36
|
+
throw new ArtifactContextError('external_mode_not_accepted');
|
|
37
|
+
// The gateway signs only a canonical path (plus its raw query); the caller's path is
|
|
38
|
+
// canonicalised the same way before comparing, so an encoded or dot-segment variant never binds.
|
|
39
|
+
if (signedPathOf(context.path) !== context.path)
|
|
40
|
+
throw new ArtifactContextError();
|
|
41
|
+
if (context.projectId !== projectId || context.environment !== environment ||
|
|
42
|
+
context.exp <= now || context.iat > now + 5 ||
|
|
43
|
+
(method !== undefined && context.method !== method.toUpperCase()) || (path !== undefined && context.path !== signedPathOf(path)))
|
|
44
|
+
throw new ArtifactContextError();
|
|
45
|
+
return freezeExternalContext(context);
|
|
46
|
+
}
|
|
47
|
+
function signedPathOf(raw) {
|
|
48
|
+
const canonical = canonicalizeIngressPath(raw);
|
|
49
|
+
return canonical && signedIngressPath(canonical);
|
|
50
|
+
}
|
|
51
|
+
function freezeExternalContext(context) {
|
|
52
|
+
const { principal } = context;
|
|
53
|
+
Object.freeze(principal.pass);
|
|
54
|
+
Object.freeze(principal.client);
|
|
55
|
+
if (principal.amr)
|
|
56
|
+
Object.freeze(principal.amr);
|
|
57
|
+
Object.freeze(principal);
|
|
58
|
+
Object.freeze(context.crit);
|
|
59
|
+
Object.freeze(context.ext);
|
|
60
|
+
return Object.freeze(context);
|
|
61
|
+
}
|
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,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
|
+
}
|
|
@@ -4,6 +4,11 @@ export interface ArtifactProcessStart {
|
|
|
4
4
|
process: string;
|
|
5
5
|
/** Idempotency key: starting the same run id again returns the existing run. */
|
|
6
6
|
runId?: string;
|
|
7
|
+
/**
|
|
8
|
+
* Enter the graph at this node in place of the process's start node, as `start_node_id` does on
|
|
9
|
+
* `POST /api/lattice/runs`; Atlas refuses a node the process does not have.
|
|
10
|
+
*/
|
|
11
|
+
startNodeId?: string;
|
|
7
12
|
title?: string;
|
|
8
13
|
entity?: {
|
|
9
14
|
type: string;
|
|
@@ -214,6 +219,51 @@ export interface ArtifactProcessAdvanceResult {
|
|
|
214
219
|
/** The step had already been completed with this decision; nothing was re-run. */
|
|
215
220
|
idempotent_replay: boolean;
|
|
216
221
|
}
|
|
222
|
+
/** The result of cancelling a run. Cancelling an already-cancelled run replays without a second intervention. */
|
|
223
|
+
export interface ArtifactProcessCancelResult {
|
|
224
|
+
/** The cancelled run, redacted as `getRun` returns it; null if Atlas could not reload it. */
|
|
225
|
+
run: ArtifactProcessRunRecord | null;
|
|
226
|
+
idempotent_replay: boolean;
|
|
227
|
+
}
|
|
228
|
+
/** Resetting a generating agent step: the step settles and the run re-parks at the node's reset edge. */
|
|
229
|
+
export interface ArtifactProcessResetStep {
|
|
230
|
+
runId: string;
|
|
231
|
+
stepId: string;
|
|
232
|
+
}
|
|
233
|
+
export interface ArtifactProcessResetStepResult {
|
|
234
|
+
run_id: string;
|
|
235
|
+
step_id: string;
|
|
236
|
+
node_id: string;
|
|
237
|
+
reset_edge_id: string;
|
|
238
|
+
resume_node_id: string;
|
|
239
|
+
/** The resume node was dispatched again. */
|
|
240
|
+
redispatched: boolean;
|
|
241
|
+
/** The run's status after the reset. */
|
|
242
|
+
status: ArtifactProcessRunStatus | null;
|
|
243
|
+
}
|
|
244
|
+
/** A page of a run's events after a cursor. */
|
|
245
|
+
export interface ArtifactProcessRunEventsQuery {
|
|
246
|
+
runId: string;
|
|
247
|
+
/** The `event_cursor` from `getRun`, or the `cursor` the previous page returned. Omitted reads from the run's first event. */
|
|
248
|
+
since?: string;
|
|
249
|
+
/** At most ARTIFACT_PROCESS_RUN_EVENTS_LIMIT; Lattice defaults to 500. */
|
|
250
|
+
limit?: number;
|
|
251
|
+
}
|
|
252
|
+
/** One event on a run, as the run's stream sends it. Payloads are redacted when the event is written. */
|
|
253
|
+
export interface ArtifactProcessRunEvent {
|
|
254
|
+
id: string;
|
|
255
|
+
run_id: string;
|
|
256
|
+
node_id: string | null;
|
|
257
|
+
event_type: string;
|
|
258
|
+
source: string;
|
|
259
|
+
payload: unknown;
|
|
260
|
+
created_at: string;
|
|
261
|
+
}
|
|
262
|
+
export interface ArtifactProcessRunEvents {
|
|
263
|
+
events: ArtifactProcessRunEvent[];
|
|
264
|
+
/** Pass as `since` on the next poll. Null only when the run has no events yet and no `since` was given. */
|
|
265
|
+
cursor: string | null;
|
|
266
|
+
}
|
|
217
267
|
/** Users (Auth0 `sub`) and chat groups (group id) to name in an assignment or a share. */
|
|
218
268
|
export interface ArtifactProcessPrincipals {
|
|
219
269
|
users?: string[];
|
|
@@ -274,6 +324,12 @@ export interface ArtifactServerProcesses {
|
|
|
274
324
|
advance(input: ArtifactProcessAdvance): Promise<ArtifactProcessAdvanceResult>;
|
|
275
325
|
/** Reassign a parked human step; the user's `owner` relation on the run decides. */
|
|
276
326
|
assign(input: ArtifactProcessAssign): Promise<ArtifactProcessAssignResult>;
|
|
327
|
+
/** Cancel a run of a process declared under `processes.act`; the user's `invoker` relation on the run decides. */
|
|
328
|
+
cancel(runId: string): Promise<ArtifactProcessCancelResult>;
|
|
329
|
+
/** Reset a generating agent step without cancelling its run; the user's `invoker` relation on the run decides. */
|
|
330
|
+
resetStep(input: ArtifactProcessResetStep): Promise<ArtifactProcessResetStepResult>;
|
|
331
|
+
/** A page of a run's events after a cursor, oldest first; poll with the returned cursor to see each event once. */
|
|
332
|
+
listEvents(query: ArtifactProcessRunEventsQuery): Promise<ArtifactProcessRunEvents>;
|
|
277
333
|
/** The explicit viewer grants on a run of a process declared under `processes.act`; owner-gated. */
|
|
278
334
|
listViewers(runId: string): Promise<ArtifactProcessRunViewers>;
|
|
279
335
|
/** Share a run with users and groups; owner-gated, additive, idempotent. */
|
package/dist/server/processes.js
CHANGED
|
@@ -36,6 +36,19 @@ export function createArtifactProcessesClient({ mode, json }) {
|
|
|
36
36
|
assign: async (input) => {
|
|
37
37
|
return await json(artifactServerRequestSchema.parse({ operation: 'lattice_assign', mode, ...input }));
|
|
38
38
|
},
|
|
39
|
+
cancel: async (runId) => {
|
|
40
|
+
return await json(artifactServerRequestSchema.parse({ operation: 'lattice_cancel', mode, runId }));
|
|
41
|
+
},
|
|
42
|
+
resetStep: async (input) => {
|
|
43
|
+
return await json(artifactServerRequestSchema.parse({ operation: 'lattice_reset_step', mode, ...input }));
|
|
44
|
+
},
|
|
45
|
+
listEvents: async (query) => {
|
|
46
|
+
const body = await json(artifactServerRequestSchema.parse({ operation: 'lattice_run_events', mode, ...query }));
|
|
47
|
+
if (!Array.isArray(body?.events) || !(typeof body.cursor === 'string' || body.cursor === null)) {
|
|
48
|
+
throw new ArtifactServerError(502, 'malformed_response', 'Atlas returned a malformed event page');
|
|
49
|
+
}
|
|
50
|
+
return body;
|
|
51
|
+
},
|
|
39
52
|
listViewers: async (runId) => {
|
|
40
53
|
return await json(artifactServerRequestSchema.parse({ operation: 'lattice_viewers', mode, runId }));
|
|
41
54
|
},
|