@volter/world-core 3.0.162 → 3.0.164
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/browser-session.d.ts +7 -1
- package/dist/src/browser-session.js +21 -10
- package/dist/src/derived-core.d.ts +36 -3
- package/dist/src/derived-core.js +121 -13
- package/dist/src/derived-real.js +15 -3
- package/dist/src/events.d.ts +6 -3
- package/dist/src/events.js +52 -7
- package/dist/src/file-response.d.ts +6 -2
- package/dist/src/file-response.js +29 -5
- package/dist/src/index.d.ts +2 -1
- package/dist/src/index.js +2 -1
- package/dist/src/managed-database.d.ts +4 -2
- package/dist/src/managed-database.js +1 -0
- package/dist/src/media-header.d.ts +34 -0
- package/dist/src/media-header.js +129 -0
- package/dist/src/pack-fetch.d.ts +24 -3
- package/dist/src/pack-fetch.js +114 -94
- package/dist/src/packRegistry.d.ts +12 -0
- package/dist/src/request-body.d.ts +2 -3
- package/dist/src/request-body.js +2 -3
- package/dist/src/runtime.d.ts +2 -1
- package/dist/src/runtime.js +2 -1
- package/dist/src/serve-http.d.ts +1 -1
- package/dist/src/serve-http.js +93 -17
- package/package.json +1 -1
- package/src/browser-session.ts +17 -5
- package/src/derived-core.ts +135 -17
- package/src/derived-real.ts +10 -2
- package/src/events.ts +54 -9
- package/src/file-response.ts +28 -6
- package/src/index.ts +2 -1
- package/src/managed-database.ts +4 -3
- package/src/media-header.ts +108 -0
- package/src/pack-fetch.ts +107 -85
- package/src/packRegistry.ts +7 -1
- package/src/request-body.ts +2 -3
- package/src/runtime.ts +2 -1
- package/src/serve-http.ts +70 -16
package/dist/src/serve-http.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// The HTTP server seam of the kernel (docs/contributing/architecture.md#the-serve-seam): a fetch handler on
|
|
2
2
|
// a port, in the shape every `Bun.serve` call already has, so the serve path meets the runtime in
|
|
3
|
-
// ONE place.
|
|
4
|
-
// as a standard Request and the handler's Response written back. Async, because binding a port is
|
|
3
|
+
// ONE place. Ordinary HTTP uses `node:http`; requested WebSockets retain Bun's native backend.
|
|
4
|
+
// The request is rebuilt as a standard Request and the handler's Response written back. Async, because binding a port is
|
|
5
5
|
// asynchronous on Node — Bun's synchronous bind is the special case.
|
|
6
6
|
//
|
|
7
7
|
// Nothing here runs at module scope (the served-handler registry is made on first use), and `node:http` is reached through `process.getBuiltinModule`
|
|
8
8
|
// rather than a static import: this module rides into browser client bundles through the kernel's
|
|
9
9
|
// entrypoint, where a static `node:` import is the 2026-09-06 class of break.
|
|
10
10
|
import { worldBootAnswer } from "./world-boot.js";
|
|
11
|
+
import { DEFAULT_MAX_DECODED_REQUEST_BODY_SIZE, RequestBodyTooLargeError } from "./request-body.js";
|
|
11
12
|
/** One socket library at the host seam, shared by inbound upgrades and authenticated outbound relays. */
|
|
12
13
|
const socketLibrary = () => import('ws');
|
|
13
14
|
export async function connectWebSocket(url, headers, protocols = []) {
|
|
@@ -56,7 +57,7 @@ export async function serveHttp(options) {
|
|
|
56
57
|
const hostname = options.hostname ?? '127.0.0.1';
|
|
57
58
|
const urlHost = options.hostname ?? '127.0.0.1';
|
|
58
59
|
const bun = bunRuntime();
|
|
59
|
-
if (bun) {
|
|
60
|
+
if (bun && (options.upgrade !== undefined || 'websocket' in options)) {
|
|
60
61
|
// the global `Bun.serve` — the request journal's wrap of it (serve.ts) applies to packs and to
|
|
61
62
|
// this seam alike, so the seam never decorates twice on Bun
|
|
62
63
|
const upgrade = options.upgrade;
|
|
@@ -191,7 +192,8 @@ function builtin(name) {
|
|
|
191
192
|
}
|
|
192
193
|
async function serveOnNode(options) {
|
|
193
194
|
const http = builtin('node:http');
|
|
194
|
-
const { Readable } = builtin('node:stream');
|
|
195
|
+
const { Readable, Transform } = builtin('node:stream');
|
|
196
|
+
const requestLimit = options.maxRequestBodySize ?? DEFAULT_MAX_DECODED_REQUEST_BODY_SIZE;
|
|
195
197
|
if ('websocket' in options)
|
|
196
198
|
throw new Error('serveHttp: use the portable upgrade callbacks for WebSocket support on Node');
|
|
197
199
|
const protocol = options.tls ? 'https' : 'http';
|
|
@@ -230,20 +232,35 @@ async function serveOnNode(options) {
|
|
|
230
232
|
const activeRequests = new Set();
|
|
231
233
|
const server = factory((req, res) => {
|
|
232
234
|
const controller = new AbortController();
|
|
233
|
-
|
|
234
|
-
const
|
|
235
|
-
|
|
235
|
+
const socket = req.socket;
|
|
236
|
+
const hasInputBody = req.headers['transfer-encoding'] !== undefined || Number(req.headers['content-length']) > 0;
|
|
237
|
+
let incoming;
|
|
238
|
+
let flushingBodyRefusal = false;
|
|
239
|
+
const abort = () => {
|
|
240
|
+
if (!controller.signal.aborted)
|
|
241
|
+
controller.abort();
|
|
242
|
+
incoming?.destroy(controller.signal.reason);
|
|
243
|
+
if (!flushingBodyRefusal) {
|
|
244
|
+
req.destroy();
|
|
245
|
+
if (!res.writableFinished)
|
|
246
|
+
res.destroy();
|
|
247
|
+
socket.destroy();
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
active.add(abort);
|
|
236
251
|
const onRequestClose = () => { if (!req.complete)
|
|
237
252
|
abort(); };
|
|
238
|
-
const onResponseClose = () => { if (!res.writableFinished)
|
|
239
|
-
|
|
253
|
+
const onResponseClose = () => { if (!res.writableFinished) {
|
|
254
|
+
flushingBodyRefusal = false;
|
|
255
|
+
abort();
|
|
256
|
+
} };
|
|
240
257
|
const onSocketClose = () => { if (!res.writableFinished)
|
|
241
258
|
abort(); };
|
|
242
259
|
req.on('aborted', abort);
|
|
243
260
|
req.on('close', onRequestClose);
|
|
244
261
|
req.on('error', abort);
|
|
245
|
-
|
|
246
|
-
|
|
262
|
+
socket.on('close', onSocketClose);
|
|
263
|
+
socket.on('error', abort);
|
|
247
264
|
res.on('close', onResponseClose);
|
|
248
265
|
res.on('error', abort);
|
|
249
266
|
const disconnected = () => controller.signal.aborted || res.destroyed;
|
|
@@ -279,8 +296,38 @@ async function serveOnNode(options) {
|
|
|
279
296
|
closed();
|
|
280
297
|
});
|
|
281
298
|
let request;
|
|
299
|
+
let bodyTooLarge = false;
|
|
300
|
+
const rejectBody = () => {
|
|
301
|
+
if (bodyTooLarge)
|
|
302
|
+
return;
|
|
303
|
+
bodyTooLarge = true;
|
|
304
|
+
const error = new RequestBodyTooLargeError();
|
|
305
|
+
if (incoming)
|
|
306
|
+
req.unpipe(incoming);
|
|
307
|
+
req.pause();
|
|
308
|
+
if (!res.headersSent && !res.destroyed) {
|
|
309
|
+
for (const name of res.getHeaderNames())
|
|
310
|
+
res.removeHeader(name);
|
|
311
|
+
flushingBodyRefusal = true;
|
|
312
|
+
res.writeHead(413, { 'connection': 'close', 'content-type': 'text/plain', 'content-length': Buffer.byteLength(error.message) });
|
|
313
|
+
res.once('finish', () => { flushingBodyRefusal = false; req.destroy(); });
|
|
314
|
+
res.end(error.message);
|
|
315
|
+
}
|
|
316
|
+
else if (!res.destroyed)
|
|
317
|
+
res.destroy(error);
|
|
318
|
+
abort();
|
|
319
|
+
};
|
|
320
|
+
const inputSettled = new Promise((resolve) => {
|
|
321
|
+
const settled = () => { req.off('end', settled); req.off('close', settled); resolve(); };
|
|
322
|
+
req.once('end', settled);
|
|
323
|
+
req.once('close', settled);
|
|
324
|
+
});
|
|
282
325
|
const handle = async () => {
|
|
283
326
|
const method = req.method ?? 'GET';
|
|
327
|
+
if (Number(req.headers['content-length']) > requestLimit) {
|
|
328
|
+
rejectBody();
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
284
331
|
let response;
|
|
285
332
|
try {
|
|
286
333
|
const headers = new Headers();
|
|
@@ -295,9 +342,26 @@ async function serveOnNode(options) {
|
|
|
295
342
|
}
|
|
296
343
|
const url = `${protocol}://${req.headers.host ?? `${options.hostname}:${server.address()?.port ?? 0}`}${req.url ?? '/'}`;
|
|
297
344
|
const withBody = method !== 'GET' && method !== 'HEAD';
|
|
298
|
-
|
|
345
|
+
let received = 0;
|
|
346
|
+
incoming = new Transform({ transform(chunk, _encoding, callback) {
|
|
347
|
+
received += chunk.byteLength;
|
|
348
|
+
if (received > requestLimit) {
|
|
349
|
+
rejectBody();
|
|
350
|
+
callback(new RequestBodyTooLargeError());
|
|
351
|
+
}
|
|
352
|
+
else
|
|
353
|
+
callback(null, chunk);
|
|
354
|
+
} });
|
|
355
|
+
const inputClosed = () => { if (!req.readableEnded)
|
|
356
|
+
abort(); };
|
|
357
|
+
incoming.on('error', inputClosed);
|
|
358
|
+
incoming.once('close', inputClosed);
|
|
359
|
+
const init = { method, headers, signal: controller.signal, ...(withBody ? { body: Readable.toWeb(incoming), duplex: 'half' } : {}) };
|
|
299
360
|
request = new Request(url, init);
|
|
300
361
|
activeRequests.add(request);
|
|
362
|
+
req.pipe(incoming);
|
|
363
|
+
if (!withBody)
|
|
364
|
+
incoming.resume();
|
|
301
365
|
response = await options.fetch(request);
|
|
302
366
|
}
|
|
303
367
|
catch (error) {
|
|
@@ -365,6 +429,7 @@ async function serveOnNode(options) {
|
|
|
365
429
|
if (k !== 'set-cookie')
|
|
366
430
|
res.setHeader(k, v);
|
|
367
431
|
}
|
|
432
|
+
res.strictContentLength = response.headers.has('content-length');
|
|
368
433
|
const cookies = response.headers.getSetCookie?.() ?? [];
|
|
369
434
|
if (cookies.length)
|
|
370
435
|
res.setHeader('set-cookie', cookies);
|
|
@@ -424,12 +489,23 @@ async function serveOnNode(options) {
|
|
|
424
489
|
res.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
425
490
|
}).then(async () => {
|
|
426
491
|
await responseSettled;
|
|
427
|
-
|
|
492
|
+
// A runtime may retire its native body forwarding when the response ends.
|
|
493
|
+
// Close unfinished input after the response flushes; a consumed upload keeps its connection.
|
|
494
|
+
if (hasInputBody && !incoming?.readableEnded)
|
|
495
|
+
abort();
|
|
496
|
+
else
|
|
497
|
+
incoming?.resume();
|
|
498
|
+
await inputSettled;
|
|
499
|
+
if (incoming) {
|
|
500
|
+
req.unpipe(incoming);
|
|
501
|
+
incoming.destroy();
|
|
502
|
+
}
|
|
503
|
+
active.delete(abort);
|
|
428
504
|
req.off('aborted', abort);
|
|
429
505
|
req.off('close', onRequestClose);
|
|
430
506
|
req.off('error', abort);
|
|
431
|
-
|
|
432
|
-
|
|
507
|
+
socket.off('close', onSocketClose);
|
|
508
|
+
socket.off('error', abort);
|
|
433
509
|
res.off('close', onResponseClose);
|
|
434
510
|
res.off('error', abort);
|
|
435
511
|
});
|
|
@@ -462,8 +538,8 @@ async function serveOnNode(options) {
|
|
|
462
538
|
return {
|
|
463
539
|
hostname: options.hostname, port, url: new URL(`${protocol}://${options.urlHost}:${port}/`),
|
|
464
540
|
stop: (closeActive = true) => new Promise((resolve) => { if (closeActive) {
|
|
465
|
-
for (const
|
|
466
|
-
|
|
541
|
+
for (const abort of active)
|
|
542
|
+
abort();
|
|
467
543
|
for (const peer of socketServer?.clients ?? [])
|
|
468
544
|
peer.terminate();
|
|
469
545
|
} socketServer?.close(); server.close(() => { if (closeActive && active.size)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-core",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.164",
|
|
4
4
|
"description": "The kernel of Volter World: one log per twin, branches as pointers, checkpoints, the fold that keeps a twin current, the head that performs a write against the vendor, references, and the git plane. A twin package builds on it; the runtime serves it.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
package/src/browser-session.ts
CHANGED
|
@@ -1,19 +1,31 @@
|
|
|
1
1
|
// A vendor's browser session: the bookkeeping used by its own sign-in and sign-out pages.
|
|
2
2
|
import type { HandlerContext } from './derived-core.ts';
|
|
3
|
+
import { personOf, type Person } from './people.ts';
|
|
3
4
|
|
|
4
5
|
type Writer = Pick<HandlerContext, 'record' | 'secret' | 'occurredAt'>;
|
|
5
6
|
|
|
6
|
-
/** The
|
|
7
|
-
export function
|
|
7
|
+
/** The value of the cookie `name` the request carries, as a browser sends it back. */
|
|
8
|
+
export function cookieOf(request: Request, name: string): string | undefined {
|
|
8
9
|
for (const part of (request.headers.get('cookie') ?? '').split(';')) {
|
|
9
10
|
const at = part.indexOf('=');
|
|
10
|
-
if (at > 0 && part.slice(0, at).trim() === name)
|
|
11
|
-
try { return decodeURIComponent(part.slice(at + 1).trim()); } catch { return undefined; }
|
|
12
|
-
}
|
|
11
|
+
if (at > 0 && part.slice(0, at).trim() === name) return decodeURIComponent(part.slice(at + 1).trim());
|
|
13
12
|
}
|
|
14
13
|
return undefined;
|
|
15
14
|
}
|
|
16
15
|
|
|
16
|
+
/** The named session cookie; malformed encodings do not name a session. */
|
|
17
|
+
export function browserSessionToken(request: Request, name: string): string | undefined {
|
|
18
|
+
try { return cookieOf(request, name); } catch { return undefined; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The person the request's session cookie names, or undefined when nobody is signed in; with `scope`, only a session
|
|
22
|
+
* begun in it (a vendor's sandbox dashboard and its production one sign a person in apart). */
|
|
23
|
+
export function signedIn(ctx: HandlerContext, cookie: string, scope?: string): Person | undefined {
|
|
24
|
+
const token = cookieOf(ctx.call.request, cookie);
|
|
25
|
+
const session = token ? ctx.rowsRaw('_web_session').find((s) => s.token === token && (scope === undefined || s.scope === scope)) : undefined;
|
|
26
|
+
return session ? personOf(ctx, String(session.email)) : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
17
29
|
/** A new, independent sign-in, returning the cookie the vendor places on its response. */
|
|
18
30
|
export async function startBrowserSession(ctx: Writer, email: string, cookie: string, scope?: string): Promise<string> {
|
|
19
31
|
const issued = await ctx.record('_web_session_issue', { email, ...(scope !== undefined ? { scope } : {}) });
|
package/src/derived-core.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { parseExactJson, compareExactNumbers, exactInteger, isExactIntegerValue
|
|
|
11
11
|
import { applyTwinWrite, applyTwinWriteAtomic, applyTwinWrites, twinResources, type TwinResource } from './serve.ts';
|
|
12
12
|
import { copyResource, foldLocalWrite, heldSubject, ownFields, parentStamp, subjectHistory, treeChangesSince, treeResources, treeStamp, type Subject } from './log.ts';
|
|
13
13
|
import { performsAtHeadNow } from './head.ts';
|
|
14
|
-
import { getActiveWorldStore } from './world-store.ts';
|
|
14
|
+
import { getActiveWorldStore, type WorldStore } from './world-store.ts';
|
|
15
15
|
import { getActiveBlobStore } from './blob-store.ts';
|
|
16
16
|
import { worldPaths } from './storage.ts';
|
|
17
17
|
import { GitObjectStore } from './git/objects.ts';
|
|
@@ -43,7 +43,7 @@ import type { SubjectFields } from './hash.ts';
|
|
|
43
43
|
import { twinPublicBase, withRequestScopes } from './twin-fetch.ts';
|
|
44
44
|
import { inVendorMove, isReadOnlyRequest, runAsVendorMove, writesRefused } from './request-scope.ts';
|
|
45
45
|
import type { DerivedCall, DerivedCoreOutcome, DerivedHandler, DerivedOperation } from './derived.ts';
|
|
46
|
-
import type
|
|
46
|
+
import { getPack, type PackDescriptor } from './packRegistry.ts';
|
|
47
47
|
import type { ScenarioDecision, ScenarioHandler } from './scenario.ts';
|
|
48
48
|
import { bodyTextOf, checkJsonBody, takeParsedBody } from './derived.ts';
|
|
49
49
|
|
|
@@ -937,7 +937,7 @@ export type CoreScope = {
|
|
|
937
937
|
root?: string; clock?: () => string; database?: string; readOnly?: boolean;
|
|
938
938
|
/** `semantics/tenant.ts`: the tenant the caller acts in (the manifest's `tenant`), or undefined for none */
|
|
939
939
|
tenant?: (ctx: HandlerContext) => string | undefined | Promise<string | undefined>;
|
|
940
|
-
/** the application a walk stands in for:
|
|
940
|
+
/** the application a walk stands in for: vendor requests and declared events are answered by it (events.ts) */
|
|
941
941
|
application?: ApplicationStandIn;
|
|
942
942
|
};
|
|
943
943
|
|
|
@@ -996,6 +996,62 @@ export async function worldAccount(service: string, root: string | undefined, id
|
|
|
996
996
|
return id;
|
|
997
997
|
}
|
|
998
998
|
|
|
999
|
+
type WorldOrganization = { id: string; label: string; members: string[] };
|
|
1000
|
+
type VendorNameRule = { max: number; lower?: boolean; allowed: RegExp; separator?: string; first?: RegExp };
|
|
1001
|
+
type NativeAccountLink = { resource: string; id: string };
|
|
1002
|
+
|
|
1003
|
+
function nativeAccountLink(row: TwinResource | undefined, field?: string): NativeAccountLink | undefined {
|
|
1004
|
+
if (!row || row.deleted === true) return undefined;
|
|
1005
|
+
const fields = ownFields(row);
|
|
1006
|
+
const value = field === undefined ? fields : fields[field];
|
|
1007
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
|
1008
|
+
const link = value as Record<string, unknown>;
|
|
1009
|
+
return typeof link.resource === 'string' && typeof link.id === 'string' ? { resource: link.resource, id: link.id } : undefined;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const sameAccountLink = (a: NativeAccountLink | undefined, b: NativeAccountLink | undefined): boolean =>
|
|
1013
|
+
a?.resource === b?.resource && a?.id === b?.id;
|
|
1014
|
+
|
|
1015
|
+
/** Native creation and reconstruction run outside the action lock; only the retained link is compared and written. */
|
|
1016
|
+
async function retainNativeAccountLink<T>(
|
|
1017
|
+
service: string, root: string | undefined, at: string, call: DerivedCall,
|
|
1018
|
+
type: string, id: string, field: string | undefined, resource: string,
|
|
1019
|
+
make: () => Promise<string>, read: (link: NativeAccountLink) => Promise<T | undefined>,
|
|
1020
|
+
): Promise<T> {
|
|
1021
|
+
for (;;) {
|
|
1022
|
+
const before = nativeAccountLink(resourcesOfType(service, type, root).find((row) => row.id === id), field);
|
|
1023
|
+
const held = before && await read(before);
|
|
1024
|
+
if (held !== undefined) return held;
|
|
1025
|
+
const made = { resource, id: await make() };
|
|
1026
|
+
if (await read(made) === undefined) throw new Error(`Credential ${type}/${id}: native ${resource}/${made.id} is unavailable after creation`);
|
|
1027
|
+
const { value } = await applyTwinWriteAtomic(service, (snapshot) => {
|
|
1028
|
+
const current = nativeAccountLink(snapshot.find((row) => row.type === type && row.id === id), field);
|
|
1029
|
+
if (!sameAccountLink(current, before)) return { kind: 'skip', value: current };
|
|
1030
|
+
return {
|
|
1031
|
+
kind: 'write', value: made,
|
|
1032
|
+
write: {
|
|
1033
|
+
operation: `${type.slice(1)}.link`, subjectType: type, subjectId: id,
|
|
1034
|
+
fields: field === undefined ? made : { name: id, [field]: made },
|
|
1035
|
+
...provisioningInput(call), occurredAt: at, actor: { kind: 'system' },
|
|
1036
|
+
},
|
|
1037
|
+
};
|
|
1038
|
+
}, root);
|
|
1039
|
+
const linked = value && await read(value);
|
|
1040
|
+
if (linked !== undefined) return linked;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function vendorName(label: string, rule: VendorNameRule, { suffixed = false, identity = label }: { suffixed?: boolean; identity?: string } = {}): string {
|
|
1045
|
+
const allowed = new RegExp(rule.allowed.source, rule.allowed.flags.replace(/[gy]/g, ''));
|
|
1046
|
+
const separator = rule.separator ?? '-';
|
|
1047
|
+
if ([...separator].length !== 1 || !allowed.test(separator)) throw new Error('Credential name separator must be one allowed character');
|
|
1048
|
+
const first = rule.first && new RegExp(rule.first.source, rule.first.flags.replace(/[gy]/g, ''));
|
|
1049
|
+
let name = [...(rule.lower ? label.toLowerCase() : label)].map((letter) => allowed.test(letter) ? letter : separator).join('');
|
|
1050
|
+
name = name.replace(new RegExp(`${escapeRe(separator)}+`, 'g'), separator);
|
|
1051
|
+
if (first && !first.test(name[0] ?? '')) name = `w${separator}${name}`;
|
|
1052
|
+
return name !== label || name.length > rule.max || suffixed ? `${name.slice(0, Math.max(0, rule.max - 9))}${separator}${sha256(identity).slice(0, 8)}` : name;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
999
1055
|
/** A World's signing key for a label (ctx.signingKey): made once from the platform's random source, then read; the
|
|
1000
1056
|
* making is one per service store and label in the process, as the seed's is. */
|
|
1001
1057
|
const SIGNING_KEY = '_world_signing_key';
|
|
@@ -1082,9 +1138,9 @@ function mintId(m: DerivedManifest, resource: string, root: string | undefined,
|
|
|
1082
1138
|
* increment. One space over every snowflake resource of the vendor, as its one generator's: the instant's first, or one
|
|
1083
1139
|
* past the greatest the World holds when that is later (a frozen clock, or several ids in one millisecond), so ids
|
|
1084
1140
|
* rise with time and never repeat. */
|
|
1085
|
-
/** The last snowflake minted per vendor and World
|
|
1141
|
+
/** The last snowflake minted per store, vendor and World: two mints before the first is written (a message
|
|
1086
1142
|
* and its attachment) must not meet. */
|
|
1087
|
-
const lastSnowflake = new Map<string, bigint
|
|
1143
|
+
const lastSnowflake = new WeakMap<WorldStore, Map<string, bigint>>();
|
|
1088
1144
|
function mintSnowflake(m: DerivedManifest, epoch: bigint, root: string | undefined, at: string, snapshot?: readonly TwinResource[]): string {
|
|
1089
1145
|
const ms = BigInt(Date.parse(at) || 0);
|
|
1090
1146
|
const base = ms > epoch ? (ms - epoch) << 22n : 0n;
|
|
@@ -1093,11 +1149,13 @@ function mintSnowflake(m: DerivedManifest, epoch: bigint, root: string | undefin
|
|
|
1093
1149
|
if (!/^\{snowflake:\d+\}$/.test(decl.ids ?? m.ids.template)) continue;
|
|
1094
1150
|
for (const r of mintRows(m, name, root, snapshot)) if (/^\d+$/.test(r.id)) { const v = BigInt(r.id); if (v > max) max = v; }
|
|
1095
1151
|
}
|
|
1152
|
+
const store = getActiveWorldStore();
|
|
1153
|
+
const reservations = lastSnowflake.get(store) ?? lastSnowflake.set(store, new Map()).get(store)!;
|
|
1096
1154
|
const key = `${m.service}\0${root ?? ''}`;
|
|
1097
|
-
const last =
|
|
1155
|
+
const last = reservations.get(key) ?? -1n;
|
|
1098
1156
|
const floor = max > last ? max : last;
|
|
1099
1157
|
const id = base > floor ? base : floor + 1n;
|
|
1100
|
-
|
|
1158
|
+
reservations.set(key, id);
|
|
1101
1159
|
return String(id);
|
|
1102
1160
|
}
|
|
1103
1161
|
|
|
@@ -1396,7 +1454,7 @@ async function finishWrite(m: DerivedManifest, call: DerivedCall, resource: stri
|
|
|
1396
1454
|
const endpoint = (kind: import('./events.ts').EndpointsDecl): import('./events.ts').EndpointsDecl => ({ ...kind, storedAs: storedType(m, kind.storedAs), ...(kind.joins ? { joins: Object.fromEntries(Object.entries(kind.joins).map(([name, join]) => [name, { ...join, storedAs: storedType(m, join.storedAs) }])) } : {}) });
|
|
1397
1455
|
const events = { ...m.events, ...(m.events.store ? { store: storedType(m, m.events.store) } : {}), endpoints: 'storedAs' in m.events.endpoints ? endpoint(m.events.endpoints) : m.events.endpoints.map(endpoint) };
|
|
1398
1456
|
const write = { operation, storedType: m.resources[resource]?.storedAs ?? resource, body, ...(root !== undefined ? { root } : {}), occurredAt, request: request.clone() };
|
|
1399
|
-
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);
|
|
1457
|
+
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, scopeOfCall.get(call)?.application);
|
|
1400
1458
|
}
|
|
1401
1459
|
if (m.sockets?.length) socketWrite(m.service, root, { operation, storedType: m.resources[resource]?.storedAs ?? resource, body, ...(root !== undefined ? { root } : {}), occurredAt, request: request.clone() });
|
|
1402
1460
|
// a read held until the World writes (held-reads.ts) hears it
|
|
@@ -1959,6 +2017,18 @@ export type SemanticsContext = {
|
|
|
1959
2017
|
secret(label: string): Promise<string>;
|
|
1960
2018
|
/** Select an account at a pack's credential door. Packs retain their own vendor account/key semantics. */
|
|
1961
2019
|
account(field?: string): Promise<string>;
|
|
2020
|
+
/** The selected organization's label and people from the World's organization registry. */
|
|
2021
|
+
organization(): Promise<WorldOrganization>;
|
|
2022
|
+
/** The selected deployment, or an empty string for the organization's own level. */
|
|
2023
|
+
deployment(): string;
|
|
2024
|
+
/** A vendor-safe label; retained links keep identity independent of names. */
|
|
2025
|
+
vendorName(label: string, rule: VendorNameRule, options?: { suffixed?: boolean; identity?: string }): string;
|
|
2026
|
+
/** Retain the native customer tenant for the selected organization. */
|
|
2027
|
+
tenant(resource: string, make: (organization: WorldOrganization) => Promise<string>): Promise<Record<string, unknown>>;
|
|
2028
|
+
/** Retain one native resource of this kind for the selected organization and deployment. */
|
|
2029
|
+
deploymentObject(kind: string, resource: string, make: (deployment: string) => Promise<string>): Promise<Record<string, unknown>>;
|
|
2030
|
+
/** Reconstruct a live native key, replacing its link when the vendor's reader refuses it. */
|
|
2031
|
+
accountKey(purpose: string, resource: string, key: { make: () => Promise<string>; read: (row: Record<string, unknown>) => Promise<string | undefined> }): Promise<{ id: string; secret: string }>;
|
|
1962
2032
|
/** The World's machine pool (machines.ts), as the pack's `machine-pool` door enrolled it; `none` when none was. */
|
|
1963
2033
|
machines: MachinePool;
|
|
1964
2034
|
/** 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
|
|
@@ -2105,7 +2175,7 @@ export type HandlerContext = Omit<SemanticsContext, 'now' | 'core' | 'atomically
|
|
|
2105
2175
|
};
|
|
2106
2176
|
/** What a write hook reads: the writing call's context without its ways to write, so a hook that renders an event
|
|
2107
2177
|
* cannot write again (and run itself again). */
|
|
2108
|
-
export type WriteHookContext = Omit<HandlerContext, 'write' | 'batch' | 'create' | 'createUnique' | 'change' | 'issue' | 'record' | 'accumulate' | 'at' | 'over'>;
|
|
2178
|
+
export type WriteHookContext = Omit<HandlerContext, 'write' | 'batch' | 'create' | 'createUnique' | 'change' | 'issue' | 'record' | 'accumulate' | 'tenant' | 'deploymentObject' | 'accountKey' | 'at' | 'over'>;
|
|
2109
2179
|
/** A derived pack's handler: one operation, by its operationId, over the contract's context. */
|
|
2110
2180
|
export type Handler = (ctx: HandlerContext) => Promise<Response>;
|
|
2111
2181
|
|
|
@@ -2153,6 +2223,8 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
2153
2223
|
});
|
|
2154
2224
|
const paths = expandPaths(m, params);
|
|
2155
2225
|
const stateChecks: StateChecks = new Map();
|
|
2226
|
+
const credentialDoor = m.descriptor?.credentialDoor ?? getPack(m.vendor)?.credentialDoor;
|
|
2227
|
+
const credentialFields = body && typeof body === 'object' && !Array.isArray(body) ? body as Record<string, unknown> : {};
|
|
2156
2228
|
const rawResources = new WeakMap<Record<string, unknown>, string>();
|
|
2157
2229
|
const rawRow = (resource: string, row: TwinResource): Record<string, unknown> => {
|
|
2158
2230
|
const name = m.resources[resource] ? resource : Object.keys(m.resources).find((name) => storedType(m, name) === row.type);
|
|
@@ -2166,6 +2238,10 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
2166
2238
|
rawResources.set(row as Record<string, unknown>, name ?? resource);
|
|
2167
2239
|
return row as Record<string, unknown>;
|
|
2168
2240
|
};
|
|
2241
|
+
const liveNativeRow = (link: NativeAccountLink): Record<string, unknown> | undefined => {
|
|
2242
|
+
const row = context.row(link.resource, link.id);
|
|
2243
|
+
return row && row.deleted !== true ? row : undefined;
|
|
2244
|
+
};
|
|
2169
2245
|
const context: SemanticsContext = {
|
|
2170
2246
|
call,
|
|
2171
2247
|
params,
|
|
@@ -2218,9 +2294,39 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
2218
2294
|
},
|
|
2219
2295
|
occurredAt: at,
|
|
2220
2296
|
secret: async (label) => hmac(await worldSeed(m.service, root, at), scopeLabel(m, label), 'base64url'),
|
|
2221
|
-
account: async (field = 'account') => {
|
|
2222
|
-
|
|
2223
|
-
|
|
2297
|
+
account: async (field = credentialDoor?.account?.field ?? 'account') => {
|
|
2298
|
+
return worldAccount(m.service, root, credentialFields[field] ?? 'default', at, call.request.headers.get('x-volter-world-activity') === 'provisioning');
|
|
2299
|
+
},
|
|
2300
|
+
organization: async () => {
|
|
2301
|
+
const id = await context.account();
|
|
2302
|
+
const row = projectOwnerResources('_world', root, { type: '_world_organization', id }).find((row) => row.type === '_world_organization' && row.id === id && row.deleted !== true);
|
|
2303
|
+
return row ? { id, label: row.label as string, members: [...(row.members as string[])] } : { id, label: id, members: ['owner@world.test'] };
|
|
2304
|
+
},
|
|
2305
|
+
deployment: () => {
|
|
2306
|
+
const id = credentialFields[credentialDoor?.deployment?.field ?? 'deployment'] ?? '';
|
|
2307
|
+
if (id !== '' && !isWorldAccountName(id)) throw new Error('Credential deployment must be a name of at most 128 letters, digits, dots, underscores or hyphens');
|
|
2308
|
+
return id;
|
|
2309
|
+
},
|
|
2310
|
+
vendorName,
|
|
2311
|
+
tenant: async (resource, make) => {
|
|
2312
|
+
const id = await context.account();
|
|
2313
|
+
return retainNativeAccountLink(m.service, root, at, call, '_world_account', id, 'tenant', resource,
|
|
2314
|
+
async () => make(await context.organization()), async (link) => liveNativeRow(link));
|
|
2315
|
+
},
|
|
2316
|
+
deploymentObject: async (kind, resource, make) => {
|
|
2317
|
+
const organization = await context.account(), deployment = context.deployment();
|
|
2318
|
+
return retainNativeAccountLink(m.service, root, at, call, '_world_deployment_object', `${organization}/${deployment}/${kind}`, undefined, resource,
|
|
2319
|
+
() => make(deployment), async (link) => liveNativeRow(link));
|
|
2320
|
+
},
|
|
2321
|
+
accountKey: async (purpose, resource, key) => {
|
|
2322
|
+
const organization = await context.account(), deployment = context.deployment();
|
|
2323
|
+
return retainNativeAccountLink(m.service, root, at, call, '_world_account_key', `${organization}/${deployment}/${purpose}`, undefined, resource,
|
|
2324
|
+
() => key.make(), async (link) => {
|
|
2325
|
+
const row = liveNativeRow(link);
|
|
2326
|
+
if (!row) return undefined;
|
|
2327
|
+
const secret = await key.read(row);
|
|
2328
|
+
return secret === undefined ? undefined : { id: link.id, secret };
|
|
2329
|
+
});
|
|
2224
2330
|
},
|
|
2225
2331
|
record: async (type, fields, id, operation) => {
|
|
2226
2332
|
if (!type.startsWith('_')) throw new Error(`semantics: ${type} is not a bookkeeping type (bookkeeping types start with _)`);
|
|
@@ -2272,7 +2378,11 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
2272
2378
|
const row = await atomicWrite(m, call, resource, (snapshot) => {
|
|
2273
2379
|
const id = options?.id ?? mintId(m, resource, root, at, snapshot);
|
|
2274
2380
|
const selected = typeof fields === 'function' ? fields(id) : fields;
|
|
2275
|
-
const hit = snapshot.find((r) =>
|
|
2381
|
+
const hit = snapshot.find((r) => {
|
|
2382
|
+
if (r.type !== storedType(m, resource) || r.deleted === true) return false;
|
|
2383
|
+
const fields = { [m.resources[resource]?.idAs ?? 'id']: r.id, ...ownFields(r) };
|
|
2384
|
+
return rules.some((rule) => rule.fields.every((field) => selected[field] !== undefined && fields[field] === selected[field]));
|
|
2385
|
+
});
|
|
2276
2386
|
if (hit) { existing = view(m, resource, hit); return undefined; }
|
|
2277
2387
|
return { id, fields: selected };
|
|
2278
2388
|
}, operation, params, root, at, undefined, stateChecks, options?.input === 'omit');
|
|
@@ -2304,7 +2414,7 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
2304
2414
|
},
|
|
2305
2415
|
writeDetailed: (resource, id, fields, operation) => writeDetailed(m, call, resource, id, fields, operation, params, root, at, stateChecks),
|
|
2306
2416
|
resolve: (resource, id) => resolveSubjectId(m.service, storedType(m, resource), id, root),
|
|
2307
|
-
storedType: (resource) =>
|
|
2417
|
+
storedType: (resource) => m.resources[resource]?.storedAs ?? resource,
|
|
2308
2418
|
ok: (fields) => Response.json({ ...(m.success ?? {}), ...fields }),
|
|
2309
2419
|
row: (resource, id, opts) => { const row = stored(m, resource, root, opts).find((r) => r.id === id); return row ? rawRow(resource, row) : undefined; },
|
|
2310
2420
|
rowsRaw: (resource, opts) => stored(m, resource, root, opts).map((row) => rawRow(resource, row)),
|
|
@@ -2427,12 +2537,14 @@ const BATCH_REFUSED_READS = ['get', 'rows', 'tree', 'history', 'find', 'list', '
|
|
|
2427
2537
|
/** The members that write, or wait on writes, or answer as another context: each lands the staged writes first. */
|
|
2428
2538
|
const BATCH_LANDING_MEMBERS = ['create', 'createUnique', 'change', 'accumulate', 'writeDetailed', 'record', 'atomically', 'remove', 'issue', 'at', 'over', 'core', 'awaitWrite', 'awaitCondition',
|
|
2429
2539
|
// a World's seed and signing keys are written when first asked (worldSeed, worldSigningKey, as a vendor's move)
|
|
2430
|
-
'secret', 'signingKey'] as const;
|
|
2540
|
+
'secret', 'signingKey', 'account', 'organization', 'tenant', 'deploymentObject', 'accountKey'] as const;
|
|
2431
2541
|
|
|
2432
2542
|
/** `ctx.batch` over a context whose writes may be grouped (see `batchable`). */
|
|
2433
2543
|
function bindBatch(m: DerivedManifest, call: DerivedCall, context: SemanticsContext, params: Record<string, unknown>, root: string | undefined, at: string,
|
|
2434
2544
|
rawRow: (resource: string, row: TwinResource) => Record<string, unknown>): void {
|
|
2435
2545
|
let staged: StagedWrite[] | undefined;
|
|
2546
|
+
// A callback's native object must land before the helper writes its retained link.
|
|
2547
|
+
let nativeAccountWrites = 0;
|
|
2436
2548
|
// each staged subject as it will stand, by `type:id`, in the order first written
|
|
2437
2549
|
const subjects = new Map<string, Subject>();
|
|
2438
2550
|
const land = async (): Promise<void> => {
|
|
@@ -2446,7 +2558,13 @@ function bindBatch(m: DerivedManifest, call: DerivedCall, context: SemanticsCont
|
|
|
2446
2558
|
for (const name of BATCH_LANDING_MEMBERS) {
|
|
2447
2559
|
const plain = ctx[name];
|
|
2448
2560
|
if (typeof plain !== 'function') continue;
|
|
2449
|
-
ctx[name] = async (...args: unknown[]) => {
|
|
2561
|
+
ctx[name] = async (...args: unknown[]) => {
|
|
2562
|
+
await land();
|
|
2563
|
+
const native = name === 'tenant' || name === 'deploymentObject' || name === 'accountKey';
|
|
2564
|
+
if (native) nativeAccountWrites++;
|
|
2565
|
+
try { return await (plain as (...a: unknown[]) => unknown)(...args); }
|
|
2566
|
+
finally { if (native) nativeAccountWrites--; }
|
|
2567
|
+
};
|
|
2450
2568
|
}
|
|
2451
2569
|
for (const name of BATCH_REFUSED_READS) {
|
|
2452
2570
|
const plain = ctx[name];
|
|
@@ -2459,7 +2577,7 @@ function bindBatch(m: DerivedManifest, call: DerivedCall, context: SemanticsCont
|
|
|
2459
2577
|
const plainWrite = context.write, plainRow = context.row, plainRowsRaw = context.rowsRaw;
|
|
2460
2578
|
context.write = async (resource, id, fields, operation) => {
|
|
2461
2579
|
// a vendor's own move (ctx.asVendor) is the clock's write, recorded as the system's: it lands alone, in its place
|
|
2462
|
-
if (!staged || inVendorMove()) { await land(); return plainWrite(resource, id, fields, operation); }
|
|
2580
|
+
if (!staged || nativeAccountWrites > 0 || inVendorMove()) { await land(); return plainWrite(resource, id, fields, operation); }
|
|
2463
2581
|
const type = storedType(m, resource), key = `${type}:${id}`;
|
|
2464
2582
|
// the one subject, as the memoized tree holds it (readTreeMap would copy the whole tree on every staged write)
|
|
2465
2583
|
const subject = foldLocalWrite(subjects.get(key) ?? heldSubject(m.service, type, id, root), type, id, fields as SubjectFields, at);
|
package/src/derived-real.ts
CHANGED
|
@@ -583,8 +583,8 @@ function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapt
|
|
|
583
583
|
const sent = asked.length ? `${target}${target.includes('?') ? '&' : '?'}${new URLSearchParams(asked)}` : target;
|
|
584
584
|
const answer = await execute({ method: op.method.toUpperCase(), path: sentTo(m, sent, subject), ...onLane, headers: fixedHeaders(op) });
|
|
585
585
|
// a door's subject the vendor does not show the root (another bot's private chat) stays as the World holds it
|
|
586
|
-
if (!rooted.has(key) && answer.status >= 400 && answer.status !== 429) continue;
|
|
587
586
|
if (answer.status === 401) throw new Error(`401: the vendor refused the root's credential (${op.method.toUpperCase()} ${path}); nothing folded`);
|
|
587
|
+
if (!rooted.has(key) && answer.status >= 400 && answer.status !== 429) continue;
|
|
588
588
|
if (answer.status === 403) { unread.push({ type, path, status: 403 }); refused = true; break; }
|
|
589
589
|
if (answer.status === 429) throw new Error(`429: rate limited${answer.headers['retry-after'] ? `, retry after ${answer.headers['retry-after']}s` : ''}`);
|
|
590
590
|
read.push(String(subject.id));
|
|
@@ -592,7 +592,15 @@ function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapt
|
|
|
592
592
|
// the vendor's answer for a subject it no longer has: a 404, or the scope's `gone` (Telegram's 400 `chat not found`)
|
|
593
593
|
if (answer.status === 404 || (scope.gone && answer.status === scope.gone.status && refusalWords(m, parse(answer)).includes(scope.gone.says))) continue;
|
|
594
594
|
if (answer.status >= 400 || refusedIn(m, parse(answer))) throw new Error(`${m.vendor}: ${resource} refresh ${op.id} (${op.method.toUpperCase()} ${op.path}): ${answer.status}: ${refusalWords(m, parse(answer))}`);
|
|
595
|
-
const
|
|
595
|
+
const parsed = parse(answer);
|
|
596
|
+
let full: Row;
|
|
597
|
+
if (op.answers?.list) {
|
|
598
|
+
const items = itemsOf(op, parsed);
|
|
599
|
+
const matching = items.find((item) => (scopeIdOf(scope, item) ?? storedIdOf(decl, item)) === vendorId);
|
|
600
|
+
if (!matching && items.length) throw new Error(`${m.vendor}: ${resource} refresh ${op.id} omitted the requested subject; nothing folded`);
|
|
601
|
+
if (!matching) continue;
|
|
602
|
+
full = matching;
|
|
603
|
+
} else full = object(op.answers?.key ? at(parsed, op.answers.key) : parsed) ?? {};
|
|
596
604
|
const local = (m as { worldFields?: Record<string, Record<string, unknown>> }).worldFields?.[resource] ?? {};
|
|
597
605
|
const fields = Object.fromEntries(Object.entries(full).filter(([k]) => !(k in local)));
|
|
598
606
|
if (decl.parent) {
|