@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/derived-core.ts
CHANGED
|
@@ -8,21 +8,42 @@
|
|
|
8
8
|
import { applyTwinWrite, applyTwinWriteAtomic, twinResources, type TwinResource } from './serve.ts';
|
|
9
9
|
import { copyResource, ownFields, parentStamp, subjectHistory, treeChangesSince, treeStamp } from './log.ts';
|
|
10
10
|
import { getActiveWorldStore } from './world-store.ts';
|
|
11
|
+
import { getActiveBlobStore } from './blob-store.ts';
|
|
12
|
+
import { worldPaths } from './storage.ts';
|
|
13
|
+
import { GitObjectStore } from './git/objects.ts';
|
|
14
|
+
import { GitRefs } from './git/refs.ts';
|
|
11
15
|
import { worldNow } from './world-clock.ts';
|
|
12
|
-
import {
|
|
16
|
+
import { worldEnvValue } from './world-env.ts';
|
|
17
|
+
import { openSessions, socketWrite, type SocketDecl, type SocketSession } from './sockets.ts';
|
|
18
|
+
import { MACHINE_POOL, poolFor, type MachinePool } from './machines.ts';
|
|
19
|
+
import { askApplication, deliverEvents, deliverToApplication, type ApplicationAnswer, type DeliveryAnswer, type EventRender, type EventScheme, type EventsDecl, type EventValues } from './events.ts';
|
|
20
|
+
import type { RateBudgetDeclaration } from './rateBudget.ts';
|
|
21
|
+
import { handlerCrypto, hmac, lettersFrom, sha256, uuidFrom, type HandlerCrypto } from './signing.ts';
|
|
13
22
|
import { hashFieldValue } from './hash.ts';
|
|
14
|
-
import {
|
|
23
|
+
import { readResourceBlob, removeResourceBlob, writeResourceBlob } from './resource-blob.ts';
|
|
24
|
+
import { managedDatabase, unboundDatabase, type ManagedDatabase } from './managed-database.ts';
|
|
25
|
+
import { execRedisRun, type RedisDialect, type RunItem } from './redis/engine.ts';
|
|
26
|
+
import { sendMail, type Mail, type SmtpRoute } from './smtp.ts';
|
|
27
|
+
import { multipartBoundary, multipartParts, type MultipartPart } from './multipart.ts';
|
|
28
|
+
import type { CorsDecl } from './cors.ts';
|
|
29
|
+
import { projectOwnerResources, resolveSubjectId, subjectAliases } from './actions.ts';
|
|
30
|
+
import { vendorFetch } from './vendor-call.ts';
|
|
15
31
|
import { packReferences } from './references.ts';
|
|
16
32
|
import type { SubjectFields } from './hash.ts';
|
|
17
33
|
import { twinPublicBase, withRequestScopes } from './twin-fetch.ts';
|
|
18
|
-
import { isReadOnlyRequest } from './request-scope.ts';
|
|
34
|
+
import { inVendorMove, isReadOnlyRequest, runAsVendorMove } from './request-scope.ts';
|
|
19
35
|
import type { DerivedCall, DerivedCoreOutcome, DerivedHandler, DerivedOperation } from './derived.ts';
|
|
36
|
+
import type { PackDescriptor } from './packRegistry.ts';
|
|
37
|
+
import type { ScenarioDecision, ScenarioHandler } from './scenario.ts';
|
|
38
|
+
import { bodyTextOf, checkJsonBody, takeParsedBody } from './derived.ts';
|
|
20
39
|
|
|
21
40
|
// ── the manifest ─────────────────────────────────────────────────────────────────────────────
|
|
22
41
|
|
|
23
42
|
/** How a stored field gets its value when the server assigns it. `now` is the world clock in the
|
|
24
43
|
* manifest's time format; `id` the subject's id; a `value` is stored as given. */
|
|
25
|
-
|
|
44
|
+
/** A field the server assigns: the World clock's now, the subject's id, a fixed value, or a template over the stored
|
|
45
|
+
* fields (and `{id}`), base64-encoded when `encode` says (GitHub's `node_id`: `05:Label{id}` in base64). */
|
|
46
|
+
export type FieldRule = { now: true } | { id: true } | { value: unknown } | { template: string; encode?: 'base64' };
|
|
26
47
|
|
|
27
48
|
/** A move of one state field, as data. `operation` is the operationId that causes it (an update
|
|
28
49
|
* that requests `to` when absent); `from` the values it leaves; `effects` the other fields it writes;
|
|
@@ -40,7 +61,7 @@ export type Transition = {
|
|
|
40
61
|
to?: string;
|
|
41
62
|
effects?: Record<string, FieldRule>;
|
|
42
63
|
/** placeholders `{from}`, `{to}`, `{field}`, `{id}` */
|
|
43
|
-
refusal?: { status: number; code?: string; message: string };
|
|
64
|
+
refusal?: { status: number; code?: string | number; message: string };
|
|
44
65
|
/** a refusal of its own for a particular current value (an invoice already `paid`) */
|
|
45
66
|
refusals?: Record<string, { status: number; code?: string; message: string }>;
|
|
46
67
|
/** where the rule comes from: a docs URL, the SDK's types, or a recording */
|
|
@@ -81,6 +102,14 @@ export type ResourceDecl = {
|
|
|
81
102
|
/** the tree's subject type, when it differs from the resource name */
|
|
82
103
|
storedAs?: string;
|
|
83
104
|
idPrefix: string;
|
|
105
|
+
/** a setting held once per parent at a path of its own (`/v1/projects/{ref}/config/auth`, GET and PATCH, which the
|
|
106
|
+
* surface names by the schema its GET answers): keyed by the path's parameter, it answers `defaults` under what was
|
|
107
|
+
* written until first written, and an update merges into it, making it on first write */
|
|
108
|
+
setting?: { defaults: Record<string, unknown> };
|
|
109
|
+
/** this resource's id template, when it is not the manifest's (`ids.template`): `{uuid}`, `{letters:20}` (twenty
|
|
110
|
+
* lowercase letters, a Supabase project ref), `{snowflake:<epoch ms>}` (Discord's ids), or a `{prefix}`/`{n}`
|
|
111
|
+
* template */
|
|
112
|
+
ids?: string;
|
|
84
113
|
/** fields the server assigns on create */
|
|
85
114
|
assigned?: Record<string, FieldRule>;
|
|
86
115
|
state?: Record<string, StateField>;
|
|
@@ -100,14 +129,19 @@ export type ResourceDecl = {
|
|
|
100
129
|
/** a number the resource counts per parent (a repository's issue numbers, a thread's messages):
|
|
101
130
|
* the stored field, set on create to one past the highest among its siblings */
|
|
102
131
|
number?: { field: string };
|
|
103
|
-
/**
|
|
104
|
-
*
|
|
105
|
-
|
|
106
|
-
/** an update replaces the fields it names (`replace`)
|
|
107
|
-
|
|
132
|
+
/** the field the vendor's answer names a subject's id by, when it is not `id` (Daily's webhook `uuid`, Twilio's `sid`,
|
|
133
|
+
* Asana's `gid`): the subject's id is answered under it, and no `id` */
|
|
134
|
+
idAs?: string;
|
|
135
|
+
/** an update replaces the fields it names (`replace`), merges a nested object one level into them with `''` removing
|
|
136
|
+
* a key (`merge`, the default: Stripe's metadata), or merges nested objects at every level with `null` removing a key
|
|
137
|
+
* (`deep`: Clerk's metadata, "a deep merge … remove metadata keys at any level by setting their value to null") */
|
|
138
|
+
update?: 'merge' | 'replace' | 'deep';
|
|
108
139
|
/** the stored subject id when it is not the path's last parameter: a template over the path
|
|
109
140
|
* parameters (`{vector_store_id}::{file_id}`) */
|
|
110
141
|
key?: string;
|
|
142
|
+
/** a resource every tenant shares (a catalogue the vendor keeps for all, its countries or currencies): the manifest's
|
|
143
|
+
* `tenant` does not scope it */
|
|
144
|
+
shared?: boolean;
|
|
111
145
|
/** only subjects whose stored fields match are readable (a resource stored for every call but
|
|
112
146
|
* answered only when asked to be kept) */
|
|
113
147
|
readableWhen?: Record<string, unknown>;
|
|
@@ -117,9 +151,73 @@ export type ResourceDecl = {
|
|
|
117
151
|
/** query parameters of the list operation that filter on the same-named field */
|
|
118
152
|
filters?: string[];
|
|
119
153
|
order?: { field: string; direction: 'asc' | 'desc' };
|
|
154
|
+
/** the list's ordering parameter (`order_by=-created_at`): `[+-]<field>` over `fields`, `default` when absent; any
|
|
155
|
+
* other field is refused with `invalid` (`{fields}` in its message names them), never ignored */
|
|
156
|
+
orderBy?: { param: string; fields: string[]; default: string; invalid: ErrorSpec };
|
|
157
|
+
/** fields no two live subjects share (a permission's `key`; a membership's `organization_id` and `user_id` together):
|
|
158
|
+
* a create or update that would make a second one is refused with `refusal`, by the core and by `ctx.conflict` */
|
|
159
|
+
unique?: Array<{ fields: string[]; refusal: ErrorSpec }>;
|
|
160
|
+
/** the subjects a delete takes with it (an organization's memberships and invitations): each resource whose `field`
|
|
161
|
+
* holds the deleted subject's id, deleted first, each through the write path as its own delete (its events sent) */
|
|
162
|
+
cascade?: Array<{ resource: string; field: string }>;
|
|
163
|
+
/** fields that name a subject as its id does (an organization by its id or its slug): a path's id and `ctx.find`
|
|
164
|
+
* match either */
|
|
165
|
+
alternateKeys?: string[];
|
|
166
|
+
/** the list's search parameter (`query`): a row matches on an `exact` field's whole value or a `partial` field's
|
|
167
|
+
* substring, case-insensitively */
|
|
168
|
+
search?: { param: string; exact?: string[]; partial?: string[] };
|
|
169
|
+
/** fields that count this subject's children (an App's `installations_count`): the live subjects of `of` whose `by`
|
|
170
|
+
* holds this one's id, only those with `present` set when it is named, plus `plus` (a customer's
|
|
171
|
+
* `next_invoice_sequence` is one past its numbered invoices). The kernel keeps each whenever a child is written. */
|
|
172
|
+
counts?: Record<string, { of: string; by: string; present?: string; plus?: number }>;
|
|
173
|
+
/** how the vendor's state of this type is read back into a root (the real-system adapters, "Refresh"): the list that
|
|
174
|
+
* enumerates it (once per parent, under `parent`; `complete: false` when it is not the whole type; `items`, the answer's
|
|
175
|
+
* path to them, when the spec does not name the list it answers), the one read of a singleton, or `none` with why the
|
|
176
|
+
* vendor offers no read-back. Every stored resource the vendor holds declares one. */
|
|
177
|
+
refresh?: { list: string; complete?: false; items?: string } | { get: string } | { none: string };
|
|
178
|
+
/** the other resources one create of this one makes, and where its answer names each's id (a JSON path) */
|
|
179
|
+
companions?: Record<string, string>;
|
|
180
|
+
/** the field telling a newer copy of a subject from an older (an `updated_at`): an ingested event older than the
|
|
181
|
+
* root's copy folds nothing */
|
|
182
|
+
version?: string;
|
|
120
183
|
};
|
|
121
184
|
|
|
122
|
-
|
|
185
|
+
/** How a vendor's signed webhooks are folded into a root (the real-system adapters, "Ingest"). */
|
|
186
|
+
export type IngestDecl = {
|
|
187
|
+
scheme: EventScheme | ReadonlyArray<EventScheme>;
|
|
188
|
+
/** where the event names its type: a header, a body path, and a sub-action path joined to it with `.` */
|
|
189
|
+
type: { header?: string; body?: string; action?: string };
|
|
190
|
+
/** the JSON path of the event's object */
|
|
191
|
+
object: string;
|
|
192
|
+
/** event type → the resource it carries, where `events.types` read backwards and `<resource>.created|updated|deleted`
|
|
193
|
+
* do not say it */
|
|
194
|
+
types?: Record<string, { resource: string; deleted?: true }>;
|
|
195
|
+
/** an event answered instead of folded (Slack's url_verification): the body fields it matches, and the answer, a
|
|
196
|
+
* `$body.<path>` read from the event or a literal */
|
|
197
|
+
handshake?: { when: Record<string, string>; answer: string };
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/** Which lane a request is the vendor's gateway's to send ("Other wires: lanes"), each condition it names holding: a
|
|
201
|
+
* path under `path` (Supabase's `/rest/v1`), a host matching `host` (a regular expression: `^secretsmanager\.`), a
|
|
202
|
+
* header starting with `header.prefix` (AWS's `x-amz-target: secretsmanager.`), and a host not in `exceptHosts`. */
|
|
203
|
+
export type LaneRoute = {
|
|
204
|
+
lane: string; path?: string; host?: string; exceptHosts?: ReadonlyArray<string>;
|
|
205
|
+
/** a header starting with `prefix`, or, with `absent`, a request whose header does not (Clerk's Frontend API is asked
|
|
206
|
+
* without a Backend API secret key) */
|
|
207
|
+
header?: { name: string; prefix: string; absent?: true };
|
|
208
|
+
/** where two of the vendor's APIs share one address: the route passes a path only the root's API serves (the lane's
|
|
209
|
+
* surface does not), which is the root's */
|
|
210
|
+
unlessOnlyRoot?: true;
|
|
211
|
+
};
|
|
212
|
+
/** A vendor's lanes: its routes, first match first, the lane that takes what no route names (`default`), and the
|
|
213
|
+
* gateway's CORS. A `/_twin/` door goes to the lane whose manifest declares it. */
|
|
214
|
+
export type LanesDecl = { cors?: CorsDecl; routes: ReadonlyArray<LaneRoute>; default?: string };
|
|
215
|
+
/** The manifest of a vendor whose every API is a lane (Cloudflare's API v4 and R2, AWS's services): no API of its own,
|
|
216
|
+
* only what the vendor's front says — its discovery facts, its lanes and its descriptor. */
|
|
217
|
+
export type VendorManifest = { vendor: string; discovery?: DerivedManifest['discovery']; lanes: LanesDecl; descriptor: Omit<PackDescriptor, 'vendor'>; ingest?: IngestDecl; rateBudget?: RateBudgetDeclaration };
|
|
218
|
+
|
|
219
|
+
/** `code` as the vendor writes it: a string, or a number (Discord's JSON error codes are integers) */
|
|
220
|
+
export type ErrorSpec = { status: number; message: string; code?: string | number; param?: string; kind?: string };
|
|
123
221
|
|
|
124
222
|
/** A vendor screen a World serves (docs/contributing/architecture.md, "Screens"). A `flow` is a hosted
|
|
125
223
|
* page an application sends its user through, held to its documented round trip; a `workspace` is a
|
|
@@ -127,8 +225,12 @@ export type ErrorSpec = { status: number; message: string; code?: string; param?
|
|
|
127
225
|
* vendor's way, each a (screen, control) cell. */
|
|
128
226
|
export type ScreenDecl = {
|
|
129
227
|
id: string;
|
|
130
|
-
|
|
228
|
+
/** a hosted flow a person is sent through, a workspace they work in, or a content host serving what the vendor stores
|
|
229
|
+
* (its CDN: an uploaded image, a video, a default avatar) */
|
|
230
|
+
kind: 'flow' | 'workspace' | 'content';
|
|
131
231
|
host: string;
|
|
232
|
+
/** every other host the screen answers on (a sandbox's dashboard and production's, one page) */
|
|
233
|
+
hosts?: string[];
|
|
132
234
|
path: string;
|
|
133
235
|
demand: string;
|
|
134
236
|
status: 'done' | 'todo';
|
|
@@ -138,11 +240,17 @@ export type ScreenDecl = {
|
|
|
138
240
|
};
|
|
139
241
|
|
|
140
242
|
export type DerivedManifest = {
|
|
243
|
+
/** The pack's descriptor, as data (docs/contributing/architecture.md, "The descriptor"), every field but `vendor`, which
|
|
244
|
+
* is the manifest's: the pack registers `packOf(manifest)`, so its index is a fixed file. A lane has none. */
|
|
245
|
+
descriptor?: Omit<PackDescriptor, 'vendor'>;
|
|
141
246
|
vendor: string;
|
|
142
247
|
/** the kernel service the pack writes */
|
|
143
248
|
service: string;
|
|
144
249
|
/** `json: 'always'` reads a body as JSON whatever its content type (GitHub does) */
|
|
145
|
-
body
|
|
250
|
+
/** `strict`: a vendor that refuses a body field its operation does not declare, a value of another type, or a number
|
|
251
|
+
* outside the spec's bounds (a NestJS validation pipe: Supabase's "property x should not exist"), with its messages;
|
|
252
|
+
* `{name}`, `{type}` (`a string`, `an integer`), `{min}` and `{max}` are filled. `ctx.fields` checks a body so. */
|
|
253
|
+
body: { form?: { coerce: boolean }; json?: 'always'; validation?: { operations?: ReadonlyArray<string>; status: number; missing: unknown; type: unknown; range: unknown }; strict?: { status: number; code?: string; unknown: string; type: string; range: string } };
|
|
146
254
|
/** `acceptProvided`: a create that names its own `id` keeps it (a vendor that seeds by id). The template counts
|
|
147
255
|
* (`{prefix}_{n}`), or is `{uuid}` for a vendor whose ids are UUIDs: the next is derived from the resource and its
|
|
148
256
|
* count, so a World mints the same ids every run */
|
|
@@ -152,7 +260,16 @@ export type DerivedManifest = {
|
|
|
152
260
|
/** operations that only read though nothing in the spec says so (a POST that returns data):
|
|
153
261
|
* a read-only twin answers them */
|
|
154
262
|
reads?: string[];
|
|
155
|
-
|
|
263
|
+
/** how the vendor writes an instant: Unix seconds, Unix milliseconds (Clerk's `created_at`), or ISO 8601 */
|
|
264
|
+
time: 'unix' | 'unix-ms' | 'iso';
|
|
265
|
+
/** the vendor's CORS, as its gateway answers a browser (cors.ts): the pack's fetch is served behind `withCors` */
|
|
266
|
+
cors?: CorsDecl;
|
|
267
|
+
/** the HTTP status a vendor answers every error below `below` with, when its body names the error's own status
|
|
268
|
+
* (storage-api: 400 for every error but a 500, the body's `statusCode` the error's: `{ status: 400, below: 500 }`) */
|
|
269
|
+
errorsAnsweredAs?: { status: number; below: number };
|
|
270
|
+
/** the texts the vendor reads as a boolean in a parameter, compared without case (Supabase: `true`, `1`, `yes`, `on`,
|
|
271
|
+
* `y`, `enabled` and their opposites); absent, `true` and `false`. `ctx.flag` reads with them. */
|
|
272
|
+
booleans?: { truthy: string[]; falsy: string[] };
|
|
156
273
|
/** the vendor's error body, with `{message}`, `{code}`, `{param}`, `{kind}` placeholders; a
|
|
157
274
|
* placeholder with no value is left out when `errorOmitsAbsent` */
|
|
158
275
|
error: unknown;
|
|
@@ -166,6 +283,16 @@ export type DerivedManifest = {
|
|
|
166
283
|
/** a request header that selects the vendor's API version, and the answer to a malformed one
|
|
167
284
|
* (`{value}` in its message is the header's value) */
|
|
168
285
|
version?: { header: string; pattern: string; error: ErrorSpec };
|
|
286
|
+
/** request headers that carry meaning to the vendor (a tenant header such as `Stripe-Account`): recorded with a write
|
|
287
|
+
* so its perform sends them again (the real-system adapters, "What a pack declares"); `version.header` is too */
|
|
288
|
+
headers?: string[];
|
|
289
|
+
/** the vendor's signed webhooks, folded into a root */
|
|
290
|
+
ingest?: IngestDecl;
|
|
291
|
+
/** the vendor's documented limits, charged by the executor on every live call (D8) */
|
|
292
|
+
rateBudget?: RateBudgetDeclaration;
|
|
293
|
+
/** a pack whose wire has no derived perform (a socket, a line protocol, a managed database): binding a root is refused
|
|
294
|
+
* with this reason */
|
|
295
|
+
vendorBacked?: { none: string };
|
|
169
296
|
/** `withParam`: the error names the path parameter that held the unknown id */
|
|
170
297
|
notFound: { status: number; message: string; code?: string; kind?: string; withParam?: boolean };
|
|
171
298
|
/** the list envelope (placeholders `{data}`, `{has_more}`, `{url}`, `{first_id}`, `{last_id}`),
|
|
@@ -181,7 +308,7 @@ export type DerivedManifest = {
|
|
|
181
308
|
* in place of id cursors: `base64-offset` is base64 of `{"o":<offset>}` */
|
|
182
309
|
cursor?: { param: string; encoding: 'base64-offset' };
|
|
183
310
|
/** numbered pages (1-based `page`, the size in `limit.param`), with the vendor's `Link` header
|
|
184
|
-
* naming the first, previous, next and last pages */
|
|
311
|
+
* naming the first, previous, next and last pages (`link`); the envelope may name `{total_count}` and `{max_page}` */
|
|
185
312
|
page?: { param: string; link: boolean };
|
|
186
313
|
/** offset paging: `param` names how many items to skip; the page sits under the operation's envelope key beside the
|
|
187
314
|
* envelope, whose `{total_count}` is the count before paging (Tremendous's `{ orders: [...], total_count }`) */
|
|
@@ -211,20 +338,93 @@ export type DerivedManifest = {
|
|
|
211
338
|
idempotency?: { header: string; storedAs: string; conflict?: ErrorSpec; methods?: string[]; onlySuccess?: boolean };
|
|
212
339
|
/** how the vendor reads its credential, and what it answers without one or with a key the twin
|
|
213
340
|
* reserves as invalid, or one of a shape the vendor never issues (`keyFormat`, a pattern every key it issues
|
|
214
|
-
* matches: Resend's begin `re_`); a request that presents no such header at all is a trusted in-process call
|
|
215
|
-
|
|
341
|
+
* matches: Resend's begin `re_`); a request that presents no such header at all is a trusted in-process call.
|
|
342
|
+
* `held`: the key must be one the account holds, kept by its SHA-256 in a stored type's field (a token a Tokens page
|
|
343
|
+
* made: `{ storedAs: '_access_token', hashField: 'sha256' }`), or one of the World's `standing` keys (the one its
|
|
344
|
+
* seeds and init hand the app); any other is `invalid`. */
|
|
345
|
+
/** how the vendor reads its credential: the `header`, after its `scheme` (`''`: the header's whole value, an
|
|
346
|
+
* `x-api-key`), or any of `also` (ElevenLabs' `xi-api-key` beside `Authorization`, Google's `?key=`); `paths` (a
|
|
347
|
+
* regular expression) the paths it gates, every other passing ungated; a key the World issued is `held` by its hash */
|
|
348
|
+
auth?: { header: string; scheme: string; also?: Array<{ header?: string; scheme?: string; query?: string }>; paths?: string; missing: ErrorSpec; invalidKeys: string[]; keyFormat?: string; invalid: ErrorSpec; gateWhenAbsent: boolean; held?: { storedAs: string; hashField: string; standing?: string[]; /** a field set true on a key no longer held (OpenRouter's `disabled`) */ unless?: string };
|
|
349
|
+
/** operations that check a credential of their own, not the account's (Cloudflare's asset upload reads its upload
|
|
350
|
+
* session's JWT from the same header): the gate leaves them to their handler */
|
|
351
|
+
exempt?: string[];
|
|
352
|
+
/** the vendor checks the key before it routes: a path it does not have is refused a missing or invalid key first */
|
|
353
|
+
beforeRouting?: boolean };
|
|
216
354
|
/** the twin door that makes the next request answer the vendor's rate-limit refusal */
|
|
217
355
|
/** headers every answer carries (a request id) */
|
|
218
356
|
answerHeaders?: Record<string, string>;
|
|
219
357
|
/** an origin the pack writes into URLs it mints, rewritten in every JSON answer to where this
|
|
220
358
|
* twin is reached for the request (twinPublicBase) */
|
|
221
359
|
origin?: { placeholder: string };
|
|
222
|
-
/** while a pack moves, how its existing code renders a stored subject (named by its stored
|
|
223
|
-
* type), so moved and unmoved operations answer the same shape */
|
|
224
|
-
view?: (storedType: string, body: Record<string, unknown>) => Record<string, unknown>;
|
|
225
360
|
resources: Record<string, ResourceDecl>;
|
|
361
|
+
/** Read-only cross-pack credential subjects of the same vendor (architecture A3); no root is exposed to handlers. */
|
|
362
|
+
ownerReads?: ReadonlyArray<{ owner: string; resource: string }>;
|
|
226
363
|
/** the vendor's screens this pack serves or owes (demand decides which exist) */
|
|
227
364
|
screens?: ScreenDecl[];
|
|
365
|
+
/** The discovery door's facts (`GET /twin`): what the twin is of, what it stores, and how it is authenticated. */
|
|
366
|
+
discovery?: {
|
|
367
|
+
twinOf: string; stores: string; identity?: string; notes?: string;
|
|
368
|
+
/** a model vendor's: what its scenario scripts (the `on` keys and the `respond` shape), and a handler as an example */
|
|
369
|
+
behavior?: string; exampleHandler?: ScenarioHandler;
|
|
370
|
+
};
|
|
371
|
+
/** The World's doors (architecture, "Doors, screens and the gap"): each a method and a path under `/_twin/` (with
|
|
372
|
+
* `{param}` segments), answered by the export of `semantics/doors.ts` its id names, over the contract's context. */
|
|
373
|
+
doors?: Array<{ id: string; method: string; path: string; note?: string }>;
|
|
374
|
+
/** The vendor's GraphQL API beside its REST one (`semantics/graphql.ts` over `generated/graphql-sdl.gen.json`): the
|
|
375
|
+
* paths it is POSTed to, and the answer to a query selecting a field nothing models (`{field}`, `{type}`). */
|
|
376
|
+
graphql?: { paths: ReadonlyArray<string>; unmodeled: { message: string; type?: string; status?: number } };
|
|
377
|
+
/** The vendor's answer for a path nothing serves and an operation nothing models, `{method}` and `{path}` filled
|
|
378
|
+
* (OpenAI's `Unknown request URL: {method} {path}`); the manifest's `notFound` when it names none. */
|
|
379
|
+
gap?: ErrorSpec;
|
|
380
|
+
/** The vendor's answer for a path its API serves under other methods than the request's (`405 Method not allowed:
|
|
381
|
+
* {method} {path}`), `{method}` and `{path}` filled; the gap when it names none. */
|
|
382
|
+
wrongMethod?: ErrorSpec;
|
|
383
|
+
/** The content codings the vendor reads a request's body in (its Content-Encoding: Tinybird's "Gzip or Zstandard"
|
|
384
|
+
* events) and answers in when the request accepts one (Accept-Encoding), and its answer for a body that does not
|
|
385
|
+
* decode as its Content-Encoding says. The kernel decodes before routing and encodes the API's answer. */
|
|
386
|
+
encodings?: { request?: ReadonlyArray<'gzip' | 'zstd' | 'deflate' | 'br'>; response?: ReadonlyArray<'gzip'>; undecodable?: ErrorSpec };
|
|
387
|
+
/** The content type the vendor labels a JSON answer with, when it is not the runtime's (Clerk's exact
|
|
388
|
+
* `application/json`, which @clerk/backend reads a body as JSON only on): every JSON answer relabelled. */
|
|
389
|
+
jsonContentType?: string;
|
|
390
|
+
/** The vendor's tenancy (architecture, "The derived core"): each stored subject belongs to the tenant it was made in —
|
|
391
|
+
* an organization, a team, an account — and a caller reaches only its own tenant's, as a parent's children are
|
|
392
|
+
* reached under it. `field` holds it on each subject; `semantics/tenant.ts` names the caller's. The core scopes every
|
|
393
|
+
* read, list, update and delete by it and stamps it on every create, for each resource not `shared`. */
|
|
394
|
+
tenant?: { field: string };
|
|
395
|
+
/** The vendor's own lanes behind this manifest's API ("Other wires: lanes"), dispatched by the kernel before the
|
|
396
|
+
* vendor's own doors, clock and API (LanesDecl). */
|
|
397
|
+
lanes?: LanesDecl;
|
|
398
|
+
/** A host whose first label is part of the path, as a regular expression over the request's vendor host whose first
|
|
399
|
+
* capture is put in front of the path before routing (S3's virtual-hosted addressing: `<bucket>.<endpoint>/<key>` is
|
|
400
|
+
* `/<bucket>/<key>`); the host itself is left as it came. */
|
|
401
|
+
pathFromHost?: string;
|
|
402
|
+
/** A prefix the vendor's hosts may put before a path (turbopuffer's region, `/aws-us-east-1`), as a regular expression
|
|
403
|
+
* matched at the path's start: stripped before routing, so the operation it names is the one served. Its named groups
|
|
404
|
+
* (Jira's `/ex/jira/(?<cloudId>[^/]+)`) are the operation's parameters as well. */
|
|
405
|
+
pathPrefix?: string;
|
|
406
|
+
/** A pattern over the request's vendor host whose named groups are the operation's parameters (E2B's envd answers each
|
|
407
|
+
* sandbox at `49983-<sandboxID>.e2b.app`: `^\\d+-(?<sandboxID>[a-z0-9]+)\\.`). */
|
|
408
|
+
hostParams?: string;
|
|
409
|
+
/** The persistent wires the vendor's clients hold open (sockets.ts: Discord's Gateway): each served at its path by
|
|
410
|
+
* the pack's `semantics/sockets.ts`, which the kernel offers every write after its webhooks. */
|
|
411
|
+
sockets?: ReadonlyArray<SocketDecl>;
|
|
412
|
+
/** The vendor speaks gRPC (architecture, "Other wires": gRPC): its server answers HTTP/2 and HTTP/1 on one port, each
|
|
413
|
+
* rpc (by its proto unit's schema, `src/generated/proto.gen.json`) through the pack's own fetch. */
|
|
414
|
+
grpc?: true;
|
|
415
|
+
/** The vendor runs its customers' images (machines.ts: Fly's Machines): the kernel serves `POST /_twin/machine-pool`
|
|
416
|
+
* and hands handlers the World's pool as `ctx.machines`. */
|
|
417
|
+
machines?: boolean;
|
|
418
|
+
/** A lane routed by host ("Other wires: lanes"): the hosts it serves, each exact or a suffix. The vendor's server sends
|
|
419
|
+
* them here, and SHAPE judges the lane on them alone, never on a page or door of the vendor on another host. */
|
|
420
|
+
hosts?: ReadonlyArray<{ host?: string; suffix?: string }>;
|
|
421
|
+
/** The path parameters whose values span segments where the spec cannot say so (an OpenAPI path has no greedy label:
|
|
422
|
+
* GitHub's `{ref}`, QStash's `{destination}`); a Smithy model's greedy labels are the surface's own `spanning`. */
|
|
423
|
+
spanning?: ReadonlyArray<string>;
|
|
424
|
+
/** The webhooks the vendor sends for its writes, as data the kernel renders, signs, delivers and records (events.ts);
|
|
425
|
+
* `render` is the pack's `semantics/events.ts`, how the vendor renders an event's `data` when it is not the written
|
|
426
|
+
* object. */
|
|
427
|
+
events?: EventsDecl & { render?: EventRender<WriteHookContext>; values?: EventValues<WriteHookContext> };
|
|
228
428
|
/** called after every stored write with the rendered resource, its stored type and the kernel operation name */
|
|
229
429
|
onWrite?: (write: {
|
|
230
430
|
operation: string; storedType: string; body: Record<string, unknown>; root?: string; occurredAt: string; request: Request;
|
|
@@ -236,6 +436,66 @@ export type DerivedManifest = {
|
|
|
236
436
|
|
|
237
437
|
// ── request parsing ──────────────────────────────────────────────────────────────────────────
|
|
238
438
|
|
|
439
|
+
/** A body's fields as a strict vendor checks them (`ctx.fields`, the manifest's `body.strict`). Without `strict`, every
|
|
440
|
+
* field passes. An object or array field is not typed here: its own shape is the handler's. */
|
|
441
|
+
export function strictFields(m: DerivedManifest, operation: DerivedOperation, body: Record<string, unknown>): { fields: Record<string, unknown> } | { refused: Response } {
|
|
442
|
+
const strict = m.body.strict;
|
|
443
|
+
if (!strict) return { fields: { ...body } };
|
|
444
|
+
const declared = new Map((operation.body ?? []).map((f) => [f.name, f]));
|
|
445
|
+
const refuse = (template: string, values: Record<string, unknown>): { refused: Response } =>
|
|
446
|
+
({ refused: vendorError(m, { status: strict.status, ...(strict.code ? { code: strict.code } : {}), message: String(fill(template, values)) }) });
|
|
447
|
+
const fields: Record<string, unknown> = {};
|
|
448
|
+
for (const [name, value] of Object.entries(body)) {
|
|
449
|
+
const field = declared.get(name);
|
|
450
|
+
if (!field) return refuse(strict.unknown, { name });
|
|
451
|
+
const { type } = field;
|
|
452
|
+
const fits = value === null
|
|
453
|
+
|| (type === 'boolean' && typeof value === 'boolean')
|
|
454
|
+
|| (type === 'integer' && Number.isInteger(value))
|
|
455
|
+
|| (type === 'number' && typeof value === 'number')
|
|
456
|
+
|| (type === 'string' && typeof value === 'string')
|
|
457
|
+
|| !['boolean', 'integer', 'number', 'string'].includes(type);
|
|
458
|
+
if (!fits) return refuse(strict.type, { name, type: type === 'integer' ? 'an integer' : `a ${type}` });
|
|
459
|
+
if (typeof value === 'number' && ((field.minimum !== undefined && value < field.minimum) || (field.maximum !== undefined && value > field.maximum))) {
|
|
460
|
+
return refuse(strict.range, { name, min: field.minimum ?? null, max: field.maximum ?? null });
|
|
461
|
+
}
|
|
462
|
+
fields[name] = value;
|
|
463
|
+
}
|
|
464
|
+
return { fields };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Top-level schema checks with vendor-supplied response templates; unknown fields pass to semantics. */
|
|
468
|
+
function validateBody(m: DerivedManifest, operation: DerivedOperation, body: unknown): Response | undefined {
|
|
469
|
+
const rule = m.body.validation;
|
|
470
|
+
if (!rule || (rule.operations && !rule.operations.includes(operation.id))) return undefined;
|
|
471
|
+
const refuse = (template: unknown, values: Record<string, unknown>): Response => Response.json(fill(template, values), { status: rule.status });
|
|
472
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return refuse(rule.type, { name: '$body', type: 'object', input: body ?? null });
|
|
473
|
+
const values = body as Record<string, unknown>;
|
|
474
|
+
for (const field of operation.body ?? []) {
|
|
475
|
+
const value = values[field.name];
|
|
476
|
+
if (value === undefined) {
|
|
477
|
+
if (field.required) return refuse(rule.missing, { name: field.name, input: values });
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
const types = field.type.split('|');
|
|
481
|
+
const fits = types.some((type) => type === 'null' ? value === null : type === 'integer' ? Number.isInteger(value) : type === 'array' ? Array.isArray(value) : type === 'object' ? value !== null && typeof value === 'object' && !Array.isArray(value) : type === 'string' ? typeof value === 'string' : type === 'number' ? typeof value === 'number' : type === 'boolean' ? typeof value === 'boolean' : true);
|
|
482
|
+
if (!fits) return refuse(rule.type, { name: field.name, type: field.type, input: value });
|
|
483
|
+
if (typeof value === 'number' && ((field.minimum !== undefined && value < field.minimum) || (field.maximum !== undefined && value > field.maximum))) return refuse(rule.range, { name: field.name, min: field.minimum ?? null, max: field.maximum ?? null, input: value });
|
|
484
|
+
}
|
|
485
|
+
return undefined;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** A value as the vendor reads a boolean (`ctx.flag`, the manifest's `booleans`). */
|
|
489
|
+
export function flagOf(m: Pick<DerivedManifest, 'booleans'>, value: unknown): boolean | undefined {
|
|
490
|
+
if (typeof value === 'boolean') return value;
|
|
491
|
+
if (value === undefined || value === null) return undefined;
|
|
492
|
+
const text = String(value).toLowerCase();
|
|
493
|
+
const { truthy, falsy } = m.booleans ?? { truthy: ['true'], falsy: ['false'] };
|
|
494
|
+
if (truthy.some((t) => t.toLowerCase() === text)) return true;
|
|
495
|
+
if (falsy.some((f) => f.toLowerCase() === text)) return false;
|
|
496
|
+
return undefined;
|
|
497
|
+
}
|
|
498
|
+
|
|
239
499
|
/** A form body's bracket notation (`a[b][0][c]=v`, `expand[]=x`) as nested objects and arrays. A form carries only
|
|
240
500
|
* text: a field the operation's spec types as a number or a boolean (`scalars`, by bracket path, written by
|
|
241
501
|
* world-tooling's formScalarsOf) is read as one when its text is that literal; every other field stays the text sent
|
|
@@ -291,24 +551,31 @@ export async function readParams(manifest: DerivedManifest, request: Request, op
|
|
|
291
551
|
// an upload: text fields as given, each file as its name, media type, size and content (its text), with its raw
|
|
292
552
|
// `bytes` beside them for a pack that must tell an image or audio file from anything else; the bytes are not
|
|
293
553
|
// enumerated, so a write's record of the request keeps the text alone
|
|
294
|
-
|
|
554
|
+
// read from its bytes (multipart.ts): a runtime's form parser drops a part with an empty field name
|
|
555
|
+
const boundary = multipartBoundary(type);
|
|
556
|
+
const parts = boundary === null ? [] : multipartParts(new Uint8Array(await request.arrayBuffer()), boundary);
|
|
295
557
|
const out: Record<string, unknown> = { ...query };
|
|
296
|
-
for (const
|
|
297
|
-
if (
|
|
298
|
-
out[
|
|
558
|
+
for (const part of parts) {
|
|
559
|
+
if (part.filename === null) {
|
|
560
|
+
out[part.name] = new TextDecoder().decode(part.body);
|
|
299
561
|
continue;
|
|
300
562
|
}
|
|
301
|
-
const
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
out[key] = Object.defineProperty(parsed, 'bytes', { value: bytes, enumerable: false });
|
|
563
|
+
const bytes = part.body;
|
|
564
|
+
const parsed = { name: part.filename, type: part.type ?? '', size: bytes.byteLength, content: new TextDecoder().decode(bytes) };
|
|
565
|
+
out[part.name] = Object.defineProperty(parsed, 'bytes', { value: bytes, enumerable: false });
|
|
305
566
|
}
|
|
306
567
|
return out;
|
|
307
568
|
}
|
|
308
|
-
const text = await request
|
|
569
|
+
const text = await bodyTextOf(request);
|
|
309
570
|
if (!text) return query;
|
|
310
|
-
|
|
311
|
-
const
|
|
571
|
+
// NDJSON (`application/x-ndjson`) is lines of JSON, not a JSON document: the handler reads its text
|
|
572
|
+
const isJson = type.includes('json') && !type.includes('ndjson');
|
|
573
|
+
const asJson = isJson || manifest.body.json === 'always';
|
|
574
|
+
// `json: 'always'` reads a body labelled otherwise as JSON (GitHub reads curl -d's); a body that is not JSON is read
|
|
575
|
+
// as the form it is labelled, never a crash (a sign-in page's own form, posted to the vendor's page)
|
|
576
|
+
let body: unknown;
|
|
577
|
+
if (asJson) { try { body = JSON.parse(text); } catch { if (isJson) throw new SyntaxError('malformed JSON body'); body = parseBracketForm(text, scalars); } }
|
|
578
|
+
else body = parseBracketForm(text, scalars);
|
|
312
579
|
// a body that is not an object (GitHub's set-labels takes a bare array) is the body, not fields
|
|
313
580
|
return body && typeof body === 'object' && !Array.isArray(body) ? { ...query, ...(body as Record<string, unknown>) } : { ...query };
|
|
314
581
|
}
|
|
@@ -325,7 +592,10 @@ function fill(template: unknown, values: Record<string, unknown>, omitAbsent = f
|
|
|
325
592
|
}
|
|
326
593
|
if (Array.isArray(template)) return template.map((t) => fill(t, values, omitAbsent)).filter((v) => v !== ABSENT);
|
|
327
594
|
if (template && typeof template === 'object') {
|
|
328
|
-
|
|
595
|
+
const entries = Object.entries(template).map(([k, v]) => [k, fill(v, values, omitAbsent)] as const).filter(([, v]) => v !== ABSENT);
|
|
596
|
+
// an object every one of whose placeholders was absent is absent itself (Clerk's `meta` with no parameter to name)
|
|
597
|
+
if (omitAbsent && entries.length === 0 && Object.keys(template).length > 0) return ABSENT;
|
|
598
|
+
return Object.fromEntries(entries);
|
|
329
599
|
}
|
|
330
600
|
return template;
|
|
331
601
|
}
|
|
@@ -333,14 +603,16 @@ function fill(template: unknown, values: Record<string, unknown>, omitAbsent = f
|
|
|
333
603
|
export function vendorError(manifest: DerivedManifest, e: ErrorSpec): Response {
|
|
334
604
|
// the status as the answer's number, and as text for a vendor whose body spells it as a string (GitHub's "404")
|
|
335
605
|
const values = { message: e.message, code: e.code ?? null, param: e.param ?? null, kind: e.kind ?? manifest.defaultKind ?? null, status: e.status, statusText: String(e.status) };
|
|
606
|
+
// the HTTP status, which a vendor may keep apart from the one its body names (errorsAnsweredAs)
|
|
607
|
+
const answered = manifest.errorsAnsweredAs && e.status < manifest.errorsAnsweredAs.below ? manifest.errorsAnsweredAs.status : e.status;
|
|
336
608
|
// a vendor that answers its errors as a document, not JSON (S3's XML `<Error><Code>…`): the template is that text,
|
|
337
609
|
// each value escaped into it
|
|
338
610
|
if (typeof manifest.error === 'string') {
|
|
339
611
|
const escape = (v: unknown): string => String(v ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
340
612
|
const body = manifest.error.replace(/\{(\w+)\}/g, (_, k: string) => escape(values[k as keyof typeof values]));
|
|
341
|
-
return new Response(body, { status:
|
|
613
|
+
return new Response(body, { status: answered, headers: { 'content-type': body.trimStart().startsWith('<') ? 'application/xml' : 'text/plain; charset=utf-8' } });
|
|
342
614
|
}
|
|
343
|
-
return Response.json(fill(manifest.error, values, manifest.errorOmitsAbsent ?? false), { status:
|
|
615
|
+
return Response.json(fill(manifest.error, values, manifest.errorOmitsAbsent ?? false), { status: answered });
|
|
344
616
|
}
|
|
345
617
|
|
|
346
618
|
function notFound(manifest: DerivedManifest, object: string, id: string, param?: string): Response {
|
|
@@ -354,7 +626,7 @@ function notFound(manifest: DerivedManifest, object: string, id: string, param?:
|
|
|
354
626
|
|
|
355
627
|
// ── the tree ─────────────────────────────────────────────────────────────────────────────────
|
|
356
628
|
|
|
357
|
-
const storedType = (m: DerivedManifest, resource: string): string => m.resources[resource]?.storedAs ?? resource;
|
|
629
|
+
export const storedType = (m: DerivedManifest, resource: string): string => m.resources[resource]?.storedAs ?? resource;
|
|
358
630
|
|
|
359
631
|
// A service's resources by type, folded once per World state (the tree's stamp; any write moves it), not on every read:
|
|
360
632
|
// a read of one resource type projected the service's whole tree, so a pack whose tree also holds many subjects of
|
|
@@ -425,36 +697,112 @@ export function render(r: TwinResource): Record<string, unknown> {
|
|
|
425
697
|
return Object.fromEntries(Object.entries({ id: r.id, ...ownFields(r) }).filter(([k]) => !k.startsWith('_') && k !== 'updatedAt'));
|
|
426
698
|
}
|
|
427
699
|
|
|
700
|
+
/** A stored subject as the vendor answers it: its own fields, its id under the name the vendor gives it. */
|
|
428
701
|
function view(m: DerivedManifest, resource: string, r: TwinResource): Record<string, unknown> {
|
|
429
|
-
const
|
|
430
|
-
if (
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
return Object.fromEntries(Object.entries(m.view(storedType(m, resource), r as Record<string, unknown>)).filter(([k]) => !k.startsWith('_')));
|
|
702
|
+
const idAs = m.resources[resource]?.idAs;
|
|
703
|
+
if (!idAs) return render(r);
|
|
704
|
+
const { id, ...rest } = render(r);
|
|
705
|
+
return { [idAs]: id, ...rest };
|
|
434
706
|
}
|
|
435
707
|
|
|
436
708
|
/** Where the core runs: the World root it reads and writes, and its clock (the World's by default;
|
|
437
709
|
* a caller that pins an instant passes its own). */
|
|
438
|
-
|
|
710
|
+
/** One call of a protocol-3 pack's default data (`src/semantics/seed.ts` exports `seed: SeedCall[]`): a request to the
|
|
711
|
+
* vendor's own operation (or the pack's door), made in order against the twin by the runner `volter-world init` writes
|
|
712
|
+
* beside the copied data. */
|
|
713
|
+
export type SeedCall = { method: string; path: string; body?: unknown; headers?: Record<string, string> };
|
|
714
|
+
|
|
715
|
+
/** Where a call is answered: the World's root, its clock, and the managed Postgres the runtime binds to the pack
|
|
716
|
+
* (`database`, the descriptor's `managedDatabase`), which a handler reaches as `ctx.engine` — READ ONLY in every
|
|
717
|
+
* transaction when the twin is (`readOnly`) or the request is (x-volter-read-only). */
|
|
718
|
+
export type CoreScope = {
|
|
719
|
+
root?: string; clock?: () => string; database?: string; readOnly?: boolean;
|
|
720
|
+
/** `semantics/tenant.ts`: the tenant the caller acts in (the manifest's `tenant`), or undefined for none */
|
|
721
|
+
tenant?: (ctx: HandlerContext) => string | undefined | Promise<string | undefined>;
|
|
722
|
+
};
|
|
439
723
|
|
|
440
724
|
function now(m: DerivedManifest, at: string): unknown {
|
|
441
|
-
return m.time === 'unix' ? Math.floor(Date.parse(at) / 1000) : at;
|
|
725
|
+
return m.time === 'unix' ? Math.floor(Date.parse(at) / 1000) : m.time === 'unix-ms' ? Date.parse(at) : at;
|
|
442
726
|
}
|
|
443
727
|
|
|
444
728
|
function applyRule(m: DerivedManifest, rule: FieldRule, id: string, values: Record<string, unknown>, at: string): unknown {
|
|
445
729
|
if ('now' in rule) return now(m, at);
|
|
446
730
|
if ('id' in rule) return id;
|
|
447
731
|
if ('value' in rule) return rule.value;
|
|
448
|
-
|
|
732
|
+
const text = fill(rule.template, { ...values, id });
|
|
733
|
+
return rule.encode === 'base64' ? btoa(String.fromCharCode(...new TextEncoder().encode(String(text)))) : text;
|
|
449
734
|
}
|
|
450
735
|
|
|
451
736
|
const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
452
737
|
|
|
453
738
|
/** The next id the manifest's template gives this resource: one past the highest it already minted. */
|
|
454
|
-
|
|
739
|
+
/** The World's seed for a service's secrets (ctx.secret): made once from the platform's random source, then read. The
|
|
740
|
+
* making is one per service store in the process: two first calls at once share it, so no secret handed out under one
|
|
741
|
+
* seed is lost to another written after it (a service's store is one process's). */
|
|
742
|
+
const SEED = '_world_seed';
|
|
743
|
+
const seeding = new Map<string, Promise<string>>();
|
|
744
|
+
function worldSeed(service: string, root: string | undefined, at: string): Promise<string> {
|
|
745
|
+
const held = resourcesOfType(service, SEED, root).find((r) => r.id === 'seed') as Record<string, unknown> | undefined;
|
|
746
|
+
if (typeof held?.value === 'string') return Promise.resolve(held.value);
|
|
747
|
+
const key = `${root ?? ''}\u0000${service}`;
|
|
748
|
+
const pending = seeding.get(key);
|
|
749
|
+
if (pending) return pending;
|
|
750
|
+
const made = (async () => {
|
|
751
|
+
const value = Buffer.from(globalThis.crypto.getRandomValues(new Uint8Array(32))).toString('hex');
|
|
752
|
+
// the World's own bookkeeping, made on first use whatever the request: a read-only one lands it too (a vendor's move)
|
|
753
|
+
await runAsVendorMove(() => applyTwinWrite(service, { operation: 'world_seed.record', subjectType: SEED, subjectId: 'seed', fields: { value }, occurredAt: at, actor: { kind: 'system' } }, root));
|
|
754
|
+
return value;
|
|
755
|
+
})().finally(() => seeding.delete(key));
|
|
756
|
+
seeding.set(key, made);
|
|
757
|
+
return made;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** A World's signing key for a label (ctx.signingKey): made once from the platform's random source, then read; the
|
|
761
|
+
* making is one per service store and label in the process, as the seed's is. */
|
|
762
|
+
const SIGNING_KEY = '_world_signing_key';
|
|
763
|
+
const keying = new Map<string, Promise<{ privatePem: string; publicPem: string }>>();
|
|
764
|
+
const pem = (kind: string, der: ArrayBuffer): string => `-----BEGIN ${kind}-----\n${Buffer.from(der).toString('base64').replace(/(.{64})/g, '$1\n').replace(/\n$/, '')}\n-----END ${kind}-----\n`;
|
|
765
|
+
function heldWorldSigningKey(service: string, root: string | undefined, label: string, alg: 'RS256' | 'Ed25519'): { privatePem: string; publicPem: string } | undefined {
|
|
766
|
+
const id = alg === 'RS256' ? label : `${label}#${alg}`;
|
|
767
|
+
const held = resourcesOfType(service, SIGNING_KEY, root).find((r) => r.id === id) as Record<string, unknown> | undefined;
|
|
768
|
+
return typeof held?.privatePem === 'string' && typeof held.publicPem === 'string' ? { privatePem: held.privatePem, publicPem: held.publicPem } : undefined;
|
|
769
|
+
}
|
|
770
|
+
function worldSigningKey(service: string, root: string | undefined, at: string, label: string, alg: 'RS256' | 'Ed25519'): Promise<{ privatePem: string; publicPem: string }> {
|
|
771
|
+
// an RSA key keeps its label; another algorithm's is kept under the label and the algorithm, so one label never names two
|
|
772
|
+
const id = alg === 'RS256' ? label : `${label}#${alg}`;
|
|
773
|
+
const held = resourcesOfType(service, SIGNING_KEY, root).find((r) => r.id === id) as Record<string, unknown> | undefined;
|
|
774
|
+
if (typeof held?.privatePem === 'string' && typeof held.publicPem === 'string') return Promise.resolve({ privatePem: held.privatePem, publicPem: held.publicPem });
|
|
775
|
+
const key = `${root ?? ''}\u0000${service}\u0000${id}`;
|
|
776
|
+
const pending = keying.get(key);
|
|
777
|
+
if (pending) return pending;
|
|
778
|
+
const made = (async () => {
|
|
779
|
+
const pair = (alg === 'Ed25519'
|
|
780
|
+
? await globalThis.crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])
|
|
781
|
+
: await globalThis.crypto.subtle.generateKey({ name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, true, ['sign', 'verify'])) as CryptoKeyPair;
|
|
782
|
+
const fields = {
|
|
783
|
+
privatePem: pem('PRIVATE KEY', await globalThis.crypto.subtle.exportKey('pkcs8', pair.privateKey)),
|
|
784
|
+
publicPem: pem('PUBLIC KEY', await globalThis.crypto.subtle.exportKey('spki', pair.publicKey)),
|
|
785
|
+
};
|
|
786
|
+
await runAsVendorMove(() => applyTwinWrite(service, { operation: 'world_signing_key.record', subjectType: SIGNING_KEY, subjectId: id, fields, occurredAt: at, actor: { kind: 'system' } }, root));
|
|
787
|
+
return fields;
|
|
788
|
+
})().finally(() => keying.delete(key));
|
|
789
|
+
keying.set(key, made);
|
|
790
|
+
return made;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function mintId(m: DerivedManifest, resource: string, root?: string, at?: string): string {
|
|
794
|
+
if (!m.resources[resource]) throw new Error(`mint: ${resource} is not a resource ${m.vendor}'s manifest declares`);
|
|
455
795
|
const prefix = m.resources[resource]!.idPrefix;
|
|
456
|
-
|
|
457
|
-
|
|
796
|
+
const template = m.resources[resource]!.ids ?? m.ids.template;
|
|
797
|
+
if (template === '{uuid}') return mintUuid(m, resource, root);
|
|
798
|
+
const snowflake = /^\{snowflake:(\d+)\}$/.exec(template);
|
|
799
|
+
if (snowflake) return mintSnowflake(m, BigInt(snowflake[1]!), root, at);
|
|
800
|
+
// `{letters:N}` and `{hex:N}`, alone or after the resource's prefix (`{prefix}{letters:16}`: Fly's `vol_…`)
|
|
801
|
+
const letters = /^(\{prefix\})?\{letters:(\d+)\}$/.exec(template);
|
|
802
|
+
if (letters) return mintLetters(m, resource, Number(letters[2]), root, letters[1] ? prefix : '');
|
|
803
|
+
const hex = /^(\{prefix\})?\{hex:(\d+)\}$/.exec(template);
|
|
804
|
+
if (hex) return mintHex(m, resource, Number(hex[2]), root, hex[1] ? prefix : '');
|
|
805
|
+
const [head, tail = ''] = template.split('{n}');
|
|
458
806
|
const pattern = new RegExp(`^${escapeRe(String(fill(head!, { prefix })))}(\\d+)${escapeRe(String(fill(tail, { prefix })))}$`);
|
|
459
807
|
let max = 0;
|
|
460
808
|
// the one type, from the index that follows writes (a scan of the whole World per create grew with it)
|
|
@@ -462,7 +810,50 @@ function mintId(m: DerivedManifest, resource: string, root?: string): string {
|
|
|
462
810
|
const hit = pattern.exec(r.id);
|
|
463
811
|
if (hit) max = Math.max(max, Number(hit[1]));
|
|
464
812
|
}
|
|
465
|
-
return String(fill(
|
|
813
|
+
return String(fill(template, { prefix, n: max + 1 }));
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/** The next snowflake (Discord's, X's): the milliseconds since the vendor's epoch shifted left 22 bits, the low bits an
|
|
817
|
+
* increment. One space over every snowflake resource of the vendor, as its one generator's: the instant's first, or one
|
|
818
|
+
* past the greatest the World holds when that is later (a frozen clock, or several ids in one millisecond), so ids
|
|
819
|
+
* rise with time and never repeat. */
|
|
820
|
+
/** The last snowflake minted per vendor and World in this process: two mints before the first is written (a message
|
|
821
|
+
* and its attachment) must not meet. */
|
|
822
|
+
const lastSnowflake = new Map<string, bigint>();
|
|
823
|
+
function mintSnowflake(m: DerivedManifest, epoch: bigint, root?: string, at?: string): string {
|
|
824
|
+
const ms = BigInt(Date.parse(at ?? '') || 0);
|
|
825
|
+
const base = ms > epoch ? (ms - epoch) << 22n : 0n;
|
|
826
|
+
let max = -1n;
|
|
827
|
+
for (const [name, decl] of Object.entries(m.resources)) {
|
|
828
|
+
if (!/^\{snowflake:\d+\}$/.test(decl.ids ?? m.ids.template)) continue;
|
|
829
|
+
for (const r of resourcesOfType(m.service, storedType(m, name), root)) if (/^\d+$/.test(r.id)) { const v = BigInt(r.id); if (v > max) max = v; }
|
|
830
|
+
}
|
|
831
|
+
const key = `${m.service}\0${root ?? ''}`;
|
|
832
|
+
const last = lastSnowflake.get(key) ?? -1n;
|
|
833
|
+
const floor = max > last ? max : last;
|
|
834
|
+
const id = base > floor ? base : floor + 1n;
|
|
835
|
+
lastSnowflake.set(key, id);
|
|
836
|
+
return String(id);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/** The next id of `count` lowercase letters for a resource, derived from the resource and the count before it, past
|
|
840
|
+
* any the World already holds. */
|
|
841
|
+
function mintLetters(m: DerivedManifest, resource: string, count: number, root?: string, prefix = ''): string {
|
|
842
|
+
const held = new Set(resourcesOfType(m.service, storedType(m, resource), root).map((r) => r.id));
|
|
843
|
+
for (let n = held.size + 1; ; n++) {
|
|
844
|
+
const id = `${prefix}${lettersFrom(`${m.service}:${resource}:${n}`, count)}`;
|
|
845
|
+
if (!held.has(id)) return id;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/** The next hex id for a resource: N hex digits derived from the resource and the count before it, past any the World
|
|
850
|
+
* already holds (a Cloudflare id, 32). */
|
|
851
|
+
function mintHex(m: DerivedManifest, resource: string, count: number, root?: string, prefix = ''): string {
|
|
852
|
+
const held = new Set(resourcesOfType(m.service, storedType(m, resource), root).map((r) => r.id));
|
|
853
|
+
for (let n = held.size + 1; ; n++) {
|
|
854
|
+
const id = `${prefix}${sha256(`${m.service}:${resource}:${n}`).slice(0, count)}`;
|
|
855
|
+
if (!held.has(id)) return id;
|
|
856
|
+
}
|
|
466
857
|
}
|
|
467
858
|
|
|
468
859
|
/** The next UUID for a resource: a version-4-shaped UUID derived from the resource and the count before it, past any
|
|
@@ -471,8 +862,7 @@ function mintUuid(m: DerivedManifest, resource: string, root?: string): string {
|
|
|
471
862
|
const type = storedType(m, resource);
|
|
472
863
|
const held = new Set(resourcesOfType(m.service, type, root).map((r) => r.id));
|
|
473
864
|
for (let n = held.size + 1; ; n++) {
|
|
474
|
-
const
|
|
475
|
-
const id = `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-${'89ab'[parseInt(h[16]!, 16) % 4]}${h.slice(17, 20)}-${h.slice(20, 32)}`;
|
|
865
|
+
const id = uuidFrom(`${m.service}:${resource}:${n}`);
|
|
476
866
|
if (!held.has(id)) return id;
|
|
477
867
|
}
|
|
478
868
|
}
|
|
@@ -484,7 +874,10 @@ function expand(m: DerivedManifest, resource: string, body: Record<string, unkno
|
|
|
484
874
|
const [head, ...rest] = path.split('.');
|
|
485
875
|
const target = embeds[head!];
|
|
486
876
|
const id = out[head!];
|
|
487
|
-
if (!target
|
|
877
|
+
if (!target) continue;
|
|
878
|
+
// an object already embedded (an earlier path's) is walked further, not fetched again
|
|
879
|
+
if (id && typeof id === 'object' && !Array.isArray(id)) { if (rest.length) out[head!] = expand(m, target, id as Record<string, unknown>, [rest.join('.')], root); continue; }
|
|
880
|
+
if (typeof id !== 'string') continue;
|
|
488
881
|
const hit = stored(m, target, root).find((r) => r.id === id);
|
|
489
882
|
if (hit) out[head!] = rest.length ? expand(m, target, view(m, target, hit), [rest.join('.')], root) : view(m, target, hit);
|
|
490
883
|
}
|
|
@@ -500,20 +893,112 @@ async function write(m: DerivedManifest, call: DerivedCall, resource: string, id
|
|
|
500
893
|
return (await writeDetailed(m, call, resource, id, fields, operation, params, root, occurredAt)).body;
|
|
501
894
|
}
|
|
502
895
|
|
|
896
|
+
/** The request a context was opened for, as it came (contextFor): its path parameters, the query names the operation
|
|
897
|
+
* declares (a repeated name as its values), its body before the core's merge and alias adoption with only the fields
|
|
898
|
+
* the operation declares (a credential the caller put in the query or the body, Slack's `token`, is not the request's:
|
|
899
|
+
* a perform carries the root's), the headers the manifest names, and the caller's own request id as provenance. */
|
|
900
|
+
type SentRequest = { path: Record<string, string>; query: Record<string, string | string[]>; body: unknown; headers: Record<string, string>; lane?: string; requestId?: string; parts?: MultipartPart[]; type?: string };
|
|
901
|
+
const sentOf = new WeakMap<DerivedCall, SentRequest>();
|
|
902
|
+
/** The lane a vendor's router sent a request to (pack-fetch's laneRouter sets it). */
|
|
903
|
+
export const LANE_HEADER = 'x-volter-lane';
|
|
904
|
+
function sentRequest(m: DerivedManifest, call: DerivedCall, body: unknown): SentRequest {
|
|
905
|
+
const url = new URL(call.request.url);
|
|
906
|
+
const named = [...(m.headers ?? []), ...(m.version ? [m.version.header] : [])];
|
|
907
|
+
const headers = Object.fromEntries(named.flatMap((h) => { const v = call.request.headers.get(h); return v === null ? [] : [[h.toLowerCase(), v]]; }));
|
|
908
|
+
const lane = call.request.headers.get(LANE_HEADER) ?? undefined;
|
|
909
|
+
const requestId = call.request.headers.get('x-twins-request-id') ?? undefined;
|
|
910
|
+
const declared = new Set((call.operation.query ?? []).map((q) => q.name));
|
|
911
|
+
const query: Record<string, string | string[]> = {};
|
|
912
|
+
for (const name of new Set(url.searchParams.keys())) {
|
|
913
|
+
if (!declared.has(name)) continue;
|
|
914
|
+
const values = url.searchParams.getAll(name);
|
|
915
|
+
query[name] = values.length > 1 ? values : values[0]!;
|
|
916
|
+
}
|
|
917
|
+
const fields = new Set((call.operation.body ?? []).map((f) => f.name));
|
|
918
|
+
const asCame = body !== null && typeof body === 'object' && ('$text' in body || '$base64' in body);
|
|
919
|
+
const sentBody = fields.size && !asCame && body !== null && typeof body === 'object' && !Array.isArray(body) ? Object.fromEntries(Object.entries(body as Record<string, unknown>).filter(([k]) => fields.has(k))) : body;
|
|
920
|
+
return { path: { ...call.params }, query, body: sentBody, headers, ...(lane ? { lane } : {}), ...(requestId ? { requestId } : {}) };
|
|
921
|
+
}
|
|
922
|
+
/** What a write records of the call that made it: the surface operation and the request as it came, or which of the
|
|
923
|
+
* World's own doors made it (a door or a screen, the gap, the clock), which a perform never sends. */
|
|
924
|
+
/** A body as it came: `{ $text }` when its bytes are UTF-8, else `{ $base64 }`. */
|
|
925
|
+
async function rawBody(request: Request): Promise<{ $text: string } | { $base64: string }> {
|
|
926
|
+
const bytes = new Uint8Array(await request.clone().arrayBuffer());
|
|
927
|
+
try { return { $text: new TextDecoder('utf-8', { fatal: true }).decode(bytes) }; } catch { return { $base64: Buffer.from(bytes).toString('base64') }; }
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/** A multipart request's file parts, kept in the World's blob store by their bytes' digest (`files/<sha256>`): what a
|
|
931
|
+
* write records of them (`input.files`), so a perform sends them again. */
|
|
932
|
+
async function keptFiles(m: DerivedManifest, call: DerivedCall, root: string | undefined): Promise<Array<{ field: string; name: string; type: string | null; blob: string }> | undefined> {
|
|
933
|
+
const files = sentOf.get(call)?.parts?.filter((p) => p.filename !== null);
|
|
934
|
+
if (!files?.length) return undefined;
|
|
935
|
+
const out: Array<{ field: string; name: string; type: string | null; blob: string }> = [];
|
|
936
|
+
for (const f of files) {
|
|
937
|
+
const blob = `files/${hashFieldValue({ bytes: Buffer.from(f.body).toString('base64') })}`;
|
|
938
|
+
await writeResourceBlob(m.service, blob, f.body, root);
|
|
939
|
+
out.push({ field: f.name, name: f.filename!, type: f.type, blob });
|
|
940
|
+
}
|
|
941
|
+
return out;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function recordedInput(m: DerivedManifest, call: DerivedCall, merged: Record<string, unknown>): Record<string, unknown> {
|
|
945
|
+
const id = call.operation.id;
|
|
946
|
+
const sent = sentOf.get(call);
|
|
947
|
+
const asSent = sent ? { path: sent.path, query: sent.query, body: sent.body, headers: sent.headers, ...(sent.lane ? { lane: sent.lane } : {}), ...(sent.requestId ? { requestId: sent.requestId } : {}), ...(sent.type ? { contentType: sent.type } : {}) } : { path: call.params, body: merged };
|
|
948
|
+
if (id === 'graphql') {
|
|
949
|
+
const b = (sent?.body ?? {}) as { query?: unknown; variables?: unknown; operationName?: unknown };
|
|
950
|
+
return { graphql: { query: b.query, variables: b.variables ?? {}, ...(b.operationName ? { operationName: b.operationName } : {}) }, ...(sent?.lane ? { lane: sent.lane } : {}), headers: sent?.headers ?? {} };
|
|
951
|
+
}
|
|
952
|
+
if (id === 'clock') return { clock: true };
|
|
953
|
+
if (id === 'unmatched') return { gap: true, ...asSent };
|
|
954
|
+
if ((m.doors ?? []).some((d) => d.id === id) || (m.screens ?? []).some((sc) => sc.id === id) || id === 'machine-pool' || id.startsWith('socket:')) return { door: id, ...asSent };
|
|
955
|
+
return { operationId: id, ...asSent };
|
|
956
|
+
}
|
|
957
|
+
|
|
503
958
|
async function writeDetailed(m: DerivedManifest, call: DerivedCall, resource: string, id: string, fields: Record<string, unknown>, operation: string, params: Record<string, unknown>, root: string | undefined, occurredAt: string): Promise<{ body: Record<string, unknown>; id: string; vendorData?: unknown }> {
|
|
959
|
+
const withFiles = async (input: Record<string, unknown>): Promise<Record<string, unknown>> => { const files = input.operationId ? await keptFiles(m, call, root) : undefined; return files ? { ...input, files } : input; };
|
|
504
960
|
const { resource: row, result } = await applyTwinWrite(
|
|
505
961
|
m.service,
|
|
506
962
|
// who made it: the login the pack's identity reads off the request, when it declares one, so a subject's
|
|
507
963
|
// history can say who did each thing
|
|
508
|
-
|
|
964
|
+
// a vendor's own move (ctx.asVendor, the clock) is the World's, as the clock's writes are: no API call a head performs
|
|
965
|
+
inVendorMove()
|
|
966
|
+
? { operation, subjectType: storedType(m, resource), subjectId: id, fields, input: { clock: true }, occurredAt, actor: { kind: 'system' } }
|
|
967
|
+
: { operation, subjectType: storedType(m, resource), subjectId: id, fields, input: await withFiles(recordedInput(m, call, params)), occurredAt, actor: { kind: 'agent', ...(m.identity ? { id: m.identity(call.request.headers.get('authorization'), root) } : {}) } },
|
|
509
968
|
root,
|
|
510
969
|
);
|
|
970
|
+
await keepCounts(m, call, resource, row as unknown as Record<string, unknown>, params, root, occurredAt);
|
|
511
971
|
const body = view(m, resource, row);
|
|
512
|
-
|
|
972
|
+
const context = (): Promise<SemanticsContext> => contextFor(m, { ...call, request: call.request.clone() }, { ...(root !== undefined ? { root } : {}), clock: () => occurredAt });
|
|
973
|
+
if (m.onWrite) await m.onWrite({ operation, storedType: storedType(m, resource), body, ...(root !== undefined ? { root } : {}), occurredAt, request: call.request, context });
|
|
974
|
+
if (m.events) {
|
|
975
|
+
const events = m.events;
|
|
976
|
+
const write = { operation, storedType: storedType(m, resource), body, ...(root !== undefined ? { root } : {}), occurredAt, request: call.request };
|
|
977
|
+
await deliverEvents(m.service, events, write, async (type) => (events.render ? events.render(await context(), write, type) : body), undefined, events.values ? async (type) => events.values!(await context(), write, type) : undefined, (answer) => deliveryOutcome(m, answer, root));
|
|
978
|
+
}
|
|
979
|
+
if (m.sockets?.length) socketWrite(m.service, root, { operation, storedType: storedType(m, resource), body, ...(root !== undefined ? { root } : {}), occurredAt, request: call.request });
|
|
513
980
|
// the subject's id as it stands after the write: the vendor's, when a live head minted one
|
|
514
981
|
return { body, id: result.externalId ?? row.id, ...(result.vendorData !== undefined ? { vendorData: result.vendorData } : {}) };
|
|
515
982
|
}
|
|
516
983
|
|
|
984
|
+
/** The declared counts a written subject is a child in (a resource's `counts`), each kept on its parent: written when
|
|
985
|
+
* the count moved, through the write path, so the parent's history says when. */
|
|
986
|
+
async function keepCounts(m: DerivedManifest, call: DerivedCall, resource: string, row: Record<string, unknown>, params: Record<string, unknown>, root: string | undefined, occurredAt: string): Promise<void> {
|
|
987
|
+
for (const [parent, decl] of Object.entries(m.resources)) {
|
|
988
|
+
for (const [field, c] of Object.entries(decl.counts ?? {})) {
|
|
989
|
+
if (storedType(m, c.of) !== storedType(m, resource) || row[c.by] === undefined || row[c.by] === null) continue;
|
|
990
|
+
const pid = String(row[c.by]);
|
|
991
|
+
const held = stored(m, parent, root).find((r) => r.id === pid) as Record<string, unknown> | undefined;
|
|
992
|
+
if (!held) continue;
|
|
993
|
+
const n = stored(m, c.of, root).filter((r) => {
|
|
994
|
+
const x = r as Record<string, unknown>;
|
|
995
|
+
return String(x[c.by]) === pid && (c.present === undefined || (x[c.present] !== undefined && x[c.present] !== null));
|
|
996
|
+
}).length + (c.plus ?? 0);
|
|
997
|
+
if (held[field] !== n) await writeDetailed(m, call, parent, pid, { [field]: n }, `${storedType(m, parent)}.counted`, params, root, occurredAt);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
517
1002
|
/** An answer in the vendor's success envelope, under the operation's key, when the vendor has one. */
|
|
518
1003
|
function envelope(m: DerivedManifest, op: DerivedOperation, body: Record<string, unknown>): Record<string, unknown> {
|
|
519
1004
|
return m.success && op.answers?.key ? { ...m.success, [op.answers.key]: body } : body;
|
|
@@ -545,6 +1030,40 @@ export function observeTransitions(observer: TransitionObserver | undefined): vo
|
|
|
545
1030
|
/** What a declared machine says to one move: the transition that allows it, or the refusal it gives (and
|
|
546
1031
|
* nothing when it declares neither). The derived core and `legal` ask it; so does an engine that is not
|
|
547
1032
|
* HTTP-shaped (a line protocol's session), so one machine rules every wire. */
|
|
1033
|
+
/** What a delivery's answer writes on its endpoint (the endpoint kind's `outcome`, architecture "Events and webhooks"):
|
|
1034
|
+
* a success resets the failure count and writes its fields; a failure counts, and a run of them makes the declared
|
|
1035
|
+
* move, asked of the endpoint's machine as the vendor's (a move the machine does not allow is not made). Read fresh
|
|
1036
|
+
* and written in one move, since the answer arrives after the write's own and answers may arrive together. */
|
|
1037
|
+
async function deliveryOutcome(m: DerivedManifest, answer: DeliveryAnswer, root?: string): Promise<void> {
|
|
1038
|
+
const o = answer.kind.outcome!;
|
|
1039
|
+
const type = answer.kind.storedAs;
|
|
1040
|
+
const ok = answer.status >= 200 && answer.status < 300;
|
|
1041
|
+
// the read and the count are one move under the action lock, so two answers at once count twice
|
|
1042
|
+
await runAsVendorMove(() => applyTwinWriteAtomic(m.service, (resources) => {
|
|
1043
|
+
const row = (resources as unknown as Array<Record<string, unknown>>).find((r) => r.type === type && String(r.id) === String(answer.row.id));
|
|
1044
|
+
if (!row || row.deleted === true) return { kind: 'skip', value: undefined };
|
|
1045
|
+
const fields: Record<string, unknown> = {};
|
|
1046
|
+
let operation = `${type}.delivered`;
|
|
1047
|
+
if (ok) {
|
|
1048
|
+
fields[o.failures] = 0;
|
|
1049
|
+
const seconds = Math.floor((Date.parse(answer.occurredAt) || 0) / 1000);
|
|
1050
|
+
for (const [k, v] of Object.entries(o.success ?? {})) fields[k] = v === '$time.iso' ? answer.occurredAt : v === '$time.s' ? seconds : v;
|
|
1051
|
+
} else {
|
|
1052
|
+
const failures = (Number(row[o.failures]) || 0) + 1;
|
|
1053
|
+
fields[o.failures] = failures;
|
|
1054
|
+
operation = `${type}.delivery-failed`;
|
|
1055
|
+
const exempt = o.unless !== undefined && row[o.unless.field] === o.unless.equals;
|
|
1056
|
+
if (o.move && o.after !== undefined && failures >= o.after && !exempt) {
|
|
1057
|
+
const decl = Object.values(m.resources).find((r) => (r.storedAs ?? '') === type) ?? m.resources[type];
|
|
1058
|
+
const machine = decl?.state?.[o.move.field];
|
|
1059
|
+
const { move } = machine ? transitionFor(o.move.field, machine, o.move.operation, row[o.move.field], o.move.to, String(row.id), 'vendor') : {};
|
|
1060
|
+
if (move) { fields[o.move.field] = o.move.to; operation = o.move.operation; }
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
return { kind: 'write', value: undefined, write: { operation, subjectType: type, subjectId: String(row.id), fields, occurredAt: answer.occurredAt, actor: { kind: 'system' } } };
|
|
1064
|
+
}, root));
|
|
1065
|
+
}
|
|
1066
|
+
|
|
548
1067
|
export function transitionFor(field: string, decl: StateField, operationId: string, current: unknown, requested: unknown, id?: string, actor: Actor = 'api'): { move?: Transition; refusal?: ErrorSpec } {
|
|
549
1068
|
const now = stateOf(decl, current);
|
|
550
1069
|
const candidates = decl.transitions.filter((t) => (t.actor ?? 'api') === actor && (t.operation ? t.operation === operationId : true) && (requested === undefined || (t.to ?? now) === String(requested)));
|
|
@@ -616,7 +1135,153 @@ async function boundaryParams(m: DerivedManifest, call: DerivedCall, root?: stri
|
|
|
616
1135
|
return adoptedInCall(m.service, await readParams(m, call.request, call.operation), root);
|
|
617
1136
|
}
|
|
618
1137
|
|
|
1138
|
+
/** The refusal a subject's fields would draw from its resource's `unique` rules: another live subject (not `except`)
|
|
1139
|
+
* already holds every field of a rule, each field given. */
|
|
1140
|
+
function conflictOf(m: DerivedManifest, resource: string, fields: Record<string, unknown>, except: string | undefined, root: string | undefined): ErrorSpec | undefined {
|
|
1141
|
+
for (const rule of m.resources[resource]?.unique ?? []) {
|
|
1142
|
+
if (rule.fields.some((f) => fields[f] === undefined || fields[f] === null)) continue;
|
|
1143
|
+
const held = stored(m, resource, root).some((r) => r.id !== except && rule.fields.every((f) => (r as Record<string, unknown>)[f] === fields[f]));
|
|
1144
|
+
if (held) return rule.refusal;
|
|
1145
|
+
}
|
|
1146
|
+
return undefined;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/** A subject deleted through the write path, after every subject its resource's `cascade` takes with it (depth first). */
|
|
1150
|
+
async function removeSubject(m: DerivedManifest, call: DerivedCall, resource: string, id: string, params: Record<string, unknown>, root: string | undefined, at: string, operation?: string): Promise<void> {
|
|
1151
|
+
for (const child of m.resources[resource]?.cascade ?? []) {
|
|
1152
|
+
for (const row of stored(m, child.resource, root).filter((r) => (r as Record<string, unknown>)[child.field] === id)) {
|
|
1153
|
+
await removeSubject(m, call, child.resource, String(row.id), params, root, at);
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
await write(m, call, resource, id, { deleted: true }, operation ?? `${storedType(m, resource)}.delete`, params, root, at);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/** Whether a key names a stored subject: its id, or one of the resource's alternate keys. */
|
|
1160
|
+
const namedBy = (decl: ResourceDecl | undefined, row: object, key: unknown): boolean =>
|
|
1161
|
+
(row as { id?: unknown }).id === key || (decl?.alternateKeys ?? []).some((f) => key !== undefined && key !== null && (row as Record<string, unknown>)[f] === key);
|
|
1162
|
+
|
|
1163
|
+
const isBag = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
1164
|
+
|
|
1165
|
+
/** One field of an update, as the resource's `update` rule merges it into what is stored. */
|
|
1166
|
+
/** A bag with every `null` key removed, at every level (a deep merge's new branch: null removes a key "at any level"). */
|
|
1167
|
+
const withoutNulls = (bag: Record<string, unknown>): Record<string, unknown> =>
|
|
1168
|
+
Object.fromEntries(Object.entries(bag).filter(([, v]) => v !== null).map(([k, v]) => [k, isBag(v) ? withoutNulls(v) : v]));
|
|
1169
|
+
|
|
1170
|
+
export function mergeField(rule: ResourceDecl['update'], prior: unknown, next: unknown): unknown {
|
|
1171
|
+
if (rule === 'deep' && isBag(next) && !isBag(prior)) return withoutNulls(next);
|
|
1172
|
+
if (rule === 'replace' || !isBag(next) || !isBag(prior)) return next;
|
|
1173
|
+
if (rule === 'deep') {
|
|
1174
|
+
const out: Record<string, unknown> = { ...prior };
|
|
1175
|
+
for (const [k, v] of Object.entries(next)) {
|
|
1176
|
+
if (v === null) delete out[k];
|
|
1177
|
+
else out[k] = isBag(v) && isBag(out[k]) ? mergeField('deep', out[k], v) : v;
|
|
1178
|
+
}
|
|
1179
|
+
return out;
|
|
1180
|
+
}
|
|
1181
|
+
return Object.fromEntries(Object.entries({ ...prior, ...next }).filter(([, value]) => value !== ''));
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/** A list's rows as the resource declares them arranged for this call: its field filters, its search, its order (the
|
|
1185
|
+
* `orderBy` parameter's, else `order`, else newest first); a refusal when the call orders by a field it does not take. */
|
|
1186
|
+
function arrangeRows(m: DerivedManifest, resource: string, params: Record<string, unknown>, input: Array<Record<string, unknown>>): Array<Record<string, unknown>> | ErrorSpec {
|
|
1187
|
+
const decl = m.resources[resource];
|
|
1188
|
+
let rows = input;
|
|
1189
|
+
for (const f of decl?.filters ?? []) if (params[f] !== undefined) rows = rows.filter((r) => String(r[f]) === String(params[f]));
|
|
1190
|
+
const needle = decl?.search ? params[decl.search.param] : undefined;
|
|
1191
|
+
if (decl?.search && typeof needle === 'string' && needle !== '') {
|
|
1192
|
+
const q = needle.toLowerCase();
|
|
1193
|
+
rows = rows.filter((r) => (decl.search!.exact ?? []).some((f) => String(r[f] ?? '').toLowerCase() === q) || (decl.search!.partial ?? []).some((f) => String(r[f] ?? '').toLowerCase().includes(q)));
|
|
1194
|
+
}
|
|
1195
|
+
let order: { field: string; direction: 'asc' | 'desc' } = decl?.order ?? { field: 'created', direction: 'desc' };
|
|
1196
|
+
// a declared ordering parameter orders text by its text; without one, the order is numeric, as it always was
|
|
1197
|
+
let byText = false;
|
|
1198
|
+
if (decl?.orderBy) {
|
|
1199
|
+
const asked = params[decl.orderBy.param];
|
|
1200
|
+
const spec = typeof asked === 'string' && asked !== '' ? asked : decl.orderBy.default;
|
|
1201
|
+
const field = spec.replace(/^[+-]/, '');
|
|
1202
|
+
if (!decl.orderBy.fields.includes(field)) return { ...decl.orderBy.invalid, message: decl.orderBy.invalid.message.replace('{fields}', decl.orderBy.fields.join(', ')) };
|
|
1203
|
+
order = { field, direction: spec.startsWith('-') ? 'desc' : 'asc' };
|
|
1204
|
+
byText = true;
|
|
1205
|
+
}
|
|
1206
|
+
const indexed = rows.map((row, i) => ({ row, i }));
|
|
1207
|
+
indexed.sort((a, b) => {
|
|
1208
|
+
const x = a.row[order.field]; const y = b.row[order.field];
|
|
1209
|
+
const byValue = !byText || typeof x === 'number' || typeof y === 'number' ? Number(x ?? 0) - Number(y ?? 0) : String(x ?? '').localeCompare(String(y ?? ''));
|
|
1210
|
+
// ties by mint order (`file-twin-10` after `file-twin-9`), not by the ids' lexical order
|
|
1211
|
+
const d = byValue || String(a.row.id).localeCompare(String(b.row.id), undefined, { numeric: true });
|
|
1212
|
+
return order.direction === 'desc' ? -d : d;
|
|
1213
|
+
});
|
|
1214
|
+
return indexed.map((x) => x.row);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/** A page of arranged rows, answered in the manifest's list envelope with its paging (cursors, pages, offsets). */
|
|
1218
|
+
function pageRows(m: DerivedManifest, op: DerivedOperation, call: DerivedCall, params: Record<string, unknown>, rows: Array<Record<string, unknown>>, resource: string, root: string | undefined, paths: string[]): Response {
|
|
1219
|
+
const size = m.list.limits?.[op.id] ?? m.list.limit;
|
|
1220
|
+
const asked = params[m.list.limit.param];
|
|
1221
|
+
const n = asked === undefined || asked === '' ? size.default : Number(asked);
|
|
1222
|
+
const limit = Number.isFinite(n) ? Math.min(size.max, Math.max(1, Math.trunc(n))) : size.default;
|
|
1223
|
+
const after = m.list.after ? params[m.list.after] : undefined;
|
|
1224
|
+
const before = m.list.before ? params[m.list.before] : undefined;
|
|
1225
|
+
let from = 0;
|
|
1226
|
+
let to: number;
|
|
1227
|
+
if (typeof before === 'string') {
|
|
1228
|
+
const at = rows.findIndex((r) => r.id === before);
|
|
1229
|
+
to = at === -1 ? (m.list.unknownCursor === 'end' ? 0 : rows.length) : at;
|
|
1230
|
+
from = Math.max(0, to - limit);
|
|
1231
|
+
} else {
|
|
1232
|
+
if (typeof after === 'string') {
|
|
1233
|
+
const at = rows.findIndex((r) => r.id === after);
|
|
1234
|
+
from = at === -1 ? (m.list.unknownCursor === 'end' ? rows.length : 0) : at + 1;
|
|
1235
|
+
}
|
|
1236
|
+
to = from + limit;
|
|
1237
|
+
}
|
|
1238
|
+
const page = rows.slice(from, to).map((r) => (paths.length ? expand(m, resource, r, paths.filter((p) => p.startsWith('data.')).map((p) => p.slice(5)), root) : r));
|
|
1239
|
+
const hasMore = typeof before === 'string' ? from > 0 : to < rows.length;
|
|
1240
|
+
const url = op.path.replace(/\{[^}]+\}/g, (p) => call.params[p.slice(1, -1)] ?? p);
|
|
1241
|
+
if (m.list.page) {
|
|
1242
|
+
const n = Math.max(1, Math.floor(Number(params[m.list.page.param]) || 1));
|
|
1243
|
+
const slice = rows.slice((n - 1) * limit, n * limit);
|
|
1244
|
+
const last = Math.max(1, Math.ceil(rows.length / limit));
|
|
1245
|
+
const headers = new Headers();
|
|
1246
|
+
if (m.list.page.link && rows.length > limit) {
|
|
1247
|
+
const at = (k: number): string => {
|
|
1248
|
+
const u = new URL(call.request.url);
|
|
1249
|
+
u.searchParams.set(m.list.page!.param, String(k));
|
|
1250
|
+
return u.toString();
|
|
1251
|
+
};
|
|
1252
|
+
const rels = [...(n > 1 ? [`<${at(n - 1)}>; rel="prev"`] : []), ...(n < last ? [`<${at(n + 1)}>; rel="next"`, `<${at(last)}>; rel="last"`] : []), ...(n > 1 ? [`<${at(1)}>; rel="first"`] : [])];
|
|
1253
|
+
if (rels.length) headers.set('link', rels.join(', '));
|
|
1254
|
+
}
|
|
1255
|
+
// `{total_count}` the count before paging, `{max_page}` the number of pages (none for an empty list: Polar's pagination)
|
|
1256
|
+
return Response.json(fill(m.list.envelope, { data: slice, next_cursor: null, key: op.answers?.key ?? 'data', total_count: rows.length, max_page: Math.ceil(rows.length / limit) }), { headers });
|
|
1257
|
+
}
|
|
1258
|
+
if (m.list.offset) {
|
|
1259
|
+
const skip = Math.max(0, Math.trunc(Number(params[m.list.offset.param]) || 0));
|
|
1260
|
+
const slice = rows.slice(skip, skip + limit);
|
|
1261
|
+
const key = op.answers?.key ?? 'data';
|
|
1262
|
+
return Response.json({ ...(m.success ?? {}), [key]: slice, ...(fill(m.list.envelopes?.[op.id] ?? m.list.envelope, { data: slice, total_count: rows.length, key }) as object) });
|
|
1263
|
+
}
|
|
1264
|
+
if (m.list.cursor) {
|
|
1265
|
+
const start = decodeOffset(params[m.list.cursor.param]);
|
|
1266
|
+
const slice = rows.slice(start, start + limit);
|
|
1267
|
+
const next = start + limit < rows.length ? encodeOffset(start + limit) : '';
|
|
1268
|
+
return Response.json({ ...(m.success ?? {}), ...(fill(m.list.envelope, { data: slice, next_cursor: next, key: op.answers?.key ?? 'data' }) as object), ...(op.answers?.key ? { [op.answers.key]: slice } : {}) });
|
|
1269
|
+
}
|
|
1270
|
+
return Response.json(fill(m.list.envelope, { data: page, has_more: hasMore, url, first_id: page[0]?.id ?? null, last_id: page.at(-1)?.id ?? null }));
|
|
1271
|
+
|
|
1272
|
+
}
|
|
1273
|
+
|
|
619
1274
|
/** Serve one operation from the manifest, or say why the core cannot. */
|
|
1275
|
+
/** A setting's fields as the vendor answers them: its own, without the kernel's bookkeeping or the parent it is kept
|
|
1276
|
+
* under (a setting answers its values, not an identity). */
|
|
1277
|
+
function settingFields(own: Record<string, unknown>, parentField?: string): Record<string, unknown> {
|
|
1278
|
+
return Object.fromEntries(Object.entries(own).filter(([k]) => !k.startsWith('_') && k !== 'deleted' && k !== parentField));
|
|
1279
|
+
}
|
|
1280
|
+
function settingView(m: DerivedManifest, resource: string, hit: TwinResource): Record<string, unknown> {
|
|
1281
|
+
// ownFields: the kernel's id, type and updatedAt gone, a vendor's own of those names restored
|
|
1282
|
+
return settingFields(ownFields(hit), m.resources[resource]?.parent?.field);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
620
1285
|
export async function serveCore(m: DerivedManifest, call: DerivedCall, scope: CoreScope = {}): Promise<CoreOutcome> {
|
|
621
1286
|
const root = scope.root;
|
|
622
1287
|
const at = (scope.clock ?? worldNow)();
|
|
@@ -632,7 +1297,12 @@ export async function serveCore(m: DerivedManifest, call: DerivedCall, scope: Co
|
|
|
632
1297
|
if (orphan) return { served: orphan };
|
|
633
1298
|
const parent = decl.parent;
|
|
634
1299
|
const parentId = parent ? parentValue(parent, call.params) : undefined;
|
|
635
|
-
|
|
1300
|
+
// the caller's tenant, when the vendor has tenancy and the resource is not shared by every tenant
|
|
1301
|
+
const tenantField = m.tenant && !decl.shared ? m.tenant.field : undefined;
|
|
1302
|
+
const tenant = tenantField && scope.tenant ? await scope.tenant(await contextFor(m, { ...call, request: call.request.clone() }, scope)) : undefined;
|
|
1303
|
+
if (tenantField && tenant === undefined) return { unmodeled: `the caller names no tenant (semantics/tenant.ts) for ${resource}` };
|
|
1304
|
+
const underParent = (r: TwinResource): boolean => (!parent || (r as Record<string, unknown>)[parent.field] === parentId)
|
|
1305
|
+
&& (!tenantField || (r as Record<string, unknown>)[tenantField] === tenant);
|
|
636
1306
|
const paths = expandPaths(m, params);
|
|
637
1307
|
// the vendor's success status from its spec: 201 for most creates, 204 with no body where it answers nothing
|
|
638
1308
|
const status = op.successStatus ?? 200;
|
|
@@ -641,90 +1311,58 @@ export async function serveCore(m: DerivedManifest, call: DerivedCall, scope: Co
|
|
|
641
1311
|
});
|
|
642
1312
|
const control = new Set([m.expandParam, m.list.limit.param, m.list.after, m.list.before, m.list.offset?.param].filter(Boolean) as string[]);
|
|
643
1313
|
const data = Object.fromEntries(Object.entries(params).filter(([k]) => !control.has(k) && k !== 'id'));
|
|
1314
|
+
// a strict vendor refuses a body it does not declare before the core writes it (body.strict, as ctx.fields)
|
|
1315
|
+
if (m.body.strict && (op.class === 'create' || op.class === 'update')) {
|
|
1316
|
+
const queried = new Set((op.query ?? []).map((q) => q.name));
|
|
1317
|
+
const checked = strictFields(m, op, Object.fromEntries(Object.entries(data).filter(([k]) => !queried.has(k) && !(k in call.params))));
|
|
1318
|
+
if ('refused' in checked) return { served: checked.refused };
|
|
1319
|
+
}
|
|
644
1320
|
|
|
645
1321
|
switch (op.class) {
|
|
646
1322
|
case 'create': {
|
|
647
1323
|
for (const field of Object.keys(decl.state ?? {})) if (field in data) return { unmodeled: `create sets state field ${field}` };
|
|
648
1324
|
if (decl.key) return { unmodeled: 'a create under a composite key is a handler' };
|
|
649
1325
|
const provided = m.ids.acceptProvided && typeof params.id === 'string' && params.id ? params.id : undefined;
|
|
650
|
-
const id = provided ?? mintId(m, resource, root);
|
|
1326
|
+
const id = provided ?? mintId(m, resource, root, at);
|
|
651
1327
|
const assigned = Object.fromEntries(Object.entries(decl.assigned ?? {}).map(([k, rule]) => [k, applyRule(m, rule, id, data, at)]));
|
|
652
1328
|
const initial = Object.fromEntries(Object.entries(decl.state ?? {}).map(([k, s]) => [k, s.initial]));
|
|
653
|
-
const under = parent ? { [parent.field]: parentId } : {};
|
|
1329
|
+
const under: Record<string, unknown> = { ...(parent ? { [parent.field]: parentId } : {}), ...(tenantField ? { [tenantField]: tenant } : {}) };
|
|
654
1330
|
if (decl.number) {
|
|
655
|
-
const siblings = stored(m, resource, root, { withDeleted: true }).filter(
|
|
1331
|
+
const siblings = stored(m, resource, root, { withDeleted: true }).filter(underParent);
|
|
656
1332
|
(under as Record<string, unknown>)[decl.number.field] = 1 + siblings.reduce((n, r) => Math.max(n, Number((r as Record<string, unknown>)[decl.number!.field]) || 0), 0);
|
|
657
1333
|
}
|
|
658
|
-
|
|
1334
|
+
const made = { ...assigned, ...initial, ...under, ...data };
|
|
1335
|
+
const clash = conflictOf(m, resource, made, undefined, root);
|
|
1336
|
+
if (clash) return { served: vendorError(m, clash) };
|
|
1337
|
+
return answer(await write(m, call, resource, id, made, `${storedType(m, resource)}.create`, params, root, at));
|
|
659
1338
|
}
|
|
660
1339
|
case 'retrieve': {
|
|
661
|
-
const hit = idParam ? stored(m, resource, root).find((r) => r
|
|
1340
|
+
const hit = idParam ? stored(m, resource, root).find((r) => namedBy(decl, r, idParam) && underParent(r)) : undefined;
|
|
1341
|
+
// a setting never written answers its defaults; one written answers them under what was written
|
|
1342
|
+
if (decl.setting) return answer({ ...decl.setting.defaults, ...(hit ? settingView(m, resource, hit) : {}) });
|
|
662
1343
|
return hit ? answer(view(m, resource, hit)) : { served: notFound(m, resource, String(idParam), Object.keys(call.params).at(-1)) };
|
|
663
1344
|
}
|
|
664
1345
|
case 'list': {
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
// ties by mint order (`file-twin-10` after `file-twin-9`), not by the ids' lexical order
|
|
670
|
-
const d = Number(a[order.field] ?? 0) - Number(b[order.field] ?? 0) || String(a.id).localeCompare(String(b.id), undefined, { numeric: true });
|
|
671
|
-
return order.direction === 'desc' ? -d : d;
|
|
672
|
-
});
|
|
673
|
-
const size = m.list.limits?.[op.id] ?? m.list.limit;
|
|
674
|
-
const asked = params[m.list.limit.param];
|
|
675
|
-
const n = asked === undefined || asked === '' ? size.default : Number(asked);
|
|
676
|
-
const limit = Number.isFinite(n) ? Math.min(size.max, Math.max(1, Math.trunc(n))) : size.default;
|
|
677
|
-
const after = m.list.after ? params[m.list.after] : undefined;
|
|
678
|
-
const before = m.list.before ? params[m.list.before] : undefined;
|
|
679
|
-
let from = 0;
|
|
680
|
-
let to: number;
|
|
681
|
-
if (typeof before === 'string') {
|
|
682
|
-
const at = rows.findIndex((r) => r.id === before);
|
|
683
|
-
to = at === -1 ? (m.list.unknownCursor === 'end' ? 0 : rows.length) : at;
|
|
684
|
-
from = Math.max(0, to - limit);
|
|
685
|
-
} else {
|
|
686
|
-
if (typeof after === 'string') {
|
|
687
|
-
const at = rows.findIndex((r) => r.id === after);
|
|
688
|
-
from = at === -1 ? (m.list.unknownCursor === 'end' ? rows.length : 0) : at + 1;
|
|
689
|
-
}
|
|
690
|
-
to = from + limit;
|
|
691
|
-
}
|
|
692
|
-
const page = rows.slice(from, to).map((r) => (paths.length ? expand(m, resource, r, paths.filter((p) => p.startsWith('data.')).map((p) => p.slice(5)), root) : r));
|
|
693
|
-
const hasMore = typeof before === 'string' ? from > 0 : to < rows.length;
|
|
694
|
-
const url = op.path.replace(/\{[^}]+\}/g, (p) => call.params[p.slice(1, -1)] ?? p);
|
|
695
|
-
if (m.list.page) {
|
|
696
|
-
const n = Math.max(1, Math.floor(Number(params[m.list.page.param]) || 1));
|
|
697
|
-
const slice = rows.slice((n - 1) * limit, n * limit);
|
|
698
|
-
const last = Math.max(1, Math.ceil(rows.length / limit));
|
|
699
|
-
const headers = new Headers();
|
|
700
|
-
if (m.list.page.link && rows.length > limit) {
|
|
701
|
-
const at = (k: number): string => {
|
|
702
|
-
const u = new URL(call.request.url);
|
|
703
|
-
u.searchParams.set(m.list.page!.param, String(k));
|
|
704
|
-
return u.toString();
|
|
705
|
-
};
|
|
706
|
-
const rels = [...(n > 1 ? [`<${at(n - 1)}>; rel="prev"`] : []), ...(n < last ? [`<${at(n + 1)}>; rel="next"`, `<${at(last)}>; rel="last"`] : []), ...(n > 1 ? [`<${at(1)}>; rel="first"`] : [])];
|
|
707
|
-
if (rels.length) headers.set('link', rels.join(', '));
|
|
708
|
-
}
|
|
709
|
-
return { served: Response.json(fill(m.list.envelope, { data: slice, next_cursor: null, key: op.answers?.key ?? 'data' }), { headers }) };
|
|
710
|
-
}
|
|
711
|
-
if (m.list.offset) {
|
|
712
|
-
const skip = Math.max(0, Math.trunc(Number(params[m.list.offset.param]) || 0));
|
|
713
|
-
const slice = rows.slice(skip, skip + limit);
|
|
714
|
-
const key = op.answers?.key ?? 'data';
|
|
715
|
-
return { served: Response.json({ ...(m.success ?? {}), [key]: slice, ...(fill(m.list.envelopes?.[op.id] ?? m.list.envelope, { data: slice, total_count: rows.length, key }) as object) }) };
|
|
716
|
-
}
|
|
717
|
-
if (m.list.cursor) {
|
|
718
|
-
const start = decodeOffset(params[m.list.cursor.param]);
|
|
719
|
-
const slice = rows.slice(start, start + limit);
|
|
720
|
-
const next = start + limit < rows.length ? encodeOffset(start + limit) : '';
|
|
721
|
-
return { served: Response.json({ ...(m.success ?? {}), ...(fill(m.list.envelope, { data: slice, next_cursor: next, key: op.answers?.key ?? 'data' }) as object), ...(op.answers?.key ? { [op.answers.key]: slice } : {}) }) };
|
|
722
|
-
}
|
|
723
|
-
return { served: Response.json(fill(m.list.envelope, { data: page, has_more: hasMore, url, first_id: page[0]?.id ?? null, last_id: page.at(-1)?.id ?? null })) };
|
|
1346
|
+
const rows = stored(m, resource, root).filter(underParent).map((r) => view(m, resource, r));
|
|
1347
|
+
const arranged = arrangeRows(m, resource, params, rows);
|
|
1348
|
+
if (!Array.isArray(arranged)) return { served: vendorError(m, arranged) };
|
|
1349
|
+
return { served: pageRows(m, op, call, params, arranged, resource, root, paths) };
|
|
724
1350
|
}
|
|
725
1351
|
case 'update':
|
|
726
1352
|
case 'action': {
|
|
727
|
-
const hit = idParam ? stored(m, resource, root).find((r) => r
|
|
1353
|
+
const hit = idParam ? stored(m, resource, root).find((r) => namedBy(decl, r, idParam) && underParent(r)) : undefined;
|
|
1354
|
+
// a setting's update merges what is given over what it holds (its defaults until first written), making it
|
|
1355
|
+
if (decl.setting && op.class === 'update' && idParam) {
|
|
1356
|
+
const current = { ...decl.setting.defaults, ...(hit ? settingView(m, resource, hit) : {}) };
|
|
1357
|
+
const merged: Record<string, unknown> = {};
|
|
1358
|
+
for (const [k, v] of Object.entries(data)) merged[k] = mergeField(decl.update, current[k], v);
|
|
1359
|
+
const under = { ...(parent ? { [parent.field]: parentId } : {}), ...(tenantField ? { [tenantField]: tenant } : {}) };
|
|
1360
|
+
await write(m, call, resource, hit?.id ?? String(idParam), { ...under, ...merged }, `${storedType(m, resource)}.update`, params, root, at);
|
|
1361
|
+
// a setting whose update the spec documents with no content answers none (a specification that omits a body
|
|
1362
|
+
// elsewhere is not trusted this far: only a setting's update reads it)
|
|
1363
|
+
if (op.emptySuccess) return { served: new Response(null, { status }) };
|
|
1364
|
+
return answer({ ...current, ...merged });
|
|
1365
|
+
}
|
|
728
1366
|
if (!hit) return { served: notFound(m, resource, String(idParam), Object.keys(call.params).at(-1)) };
|
|
729
1367
|
const current = view(m, resource, hit);
|
|
730
1368
|
const changes: Record<string, unknown> = { ...(op.class === 'update' ? data : {}) };
|
|
@@ -745,16 +1383,15 @@ export async function serveCore(m: DerivedManifest, call: DerivedCall, scope: Co
|
|
|
745
1383
|
}
|
|
746
1384
|
if (op.class === 'action' && !moved) return { unmodeled: `action ${op.id} declares no transition` };
|
|
747
1385
|
const merged: Record<string, unknown> = {};
|
|
748
|
-
for (const [k, v] of Object.entries(changes))
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
}
|
|
1386
|
+
for (const [k, v] of Object.entries(changes)) merged[k] = mergeField(decl.update, current[k], v);
|
|
1387
|
+
const clash = conflictOf(m, resource, { ...current, ...merged }, hit.id, root);
|
|
1388
|
+
if (clash) return { served: vendorError(m, clash) };
|
|
752
1389
|
return answer(await write(m, call, resource, hit.id, merged, `${storedType(m, resource)}.${op.class === 'update' ? 'update' : op.id}`, params, root, at));
|
|
753
1390
|
}
|
|
754
1391
|
case 'delete': {
|
|
755
|
-
const hit = idParam ? stored(m, resource, root).find((r) => r
|
|
1392
|
+
const hit = idParam ? stored(m, resource, root).find((r) => namedBy(decl, r, idParam) && underParent(r)) : undefined;
|
|
756
1393
|
if (!hit) return { served: notFound(m, resource, String(idParam), Object.keys(call.params).at(-1)) };
|
|
757
|
-
await
|
|
1394
|
+
await removeSubject(m, call, resource, hit.id, params, root, at);
|
|
758
1395
|
const shape = decl.deleted !== undefined ? decl.deleted : m.deleted;
|
|
759
1396
|
return { served: status === 204 || shape === null ? new Response(null, { status: 204 }) : Response.json(fill(shape, { id: hit.id, object: current(hit) }), { status }) };
|
|
760
1397
|
}
|
|
@@ -798,17 +1435,67 @@ export type SemanticsContext = {
|
|
|
798
1435
|
text: string;
|
|
799
1436
|
root: string | undefined;
|
|
800
1437
|
occurredAt: string;
|
|
1438
|
+
/** The twin's own base URL as this request reached it (its links and redirects to its own pages carry it). */
|
|
1439
|
+
publicBase: string;
|
|
1440
|
+
/** What the World's scenario decided for this turn (a model vendor's answer: the handler matched, or the miss), on an
|
|
1441
|
+
* operation the pack's `semantics/scenario.ts` names; undefined elsewhere, or when the World carries no scenario. */
|
|
1442
|
+
scenario: ScenarioDecision | undefined;
|
|
1443
|
+
/** A message the vendor sends the application's own server that no declared event is (a push notification), through
|
|
1444
|
+
* the World's route and egress rule: the receiver's status, or 0 when refused or unreachable. */
|
|
1445
|
+
deliver(url: string, init: RequestInit): Promise<number>;
|
|
1446
|
+
/** A message the vendor sends the application's server and decides by its answer, waited on for at most `within`
|
|
1447
|
+
* milliseconds (a real-time authorization request): its status and body, or why there was none. */
|
|
1448
|
+
ask(url: string, init: RequestInit, within: number): Promise<ApplicationAnswer>;
|
|
1449
|
+
/** A git repository of the World, by the pack's name for it (the git plane, world-core git/): its objects in the
|
|
1450
|
+
* World's content-addressed object store (one per service), its refs in the World's store. Served over smart HTTP
|
|
1451
|
+
* (`git.serveSmartHttp`), read and written through the library's codecs. */
|
|
1452
|
+
git(name: string): { store: GitObjectStore; refs: GitRefs };
|
|
1453
|
+
/** A value the World set for its application (world-env.ts): what the app was given and the vendor must agree with (a
|
|
1454
|
+
* webhook signing secret the app verifies with), never the caller's own shell; undefined when the World sets none. */
|
|
1455
|
+
worldEnv(name: string): string | undefined;
|
|
1456
|
+
/** Runs `fn` as the vendor's own move, not the caller's: what time makes due (a renewal a read catches up) is
|
|
1457
|
+
* recorded with no caller, wherever a handler reaches it. */
|
|
1458
|
+
asVendor<T>(fn: () => Promise<T>): Promise<T>;
|
|
1459
|
+
/** A mail the vendor sends a person, over SMTP to the route its settings name (smtp.ts), under the World's egress rule;
|
|
1460
|
+
* throws when it cannot be delivered. */
|
|
1461
|
+
mail(route: SmtpRoute, mail: Mail, headers?: Record<string, string>): Promise<void>;
|
|
801
1462
|
/** who is calling, when the manifest says how to tell */
|
|
802
1463
|
actor: string | undefined;
|
|
803
1464
|
now(): unknown;
|
|
804
1465
|
get(resource: string, id: string): Record<string, unknown> | undefined;
|
|
1466
|
+
/** One credential subject in a manifest-declared owner's World store. Ambiguous stores refuse the read. */
|
|
1467
|
+
ownerRow(owner: string, resource: string, id: string): Record<string, unknown> | undefined;
|
|
805
1468
|
rows(resource: string): Array<Record<string, unknown>>;
|
|
806
1469
|
mint(resource: string): string;
|
|
1470
|
+
/** An id the vendor issues for what it does not keep (a completion's, a request's): minted, and its subject recorded,
|
|
1471
|
+
* so the next is another; a read-only request mints without recording. The resource is a declared bookkeeping type. */
|
|
1472
|
+
issue(resource: string): Promise<string>;
|
|
1473
|
+
/** The vendor's open sessions of one of its sockets in this World (a request answered by a session: sockets.ts). */
|
|
1474
|
+
sockets(id: string): SocketSession[];
|
|
1475
|
+
/** A secret for `label` (an app's client secret, a store's read token): an HMAC under a seed the World makes once, at
|
|
1476
|
+
* random, and keeps as bookkeeping no door answers. The same label gives the same secret for the World's whole life,
|
|
1477
|
+
* and no one can compute it from ids the World shows, as they can a value derived from them alone. */
|
|
1478
|
+
secret(label: string): Promise<string>;
|
|
1479
|
+
/** The World's machine pool (machines.ts), as the pack's `machine-pool` door enrolled it; `none` when none was. */
|
|
1480
|
+
machines: MachinePool;
|
|
1481
|
+
/** A signing key pair for `label` (an issuer's ID tokens, a provider's JWTs, a CA's or a log's key), PEM: RSA 2048
|
|
1482
|
+
* (the default) or Ed25519, made once, at random, when the label is first asked for, and kept as bookkeeping no door
|
|
1483
|
+
* answers, so what it signs verifies against the World's published key (ctx.crypto.jwks of the public half) and no
|
|
1484
|
+
* one can forge it from anything the pack or the World shows, as they can with a key written in the pack. */
|
|
1485
|
+
signingKey(label: string, alg?: 'RS256' | 'Ed25519'): Promise<{ privatePem: string; publicPem: string }>;
|
|
1486
|
+
/** The signing key for `label` when the World has made it (`signingKey`), else undefined: for code that signs where it
|
|
1487
|
+
* cannot wait (a cookie's token, read everywhere), after a front made the key before anything is answered. */
|
|
1488
|
+
heldSigningKey(label: string, alg?: 'RS256' | 'Ed25519'): { privatePem: string; publicPem: string } | undefined;
|
|
807
1489
|
write(resource: string, id: string, fields: Record<string, unknown>, operation: string): Promise<Record<string, unknown>>;
|
|
1490
|
+
/** Add numeric deltas to an existing bookkeeping row under the kernel's atomic write seam. */
|
|
1491
|
+
accumulate(resource: string, id: string, deltas: Record<string, number>, operation: string): Promise<void>;
|
|
808
1492
|
/** `write`, with what a live vendor answered when the head performed it */
|
|
809
1493
|
writeDetailed(resource: string, id: string, fields: Record<string, unknown>, operation: string): Promise<{ body: Record<string, unknown>; id: string; vendorData?: unknown }>;
|
|
810
1494
|
/** The id a subject has now, for an id a caller may still use from before the vendor minted its own
|
|
811
1495
|
* (a branch naming a message by the ts it minted locally). */
|
|
1496
|
+
/** The tree's type a resource's rows are kept under (the manifest's `storedAs`, else its name): what an operation on
|
|
1497
|
+
* it is named by (`issuing_card.create`), as the vendor names the events it sends. */
|
|
1498
|
+
storedType(resource: string): string;
|
|
812
1499
|
resolve(resource: string, id: string): string;
|
|
813
1500
|
/** Answer the vendor's success envelope around several fields (`{ ok: true, ts, channel, message }`). */
|
|
814
1501
|
ok(fields: Record<string, unknown>): Response;
|
|
@@ -830,6 +1517,8 @@ export type SemanticsContext = {
|
|
|
830
1517
|
/** Whether the machine lets `operationId` move `field` from `current` (to `to`): nothing when it
|
|
831
1518
|
* does, the vendor's refusal when it does not. A move the machine does not declare throws. */
|
|
832
1519
|
legal(resource: string, field: string, operationId: string, current: unknown, to?: string, id?: string, actor?: Actor): ErrorSpec | undefined;
|
|
1520
|
+
/** Validate the request body's top-level derived schema under body.validation, before a write. */
|
|
1521
|
+
validate(): Response | undefined;
|
|
833
1522
|
refuse(e: ErrorSpec): Response;
|
|
834
1523
|
notFound(resource: string, id: string, param?: string): Response;
|
|
835
1524
|
reply(body: unknown, status?: number): Response;
|
|
@@ -837,7 +1526,7 @@ export type SemanticsContext = {
|
|
|
837
1526
|
wrap(body: Record<string, unknown>, extra?: Record<string, unknown>): Response;
|
|
838
1527
|
/** Answer as a server-sent event stream, framed as the manifest says. */
|
|
839
1528
|
sse(events: Array<{ event?: string; data: unknown }>): Response;
|
|
840
|
-
expand(resource: string, body: Record<string, unknown
|
|
1529
|
+
expand(resource: string, body: Record<string, unknown>, paths?: string[]): Record<string, unknown>;
|
|
841
1530
|
/** This context at another moment of the World clock (ISO): a move time makes is written at the moment it fell due. */
|
|
842
1531
|
at(when: string): Promise<SemanticsContext>;
|
|
843
1532
|
/** This call's context over another manifest of the same service, at the same moment: a lane over its vendor's
|
|
@@ -847,6 +1536,47 @@ export type SemanticsContext = {
|
|
|
847
1536
|
core(): Promise<Response | undefined>;
|
|
848
1537
|
/** A stored row's own fields, as the vendor's object carries them (an `id`, `type` or `updatedAt` of its own restored). */
|
|
849
1538
|
own(row: Record<string, unknown>): Record<string, unknown>;
|
|
1539
|
+
/** A handler's own rows (views) answered as the call's list: the resource's filters, search and order, then the
|
|
1540
|
+
* manifest's paging and envelope; the vendor's refusal when the call orders by a field the resource does not take. */
|
|
1541
|
+
list(resource: string, rows: Array<Record<string, unknown>>): Response;
|
|
1542
|
+
/** The vendor's refusal when these fields would make a second live subject where the resource's `unique` rules allow
|
|
1543
|
+
* one (`except`: the subject being updated), or nothing. */
|
|
1544
|
+
conflict(resource: string, fields: Record<string, unknown>, except?: string): Response | undefined;
|
|
1545
|
+
/** A subject deleted as the vendor deletes it: every subject its resource's `cascade` names deleted first, then it,
|
|
1546
|
+
* each through the write path (`operation` names the subject's own delete when it is not `<stored type>.delete`). */
|
|
1547
|
+
remove(resource: string, id: string, operation?: string): Promise<void>;
|
|
1548
|
+
/** The subject a key names, by its id (an alias the caller may still use resolved) or one of the resource's
|
|
1549
|
+
* `alternateKeys` (an organization by its slug), as the vendor serves it. */
|
|
1550
|
+
find(resource: string, key: string): Record<string, unknown> | undefined;
|
|
1551
|
+
/** A field's new value as the resource's `update` rule merges it into the stored one (`deep`: Clerk's metadata). */
|
|
1552
|
+
merge(resource: string, prior: unknown, next: unknown): unknown;
|
|
1553
|
+
/** What a handler signs and hashes with (signing.ts): JWTs with a pack's key, their verification, a JWKS, an HMAC and
|
|
1554
|
+
* a SHA-256, deterministic, so no pack reaches `node:crypto` itself. */
|
|
1555
|
+
crypto: HandlerCrypto;
|
|
1556
|
+
/** The World's managed Postgres (managed-database.ts): one transaction per batch, as a role with its settings, or a
|
|
1557
|
+
* script. A World that binds none refuses every use with the reason (EngineUnavailableError). */
|
|
1558
|
+
engine: ManagedDatabase;
|
|
1559
|
+
/** A vendor's Redis (the kernel's Redis library, redis/engine.ts): a request's commands run in order over this call's
|
|
1560
|
+
* tree and clock, in the pack's own service, under its wire's dialect, in one of its databases (`database`: its keys
|
|
1561
|
+
* and scripts apart from the others'); each answered `{result}` or `{error}`, and whether any wrote. A read-only
|
|
1562
|
+
* World raises ReadOnlyError at the first write. The wire around it (Upstash's REST envelope) is the pack's. */
|
|
1563
|
+
redis(commands: string[][], options: { dialect: RedisDialect; database?: string }): Promise<{ items: RunItem[]; wrote: boolean }>;
|
|
1564
|
+
/** Another vendor's own URL answered by the World's twin of it (vendor-call.ts): a vendor's call to another as part of
|
|
1565
|
+
* its behaviour (Clerk exchanging a Google sign-in's code). A vendor the World runs no twin of is refused
|
|
1566
|
+
* (VendorUnreachableError); nothing leaves the World. */
|
|
1567
|
+
vendorFetch(url: string | URL, init?: RequestInit): Promise<Response>;
|
|
1568
|
+
/** A multipart/form-data body's parts in order (multipart.ts: field name, filename, media type, bytes), a part with an
|
|
1569
|
+
* empty field name included; none for any other body. */
|
|
1570
|
+
parts(): Promise<MultipartPart[]>;
|
|
1571
|
+
/** The pack's resource blobs (an object's bytes), by key, on the World's branch: they branch with the World, and a
|
|
1572
|
+
* read falls back to an ancestor's. The row that names a blob is the pack's; the bytes are here. */
|
|
1573
|
+
blobs: { put(key: string, bytes: Uint8Array): Promise<void>; get(key: string): Promise<Uint8Array | null>; remove(key: string): Promise<void> };
|
|
1574
|
+
/** A body's fields checked against the operation's declared ones under the manifest's `body.strict`: the fields, or
|
|
1575
|
+
* the vendor's refusal of the first that is unknown, of another type or out of its bounds. */
|
|
1576
|
+
fields(body: Record<string, unknown>): { fields: Record<string, unknown> } | { refused: Response };
|
|
1577
|
+
/** A parameter as the vendor reads a boolean (the manifest's `booleans`): true, false, or undefined for a value that
|
|
1578
|
+
* is neither (absent, or text the vendor does not read as one). A boolean passes as itself. */
|
|
1579
|
+
flag(value: unknown): boolean | undefined;
|
|
850
1580
|
};
|
|
851
1581
|
export type Semantics = (ctx: SemanticsContext) => Promise<Response>;
|
|
852
1582
|
|
|
@@ -863,18 +1593,29 @@ export type HandlerContext = Omit<SemanticsContext, 'now' | 'core' | 'atomically
|
|
|
863
1593
|
};
|
|
864
1594
|
/** What a write hook reads: the writing call's context without its ways to write, so a hook that renders an event
|
|
865
1595
|
* cannot write again (and run itself again). */
|
|
866
|
-
export type WriteHookContext = Omit<HandlerContext, 'write' | 'record' | 'at' | 'over'>;
|
|
1596
|
+
export type WriteHookContext = Omit<HandlerContext, 'write' | 'record' | 'accumulate' | 'at' | 'over'>;
|
|
867
1597
|
/** A derived pack's handler: one operation, by its operationId, over the contract's context. */
|
|
868
1598
|
export type Handler = (ctx: HandlerContext) => Promise<Response>;
|
|
869
1599
|
|
|
870
|
-
|
|
1600
|
+
/** The context the kernel opens for a call (a handler's, a door's, a screen's). Kernel-internal: packs are given it. */
|
|
1601
|
+
export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: CoreScope): Promise<SemanticsContext> {
|
|
871
1602
|
const root = scope.root;
|
|
872
1603
|
const asAsked = call;
|
|
873
1604
|
call = adoptedPath(m, call, root);
|
|
874
1605
|
const at = (scope.clock ?? worldNow)();
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
1606
|
+
// the guard's parse when it made one (taken once), else this context's own
|
|
1607
|
+
const parsed = takeParsedBody(call.request);
|
|
1608
|
+
const text = parsed ? '' : await bodyTextOf(call.request.clone()).catch(() => '');
|
|
1609
|
+
let body: unknown = parsed?.value;
|
|
1610
|
+
if (!parsed) { try { body = text ? JSON.parse(text) : undefined; } catch { body = text; } }
|
|
1611
|
+
// the request as it came, before the core's parameter merge and alias adoption: what a write records, so a perform
|
|
1612
|
+
// can send it again (the real-system adapters, "What the derived core records with a write")
|
|
1613
|
+
const boundary = multipartBoundary(call.request.headers.get('content-type'));
|
|
1614
|
+
const parts = boundary === null ? undefined : multipartParts(new Uint8Array(await call.request.clone().arrayBuffer()), boundary);
|
|
1615
|
+
// a body that is not JSON is recorded as it came, its type with it: text as text, bytes (a gzip the vendor's client
|
|
1616
|
+
// compressed itself, PostHog's `compression=gzip-js`) as base64
|
|
1617
|
+
const raw = !parts && typeof body === 'string' && body !== '' ? await rawBody(call.request) : undefined;
|
|
1618
|
+
sentOf.set(call, { ...sentRequest(m, asAsked, parts ? Object.fromEntries(parts.filter((p) => p.filename === null).map((p) => [p.name, new TextDecoder().decode(p.body)])) : raw ?? body), ...(parts ? { parts } : {}), ...(raw ? { type: call.request.headers.get('content-type') ?? undefined } : {}) });
|
|
878
1619
|
// a handler reading the body itself sees the adopted id too (the boundary's one resolution, below)
|
|
879
1620
|
if (body && typeof body === 'object' && !Array.isArray(body)) body = adoptedInCall(m.service, body as Record<string, unknown>, root);
|
|
880
1621
|
// every read of the body is of a copy, so the handler's own `call.request` and the core's answer (`core()`) each
|
|
@@ -890,17 +1631,65 @@ async function contextFor(m: DerivedManifest, call: DerivedCall, scope: CoreScop
|
|
|
890
1631
|
text,
|
|
891
1632
|
root,
|
|
892
1633
|
occurredAt: at,
|
|
1634
|
+
publicBase: twinPublicBase(call.request),
|
|
1635
|
+
scenario: scenarioDecisionOf(call.request),
|
|
1636
|
+
deliver: (url, init) => deliverToApplication(url, init),
|
|
1637
|
+
ask: (url, init, within) => askApplication(url, init, within),
|
|
1638
|
+
worldEnv: (name) => worldEnvValue(name),
|
|
1639
|
+
asVendor: (fn) => runAsVendorMove(fn),
|
|
1640
|
+
git: (name) => {
|
|
1641
|
+
if (!/^[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.-]+)*$/.test(name) || name.split('/').some((seg) => seg === '.' || seg === '..')) throw new Error(`semantics: ${name} is not a git repository name`);
|
|
1642
|
+
const plane = `${worldPaths(m.service, root).dir}/git`;
|
|
1643
|
+
return { store: new GitObjectStore(getActiveBlobStore(), `${plane}/objects`), refs: new GitRefs(getActiveWorldStore(), `${plane}/repos/${name}`) };
|
|
1644
|
+
},
|
|
1645
|
+
mail: (route, mail, headers) => sendMail(route, mail, headers),
|
|
893
1646
|
actor: m.identity ? m.identity(call.request.headers.get('authorization'), root) : undefined,
|
|
894
1647
|
now: () => now(m, at),
|
|
895
1648
|
get: (resource, id) => {
|
|
896
1649
|
const hit = stored(m, resource, root).find((r) => r.id === id);
|
|
897
1650
|
return hit ? view(m, resource, hit) : undefined;
|
|
898
1651
|
},
|
|
1652
|
+
ownerRow: (owner, resource, id) => {
|
|
1653
|
+
if (!m.ownerReads?.some((r) => r.owner === owner && r.resource === resource)) throw new Error(`semantics: undeclared owner read ${owner}/${resource}`);
|
|
1654
|
+
const row = projectOwnerResources(owner, root, { type: resource, id }).find((r) => r.type === resource && r.id === id);
|
|
1655
|
+
return row ? { ...ownFields(row), ...(row.deleted === true ? { deleted: true } : {}) } : undefined;
|
|
1656
|
+
},
|
|
899
1657
|
rows: (resource) => stored(m, resource, root).map((r) => view(m, resource, r)),
|
|
900
|
-
mint: (resource) => mintId(m, resource, root),
|
|
1658
|
+
mint: (resource) => mintId(m, resource, root, at),
|
|
1659
|
+
sockets: (id) => openSessions(m.service, root, id),
|
|
1660
|
+
issue: async (resource) => {
|
|
1661
|
+
const id = mintId(m, resource, root, at);
|
|
1662
|
+
if (!resource.startsWith('_')) throw new Error(`semantics: ${resource} is not a bookkeeping type (an issued id's type starts with _)`);
|
|
1663
|
+
if (scope.readOnly !== true && !isReadOnlyRequest(call.request)) {
|
|
1664
|
+
await applyTwinWrite(m.service, { operation: `${resource.slice(1)}.issue`, subjectType: storedType(m, resource), subjectId: id, fields: { issuedAt: at }, occurredAt: at, actor: { kind: 'system' } }, root);
|
|
1665
|
+
}
|
|
1666
|
+
return id;
|
|
1667
|
+
},
|
|
1668
|
+
secret: async (label) => hmac(await worldSeed(m.service, root, at), label, 'base64url'),
|
|
1669
|
+
get machines() {
|
|
1670
|
+
const enrolled = resourcesOfType(m.service, MACHINE_POOL, root).find((r) => r.id === 'pool') as unknown as { kind?: MachinePool['kind'] } | undefined;
|
|
1671
|
+
return poolFor(m.service, root, enrolled?.kind ?? 'none');
|
|
1672
|
+
},
|
|
1673
|
+
signingKey: (label, alg) => worldSigningKey(m.service, root, at, label, alg ?? 'RS256'),
|
|
1674
|
+
heldSigningKey: (label, alg) => heldWorldSigningKey(m.service, root, label, alg ?? 'RS256'),
|
|
901
1675
|
write: (resource, id, fields, operation) => write(m, call, resource, id, fields, operation, params, root, at),
|
|
1676
|
+
accumulate: async (resource, id, deltas, operation) => {
|
|
1677
|
+
if (!resource.startsWith('_') || !m.resources[resource]) throw new Error('accumulate requires a declared bookkeeping resource');
|
|
1678
|
+
await applyTwinWriteAtomic(m.service, (resources) => {
|
|
1679
|
+
const prior = resources.find((row) => row.type === storedType(m, resource) && row.id === id && row.deleted !== true);
|
|
1680
|
+
if (!prior) throw new Error('accumulate requires an existing bookkeeping row');
|
|
1681
|
+
const fields: Record<string, number> = {};
|
|
1682
|
+
for (const [field, delta] of Object.entries(deltas)) {
|
|
1683
|
+
const value = prior[field];
|
|
1684
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isFinite(delta) || !Number.isFinite(value + delta)) throw new Error('accumulate requires finite numeric fields and deltas');
|
|
1685
|
+
fields[field] = value + delta;
|
|
1686
|
+
}
|
|
1687
|
+
return { kind: 'write', value: undefined, write: { operation, subjectType: storedType(m, resource), subjectId: id, fields, occurredAt: at, actor: { kind: 'agent' } } };
|
|
1688
|
+
}, root);
|
|
1689
|
+
},
|
|
902
1690
|
writeDetailed: (resource, id, fields, operation) => writeDetailed(m, call, resource, id, fields, operation, params, root, at),
|
|
903
1691
|
resolve: (resource, id) => resolveSubjectId(m.service, storedType(m, resource), id, root),
|
|
1692
|
+
storedType: (resource) => storedType(m, resource),
|
|
904
1693
|
ok: (fields) => Response.json({ ...(m.success ?? {}), ...fields }),
|
|
905
1694
|
row: (resource, id, opts) => stored(m, resource, root, opts).find((r) => r.id === id) as Record<string, unknown> | undefined,
|
|
906
1695
|
rowsRaw: (resource, opts) => stored(m, resource, root, opts) as Array<Record<string, unknown>>,
|
|
@@ -908,7 +1697,14 @@ async function contextFor(m: DerivedManifest, call: DerivedCall, scope: CoreScop
|
|
|
908
1697
|
history: (resource, id) => subjectHistory(m.service, { type: storedType(m, resource), id }, root).map((e) => ({ ...(e.operation ? { operation: e.operation } : {}), ...(e.fields ? { fields: e.fields as Record<string, unknown> } : {}), ...(e.occurredAt ? { occurredAt: e.occurredAt } : {}) })),
|
|
909
1698
|
record: async (type, fields, id) => {
|
|
910
1699
|
if (!type.startsWith('_')) throw new Error(`semantics: ${type} is not a bookkeeping type (bookkeeping types start with _)`);
|
|
911
|
-
|
|
1700
|
+
// an id the caller does not give is the next past every one of this type the tree holds (as `{n}` ids are minted),
|
|
1701
|
+
// never a count of the rows, which a removed row would make repeat
|
|
1702
|
+
const subject = id ?? (() => {
|
|
1703
|
+
const prefix = `${type.slice(1)}_`;
|
|
1704
|
+
let max = 0;
|
|
1705
|
+
for (const r of resourcesOfType(m.service, type, root)) { const n = r.id.startsWith(prefix) ? Number(r.id.slice(prefix.length)) : NaN; if (Number.isInteger(n) && n > max) max = n; }
|
|
1706
|
+
return `${prefix}${max + 1}`;
|
|
1707
|
+
})();
|
|
912
1708
|
await applyTwinWrite(m.service, { operation: `${type.slice(1)}.record`, subjectType: type, subjectId: subject, fields, occurredAt: at, actor: { kind: 'system' } }, root);
|
|
913
1709
|
return subject;
|
|
914
1710
|
},
|
|
@@ -937,11 +1733,45 @@ async function contextFor(m: DerivedManifest, call: DerivedCall, scope: CoreScop
|
|
|
937
1733
|
},
|
|
938
1734
|
refuse: (e) => vendorError(m, e),
|
|
939
1735
|
notFound: (resource, id, param) => notFound(m, resource, id, param),
|
|
1736
|
+
validate: () => validateBody(m, call.operation, body),
|
|
940
1737
|
reply: (body, status = 200) => Response.json(body, { status }),
|
|
941
1738
|
wrap: (body, extra = {}) => Response.json({ ...envelope(m, call.operation, body), ...extra }),
|
|
942
1739
|
sse: (events) => sse(m, events, call.operation.id),
|
|
943
|
-
expand: (resource, body) => (paths.length ? expand(m, resource, body, paths, root) : body),
|
|
1740
|
+
expand: (resource, body, given) => ((given ?? paths).length ? expand(m, resource, body, given ?? paths, root) : body),
|
|
944
1741
|
at: (when) => contextFor(m, { ...call, request: new Request(call.request.url) }, { ...scope, clock: () => when }),
|
|
1742
|
+
crypto: handlerCrypto,
|
|
1743
|
+
flag: (value) => flagOf(m, value),
|
|
1744
|
+
fields: (body) => strictFields(m, call.operation, body),
|
|
1745
|
+
vendorFetch: (url, init) => vendorFetch(url, init),
|
|
1746
|
+
parts: async () => {
|
|
1747
|
+
const boundary = multipartBoundary(call.request.headers.get('content-type'));
|
|
1748
|
+
return boundary === null ? [] : multipartParts(new Uint8Array(await call.request.clone().arrayBuffer()), boundary);
|
|
1749
|
+
},
|
|
1750
|
+
engine: scope.database === undefined ? unboundDatabase(m.service) : managedDatabase(scope.database, { readOnly: scope.readOnly === true || isReadOnlyRequest(call.request), clock: at }),
|
|
1751
|
+
redis: (commands, options) => execRedisRun(commands, {
|
|
1752
|
+
root, occurredAt: at, service: m.service, readOnly: scope.readOnly === true || isReadOnlyRequest(call.request), dialect: options.dialect,
|
|
1753
|
+
...(options.database === undefined ? {} : { database: options.database }),
|
|
1754
|
+
}),
|
|
1755
|
+
blobs: {
|
|
1756
|
+
put: (key, bytes) => writeResourceBlob(m.service, key, bytes, root),
|
|
1757
|
+
get: (key) => readResourceBlob(m.service, key, root),
|
|
1758
|
+
remove: (key) => removeResourceBlob(m.service, key, root),
|
|
1759
|
+
},
|
|
1760
|
+
merge: (resource, prior, next) => mergeField(m.resources[resource]?.update, prior, next),
|
|
1761
|
+
conflict: (resource, fields, except) => {
|
|
1762
|
+
const clash = conflictOf(m, resource, fields, except, root);
|
|
1763
|
+
return clash ? vendorError(m, clash) : undefined;
|
|
1764
|
+
},
|
|
1765
|
+
remove: (resource, id, operation) => removeSubject(m, call, resource, id, params, root, at, operation),
|
|
1766
|
+
find: (resource, key) => {
|
|
1767
|
+
const id = resolveSubjectId(m.service, storedType(m, resource), key, root);
|
|
1768
|
+
const hit = stored(m, resource, root).find((r) => r.id === id || namedBy(m.resources[resource], r, key));
|
|
1769
|
+
return hit ? view(m, resource, hit) : undefined;
|
|
1770
|
+
},
|
|
1771
|
+
list: (resource, rows) => {
|
|
1772
|
+
const arranged = arrangeRows(m, resource, params, rows);
|
|
1773
|
+
return Array.isArray(arranged) ? pageRows(m, call.operation, call, params, arranged, resource, root, paths) : vendorError(m, arranged);
|
|
1774
|
+
},
|
|
945
1775
|
over: (other) => contextFor(other, { ...asAsked, request: asAsked.request.clone() }, { ...scope, clock: () => at }),
|
|
946
1776
|
core: async () => { const out = await serveCore(m, { ...call, request: pristine.clone() }, { ...scope, clock: () => at }); return 'served' in out ? out.served : undefined; },
|
|
947
1777
|
own: (row) => ownFields(row as unknown as TwinResource),
|
|
@@ -971,6 +1801,46 @@ const hex = (s: string): string => Array.from(new TextEncoder().encode(s), (b) =
|
|
|
971
1801
|
|
|
972
1802
|
/** Read-only refusal, API-version validation and idempotent replay, applied once around every
|
|
973
1803
|
* operation a handler or the core serves. */
|
|
1804
|
+
/** The vendor's refusal of a request's credential under the manifest's `auth`, or undefined when it passes: what the
|
|
1805
|
+
* cross-cutting gate answers for an operation, and what a pack whose vendor checks the credential before routing (an
|
|
1806
|
+
* unknown path answers 401, as Supabase's recording shows) calls in front of its dispatch. */
|
|
1807
|
+
/** The header the kernel names a scenario's decision in, set by createPackFetch alone (it strips it from every request it
|
|
1808
|
+
* is sent). */
|
|
1809
|
+
export const SCENARIO_DECISION_HEADER = 'x-volter-scenario-decision';
|
|
1810
|
+
function scenarioDecisionOf(request: Request): ScenarioDecision | undefined {
|
|
1811
|
+
const raw = request.headers.get(SCENARIO_DECISION_HEADER);
|
|
1812
|
+
if (!raw) return undefined;
|
|
1813
|
+
try { return JSON.parse(raw) as ScenarioDecision; } catch { return undefined; }
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
export function authRefusal(m: DerivedManifest, request: Request, root?: string): Response | undefined {
|
|
1817
|
+
if (!m.auth) return undefined;
|
|
1818
|
+
const url = new URL(request.url);
|
|
1819
|
+
if (m.auth.paths !== undefined && !new RegExp(m.auth.paths).test(url.pathname)) return undefined;
|
|
1820
|
+
const sources = [{ header: m.auth.header, scheme: m.auth.scheme }, ...(m.auth.also ?? [])];
|
|
1821
|
+
const given = sources.map((s) => (s.query !== undefined ? url.searchParams.get(s.query) : s.header ? request.headers.get(s.header) : null)).some((v) => v !== null);
|
|
1822
|
+
if (!given && !m.auth.gateWhenAbsent) return undefined;
|
|
1823
|
+
const keyOf = (s: (typeof sources)[number]): string => {
|
|
1824
|
+
if (s.query !== undefined) return (url.searchParams.get(s.query) ?? '').trim();
|
|
1825
|
+
const raw = s.header ? request.headers.get(s.header) : null;
|
|
1826
|
+
if (raw === null) return '';
|
|
1827
|
+
if (!s.scheme) return raw.trim();
|
|
1828
|
+
const scheme = `${s.scheme.toLowerCase()} `;
|
|
1829
|
+
return raw.toLowerCase().startsWith(scheme) ? raw.slice(scheme.length).trim() : '';
|
|
1830
|
+
};
|
|
1831
|
+
const key = sources.map(keyOf).find((k) => k !== '') ?? '';
|
|
1832
|
+
// no credential sent is `missing`; one sent in a form the vendor does not read is a key it does not know
|
|
1833
|
+
if (!key) return vendorError(m, given ? m.auth.invalid : m.auth.missing);
|
|
1834
|
+
if (m.auth.invalidKeys.includes(key) || (m.auth.keyFormat && !new RegExp(m.auth.keyFormat).test(key))) return vendorError(m, m.auth.invalid);
|
|
1835
|
+
const held = m.auth.held;
|
|
1836
|
+
if (held && !held.standing?.includes(key)) {
|
|
1837
|
+
const hash = sha256(key);
|
|
1838
|
+
const holds = resourcesOfType(m.service, held.storedAs, root).some((r) => r.deleted !== true && (r as Record<string, unknown>)[held.hashField] === hash && (held.unless === undefined || (r as Record<string, unknown>)[held.unless] !== true));
|
|
1839
|
+
if (!holds) return vendorError(m, m.auth.invalid);
|
|
1840
|
+
}
|
|
1841
|
+
return undefined;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
974
1844
|
export function crossCutting(m: DerivedManifest, opts: CoreScope & { readOnly?: boolean } = {}): (call: DerivedCall, next: () => Promise<Response>) => Promise<Response> {
|
|
975
1845
|
const finish = async (r: Response, request: Request): Promise<Response> => {
|
|
976
1846
|
if (!m.origin || !(r.headers.get('content-type') ?? '').includes('json')) return withHeaders(r);
|
|
@@ -986,24 +1856,21 @@ export function crossCutting(m: DerivedManifest, opts: CoreScope & { readOnly?:
|
|
|
986
1856
|
return async (call, next) => finish(await guarded(call, next), call.request);
|
|
987
1857
|
async function guarded(call: DerivedCall, next: () => Promise<Response>): Promise<Response> {
|
|
988
1858
|
const { request } = call;
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
if (raw !== null || m.auth.gateWhenAbsent) {
|
|
992
|
-
const scheme = `${m.auth.scheme.toLowerCase()} `;
|
|
993
|
-
const key = raw && raw.toLowerCase().startsWith(scheme) ? raw.slice(scheme.length).trim() : '';
|
|
994
|
-
if (!key) return vendorError(m, m.auth.missing);
|
|
995
|
-
if (m.auth.invalidKeys.includes(key) || (m.auth.keyFormat && !new RegExp(m.auth.keyFormat).test(key))) return vendorError(m, m.auth.invalid);
|
|
996
|
-
}
|
|
997
|
-
}
|
|
1859
|
+
const refused = m.auth?.exempt?.includes(call.operation.id) ? undefined : authRefusal(m, request, opts.root);
|
|
1860
|
+
if (refused) return refused;
|
|
998
1861
|
// a read-only twin refuses writes: what the operation does, not the HTTP verb it came by (an RPC
|
|
999
1862
|
// wire POSTs its reads). A read-only REQUEST (x-volter-read-only, request-scope.ts) is refused the
|
|
1000
1863
|
// same way, up front, on a writable twin.
|
|
1001
1864
|
const readOnly = opts.readOnly || isReadOnlyRequest(request);
|
|
1002
1865
|
if (readOnly && !['retrieve', 'list', 'computed'].includes(call.operation.class) && !m.reads?.includes(call.operation.id)) return vendorError(m, m.readOnly);
|
|
1003
1866
|
// a body labelled JSON that does not parse is the vendor's refusal, never a crash of the twin
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1867
|
+
// (`json: 'always'` reads the vendor's JSON bodies whatever their label, never an operation its spec declares
|
|
1868
|
+
// multipart or form: Cloudflare's asset and script uploads)
|
|
1869
|
+
const declared = call.operation.bodyEncoding;
|
|
1870
|
+
const labelled = request.headers.get('content-type') ?? '';
|
|
1871
|
+
if ((labelled.includes('json') && !labelled.includes('ndjson')) || (m.body.json === 'always' && declared !== 'multipart' && declared !== 'form')) {
|
|
1872
|
+
const ok = request.method === 'GET' || request.method === 'HEAD' ? true : await checkJsonBody(request.clone()).catch(() => true);
|
|
1873
|
+
if (!ok) return vendorError(m, m.malformedBody ?? { status: 400, message: 'The request body could not be parsed as JSON.' });
|
|
1007
1874
|
}
|
|
1008
1875
|
if (m.version) {
|
|
1009
1876
|
const value = request.headers.get(m.version.header);
|
|
@@ -1012,7 +1879,7 @@ export function crossCutting(m: DerivedManifest, opts: CoreScope & { readOnly?:
|
|
|
1012
1879
|
const key = m.idempotency ? request.headers.get(m.idempotency.header) : null;
|
|
1013
1880
|
if (!m.idempotency || !key || !(m.idempotency.methods ?? ['POST']).includes(request.method.toUpperCase())) return next();
|
|
1014
1881
|
const url = new URL(request.url);
|
|
1015
|
-
const signature = hashFieldValue({ path: url.pathname + url.search, body: await request.clone()
|
|
1882
|
+
const signature = hashFieldValue({ path: url.pathname + url.search, body: await bodyTextOf(request.clone()) });
|
|
1016
1883
|
const id = `idem_${hex(key)}`;
|
|
1017
1884
|
const prior = twinResources(m.service, opts.root).find((r) => r.type === m.idempotency!.storedAs && r.id === id) as Record<string, unknown> | undefined;
|
|
1018
1885
|
if (prior) {
|