@volter/world-core 2.0.37 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -5
- package/app-route.cjs +12 -6
- package/app-route.d.cts +1 -1
- package/dist/app-route.cjs +12 -6
- package/dist/app-route.d.cts +1 -1
- package/dist/generated/pack-facts.json +1410 -3069
- package/dist/inject.cjs +64 -9
- package/dist/pack-facts.cjs +44 -0
- package/dist/src/actions.d.ts +3 -3
- package/dist/src/actions.js +22 -16
- package/dist/src/ancestry.d.ts +14 -2
- package/dist/src/ancestry.js +92 -2
- package/dist/src/anthropic-wire.d.ts +39 -0
- package/dist/src/anthropic-wire.js +136 -0
- package/dist/src/bytes.d.ts +7 -0
- package/dist/src/bytes.js +35 -0
- package/dist/src/changeset.d.ts +1 -1
- package/dist/src/changeset.js +0 -0
- package/dist/src/clickhouse/index.d.ts +3 -0
- package/dist/src/clickhouse/index.js +6 -0
- package/dist/src/clickhouse/sql.d.ts +233 -0
- package/dist/src/clickhouse/sql.js +4329 -0
- package/dist/src/clickhouse/types.d.ts +18 -0
- package/dist/src/clickhouse/types.js +47 -0
- package/dist/src/clickhouse/values.d.ts +146 -0
- package/dist/src/clickhouse/values.js +858 -0
- package/dist/src/client-bundle.js +2 -3
- package/dist/src/cors.d.ts +15 -0
- package/dist/src/cors.js +31 -0
- package/dist/src/derived-core.d.ts +487 -24
- package/dist/src/derived-core.js +788 -144
- package/dist/src/derived-real.d.ts +13 -0
- package/dist/src/derived-real.js +518 -0
- package/dist/src/derived.d.ts +35 -1
- package/dist/src/derived.js +61 -9
- package/dist/src/emit.js +1 -2
- package/dist/src/events.d.ts +206 -0
- package/dist/src/events.js +341 -0
- package/dist/src/executor.d.ts +3 -0
- package/dist/src/executor.js +19 -2
- package/dist/src/file-response.d.ts +6 -0
- package/dist/src/file-response.js +30 -0
- package/dist/src/fork.js +3 -2
- package/dist/src/git/history.d.ts +7 -0
- package/dist/src/git/history.js +24 -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 +28 -0
- package/dist/src/git/lfs.js +66 -0
- package/dist/src/git/objects.js +3 -8
- package/dist/src/git/smart-http.d.ts +3 -1
- package/dist/src/git/smart-http.js +67 -6
- package/dist/src/graphql-wire.d.ts +29 -0
- package/dist/src/graphql-wire.js +101 -0
- package/dist/src/grpc-wire.d.ts +67 -0
- package/dist/src/grpc-wire.js +170 -0
- package/dist/src/h2.d.ts +40 -0
- package/dist/src/h2.js +656 -0
- package/dist/src/head.d.ts +32 -3
- package/dist/src/head.js +161 -40
- package/dist/src/history.d.ts +1 -1
- package/dist/src/history.js +6 -6
- package/dist/src/hpack.json +1 -0
- package/dist/src/index.d.ts +64 -75
- package/dist/src/index.js +58 -101
- package/dist/src/log.js +28 -19
- package/dist/src/machines.d.ts +50 -0
- package/dist/src/machines.js +151 -0
- package/dist/src/managed-database.d.ts +86 -0
- package/dist/src/managed-database.js +283 -0
- package/dist/src/multipart.d.ts +11 -0
- package/dist/src/multipart.js +51 -0
- package/dist/src/observe.d.ts +15 -5
- package/dist/src/observe.js +23 -9
- package/dist/src/openai-wire.d.ts +108 -0
- package/dist/src/openai-wire.js +337 -0
- package/dist/src/pack-assets.d.ts +3 -4
- package/dist/src/pack-assets.js +15 -10
- package/dist/src/pack-fetch.d.ts +77 -0
- package/dist/src/pack-fetch.js +449 -0
- package/dist/src/pack-paths.d.ts +12 -0
- package/dist/src/pack-paths.js +86 -0
- package/dist/src/packRegistry.d.ts +69 -162
- package/dist/src/packRegistry.js +55 -20
- package/dist/src/people.d.ts +13 -0
- package/dist/src/people.js +18 -0
- package/dist/src/placeholder-image.d.ts +5 -0
- package/dist/src/placeholder-image.js +114 -0
- package/dist/src/protobuf.d.ts +28 -0
- package/dist/src/protobuf.js +332 -0
- package/dist/src/redis/engine.js +1 -1
- package/dist/src/request-scope.d.ts +1 -1
- package/dist/src/request-scope.js +6 -4
- package/dist/src/resource-blob.d.ts +5 -0
- package/dist/src/resource-blob.js +11 -0
- package/dist/src/runtime.d.ts +85 -0
- package/dist/src/runtime.js +104 -0
- package/dist/src/s3/wire.d.ts +60 -0
- package/dist/src/s3/wire.js +157 -0
- package/dist/src/scenario.d.ts +3 -0
- package/dist/src/scenario.js +2 -0
- package/dist/src/schema-sample.d.ts +1 -0
- package/dist/src/schema-sample.js +21 -0
- package/dist/src/sealed-box.d.ts +14 -0
- package/dist/src/sealed-box.js +225 -0
- package/dist/src/serve-http.d.ts +14 -0
- package/dist/src/serve-http.js +27 -3
- package/dist/src/serve.d.ts +6 -0
- package/dist/src/serve.js +69 -14
- package/dist/src/signing.d.ts +135 -0
- package/dist/src/signing.js +222 -0
- package/dist/src/sigv4.d.ts +48 -0
- package/dist/src/sigv4.js +167 -0
- package/dist/src/smtp.d.ts +16 -0
- package/dist/src/smtp.js +72 -0
- package/dist/src/sockets.d.ts +51 -0
- package/dist/src/sockets.js +90 -0
- package/dist/src/state-system.d.ts +1 -0
- package/dist/src/state-system.js +1 -1
- package/dist/src/storage.d.ts +1 -1
- package/dist/src/storage.js +3 -3
- package/dist/src/trace-context.js +1 -1
- package/dist/src/twin-fetch.d.ts +0 -7
- package/dist/src/twin-fetch.js +0 -14
- package/dist/src/vendor-call.d.ts +6 -0
- package/dist/src/vendor-call.js +41 -0
- package/dist/src/world-store.js +1 -1
- package/dist/vendor-hosts.cjs +36 -125
- package/dist/vendor-hosts.d.cts +8 -0
- package/generated/pack-facts.json +1410 -3069
- package/inject.cjs +64 -9
- package/pack-facts.cjs +44 -0
- package/package.json +17 -3
- package/src/actions.ts +23 -16
- package/src/ancestry.ts +74 -2
- package/src/anthropic-wire.ts +137 -0
- package/src/bytes.ts +42 -0
- package/src/changeset.ts +5 -5
- package/src/clickhouse/index.ts +6 -0
- package/src/clickhouse/sql.ts +3059 -0
- package/src/clickhouse/types.ts +44 -0
- package/src/clickhouse/values.ts +697 -0
- package/src/client-bundle.ts +2 -3
- package/src/cors.ts +34 -0
- package/src/derived-core.ts +1013 -146
- package/src/derived-real.ts +434 -0
- package/src/derived.ts +73 -3
- package/src/emit.ts +1 -2
- package/src/events.ts +449 -0
- package/src/executor.ts +24 -2
- package/src/file-response.ts +27 -0
- package/src/fork.ts +3 -2
- package/src/git/history.ts +19 -0
- package/src/git/index.ts +1 -0
- package/src/git/lfs.ts +67 -0
- package/src/git/objects.ts +3 -5
- package/src/git/smart-http.ts +56 -6
- package/src/graphql-wire.ts +106 -0
- package/src/grpc-wire.ts +159 -0
- package/src/h2.ts +627 -0
- package/src/head.ts +132 -41
- package/src/history.ts +6 -6
- package/src/hpack.json +1 -0
- package/src/index.ts +82 -329
- package/src/log.ts +27 -18
- package/src/machines.ts +151 -0
- package/src/managed-database.ts +299 -0
- package/src/multipart.ts +51 -0
- package/src/observe.ts +31 -15
- package/src/openai-wire.ts +371 -0
- package/src/pack-assets.ts +15 -11
- package/src/pack-fetch.ts +458 -0
- package/src/pack-paths.ts +72 -0
- package/src/packRegistry.ts +79 -167
- package/src/people.ts +31 -0
- package/src/placeholder-image.ts +88 -0
- package/src/protobuf.ts +251 -0
- package/src/redis/engine.ts +1 -1
- package/src/request-scope.ts +8 -4
- package/src/resource-blob.ts +13 -0
- package/src/runtime.ts +344 -0
- package/src/s3/wire.ts +172 -0
- package/src/scenario.ts +4 -0
- package/src/schema-sample.ts +24 -0
- package/src/sealed-box.ts +182 -0
- package/src/serve-http.ts +31 -3
- package/src/serve.ts +58 -14
- package/src/signing.ts +231 -0
- package/src/sigv4.ts +158 -0
- package/src/smtp.ts +76 -0
- package/src/sockets.ts +140 -0
- package/src/state-system.ts +2 -2
- package/src/storage.ts +3 -3
- package/src/trace-context.ts +1 -1
- package/src/twin-fetch.ts +0 -20
- package/src/vendor-call.ts +41 -0
- package/src/world-store.ts +1 -1
- package/vendor-hosts.cjs +36 -125
- package/vendor-hosts.d.cts +8 -0
- package/dist/src/mirror-shell.d.ts +0 -2
- package/dist/src/mirror-shell.js +0 -13
- package/dist/src/v1-removed.d.ts +0 -159
- package/dist/src/v1-removed.js +0 -124
- package/src/mirror-shell.ts +0 -15
- package/src/v1-removed.ts +0 -172
package/dist/src/derived.js
CHANGED
|
@@ -1,11 +1,54 @@
|
|
|
1
|
-
// THE DERIVED DISPATCH — the generated wire's router (docs/contributing/architecture.md, "The derived
|
|
2
|
-
// pack"). A derived pack's surface (`src/generated/surface.gen.json`, written by scripts/derive-pack.ts)
|
|
3
|
-
// names every operation the vendor publishes. This matches a request to one of them and hands it to its
|
|
4
|
-
// owner: the pack's semantics handler for that operationId when there is one, otherwise the pack's
|
|
5
|
-
// existing fetch, which keeps serving everything not yet moved (and every route outside the spec, such
|
|
6
|
-
// as the `/twin` door). `owners` says, per operation, which of the two serves it: the count a pack's
|
|
7
|
-
// move is measured by. Workerd-clean: no fs, no clock, nothing at import.
|
|
8
1
|
import { runWithRequestTrace } from "./trace-context.js";
|
|
2
|
+
/**
|
|
3
|
+
* A request's body as text, read once per request: the dispatch reads it and tags the request with it, and every clone
|
|
4
|
+
* made from a tagged request carries the tag, so the malformed-JSON guard, the idempotency signature, the handler's
|
|
5
|
+
* context and the parameters all take the one string (a publish's body is megabytes, and each read made a copy).
|
|
6
|
+
*/
|
|
7
|
+
const BODY_TEXT = Symbol('volter.bodyText');
|
|
8
|
+
function tagBody(request, text, key = {}) {
|
|
9
|
+
const tagged = request;
|
|
10
|
+
tagged[BODY_TEXT] = text;
|
|
11
|
+
BODY_PARSE_KEY.set(request, key);
|
|
12
|
+
const clone = request.clone.bind(request);
|
|
13
|
+
tagged.clone = () => tagBody(clone(), text, key);
|
|
14
|
+
return request;
|
|
15
|
+
}
|
|
16
|
+
export async function bodyTextOf(request) {
|
|
17
|
+
const held = request[BODY_TEXT];
|
|
18
|
+
return held !== undefined ? held : request.text();
|
|
19
|
+
}
|
|
20
|
+
/** The body parsed by the malformed-JSON guard, which only checks it: handed once to the handler's context (that parse
|
|
21
|
+
* would be the same), and never shared further, so no reader sees another's changes. */
|
|
22
|
+
const BODY_PARSED = new WeakMap();
|
|
23
|
+
/** The body's text, as JSON, or undefined when it is not JSON; the parse is kept for the first taker. */
|
|
24
|
+
export async function checkJsonBody(request) {
|
|
25
|
+
const text = await bodyTextOf(request);
|
|
26
|
+
if (!text)
|
|
27
|
+
return true;
|
|
28
|
+
// a body labelled otherwise and not shaped as JSON is the form it is labelled (an OAuth token request), not bad JSON
|
|
29
|
+
if (!(request.headers.get('content-type') ?? '').includes('json') && !/^\s*[[{]/.test(text))
|
|
30
|
+
return true;
|
|
31
|
+
try {
|
|
32
|
+
const value = JSON.parse(text);
|
|
33
|
+
const tag = request[BODY_TEXT] !== undefined ? BODY_PARSE_KEY.get(request) : undefined;
|
|
34
|
+
if (tag)
|
|
35
|
+
BODY_PARSED.set(tag, { value });
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** The guard's parse of this request's body, taken once (a clone of a tagged request shares its key). */
|
|
43
|
+
export function takeParsedBody(request) {
|
|
44
|
+
const tag = BODY_PARSE_KEY.get(request);
|
|
45
|
+
const held = tag ? BODY_PARSED.get(tag) : undefined;
|
|
46
|
+
if (tag && held)
|
|
47
|
+
BODY_PARSED.delete(tag);
|
|
48
|
+
return held;
|
|
49
|
+
}
|
|
50
|
+
/** One key per request as the dispatch received it, shared by every clone made from it. */
|
|
51
|
+
const BODY_PARSE_KEY = new WeakMap();
|
|
9
52
|
class Unmodeled extends Error {
|
|
10
53
|
}
|
|
11
54
|
function compile(operation, basePath, spanning) {
|
|
@@ -32,7 +75,9 @@ function compile(operation, basePath, spanning) {
|
|
|
32
75
|
}).join('');
|
|
33
76
|
})
|
|
34
77
|
.join('/');
|
|
35
|
-
|
|
78
|
+
// a path the spec writes with its trailing slash (Django's `/persons/`) is the same path without it, as a router that
|
|
79
|
+
// takes both answers it (PostHog's `trailing_slash = r"/?"`)
|
|
80
|
+
return { operation, pattern: new RegExp(`^${body.replace(/\/$/, '')}/?$`), names, literals };
|
|
36
81
|
}
|
|
37
82
|
const compiled = new WeakMap(); // cache: compiled routes per surface; a surface is immutable generated data
|
|
38
83
|
/** Routes per method, most literal segments first, so `/v1/customers/search` wins over `/v1/customers/{customer}`. */
|
|
@@ -78,6 +123,8 @@ export function matchOperation(routes, method, pathname, query, headers) {
|
|
|
78
123
|
}
|
|
79
124
|
return undefined;
|
|
80
125
|
}
|
|
126
|
+
/** The header the kernel carries a path prefix's named groups in, from routing to the call (never a client's). */
|
|
127
|
+
export const PREFIX_PARAMS_HEADER = 'x-volter-path-prefix-params';
|
|
81
128
|
export function createDerivedFetch(options) {
|
|
82
129
|
const handlers = options.handlers ?? {};
|
|
83
130
|
const known = new Set(options.surface.operations.map((o) => o.id));
|
|
@@ -95,7 +142,12 @@ export function createDerivedFetch(options) {
|
|
|
95
142
|
: matchOperation(routes, request.method, url.pathname, url.searchParams, request.headers);
|
|
96
143
|
if (!matched)
|
|
97
144
|
return fallback(request, undefined, 'no operation at this path');
|
|
98
|
-
|
|
145
|
+
// the body read once, as text, for everything the dispatch does with it (bodyTextOf)
|
|
146
|
+
if (request.method !== 'GET' && request.method !== 'HEAD' && request.body && !(request.headers.get('content-type') ?? '').includes('multipart'))
|
|
147
|
+
tagBody(request, await request.clone().text());
|
|
148
|
+
// the path prefix's named groups (pack-fetch.ts, `pathPrefix`) are the call's parameters too
|
|
149
|
+
const prefixed = request.headers.get(PREFIX_PARAMS_HEADER);
|
|
150
|
+
const call = { request, operation: matched.operation, params: prefixed ? { ...JSON.parse(prefixed), ...matched.params } : matched.params };
|
|
99
151
|
const around = options.around ?? ((_c, next) => next());
|
|
100
152
|
const handler = handlers[matched.operation.id];
|
|
101
153
|
if (handler)
|
package/dist/src/emit.js
CHANGED
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
// `TwinEmitter` and registered on its `TwinPack.emitter` (same argument as `browserRouting`:
|
|
7
7
|
// vendor knowledge in the descriptor, kernel stays vendor-agnostic).
|
|
8
8
|
//
|
|
9
|
-
// The operator surface is the PACK's bin (`world-stripe emit …`),
|
|
10
|
-
// UIs — the kernel never imports packs, so `volter-twin emit <service>` only works when a
|
|
9
|
+
// The operator surface is the PACK's bin (`world-stripe emit …`) — the kernel never imports packs, so `volter-twin emit <service>` only works when a
|
|
11
10
|
// consumer registered the pack in-process, and otherwise fails loudly pointing at the
|
|
12
11
|
// vendor bin. Unlike the twin's own background emission on state changes (fire-and-forget,
|
|
13
12
|
// vendor-faithful), an explicit DELIVER is an operator asking for exactly this delivery, so
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/** How the vendor signs a delivery. */
|
|
2
|
+
export type EventScheme =
|
|
3
|
+
/** A message id, a timestamp and a signature header (the Standard Webhooks shape): `<prefix>-id`,
|
|
4
|
+
* `<prefix>-timestamp` (seconds) and `<prefix>-signature: v1,<base64 HMAC-SHA256>` over `${id}.${timestamp}.${body}`,
|
|
5
|
+
* keyed by the base64 part of the secret after `secretPrefix`, or by the secret's own UTF-8 bytes (`secretKey: 'utf8'`:
|
|
6
|
+
* Polar's, whose SDK base64-encodes the secret as it is before verifying). */
|
|
7
|
+
{
|
|
8
|
+
kind: 'webhook-id';
|
|
9
|
+
prefix: string;
|
|
10
|
+
secretPrefix?: string;
|
|
11
|
+
secretKey?: 'base64' | 'utf8';
|
|
12
|
+
}
|
|
13
|
+
/** One header of a timestamp and its signatures: `<header>: t=<seconds>,v1=<hex HMAC-SHA256>` over `${t}.${body}`,
|
|
14
|
+
* keyed by the secret as it is. */
|
|
15
|
+
| {
|
|
16
|
+
kind: 'timestamp-v1';
|
|
17
|
+
header: string;
|
|
18
|
+
}
|
|
19
|
+
/** One HMAC header over the body (`X-Hub-Signature-256: sha256=<hex>`, GitHub's), or over `${timestamp}.${body}` when
|
|
20
|
+
* the vendor sends its timestamp in `timestampHeader`; `signed` names what is signed otherwise, `{t}` the timestamp
|
|
21
|
+
* and `{body}` the body (Slack's `v0:{t}:{body}`), and `algorithm` the hash (SHA-256 unless named: GitHub's older
|
|
22
|
+
* `X-Hub-Signature` is SHA-1). */
|
|
23
|
+
| {
|
|
24
|
+
kind: 'hmac';
|
|
25
|
+
header: string;
|
|
26
|
+
encoding: 'hex' | 'base64';
|
|
27
|
+
prefix?: string;
|
|
28
|
+
timestampHeader?: string;
|
|
29
|
+
signed?: string;
|
|
30
|
+
algorithm?: 'sha1' | 'sha256';
|
|
31
|
+
secretKey?: 'base64' | 'utf8';
|
|
32
|
+
}
|
|
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
|
|
34
|
+
* of the app's client secret followed by the request body); `signed` names the input, `{secret}` and `{body}` */
|
|
35
|
+
| {
|
|
36
|
+
kind: 'hash';
|
|
37
|
+
header: string;
|
|
38
|
+
signed: string;
|
|
39
|
+
encoding: 'hex' | 'base64';
|
|
40
|
+
algorithm?: 'sha256';
|
|
41
|
+
};
|
|
42
|
+
/** Where the World's endpoints of one kind are kept (a door the vendor's dashboard stands in for writes them): their
|
|
43
|
+
* stored type and the fields holding the URL, the signing secret, the event types an endpoint takes (all when absent,
|
|
44
|
+
* `*` and `<family>.*` patterns) and whether it is disabled (`disabled` true), not enabled (`enabled` false) or not live
|
|
45
|
+
* (`status`). A field may be a dotted path into the row (`config.url`). */
|
|
46
|
+
export type EndpointsDecl = {
|
|
47
|
+
storedAs: string;
|
|
48
|
+
url: string;
|
|
49
|
+
secret: string;
|
|
50
|
+
filter?: string;
|
|
51
|
+
disabled?: string;
|
|
52
|
+
enabled?: string;
|
|
53
|
+
/** An endpoint that is live only while a field holds one value (Stripe's `status: 'enabled'`). */
|
|
54
|
+
status?: {
|
|
55
|
+
field: string;
|
|
56
|
+
live: string;
|
|
57
|
+
};
|
|
58
|
+
/** Which events an endpoint takes by whom they are about: one whose `field` is true takes only events whose
|
|
59
|
+
* envelope value `value` is set (Stripe's Connect endpoint, `connect: true`, takes connected accounts' events, those
|
|
60
|
+
* with an `$account`), and any other only events where it is not. */
|
|
61
|
+
scope?: {
|
|
62
|
+
field: string;
|
|
63
|
+
value: string;
|
|
64
|
+
};
|
|
65
|
+
/** Endpoint fields that must equal an event's values (field → `$value`, or a literal): a repository's hook takes only
|
|
66
|
+
* its repository's events (GitHub's `{ repository: '$repository' }`), an organization's its organization's. A list
|
|
67
|
+
* field takes the event when it holds the value. Several matches: the endpoint takes the event when any holds (a
|
|
68
|
+
* GitHub App's installation: its own events, its account's when it covers all the account's repositories, a listed
|
|
69
|
+
* repository's). */
|
|
70
|
+
match?: Record<string, string> | ReadonlyArray<Record<string, string>>;
|
|
71
|
+
/** Related rows an endpoint's fields read through (`app.hook_url`): each join's name, the stored type it names and the
|
|
72
|
+
* endpoint's field holding its id (a GitHub installation's URL, secret and events are its App's). */
|
|
73
|
+
joins?: Record<string, {
|
|
74
|
+
storedAs: string;
|
|
75
|
+
by: string;
|
|
76
|
+
}>;
|
|
77
|
+
/** The event types this kind of endpoint takes (every type when absent), or those it does not (`skip`): two kinds over
|
|
78
|
+
* the same rows, one taking a type under a `match` the other has no need of (a Slack app is sent `app_mention` only
|
|
79
|
+
* when a message mentions it, and every `message` its subscription names). */
|
|
80
|
+
types?: ReadonlyArray<string>;
|
|
81
|
+
skip?: ReadonlyArray<string>;
|
|
82
|
+
/** What each delivery's answer writes on the endpoint, when the vendor keeps score (Daily's webhook): a success (2xx)
|
|
83
|
+
* resets `failures` and writes `success` (templates: `$time.iso`, `$time.s`), a failure adds one to `failures`, and
|
|
84
|
+
* `after` failures in a row make `move`, the vendor's transition of a state field, asked of the endpoint's machine,
|
|
85
|
+
* on every endpoint but one matching `unless` (Daily's `exponential` retry never breaks). */
|
|
86
|
+
outcome?: EndpointOutcome;
|
|
87
|
+
};
|
|
88
|
+
/** An endpoint kind's delivery outcome (EndpointsDecl.outcome). */
|
|
89
|
+
export type EndpointOutcome = {
|
|
90
|
+
failures: string;
|
|
91
|
+
success?: Record<string, string>;
|
|
92
|
+
after?: number;
|
|
93
|
+
move?: {
|
|
94
|
+
field: string;
|
|
95
|
+
to: string;
|
|
96
|
+
operation: string;
|
|
97
|
+
};
|
|
98
|
+
unless?: {
|
|
99
|
+
field: string;
|
|
100
|
+
equals: unknown;
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
/** A delivery's answer as the kernel reads it: the endpoint kind and row it went to, and the receiver's status (0 when
|
|
104
|
+
* the World refused it or nothing answered). */
|
|
105
|
+
export type DeliveryAnswer = {
|
|
106
|
+
kind: EndpointsDecl;
|
|
107
|
+
row: Record<string, unknown>;
|
|
108
|
+
status: number;
|
|
109
|
+
occurredAt: string;
|
|
110
|
+
};
|
|
111
|
+
/** The events a pack's writes send. */
|
|
112
|
+
export type EventsDecl = {
|
|
113
|
+
/** how each delivery is signed; several schemes each add their headers (GitHub's SHA-256 and SHA-1 signatures) */
|
|
114
|
+
scheme: EventScheme | ReadonlyArray<EventScheme>;
|
|
115
|
+
/** further headers each delivery carries, templates as the envelope's are, plus `$endpoint.<field>` for the endpoint
|
|
116
|
+
* it goes to (GitHub's `X-GitHub-Event`, `X-GitHub-Delivery` and `X-GitHub-Hook-ID`) */
|
|
117
|
+
headers?: Record<string, string>;
|
|
118
|
+
/** A write's kernel operation (`user.create`) → the vendor's event type (`user.created`), or several (a Slack message
|
|
119
|
+
* sends `message` and `app_mention`; `values` withholds one the write does not warrant); a write not named sends none. */
|
|
120
|
+
types: Record<string, string | ReadonlyArray<string>>;
|
|
121
|
+
/** The event types the vendor enumerates, when it names every write's event by its resource: an operation that is
|
|
122
|
+
* one of them sends it, and `<resource>.create|update|delete` sends `<resource>.created|updated|deleted` when that is
|
|
123
|
+
* one of them (a vendor whose spec lists its event types: Stripe's). `types` is read first. */
|
|
124
|
+
known?: ReadonlyArray<string>;
|
|
125
|
+
/** The vendor's own resource each event is also kept as (its Events API: Stripe's `GET /v1/events`), the envelope as
|
|
126
|
+
* its fields under the event's id, whether or not an endpoint takes it. */
|
|
127
|
+
store?: string;
|
|
128
|
+
/** The event as the vendor sends it, a template: a string `$type`, `$data`, `$id`, `$time.ms`, `$time.s`, `$time.iso`,
|
|
129
|
+
* `$request.client_ip` or `$request.user_agent` is replaced by that value; everything else is sent as written. */
|
|
130
|
+
envelope: unknown;
|
|
131
|
+
/** Where the World's endpoints are kept: one kind, or several (GitHub's repository and organization hooks). */
|
|
132
|
+
endpoints: EndpointsDecl | ReadonlyArray<EndpointsDecl>;
|
|
133
|
+
/** The bookkeeping type each delivery is kept as (`_webhook_message`): what a World reads to see what was sent. */
|
|
134
|
+
record?: string;
|
|
135
|
+
/** The bookkeeping type a World states its receivers' answers in (`{url, status}`, Daily's `_receiver`), for receivers
|
|
136
|
+
* it runs no server for: a delivery whose URL starts with a stated `url` (the longest) is answered that status, and
|
|
137
|
+
* nothing is sent. */
|
|
138
|
+
receivers?: string;
|
|
139
|
+
};
|
|
140
|
+
/** An event's further envelope values (`$`-named) the pack computes for a write, from the vendor's own state (the
|
|
141
|
+
* connected account it is about, the API version it is rendered in): the pack's `semantics/events.ts` `values`. A
|
|
142
|
+
* value left undefined drops its key from the envelope; `$send: false` keeps the event (stored) but sends it to no
|
|
143
|
+
* endpoint (an account the vendor no longer reports to). */
|
|
144
|
+
export type EventValues<C> = (ctx: C, write: EventWrite, type: string) => Record<string, unknown>;
|
|
145
|
+
/** The write an event reports, as the kernel's write hook sees it. */
|
|
146
|
+
export type EventWrite = {
|
|
147
|
+
operation: string;
|
|
148
|
+
storedType: string;
|
|
149
|
+
body: Record<string, unknown>;
|
|
150
|
+
root?: string;
|
|
151
|
+
occurredAt: string;
|
|
152
|
+
request?: Request;
|
|
153
|
+
};
|
|
154
|
+
/** How the vendor renders an event's `data` for a write, when it is not the written object as its operation answered
|
|
155
|
+
* it: the pack's `semantics/events.ts`, under the handler contract's read-only context, for each type the write sends
|
|
156
|
+
* (a Slack message renders as `message` and as `app_mention`). */
|
|
157
|
+
export type EventRender<C> = (ctx: C, write: EventWrite, type: string) => Record<string, unknown>;
|
|
158
|
+
/** A delivery made: where it went and exactly what was sent. */
|
|
159
|
+
export type EventDelivery = {
|
|
160
|
+
url: string;
|
|
161
|
+
body: string;
|
|
162
|
+
headers: Record<string, string>;
|
|
163
|
+
};
|
|
164
|
+
/** The transport a delivery is made over; the World's by default, a test's own when given. */
|
|
165
|
+
export type EventTransport = (url: string, body: string, headers: Record<string, string>) => Promise<number | void>;
|
|
166
|
+
/** A signature scheme's headers for one delivery: deterministic (the id is derived from the delivery itself). */
|
|
167
|
+
/** `seq` tells apart two identical deliveries at one instant (a frozen World clock): the World's count of deliveries
|
|
168
|
+
* made before this one, so a receiver that deduplicates by id drops neither. */
|
|
169
|
+
export declare function signEvent(scheme: EventScheme, secret: string, body: string, seconds: number, url: string, seq?: number): Record<string, string>;
|
|
170
|
+
/** Every scheme's headers for one delivery, merged. */
|
|
171
|
+
export declare function signEventAll(schemes: EventScheme | ReadonlyArray<EventScheme>, secret: string, body: string, seconds: number, url: string, seq?: number): Record<string, string>;
|
|
172
|
+
/** A delivery's signature checked as its receiver checks it (a Standard Webhooks verifier, a timestamp-v1 verifier, a plain
|
|
173
|
+
* HMAC header): true when one of its signatures is the one the secret makes (constant time), and its timestamp,
|
|
174
|
+
* when the scheme sends one, is within `tolerance` seconds of `now`. */
|
|
175
|
+
export declare function verifyEvent(scheme: EventScheme, secret: string, body: string, headers: Record<string, string>, opts?: {
|
|
176
|
+
tolerance?: number;
|
|
177
|
+
now?: number;
|
|
178
|
+
}): boolean;
|
|
179
|
+
/** The envelope template filled for one event, and for one endpoint when it names `$endpoint.<field>` (left out when
|
|
180
|
+
* there is none: the event as kept). A `...` key spreads its filled value into the object. */
|
|
181
|
+
export declare function fillEnvelope(template: unknown, values: Record<string, unknown>, endpoint?: Record<string, unknown>): unknown;
|
|
182
|
+
/** The event types a write's operation sends: `types`, else the vendor's enumerated types (`known`). */
|
|
183
|
+
export declare function eventTypesOf(decl: Pick<EventsDecl, 'types' | 'known'>, operation: string): string[];
|
|
184
|
+
/** The event type a write's operation sends, if any: its first under `types`, else the vendor's enumerated types
|
|
185
|
+
* (`known`). */
|
|
186
|
+
export declare function eventTypeOf(decl: Pick<EventsDecl, 'types' | 'known'>, operation: string): string | undefined;
|
|
187
|
+
/** Whether an endpoint's list of event types takes a type: every type when the list is empty, `*` or `<family>.*`
|
|
188
|
+
* its patterns, else the type itself. */
|
|
189
|
+
export declare function takesEvent(list: unknown, type: string): boolean;
|
|
190
|
+
/** A message a vendor sends the application's own server that is no declared event (Google Calendar's push notification:
|
|
191
|
+
* a bodiless POST with its X-Goog headers), over the same transport: the receiver's status, or 0 when the World lets
|
|
192
|
+
* no request out to it or it cannot be reached. The vendor waits on it, as the sender of such a message does. */
|
|
193
|
+
export declare function deliverToApplication(url: string, init: RequestInit): Promise<number>;
|
|
194
|
+
/** What the application's server answered a message the vendor waits on, or why it did not answer: `missed` when the
|
|
195
|
+
* World let no request out to it or it could not be reached (`unreachable`), or it did not answer in time (`timeout`). */
|
|
196
|
+
export type ApplicationAnswer = {
|
|
197
|
+
status: number;
|
|
198
|
+
body: string;
|
|
199
|
+
missed?: 'timeout' | 'unreachable';
|
|
200
|
+
};
|
|
201
|
+
/** A message the vendor sends the application's server and decides by its answer (Stripe's real-time authorization
|
|
202
|
+
* request, answered within its window): over the same transport, waited on for at most `within` milliseconds. */
|
|
203
|
+
export declare function askApplication(url: string, init: RequestInit, within: number): Promise<ApplicationAnswer>;
|
|
204
|
+
/** Every event a write sends, rendered, stored when the vendor keeps its events, signed, delivered to each endpoint
|
|
205
|
+
* that takes it, and recorded. */
|
|
206
|
+
export declare function deliverEvents(service: string, decl: EventsDecl, write: EventWrite, data: (type: string) => Promise<Record<string, unknown>>, transport?: EventTransport, values?: (type: string) => Promise<Record<string, unknown>>, onAnswer?: (answer: DeliveryAnswer) => Promise<void>): Promise<EventDelivery[]>;
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
// DECLARED EVENTS (docs/contributing/architecture.md, "What an author writes, and how": the events row) — the webhook a
|
|
2
|
+
// vendor sends for a write, as data the kernel executes. A pack declares which write sends which event type, the
|
|
3
|
+
// event's envelope, the scheme that signs it and where the World's endpoints are kept; the kernel renders, signs,
|
|
4
|
+
// delivers (through the World's application route and egress rule) and records each delivery. The one thing a pack
|
|
5
|
+
// may write is how its vendor renders an event's `data` (`semantics/events.ts`), when it is not the written object.
|
|
6
|
+
//
|
|
7
|
+
// Imports stay free of side effects: the delivery's transport (Node's http, the World's egress policy) is loaded when a
|
|
8
|
+
// delivery is made, never when a browser client imports the kernel.
|
|
9
|
+
import { createHash, createHmac } from 'node:crypto';
|
|
10
|
+
import { applyTwinWrite, twinResources } from "./serve.js";
|
|
11
|
+
import { equalSecrets as same } from "./signing.js";
|
|
12
|
+
import { deliveryTraceHeaders } from "./trace-context.js";
|
|
13
|
+
/** Whether an endpoint takes an event by its `match`: every field of one match (any, when several) equal to the event's
|
|
14
|
+
* value, or, a list, holding it. */
|
|
15
|
+
function matches(row, match, filled) {
|
|
16
|
+
if (match === undefined)
|
|
17
|
+
return true;
|
|
18
|
+
const one = (m) => Object.entries(m).every(([field, value]) => {
|
|
19
|
+
const want = value.startsWith('$') ? filled[value] : value;
|
|
20
|
+
const have = at(row, field);
|
|
21
|
+
// a list value takes a row whose field is one of it (the apps a Slack message mentions)
|
|
22
|
+
if (Array.isArray(want))
|
|
23
|
+
return want.some((w) => String(w) === String(have));
|
|
24
|
+
return Array.isArray(have) ? have.some((h) => String(h) === String(want)) : have === want || (have !== undefined && want !== undefined && String(have) === String(want));
|
|
25
|
+
});
|
|
26
|
+
return Array.isArray(match) ? match.some(one) : one(match);
|
|
27
|
+
}
|
|
28
|
+
const digest = (s) => createHash('sha256').update(s).digest('hex');
|
|
29
|
+
/** A `webhook-id` scheme's key: the base64 part of the secret, after its prefix when it carries one. */
|
|
30
|
+
const webhookKey = (scheme, secret) => scheme.secretKey === 'utf8' ? Buffer.from(secret, 'utf8') : Buffer.from(scheme.secretPrefix && secret.startsWith(scheme.secretPrefix) ? secret.slice(scheme.secretPrefix.length) : secret, 'base64');
|
|
31
|
+
/** A signature scheme's headers for one delivery: deterministic (the id is derived from the delivery itself). */
|
|
32
|
+
/** `seq` tells apart two identical deliveries at one instant (a frozen World clock): the World's count of deliveries
|
|
33
|
+
* made before this one, so a receiver that deduplicates by id drops neither. */
|
|
34
|
+
export function signEvent(scheme, secret, body, seconds, url, seq = 0) {
|
|
35
|
+
if (scheme.kind === 'webhook-id') {
|
|
36
|
+
const id = `msg_twin_${digest(`${url}|${seconds}|${seq}|${body}`).slice(0, 24)}`;
|
|
37
|
+
const p = scheme.prefix.toLowerCase();
|
|
38
|
+
const signature = createHmac('sha256', webhookKey(scheme, secret)).update(`${id}.${seconds}.${body}`, 'utf8').digest('base64');
|
|
39
|
+
return { [`${p}-id`]: id, [`${p}-timestamp`]: String(seconds), [`${p}-signature`]: `v1,${signature}` };
|
|
40
|
+
}
|
|
41
|
+
if (scheme.kind === 'timestamp-v1') {
|
|
42
|
+
const signature = createHmac('sha256', secret).update(`${seconds}.${body}`, 'utf8').digest('hex');
|
|
43
|
+
return { [scheme.header.toLowerCase()]: `t=${seconds},v1=${signature}` };
|
|
44
|
+
}
|
|
45
|
+
if (scheme.kind === 'hash')
|
|
46
|
+
return { [scheme.header.toLowerCase()]: hashOf(scheme, secret, body) };
|
|
47
|
+
// the key is the secret as it is, or its base64 decoded (`secretKey: 'base64'`, Daily's)
|
|
48
|
+
const mac = createHmac(scheme.algorithm ?? 'sha256', scheme.secretKey === 'base64' ? Buffer.from(secret, 'base64') : secret).update(hmacBase(scheme, seconds, body), 'utf8').digest(scheme.encoding);
|
|
49
|
+
return { [scheme.header.toLowerCase()]: `${scheme.prefix ?? ''}${mac}`, ...(scheme.timestampHeader ? { [scheme.timestampHeader.toLowerCase()]: String(seconds) } : {}) };
|
|
50
|
+
}
|
|
51
|
+
/** A `hash` scheme's value: the digest of its `signed` template with the secret and the body put in. */
|
|
52
|
+
const hashOf = (scheme, secret, body) => createHash(scheme.algorithm ?? 'sha256').update(scheme.signed.replaceAll('{secret}', secret).replaceAll('{body}', body), 'utf8').digest(scheme.encoding);
|
|
53
|
+
/** What an `hmac` scheme signs: its `signed` template, else `${t}.${body}` with a timestamp header, else the body. */
|
|
54
|
+
const hmacBase = (scheme, seconds, body) => scheme.signed !== undefined ? scheme.signed.replaceAll('{t}', String(seconds)).replaceAll('{body}', body) : scheme.timestampHeader ? `${seconds}.${body}` : body;
|
|
55
|
+
/** Every scheme's headers for one delivery, merged. */
|
|
56
|
+
export function signEventAll(schemes, secret, body, seconds, url, seq = 0) {
|
|
57
|
+
return Object.assign({}, ...(Array.isArray(schemes) ? schemes : [schemes]).map((scheme) => signEvent(scheme, secret, body, seconds, url, seq)));
|
|
58
|
+
}
|
|
59
|
+
/** A delivery's signature checked as its receiver checks it (a Standard Webhooks verifier, a timestamp-v1 verifier, a plain
|
|
60
|
+
* HMAC header): true when one of its signatures is the one the secret makes (constant time), and its timestamp,
|
|
61
|
+
* when the scheme sends one, is within `tolerance` seconds of `now`. */
|
|
62
|
+
export function verifyEvent(scheme, secret, body, headers, opts = {}) {
|
|
63
|
+
const h = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]));
|
|
64
|
+
// a check given the time holds the timestamp to five minutes unless it says otherwise (Standard Webhooks' default)
|
|
65
|
+
const fresh = (seconds) => opts.now === undefined || Math.abs(opts.now - seconds) <= (opts.tolerance ?? 300);
|
|
66
|
+
if (secret === '')
|
|
67
|
+
return false;
|
|
68
|
+
if (scheme.kind === 'webhook-id') {
|
|
69
|
+
const p = scheme.prefix.toLowerCase();
|
|
70
|
+
const id = h[`${p}-id`];
|
|
71
|
+
const seconds = Number(h[`${p}-timestamp`]);
|
|
72
|
+
const given = h[`${p}-signature`];
|
|
73
|
+
if (!id || !given || !Number.isFinite(seconds) || !fresh(seconds))
|
|
74
|
+
return false;
|
|
75
|
+
const expected = createHmac('sha256', webhookKey(scheme, secret)).update(`${id}.${seconds}.${body}`, 'utf8').digest('base64');
|
|
76
|
+
return given.split(' ').some((part) => part.startsWith('v1,') && same(part.slice('v1,'.length), expected));
|
|
77
|
+
}
|
|
78
|
+
if (scheme.kind === 'timestamp-v1') {
|
|
79
|
+
const parts = (h[scheme.header.toLowerCase()] ?? '').split(',').map((p) => p.split('='));
|
|
80
|
+
const seconds = Number(parts.find(([k]) => k === 't')?.[1]);
|
|
81
|
+
if (!Number.isFinite(seconds) || !fresh(seconds))
|
|
82
|
+
return false;
|
|
83
|
+
const expected = createHmac('sha256', secret).update(`${seconds}.${body}`, 'utf8').digest('hex');
|
|
84
|
+
return parts.some(([k, v]) => k === 'v1' && v !== undefined && same(v, expected));
|
|
85
|
+
}
|
|
86
|
+
if (scheme.kind === 'hash') {
|
|
87
|
+
const given = h[scheme.header.toLowerCase()];
|
|
88
|
+
return given !== undefined && same(given, hashOf(scheme, secret, body));
|
|
89
|
+
}
|
|
90
|
+
const seconds = scheme.timestampHeader ? Number(h[scheme.timestampHeader.toLowerCase()]) : undefined;
|
|
91
|
+
if (seconds !== undefined && (!Number.isFinite(seconds) || !fresh(seconds)))
|
|
92
|
+
return false;
|
|
93
|
+
const expected = `${scheme.prefix ?? ''}${createHmac(scheme.algorithm ?? 'sha256', secret).update(hmacBase(scheme, seconds ?? 0, body), 'utf8').digest(scheme.encoding)}`;
|
|
94
|
+
return same(h[scheme.header.toLowerCase()] ?? '', expected);
|
|
95
|
+
}
|
|
96
|
+
/** The envelope template filled for one event, and for one endpoint when it names `$endpoint.<field>` (left out when
|
|
97
|
+
* there is none: the event as kept). A `...` key spreads its filled value into the object. */
|
|
98
|
+
export function fillEnvelope(template, values, endpoint) {
|
|
99
|
+
if (typeof template === 'string') {
|
|
100
|
+
if (template.startsWith('$endpoint.'))
|
|
101
|
+
return endpoint ? at(endpoint, template.slice('$endpoint.'.length)) ?? undefined : undefined;
|
|
102
|
+
return template.startsWith('$') && template in values ? values[template] : template;
|
|
103
|
+
}
|
|
104
|
+
if (Array.isArray(template))
|
|
105
|
+
return template.map((item) => fillEnvelope(item, values, endpoint));
|
|
106
|
+
// a key whose value is a `$` value left undefined is not sent (an event about no connected account has no `account`)
|
|
107
|
+
if (template && typeof template === 'object') {
|
|
108
|
+
const out = {};
|
|
109
|
+
for (const [key, value] of Object.entries(template)) {
|
|
110
|
+
const filled = fillEnvelope(value, values, endpoint);
|
|
111
|
+
if (key === '...') {
|
|
112
|
+
if (filled && typeof filled === 'object' && !Array.isArray(filled))
|
|
113
|
+
Object.assign(out, filled);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (filled !== undefined)
|
|
117
|
+
out[key] = filled;
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
return template;
|
|
122
|
+
}
|
|
123
|
+
/** Whether an envelope template names an endpoint's fields (so each delivery's body is its own). */
|
|
124
|
+
const perEndpoint = (template) => JSON.stringify(template ?? null).includes('"$endpoint.');
|
|
125
|
+
/** A row's field by a dotted path (`config.url`). */
|
|
126
|
+
function at(row, path) {
|
|
127
|
+
let v = row;
|
|
128
|
+
for (const key of path.split('.'))
|
|
129
|
+
v = v && typeof v === 'object' ? v[key] : undefined;
|
|
130
|
+
return v;
|
|
131
|
+
}
|
|
132
|
+
/** A delivery's further headers: each a `$` value, an endpoint's `$endpoint.<field>`, or written as it is. */
|
|
133
|
+
function headersFor(templates, values, endpoint) {
|
|
134
|
+
const out = {};
|
|
135
|
+
for (const [name, template] of Object.entries(templates)) {
|
|
136
|
+
// a literal may stand before an endpoint's field (`Basic $endpoint.basicAuth`); the header is dropped when it is empty
|
|
137
|
+
const prefixed = /^(.+?)\$endpoint\.([\w.]+)$/.exec(template);
|
|
138
|
+
const field = prefixed ? at(endpoint, prefixed[2]) : undefined;
|
|
139
|
+
const value = prefixed ? (field === undefined || field === null || field === '' ? undefined : `${prefixed[1]}${String(field)}`)
|
|
140
|
+
: template.startsWith('$endpoint.') ? at(endpoint, template.slice('$endpoint.'.length)) : template.startsWith('$') ? values[template] : template;
|
|
141
|
+
if (value !== undefined && value !== null)
|
|
142
|
+
out[name.toLowerCase()] = String(value);
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
const knownSets = new WeakMap();
|
|
147
|
+
/** The event types a write's operation sends: `types`, else the vendor's enumerated types (`known`). */
|
|
148
|
+
export function eventTypesOf(decl, operation) {
|
|
149
|
+
const named = decl.types[operation];
|
|
150
|
+
if (named !== undefined)
|
|
151
|
+
return typeof named === 'string' ? [named] : [...named];
|
|
152
|
+
const one = eventTypeOf(decl, operation);
|
|
153
|
+
return one === undefined ? [] : [one];
|
|
154
|
+
}
|
|
155
|
+
/** The event type a write's operation sends, if any: its first under `types`, else the vendor's enumerated types
|
|
156
|
+
* (`known`). */
|
|
157
|
+
export function eventTypeOf(decl, operation) {
|
|
158
|
+
const given = decl.types[operation];
|
|
159
|
+
const named = typeof given === 'string' || given === undefined ? given : given[0];
|
|
160
|
+
if (named !== undefined || !decl.known)
|
|
161
|
+
return named;
|
|
162
|
+
let known = knownSets.get(decl.known);
|
|
163
|
+
if (!known)
|
|
164
|
+
knownSets.set(decl.known, known = new Set(decl.known));
|
|
165
|
+
if (known.has(operation))
|
|
166
|
+
return operation;
|
|
167
|
+
const verb = /^(.+)\.(create|update|delete)$/.exec(operation);
|
|
168
|
+
return verb && known.has(`${verb[1]}.${verb[2]}d`) ? `${verb[1]}.${verb[2]}d` : undefined;
|
|
169
|
+
}
|
|
170
|
+
/** Whether an endpoint's list of event types takes a type: every type when the list is empty, `*` or `<family>.*`
|
|
171
|
+
* its patterns, else the type itself. */
|
|
172
|
+
export function takesEvent(list, type) {
|
|
173
|
+
if (!Array.isArray(list) || list.length === 0)
|
|
174
|
+
return true;
|
|
175
|
+
return list.some((p) => { const s = String(p); return s === '*' || s === type || (s.endsWith('.*') && type.startsWith(s.slice(0, -1))); });
|
|
176
|
+
}
|
|
177
|
+
/** The request that made the write, as an event's attributes name it: the client's address (the first hop a proxy
|
|
178
|
+
* names) and its user agent. */
|
|
179
|
+
function requestValues(request) {
|
|
180
|
+
const forwarded = request?.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
|
|
181
|
+
return {
|
|
182
|
+
'$request.client_ip': forwarded ?? request?.headers.get('x-real-ip') ?? '',
|
|
183
|
+
'$request.user_agent': request?.headers.get('user-agent') ?? '',
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/** The World's transport: its application route (the app's own hostnames reach it inside the World) and its egress
|
|
187
|
+
* rule (a refused destination is dropped as an unreachable endpoint is), never awaited by the write that sent it (deliverEvents schedules it after the answer). Loaded at
|
|
188
|
+
* the first delivery, so importing the kernel stays free of Node's http. */
|
|
189
|
+
const worldTransport = async (url, body, headers) => {
|
|
190
|
+
const [{ appDestination, appFetch }, { worldEgressRefusal }] = await Promise.all([
|
|
191
|
+
import('../app-route.cjs'),
|
|
192
|
+
import('../network-policy.cjs'),
|
|
193
|
+
]);
|
|
194
|
+
if (!appDestination(url) && worldEgressRefusal(url) !== null)
|
|
195
|
+
return 0;
|
|
196
|
+
try {
|
|
197
|
+
return (await appFetch(url, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body })).status;
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
/** A message a vendor sends the application's own server that is no declared event (Google Calendar's push notification:
|
|
204
|
+
* a bodiless POST with its X-Goog headers), over the same transport: the receiver's status, or 0 when the World lets
|
|
205
|
+
* no request out to it or it cannot be reached. The vendor waits on it, as the sender of such a message does. */
|
|
206
|
+
export async function deliverToApplication(url, init) {
|
|
207
|
+
const [{ appDestination, appFetch }, { worldEgressRefusal }] = await Promise.all([
|
|
208
|
+
import('../app-route.cjs'),
|
|
209
|
+
import('../network-policy.cjs'),
|
|
210
|
+
]);
|
|
211
|
+
if (!appDestination(url) && worldEgressRefusal(url) !== null)
|
|
212
|
+
return 0;
|
|
213
|
+
// the request that caused the message is its trace's parent (trace-context.ts)
|
|
214
|
+
const headers = new Headers(init.headers);
|
|
215
|
+
for (const [k, v] of Object.entries(deliveryTraceHeaders()))
|
|
216
|
+
if (!headers.has(k))
|
|
217
|
+
headers.set(k, v);
|
|
218
|
+
try {
|
|
219
|
+
return (await appFetch(url, { ...init, headers })).status;
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/** A message the vendor sends the application's server and decides by its answer (Stripe's real-time authorization
|
|
226
|
+
* request, answered within its window): over the same transport, waited on for at most `within` milliseconds. */
|
|
227
|
+
export async function askApplication(url, init, within) {
|
|
228
|
+
const [{ appDestination, appFetch }, { worldEgressRefusal }] = await Promise.all([
|
|
229
|
+
import('../app-route.cjs'),
|
|
230
|
+
import('../network-policy.cjs'),
|
|
231
|
+
]);
|
|
232
|
+
if (!appDestination(url) && worldEgressRefusal(url) !== null)
|
|
233
|
+
return { status: 0, body: '', missed: 'unreachable' };
|
|
234
|
+
const headers = new Headers(init.headers);
|
|
235
|
+
for (const [k, v] of Object.entries(deliveryTraceHeaders()))
|
|
236
|
+
if (!headers.has(k))
|
|
237
|
+
headers.set(k, v);
|
|
238
|
+
try {
|
|
239
|
+
const res = await appFetch(url, { ...init, headers, signal: AbortSignal.timeout(within) });
|
|
240
|
+
return { status: res.status, body: await res.text() };
|
|
241
|
+
}
|
|
242
|
+
catch (e) {
|
|
243
|
+
return { status: 0, body: '', missed: e instanceof Error && (e.name === 'TimeoutError' || e.name === 'AbortError') ? 'timeout' : 'unreachable' };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/** Every event a write sends, rendered, stored when the vendor keeps its events, signed, delivered to each endpoint
|
|
247
|
+
* that takes it, and recorded. */
|
|
248
|
+
export async function deliverEvents(service, decl, write, data, transport = worldTransport, values, onAnswer) {
|
|
249
|
+
if (write.storedType === decl.store)
|
|
250
|
+
return [];
|
|
251
|
+
const sent = [];
|
|
252
|
+
for (const type of eventTypesOf(decl, write.operation))
|
|
253
|
+
sent.push(...(await deliverEventType(service, decl, write, data, transport, values, type, onAnswer)));
|
|
254
|
+
return sent;
|
|
255
|
+
}
|
|
256
|
+
/** One event type a write sends: to the live endpoints that take it, stored when the vendor keeps its events. */
|
|
257
|
+
async function deliverEventType(service, decl, write, data, transport, values, type, onAnswer) {
|
|
258
|
+
const all = twinResources(service, write.root);
|
|
259
|
+
const kinds = Array.isArray(decl.endpoints) ? decl.endpoints : [decl.endpoints];
|
|
260
|
+
// an endpoint's row with its joined rows under their names (`app.hook_url` reads the installation's App)
|
|
261
|
+
const joined = (kind, r) => {
|
|
262
|
+
if (!kind.joins)
|
|
263
|
+
return r;
|
|
264
|
+
const out = { ...r, id: r.id };
|
|
265
|
+
for (const [name, j] of Object.entries(kind.joins))
|
|
266
|
+
out[name] = all.find((x) => x.type === j.storedAs && x.deleted !== true && String(x.id) === String(at(r, j.by)));
|
|
267
|
+
return out;
|
|
268
|
+
};
|
|
269
|
+
const live = kinds.filter((kind) => (!kind.types || kind.types.includes(type)) && !kind.skip?.includes(type)).flatMap((kind) => all
|
|
270
|
+
.filter((r) => r.type === kind.storedAs && r.deleted !== true)
|
|
271
|
+
.map((r) => joined(kind, r))
|
|
272
|
+
.filter((r) => (kind.disabled === undefined || at(r, kind.disabled) !== true) && (kind.enabled === undefined || at(r, kind.enabled) !== false) && (kind.status === undefined || at(r, kind.status.field) === kind.status.live))
|
|
273
|
+
.filter((r) => typeof at(r, kind.url) === 'string' && typeof at(r, kind.secret) === 'string')
|
|
274
|
+
.filter((r) => kind.filter === undefined || takesEvent(at(r, kind.filter), type))
|
|
275
|
+
.map((row) => ({ row, kind })));
|
|
276
|
+
if (live.length === 0 && decl.store === undefined)
|
|
277
|
+
return [];
|
|
278
|
+
const millis = Date.parse(write.occurredAt) || 0;
|
|
279
|
+
const seconds = Math.floor(millis / 1000);
|
|
280
|
+
const eventData = await data(type);
|
|
281
|
+
const extra = values ? await values(type) : {};
|
|
282
|
+
// the World's count of events kept so far tells apart two identical events at one instant (a frozen clock)
|
|
283
|
+
const kept = decl.store !== undefined ? all.filter((r) => r.type === decl.store).length : 0;
|
|
284
|
+
const filled = {
|
|
285
|
+
$type: type, $data: eventData,
|
|
286
|
+
$id: `evt_twin_${digest(`${type}|${write.occurredAt}|${kept}|${JSON.stringify(eventData)}`).slice(0, 24)}`,
|
|
287
|
+
'$time.ms': millis, '$time.s': seconds, '$time.iso': write.occurredAt,
|
|
288
|
+
...requestValues(write.request),
|
|
289
|
+
...extra,
|
|
290
|
+
};
|
|
291
|
+
const envelope = fillEnvelope(decl.envelope, filled);
|
|
292
|
+
if (decl.store !== undefined) {
|
|
293
|
+
await applyTwinWrite(service, { operation: `${decl.store}.record`, subjectType: decl.store, subjectId: String(filled.$id), fields: envelope, occurredAt: write.occurredAt, actor: { kind: 'system' } }, write.root);
|
|
294
|
+
}
|
|
295
|
+
if (extra.$send === false)
|
|
296
|
+
return [];
|
|
297
|
+
const endpoints = live
|
|
298
|
+
.filter(({ row, kind }) => kind.scope === undefined || (at(row, kind.scope.field) === true) === (filled[kind.scope.value] !== undefined))
|
|
299
|
+
.filter(({ row, kind }) => matches(row, kind.match, filled));
|
|
300
|
+
if (endpoints.length === 0)
|
|
301
|
+
return [];
|
|
302
|
+
const shared = JSON.stringify(envelope);
|
|
303
|
+
const each = perEndpoint(decl.envelope);
|
|
304
|
+
const sent = [];
|
|
305
|
+
const to = [];
|
|
306
|
+
const before = decl.record !== undefined ? all.filter((r) => r.type === decl.record).length : 0;
|
|
307
|
+
for (const [i, { row, kind }] of endpoints.entries()) {
|
|
308
|
+
const body = each ? JSON.stringify(fillEnvelope(decl.envelope, filled, row)) : shared;
|
|
309
|
+
const url = String(at(row, kind.url));
|
|
310
|
+
const seq = before + i;
|
|
311
|
+
const extraHeaders = decl.headers ? headersFor(decl.headers, filled, row) : {};
|
|
312
|
+
const headers = { ...extraHeaders, ...signEventAll(decl.scheme, String(at(row, kind.secret)), body, seconds, url, seq) };
|
|
313
|
+
sent.push({ url, body, headers });
|
|
314
|
+
to.push({ kind, row });
|
|
315
|
+
// kept with the write, by an id of the delivery itself (two made at once never take the same one)
|
|
316
|
+
if (decl.record !== undefined) {
|
|
317
|
+
await applyTwinWrite(service, {
|
|
318
|
+
operation: `${decl.record.slice(1)}.record`, subjectType: decl.record, subjectId: `${decl.record.slice(1)}_${digest(`${url}|${seconds}|${seq}|${body}`).slice(0, 16)}`,
|
|
319
|
+
fields: { url, body, headers, event_type: type, sent_at: millis }, occurredAt: write.occurredAt, actor: { kind: 'system' },
|
|
320
|
+
}, write.root);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
// sent after the write's answer, as a vendor sends it: the caller never waits on its receiver, and a receiver that is
|
|
324
|
+
// slow or down fails no request
|
|
325
|
+
// the write's trace is read now, inside its request, and carried on each delivery (W3C trace continuation)
|
|
326
|
+
const trace = deliveryTraceHeaders();
|
|
327
|
+
// each answer is read as the vendor reads it, when the endpoint kind keeps score (its `outcome`)
|
|
328
|
+
const stated = (url) => {
|
|
329
|
+
const r = decl.receivers === undefined ? undefined : all.filter((x) => x.type === decl.receivers && x.deleted !== true && url.startsWith(String(x.url)))
|
|
330
|
+
.sort((x, y) => String(y.url).length - String(x.url).length)[0];
|
|
331
|
+
return r === undefined ? undefined : Number(r.status);
|
|
332
|
+
};
|
|
333
|
+
for (const [i, { url, body: payload, headers }] of sent.entries())
|
|
334
|
+
setTimeout(() => {
|
|
335
|
+
const answered = stated(url);
|
|
336
|
+
(answered !== undefined ? Promise.resolve(answered) : transport(url, payload, { ...headers, ...trace }))
|
|
337
|
+
.then((status) => (onAnswer && to[i].kind.outcome ? onAnswer({ ...to[i], status: typeof status === 'number' ? status : 0, occurredAt: write.occurredAt }) : undefined))
|
|
338
|
+
.catch(() => { });
|
|
339
|
+
}, 0);
|
|
340
|
+
return sent;
|
|
341
|
+
}
|