@volter/world-core 3.0.38 → 3.0.39
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/app-route.cjs +26 -25
- package/app-route.d.cts +1 -0
- package/dist/app-route.cjs +26 -25
- package/dist/app-route.d.cts +1 -0
- package/dist/generated/pack-facts.json +409 -352
- package/dist/inject.cjs +474 -95
- package/dist/network-policy.cjs +108 -13
- package/dist/network-policy.d.cts +8 -3
- package/dist/src/actions.js +2 -1
- package/dist/src/anthropic-wire.d.ts +2 -0
- package/dist/src/anthropic-wire.js +5 -0
- package/dist/src/attestation.d.ts +6 -0
- package/dist/src/attestation.js +27 -0
- package/dist/src/bytes.d.ts +3 -0
- package/dist/src/bytes.js +17 -0
- package/dist/src/changeset.js +2 -1
- package/dist/src/clickhouse/sql.js +73 -14
- package/dist/src/derived-core.d.ts +210 -32
- package/dist/src/derived-core.js +510 -244
- package/dist/src/derived-real.d.ts +1 -1
- package/dist/src/derived-real.js +704 -93
- package/dist/src/derived.d.ts +18 -2
- package/dist/src/derived.js +21 -2
- package/dist/src/events.d.ts +28 -45
- package/dist/src/events.js +40 -58
- package/dist/src/exact-json.d.ts +10 -0
- package/dist/src/exact-json.js +98 -0
- package/dist/src/executor.d.ts +17 -0
- package/dist/src/executor.js +91 -30
- package/dist/src/file-response.js +1 -1
- package/dist/src/form.d.ts +8 -0
- package/dist/src/form.js +76 -0
- package/dist/src/git/format.d.ts +16 -0
- package/dist/src/git/format.js +110 -0
- package/dist/src/git/index.d.ts +1 -0
- package/dist/src/git/index.js +1 -0
- package/dist/src/git/lfs.d.ts +1 -1
- package/dist/src/git/lfs.js +4 -3
- package/dist/src/graphql-wire.d.ts +6 -0
- package/dist/src/graphql-wire.js +6 -3
- package/dist/src/grpc-wire.js +2 -1
- package/dist/src/head.d.ts +1 -0
- package/dist/src/head.js +15 -4
- package/dist/src/held-reads.d.ts +6 -0
- package/dist/src/held-reads.js +60 -0
- package/dist/src/history.js +5 -4
- package/dist/src/host-port.d.ts +5 -0
- package/dist/src/host-port.js +14 -0
- package/dist/src/index.d.ts +11 -3
- package/dist/src/index.js +9 -4
- package/dist/src/log.js +12 -7
- package/dist/src/machines.d.ts +81 -5
- package/dist/src/machines.js +125 -9
- package/dist/src/managed-database.d.ts +15 -11
- package/dist/src/managed-database.js +38 -37
- package/dist/src/multipart.d.ts +21 -3
- package/dist/src/multipart.js +112 -38
- package/dist/src/observe.d.ts +3 -0
- package/dist/src/observe.js +6 -2
- package/dist/src/openai-wire.d.ts +16 -0
- package/dist/src/openai-wire.js +43 -12
- package/dist/src/pack-fetch.d.ts +4 -6
- package/dist/src/pack-fetch.js +189 -57
- package/dist/src/packRegistry.d.ts +7 -1
- package/dist/src/packRegistry.js +20 -3
- package/dist/src/private-transport.d.ts +1 -0
- package/dist/src/private-transport.js +11 -0
- package/dist/src/protobuf.js +10 -4
- package/dist/src/rateBudget.d.ts +8 -1
- package/dist/src/rateBudget.js +1 -0
- package/dist/src/redis/engine.d.ts +14 -5
- package/dist/src/redis/engine.js +271 -72
- package/dist/src/redis/frames.d.ts +17 -0
- package/dist/src/redis/frames.js +123 -0
- package/dist/src/redis/index.d.ts +6 -0
- package/dist/src/redis/index.js +6 -0
- package/dist/src/redis/json.d.ts +2 -0
- package/dist/src/redis/json.js +14 -0
- package/dist/src/redis/lua-libs.d.ts +4 -0
- package/dist/src/redis/lua-libs.js +173 -0
- package/dist/src/redis/lua.d.ts +1 -0
- package/dist/src/redis/lua.js +36 -0
- package/dist/src/redis/member-storage.d.ts +5 -0
- package/dist/src/redis/member-storage.js +75 -0
- package/dist/src/redis/resp2.d.ts +4 -0
- package/dist/src/redis/resp2.js +29 -0
- package/dist/src/redis/stream.d.ts +14 -0
- package/dist/src/redis/stream.js +335 -0
- package/dist/src/remote-execute.d.ts +2 -2
- package/dist/src/request-body.d.ts +11 -0
- package/dist/src/request-body.js +11 -0
- package/dist/src/runtime.d.ts +6 -5
- package/dist/src/runtime.js +5 -5
- package/dist/src/s3/wire.d.ts +4 -4
- package/dist/src/s3/wire.js +5 -3
- package/dist/src/scenario.d.ts +22 -0
- package/dist/src/scenario.js +35 -31
- package/dist/src/serve-http.d.ts +2 -1
- package/dist/src/serve-http.js +10 -1
- package/dist/src/serve.js +7 -4
- package/dist/src/signing.d.ts +23 -5
- package/dist/src/signing.js +74 -11
- package/dist/src/sigv4.d.ts +3 -1
- package/dist/src/sigv4.js +4 -2
- package/dist/src/smtp.js +2 -1
- package/dist/src/sockets.js +8 -3
- package/dist/src/state-system.d.ts +3 -0
- package/dist/src/storage.js +5 -2
- package/dist/src/twin-fetch.d.ts +3 -1
- package/dist/src/twin-fetch.js +3 -2
- package/dist/src/vendor-call.d.ts +10 -3
- package/dist/src/vendor-call.js +58 -14
- package/dist/test-fixtures/attestation.SOURCE.md +1 -0
- package/dist/test-fixtures/attestation.json +50 -0
- package/generated/pack-facts.json +409 -352
- package/inject.cjs +474 -95
- package/network-policy.cjs +108 -13
- package/network-policy.d.cts +8 -3
- package/package.json +11 -4
- package/src/actions.ts +2 -1
- package/src/anthropic-wire.ts +6 -0
- package/src/attestation.ts +23 -0
- package/src/bytes.ts +21 -0
- package/src/changeset.ts +2 -1
- package/src/clickhouse/sql.ts +50 -16
- package/src/derived-core.ts +573 -249
- package/src/derived-real.ts +552 -42
- package/src/derived.ts +23 -4
- package/src/events.ts +54 -85
- package/src/exact-json.ts +71 -0
- package/src/executor.ts +86 -27
- package/src/file-response.ts +1 -1
- package/src/form.ts +66 -0
- package/src/git/format.ts +89 -0
- package/src/git/index.ts +1 -0
- package/src/git/lfs.ts +5 -4
- package/src/graphql-wire.ts +8 -3
- package/src/grpc-wire.ts +2 -1
- package/src/head.ts +15 -5
- package/src/held-reads.ts +54 -0
- package/src/history.ts +5 -4
- package/src/host-port.ts +9 -0
- package/src/index.ts +10 -4
- package/src/log.ts +11 -7
- package/src/machines.ts +135 -13
- package/src/managed-database.ts +48 -40
- package/src/multipart.ts +110 -35
- package/src/observe.ts +6 -3
- package/src/openai-wire.ts +45 -14
- package/src/pack-fetch.ts +173 -50
- package/src/packRegistry.ts +27 -4
- package/src/private-transport.ts +8 -0
- package/src/protobuf.ts +8 -4
- package/src/rateBudget.ts +6 -1
- package/src/redis/engine.ts +168 -63
- package/src/redis/frames.ts +53 -0
- package/src/redis/index.ts +6 -0
- package/src/redis/json.ts +13 -0
- package/src/redis/lua-libs.ts +62 -0
- package/src/redis/lua.ts +20 -0
- package/src/redis/member-storage.ts +58 -0
- package/src/redis/resp2.ts +25 -0
- package/src/redis/stream.ts +102 -0
- package/src/remote-execute.ts +2 -2
- package/src/request-body.ts +12 -0
- package/src/runtime.ts +6 -5
- package/src/s3/wire.ts +8 -5
- package/src/scenario.ts +34 -13
- package/src/serve-http.ts +11 -2
- package/src/serve.ts +4 -3
- package/src/signing.ts +71 -17
- package/src/sigv4.ts +5 -3
- package/src/smtp.ts +3 -2
- package/src/sockets.ts +8 -3
- package/src/state-system.ts +3 -0
- package/src/storage.ts +5 -2
- package/src/twin-fetch.ts +4 -3
- package/src/vendor-call.ts +59 -12
- package/test-fixtures/attestation.json +50 -0
package/src/derived.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseExactJson } from './exact-json.ts';
|
|
1
2
|
// THE DERIVED DISPATCH — the generated wire's router (docs/contributing/architecture.md, "The derived
|
|
2
3
|
// pack"). A derived pack's surface (`src/generated/surface.gen.json`, written by scripts/derive-pack.ts)
|
|
3
4
|
// names every operation the vendor publishes. This matches a request to one of them and hands it to its
|
|
@@ -38,11 +39,11 @@ export type DerivedOperation = {
|
|
|
38
39
|
scopes?: ReadonlyArray<string>;
|
|
39
40
|
credentials?: ReadonlyArray<string>;
|
|
40
41
|
/** the body's declared fields: name, type, and the numeric bounds the spec states (a strict body, `ctx.fields`) */
|
|
41
|
-
body?: ReadonlyArray<{ name: string; type: string; required?: boolean; minimum?: number; maximum?: number }>;
|
|
42
|
+
body?: ReadonlyArray<{ name: string; type: string; required?: boolean; minimum?: number | string; maximum?: number | string }>;
|
|
42
43
|
};
|
|
43
44
|
/** `basePath` is what the vendor's server URL puts before every operation path; `spanning` names the
|
|
44
45
|
* path parameters whose values may hold slashes (a git ref, a file path), which no spec says. */
|
|
45
|
-
export type DerivedSurface = { version: string; basePath?: string; spanning?: ReadonlyArray<string>; operations: ReadonlyArray<DerivedOperation> };
|
|
46
|
+
export type DerivedSurface = { graphql?: { sdl: string }; format?: string; commands?: ReadonlyArray<{ id: string }>; version: string; basePath?: string; spanning?: ReadonlyArray<string>; operations: ReadonlyArray<DerivedOperation> };
|
|
46
47
|
|
|
47
48
|
/** What a semantics handler receives: the request, the operation it matched, and its path parameters. */
|
|
48
49
|
export type DerivedCall = { request: Request; operation: DerivedOperation; params: Record<string, string> };
|
|
@@ -77,7 +78,7 @@ export async function checkJsonBody(request: Request): Promise<boolean> {
|
|
|
77
78
|
// a body labelled otherwise and not shaped as JSON is the form it is labelled (an OAuth token request), not bad JSON
|
|
78
79
|
if (!(request.headers.get('content-type') ?? '').includes('json') && !/^\s*[[{]/.test(text)) return true;
|
|
79
80
|
try {
|
|
80
|
-
const value =
|
|
81
|
+
const value = parseExactJson(text);
|
|
81
82
|
const tag = (request as Tagged)[BODY_TEXT] !== undefined ? BODY_PARSE_KEY.get(request) : undefined;
|
|
82
83
|
if (tag) BODY_PARSED.set(tag, { value });
|
|
83
84
|
return true;
|
|
@@ -185,6 +186,19 @@ export function matchOperation(routes: Map<string, Route[]>, method: string, pat
|
|
|
185
186
|
return undefined;
|
|
186
187
|
}
|
|
187
188
|
|
|
189
|
+
/** The path's template parameters for a request no operation matches (a CORS preflight, a method the path is not
|
|
190
|
+
* served by): read from the most literal route of any method whose path the request's path fits, discriminators
|
|
191
|
+
* aside, so `around` and `gap` know the resource the URL names without parsing it (S3's OPTIONS on `/{Bucket}/{Key+}`). */
|
|
192
|
+
export function pathParams(routes: Map<string, Route[]>, pathname: string): Record<string, string> {
|
|
193
|
+
const fits = [...routes.values()].flat().filter((r) => r.pattern.test(pathname)).sort((a, b) => b.literals - a.literals || b.names.length - a.names.length);
|
|
194
|
+
const route = fits[0];
|
|
195
|
+
const m = route?.pattern.exec(pathname);
|
|
196
|
+
if (!route || !m) return {};
|
|
197
|
+
const params: Record<string, string> = {};
|
|
198
|
+
try { route.names.forEach((name, i) => { params[name] = decodeURIComponent(m[i + 1]!); }); } catch { return {}; }
|
|
199
|
+
return params;
|
|
200
|
+
}
|
|
201
|
+
|
|
188
202
|
export type DerivedOwner = 'handler' | 'core' | 'gap';
|
|
189
203
|
|
|
190
204
|
/** The header the kernel carries a path prefix's named groups in, from routing to the call (never a client's). */
|
|
@@ -194,8 +208,13 @@ export type DerivedFetch = ((request: Request) => Promise<Response>) & {
|
|
|
194
208
|
/** Per operationId, who serves it: a semantics handler, the derived core, the pack's existing
|
|
195
209
|
* fetch while it moves, or nothing (the vendor's gap). */
|
|
196
210
|
owners(): Record<string, DerivedOwner>;
|
|
211
|
+
/** A vendor's owners by unit: its own API's under `''`, each lane's under the lane's name, each keyed by the unit's own
|
|
212
|
+
* operation ids (an id two units share is two operations, each served or the gap on its own). `owners()` is this map
|
|
213
|
+
* flat, a lane's ids written `<lane>:<id>`. A fetch with no lanes may leave it out: its one unit is `owners()`. */
|
|
214
|
+
ownersByUnit?(): Record<string, Record<string, DerivedOwner>>;
|
|
197
215
|
/** The vendor's sockets (sockets.ts), for the serve seam to upgrade to, when its manifest declares any. */
|
|
198
216
|
upgrade?: WebSocketUpgrade;
|
|
217
|
+
maxRequestBodySize?: number;
|
|
199
218
|
/** The workspace screens it draws, its lanes' too (`GET /twin`'s `screens`). */
|
|
200
219
|
workspaces?(): Workspace[];
|
|
201
220
|
/** Whether it lays out frames of the World's board (`semantics/board.ts`). */
|
|
@@ -256,7 +275,7 @@ export function createDerivedFetch(options: DerivedFetchOptions): DerivedFetch {
|
|
|
256
275
|
if (request.method !== 'GET' && request.method !== 'HEAD' && request.body && !(request.headers.get('content-type') ?? '').includes('multipart')) tagBody(request, await request.clone().text());
|
|
257
276
|
// the path prefix's named groups (pack-fetch.ts, `pathPrefix`) are the call's parameters too
|
|
258
277
|
const prefixed = request.headers.get(PREFIX_PARAMS_HEADER);
|
|
259
|
-
const call: DerivedCall = { request, operation: matched.operation, params: prefixed ? { ...(
|
|
278
|
+
const call: DerivedCall = { request, operation: matched.operation, params: prefixed ? { ...(parseExactJson(prefixed) as Record<string, string>), ...matched.params } : matched.params };
|
|
260
279
|
const around = options.around ?? ((_c: DerivedCall, next: () => Promise<Response>) => next());
|
|
261
280
|
const handler = handlers[matched.operation.id];
|
|
262
281
|
if (handler) return around(call, () => handler(call));
|
package/src/events.ts
CHANGED
|
@@ -8,27 +8,32 @@
|
|
|
8
8
|
// delivery is made, never when a browser client imports the kernel.
|
|
9
9
|
import { createHash, createHmac } from 'node:crypto';
|
|
10
10
|
import { applyTwinWrite, twinResources } from './serve.ts';
|
|
11
|
-
import { equalSecrets as same } from './signing.ts';
|
|
11
|
+
import { equalSecrets as same, ksuidFrom } from './signing.ts';
|
|
12
12
|
import { deliveryTraceHeaders } from './trace-context.ts';
|
|
13
13
|
|
|
14
14
|
/** How the vendor signs a delivery. */
|
|
15
15
|
export type EventScheme =
|
|
16
|
+
/** One header carrying the destination's secret as it is, no signature over the body (Cloudflare Notifications'
|
|
17
|
+
* `cf-webhook-auth`, the secret a webhook destination was made with). */
|
|
18
|
+
| { kind: 'secret'; header: string }
|
|
16
19
|
/** A message id, a timestamp and a signature header (the Standard Webhooks shape): `<prefix>-id`,
|
|
17
20
|
* `<prefix>-timestamp` (seconds) and `<prefix>-signature: v1,<base64 HMAC-SHA256>` over `${id}.${timestamp}.${body}`,
|
|
18
21
|
* keyed by the base64 part of the secret after `secretPrefix`, or by the secret's own UTF-8 bytes (`secretKey: 'utf8'`:
|
|
19
22
|
* Polar's, whose SDK base64-encodes the secret as it is before verifying). */
|
|
20
23
|
| { kind: 'webhook-id'; prefix: string; secretPrefix?: string; secretKey?: 'base64' | 'utf8' }
|
|
21
24
|
/** One header of a timestamp and its signatures: `<header>: t=<seconds>,v1=<hex HMAC-SHA256>` over `${t}.${body}`,
|
|
22
|
-
* keyed by the secret as it is
|
|
23
|
-
|
|
25
|
+
* keyed by the secret as it is; `version` names the signature's key when the vendor's is not `v1` (ElevenLabs'
|
|
26
|
+
* `ElevenLabs-Signature: t=…,v0=…`). */
|
|
27
|
+
| { kind: 'timestamp-v1'; header: string; version?: string }
|
|
24
28
|
/** One HMAC header over the body (`X-Hub-Signature-256: sha256=<hex>`, GitHub's), or over `${timestamp}.${body}` when
|
|
25
29
|
* the vendor sends its timestamp in `timestampHeader`; `signed` names what is signed otherwise, `{t}` the timestamp
|
|
26
30
|
* and `{body}` the body (Slack's `v0:{t}:{body}`), and `algorithm` the hash (SHA-256 unless named: GitHub's older
|
|
27
31
|
* `X-Hub-Signature` is SHA-1). */
|
|
28
|
-
| { kind: 'hmac'; header: string; encoding: 'hex' | 'base64'; prefix?: string; timestampHeader?: string; signed?: string; algorithm?: 'sha1' | 'sha256'
|
|
32
|
+
| { kind: 'hmac'; header: string; encoding: 'hex' | 'base64'; prefix?: string; timestampHeader?: string; signed?: string; algorithm?: 'sha1' | 'sha256' }
|
|
29
33
|
/** One header of a plain hash (not an HMAC) of the secret and the body (HubSpot's v1 `X-HubSpot-Signature`: the SHA-256
|
|
30
|
-
* of the app's client secret followed by the request body); `signed` names the input, `{secret}` and `{body}
|
|
31
|
-
|
|
34
|
+
* of the app's client secret followed by the request body); `signed` names the input, `{secret}` and `{body}`;
|
|
35
|
+
* `base64-unpadded` is base64 with its trailing `=` removed (E2B's `e2b-signature`) */
|
|
36
|
+
| { kind: 'hash'; header: string; signed: string; encoding: 'hex' | 'base64' | 'base64-unpadded'; algorithm?: 'sha256' };
|
|
32
37
|
|
|
33
38
|
/** Where the World's endpoints of one kind are kept (a door the vendor's dashboard stands in for writes them): their
|
|
34
39
|
* stored type and the fields holding the URL, the signing secret, the event types an endpoint takes (all when absent,
|
|
@@ -56,26 +61,8 @@ export type EndpointsDecl = {
|
|
|
56
61
|
* when a message mentions it, and every `message` its subscription names). */
|
|
57
62
|
types?: ReadonlyArray<string>;
|
|
58
63
|
skip?: ReadonlyArray<string>;
|
|
59
|
-
/** What each delivery's answer writes on the endpoint, when the vendor keeps score (Daily's webhook): a success (2xx)
|
|
60
|
-
* resets `failures` and writes `success` (templates: `$time.iso`, `$time.s`), a failure adds one to `failures`, and
|
|
61
|
-
* `after` failures in a row make `move`, the vendor's transition of a state field, asked of the endpoint's machine,
|
|
62
|
-
* on every endpoint but one matching `unless` (Daily's `exponential` retry never breaks). */
|
|
63
|
-
outcome?: EndpointOutcome;
|
|
64
64
|
};
|
|
65
65
|
|
|
66
|
-
/** An endpoint kind's delivery outcome (EndpointsDecl.outcome). */
|
|
67
|
-
export type EndpointOutcome = {
|
|
68
|
-
failures: string;
|
|
69
|
-
success?: Record<string, string>;
|
|
70
|
-
after?: number;
|
|
71
|
-
move?: { field: string; to: string; operation: string };
|
|
72
|
-
unless?: { field: string; equals: unknown };
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
/** A delivery's answer as the kernel reads it: the endpoint kind and row it went to, and the receiver's status (0 when
|
|
76
|
-
* the World refused it or nothing answered). */
|
|
77
|
-
export type DeliveryAnswer = { kind: EndpointsDecl; row: Record<string, unknown>; status: number; occurredAt: string };
|
|
78
|
-
|
|
79
66
|
/** Whether an endpoint takes an event by its `match`: every field of one match (any, when several) equal to the event's
|
|
80
67
|
* value, or, a list, holding it. */
|
|
81
68
|
function matches(row: Record<string, unknown>, match: EndpointsDecl['match'], filled: Record<string, unknown>): boolean {
|
|
@@ -114,10 +101,6 @@ export type EventsDecl = {
|
|
|
114
101
|
endpoints: EndpointsDecl | ReadonlyArray<EndpointsDecl>;
|
|
115
102
|
/** The bookkeeping type each delivery is kept as (`_webhook_message`): what a World reads to see what was sent. */
|
|
116
103
|
record?: string;
|
|
117
|
-
/** The bookkeeping type a World states its receivers' answers in (`{url, status}`, Daily's `_receiver`), for receivers
|
|
118
|
-
* it runs no server for: a delivery whose URL starts with a stated `url` (the longest) is answered that status, and
|
|
119
|
-
* nothing is sent. */
|
|
120
|
-
receivers?: string;
|
|
121
104
|
};
|
|
122
105
|
|
|
123
106
|
/** An event's further envelope values (`$`-named) the pack computes for a write, from the vendor's own state (the
|
|
@@ -151,24 +134,27 @@ const webhookKey = (scheme: { secretPrefix?: string; secretKey?: 'base64' | 'utf
|
|
|
151
134
|
* made before this one, so a receiver that deduplicates by id drops neither. */
|
|
152
135
|
export function signEvent(scheme: EventScheme, secret: string, body: string, seconds: number, url: string, seq = 0): Record<string, string> {
|
|
153
136
|
if (scheme.kind === 'webhook-id') {
|
|
154
|
-
|
|
137
|
+
// Svix's `msg_` and a KSUID (its ids' form), derived from the delivery itself
|
|
138
|
+
const id = `msg_${ksuidFrom(seconds, seq, `${url}|${seconds}|${seq}|${body}`)}`;
|
|
155
139
|
const p = scheme.prefix.toLowerCase();
|
|
156
140
|
const signature = createHmac('sha256', webhookKey(scheme, secret)).update(`${id}.${seconds}.${body}`, 'utf8').digest('base64');
|
|
157
141
|
return { [`${p}-id`]: id, [`${p}-timestamp`]: String(seconds), [`${p}-signature`]: `v1,${signature}` };
|
|
158
142
|
}
|
|
159
143
|
if (scheme.kind === 'timestamp-v1') {
|
|
160
144
|
const signature = createHmac('sha256', secret).update(`${seconds}.${body}`, 'utf8').digest('hex');
|
|
161
|
-
return { [scheme.header.toLowerCase()]: `t=${seconds}
|
|
145
|
+
return { [scheme.header.toLowerCase()]: `t=${seconds},${scheme.version ?? 'v1'}=${signature}` };
|
|
162
146
|
}
|
|
147
|
+
if (scheme.kind === 'secret') return { [scheme.header.toLowerCase()]: secret };
|
|
163
148
|
if (scheme.kind === 'hash') return { [scheme.header.toLowerCase()]: hashOf(scheme, secret, body) };
|
|
164
|
-
|
|
165
|
-
const mac = createHmac(scheme.algorithm ?? 'sha256', scheme.secretKey === 'base64' ? Buffer.from(secret, 'base64') : secret).update(hmacBase(scheme, seconds, body), 'utf8').digest(scheme.encoding);
|
|
149
|
+
const mac = createHmac(scheme.algorithm ?? 'sha256', secret).update(hmacBase(scheme, seconds, body), 'utf8').digest(scheme.encoding);
|
|
166
150
|
return { [scheme.header.toLowerCase()]: `${scheme.prefix ?? ''}${mac}`, ...(scheme.timestampHeader ? { [scheme.timestampHeader.toLowerCase()]: String(seconds) } : {}) };
|
|
167
151
|
}
|
|
168
152
|
|
|
169
153
|
/** A `hash` scheme's value: the digest of its `signed` template with the secret and the body put in. */
|
|
170
|
-
const hashOf = (scheme: { signed: string; encoding: 'hex' | 'base64'; algorithm?: 'sha256' }, secret: string, body: string): string =>
|
|
171
|
-
createHash(scheme.algorithm ?? 'sha256').update(scheme.signed.replaceAll('{secret}', secret).replaceAll('{body}', body), 'utf8')
|
|
154
|
+
const hashOf = (scheme: { signed: string; encoding: 'hex' | 'base64' | 'base64-unpadded'; algorithm?: 'sha256' }, secret: string, body: string): string => {
|
|
155
|
+
const digest = createHash(scheme.algorithm ?? 'sha256').update(scheme.signed.replaceAll('{secret}', secret).replaceAll('{body}', body), 'utf8');
|
|
156
|
+
return scheme.encoding === 'base64-unpadded' ? digest.digest('base64').replace(/=+$/, '') : digest.digest(scheme.encoding);
|
|
157
|
+
};
|
|
172
158
|
|
|
173
159
|
/** What an `hmac` scheme signs: its `signed` template, else `${t}.${body}` with a timestamp header, else the body. */
|
|
174
160
|
const hmacBase = (scheme: { timestampHeader?: string; signed?: string }, seconds: number, body: string): string =>
|
|
@@ -199,8 +185,9 @@ export function verifyEvent(scheme: EventScheme, secret: string, body: string, h
|
|
|
199
185
|
const seconds = Number(parts.find(([k]) => k === 't')?.[1]);
|
|
200
186
|
if (!Number.isFinite(seconds) || !fresh(seconds)) return false;
|
|
201
187
|
const expected = createHmac('sha256', secret).update(`${seconds}.${body}`, 'utf8').digest('hex');
|
|
202
|
-
return parts.some(([k, v]) => k === 'v1' && v !== undefined && same(v, expected));
|
|
188
|
+
return parts.some(([k, v]) => k === (scheme.version ?? 'v1') && v !== undefined && same(v, expected));
|
|
203
189
|
}
|
|
190
|
+
if (scheme.kind === 'secret') { const given = h[scheme.header.toLowerCase()]; return given !== undefined && same(given, secret); }
|
|
204
191
|
if (scheme.kind === 'hash') { const given = h[scheme.header.toLowerCase()]; return given !== undefined && same(given, hashOf(scheme, secret, body)); }
|
|
205
192
|
const seconds = scheme.timestampHeader ? Number(h[scheme.timestampHeader.toLowerCase()]) : undefined;
|
|
206
193
|
if (seconds !== undefined && (!Number.isFinite(seconds) || !fresh(seconds))) return false;
|
|
@@ -243,11 +230,7 @@ function at(row: Record<string, unknown>, path: string): unknown {
|
|
|
243
230
|
function headersFor(templates: Record<string, string>, values: Record<string, unknown>, endpoint: Record<string, unknown>): Record<string, string> {
|
|
244
231
|
const out: Record<string, string> = {};
|
|
245
232
|
for (const [name, template] of Object.entries(templates)) {
|
|
246
|
-
|
|
247
|
-
const prefixed = /^(.+?)\$endpoint\.([\w.]+)$/.exec(template);
|
|
248
|
-
const field = prefixed ? at(endpoint, prefixed[2]!) : undefined;
|
|
249
|
-
const value = prefixed ? (field === undefined || field === null || field === '' ? undefined : `${prefixed[1]}${String(field)}`)
|
|
250
|
-
: template.startsWith('$endpoint.') ? at(endpoint, template.slice('$endpoint.'.length)) : template.startsWith('$') ? values[template] : template;
|
|
233
|
+
const value = template.startsWith('$endpoint.') ? at(endpoint, template.slice('$endpoint.'.length)) : template.startsWith('$') ? values[template] : template;
|
|
251
234
|
if (value !== undefined && value !== null) out[name.toLowerCase()] = String(value);
|
|
252
235
|
}
|
|
253
236
|
return out;
|
|
@@ -298,38 +281,35 @@ function requestValues(request: Request | undefined): Record<string, string> {
|
|
|
298
281
|
const worldTransport: EventTransport = async (url, body, headers) => {
|
|
299
282
|
const [{ appDestination, appFetch }, { worldEgressRefusal }] = await Promise.all([
|
|
300
283
|
import('../app-route.cjs') as Promise<{ appDestination: (value: string) => unknown; appFetch: (url: string, init: RequestInit) => Promise<unknown> }>,
|
|
301
|
-
import('../network-policy.cjs') as Promise<{ worldEgressRefusal: (value: string) => unknown }>,
|
|
284
|
+
import('../network-policy.cjs') as Promise<{ worldEgressRefusal: (value: string, env?: NodeJS.ProcessEnv, method?: string) => unknown }>,
|
|
302
285
|
]);
|
|
303
|
-
if (!appDestination(url) && worldEgressRefusal(url) !== null) return 0;
|
|
286
|
+
if (!appDestination(url) && worldEgressRefusal(url, process.env, 'POST') !== null) return 0;
|
|
304
287
|
try { return ((await appFetch(url, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body })) as { status: number }).status; } catch { return 0; }
|
|
305
288
|
};
|
|
306
289
|
|
|
307
|
-
/**
|
|
308
|
-
*
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
if (!appDestination(url) && worldEgressRefusal(url) !== null) return 0;
|
|
316
|
-
// the request that caused the message is its trace's parent (trace-context.ts)
|
|
317
|
-
const headers = new Headers(init.headers);
|
|
318
|
-
for (const [k, v] of Object.entries(deliveryTraceHeaders())) if (!headers.has(k)) headers.set(k, v);
|
|
319
|
-
try { return (await appFetch(url, { ...init, headers })).status; } catch { return 0; }
|
|
290
|
+
/** Who watches what the World sends (a walk reading each delivery as its receiver would): told of every delivery as it
|
|
291
|
+
* is made, before its transport. Set and restored by the walk, as its World store is; unset, nothing watches. */
|
|
292
|
+
export type DeliveryObserver = (delivery: EventDelivery & { service: string }) => void;
|
|
293
|
+
let deliveryObserver: DeliveryObserver | undefined;
|
|
294
|
+
export function setDeliveryObserver(observer: DeliveryObserver | undefined): DeliveryObserver | undefined {
|
|
295
|
+
const previous = deliveryObserver;
|
|
296
|
+
deliveryObserver = observer;
|
|
297
|
+
return previous;
|
|
320
298
|
}
|
|
321
299
|
|
|
322
|
-
/** What the application's server answered a message the vendor waits on
|
|
323
|
-
* World let no request out to it or it could not be reached
|
|
324
|
-
|
|
300
|
+
/** What the application's server answered a message the vendor waits on (its status, its headers by lower-cased name,
|
|
301
|
+
* its body), or why it did not answer: `missed` when the World let no request out to it or it could not be reached
|
|
302
|
+
* (`unreachable`), or it did not answer in time (`timeout`). */
|
|
303
|
+
export type ApplicationAnswer = { status: number; headers: Record<string, string>; body: string; missed?: 'timeout' | 'unreachable' };
|
|
325
304
|
|
|
326
305
|
/** Who answers what the vendor asks the application (`ctx.ask`) before the World's application route does, given to a
|
|
327
306
|
* pack's fetch (`PackFetchOptions.application`) and belonging to that fetch alone: an answer it gives is the
|
|
328
307
|
* application's; `'unreachable'` is a question nobody answers (a walk's life, a sealed World, takes no other); undefined
|
|
329
308
|
* is a question it does not take, which goes on as any other does: the application route, the World's egress rule, the
|
|
330
309
|
* network. A walk's stand-in answers every question (its life's answers, else unreachable); a hosted World's router
|
|
331
|
-
* answers only what it can answer truthfully (WorldDoors.askInWorld) and leaves the rest.
|
|
332
|
-
|
|
310
|
+
* answers only what it can answer truthfully (WorldDoors.askInWorld) and leaves the rest. `bodyError` models a
|
|
311
|
+
* disconnect after the response headers; a caller needing the complete body receives no complete answer. */
|
|
312
|
+
export type ApplicationStandIn = (url: string, init: RequestInit) => Promise<{ status: number; headers?: Record<string, string>; body: string; bodyError?: string } | 'unreachable' | undefined>;
|
|
333
313
|
|
|
334
314
|
/** A message the vendor sends the application's server and decides by its answer (Stripe's real-time authorization
|
|
335
315
|
* request, answered within its window): over the same transport, waited on for at most `within` milliseconds, the
|
|
@@ -339,22 +319,23 @@ export async function askApplication(url: string, init: RequestInit, within: num
|
|
|
339
319
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
340
320
|
const late = new Promise<'late'>((done) => { timer = setTimeout(() => done('late'), within); });
|
|
341
321
|
const stood = await Promise.race([standIn(url, init), late]).finally(() => clearTimeout(timer));
|
|
342
|
-
if (stood === 'late') return { status: 0, body: '', missed: 'timeout' };
|
|
343
|
-
if (stood === 'unreachable') return { status: 0, body: '', missed: 'unreachable' };
|
|
344
|
-
if (stood !== undefined) return
|
|
322
|
+
if (stood === 'late') return { status: 0, headers: {}, body: '', missed: 'timeout' };
|
|
323
|
+
if (stood === 'unreachable') return { status: 0, headers: {}, body: '', missed: 'unreachable' };
|
|
324
|
+
if (stood !== undefined && stood.bodyError !== undefined) return { status: 0, headers: {}, body: '', missed: 'unreachable' };
|
|
325
|
+
if (stood !== undefined) return { status: stood.status, headers: Object.fromEntries(Object.entries(stood.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v])), body: stood.body };
|
|
345
326
|
}
|
|
346
327
|
const [{ appDestination, appFetch }, { worldEgressRefusal }] = await Promise.all([
|
|
347
|
-
import('../app-route.cjs') as Promise<{ appDestination: (value: string) => unknown; appFetch: (url: string, init: RequestInit) => Promise<{ status: number; text(): Promise<string> }> }>,
|
|
348
|
-
import('../network-policy.cjs') as Promise<{ worldEgressRefusal: (value: string) => unknown }>,
|
|
328
|
+
import('../app-route.cjs') as Promise<{ appDestination: (value: string) => unknown; appFetch: (url: string, init: RequestInit) => Promise<{ status: number; headers: Headers; text(): Promise<string> }> }>,
|
|
329
|
+
import('../network-policy.cjs') as Promise<{ worldEgressRefusal: (value: string, env?: NodeJS.ProcessEnv, method?: string) => unknown }>,
|
|
349
330
|
]);
|
|
350
|
-
if (!appDestination(url) && worldEgressRefusal(url) !== null) return { status: 0, body: '', missed: 'unreachable' };
|
|
331
|
+
if (!appDestination(url) && worldEgressRefusal(url, process.env, init.method ?? 'GET') !== null) return { status: 0, headers: {}, body: '', missed: 'unreachable' };
|
|
351
332
|
const headers = new Headers(init.headers);
|
|
352
333
|
for (const [k, v] of Object.entries(deliveryTraceHeaders())) if (!headers.has(k)) headers.set(k, v);
|
|
353
334
|
try {
|
|
354
335
|
const res = await appFetch(url, { ...init, headers, signal: AbortSignal.timeout(within) });
|
|
355
|
-
return { status: res.status, body: await res.text() };
|
|
336
|
+
return { status: res.status, headers: Object.fromEntries(res.headers), body: await res.text() };
|
|
356
337
|
} catch (e) {
|
|
357
|
-
return { status: 0, body: '', missed: e instanceof Error && (e.name === 'TimeoutError' || e.name === 'AbortError') ? 'timeout' : 'unreachable' };
|
|
338
|
+
return { status: 0, headers: {}, body: '', missed: e instanceof Error && (e.name === 'TimeoutError' || e.name === 'AbortError') ? 'timeout' : 'unreachable' };
|
|
358
339
|
}
|
|
359
340
|
}
|
|
360
341
|
|
|
@@ -367,11 +348,10 @@ export async function deliverEvents(
|
|
|
367
348
|
data: (type: string) => Promise<Record<string, unknown>>,
|
|
368
349
|
transport: EventTransport = worldTransport,
|
|
369
350
|
values?: (type: string) => Promise<Record<string, unknown>>,
|
|
370
|
-
onAnswer?: (answer: DeliveryAnswer) => Promise<void>,
|
|
371
351
|
): Promise<EventDelivery[]> {
|
|
372
352
|
if (write.storedType === decl.store) return [];
|
|
373
353
|
const sent: EventDelivery[] = [];
|
|
374
|
-
for (const type of eventTypesOf(decl, write.operation)) sent.push(...(await deliverEventType(service, decl, write, data, transport, values, type
|
|
354
|
+
for (const type of eventTypesOf(decl, write.operation)) sent.push(...(await deliverEventType(service, decl, write, data, transport, values, type)));
|
|
375
355
|
return sent;
|
|
376
356
|
}
|
|
377
357
|
|
|
@@ -384,7 +364,6 @@ async function deliverEventType(
|
|
|
384
364
|
transport: EventTransport,
|
|
385
365
|
values: ((type: string) => Promise<Record<string, unknown>>) | undefined,
|
|
386
366
|
type: string,
|
|
387
|
-
onAnswer?: (answer: DeliveryAnswer) => Promise<void>,
|
|
388
367
|
): Promise<EventDelivery[]> {
|
|
389
368
|
const all = twinResources(service, write.root) as unknown as Array<Record<string, unknown>>;
|
|
390
369
|
const kinds = Array.isArray(decl.endpoints) ? decl.endpoints : [decl.endpoints as EndpointsDecl];
|
|
@@ -411,7 +390,7 @@ async function deliverEventType(
|
|
|
411
390
|
const kept = decl.store !== undefined ? all.filter((r) => r.type === decl.store).length : 0;
|
|
412
391
|
const filled: Record<string, unknown> = {
|
|
413
392
|
$type: type, $data: eventData,
|
|
414
|
-
$id: `
|
|
393
|
+
$id: `evt_${ksuidFrom(seconds, kept, `${type}|${write.occurredAt}|${kept}|${JSON.stringify(eventData)}`)}`,
|
|
415
394
|
'$time.ms': millis, '$time.s': seconds, '$time.iso': write.occurredAt,
|
|
416
395
|
...requestValues(write.request),
|
|
417
396
|
...extra,
|
|
@@ -428,7 +407,6 @@ async function deliverEventType(
|
|
|
428
407
|
const shared = JSON.stringify(envelope);
|
|
429
408
|
const each = perEndpoint(decl.envelope);
|
|
430
409
|
const sent: EventDelivery[] = [];
|
|
431
|
-
const to: Array<{ kind: EndpointsDecl; row: Record<string, unknown> }> = [];
|
|
432
410
|
const before = decl.record !== undefined ? all.filter((r) => r.type === decl.record).length : 0;
|
|
433
411
|
for (const [i, { row, kind }] of endpoints.entries()) {
|
|
434
412
|
const body = each ? JSON.stringify(fillEnvelope(decl.envelope, filled, row)) : shared;
|
|
@@ -437,7 +415,6 @@ async function deliverEventType(
|
|
|
437
415
|
const extraHeaders = decl.headers ? headersFor(decl.headers, filled, row) : {};
|
|
438
416
|
const headers = { ...extraHeaders, ...signEventAll(decl.scheme, String(at(row, kind.secret)), body, seconds, url, seq) };
|
|
439
417
|
sent.push({ url, body, headers });
|
|
440
|
-
to.push({ kind, row });
|
|
441
418
|
// kept with the write, by an id of the delivery itself (two made at once never take the same one)
|
|
442
419
|
if (decl.record !== undefined) {
|
|
443
420
|
await applyTwinWrite(service, {
|
|
@@ -450,17 +427,9 @@ async function deliverEventType(
|
|
|
450
427
|
// slow or down fails no request
|
|
451
428
|
// the write's trace is read now, inside its request, and carried on each delivery (W3C trace continuation)
|
|
452
429
|
const trace = deliveryTraceHeaders();
|
|
453
|
-
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
-
.sort((x, y) => String(y.url).length - String(x.url).length)[0];
|
|
457
|
-
return r === undefined ? undefined : Number(r.status);
|
|
458
|
-
};
|
|
459
|
-
for (const [i, { url, body: payload, headers }] of sent.entries()) setTimeout(() => {
|
|
460
|
-
const answered = stated(url);
|
|
461
|
-
(answered !== undefined ? Promise.resolve(answered) : transport(url, payload, { ...headers, ...trace }))
|
|
462
|
-
.then((status) => (onAnswer && to[i]!.kind.outcome ? onAnswer({ ...to[i]!, status: typeof status === 'number' ? status : 0, occurredAt: write.occurredAt }) : undefined))
|
|
463
|
-
.catch(() => {});
|
|
430
|
+
for (const d of sent) deliveryObserver?.({ ...d, service });
|
|
431
|
+
for (const { url, body: payload, headers } of sent) setTimeout(() => {
|
|
432
|
+
void transport(url, payload, { ...headers, ...trace }).catch(() => {});
|
|
464
433
|
}, 0);
|
|
465
434
|
return sent;
|
|
466
435
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** JSON and wire integers outside JavaScript's safe range retain their decimal digits.
|
|
2
|
+
* Safe integers stay numbers; floating-point tokens keep their wire's double semantics.
|
|
3
|
+
* Quoted strings are consumed whole, so their contents are never rewritten.
|
|
4
|
+
*/
|
|
5
|
+
export function exactInteger(digits: string): number | string {
|
|
6
|
+
const n = BigInt(digits);
|
|
7
|
+
return n >= -9007199254740991n && n <= 9007199254740991n ? Number(n) : n.toString();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function parseExactJson(text: string, integer: (digits: string) => number | string | bigint = exactInteger): any {
|
|
11
|
+
let at = 0;
|
|
12
|
+
const number = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
|
|
13
|
+
const space = (): void => { while (/[\x20\t\r\n]/.test(text[at] ?? '') && at < text.length) at++; };
|
|
14
|
+
const invalid = (): never => { throw new SyntaxError(`Invalid JSON at position ${at}`); };
|
|
15
|
+
const quoted = (): string => {
|
|
16
|
+
const begin = at++;
|
|
17
|
+
for (; at < text.length; at++) {
|
|
18
|
+
if (text[at] === '\\') { at++; continue; }
|
|
19
|
+
if (text[at] === '"') {
|
|
20
|
+
const token = text.slice(begin, ++at);
|
|
21
|
+
// JSON.parse reads only a quoted string, never an integer token.
|
|
22
|
+
return JSON.parse(token);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return invalid();
|
|
26
|
+
};
|
|
27
|
+
const value = (): any => {
|
|
28
|
+
space();
|
|
29
|
+
if (text[at] === '"') return quoted();
|
|
30
|
+
if (text[at] === '[' || text[at] === '{') {
|
|
31
|
+
const array = text[at++] === '[';
|
|
32
|
+
const out: any = array ? [] : {};
|
|
33
|
+
const end = array ? ']' : '}';
|
|
34
|
+
space(); if (text[at] === end) { at++; return out; }
|
|
35
|
+
for (;;) {
|
|
36
|
+
space();
|
|
37
|
+
let key: string | undefined;
|
|
38
|
+
if (!array) { if (text[at] !== '"') return invalid(); key = quoted(); space(); if (text[at++] !== ':') return invalid(); }
|
|
39
|
+
const item = value();
|
|
40
|
+
if (array) out.push(item);
|
|
41
|
+
else Object.defineProperty(out, key!, { value: item, enumerable: true, writable: true, configurable: true });
|
|
42
|
+
space();
|
|
43
|
+
if (text[at] === end) { at++; return out; }
|
|
44
|
+
if (text[at++] !== ',') return invalid();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
for (const [literal, decoded] of [['true', true], ['false', false], ['null', null]] as const) {
|
|
48
|
+
if (text.startsWith(literal, at)) { at += literal.length; return decoded; }
|
|
49
|
+
}
|
|
50
|
+
number.lastIndex = at;
|
|
51
|
+
const token = number.exec(text)?.[0];
|
|
52
|
+
if (token === undefined) return invalid();
|
|
53
|
+
at += token.length;
|
|
54
|
+
return /^-?\d+$/.test(token) && !Number.isSafeInteger(Number(token)) ? integer(token) : Number(token);
|
|
55
|
+
};
|
|
56
|
+
const out = value(); space(); if (at !== text.length) return invalid(); return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Ordering integer versions and stored fields must not round adjacent int64 values. */
|
|
60
|
+
export function compareExactNumbers(a: unknown, b: unknown): number {
|
|
61
|
+
if (/^-?\d+$/.test(String(a)) && /^-?\d+$/.test(String(b))) {
|
|
62
|
+
const x = BigInt(String(a)), y = BigInt(String(b));
|
|
63
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
64
|
+
}
|
|
65
|
+
return Number(a) - Number(b);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Unsafe integer tokens use decimal strings at kernel interfaces; schema checks retain that meaning. */
|
|
69
|
+
export function isExactIntegerValue(value: unknown): boolean {
|
|
70
|
+
return Number.isInteger(value) || typeof value === 'string' && /^-?\d+$/.test(value) && !Number.isSafeInteger(Number(value));
|
|
71
|
+
}
|
package/src/executor.ts
CHANGED
|
@@ -24,8 +24,12 @@ export type TwinAuthStrategy =
|
|
|
24
24
|
/** A vendor of lanes whose lanes authenticate apart (Cloudflare's API by bearer, its R2 by SigV4): a lane named takes
|
|
25
25
|
* its own strategy, every other call the sealed headers; the request names its lane (`RemoteExecuteRequest.lane`). */
|
|
26
26
|
| { in: 'lanes'; lanes: Record<string, TwinAuthStrategy> }
|
|
27
|
+
| { in: 'choice'; choices: Array<{ credential: string; strategy: TwinAuthStrategy }> }
|
|
28
|
+
| { in: 'signature'; algorithm: 'oauth1-hmac-sha1' }
|
|
27
29
|
/** the key rides in the query string (`?key=…`, `?appid=…`) — no header will do */
|
|
28
30
|
| { in: 'query'; name: string }
|
|
31
|
+
/** the key rides in the path (Telegram's `/bot<token>/METHOD_NAME`): `pattern`'s first group is its place */
|
|
32
|
+
| { in: 'path'; pattern: string }
|
|
29
33
|
/** AWS Signature Version 4 — a signature computed per request over the whole canonical request.
|
|
30
34
|
* `scope` answers which region and service THIS request is for, because a pack may route several
|
|
31
35
|
* services over one executor (aws routes six); it is pure and is handed no secret. */
|
|
@@ -76,6 +80,29 @@ export type TwinAuthStrategy =
|
|
|
76
80
|
rotate?: { answer: string; credential: string };
|
|
77
81
|
};
|
|
78
82
|
|
|
83
|
+
async function oauth1SignedHeader(credential: CredentialPayload, method: string, target: URL, headers: Headers, body?: string | Uint8Array): Promise<string> {
|
|
84
|
+
const field = (name: string): string => {
|
|
85
|
+
const value = credential[name];
|
|
86
|
+
if (typeof value !== 'string' || !value) throw new Error(`remote execute: oauth1-hmac-sha1 needs sealed ${name}`);
|
|
87
|
+
return value;
|
|
88
|
+
};
|
|
89
|
+
const consumerKey = field('consumerKey'), consumerSecret = field('consumerSecret');
|
|
90
|
+
const token = field('token'), tokenSecret = field('tokenSecret');
|
|
91
|
+
const encode = (value: string): string => encodeURIComponent(value).replace(/[!\x27()*]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
92
|
+
const protocol: Array<[string, string]> = [
|
|
93
|
+
['oauth_consumer_key', consumerKey], ['oauth_token', token], ['oauth_signature_method', 'HMAC-SHA1'],
|
|
94
|
+
['oauth_timestamp', String(Math.floor(Date.now() / 1000))], ['oauth_nonce', crypto.randomUUID()], ['oauth_version', '1.0'],
|
|
95
|
+
];
|
|
96
|
+
const form = typeof body === 'string' && /^application\/x-www-form-urlencoded/i.test(headers.get('content-type') ?? '') ? [...new URLSearchParams(body)] : [];
|
|
97
|
+
const pairs = [...protocol, ...target.searchParams, ...form].filter(([name]) => name !== 'oauth_signature').map(([name, value]) => [encode(name), encode(value)]);
|
|
98
|
+
pairs.sort(([a, av], [b, bv]) => a < b ? -1 : a > b ? 1 : av < bv ? -1 : av > bv ? 1 : 0);
|
|
99
|
+
const base = `${method.toUpperCase()}&${encode(`${target.protocol}//${target.host.toLowerCase()}${target.pathname}`)}&${encode(pairs.map(([name, value]) => `${name}=${value}`).join('&'))}`;
|
|
100
|
+
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(`${encode(consumerSecret)}&${encode(tokenSecret)}`), { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
|
|
101
|
+
const bytes = new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(base)));
|
|
102
|
+
const signature = btoa(Array.from(bytes, byte => String.fromCharCode(byte)).join(''));
|
|
103
|
+
return `OAuth ${[...protocol, ['oauth_signature', signature]].map(([name, value]) => `${encode(name)}="${encode(value)}"`).join(', ')}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
79
106
|
const MAX_ORIGIN_LENGTH = 2048;
|
|
80
107
|
|
|
81
108
|
function isLoopbackHost(hostname: string): boolean {
|
|
@@ -224,7 +251,9 @@ async function awsSigV4Headers(input: {
|
|
|
224
251
|
export type CredentialCustody = { key: string; open: () => Promise<CredentialPayload>; seal: (credential: CredentialPayload) => Promise<void> | void };
|
|
225
252
|
/** `vendor`: the vendor whose rate budget every call is charged to (D8): the call is priced and reserved before it is
|
|
226
253
|
* made, refused when the budget is spent, and settled with the vendor's answer (its back-off honoured). */
|
|
227
|
-
|
|
254
|
+
/** `now`: the instant a signature is made at (the wall clock unless given: an acceptance whose vendor is a fixture World
|
|
255
|
+
* signs at that World's clock, which its twin checks a signature's date against). */
|
|
256
|
+
export type RemoteExecuteOptions = { custody?: CredentialCustody; vendor?: string; now?: () => Date };
|
|
228
257
|
export function buildRemoteExecute(origin: URL, credential: CredentialPayload, auth?: TwinAuthStrategy, vendorHosts?: readonly HostRule[], options: RemoteExecuteOptions = {}): RemoteExecute {
|
|
229
258
|
if (auth?.in === 'lanes') {
|
|
230
259
|
// each lane's strategy over the same origin and credential, the budget charged once, around them all
|
|
@@ -234,7 +263,12 @@ export function buildRemoteExecute(origin: URL, credential: CredentialPayload, a
|
|
|
234
263
|
const execute: RemoteExecute = (request) => (byLane.get(request.lane ?? '') ?? headers)(request);
|
|
235
264
|
return options.vendor === undefined ? execute : budgeted(options.vendor, execute, options.custody?.key ?? JSON.stringify(credential.headers));
|
|
236
265
|
}
|
|
237
|
-
|
|
266
|
+
if (auth?.in === 'choice') {
|
|
267
|
+
const picked = auth.choices.find(c => typeof credential[c.credential] === 'string' && credential[c.credential] !== '');
|
|
268
|
+
if (!picked) throw new Error('remote execute: no credential matches the declared strategy choices');
|
|
269
|
+
return buildRemoteExecute(origin, credential, picked.strategy, vendorHosts, options);
|
|
270
|
+
}
|
|
271
|
+
const execute = auth?.in === 'exchange' ? exchangeExecute(origin, credential, auth, vendorHosts, options.custody) : direct(origin, credential, auth, vendorHosts, options.now);
|
|
238
272
|
return options.vendor === undefined ? execute : budgeted(options.vendor, execute, options.custody?.key ?? JSON.stringify(credential.headers));
|
|
239
273
|
}
|
|
240
274
|
|
|
@@ -253,7 +287,7 @@ function budgeted(vendor: string, execute: RemoteExecute, token: string): Remote
|
|
|
253
287
|
}
|
|
254
288
|
|
|
255
289
|
/** The call made as the root's credential is applied by the strategy (header replacement when none). */
|
|
256
|
-
function direct(origin: URL, credential: CredentialPayload, auth?: Exclude<TwinAuthStrategy, { in: 'exchange' | 'lanes' }>, vendorHosts?: readonly HostRule[]): RemoteExecute {
|
|
290
|
+
function direct(origin: URL, credential: CredentialPayload, auth?: Exclude<TwinAuthStrategy, { in: 'exchange' | 'choice' | 'lanes' }>, vendorHosts?: readonly HostRule[], now: () => Date = () => new Date()): RemoteExecute {
|
|
257
291
|
return async ({ method, path, headers, body, responseType, presigned, issued }) => {
|
|
258
292
|
// A PRESIGNED UPLOAD URL the vendor returned: its own signature is its authorization, so the
|
|
259
293
|
// sealed credential never goes with it, whatever host it names. A host other than the root's
|
|
@@ -284,13 +318,23 @@ function direct(origin: URL, credential: CredentialPayload, auth?: Exclude<TwinA
|
|
|
284
318
|
// the origin into USERINFO and ships the credential to evil.com. A path is a path:
|
|
285
319
|
// it starts with '/', and the parse must land on the validated origin, asserted.
|
|
286
320
|
let target: URL;
|
|
321
|
+
// the vendor's own URL, where a loopback root stands in for it: what a signature is made over
|
|
322
|
+
let vendorUrl: URL | undefined;
|
|
287
323
|
if (/^https:\/\//i.test(path) && vendorHosts?.length) {
|
|
288
324
|
// a second host the pack declares for this vendor, on a path its rule names: validated as the root is
|
|
289
325
|
target = new URL(path);
|
|
290
326
|
if (target.username || target.password) throw new Error('remote execute: a vendor URL carries no userinfo');
|
|
291
327
|
validateRemoteOrigin(target.origin);
|
|
292
|
-
const
|
|
328
|
+
const named = (r: HostRule): boolean => ('host' in r ? r.host === target.hostname : 'suffix' in r ? target.hostname.endsWith(r.suffix) : new RegExp(r.hostPattern).test(target.hostname))
|
|
329
|
+
&& (r.pathPattern === undefined || new RegExp(r.pathPattern).test(target.pathname));
|
|
330
|
+
const declared = vendorHosts.some((r) => !r.exclude && named(r)) && !vendorHosts.some((r) => r.exclude && named(r));
|
|
293
331
|
if (!declared) throw new Error(`remote execute: ${target.host}${target.pathname.slice(0, 64)} is not a host this vendor's pack declares for this path`);
|
|
332
|
+
// A loopback root is the vendor substitute. Preserve the vendor Host while retaining
|
|
333
|
+
// the already-validated root's mounted path; an absolute URL never escapes that root.
|
|
334
|
+
if (isLoopbackHost(origin.hostname)) {
|
|
335
|
+
vendorUrl = target;
|
|
336
|
+
target = new URL(origin.href.replace(/\/+$/, '') + target.pathname + target.search);
|
|
337
|
+
}
|
|
294
338
|
} else {
|
|
295
339
|
if (!path.startsWith('/')) throw new Error(`remote execute: path must start with '/' (got ${JSON.stringify(path.slice(0, 64))})`);
|
|
296
340
|
// the base keeps its path: a served world's twin lives under /<org>/<world>/<vendor>, github.com under /
|
|
@@ -302,6 +346,8 @@ function direct(origin: URL, credential: CredentialPayload, auth?: Exclude<TwinA
|
|
|
302
346
|
// a credential the vendor issued in this perform is the request's authorization; the sealed one stays home
|
|
303
347
|
if (issued !== undefined) outbound.set('authorization', issued);
|
|
304
348
|
else for (const [name, value] of Object.entries(credential.headers)) outbound.set(name, value);
|
|
349
|
+
// the vendor's host, kept over any the credential carries (a loopback twin routes by it, as the World's proxy's)
|
|
350
|
+
if (vendorUrl) { outbound.set('host', vendorUrl.host); outbound.set('x-volter-twin-original-host', vendorUrl.host); }
|
|
305
351
|
|
|
306
352
|
// ── THE STRATEGIES BEYOND HEADER REPLACEMENT ──────────────────────────────────────────────
|
|
307
353
|
// Applied AFTER the anchoring check above, so nothing here can move the request off the
|
|
@@ -310,30 +356,43 @@ function direct(origin: URL, credential: CredentialPayload, auth?: Exclude<TwinA
|
|
|
310
356
|
// FAIL CLOSED. A strategy with no secret must never send a request that merely LOOKS
|
|
311
357
|
// authenticated — that silence is the whole defect this exists to remove. The refusals name the
|
|
312
358
|
// strategy and the parameter, never the secret.
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
if (
|
|
316
|
-
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
359
|
+
// an issued credential is the authorization: nothing of the sealed one signs the request
|
|
360
|
+
if (issued === undefined) {
|
|
361
|
+
if (auth?.in === 'signature' && auth.algorithm === 'oauth1-hmac-sha1') {
|
|
362
|
+
outbound.set('authorization', await oauth1SignedHeader(credential, method, target, outbound, body));
|
|
363
|
+
} else if (auth !== undefined) {
|
|
364
|
+
const secret = credential.secret;
|
|
365
|
+
if (typeof secret !== 'string' || secret === '') {
|
|
366
|
+
throw new Error(`remote execute: the ${auth.in} credential strategy needs \`secret\` on the sealed credential, and it is absent`);
|
|
367
|
+
}
|
|
368
|
+
if (auth.in === 'query') {
|
|
369
|
+
// `set`, not `append`: it REPLACES whatever placeholder the pack put in its own path,
|
|
370
|
+
// exactly as `outbound.set` replaces a placeholder authorization header.
|
|
371
|
+
target.searchParams.set(auth.name, secret);
|
|
372
|
+
} else if (auth.in === 'path') {
|
|
373
|
+
// the key's place in the path, filled after the origin is validated; a secret that could end the segment or
|
|
374
|
+
// the path (a slash, a query, a fragment, an escape) is refused, so the request cannot leave the validated root
|
|
375
|
+
if (/[/?#%\\]/.test(secret)) throw new Error('remote execute: the path credential strategy refuses a secret that is not one path segment');
|
|
376
|
+
const place = new RegExp(auth.pattern, 'd').exec(target.pathname)?.indices?.[1];
|
|
377
|
+
if (!place) throw new Error(`remote execute: the path credential strategy's pattern does not name the key's place in ${target.pathname.slice(0, 64)}`);
|
|
378
|
+
target.pathname = target.pathname.slice(0, place[0]) + secret + target.pathname.slice(place[1]);
|
|
379
|
+
} else if (auth.algorithm === 'aws-sigv4') {
|
|
380
|
+
const keyId = credential.keyId;
|
|
381
|
+
if (typeof keyId !== 'string' || keyId === '') {
|
|
382
|
+
throw new Error('remote execute: the aws-sigv4 credential strategy needs `keyId` on the sealed credential, and it is absent');
|
|
383
|
+
}
|
|
384
|
+
// the HOST goes with it: an AWS endpoint names its own region, and only the pack knows how
|
|
385
|
+
const signedUrl = vendorUrl ?? target;
|
|
386
|
+
const { region, service } = typeof auth.scope === 'function' ? auth.scope({ method, path, host: signedUrl.host, headers: Object.fromEntries(outbound.entries()), ...(body === undefined ? {} : { body }) }) : auth.scope;
|
|
387
|
+
const signed = await awsSigV4Headers({ method, url: signedUrl, ...(body === undefined ? {} : { body }), keyId, secret, region, service, headers: outbound, at: now() });
|
|
388
|
+
for (const [name, value] of Object.entries(signed)) outbound.set(name, value);
|
|
389
|
+
} else {
|
|
390
|
+
// `null` is the vendor's own exemption, declared by the pack — not an error, and not a
|
|
391
|
+
// reason to send an unsigned header.
|
|
392
|
+
if (body instanceof Uint8Array) throw new Error('remote execute: this signature canonicalizer accepts text bodies only');
|
|
393
|
+
const payload = auth.canonical({ method, path, ...(body === undefined ? {} : { body }) });
|
|
394
|
+
if (payload !== null) outbound.set(auth.header, await hmacSha256(secret, payload, auth.encoding));
|
|
326
395
|
}
|
|
327
|
-
// the HOST goes with it: an AWS endpoint names its own region, and only the pack knows how
|
|
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;
|
|
329
|
-
const signed = await awsSigV4Headers({ method, url: target, ...(body === undefined ? {} : { body }), keyId, secret, region, service, headers: outbound, at: new Date() });
|
|
330
|
-
for (const [name, value] of Object.entries(signed)) outbound.set(name, value);
|
|
331
|
-
} else {
|
|
332
|
-
// `null` is the vendor's own exemption, declared by the pack — not an error, and not a
|
|
333
|
-
// reason to send an unsigned header.
|
|
334
|
-
if (body instanceof Uint8Array) throw new Error('remote execute: this signature canonicalizer accepts text bodies only');
|
|
335
|
-
const payload = auth.canonical({ method, path, ...(body === undefined ? {} : { body }) });
|
|
336
|
-
if (payload !== null) outbound.set(auth.header, await hmacSha256(secret, payload, auth.encoding));
|
|
337
396
|
}
|
|
338
397
|
}
|
|
339
398
|
|