@volter/world-core 2.0.37 → 3.0.1
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/src/events.ts
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
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.ts';
|
|
11
|
+
import { equalSecrets as same } from './signing.ts';
|
|
12
|
+
import { deliveryTraceHeaders } from './trace-context.ts';
|
|
13
|
+
|
|
14
|
+
/** How the vendor signs a delivery. */
|
|
15
|
+
export type EventScheme =
|
|
16
|
+
/** A message id, a timestamp and a signature header (the Standard Webhooks shape): `<prefix>-id`,
|
|
17
|
+
* `<prefix>-timestamp` (seconds) and `<prefix>-signature: v1,<base64 HMAC-SHA256>` over `${id}.${timestamp}.${body}`,
|
|
18
|
+
* keyed by the base64 part of the secret after `secretPrefix`, or by the secret's own UTF-8 bytes (`secretKey: 'utf8'`:
|
|
19
|
+
* Polar's, whose SDK base64-encodes the secret as it is before verifying). */
|
|
20
|
+
| { kind: 'webhook-id'; prefix: string; secretPrefix?: string; secretKey?: 'base64' | 'utf8' }
|
|
21
|
+
/** 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
|
+
| { kind: 'timestamp-v1'; header: string }
|
|
24
|
+
/** One HMAC header over the body (`X-Hub-Signature-256: sha256=<hex>`, GitHub's), or over `${timestamp}.${body}` when
|
|
25
|
+
* the vendor sends its timestamp in `timestampHeader`; `signed` names what is signed otherwise, `{t}` the timestamp
|
|
26
|
+
* and `{body}` the body (Slack's `v0:{t}:{body}`), and `algorithm` the hash (SHA-256 unless named: GitHub's older
|
|
27
|
+
* `X-Hub-Signature` is SHA-1). */
|
|
28
|
+
| { kind: 'hmac'; header: string; encoding: 'hex' | 'base64'; prefix?: string; timestampHeader?: string; signed?: string; algorithm?: 'sha1' | 'sha256'; secretKey?: 'base64' | 'utf8' }
|
|
29
|
+
/** 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
|
+
| { kind: 'hash'; header: string; signed: string; encoding: 'hex' | 'base64'; algorithm?: 'sha256' };
|
|
32
|
+
|
|
33
|
+
/** Where the World's endpoints of one kind are kept (a door the vendor's dashboard stands in for writes them): their
|
|
34
|
+
* stored type and the fields holding the URL, the signing secret, the event types an endpoint takes (all when absent,
|
|
35
|
+
* `*` and `<family>.*` patterns) and whether it is disabled (`disabled` true), not enabled (`enabled` false) or not live
|
|
36
|
+
* (`status`). A field may be a dotted path into the row (`config.url`). */
|
|
37
|
+
export type EndpointsDecl = {
|
|
38
|
+
storedAs: string; url: string; secret: string; filter?: string; disabled?: string; enabled?: string;
|
|
39
|
+
/** An endpoint that is live only while a field holds one value (Stripe's `status: 'enabled'`). */
|
|
40
|
+
status?: { field: string; live: string };
|
|
41
|
+
/** Which events an endpoint takes by whom they are about: one whose `field` is true takes only events whose
|
|
42
|
+
* envelope value `value` is set (Stripe's Connect endpoint, `connect: true`, takes connected accounts' events, those
|
|
43
|
+
* with an `$account`), and any other only events where it is not. */
|
|
44
|
+
scope?: { field: string; value: string };
|
|
45
|
+
/** Endpoint fields that must equal an event's values (field → `$value`, or a literal): a repository's hook takes only
|
|
46
|
+
* its repository's events (GitHub's `{ repository: '$repository' }`), an organization's its organization's. A list
|
|
47
|
+
* field takes the event when it holds the value. Several matches: the endpoint takes the event when any holds (a
|
|
48
|
+
* GitHub App's installation: its own events, its account's when it covers all the account's repositories, a listed
|
|
49
|
+
* repository's). */
|
|
50
|
+
match?: Record<string, string> | ReadonlyArray<Record<string, string>>;
|
|
51
|
+
/** Related rows an endpoint's fields read through (`app.hook_url`): each join's name, the stored type it names and the
|
|
52
|
+
* endpoint's field holding its id (a GitHub installation's URL, secret and events are its App's). */
|
|
53
|
+
joins?: Record<string, { storedAs: string; by: string }>;
|
|
54
|
+
/** The event types this kind of endpoint takes (every type when absent), or those it does not (`skip`): two kinds over
|
|
55
|
+
* the same rows, one taking a type under a `match` the other has no need of (a Slack app is sent `app_mention` only
|
|
56
|
+
* when a message mentions it, and every `message` its subscription names). */
|
|
57
|
+
types?: ReadonlyArray<string>;
|
|
58
|
+
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
|
+
};
|
|
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
|
+
/** Whether an endpoint takes an event by its `match`: every field of one match (any, when several) equal to the event's
|
|
80
|
+
* value, or, a list, holding it. */
|
|
81
|
+
function matches(row: Record<string, unknown>, match: EndpointsDecl['match'], filled: Record<string, unknown>): boolean {
|
|
82
|
+
if (match === undefined) return true;
|
|
83
|
+
const one = (m: Record<string, string>): boolean => Object.entries(m).every(([field, value]) => {
|
|
84
|
+
const want = value.startsWith('$') ? filled[value] : value;
|
|
85
|
+
const have = at(row, field);
|
|
86
|
+
// a list value takes a row whose field is one of it (the apps a Slack message mentions)
|
|
87
|
+
if (Array.isArray(want)) return want.some((w) => String(w) === String(have));
|
|
88
|
+
return Array.isArray(have) ? have.some((h) => String(h) === String(want)) : have === want || (have !== undefined && want !== undefined && String(have) === String(want));
|
|
89
|
+
});
|
|
90
|
+
return Array.isArray(match) ? match.some(one) : one(match as Record<string, string>);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The events a pack's writes send. */
|
|
94
|
+
export type EventsDecl = {
|
|
95
|
+
/** how each delivery is signed; several schemes each add their headers (GitHub's SHA-256 and SHA-1 signatures) */
|
|
96
|
+
scheme: EventScheme | ReadonlyArray<EventScheme>;
|
|
97
|
+
/** further headers each delivery carries, templates as the envelope's are, plus `$endpoint.<field>` for the endpoint
|
|
98
|
+
* it goes to (GitHub's `X-GitHub-Event`, `X-GitHub-Delivery` and `X-GitHub-Hook-ID`) */
|
|
99
|
+
headers?: Record<string, string>;
|
|
100
|
+
/** A write's kernel operation (`user.create`) → the vendor's event type (`user.created`), or several (a Slack message
|
|
101
|
+
* sends `message` and `app_mention`; `values` withholds one the write does not warrant); a write not named sends none. */
|
|
102
|
+
types: Record<string, string | ReadonlyArray<string>>;
|
|
103
|
+
/** The event types the vendor enumerates, when it names every write's event by its resource: an operation that is
|
|
104
|
+
* one of them sends it, and `<resource>.create|update|delete` sends `<resource>.created|updated|deleted` when that is
|
|
105
|
+
* one of them (a vendor whose spec lists its event types: Stripe's). `types` is read first. */
|
|
106
|
+
known?: ReadonlyArray<string>;
|
|
107
|
+
/** The vendor's own resource each event is also kept as (its Events API: Stripe's `GET /v1/events`), the envelope as
|
|
108
|
+
* its fields under the event's id, whether or not an endpoint takes it. */
|
|
109
|
+
store?: string;
|
|
110
|
+
/** The event as the vendor sends it, a template: a string `$type`, `$data`, `$id`, `$time.ms`, `$time.s`, `$time.iso`,
|
|
111
|
+
* `$request.client_ip` or `$request.user_agent` is replaced by that value; everything else is sent as written. */
|
|
112
|
+
envelope: unknown;
|
|
113
|
+
/** Where the World's endpoints are kept: one kind, or several (GitHub's repository and organization hooks). */
|
|
114
|
+
endpoints: EndpointsDecl | ReadonlyArray<EndpointsDecl>;
|
|
115
|
+
/** The bookkeeping type each delivery is kept as (`_webhook_message`): what a World reads to see what was sent. */
|
|
116
|
+
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
|
+
};
|
|
122
|
+
|
|
123
|
+
/** An event's further envelope values (`$`-named) the pack computes for a write, from the vendor's own state (the
|
|
124
|
+
* connected account it is about, the API version it is rendered in): the pack's `semantics/events.ts` `values`. A
|
|
125
|
+
* value left undefined drops its key from the envelope; `$send: false` keeps the event (stored) but sends it to no
|
|
126
|
+
* endpoint (an account the vendor no longer reports to). */
|
|
127
|
+
export type EventValues<C> = (ctx: C, write: EventWrite, type: string) => Record<string, unknown>;
|
|
128
|
+
|
|
129
|
+
/** The write an event reports, as the kernel's write hook sees it. */
|
|
130
|
+
export type EventWrite = { operation: string; storedType: string; body: Record<string, unknown>; root?: string; occurredAt: string; request?: Request };
|
|
131
|
+
|
|
132
|
+
/** How the vendor renders an event's `data` for a write, when it is not the written object as its operation answered
|
|
133
|
+
* it: the pack's `semantics/events.ts`, under the handler contract's read-only context, for each type the write sends
|
|
134
|
+
* (a Slack message renders as `message` and as `app_mention`). */
|
|
135
|
+
export type EventRender<C> = (ctx: C, write: EventWrite, type: string) => Record<string, unknown>;
|
|
136
|
+
|
|
137
|
+
/** A delivery made: where it went and exactly what was sent. */
|
|
138
|
+
export type EventDelivery = { url: string; body: string; headers: Record<string, string> };
|
|
139
|
+
|
|
140
|
+
/** The transport a delivery is made over; the World's by default, a test's own when given. */
|
|
141
|
+
export type EventTransport = (url: string, body: string, headers: Record<string, string>) => Promise<number | void>;
|
|
142
|
+
|
|
143
|
+
const digest = (s: string): string => createHash('sha256').update(s).digest('hex');
|
|
144
|
+
|
|
145
|
+
/** A `webhook-id` scheme's key: the base64 part of the secret, after its prefix when it carries one. */
|
|
146
|
+
const webhookKey = (scheme: { secretPrefix?: string; secretKey?: 'base64' | 'utf8' }, secret: string): Buffer =>
|
|
147
|
+
scheme.secretKey === 'utf8' ? Buffer.from(secret, 'utf8') : Buffer.from(scheme.secretPrefix && secret.startsWith(scheme.secretPrefix) ? secret.slice(scheme.secretPrefix.length) : secret, 'base64');
|
|
148
|
+
|
|
149
|
+
/** A signature scheme's headers for one delivery: deterministic (the id is derived from the delivery itself). */
|
|
150
|
+
/** `seq` tells apart two identical deliveries at one instant (a frozen World clock): the World's count of deliveries
|
|
151
|
+
* made before this one, so a receiver that deduplicates by id drops neither. */
|
|
152
|
+
export function signEvent(scheme: EventScheme, secret: string, body: string, seconds: number, url: string, seq = 0): Record<string, string> {
|
|
153
|
+
if (scheme.kind === 'webhook-id') {
|
|
154
|
+
const id = `msg_twin_${digest(`${url}|${seconds}|${seq}|${body}`).slice(0, 24)}`;
|
|
155
|
+
const p = scheme.prefix.toLowerCase();
|
|
156
|
+
const signature = createHmac('sha256', webhookKey(scheme, secret)).update(`${id}.${seconds}.${body}`, 'utf8').digest('base64');
|
|
157
|
+
return { [`${p}-id`]: id, [`${p}-timestamp`]: String(seconds), [`${p}-signature`]: `v1,${signature}` };
|
|
158
|
+
}
|
|
159
|
+
if (scheme.kind === 'timestamp-v1') {
|
|
160
|
+
const signature = createHmac('sha256', secret).update(`${seconds}.${body}`, 'utf8').digest('hex');
|
|
161
|
+
return { [scheme.header.toLowerCase()]: `t=${seconds},v1=${signature}` };
|
|
162
|
+
}
|
|
163
|
+
if (scheme.kind === 'hash') return { [scheme.header.toLowerCase()]: hashOf(scheme, secret, body) };
|
|
164
|
+
// the key is the secret as it is, or its base64 decoded (`secretKey: 'base64'`, Daily's)
|
|
165
|
+
const mac = createHmac(scheme.algorithm ?? 'sha256', scheme.secretKey === 'base64' ? Buffer.from(secret, 'base64') : secret).update(hmacBase(scheme, seconds, body), 'utf8').digest(scheme.encoding);
|
|
166
|
+
return { [scheme.header.toLowerCase()]: `${scheme.prefix ?? ''}${mac}`, ...(scheme.timestampHeader ? { [scheme.timestampHeader.toLowerCase()]: String(seconds) } : {}) };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** 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').digest(scheme.encoding);
|
|
172
|
+
|
|
173
|
+
/** What an `hmac` scheme signs: its `signed` template, else `${t}.${body}` with a timestamp header, else the body. */
|
|
174
|
+
const hmacBase = (scheme: { timestampHeader?: string; signed?: string }, seconds: number, body: string): string =>
|
|
175
|
+
scheme.signed !== undefined ? scheme.signed.replaceAll('{t}', String(seconds)).replaceAll('{body}', body) : scheme.timestampHeader ? `${seconds}.${body}` : body;
|
|
176
|
+
|
|
177
|
+
/** Every scheme's headers for one delivery, merged. */
|
|
178
|
+
export function signEventAll(schemes: EventScheme | ReadonlyArray<EventScheme>, secret: string, body: string, seconds: number, url: string, seq = 0): Record<string, string> {
|
|
179
|
+
return Object.assign({}, ...(Array.isArray(schemes) ? schemes : [schemes]).map((scheme: EventScheme) => signEvent(scheme, secret, body, seconds, url, seq)));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** A delivery's signature checked as its receiver checks it (a Standard Webhooks verifier, a timestamp-v1 verifier, a plain
|
|
183
|
+
* HMAC header): true when one of its signatures is the one the secret makes (constant time), and its timestamp,
|
|
184
|
+
* when the scheme sends one, is within `tolerance` seconds of `now`. */
|
|
185
|
+
export function verifyEvent(scheme: EventScheme, secret: string, body: string, headers: Record<string, string>, opts: { tolerance?: number; now?: number } = {}): boolean {
|
|
186
|
+
const h = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]));
|
|
187
|
+
// a check given the time holds the timestamp to five minutes unless it says otherwise (Standard Webhooks' default)
|
|
188
|
+
const fresh = (seconds: number): boolean => opts.now === undefined || Math.abs(opts.now - seconds) <= (opts.tolerance ?? 300);
|
|
189
|
+
if (secret === '') return false;
|
|
190
|
+
if (scheme.kind === 'webhook-id') {
|
|
191
|
+
const p = scheme.prefix.toLowerCase();
|
|
192
|
+
const id = h[`${p}-id`]; const seconds = Number(h[`${p}-timestamp`]); const given = h[`${p}-signature`];
|
|
193
|
+
if (!id || !given || !Number.isFinite(seconds) || !fresh(seconds)) return false;
|
|
194
|
+
const expected = createHmac('sha256', webhookKey(scheme, secret)).update(`${id}.${seconds}.${body}`, 'utf8').digest('base64');
|
|
195
|
+
return given.split(' ').some((part) => part.startsWith('v1,') && same(part.slice('v1,'.length), expected));
|
|
196
|
+
}
|
|
197
|
+
if (scheme.kind === 'timestamp-v1') {
|
|
198
|
+
const parts = (h[scheme.header.toLowerCase()] ?? '').split(',').map((p) => p.split('=') as [string, string]);
|
|
199
|
+
const seconds = Number(parts.find(([k]) => k === 't')?.[1]);
|
|
200
|
+
if (!Number.isFinite(seconds) || !fresh(seconds)) return false;
|
|
201
|
+
const expected = createHmac('sha256', secret).update(`${seconds}.${body}`, 'utf8').digest('hex');
|
|
202
|
+
return parts.some(([k, v]) => k === 'v1' && v !== undefined && same(v, expected));
|
|
203
|
+
}
|
|
204
|
+
if (scheme.kind === 'hash') { const given = h[scheme.header.toLowerCase()]; return given !== undefined && same(given, hashOf(scheme, secret, body)); }
|
|
205
|
+
const seconds = scheme.timestampHeader ? Number(h[scheme.timestampHeader.toLowerCase()]) : undefined;
|
|
206
|
+
if (seconds !== undefined && (!Number.isFinite(seconds) || !fresh(seconds))) return false;
|
|
207
|
+
const expected = `${scheme.prefix ?? ''}${createHmac(scheme.algorithm ?? 'sha256', secret).update(hmacBase(scheme, seconds ?? 0, body), 'utf8').digest(scheme.encoding)}`;
|
|
208
|
+
return same(h[scheme.header.toLowerCase()] ?? '', expected);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The envelope template filled for one event, and for one endpoint when it names `$endpoint.<field>` (left out when
|
|
212
|
+
* there is none: the event as kept). A `...` key spreads its filled value into the object. */
|
|
213
|
+
export function fillEnvelope(template: unknown, values: Record<string, unknown>, endpoint?: Record<string, unknown>): unknown {
|
|
214
|
+
if (typeof template === 'string') {
|
|
215
|
+
if (template.startsWith('$endpoint.')) return endpoint ? at(endpoint, template.slice('$endpoint.'.length)) ?? undefined : undefined;
|
|
216
|
+
return template.startsWith('$') && template in values ? values[template] : template;
|
|
217
|
+
}
|
|
218
|
+
if (Array.isArray(template)) return template.map((item) => fillEnvelope(item, values, endpoint));
|
|
219
|
+
// a key whose value is a `$` value left undefined is not sent (an event about no connected account has no `account`)
|
|
220
|
+
if (template && typeof template === 'object') {
|
|
221
|
+
const out: Record<string, unknown> = {};
|
|
222
|
+
for (const [key, value] of Object.entries(template)) {
|
|
223
|
+
const filled = fillEnvelope(value, values, endpoint);
|
|
224
|
+
if (key === '...') { if (filled && typeof filled === 'object' && !Array.isArray(filled)) Object.assign(out, filled); continue; }
|
|
225
|
+
if (filled !== undefined) out[key] = filled;
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
return template;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Whether an envelope template names an endpoint's fields (so each delivery's body is its own). */
|
|
233
|
+
const perEndpoint = (template: unknown): boolean => JSON.stringify(template ?? null).includes('"$endpoint.');
|
|
234
|
+
|
|
235
|
+
/** A row's field by a dotted path (`config.url`). */
|
|
236
|
+
function at(row: Record<string, unknown>, path: string): unknown {
|
|
237
|
+
let v: unknown = row;
|
|
238
|
+
for (const key of path.split('.')) v = v && typeof v === 'object' ? (v as Record<string, unknown>)[key] : undefined;
|
|
239
|
+
return v;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** A delivery's further headers: each a `$` value, an endpoint's `$endpoint.<field>`, or written as it is. */
|
|
243
|
+
function headersFor(templates: Record<string, string>, values: Record<string, unknown>, endpoint: Record<string, unknown>): Record<string, string> {
|
|
244
|
+
const out: Record<string, string> = {};
|
|
245
|
+
for (const [name, template] of Object.entries(templates)) {
|
|
246
|
+
// a literal may stand before an endpoint's field (`Basic $endpoint.basicAuth`); the header is dropped when it is empty
|
|
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;
|
|
251
|
+
if (value !== undefined && value !== null) out[name.toLowerCase()] = String(value);
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const knownSets = new WeakMap<ReadonlyArray<string>, Set<string>>();
|
|
257
|
+
/** The event types a write's operation sends: `types`, else the vendor's enumerated types (`known`). */
|
|
258
|
+
export function eventTypesOf(decl: Pick<EventsDecl, 'types' | 'known'>, operation: string): string[] {
|
|
259
|
+
const named = decl.types[operation];
|
|
260
|
+
if (named !== undefined) return typeof named === 'string' ? [named] : [...named];
|
|
261
|
+
const one = eventTypeOf(decl, operation);
|
|
262
|
+
return one === undefined ? [] : [one];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The event type a write's operation sends, if any: its first under `types`, else the vendor's enumerated types
|
|
266
|
+
* (`known`). */
|
|
267
|
+
export function eventTypeOf(decl: Pick<EventsDecl, 'types' | 'known'>, operation: string): string | undefined {
|
|
268
|
+
const given = decl.types[operation];
|
|
269
|
+
const named = typeof given === 'string' || given === undefined ? given : given[0];
|
|
270
|
+
if (named !== undefined || !decl.known) return named;
|
|
271
|
+
let known = knownSets.get(decl.known);
|
|
272
|
+
if (!known) knownSets.set(decl.known, known = new Set(decl.known));
|
|
273
|
+
if (known.has(operation)) return operation;
|
|
274
|
+
const verb = /^(.+)\.(create|update|delete)$/.exec(operation);
|
|
275
|
+
return verb && known.has(`${verb[1]}.${verb[2]}d`) ? `${verb[1]}.${verb[2]}d` : undefined;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Whether an endpoint's list of event types takes a type: every type when the list is empty, `*` or `<family>.*`
|
|
279
|
+
* its patterns, else the type itself. */
|
|
280
|
+
export function takesEvent(list: unknown, type: string): boolean {
|
|
281
|
+
if (!Array.isArray(list) || list.length === 0) return true;
|
|
282
|
+
return list.some((p) => { const s = String(p); return s === '*' || s === type || (s.endsWith('.*') && type.startsWith(s.slice(0, -1))); });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** The request that made the write, as an event's attributes name it: the client's address (the first hop a proxy
|
|
286
|
+
* names) and its user agent. */
|
|
287
|
+
function requestValues(request: Request | undefined): Record<string, string> {
|
|
288
|
+
const forwarded = request?.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
|
|
289
|
+
return {
|
|
290
|
+
'$request.client_ip': forwarded ?? request?.headers.get('x-real-ip') ?? '',
|
|
291
|
+
'$request.user_agent': request?.headers.get('user-agent') ?? '',
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** The World's transport: its application route (the app's own hostnames reach it inside the World) and its egress
|
|
296
|
+
* 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
|
|
297
|
+
* the first delivery, so importing the kernel stays free of Node's http. */
|
|
298
|
+
const worldTransport: EventTransport = async (url, body, headers) => {
|
|
299
|
+
const [{ appDestination, appFetch }, { worldEgressRefusal }] = await Promise.all([
|
|
300
|
+
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 }>,
|
|
302
|
+
]);
|
|
303
|
+
if (!appDestination(url) && worldEgressRefusal(url) !== null) return 0;
|
|
304
|
+
try { return ((await appFetch(url, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body })) as { status: number }).status; } catch { return 0; }
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
/** A message a vendor sends the application's own server that is no declared event (Google Calendar's push notification:
|
|
308
|
+
* a bodiless POST with its X-Goog headers), over the same transport: the receiver's status, or 0 when the World lets
|
|
309
|
+
* no request out to it or it cannot be reached. The vendor waits on it, as the sender of such a message does. */
|
|
310
|
+
export async function deliverToApplication(url: string, init: RequestInit): Promise<number> {
|
|
311
|
+
const [{ appDestination, appFetch }, { worldEgressRefusal }] = await Promise.all([
|
|
312
|
+
import('../app-route.cjs') as Promise<{ appDestination: (value: string) => unknown; appFetch: (url: string, init: RequestInit) => Promise<{ status: number }> }>,
|
|
313
|
+
import('../network-policy.cjs') as Promise<{ worldEgressRefusal: (value: string) => unknown }>,
|
|
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; }
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** What the application's server answered a message the vendor waits on, or why it did not answer: `missed` when the
|
|
323
|
+
* World let no request out to it or it could not be reached (`unreachable`), or it did not answer in time (`timeout`). */
|
|
324
|
+
export type ApplicationAnswer = { status: number; body: string; missed?: 'timeout' | 'unreachable' };
|
|
325
|
+
|
|
326
|
+
/** A message the vendor sends the application's server and decides by its answer (Stripe's real-time authorization
|
|
327
|
+
* request, answered within its window): over the same transport, waited on for at most `within` milliseconds. */
|
|
328
|
+
export async function askApplication(url: string, init: RequestInit, within: number): Promise<ApplicationAnswer> {
|
|
329
|
+
const [{ appDestination, appFetch }, { worldEgressRefusal }] = await Promise.all([
|
|
330
|
+
import('../app-route.cjs') as Promise<{ appDestination: (value: string) => unknown; appFetch: (url: string, init: RequestInit) => Promise<{ status: number; text(): Promise<string> }> }>,
|
|
331
|
+
import('../network-policy.cjs') as Promise<{ worldEgressRefusal: (value: string) => unknown }>,
|
|
332
|
+
]);
|
|
333
|
+
if (!appDestination(url) && worldEgressRefusal(url) !== null) return { status: 0, body: '', missed: 'unreachable' };
|
|
334
|
+
const headers = new Headers(init.headers);
|
|
335
|
+
for (const [k, v] of Object.entries(deliveryTraceHeaders())) if (!headers.has(k)) headers.set(k, v);
|
|
336
|
+
try {
|
|
337
|
+
const res = await appFetch(url, { ...init, headers, signal: AbortSignal.timeout(within) });
|
|
338
|
+
return { status: res.status, body: await res.text() };
|
|
339
|
+
} catch (e) {
|
|
340
|
+
return { status: 0, body: '', missed: e instanceof Error && (e.name === 'TimeoutError' || e.name === 'AbortError') ? 'timeout' : 'unreachable' };
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Every event a write sends, rendered, stored when the vendor keeps its events, signed, delivered to each endpoint
|
|
345
|
+
* that takes it, and recorded. */
|
|
346
|
+
export async function deliverEvents(
|
|
347
|
+
service: string,
|
|
348
|
+
decl: EventsDecl,
|
|
349
|
+
write: EventWrite,
|
|
350
|
+
data: (type: string) => Promise<Record<string, unknown>>,
|
|
351
|
+
transport: EventTransport = worldTransport,
|
|
352
|
+
values?: (type: string) => Promise<Record<string, unknown>>,
|
|
353
|
+
onAnswer?: (answer: DeliveryAnswer) => Promise<void>,
|
|
354
|
+
): Promise<EventDelivery[]> {
|
|
355
|
+
if (write.storedType === decl.store) return [];
|
|
356
|
+
const sent: EventDelivery[] = [];
|
|
357
|
+
for (const type of eventTypesOf(decl, write.operation)) sent.push(...(await deliverEventType(service, decl, write, data, transport, values, type, onAnswer)));
|
|
358
|
+
return sent;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** One event type a write sends: to the live endpoints that take it, stored when the vendor keeps its events. */
|
|
362
|
+
async function deliverEventType(
|
|
363
|
+
service: string,
|
|
364
|
+
decl: EventsDecl,
|
|
365
|
+
write: EventWrite,
|
|
366
|
+
data: (type: string) => Promise<Record<string, unknown>>,
|
|
367
|
+
transport: EventTransport,
|
|
368
|
+
values: ((type: string) => Promise<Record<string, unknown>>) | undefined,
|
|
369
|
+
type: string,
|
|
370
|
+
onAnswer?: (answer: DeliveryAnswer) => Promise<void>,
|
|
371
|
+
): Promise<EventDelivery[]> {
|
|
372
|
+
const all = twinResources(service, write.root) as unknown as Array<Record<string, unknown>>;
|
|
373
|
+
const kinds = Array.isArray(decl.endpoints) ? decl.endpoints : [decl.endpoints as EndpointsDecl];
|
|
374
|
+
// an endpoint's row with its joined rows under their names (`app.hook_url` reads the installation's App)
|
|
375
|
+
const joined = (kind: EndpointsDecl, r: Record<string, unknown>): Record<string, unknown> => {
|
|
376
|
+
if (!kind.joins) return r;
|
|
377
|
+
const out: Record<string, unknown> = { ...r, id: r.id };
|
|
378
|
+
for (const [name, j] of Object.entries(kind.joins)) out[name] = all.find((x) => x.type === j.storedAs && x.deleted !== true && String(x.id) === String(at(r, j.by)));
|
|
379
|
+
return out;
|
|
380
|
+
};
|
|
381
|
+
const live = kinds.filter((kind) => (!kind.types || kind.types.includes(type)) && !kind.skip?.includes(type)).flatMap((kind) => all
|
|
382
|
+
.filter((r) => r.type === kind.storedAs && r.deleted !== true)
|
|
383
|
+
.map((r) => joined(kind, r as Record<string, unknown>))
|
|
384
|
+
.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))
|
|
385
|
+
.filter((r) => typeof at(r, kind.url) === 'string' && typeof at(r, kind.secret) === 'string')
|
|
386
|
+
.filter((r) => kind.filter === undefined || takesEvent(at(r, kind.filter), type))
|
|
387
|
+
.map((row) => ({ row, kind })));
|
|
388
|
+
if (live.length === 0 && decl.store === undefined) return [];
|
|
389
|
+
const millis = Date.parse(write.occurredAt) || 0;
|
|
390
|
+
const seconds = Math.floor(millis / 1000);
|
|
391
|
+
const eventData = await data(type);
|
|
392
|
+
const extra = values ? await values(type) : {};
|
|
393
|
+
// the World's count of events kept so far tells apart two identical events at one instant (a frozen clock)
|
|
394
|
+
const kept = decl.store !== undefined ? all.filter((r) => r.type === decl.store).length : 0;
|
|
395
|
+
const filled: Record<string, unknown> = {
|
|
396
|
+
$type: type, $data: eventData,
|
|
397
|
+
$id: `evt_twin_${digest(`${type}|${write.occurredAt}|${kept}|${JSON.stringify(eventData)}`).slice(0, 24)}`,
|
|
398
|
+
'$time.ms': millis, '$time.s': seconds, '$time.iso': write.occurredAt,
|
|
399
|
+
...requestValues(write.request),
|
|
400
|
+
...extra,
|
|
401
|
+
};
|
|
402
|
+
const envelope = fillEnvelope(decl.envelope, filled);
|
|
403
|
+
if (decl.store !== undefined) {
|
|
404
|
+
await applyTwinWrite(service, { operation: `${decl.store}.record`, subjectType: decl.store, subjectId: String(filled.$id), fields: envelope as Record<string, unknown>, occurredAt: write.occurredAt, actor: { kind: 'system' } }, write.root);
|
|
405
|
+
}
|
|
406
|
+
if (extra.$send === false) return [];
|
|
407
|
+
const endpoints = live
|
|
408
|
+
.filter(({ row, kind }) => kind.scope === undefined || (at(row, kind.scope.field) === true) === (filled[kind.scope.value] !== undefined))
|
|
409
|
+
.filter(({ row, kind }) => matches(row, kind.match, filled));
|
|
410
|
+
if (endpoints.length === 0) return [];
|
|
411
|
+
const shared = JSON.stringify(envelope);
|
|
412
|
+
const each = perEndpoint(decl.envelope);
|
|
413
|
+
const sent: EventDelivery[] = [];
|
|
414
|
+
const to: Array<{ kind: EndpointsDecl; row: Record<string, unknown> }> = [];
|
|
415
|
+
const before = decl.record !== undefined ? all.filter((r) => r.type === decl.record).length : 0;
|
|
416
|
+
for (const [i, { row, kind }] of endpoints.entries()) {
|
|
417
|
+
const body = each ? JSON.stringify(fillEnvelope(decl.envelope, filled, row)) : shared;
|
|
418
|
+
const url = String(at(row, kind.url));
|
|
419
|
+
const seq = before + i;
|
|
420
|
+
const extraHeaders = decl.headers ? headersFor(decl.headers, filled, row) : {};
|
|
421
|
+
const headers = { ...extraHeaders, ...signEventAll(decl.scheme, String(at(row, kind.secret)), body, seconds, url, seq) };
|
|
422
|
+
sent.push({ url, body, headers });
|
|
423
|
+
to.push({ kind, row });
|
|
424
|
+
// kept with the write, by an id of the delivery itself (two made at once never take the same one)
|
|
425
|
+
if (decl.record !== undefined) {
|
|
426
|
+
await applyTwinWrite(service, {
|
|
427
|
+
operation: `${decl.record.slice(1)}.record`, subjectType: decl.record, subjectId: `${decl.record.slice(1)}_${digest(`${url}|${seconds}|${seq}|${body}`).slice(0, 16)}`,
|
|
428
|
+
fields: { url, body, headers, event_type: type, sent_at: millis }, occurredAt: write.occurredAt, actor: { kind: 'system' },
|
|
429
|
+
}, write.root);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
// sent after the write's answer, as a vendor sends it: the caller never waits on its receiver, and a receiver that is
|
|
433
|
+
// slow or down fails no request
|
|
434
|
+
// the write's trace is read now, inside its request, and carried on each delivery (W3C trace continuation)
|
|
435
|
+
const trace = deliveryTraceHeaders();
|
|
436
|
+
// each answer is read as the vendor reads it, when the endpoint kind keeps score (its `outcome`)
|
|
437
|
+
const stated = (url: string): number | undefined => {
|
|
438
|
+
const r = decl.receivers === undefined ? undefined : all.filter((x) => x.type === decl.receivers && x.deleted !== true && url.startsWith(String(x.url)))
|
|
439
|
+
.sort((x, y) => String(y.url).length - String(x.url).length)[0];
|
|
440
|
+
return r === undefined ? undefined : Number(r.status);
|
|
441
|
+
};
|
|
442
|
+
for (const [i, { url, body: payload, headers }] of sent.entries()) setTimeout(() => {
|
|
443
|
+
const answered = stated(url);
|
|
444
|
+
(answered !== undefined ? Promise.resolve(answered) : transport(url, payload, { ...headers, ...trace }))
|
|
445
|
+
.then((status) => (onAnswer && to[i]!.kind.outcome ? onAnswer({ ...to[i]!, status: typeof status === 'number' ? status : 0, occurredAt: write.occurredAt }) : undefined))
|
|
446
|
+
.catch(() => {});
|
|
447
|
+
}, 0);
|
|
448
|
+
return sent;
|
|
449
|
+
}
|
package/src/executor.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import type { CredentialPayload } from './credential.ts';
|
|
9
9
|
import type { RemoteExecute } from './remote-execute.ts';
|
|
10
10
|
import type { HostRule } from './packRegistry.ts';
|
|
11
|
+
import { RateBudget } from './rateBudget.ts';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* HOW A VENDOR AUTHENTICATES — a fact about the vendor, declared by the pack (`TwinPack.auth`) and
|
|
@@ -218,9 +219,30 @@ async function awsSigV4Headers(input: {
|
|
|
218
219
|
/** Where a credential is kept (the head's and refresh's root: head.ts rootCustody): `key` names the credential stably
|
|
219
220
|
* across rotations and anew when another is sealed in its place (the token cache is keyed by it, not by the secret's value), `open` reads it as sealed now, `seal` keeps a rotated one. */
|
|
220
221
|
export type CredentialCustody = { key: string; open: () => Promise<CredentialPayload>; seal: (credential: CredentialPayload) => Promise<void> | void };
|
|
221
|
-
|
|
222
|
+
/** `vendor`: the vendor whose rate budget every call is charged to (D8): the call is priced and reserved before it is
|
|
223
|
+
* made, refused when the budget is spent, and settled with the vendor's answer (its back-off honoured). */
|
|
224
|
+
export type RemoteExecuteOptions = { custody?: CredentialCustody; vendor?: string };
|
|
222
225
|
export function buildRemoteExecute(origin: URL, credential: CredentialPayload, auth?: TwinAuthStrategy, vendorHosts?: readonly HostRule[], options: RemoteExecuteOptions = {}): RemoteExecute {
|
|
223
|
-
|
|
226
|
+
const execute = auth?.in === 'exchange' ? exchangeExecute(origin, credential, auth, vendorHosts, options.custody) : direct(origin, credential, auth, vendorHosts);
|
|
227
|
+
return options.vendor === undefined ? execute : budgeted(options.vendor, execute, options.custody?.key ?? JSON.stringify(credential.headers));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Every live call charged to the vendor's rate budget (rateBudget.ts, D8): a call the budget refuses is never made;
|
|
231
|
+
* the ledger is the credential's (only its hash names it). */
|
|
232
|
+
function budgeted(vendor: string, execute: RemoteExecute, token: string): RemoteExecute {
|
|
233
|
+
const budget = new RateBudget({ vendor, token });
|
|
234
|
+
return async (request) => {
|
|
235
|
+
const url = new URL(request.path, 'http://budget.invalid');
|
|
236
|
+
const weight = budget.weightFor(`${request.method.toUpperCase()} ${url.pathname}`, Object.fromEntries(url.searchParams));
|
|
237
|
+
const reservation = budget.checkBudget(weight);
|
|
238
|
+
const answer = await execute(request);
|
|
239
|
+
budget.recordCall(weight, answer.headers, { status: answer.status, reservation });
|
|
240
|
+
return answer;
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The call made as the root's credential is applied by the strategy (header replacement when none). */
|
|
245
|
+
function direct(origin: URL, credential: CredentialPayload, auth?: Exclude<TwinAuthStrategy, { in: 'exchange' }>, vendorHosts?: readonly HostRule[]): RemoteExecute {
|
|
224
246
|
return async ({ method, path, headers, body, responseType, presigned }) => {
|
|
225
247
|
// A PRESIGNED UPLOAD URL the vendor returned: its own signature is its authorization, so the
|
|
226
248
|
// sealed credential never goes with it, whatever host it names. A host other than the root's
|
package/src/file-response.ts
CHANGED
|
@@ -20,3 +20,30 @@ export function fileResponse(path: string, init?: ResponseInit): Response {
|
|
|
20
20
|
if (!headers.has('content-type')) headers.set('content-type', contentTypeOf(path));
|
|
21
21
|
return new Response(readFileSync(path), { ...init, headers });
|
|
22
22
|
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Stored bytes as a response, honouring a single `Range: bytes=` as RFC 9110 §14 has it: a range that does not parse, a
|
|
26
|
+
* first-byte-pos past its last-byte-pos (`bytes=5-2`) included, is ignored and the whole body is a 200; a well-formed
|
|
27
|
+
* range that starts past the end is a 416. `nosniff` stops a browser second-guessing the content type the vendor gave.
|
|
28
|
+
*/
|
|
29
|
+
export function bytesResponse(request: Request, bytes: Uint8Array, contentType: string, cacheControl = 'public, max-age=604800, immutable'): Response {
|
|
30
|
+
const total = bytes.length;
|
|
31
|
+
const base = { 'content-type': contentType, 'x-content-type-options': 'nosniff', 'accept-ranges': 'bytes', 'cache-control': cacheControl };
|
|
32
|
+
const range = /^bytes=(\d*)-(\d*)$/.exec((request.headers.get('range') ?? '').trim());
|
|
33
|
+
let start = 0;
|
|
34
|
+
let end = total - 1;
|
|
35
|
+
const valid = range !== null && (range[1] !== '' || range[2] !== '') && !(range[1] !== '' && range[2] !== '' && Number(range[1]) > Number(range[2]));
|
|
36
|
+
if (valid) {
|
|
37
|
+
if (range[1] === '') { start = Math.max(0, total - Number(range[2])); } else {
|
|
38
|
+
start = Number(range[1]);
|
|
39
|
+
end = range[2] === '' ? total - 1 : Math.min(Number(range[2]), total - 1);
|
|
40
|
+
}
|
|
41
|
+
if (start >= total || (range[1] === '' && Number(range[2]) === 0)) return new Response(null, { status: 416, headers: { ...base, 'content-range': `bytes */${total}` } });
|
|
42
|
+
}
|
|
43
|
+
const partial = start !== 0 || end !== total - 1;
|
|
44
|
+
const body = bytes.slice(start, end + 1);
|
|
45
|
+
return new Response(request.method === 'HEAD' ? null : new Blob([body]), {
|
|
46
|
+
status: partial ? 206 : 200,
|
|
47
|
+
headers: { ...base, 'content-length': String(body.length), ...(partial ? { 'content-range': `bytes ${start}-${end}/${total}` } : {}) },
|
|
48
|
+
});
|
|
49
|
+
}
|
package/src/fork.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { captureHistory, historyEntries, historyLength, historyPrefix, readHisto
|
|
|
3
3
|
// The kernel registers parent ownership before publishing the pointer. Lifecycle removal
|
|
4
4
|
// refuses while a child depends on that storage; direct filesystem deletion bypasses it.
|
|
5
5
|
import { dirname, join } from 'node:path';
|
|
6
|
-
import { checkParent,
|
|
6
|
+
import { checkParent, withHistoryLock } from './ancestry.ts';
|
|
7
7
|
import { getActiveWorldStore } from './world-store.ts';
|
|
8
8
|
import { position, wholeLog, writeBranchMeta , assertBatchBoundary } from './log.ts';
|
|
9
9
|
import { twinResources } from './serve.ts';
|
|
@@ -41,7 +41,8 @@ export function forkTwin(opts: {
|
|
|
41
41
|
view?: string;
|
|
42
42
|
expectedGeneration?: string;
|
|
43
43
|
}): ForkMeta {
|
|
44
|
-
|
|
44
|
+
// under the new branch's history lock; capturing the base nests the base's inside it (child before ancestor)
|
|
45
|
+
return withHistoryLock(worldPaths(opts.service, opts.toRoot).dir, () => {
|
|
45
46
|
// A BRANCH IS A POINTER (log.ts): the base root and the position in its logs, nothing copied.
|
|
46
47
|
// The branch reads the base's parent view and branch log up to that position as its own
|
|
47
48
|
// parent, and records only its own entries from there. The base keeps appending past the
|
package/src/git/history.ts
CHANGED
|
@@ -175,3 +175,22 @@ export function mergeText(base: string, ours: string, theirs: string): string |
|
|
|
175
175
|
for (const h of edits) result.splice(h.start, h.end - h.start, ...h.lines);
|
|
176
176
|
return result.map(line => JSON.parse(line) as string).join('');
|
|
177
177
|
}
|
|
178
|
+
|
|
179
|
+
/** The blob at a path in a commit (walking its trees segment by segment), or null when the commit, a directory on the
|
|
180
|
+
* way or the file is not there. A host that serves a file at a revision (the Hugging Face Hub's `resolve`) reads it. */
|
|
181
|
+
export async function blobAt(store: GitObjectStore, commit: string, path: string): Promise<{ sha: string; mode: string; bytes: Uint8Array } | null> {
|
|
182
|
+
const c = await store.read(commit);
|
|
183
|
+
if (!c || c.type !== 'commit') return null;
|
|
184
|
+
let tree = decodeCommit(c.payload).tree;
|
|
185
|
+
const parts = path.split('/').filter(Boolean);
|
|
186
|
+
for (const [i, name] of parts.entries()) {
|
|
187
|
+
const t = await store.read(tree);
|
|
188
|
+
if (!t || t.type !== 'tree') return null;
|
|
189
|
+
const e = decodeTree(t.payload).find((x) => x.name === name);
|
|
190
|
+
if (!e) return null;
|
|
191
|
+
if (i < parts.length - 1) { tree = e.sha; continue; }
|
|
192
|
+
const b = await store.read(e.sha);
|
|
193
|
+
return b && b.type === 'blob' ? { sha: e.sha, mode: e.mode, bytes: b.payload } : null;
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
package/src/git/index.ts
CHANGED