@sequenceholdings/artifact-studio 0.6.1 → 0.6.3
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 +7 -0
- 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/server/api-client.d.ts +1 -0
- package/dist/server/api-client.js +2 -1
- package/dist/server/broker-contract.d.ts +33 -6
- package/dist/server/broker-contract.js +20 -2
- package/dist/server/client.js +31 -16
- package/dist/server/external-context.js +1 -1
- 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/verify-any.d.ts +3 -1
- package/dist/server/internal/verify-any.js +13 -3
- package/dist/server/processes.d.ts +56 -0
- package/dist/server/processes.js +13 -0
- package/dist/server/request-context.d.ts +8 -4
- package/dist/server/request-context.js +8 -6
- package/dist/server/runtime-config.d.ts +1 -1
- package/dist/templates/nextjs/README.md +9 -0
- 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 +9 -1
- package/templates/nextjs/README.md +9 -0
|
@@ -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
|
+
}
|
|
@@ -3,7 +3,9 @@ export type AnyArtifactRequestContext = ArtifactRequestContext | RequestContextV
|
|
|
3
3
|
/**
|
|
4
4
|
* Version-dispatching verifier for platform code that must accept both Atlas-user (v1) and
|
|
5
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
|
|
6
|
+
* from `./server` or `./server/context`, and the exports map names no `./server/internal` subpath,
|
|
7
|
+
* so a package import of it fails to resolve. Atlas, the only consumer, reaches the built file
|
|
8
|
+
* through its own resolver aliases (tsconfig `paths`, Next and Vitest `resolve.alias`).
|
|
7
9
|
*
|
|
8
10
|
* Kid and version are paired both ways: an `external-` kid must sign a v2 context, and a v2
|
|
9
11
|
* context must be signed by an `external-` kid. This pairing is the only thing standing between
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { ArtifactContextError, requestContextV2ExternalSchema, } from '../request-context.js';
|
|
2
2
|
import { ARTIFACT_EXTERNAL_CONTEXT_KID_PREFIX, verifySignedContext } from './signed-token.js';
|
|
3
3
|
import { parseV1Context } from './v1-context.js';
|
|
4
|
+
import { canonicalizeIngressPath, signedIngressPath } from '../ingress-routes.js';
|
|
4
5
|
/**
|
|
5
6
|
* Version-dispatching verifier for platform code that must accept both Atlas-user (v1) and
|
|
6
7
|
* 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
|
|
8
|
+
* from `./server` or `./server/context`, and the exports map names no `./server/internal` subpath,
|
|
9
|
+
* so a package import of it fails to resolve. Atlas, the only consumer, reaches the built file
|
|
10
|
+
* through its own resolver aliases (tsconfig `paths`, Next and Vitest `resolve.alias`).
|
|
8
11
|
*
|
|
9
12
|
* Kid and version are paired both ways: an `external-` kid must sign a v2 context, and a v2
|
|
10
13
|
* context must be signed by an `external-` kid. This pairing is the only thing standing between
|
|
@@ -33,13 +36,20 @@ export function verifyAnyArtifactContext({ token, publicKeys, projectId, environ
|
|
|
33
36
|
const context = parsed.data;
|
|
34
37
|
if (context.mode !== 'published')
|
|
35
38
|
throw new ArtifactContextError('external_mode_not_accepted');
|
|
39
|
+
// The gateway signs only a canonical path (plus its raw query); the caller's path is
|
|
40
|
+
// canonicalised the same way before comparing, so an encoded or dot-segment variant never binds.
|
|
41
|
+
if (signedPathOf(context.path) !== context.path)
|
|
42
|
+
throw new ArtifactContextError();
|
|
36
43
|
if (context.projectId !== projectId || context.environment !== environment ||
|
|
37
44
|
context.exp <= now || context.iat > now + 5 ||
|
|
38
|
-
|
|
39
|
-
(method !== undefined && context.method !== method.toUpperCase()) || (path !== undefined && context.path !== path))
|
|
45
|
+
(method !== undefined && context.method !== method.toUpperCase()) || (path !== undefined && context.path !== signedPathOf(path)))
|
|
40
46
|
throw new ArtifactContextError();
|
|
41
47
|
return freezeExternalContext(context);
|
|
42
48
|
}
|
|
49
|
+
function signedPathOf(raw) {
|
|
50
|
+
const canonical = canonicalizeIngressPath(raw);
|
|
51
|
+
return canonical && signedIngressPath(canonical);
|
|
52
|
+
}
|
|
43
53
|
function freezeExternalContext(context) {
|
|
44
54
|
const { principal } = context;
|
|
45
55
|
Object.freeze(principal.pass);
|
|
@@ -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
|
},
|
|
@@ -11,6 +11,8 @@ export declare const ARTIFACT_CONTEXT_MAX_AGE_SECONDS = 60;
|
|
|
11
11
|
* window applies only when the signed context names a Lattice run.
|
|
12
12
|
*/
|
|
13
13
|
export declare const ARTIFACT_LATTICE_CONTEXT_MAX_AGE_SECONDS = 300;
|
|
14
|
+
/** An exact origin: HTTPS, or HTTP on a loopback host. */
|
|
15
|
+
export declare const artifactOriginSchema: z.ZodString;
|
|
14
16
|
/** Public request context. No bearer credentials or secret values belong here. */
|
|
15
17
|
export declare const artifactRequestContextSchema: z.ZodObject<{
|
|
16
18
|
v: z.ZodLiteral<1>;
|
|
@@ -31,10 +33,10 @@ export declare const artifactRequestContextSchema: z.ZodObject<{
|
|
|
31
33
|
method: z.ZodEnum<{
|
|
32
34
|
GET: "GET";
|
|
33
35
|
POST: "POST";
|
|
34
|
-
DELETE: "DELETE";
|
|
35
|
-
HEAD: "HEAD";
|
|
36
36
|
PUT: "PUT";
|
|
37
37
|
PATCH: "PATCH";
|
|
38
|
+
DELETE: "DELETE";
|
|
39
|
+
HEAD: "HEAD";
|
|
38
40
|
OPTIONS: "OPTIONS";
|
|
39
41
|
}>;
|
|
40
42
|
path: z.ZodString;
|
|
@@ -70,6 +72,8 @@ export { ARTIFACT_EXTERNAL_CONTEXT_KID_PREFIX };
|
|
|
70
72
|
export declare const ARTIFACT_SERVICE_ACCOUNT_SUB_PREFIX = "service-account|";
|
|
71
73
|
/** An external context's lifetime cap. External contexts never carry a Lattice run. */
|
|
72
74
|
export declare const ARTIFACT_EXTERNAL_CONTEXT_MAX_AGE_SECONDS = 60;
|
|
75
|
+
/** An IPv4 or IPv6 address, as the gateway observed it. The control contract reuses it. */
|
|
76
|
+
export declare const artifactIpAddressSchema: z.ZodUnion<readonly [z.ZodIPv4, z.ZodIPv6]>;
|
|
73
77
|
/** The external caller as the gateway verified it at pass exchange. Strict: see the v2 key policy below. */
|
|
74
78
|
export declare const artifactExternalPrincipalSchema: z.ZodObject<{
|
|
75
79
|
kind: z.ZodLiteral<"external">;
|
|
@@ -123,10 +127,10 @@ export declare const requestContextV2ExternalSchema: z.ZodObject<{
|
|
|
123
127
|
method: z.ZodEnum<{
|
|
124
128
|
GET: "GET";
|
|
125
129
|
POST: "POST";
|
|
126
|
-
DELETE: "DELETE";
|
|
127
|
-
HEAD: "HEAD";
|
|
128
130
|
PUT: "PUT";
|
|
129
131
|
PATCH: "PATCH";
|
|
132
|
+
DELETE: "DELETE";
|
|
133
|
+
HEAD: "HEAD";
|
|
130
134
|
OPTIONS: "OPTIONS";
|
|
131
135
|
}>;
|
|
132
136
|
path: z.ZodString;
|
|
@@ -13,7 +13,8 @@ export const ARTIFACT_CONTEXT_MAX_AGE_SECONDS = 60;
|
|
|
13
13
|
*/
|
|
14
14
|
export const ARTIFACT_LATTICE_CONTEXT_MAX_AGE_SECONDS = 300;
|
|
15
15
|
const id = z.string().min(1).max(255);
|
|
16
|
-
|
|
16
|
+
/** An exact origin: HTTPS, or HTTP on a loopback host. */
|
|
17
|
+
export const artifactOriginSchema = z.string().max(2048).refine(value => {
|
|
17
18
|
try {
|
|
18
19
|
const url = new URL(value);
|
|
19
20
|
return value === url.origin && (url.protocol === 'https:' ||
|
|
@@ -46,7 +47,7 @@ export const artifactRequestContextSchema = z.object({
|
|
|
46
47
|
exp: z.number().int().positive(),
|
|
47
48
|
capabilities: z.array(id).max(256),
|
|
48
49
|
serviceAccountId: id.nullable(),
|
|
49
|
-
bridge: z.object({ parentOrigin:
|
|
50
|
+
bridge: z.object({ parentOrigin: artifactOriginSchema, channelId: id, nonce: z.string().min(16).max(255) }).strict(),
|
|
50
51
|
theme: z.enum(['light', 'dark', 'system']),
|
|
51
52
|
lattice: z.object({ runId: id.nullable(), nodeId: id.nullable(), stepId: id.nullable(), processId: id.nullable() }).strict(),
|
|
52
53
|
taskRoutes: z.array(z.string().min(1).max(512)).max(16).refine(routes => routes.join('').length <= 1024).optional(),
|
|
@@ -69,7 +70,8 @@ export { ARTIFACT_EXTERNAL_CONTEXT_KID_PREFIX };
|
|
|
69
70
|
export const ARTIFACT_SERVICE_ACCOUNT_SUB_PREFIX = 'service-account|';
|
|
70
71
|
/** An external context's lifetime cap. External contexts never carry a Lattice run. */
|
|
71
72
|
export const ARTIFACT_EXTERNAL_CONTEXT_MAX_AGE_SECONDS = 60;
|
|
72
|
-
|
|
73
|
+
/** An IPv4 or IPv6 address, as the gateway observed it. The control contract reuses it. */
|
|
74
|
+
export const artifactIpAddressSchema = z.union([z.ipv4(), z.ipv6()]);
|
|
73
75
|
/** Signed by the gateway after it strips control characters and truncates; refused here otherwise. */
|
|
74
76
|
const observedUserAgent = z.string().max(512).refine(value => !/[\u0000-\u001f\u007f]/.test(value), 'Expected a user agent without control characters');
|
|
75
77
|
/** The external caller as the gateway verified it at pass exchange. Strict: see the v2 key policy below. */
|
|
@@ -87,12 +89,12 @@ export const artifactExternalPrincipalSchema = z.object({
|
|
|
87
89
|
authTime: z.number().int().nonnegative().nullable(),
|
|
88
90
|
amr: z.array(z.string().min(1).max(64)).max(16).nullable(),
|
|
89
91
|
tokenIssuedAt: z.number().int().nonnegative(),
|
|
90
|
-
pass: z.object({ id: z.uuid(), kid: id, issuedAt: z.number().int().nonnegative(), exchangeIp:
|
|
92
|
+
pass: z.object({ id: z.uuid(), kid: id, issuedAt: z.number().int().nonnegative(), exchangeIp: artifactIpAddressSchema }).strict(),
|
|
91
93
|
client: z.object({
|
|
92
|
-
ip:
|
|
94
|
+
ip: artifactIpAddressSchema,
|
|
93
95
|
userAgent: observedUserAgent,
|
|
94
96
|
userAgentSha256: z.string().regex(/^[0-9a-f]{64}$/),
|
|
95
|
-
origin:
|
|
97
|
+
origin: artifactOriginSchema.nullable(),
|
|
96
98
|
}).strict(),
|
|
97
99
|
}).strict();
|
|
98
100
|
/**
|
|
@@ -32,7 +32,7 @@ export declare function getArtifactRequestContext({ headers, runtime, method, pa
|
|
|
32
32
|
devSessionId: string | null;
|
|
33
33
|
requestId: string;
|
|
34
34
|
jti: string;
|
|
35
|
-
method: "GET" | "POST" | "
|
|
35
|
+
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
|
|
36
36
|
path: string;
|
|
37
37
|
iat: number;
|
|
38
38
|
exp: number;
|
|
@@ -97,6 +97,11 @@ only runs that user may view, `getRun` needs Viewer on the run and returns
|
|
|
97
97
|
every viewer — keep the data your pages need in your own ORM namespace. Reads
|
|
98
98
|
work from pages and GET route handlers; starting a run does not.
|
|
99
99
|
|
|
100
|
+
To follow a run as it moves, poll `processes.listEvents({ runId, since, limit })`
|
|
101
|
+
with the `event_cursor` from `getRun`, then with the `cursor` each page returns:
|
|
102
|
+
every event arrives once, oldest first, in the shape the run's stream sends. It
|
|
103
|
+
needs Viewer on the run, like `getRun`.
|
|
104
|
+
|
|
100
105
|
The same `processes.read` declaration lets you render the process itself.
|
|
101
106
|
`processes.describe({ process, version })` returns the applied template a run
|
|
102
107
|
is filled in from — `{ id, name, version, new_runs_disabled, start_node_id,
|
|
@@ -128,6 +133,10 @@ groups })` reassigns a parked step (its advancers become exactly those
|
|
|
128
133
|
principals) and `processes.grantViewers` / `processes.revokeViewers({ runId,
|
|
129
134
|
users, groups })` share a run or take a share back; all three need the user to
|
|
130
135
|
own the run, and `processes.listViewers(runId)` shows the run's stored shares.
|
|
136
|
+
`processes.cancel(runId)` cancels a run, and `processes.resetStep({ runId,
|
|
137
|
+
stepId })` stops a generating agent step without cancelling its run (the run
|
|
138
|
+
re-parks at the node's reset edge); both need the user's `invoker` relation on
|
|
139
|
+
the run, as the Lattice routes do.
|
|
131
140
|
Users are Auth0 subs and groups are chat-group ids — take them from
|
|
132
141
|
`processes.searchPrincipals({ q, types, limit })`, which finds people in the
|
|
133
142
|
user's own tenant and groups they can see, as the Lattice picker does. The
|
package/dist/tunnel/protocol.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { isLoopbackHostname } from '../loopback.js';
|
|
4
|
+
import { isPrivateRequestHeader } from './reserved-headers.js';
|
|
4
5
|
/** Version 1 is negotiated by the tunnel connection, not by individual streams. */
|
|
5
6
|
export const TUNNEL_PROTOCOL = 'artifact-tunnel.v1';
|
|
6
7
|
export const DEFAULT_TUNNEL_LIMITS = Object.freeze({
|
|
@@ -143,10 +144,7 @@ function removePrivateHeaders(headers) {
|
|
|
143
144
|
const nominated = new Set((Array.isArray(connection) ? connection.join(',') : connection ?? '')
|
|
144
145
|
.split(',').map((name) => name.trim().toLowerCase()));
|
|
145
146
|
for (const name of headers.keys()) {
|
|
146
|
-
if (HOP_HEADERS.has(name) || nominated.has(name) || name
|
|
147
|
-
name === 'authorization' || name === 'remote-user' || name === 'x-remote-user' ||
|
|
148
|
-
name === 'x-serverless-authorization' || name === 'x-goog-iap-jwt-assertion' ||
|
|
149
|
-
/^(?:x-forwarded-|x-sequence-|x-artifact-|x-atlas-|x-user-|x-auth-|x-authenticated-|x-goog-authenticated-|proxy-)/.test(name))
|
|
147
|
+
if (HOP_HEADERS.has(name) || nominated.has(name) || isPrivateRequestHeader(name))
|
|
150
148
|
headers.delete(name);
|
|
151
149
|
}
|
|
152
150
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Lowercase header name the proxy strips from every request it forwards to an app. */
|
|
2
|
+
export declare function isPrivateRequestHeader(name: string): boolean;
|
|
3
|
+
/**
|
|
4
|
+
* Client-address headers an external ingress drops: on an ingress the client address comes only
|
|
5
|
+
* from the edge-verified `CF-Connecting-IP`, never from a header the caller can set.
|
|
6
|
+
*/
|
|
7
|
+
export declare const INGRESS_CLIENT_ADDRESS_HEADERS: readonly string[];
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request headers the platform owns. The proxy strips them before a request reaches app code
|
|
3
|
+
* (`removePrivateHeaders` in `protocol.ts`), so a manifest may never declare one as an app header.
|
|
4
|
+
* Dependency-free: browser code that parses manifests imports it.
|
|
5
|
+
*/
|
|
6
|
+
const PRIVATE_REQUEST_HEADER_NAMES = new Set([
|
|
7
|
+
'forwarded', 'authorization', 'remote-user', 'x-remote-user', 'x-serverless-authorization', 'x-goog-iap-jwt-assertion',
|
|
8
|
+
]);
|
|
9
|
+
const PRIVATE_REQUEST_HEADER_PREFIX = /^(?:x-forwarded-|x-sequence-|x-artifact-|x-atlas-|x-user-|x-auth-|x-authenticated-|x-goog-authenticated-|proxy-)/;
|
|
10
|
+
/** Lowercase header name the proxy strips from every request it forwards to an app. */
|
|
11
|
+
export function isPrivateRequestHeader(name) {
|
|
12
|
+
return PRIVATE_REQUEST_HEADER_NAMES.has(name) || PRIVATE_REQUEST_HEADER_PREFIX.test(name);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Client-address headers an external ingress drops: on an ingress the client address comes only
|
|
16
|
+
* from the edge-verified `CF-Connecting-IP`, never from a header the caller can set.
|
|
17
|
+
*/
|
|
18
|
+
export const INGRESS_CLIENT_ADDRESS_HEADERS = Object.freeze([
|
|
19
|
+
'true-client-ip', 'x-real-ip', 'x-client-ip', 'client-ip', 'x-cluster-client-ip',
|
|
20
|
+
]);
|
package/package.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"publishConfig": {
|
|
10
10
|
"access": "public"
|
|
11
11
|
},
|
|
12
|
-
"version": "0.6.
|
|
12
|
+
"version": "0.6.3",
|
|
13
13
|
"description": "SDK types and CLI library for building Artifact Studio apps. Driven via `seq-studio artifact <sub>` (@sequenceholdings/studio-cli), which runs the `./cli` runCli export.",
|
|
14
14
|
"type": "module",
|
|
15
15
|
"exports": {
|
|
@@ -101,6 +101,14 @@
|
|
|
101
101
|
"types": "./dist/server/broker-contract.d.ts",
|
|
102
102
|
"default": "./dist/server/broker-contract.js"
|
|
103
103
|
},
|
|
104
|
+
"./server/ingress-control-contract": {
|
|
105
|
+
"types": "./dist/server/ingress-control-contract.d.ts",
|
|
106
|
+
"default": "./dist/server/ingress-control-contract.js"
|
|
107
|
+
},
|
|
108
|
+
"./server/ingress-routes": {
|
|
109
|
+
"types": "./dist/server/ingress-routes.d.ts",
|
|
110
|
+
"default": "./dist/server/ingress-routes.js"
|
|
111
|
+
},
|
|
104
112
|
"./server/release-forms": {
|
|
105
113
|
"types": "./dist/server/release-forms.d.ts",
|
|
106
114
|
"default": "./dist/server/release-forms.js"
|
|
@@ -97,6 +97,11 @@ only runs that user may view, `getRun` needs Viewer on the run and returns
|
|
|
97
97
|
every viewer — keep the data your pages need in your own ORM namespace. Reads
|
|
98
98
|
work from pages and GET route handlers; starting a run does not.
|
|
99
99
|
|
|
100
|
+
To follow a run as it moves, poll `processes.listEvents({ runId, since, limit })`
|
|
101
|
+
with the `event_cursor` from `getRun`, then with the `cursor` each page returns:
|
|
102
|
+
every event arrives once, oldest first, in the shape the run's stream sends. It
|
|
103
|
+
needs Viewer on the run, like `getRun`.
|
|
104
|
+
|
|
100
105
|
The same `processes.read` declaration lets you render the process itself.
|
|
101
106
|
`processes.describe({ process, version })` returns the applied template a run
|
|
102
107
|
is filled in from — `{ id, name, version, new_runs_disabled, start_node_id,
|
|
@@ -128,6 +133,10 @@ groups })` reassigns a parked step (its advancers become exactly those
|
|
|
128
133
|
principals) and `processes.grantViewers` / `processes.revokeViewers({ runId,
|
|
129
134
|
users, groups })` share a run or take a share back; all three need the user to
|
|
130
135
|
own the run, and `processes.listViewers(runId)` shows the run's stored shares.
|
|
136
|
+
`processes.cancel(runId)` cancels a run, and `processes.resetStep({ runId,
|
|
137
|
+
stepId })` stops a generating agent step without cancelling its run (the run
|
|
138
|
+
re-parks at the node's reset edge); both need the user's `invoker` relation on
|
|
139
|
+
the run, as the Lattice routes do.
|
|
131
140
|
Users are Auth0 subs and groups are chat-group ids — take them from
|
|
132
141
|
`processes.searchPrincipals({ q, types, limit })`, which finds people in the
|
|
133
142
|
user's own tenant and groups they can see, as the Lattice picker does. The
|