@volter/world-core 3.0.4 → 3.0.6
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/dist/src/derived-core.d.ts +23 -0
- package/dist/src/derived-real.js +69 -7
- package/dist/src/executor.d.ts +12 -3
- package/dist/src/executor.js +18 -6
- package/dist/src/index.d.ts +1 -1
- package/dist/src/remote-execute.d.ts +6 -0
- package/package.json +1 -1
- package/src/derived-core.ts +24 -0
- package/src/derived-real.ts +59 -7
- package/src/executor.ts +20 -7
- package/src/index.ts +1 -1
- package/src/remote-execute.ts +6 -0
|
@@ -296,6 +296,21 @@ export type ScreenDecl = {
|
|
|
296
296
|
/** the vendor's documentation of the round trip or the screen */
|
|
297
297
|
source: string;
|
|
298
298
|
};
|
|
299
|
+
/** A protocol hook's arguments: the root's executor (it applies the sealed credential; `issued` carries one the vendor
|
|
300
|
+
* issued instead), the group and its first entry's recorded input, the perform's context, the derived perform of an
|
|
301
|
+
* input (`send`: the recorded request, or one the hook changed), the stored id the World's id of a type resolves to,
|
|
302
|
+
* the surface's base path, and the World's blobs of this twin. */
|
|
303
|
+
export type PerformHookArgs = {
|
|
304
|
+
execute: import('./remote-execute.js').RemoteExecute;
|
|
305
|
+
group: import('./actions.js').TwinAction[];
|
|
306
|
+
input: Record<string, unknown>;
|
|
307
|
+
ctx: import('./head.js').PerformContext;
|
|
308
|
+
send: (input: Record<string, unknown>) => Promise<import('./head.js').PushOutcome>;
|
|
309
|
+
resolve: (type: string, localId: string) => string;
|
|
310
|
+
basePath: string;
|
|
311
|
+
blob: (key: string) => Promise<Uint8Array | null>;
|
|
312
|
+
};
|
|
313
|
+
export type PerformHook = (args: PerformHookArgs) => Promise<import('./head.js').PushOutcome>;
|
|
299
314
|
export type DerivedManifest = {
|
|
300
315
|
/** The pack's descriptor, as data (docs/contributing/architecture.md, "The descriptor"), every field but `vendor`, which
|
|
301
316
|
* is the manifest's: the pack registers `packOf(manifest)`, so its index is a fixed file. A lane has none. */
|
|
@@ -384,6 +399,14 @@ export type DerivedManifest = {
|
|
|
384
399
|
vendorBacked?: {
|
|
385
400
|
none: string;
|
|
386
401
|
};
|
|
402
|
+
/** the resource the vendor's paths name the account by (Cloudflare's `accounts/{account_id}`): with a root, the
|
|
403
|
+
* World's account is the one account the root's credential reaches, which refresh observes ("The account a root
|
|
404
|
+
* is", architecture) */
|
|
405
|
+
account?: string;
|
|
406
|
+
/** a vendor protocol one recorded request cannot express, by the operation that ends it: a session whose answer
|
|
407
|
+
* issues the credential its next calls carry (Cloudflare's assets upload), performed by the hook in place of the
|
|
408
|
+
* derived request, which it composes (`send`) ("Protocols a request cannot express", architecture) */
|
|
409
|
+
performs?: Record<string, PerformHook>;
|
|
387
410
|
/** `withParam`: the error names the path parameter that held the unknown id */
|
|
388
411
|
notFound: {
|
|
389
412
|
status: number;
|
package/dist/src/derived-real.js
CHANGED
|
@@ -77,7 +77,9 @@ function resolvedDeep(value, resolve) {
|
|
|
77
77
|
const o = object(value);
|
|
78
78
|
return o ? Object.fromEntries(Object.entries(o).map(([k, v]) => [k, resolvedDeep(v, resolve)])) : value;
|
|
79
79
|
}
|
|
80
|
-
/** A multipart body: the recorded fields, then each recorded file part read back from the World's blob store.
|
|
80
|
+
/** A multipart body: the recorded fields, then each recorded file part read back from the World's blob store. An
|
|
81
|
+
* operation the vendor takes as multipart is sent so with no file part too (a Workers upload of assets alone carries
|
|
82
|
+
* only its `metadata` field). */
|
|
81
83
|
async function multipart(body, files, ctx) {
|
|
82
84
|
const boundary = `----volter${hashFieldValue({ body, files }).slice(0, 24)}`;
|
|
83
85
|
const chunks = [];
|
|
@@ -122,10 +124,37 @@ function unitOf(units, input) {
|
|
|
122
124
|
?? units.find((u) => u.surface.operations.some((o) => o.id === id))
|
|
123
125
|
?? (input.graphql ? units[0] : undefined);
|
|
124
126
|
}
|
|
127
|
+
/** The account a root is, before a perform sends a path naming the World's ("The account a root is"): the World's
|
|
128
|
+
* account its own doors made, unadopted, adopts the one account the root's credential lists. Read once per deploy (an
|
|
129
|
+
* adopted account is not asked again); a list the vendor refuses, or one naming several accounts, adopts none. */
|
|
130
|
+
async function adoptAccount(units, execute, ctx) {
|
|
131
|
+
if (ctx.service === undefined)
|
|
132
|
+
return;
|
|
133
|
+
for (const u of units) {
|
|
134
|
+
const m = u.manifest;
|
|
135
|
+
const decl = m.account ? m.resources[m.account] : undefined;
|
|
136
|
+
if (!m.account || !decl)
|
|
137
|
+
continue;
|
|
138
|
+
const type = storedType(m, m.account);
|
|
139
|
+
const local = readTree(ctx.service, ctx.root).filter((r) => r.type === type && r.deleted !== true && ctx.resolve(type, String(r.id)) === String(r.id));
|
|
140
|
+
const scope = decl.refresh;
|
|
141
|
+
const op = scope && 'list' in scope ? u.surface.operations.find((o) => o.id === scope.list) : undefined;
|
|
142
|
+
if (!local.length || !op)
|
|
143
|
+
continue;
|
|
144
|
+
const answer = await execute({ method: op.method.toUpperCase(), path: `${op.basePath ?? u.surface.basePath ?? ''}${op.path}`, ...(u.lane ? { lane: u.lane } : {}) });
|
|
145
|
+
if (answer.status >= 400)
|
|
146
|
+
continue;
|
|
147
|
+
const items = itemsOf(op, parse(answer));
|
|
148
|
+
const id = items.length === 1 ? storedIdOf(decl, items[0]) : undefined;
|
|
149
|
+
if (id === undefined || local.some((l) => String(l.id) === id))
|
|
150
|
+
continue;
|
|
151
|
+
observeResources(ctx.service, [{ type, id, fields: items[0], adopts: String(local[0].id) }], ctx.root !== undefined ? { root: ctx.root } : {});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
125
154
|
function performOf(units) {
|
|
126
|
-
|
|
155
|
+
// the request a group's first entry recorded (or one a protocol hook changed), sent to the vendor
|
|
156
|
+
const derived = async (execute, group, ctx, input) => {
|
|
127
157
|
const first = group[0];
|
|
128
|
-
const input = (first.input ?? {});
|
|
129
158
|
const unit = unitOf(units, input);
|
|
130
159
|
if (!unit)
|
|
131
160
|
throw new RefusedWriteError('vendor', `the write records no operation of the vendor's surface (${String(input.operationId ?? 'none')})`, first.id);
|
|
@@ -183,12 +212,12 @@ function performOf(units) {
|
|
|
183
212
|
const asCame = object(input.body);
|
|
184
213
|
const raw = asCame && typeof asCame.$text === 'string' ? { body: asCame.$text } : asCame && typeof asCame.$base64 === 'string' ? { body: new Uint8Array(Buffer.from(asCame.$base64, 'base64')) } : undefined;
|
|
185
214
|
const sent = raw ? { ...raw, ...(typeof input.contentType === 'string' ? { type: input.contentType } : {}) }
|
|
186
|
-
: files.length ? await multipart(body, files, ctx) : op ? encode(op, body) : { body: JSON.stringify(body), type: 'application/json' };
|
|
215
|
+
: files.length || op?.bodyEncoding === 'multipart' ? await multipart(body, files, ctx) : op ? encode(op, body) : { body: JSON.stringify(body), type: 'application/json' };
|
|
187
216
|
const headers = { ...(object(input.headers) ?? {}), ...(sent.type ? { 'content-type': sent.type } : {}) };
|
|
188
217
|
// a retried deploy makes nothing twice, and two Worlds deploying into one account never collide
|
|
189
218
|
if (m.idempotency)
|
|
190
219
|
headers[m.idempotency.header] = hashFieldValue({ credential: ctx.credential ?? '', entry: first.id });
|
|
191
|
-
const answer = await execute({ method, path, headers, ...(sent.body !== undefined ? { body: sent.body } : {}) });
|
|
220
|
+
const answer = await execute({ method, path, headers, ...(sent.body !== undefined ? { body: sent.body } : {}), ...(unit.lane ? { lane: unit.lane } : {}) });
|
|
192
221
|
const parsed = parse(answer);
|
|
193
222
|
const shape = refusalShape(m);
|
|
194
223
|
const refused = answer.status >= 400 || (shape.length > 0 && shape.every(([p, v]) => at(parsed, p) === v));
|
|
@@ -228,6 +257,23 @@ function performOf(units) {
|
|
|
228
257
|
}
|
|
229
258
|
return { externalId, actionId: primary.id, data: obj, ...(also.length ? { also } : {}) };
|
|
230
259
|
};
|
|
260
|
+
return async (execute, group, ctx) => {
|
|
261
|
+
await adoptAccount(units, execute, ctx);
|
|
262
|
+
const input = (group[0].input ?? {});
|
|
263
|
+
const unit = unitOf(units, input);
|
|
264
|
+
// a protocol one request cannot express is the pack's hook, which composes the derived perform ("Protocols a
|
|
265
|
+
// request cannot express")
|
|
266
|
+
const hook = unit && typeof input.operationId === 'string' ? unit.manifest.performs?.[input.operationId] : undefined;
|
|
267
|
+
if (!hook || !unit)
|
|
268
|
+
return derived(execute, group, ctx, input);
|
|
269
|
+
return hook({
|
|
270
|
+
execute, group, input, ctx,
|
|
271
|
+
send: (changed) => derived(execute, group, ctx, changed),
|
|
272
|
+
resolve: (type, localId) => ctx.resolve(type, localId),
|
|
273
|
+
basePath: unit.surface.basePath ?? '',
|
|
274
|
+
blob: (key) => readResourceBlob(ctx.service ?? '', key, ctx.root),
|
|
275
|
+
});
|
|
276
|
+
};
|
|
231
277
|
}
|
|
232
278
|
/** Path parameters that name a subject by a field rather than its id, sent as the vendor's value of that field, read
|
|
233
279
|
* from the adopted landed copy ("Perform", step 1): a parent found by `parent.where` (a repository addressed as
|
|
@@ -279,7 +325,8 @@ function refreshOf(units, service) {
|
|
|
279
325
|
return async (execute, opts) => {
|
|
280
326
|
const observed = [];
|
|
281
327
|
const complete = [];
|
|
282
|
-
for (const
|
|
328
|
+
for (const unit of units) {
|
|
329
|
+
const { manifest: m, surface } = unit;
|
|
283
330
|
// parents before children, however deep
|
|
284
331
|
const scoped = Object.entries(m.resources).filter(([, d]) => d.refresh && !('none' in d.refresh));
|
|
285
332
|
const depth = (r, seen = new Set()) => { const p = m.resources[r]?.parent?.resource; return p && !seen.has(p) ? 1 + depth(p, seen.add(r)) : 0; };
|
|
@@ -301,7 +348,7 @@ function refreshOf(units, service) {
|
|
|
301
348
|
// a parent the operation names in its query, not its path (Slack's conversations.history `channel`)
|
|
302
349
|
const byQuery = decl.parent?.param && parent !== undefined && !op.path.includes(`{${decl.parent.param}}`) ? { [decl.parent.param]: String(parent) } : {};
|
|
303
350
|
const q = new URLSearchParams({ ...page, ...byQuery });
|
|
304
|
-
const answer = await execute({ method: op.method.toUpperCase(), path: [...q].length ? `${path}?${q}` : path });
|
|
351
|
+
const answer = await execute({ method: op.method.toUpperCase(), path: [...q].length ? `${path}?${q}` : path, ...(unit.lane ? { lane: unit.lane } : {}) });
|
|
305
352
|
if (answer.status === 401 || answer.status === 403)
|
|
306
353
|
throw new Error(`${answer.status}: the vendor refused the root's credential; nothing folded`);
|
|
307
354
|
if (answer.status === 429)
|
|
@@ -400,6 +447,21 @@ function adopt(service, root, units, observed) {
|
|
|
400
447
|
list.push({ id: e.subject.id, fields: held.fields });
|
|
401
448
|
pending.set(e.subject.type, list);
|
|
402
449
|
}
|
|
450
|
+
// the account a root is ("The account a root is"): the root's credential reaches one account of the type the pack's
|
|
451
|
+
// paths name it by; observed alone, it is the World's own account (made by the World's door, so no deploy settled it)
|
|
452
|
+
for (const u of units) {
|
|
453
|
+
if (!u.manifest.account)
|
|
454
|
+
continue;
|
|
455
|
+
const type = storedType(u.manifest, u.manifest.account);
|
|
456
|
+
const seen = observed.filter((o) => o.type === type);
|
|
457
|
+
if (seen.length !== 1 || tree.has(`${type}:${seen[0].id}`))
|
|
458
|
+
continue;
|
|
459
|
+
const local = readTree(service, root).find((r) => r.type === type && r.deleted !== true && !aliases.has(`${type}:${String(r.id)}`) && String(r.id) !== seen[0].id);
|
|
460
|
+
if (local) {
|
|
461
|
+
seen[0].adopts = String(local.id);
|
|
462
|
+
aliases.set(`${type}:${String(local.id)}`, seen[0].id);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
403
465
|
if (!pending.size)
|
|
404
466
|
return;
|
|
405
467
|
const declOf = (type) => {
|
package/dist/src/executor.d.ts
CHANGED
|
@@ -12,8 +12,14 @@ import type { HostRule } from './packRegistry.js';
|
|
|
12
12
|
* and no sealed credential migrates.
|
|
13
13
|
*/
|
|
14
14
|
export type TwinAuthStrategy =
|
|
15
|
-
/**
|
|
15
|
+
/** A vendor of lanes whose lanes authenticate apart (Cloudflare's API by bearer, its R2 by SigV4): a lane named takes
|
|
16
|
+
* its own strategy, every other call the sealed headers; the request names its lane (`RemoteExecuteRequest.lane`). */
|
|
16
17
|
{
|
|
18
|
+
in: 'lanes';
|
|
19
|
+
lanes: Record<string, TwinAuthStrategy>;
|
|
20
|
+
}
|
|
21
|
+
/** the key rides in the query string (`?key=…`, `?appid=…`) — no header will do */
|
|
22
|
+
| {
|
|
17
23
|
in: 'query';
|
|
18
24
|
name: string;
|
|
19
25
|
}
|
|
@@ -23,7 +29,10 @@ export type TwinAuthStrategy =
|
|
|
23
29
|
| {
|
|
24
30
|
in: 'signature';
|
|
25
31
|
algorithm: 'aws-sigv4';
|
|
26
|
-
scope:
|
|
32
|
+
scope: {
|
|
33
|
+
region: string;
|
|
34
|
+
service: string;
|
|
35
|
+
} | ((req: {
|
|
27
36
|
method: string;
|
|
28
37
|
path: string;
|
|
29
38
|
host: string;
|
|
@@ -32,7 +41,7 @@ export type TwinAuthStrategy =
|
|
|
32
41
|
}) => {
|
|
33
42
|
region: string;
|
|
34
43
|
service: string;
|
|
35
|
-
};
|
|
44
|
+
});
|
|
36
45
|
}
|
|
37
46
|
/** a signature computed PER REQUEST over bytes only the pack can canonicalize */
|
|
38
47
|
| {
|
package/dist/src/executor.js
CHANGED
|
@@ -130,6 +130,14 @@ async function awsSigV4Headers(input) {
|
|
|
130
130
|
};
|
|
131
131
|
}
|
|
132
132
|
export function buildRemoteExecute(origin, credential, auth, vendorHosts, options = {}) {
|
|
133
|
+
if (auth?.in === 'lanes') {
|
|
134
|
+
// each lane's strategy over the same origin and credential, the budget charged once, around them all
|
|
135
|
+
const own = { ...options, vendor: undefined };
|
|
136
|
+
const byLane = new Map(Object.entries(auth.lanes).map(([lane, strategy]) => [lane, buildRemoteExecute(origin, credential, strategy, vendorHosts, own)]));
|
|
137
|
+
const headers = buildRemoteExecute(origin, credential, undefined, vendorHosts, own);
|
|
138
|
+
const execute = (request) => (byLane.get(request.lane ?? '') ?? headers)(request);
|
|
139
|
+
return options.vendor === undefined ? execute : budgeted(options.vendor, execute, options.custody?.key ?? JSON.stringify(credential.headers));
|
|
140
|
+
}
|
|
133
141
|
const execute = auth?.in === 'exchange' ? exchangeExecute(origin, credential, auth, vendorHosts, options.custody) : direct(origin, credential, auth, vendorHosts);
|
|
134
142
|
return options.vendor === undefined ? execute : budgeted(options.vendor, execute, options.custody?.key ?? JSON.stringify(credential.headers));
|
|
135
143
|
}
|
|
@@ -148,7 +156,7 @@ function budgeted(vendor, execute, token) {
|
|
|
148
156
|
}
|
|
149
157
|
/** The call made as the root's credential is applied by the strategy (header replacement when none). */
|
|
150
158
|
function direct(origin, credential, auth, vendorHosts) {
|
|
151
|
-
return async ({ method, path, headers, body, responseType, presigned }) => {
|
|
159
|
+
return async ({ method, path, headers, body, responseType, presigned, issued }) => {
|
|
152
160
|
// A PRESIGNED UPLOAD URL the vendor returned: its own signature is its authorization, so the
|
|
153
161
|
// sealed credential never goes with it, whatever host it names. A host other than the root's
|
|
154
162
|
// passes the same validation the root does; the root's own origin (a twin standing in for the
|
|
@@ -205,8 +213,12 @@ function direct(origin, credential, auth, vendorHosts) {
|
|
|
205
213
|
throw new Error('remote execute: path escaped the validated origin');
|
|
206
214
|
}
|
|
207
215
|
const outbound = new Headers(headers ?? {});
|
|
208
|
-
|
|
209
|
-
|
|
216
|
+
// a credential the vendor issued in this perform is the request's authorization; the sealed one stays home
|
|
217
|
+
if (issued !== undefined)
|
|
218
|
+
outbound.set('authorization', issued);
|
|
219
|
+
else
|
|
220
|
+
for (const [name, value] of Object.entries(credential.headers))
|
|
221
|
+
outbound.set(name, value);
|
|
210
222
|
// ── THE STRATEGIES BEYOND HEADER REPLACEMENT ──────────────────────────────────────────────
|
|
211
223
|
// Applied AFTER the anchoring check above, so nothing here can move the request off the
|
|
212
224
|
// validated origin: `searchParams.set` only ever touches the query, and a header is a header.
|
|
@@ -214,7 +226,7 @@ function direct(origin, credential, auth, vendorHosts) {
|
|
|
214
226
|
// FAIL CLOSED. A strategy with no secret must never send a request that merely LOOKS
|
|
215
227
|
// authenticated — that silence is the whole defect this exists to remove. The refusals name the
|
|
216
228
|
// strategy and the parameter, never the secret.
|
|
217
|
-
if (auth !== undefined) {
|
|
229
|
+
if (auth !== undefined && issued === undefined) {
|
|
218
230
|
const secret = credential.secret;
|
|
219
231
|
if (typeof secret !== 'string' || secret === '') {
|
|
220
232
|
throw new Error(`remote execute: the ${auth.in} credential strategy needs \`secret\` on the sealed credential, and it is absent`);
|
|
@@ -230,7 +242,7 @@ function direct(origin, credential, auth, vendorHosts) {
|
|
|
230
242
|
throw new Error('remote execute: the aws-sigv4 credential strategy needs `keyId` on the sealed credential, and it is absent');
|
|
231
243
|
}
|
|
232
244
|
// the HOST goes with it: an AWS endpoint names its own region, and only the pack knows how
|
|
233
|
-
const { region, service } = auth.scope({ method, path, host: target.host, headers: Object.fromEntries(outbound.entries()), ...(body === undefined ? {} : { body }) });
|
|
245
|
+
const { region, service } = typeof auth.scope === 'function' ? auth.scope({ method, path, host: target.host, headers: Object.fromEntries(outbound.entries()), ...(body === undefined ? {} : { body }) }) : auth.scope;
|
|
234
246
|
const signed = await awsSigV4Headers({ method, url: target, ...(body === undefined ? {} : { body }), keyId, secret, region, service, headers: outbound, at: new Date() });
|
|
235
247
|
for (const [name, value] of Object.entries(signed))
|
|
236
248
|
outbound.set(name, value);
|
|
@@ -379,7 +391,7 @@ function exchangeExecute(origin, sealed, auth, vendorHosts, custody) {
|
|
|
379
391
|
return anchored({ ...request, headers });
|
|
380
392
|
};
|
|
381
393
|
return async (request) => {
|
|
382
|
-
if (request.presigned)
|
|
394
|
+
if (request.presigned || request.issued !== undefined)
|
|
383
395
|
return anchored(request);
|
|
384
396
|
const held = current();
|
|
385
397
|
const first = await call(request, held);
|
package/dist/src/index.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ export declare const packOf: (manifest: {
|
|
|
14
14
|
}>) => PackDescriptor;
|
|
15
15
|
/** The pack registered, so the World finds it. */
|
|
16
16
|
export declare const registerPack: (pack: PackDescriptor) => PackDescriptor;
|
|
17
|
-
export type { Actor, CoreScope, DerivedManifest, ErrorSpec, LaneRoute, LanesDecl, VendorManifest, FieldRule, ResourceDecl, ScreenDecl, SeedCall, StateField, Transition } from './derived-core.js';
|
|
17
|
+
export type { Actor, CoreScope, DerivedManifest, PerformHook, PerformHookArgs, ErrorSpec, LaneRoute, LanesDecl, VendorManifest, FieldRule, ResourceDecl, ScreenDecl, SeedCall, StateField, Transition } from './derived-core.js';
|
|
18
18
|
export type { ApplicationAnswer, EndpointsDecl, EventRender, EventScheme, EventsDecl, EventTransport, EventValues, EventWrite } from './events.js';
|
|
19
19
|
export { verifyEvent } from './events.js';
|
|
20
20
|
export { deriveStateSystem, deriveVendorStateSystem } from './derived-real.js';
|
|
@@ -12,6 +12,12 @@ export type RemoteExecuteRequest = {
|
|
|
12
12
|
* the API, and the request goes WITHOUT the sealed credential. An upload the vendor authorizes
|
|
13
13
|
* with the API token (LinkedIn's images) is not presigned and cannot use this. */
|
|
14
14
|
presigned?: true;
|
|
15
|
+
/** An Authorization the vendor ISSUED earlier in the same perform (Cloudflare's assets upload session answers a JWT
|
|
16
|
+
* that its upload calls carry instead of the API token), on the root's own origin: it is sent as the request's
|
|
17
|
+
* Authorization and the sealed credential does not go with it. */
|
|
18
|
+
issued?: string;
|
|
19
|
+
/** the lane of a vendor of lanes the request is (its strategy, when its lanes authenticate apart) */
|
|
20
|
+
lane?: string;
|
|
15
21
|
};
|
|
16
22
|
export type RemoteExecuteResponse = {
|
|
17
23
|
status: number;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-core",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.6",
|
|
4
4
|
"description": "The kernel of Volter World: one log per twin, branches as pointers, checkpoints, the fold that keeps a twin current, the head that performs a write against the vendor, references, and the git plane. A twin package builds on it; the runtime serves it.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
package/src/derived-core.ts
CHANGED
|
@@ -239,6 +239,22 @@ export type ScreenDecl = {
|
|
|
239
239
|
source: string;
|
|
240
240
|
};
|
|
241
241
|
|
|
242
|
+
/** A protocol hook's arguments: the root's executor (it applies the sealed credential; `issued` carries one the vendor
|
|
243
|
+
* issued instead), the group and its first entry's recorded input, the perform's context, the derived perform of an
|
|
244
|
+
* input (`send`: the recorded request, or one the hook changed), the stored id the World's id of a type resolves to,
|
|
245
|
+
* the surface's base path, and the World's blobs of this twin. */
|
|
246
|
+
export type PerformHookArgs = {
|
|
247
|
+
execute: import('./remote-execute.ts').RemoteExecute;
|
|
248
|
+
group: import('./actions.ts').TwinAction[];
|
|
249
|
+
input: Record<string, unknown>;
|
|
250
|
+
ctx: import('./head.ts').PerformContext;
|
|
251
|
+
send: (input: Record<string, unknown>) => Promise<import('./head.ts').PushOutcome>;
|
|
252
|
+
resolve: (type: string, localId: string) => string;
|
|
253
|
+
basePath: string;
|
|
254
|
+
blob: (key: string) => Promise<Uint8Array | null>;
|
|
255
|
+
};
|
|
256
|
+
export type PerformHook = (args: PerformHookArgs) => Promise<import('./head.ts').PushOutcome>;
|
|
257
|
+
|
|
242
258
|
export type DerivedManifest = {
|
|
243
259
|
/** The pack's descriptor, as data (docs/contributing/architecture.md, "The descriptor"), every field but `vendor`, which
|
|
244
260
|
* is the manifest's: the pack registers `packOf(manifest)`, so its index is a fixed file. A lane has none. */
|
|
@@ -293,6 +309,14 @@ export type DerivedManifest = {
|
|
|
293
309
|
/** a pack whose wire has no derived perform (a socket, a line protocol, a managed database): binding a root is refused
|
|
294
310
|
* with this reason */
|
|
295
311
|
vendorBacked?: { none: string };
|
|
312
|
+
/** the resource the vendor's paths name the account by (Cloudflare's `accounts/{account_id}`): with a root, the
|
|
313
|
+
* World's account is the one account the root's credential reaches, which refresh observes ("The account a root
|
|
314
|
+
* is", architecture) */
|
|
315
|
+
account?: string;
|
|
316
|
+
/** a vendor protocol one recorded request cannot express, by the operation that ends it: a session whose answer
|
|
317
|
+
* issues the credential its next calls carry (Cloudflare's assets upload), performed by the hook in place of the
|
|
318
|
+
* derived request, which it composes (`send`) ("Protocols a request cannot express", architecture) */
|
|
319
|
+
performs?: Record<string, PerformHook>;
|
|
296
320
|
/** `withParam`: the error names the path parameter that held the unknown id */
|
|
297
321
|
notFound: { status: number; message: string; code?: string; kind?: string; withParam?: boolean };
|
|
298
322
|
/** the list envelope (placeholders `{data}`, `{has_more}`, `{url}`, `{first_id}`, `{last_id}`),
|
package/src/derived-real.ts
CHANGED
|
@@ -70,7 +70,9 @@ function resolvedDeep(value: unknown, resolve: (v: string) => string): unknown {
|
|
|
70
70
|
return o ? Object.fromEntries(Object.entries(o).map(([k, v]) => [k, resolvedDeep(v, resolve)])) : value;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
/** A multipart body: the recorded fields, then each recorded file part read back from the World's blob store.
|
|
73
|
+
/** A multipart body: the recorded fields, then each recorded file part read back from the World's blob store. An
|
|
74
|
+
* operation the vendor takes as multipart is sent so with no file part too (a Workers upload of assets alone carries
|
|
75
|
+
* only its `metadata` field). */
|
|
74
76
|
async function multipart(body: unknown, files: Row[], ctx: PerformContext): Promise<{ body: Uint8Array; type: string }> {
|
|
75
77
|
const boundary = `----volter${hashFieldValue({ body, files }).slice(0, 24)}`;
|
|
76
78
|
const chunks: Uint8Array[] = [];
|
|
@@ -109,10 +111,33 @@ function unitOf(units: Unit[], input: Row): Unit | undefined {
|
|
|
109
111
|
?? (input.graphql ? units[0] : undefined);
|
|
110
112
|
}
|
|
111
113
|
|
|
114
|
+
/** The account a root is, before a perform sends a path naming the World's ("The account a root is"): the World's
|
|
115
|
+
* account its own doors made, unadopted, adopts the one account the root's credential lists. Read once per deploy (an
|
|
116
|
+
* adopted account is not asked again); a list the vendor refuses, or one naming several accounts, adopts none. */
|
|
117
|
+
async function adoptAccount(units: Unit[], execute: RemoteExecute, ctx: PerformContext): Promise<void> {
|
|
118
|
+
if (ctx.service === undefined) return;
|
|
119
|
+
for (const u of units) {
|
|
120
|
+
const m = u.manifest;
|
|
121
|
+
const decl = m.account ? m.resources[m.account] : undefined;
|
|
122
|
+
if (!m.account || !decl) continue;
|
|
123
|
+
const type = storedType(m, m.account);
|
|
124
|
+
const local = (readTree(ctx.service, ctx.root) as Row[]).filter((r) => r.type === type && r.deleted !== true && ctx.resolve(type, String(r.id)) === String(r.id));
|
|
125
|
+
const scope = decl.refresh;
|
|
126
|
+
const op = scope && 'list' in scope ? u.surface.operations.find((o) => o.id === scope.list) : undefined;
|
|
127
|
+
if (!local.length || !op) continue;
|
|
128
|
+
const answer = await execute({ method: op.method.toUpperCase(), path: `${op.basePath ?? u.surface.basePath ?? ''}${op.path}`, ...(u.lane ? { lane: u.lane } : {}) });
|
|
129
|
+
if (answer.status >= 400) continue;
|
|
130
|
+
const items = itemsOf(op, parse(answer));
|
|
131
|
+
const id = items.length === 1 ? storedIdOf(decl, items[0]!) : undefined;
|
|
132
|
+
if (id === undefined || local.some((l) => String(l.id) === id)) continue;
|
|
133
|
+
observeResources(ctx.service, [{ type, id, fields: items[0] as ObservedResource['fields'], adopts: String(local[0]!.id) }], ctx.root !== undefined ? { root: ctx.root } : {});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
112
137
|
function performOf(units: Unit[]): PerformAction {
|
|
113
|
-
|
|
138
|
+
// the request a group's first entry recorded (or one a protocol hook changed), sent to the vendor
|
|
139
|
+
const derived = async (execute: RemoteExecute, group: TwinAction[], ctx: PerformContext, input: Row): Promise<PushOutcome> => {
|
|
114
140
|
const first = group[0]!;
|
|
115
|
-
const input = (first.input ?? {}) as Row;
|
|
116
141
|
const unit = unitOf(units, input);
|
|
117
142
|
if (!unit) throw new RefusedWriteError('vendor', `the write records no operation of the vendor's surface (${String(input.operationId ?? 'none')})`, first.id);
|
|
118
143
|
const { manifest: m, surface } = unit;
|
|
@@ -153,11 +178,11 @@ function performOf(units: Unit[]): PerformAction {
|
|
|
153
178
|
const asCame = object(input.body);
|
|
154
179
|
const raw = asCame && typeof asCame.$text === 'string' ? { body: asCame.$text as string } : asCame && typeof asCame.$base64 === 'string' ? { body: new Uint8Array(Buffer.from(asCame.$base64 as string, 'base64')) } : undefined;
|
|
155
180
|
const sent: { body?: string | Uint8Array; type?: string } = raw ? { ...raw, ...(typeof input.contentType === 'string' ? { type: input.contentType } : {}) }
|
|
156
|
-
: files.length ? await multipart(body, files, ctx) : op ? encode(op, body) : { body: JSON.stringify(body), type: 'application/json' };
|
|
181
|
+
: files.length || op?.bodyEncoding === 'multipart' ? await multipart(body, files, ctx) : op ? encode(op, body) : { body: JSON.stringify(body), type: 'application/json' };
|
|
157
182
|
const headers: Record<string, string> = { ...((object(input.headers) ?? {}) as Record<string, string>), ...(sent.type ? { 'content-type': sent.type } : {}) };
|
|
158
183
|
// a retried deploy makes nothing twice, and two Worlds deploying into one account never collide
|
|
159
184
|
if (m.idempotency) headers[m.idempotency.header] = hashFieldValue({ credential: ctx.credential ?? '', entry: first.id });
|
|
160
|
-
const answer = await execute({ method, path, headers, ...(sent.body !== undefined ? { body: sent.body } : {}) });
|
|
185
|
+
const answer = await execute({ method, path, headers, ...(sent.body !== undefined ? { body: sent.body } : {}), ...(unit.lane ? { lane: unit.lane } : {}) });
|
|
161
186
|
const parsed = parse(answer);
|
|
162
187
|
const shape = refusalShape(m);
|
|
163
188
|
const refused = answer.status >= 400 || (shape.length > 0 && shape.every(([p, v]) => at(parsed, p) === v));
|
|
@@ -193,6 +218,22 @@ function performOf(units: Unit[]): PerformAction {
|
|
|
193
218
|
}
|
|
194
219
|
return { externalId, actionId: primary.id, data: obj, ...(also.length ? { also } : {}) };
|
|
195
220
|
};
|
|
221
|
+
return async (execute: RemoteExecute, group: TwinAction[], ctx: PerformContext): Promise<PushOutcome> => {
|
|
222
|
+
await adoptAccount(units, execute, ctx);
|
|
223
|
+
const input = (group[0]!.input ?? {}) as Row;
|
|
224
|
+
const unit = unitOf(units, input);
|
|
225
|
+
// a protocol one request cannot express is the pack's hook, which composes the derived perform ("Protocols a
|
|
226
|
+
// request cannot express")
|
|
227
|
+
const hook = unit && typeof input.operationId === 'string' ? unit.manifest.performs?.[input.operationId] : undefined;
|
|
228
|
+
if (!hook || !unit) return derived(execute, group, ctx, input);
|
|
229
|
+
return hook({
|
|
230
|
+
execute, group, input, ctx,
|
|
231
|
+
send: (changed) => derived(execute, group, ctx, changed),
|
|
232
|
+
resolve: (type, localId) => ctx.resolve(type, localId),
|
|
233
|
+
basePath: unit.surface.basePath ?? '',
|
|
234
|
+
blob: (key) => readResourceBlob(ctx.service ?? '', key, ctx.root),
|
|
235
|
+
});
|
|
236
|
+
};
|
|
196
237
|
}
|
|
197
238
|
|
|
198
239
|
/** Path parameters that name a subject by a field rather than its id, sent as the vendor's value of that field, read
|
|
@@ -239,7 +280,8 @@ function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapt
|
|
|
239
280
|
return async (execute, opts) => {
|
|
240
281
|
const observed: ObservedResource[] = [];
|
|
241
282
|
const complete: Completed[] = [];
|
|
242
|
-
for (const
|
|
283
|
+
for (const unit of units) {
|
|
284
|
+
const { manifest: m, surface } = unit;
|
|
243
285
|
// parents before children, however deep
|
|
244
286
|
const scoped = Object.entries(m.resources).filter(([, d]) => d.refresh && !('none' in d.refresh));
|
|
245
287
|
const depth = (r: string, seen = new Set<string>()): number => { const p = m.resources[r]?.parent?.resource; return p && !seen.has(p) ? 1 + depth(p, seen.add(r)) : 0; };
|
|
@@ -260,7 +302,7 @@ function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapt
|
|
|
260
302
|
// a parent the operation names in its query, not its path (Slack's conversations.history `channel`)
|
|
261
303
|
const byQuery = decl.parent?.param && parent !== undefined && !op.path.includes(`{${decl.parent.param}}`) ? { [decl.parent.param]: String(parent) } : {};
|
|
262
304
|
const q = new URLSearchParams({ ...page, ...byQuery });
|
|
263
|
-
const answer = await execute({ method: op.method.toUpperCase(), path: [...q].length ? `${path}?${q}` : path });
|
|
305
|
+
const answer = await execute({ method: op.method.toUpperCase(), path: [...q].length ? `${path}?${q}` : path, ...(unit.lane ? { lane: unit.lane } : {}) });
|
|
264
306
|
if (answer.status === 401 || answer.status === 403) throw new Error(`${answer.status}: the vendor refused the root's credential; nothing folded`);
|
|
265
307
|
if (answer.status === 429) throw new Error(`429: rate limited${answer.headers['retry-after'] ? `, retry after ${answer.headers['retry-after']}s` : ''}`);
|
|
266
308
|
if (answer.status >= 400) throw new Error(`${answer.status}: ${refusalWords(m, parse(answer))}`);
|
|
@@ -336,6 +378,16 @@ function adopt(service: string, root: string | undefined, units: Unit[], observe
|
|
|
336
378
|
if (!list.some((p) => p.id === e.subject.id)) list.push({ id: e.subject.id, fields: held.fields });
|
|
337
379
|
pending.set(e.subject.type, list);
|
|
338
380
|
}
|
|
381
|
+
// the account a root is ("The account a root is"): the root's credential reaches one account of the type the pack's
|
|
382
|
+
// paths name it by; observed alone, it is the World's own account (made by the World's door, so no deploy settled it)
|
|
383
|
+
for (const u of units) {
|
|
384
|
+
if (!u.manifest.account) continue;
|
|
385
|
+
const type = storedType(u.manifest, u.manifest.account);
|
|
386
|
+
const seen = observed.filter((o) => o.type === type);
|
|
387
|
+
if (seen.length !== 1 || tree.has(`${type}:${seen[0]!.id}`)) continue;
|
|
388
|
+
const local = (readTree(service, root) as Row[]).find((r) => r.type === type && r.deleted !== true && !aliases.has(`${type}:${String(r.id)}`) && String(r.id) !== seen[0]!.id);
|
|
389
|
+
if (local) { seen[0]!.adopts = String(local.id); aliases.set(`${type}:${String(local.id)}`, seen[0]!.id); }
|
|
390
|
+
}
|
|
339
391
|
if (!pending.size) return;
|
|
340
392
|
const declOf = (type: string): { m: DerivedManifest; decl: ResourceDecl } | undefined => {
|
|
341
393
|
for (const u of units) { const hit = Object.entries(u.manifest.resources).find(([r]) => storedType(u.manifest, r) === type); if (hit) return { m: u.manifest, decl: hit[1] }; }
|
package/src/executor.ts
CHANGED
|
@@ -21,12 +21,15 @@ import { RateBudget } from './rateBudget.ts';
|
|
|
21
21
|
* and no sealed credential migrates.
|
|
22
22
|
*/
|
|
23
23
|
export type TwinAuthStrategy =
|
|
24
|
+
/** A vendor of lanes whose lanes authenticate apart (Cloudflare's API by bearer, its R2 by SigV4): a lane named takes
|
|
25
|
+
* its own strategy, every other call the sealed headers; the request names its lane (`RemoteExecuteRequest.lane`). */
|
|
26
|
+
| { in: 'lanes'; lanes: Record<string, TwinAuthStrategy> }
|
|
24
27
|
/** the key rides in the query string (`?key=…`, `?appid=…`) — no header will do */
|
|
25
28
|
| { in: 'query'; name: string }
|
|
26
29
|
/** AWS Signature Version 4 — a signature computed per request over the whole canonical request.
|
|
27
30
|
* `scope` answers which region and service THIS request is for, because a pack may route several
|
|
28
31
|
* services over one executor (aws routes six); it is pure and is handed no secret. */
|
|
29
|
-
| { in: 'signature'; algorithm: 'aws-sigv4'; scope: (req: { method: string; path: string; host: string; headers: Record<string, string>; body?: string | Uint8Array }) => { region: string; service: string } }
|
|
32
|
+
| { in: 'signature'; algorithm: 'aws-sigv4'; scope: { region: string; service: string } | ((req: { method: string; path: string; host: string; headers: Record<string, string>; body?: string | Uint8Array }) => { region: string; service: string }) }
|
|
30
33
|
/** a signature computed PER REQUEST over bytes only the pack can canonicalize */
|
|
31
34
|
| {
|
|
32
35
|
in: 'signature';
|
|
@@ -223,6 +226,14 @@ export type CredentialCustody = { key: string; open: () => Promise<CredentialPay
|
|
|
223
226
|
* made, refused when the budget is spent, and settled with the vendor's answer (its back-off honoured). */
|
|
224
227
|
export type RemoteExecuteOptions = { custody?: CredentialCustody; vendor?: string };
|
|
225
228
|
export function buildRemoteExecute(origin: URL, credential: CredentialPayload, auth?: TwinAuthStrategy, vendorHosts?: readonly HostRule[], options: RemoteExecuteOptions = {}): RemoteExecute {
|
|
229
|
+
if (auth?.in === 'lanes') {
|
|
230
|
+
// each lane's strategy over the same origin and credential, the budget charged once, around them all
|
|
231
|
+
const own = { ...options, vendor: undefined };
|
|
232
|
+
const byLane = new Map(Object.entries(auth.lanes).map(([lane, strategy]) => [lane, buildRemoteExecute(origin, credential, strategy, vendorHosts, own)]));
|
|
233
|
+
const headers = buildRemoteExecute(origin, credential, undefined, vendorHosts, own);
|
|
234
|
+
const execute: RemoteExecute = (request) => (byLane.get(request.lane ?? '') ?? headers)(request);
|
|
235
|
+
return options.vendor === undefined ? execute : budgeted(options.vendor, execute, options.custody?.key ?? JSON.stringify(credential.headers));
|
|
236
|
+
}
|
|
226
237
|
const execute = auth?.in === 'exchange' ? exchangeExecute(origin, credential, auth, vendorHosts, options.custody) : direct(origin, credential, auth, vendorHosts);
|
|
227
238
|
return options.vendor === undefined ? execute : budgeted(options.vendor, execute, options.custody?.key ?? JSON.stringify(credential.headers));
|
|
228
239
|
}
|
|
@@ -242,8 +253,8 @@ function budgeted(vendor: string, execute: RemoteExecute, token: string): Remote
|
|
|
242
253
|
}
|
|
243
254
|
|
|
244
255
|
/** The call made as the root's credential is applied by the strategy (header replacement when none). */
|
|
245
|
-
function direct(origin: URL, credential: CredentialPayload, auth?: Exclude<TwinAuthStrategy, { in: 'exchange' }>, vendorHosts?: readonly HostRule[]): RemoteExecute {
|
|
246
|
-
return async ({ method, path, headers, body, responseType, presigned }) => {
|
|
256
|
+
function direct(origin: URL, credential: CredentialPayload, auth?: Exclude<TwinAuthStrategy, { in: 'exchange' | 'lanes' }>, vendorHosts?: readonly HostRule[]): RemoteExecute {
|
|
257
|
+
return async ({ method, path, headers, body, responseType, presigned, issued }) => {
|
|
247
258
|
// A PRESIGNED UPLOAD URL the vendor returned: its own signature is its authorization, so the
|
|
248
259
|
// sealed credential never goes with it, whatever host it names. A host other than the root's
|
|
249
260
|
// passes the same validation the root does; the root's own origin (a twin standing in for the
|
|
@@ -288,7 +299,9 @@ function direct(origin: URL, credential: CredentialPayload, auth?: Exclude<TwinA
|
|
|
288
299
|
if (target.origin !== origin.origin || !target.pathname.startsWith(origin.pathname.replace(/\/+$/, ''))) throw new Error('remote execute: path escaped the validated origin');
|
|
289
300
|
}
|
|
290
301
|
const outbound = new Headers(headers ?? {});
|
|
291
|
-
|
|
302
|
+
// a credential the vendor issued in this perform is the request's authorization; the sealed one stays home
|
|
303
|
+
if (issued !== undefined) outbound.set('authorization', issued);
|
|
304
|
+
else for (const [name, value] of Object.entries(credential.headers)) outbound.set(name, value);
|
|
292
305
|
|
|
293
306
|
// ── THE STRATEGIES BEYOND HEADER REPLACEMENT ──────────────────────────────────────────────
|
|
294
307
|
// Applied AFTER the anchoring check above, so nothing here can move the request off the
|
|
@@ -297,7 +310,7 @@ function direct(origin: URL, credential: CredentialPayload, auth?: Exclude<TwinA
|
|
|
297
310
|
// FAIL CLOSED. A strategy with no secret must never send a request that merely LOOKS
|
|
298
311
|
// authenticated — that silence is the whole defect this exists to remove. The refusals name the
|
|
299
312
|
// strategy and the parameter, never the secret.
|
|
300
|
-
if (auth !== undefined) {
|
|
313
|
+
if (auth !== undefined && issued === undefined) {
|
|
301
314
|
const secret = credential.secret;
|
|
302
315
|
if (typeof secret !== 'string' || secret === '') {
|
|
303
316
|
throw new Error(`remote execute: the ${auth.in} credential strategy needs \`secret\` on the sealed credential, and it is absent`);
|
|
@@ -312,7 +325,7 @@ function direct(origin: URL, credential: CredentialPayload, auth?: Exclude<TwinA
|
|
|
312
325
|
throw new Error('remote execute: the aws-sigv4 credential strategy needs `keyId` on the sealed credential, and it is absent');
|
|
313
326
|
}
|
|
314
327
|
// the HOST goes with it: an AWS endpoint names its own region, and only the pack knows how
|
|
315
|
-
const { region, service } = auth.scope({ method, path, host: target.host, headers: Object.fromEntries(outbound.entries()), ...(body === undefined ? {} : { body }) });
|
|
328
|
+
const { region, service } = typeof auth.scope === 'function' ? auth.scope({ method, path, host: target.host, headers: Object.fromEntries(outbound.entries()), ...(body === undefined ? {} : { body }) }) : auth.scope;
|
|
316
329
|
const signed = await awsSigV4Headers({ method, url: target, ...(body === undefined ? {} : { body }), keyId, secret, region, service, headers: outbound, at: new Date() });
|
|
317
330
|
for (const [name, value] of Object.entries(signed)) outbound.set(name, value);
|
|
318
331
|
} else {
|
|
@@ -437,7 +450,7 @@ function exchangeExecute(origin: URL, sealed: CredentialPayload, auth: Extract<T
|
|
|
437
450
|
return anchored({ ...request, headers });
|
|
438
451
|
};
|
|
439
452
|
return async (request) => {
|
|
440
|
-
if (request.presigned) return anchored(request);
|
|
453
|
+
if (request.presigned || request.issued !== undefined) return anchored(request);
|
|
441
454
|
const held = current();
|
|
442
455
|
const first = await call(request, held);
|
|
443
456
|
if (!retryOn.includes(first.status)) return first;
|
package/src/index.ts
CHANGED
|
@@ -21,7 +21,7 @@ export const packOf = (
|
|
|
21
21
|
export const registerPack = (pack: PackDescriptor): PackDescriptor => registerAny(pack);
|
|
22
22
|
|
|
23
23
|
// ── the manifest ───────────────────────────────────────────────────────────────────────────────
|
|
24
|
-
export type { Actor, CoreScope, DerivedManifest, ErrorSpec, LaneRoute, LanesDecl, VendorManifest, FieldRule, ResourceDecl, ScreenDecl, SeedCall, StateField, Transition } from './derived-core.ts';
|
|
24
|
+
export type { Actor, CoreScope, DerivedManifest, PerformHook, PerformHookArgs, ErrorSpec, LaneRoute, LanesDecl, VendorManifest, FieldRule, ResourceDecl, ScreenDecl, SeedCall, StateField, Transition } from './derived-core.ts';
|
|
25
25
|
export type { ApplicationAnswer, EndpointsDecl, EventRender, EventScheme, EventsDecl, EventTransport, EventValues, EventWrite } from './events.ts';
|
|
26
26
|
export { verifyEvent } from './events.ts';
|
|
27
27
|
export { deriveStateSystem, deriveVendorStateSystem } from './derived-real.ts';
|
package/src/remote-execute.ts
CHANGED
|
@@ -16,6 +16,12 @@ export type RemoteExecuteRequest = {
|
|
|
16
16
|
* the API, and the request goes WITHOUT the sealed credential. An upload the vendor authorizes
|
|
17
17
|
* with the API token (LinkedIn's images) is not presigned and cannot use this. */
|
|
18
18
|
presigned?: true;
|
|
19
|
+
/** An Authorization the vendor ISSUED earlier in the same perform (Cloudflare's assets upload session answers a JWT
|
|
20
|
+
* that its upload calls carry instead of the API token), on the root's own origin: it is sent as the request's
|
|
21
|
+
* Authorization and the sealed credential does not go with it. */
|
|
22
|
+
issued?: string;
|
|
23
|
+
/** the lane of a vendor of lanes the request is (its strategy, when its lanes authenticate apart) */
|
|
24
|
+
lane?: string;
|
|
19
25
|
};
|
|
20
26
|
export type RemoteExecuteResponse = {
|
|
21
27
|
status: number;
|