@volter/world-core 3.0.161 → 3.0.162

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/src/pack-fetch.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { runWithStorageTenant } from './request-scope.ts';
1
2
  import { parseExactJson } from './exact-json.ts';
2
3
  import { redisServiceTimes } from './redis/service-times.ts';
3
4
  import { readHostPort } from './host-port.ts';
@@ -502,7 +503,7 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
502
503
  redisServiceTimes.lap('2 content and lane routes, decoding, routing');
503
504
  if (request.method === 'GET' && path === '/twin' && m.discovery) {
504
505
  const d = m.discovery;
505
- const discoveryDoors = [...(m.doors ?? []), ...(m.machines ? [{ id: 'machine-pool', method: 'POST', path: '/_twin/machine-pool', note: 'Enroll the World execution pool: { kind: light | docker | none | fake, images?: { image: { context, command?, runtime? } } }.' }] : [])];
506
+ const discoveryDoors = [...(m.doors ?? []), ...(m.machines ? [{ id: 'machine-pool', method: 'POST', path: '/_twin/machine-pool', note: 'Enroll the World execution pool: { kind: docker | none | fake }.' }] : [])];
506
507
  // the workspaces a person can be shown (Viewing a World): each built workspace screen's id and path
507
508
  const laneDiscovery = await laneDoor(request, '/twin');
508
509
  const resources: Record<string, { relations: Array<{ field: string; to: string }> }> = {};
@@ -546,7 +547,7 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
546
547
  try { enrollment = parseExactJson(await request.clone().text()); } catch {}
547
548
  const kind = enrollment.kind;
548
549
  if (!POOL_KINDS.includes(kind as never)) return Response.json({ message: `kind must be one of ${POOL_KINDS.join(', ')}` }, { status: 400 });
549
- await applyTwinWrite(m.service, { operation: 'machine_pool.enroll', subjectType: MACHINE_POOL, subjectId: 'pool', fields: { kind, ...(kind === 'fake' && Array.isArray(enrollment.httpReplies) ? { httpReplies: enrollment.httpReplies } : {}), ...(kind === 'light' ? { images: enrollment.images ?? {} } : {}) }, occurredAt: (await open(request, 'machine-pool', 'POST', '/_twin/machine-pool', {})).occurredAt, actor: { kind: 'system' } }, options.root);
550
+ await applyTwinWrite(m.service, { operation: 'machine_pool.enroll', subjectType: MACHINE_POOL, subjectId: 'pool', fields: { kind, ...(kind === 'fake' && Array.isArray(enrollment.httpReplies) ? { httpReplies: enrollment.httpReplies } : {}) }, occurredAt: (await open(request, 'machine-pool', 'POST', '/_twin/machine-pool', {})).occurredAt, actor: { kind: 'system' } }, options.root);
550
551
  if (kind === 'fake') resetFakePool(m.service, options.root);
551
552
  return Response.json({ kind }, { status: 201 });
552
553
  }
@@ -656,7 +657,16 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
656
657
  };
657
658
  const cors = m.cors ? withCors(m.cors, labelled) : labelled;
658
659
  // at a World's place every cookie the twin sets is kept to the twin's base path (twin-fetch.ts, placedCookies)
659
- const served = async (request: Request): Promise<Response> => placedCookies(request, await cors(request));
660
+ const selectedAccount = async (request: Request): Promise<string | undefined> => {
661
+ if (m.tenant?.storage !== 'namespaced' || !parts.tenant) return undefined;
662
+ const authority = routed(request.clone()), path = new URL(authority.url).pathname;
663
+ return runWithStorageTenant(m.service, undefined, async () => (await parts.tenant!(await open(authority, 'account-authority', request.method, path, {}))) ?? '__unselected__');
664
+ };
665
+ const served = async (request: Request): Promise<Response> => {
666
+ const path = new URL(routed(request.clone()).url).pathname;
667
+ if (m.tenant?.storage !== 'namespaced' || !parts.tenant || path === '/twin' || path.startsWith('/twin/')) return placedCookies(request, await cors(request));
668
+ return runWithStorageTenant(m.service, await selectedAccount(request), async () => placedCookies(request, await cors(request)));
669
+ };
660
670
  // which paths this pack's API serves, for a vendor's lane router that asks (unlessOnlyRoot)
661
671
  const serves = (method: string, path: string): boolean => Boolean(matchOperation(routes, method, path));
662
672
  // the vendor's sockets: a request to a declared socket's path opens a session of its engine, over a context of the
@@ -666,7 +676,19 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
666
676
  (request, d, params) => open(request, `socket:${d.id}`, 'GET', d.path, params), hostOf, (request) => new URL(routed(request).url).pathname,
667
677
  // a turn a session speaks (a voice agent's reply) is the World's scenario's, as a turn over HTTP is
668
678
  async (asked) => engineOf()?.serve(asked)) : undefined;
669
- const upgrade = laneUpgrades(parts.lanes ?? {}, ownUpgrade);
679
+ const rawUpgrade = laneUpgrades(parts.lanes ?? {}, ownUpgrade);
680
+ const opening = new WeakMap<WebSocketPeer, Promise<void>>();
681
+ const upgrade: WebSocketUpgrade | undefined = rawUpgrade && m.tenant?.storage === 'namespaced' && parts.tenant ? {
682
+ accepts: request => rawUpgrade.accepts(request),
683
+ protocol: request => rawUpgrade.protocol?.(request),
684
+ open(peer, request) {
685
+ const ready = selectedAccount(request).then(account => runWithStorageTenant(m.service, account, () => rawUpgrade.open(peer, request)));
686
+ opening.set(peer, ready);
687
+ void ready.catch(error => peer.close(1011, String(error instanceof Error ? error.message : error).slice(0, 120)));
688
+ },
689
+ async message(peer, data) { await opening.get(peer); return rawUpgrade.message(peer, data); },
690
+ close(peer) { const ready = opening.get(peer); opening.delete(peer); void ready?.then(() => rawUpgrade.close(peer), () => undefined); },
691
+ } : rawUpgrade;
670
692
  // the broadcasts the vendor takes over RTMP: a publisher's connection, its publish ruled by the pack's dialect over a
671
693
  // context of its own (operation `live:<id>`), its recording kept as the World's bookkeeping and blobs (live/ingest.ts)
672
694
  const ownIngest = m.live?.length ? liveIngest<HandlerContext>({
@@ -692,7 +714,7 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
692
714
  ...lanesOwners(parts.lanes ?? {}),
693
715
  });
694
716
  const owners = (): Record<string, DerivedOwner> => flatOwners(ownersByUnit());
695
- return derivedRequestScopes(m, Object.assign(served, { owners, ownersByUnit, serves, advanceClock, workspaces, boards: Boolean(parts.board), maxRequestBodySize: m.descriptor?.maxRequestBodySize, draws: drawsUrl, contentHosts, ...(upgrade ? { upgrade } : {}), ...(ingest ? { ingest } : {}) }), options.readOnly);
717
+ return derivedRequestScopes(m, Object.assign(served, { owners, ownersByUnit, serves, advanceClock: async (request: Request) => runWithStorageTenant(m.service, await selectedAccount(request), () => advanceClock(request)), workspaces, boards: Boolean(parts.board), maxRequestBodySize: m.descriptor?.maxRequestBodySize, draws: drawsUrl, contentHosts, ...(upgrade ? { upgrade } : {}), ...(ingest ? { ingest } : {}) }), options.readOnly);
696
718
  }
697
719
 
698
720
  /** The vendor's gateway over its lanes: the lane a request goes to (a door to the lane that declares it, then the first
@@ -221,12 +221,12 @@ export type TwinPack = {
221
221
  * answer: `init` writes `$issue:<service>` for each, and `up`, once this twin is running, POSTs the body to the door
222
222
  * and sets each name to its field (`file:<field>` materializes one file; `directory:<field>` a relative-file map). A twin that holds its keys (refusing any it
223
223
  * did not issue) declares one, so a World's applications hold keys it issued rather than fixtures it refuses. */
224
- credentialDoor?: { path: string; body: Record<string, unknown>; fill: Record<string, string> };
224
+ credentialDoor?: { path: string; body: Record<string, unknown>; fill: Record<string, string>; account?: { field: string } };
225
225
  /** Another pack of this vendor issues the API's credentials; architecture, descriptor World wiring. */
226
226
  credentialIssuer?: string;
227
227
  prismaAdapter?: { adapter: string; export: string; urlEnv: string };
228
228
  managedService?: { kind: string; scheme: string; note: string; configurationArg?: '--configuration'; originalInputs?: { version: 1; manifestArg: '--original-inputs'; rootArg: '--original-root'; digestArg: '--original-inputs-sha256'; paths: string[] } };
229
- managedDatabase?: { kind: 'postgres'; arg: string; note: string };
229
+ managedDatabase?: { kind: 'postgres'; arg: string; user: string; database: string; clock: 'wall'; credentialField: string; note: string };
230
230
  sidePorts?: Array<{ name: string; option: string; flag: string; path?: string; scheme?: string; note: string }>;
231
231
  stateSystem?: StateSystemAdapters;
232
232
  /** The pack has no vendor-backed half, and why (copied from the manifest by packOf). */
package/src/protobuf.ts CHANGED
@@ -17,7 +17,6 @@ export type ProtoSchema = {
17
17
  const VARINT = new Set(['int32', 'int64', 'uint32', 'uint64', 'sint32', 'sint64', 'bool']);
18
18
  const FIXED64 = new Set(['fixed64', 'sfixed64', 'double']);
19
19
  const FIXED32 = new Set(['fixed32', 'sfixed32', 'float']);
20
- const LONG = new Set(['int64', 'uint64', 'sint64', 'fixed64', 'sfixed64']);
21
20
  const WRAPPERS: Record<string, string> = {
22
21
  'google.protobuf.DoubleValue': 'double', 'google.protobuf.FloatValue': 'float', 'google.protobuf.Int64Value': 'int64',
23
22
  'google.protobuf.UInt64Value': 'uint64', 'google.protobuf.Int32Value': 'int32', 'google.protobuf.UInt32Value': 'uint32',
@@ -150,8 +149,21 @@ class Reader {
150
149
  const out = this.b.subarray(this.at, this.at + n); this.at += n; return out;
151
150
  }
152
151
  bytes(): Uint8Array { return this.take(Number(this.varint())); }
153
- skip(wire: number): void {
152
+ skip(wire: number, number?: number): void {
154
153
  if (wire === 0) this.varint(); else if (wire === 1) this.take(8); else if (wire === 5) this.take(4); else if (wire === 2) this.bytes();
154
+ else if (wire === 3) {
155
+ while (!this.done) {
156
+ const tag = this.varint();
157
+ if (tag > 0xffffffffn || (tag >> 3n) === 0n) throw new Error('protobuf: invalid group field tag');
158
+ const field = Number(tag >> 3n), kind = Number(tag & 7n);
159
+ if (kind === 4) {
160
+ if (number !== undefined && field !== number) throw new Error('protobuf: mismatched group end');
161
+ return;
162
+ }
163
+ this.skip(kind, field);
164
+ }
165
+ throw new Error('protobuf: unterminated group');
166
+ }
155
167
  else throw new Error(`protobuf: wire type ${wire} is not supported`);
156
168
  }
157
169
  }
@@ -253,3 +265,28 @@ export function decodeMessage(schema: ProtoSchema, type: string, bytes: Uint8Arr
253
265
  }
254
266
  return wellKnownOut(type, out);
255
267
  }
268
+
269
+ /** Rewrite selected length-delimited fields without decoding/re-encoding the rest of a message.
270
+ * Undefined keeps the original field bytes; null removes that occurrence. Nested rewrites use this same visitor.
271
+ * This is for transparent vendor fronts: unknown fields, ordering and untouched encodings survive unchanged. */
272
+ export function rewriteProtobufFields(
273
+ bytes: Uint8Array,
274
+ fields: Readonly<Record<number, (value: Uint8Array) => Uint8Array | null | undefined>>,
275
+ ): Uint8Array {
276
+ const reader = new Reader(bytes), writer = new Writer();
277
+ while (!reader.done) {
278
+ const start = reader.at, tag = reader.varint();
279
+ if (tag > 0xffffffffn || (tag >> 3n) === 0n) throw new Error('protobuf: invalid field tag');
280
+ const number = Number(tag >> 3n), wire = Number(tag & 7n);
281
+ const rewrite = Object.hasOwn(fields, number) ? fields[number] : undefined;
282
+ if (wire === 2 && rewrite) {
283
+ const changed = rewrite(reader.bytes());
284
+ if (changed === undefined) writer.raw(bytes.subarray(start, reader.at));
285
+ else if (changed !== null) { writer.tag(number, wire); writer.bytes(changed); }
286
+ } else {
287
+ reader.skip(wire, number);
288
+ writer.raw(bytes.subarray(start, reader.at));
289
+ }
290
+ }
291
+ return writer.done();
292
+ }
@@ -84,3 +84,12 @@ export function refuseReadOnlyWrite(service: string): void {
84
84
  writeScopeStore!.getStore()!.refusals.push(error);
85
85
  throw error;
86
86
  }
87
+
88
+ /** Request-local service tenancy. Created lazily so imports remain inert. */
89
+ let tenantStore: AsyncLocalStorage<ReadonlyMap<string, string | undefined>> | undefined;
90
+ export function storageTenant(service: string): string | undefined { return tenantStore?.getStore()?.get(service); }
91
+ export function runWithStorageTenant<T>(service: string, tenant: string | undefined, fn: () => T): T {
92
+ tenantStore ??= new AsyncLocalStorage<ReadonlyMap<string, string | undefined>>();
93
+ const scope = new Map(tenantStore.getStore()); scope.set(service, tenant);
94
+ return tenantStore.run(scope, fn);
95
+ }
package/src/runtime.ts CHANGED
@@ -21,8 +21,8 @@ export type { Traceparent } from './trace-context.ts';
21
21
  export type { DerivedCall, DerivedCoreOutcome, DerivedDispatch, DerivedFetch, DerivedFetchOptions, DerivedHandler, DerivedOperation, DerivedOwner, DerivedSurface, BoardFrame } from './derived.ts';
22
22
  export { authRefusal, bindSemantics, coreFor, crossCutting, flagOf, derivedRequestScopes, observeTransitions, resourcesOfType as twinResourcesOfType, semanticsContext, stateOf, transitionFor, parseBracketForm, readParams, render as renderDerived, serveCore, sse, vendorError } from './derived-core.ts';
23
23
  export { deliverEvents, fillEnvelope, setDeliveryObserver, signEvent, signEventAll, verifyEvent } from './events.ts';
24
- export { equalSecrets, hmac, jwks, jwtDecode, jwtSign, jwtVerify, keyId, lettersFrom, md5, sha256, SIGNING_ALGORITHMS, uuidFrom } from './signing.ts';
25
- export type { HandlerCrypto, Jwk, Jwks, JwtVerdict, SigningKey } from './signing.ts';
24
+ export { equalSecrets, hmac, jwks, jwtDecode, jwtSign, jwtSignClaims, jwtVerify, jwtVerifySignature, keyId, lettersFrom, md5, sha256, SIGNING_ALGORITHMS, uuidFrom } from './signing.ts';
25
+ export type { HandlerCrypto, Jwk, Jwks, JwtDecodeOptions, JwtSignatureOptions, JwtVerdict, JwtVerificationKey, SigningKey } from './signing.ts';
26
26
  export type { EventDelivery, EventRender, EventScheme, EventsDecl, EventTransport, EventWrite } from './events.ts';
27
27
  export type { Actor, CoreScope, DerivedManifest, ErrorSpec, FieldRule, Handler, HandlerContext, ResourceDecl, WriteHookContext, ScreenDecl, Semantics, SemanticsContext, StateField, Transition, TransitionObserver, SeedCall } from './derived-core.ts';
28
28
  export type { RemoteExecute, RemoteExecuteRequest, RemoteExecuteResponse } from './remote-execute.ts';
@@ -352,9 +352,14 @@ export { captureHistory, captureParentHistory, historyChanges, historyAtInstant,
352
352
  export type { HistoryView, HistoryLayout, HistoryReference, HistoryOrigin } from './history.ts';
353
353
  export { foldHistory } from './log.ts';
354
354
  export { volterHome } from './volter-home.ts';
355
- export type { MachinePool, MachineSpec, MachineProcess, MachineStatus, MachineFiles, MachineFileStat, GuestImage, LightPoolOptions } from './machines.ts';
355
+ export type { MachinePool, MachineSpec, MachineProcess, MachineStatus, MachineFiles, MachineFileStat } from './machines.ts';
356
356
 
357
357
 
358
358
  export { boardFrameKey } from './board-keys.ts';
359
359
 
360
360
  export * as managedOriginalInputs from './managed-original-inputs.ts';
361
+
362
+ export { worldSecret, worldAccount } from './derived-core.ts';
363
+
364
+ export { rewriteProtobufFields } from './protobuf.ts';
365
+ export { attachWebSocketRelay } from './serve-http.ts';
package/src/serve-http.ts CHANGED
@@ -418,3 +418,114 @@ async function serveOnNode(options: ServeHttpOptions & { hostname: string; urlHo
418
418
  stop: (closeActive = true) => new Promise<void>((resolve) => { if (closeActive) { for (const controller of active) controller.abort(); for (const peer of socketServer?.clients ?? []) peer.terminate(); } socketServer?.close(); server.close(() => { if (closeActive && active.size) reportCleanup('forced stop left pending handler cleanup', `${active.size} request(s)`); resolve(); }); if (closeActive) server.closeAllConnections(); }),
419
419
  };
420
420
  }
421
+
422
+
423
+ export type WebSocketRelay = {
424
+ url: string;
425
+ headers?: Record<string, string | string[] | undefined>;
426
+ outgoing?: (frame: string | Uint8Array) => string | Uint8Array | Promise<string | Uint8Array>;
427
+ };
428
+
429
+ /** Attach an authenticated native WebSocket relay to an existing Node front.
430
+ * The shared socket library owns handshake, fragmentation, masking, compression and control frames.
431
+ * Payload transforms are ordered; unknown vendor messages remain opaque bytes. */
432
+ export async function attachWebSocketRelay(
433
+ server: import('node:http').Server,
434
+ route: (request: import('node:http').IncomingMessage) => WebSocketRelay | undefined,
435
+ ): Promise<{ stop: () => void }> {
436
+ const { WebSocket: Client, WebSocketServer } = await socketLibrary();
437
+ const routes = new WeakMap<import('node:http').IncomingMessage, import('ws').WebSocket>();
438
+ const pairs = new Set<{ upstream: import('ws').WebSocket; downstream?: import('ws').WebSocket }>();
439
+ const accepted = new WebSocketServer({ noServer: true, handleProtocols: (_offered, request) => routes.get(request)?.protocol || false });
440
+ let stopped = false;
441
+ const reject = (socket: import('node:stream').Duplex, status: number, message: string) => {
442
+ const body = new TextEncoder().encode(message);
443
+ socket.end('HTTP/1.1 ' + status + ' Rejected\r\nConnection: close\r\nContent-Type: text/plain\r\nContent-Length: ' + body.length + '\r\n\r\n' + message);
444
+ };
445
+ const upgrade = (request: import('node:http').IncomingMessage, socket: import('node:stream').Duplex, head: Buffer) => {
446
+ let selected: WebSocketRelay | undefined;
447
+ try { if (!stopped) selected = route(request); }
448
+ catch { reject(socket, 401, 'invalid authorization token'); return; }
449
+ if (!selected) { reject(socket, 404, 'WebSocket route is not served'); return; }
450
+ const headers = { ...selected.headers };
451
+ // Each hop has its own RFC6455 handshake; the library regenerates these headers.
452
+ for (const name of Object.keys(headers)) if (/^(host|connection|upgrade|sec-websocket-(key|version|extensions|protocol))$/i.test(name)) delete headers[name];
453
+ const protocols = String(request.headers['sec-websocket-protocol'] ?? '').split(',').map(value => value.trim()).filter(Boolean);
454
+ const upstream = new Client(selected.url, protocols, { headers });
455
+ const pair: { upstream: import('ws').WebSocket; downstream?: import('ws').WebSocket } = { upstream };
456
+ pairs.add(pair);
457
+ const pending: Array<string | Uint8Array> = [];
458
+ let queue = Promise.resolve(), closed = false;
459
+ const dispose = () => {
460
+ if (closed) return;
461
+ closed = true;
462
+ pairs.delete(pair);
463
+ upstream.terminate();
464
+ pair.downstream?.terminate();
465
+ socket.destroy();
466
+ };
467
+ const closeCode = (code: number): number => code === 1005 ? 1000 : code === 1006 ? 1011 : code;
468
+ const frameOf = (data: import('ws').RawData, binary: boolean): string | Uint8Array => {
469
+ const raw = Array.isArray(data) ? Buffer.concat(data) : data instanceof ArrayBuffer ? new Uint8Array(data) : data;
470
+ return binary ? new Uint8Array(raw) : Buffer.from(raw).toString('utf8');
471
+ };
472
+ const send = (frame: string | Uint8Array) => {
473
+ queue = queue.then(async () => {
474
+ const output = await (selected!.outgoing?.(frame) ?? frame);
475
+ if (!closed && pair.downstream?.readyState === Client.OPEN) pair.downstream.send(output, { binary: typeof output !== 'string' });
476
+ }).catch(dispose);
477
+ };
478
+ upstream.on('message', (data, binary) => {
479
+ const frame = frameOf(data, binary);
480
+ if (!pair.downstream) pending.push(frame); else send(frame);
481
+ });
482
+ upstream.once('open', () => {
483
+ if (closed || stopped || socket.destroyed) { dispose(); return; }
484
+ routes.set(request, upstream);
485
+ accepted.handleUpgrade(request, socket, head, downstream => {
486
+ pair.downstream = downstream;
487
+ downstream.on('message', (data, binary) => {
488
+ const frame = frameOf(data, binary);
489
+ if (!closed && upstream.readyState === Client.OPEN) upstream.send(frame, { binary: typeof frame !== 'string' });
490
+ });
491
+ downstream.on('error', dispose);
492
+ downstream.once('close', (code, reason) => { if (!closed && upstream.readyState === Client.OPEN) upstream.close(closeCode(code), reason); });
493
+ for (const frame of pending.splice(0)) send(frame);
494
+ });
495
+ });
496
+ upstream.once('unexpected-response', (_request, response) => {
497
+ if (pair.downstream) { dispose(); return; }
498
+ const chunks: Uint8Array[] = [];
499
+ response.on('data', (chunk: Uint8Array) => chunks.push(chunk));
500
+ response.once('end', () => {
501
+ const body = Buffer.concat(chunks);
502
+ const carried = Object.entries(response.headers).filter(([name]) => !/^(connection|content-length|transfer-encoding)$/i.test(name))
503
+ .flatMap(([name, values]) => (Array.isArray(values) ? values : [values]).filter(value => value !== undefined).map(value => name + ': ' + value));
504
+ const header = Buffer.from(['HTTP/1.1 ' + (response.statusCode ?? 502) + ' ' + (response.statusMessage ?? 'Rejected'),
505
+ 'Connection: close', 'Content-Length: ' + body.length, ...carried, '', ''].join('\r\n'));
506
+ socket.end(Buffer.concat([header, body]));
507
+ pairs.delete(pair);
508
+ closed = true;
509
+ upstream.terminate();
510
+ });
511
+ response.once('error', dispose);
512
+ });
513
+ upstream.on('error', dispose);
514
+ upstream.once('close', (code, reason) => {
515
+ if (pair.downstream && pair.downstream.readyState === Client.OPEN) pair.downstream.close(closeCode(code), reason);
516
+ else if (!pair.downstream) socket.destroy();
517
+ pairs.delete(pair);
518
+ });
519
+ socket.once('error', dispose);
520
+ socket.once('close', () => { if (!pair.downstream) dispose(); });
521
+ };
522
+ server.on('upgrade', upgrade);
523
+ return { stop: () => {
524
+ stopped = true;
525
+ server.off('upgrade', upgrade);
526
+ for (const pair of pairs) { pair.upstream.terminate(); pair.downstream?.terminate(); }
527
+ pairs.clear();
528
+ for (const peer of accepted.clients) peer.terminate();
529
+ accepted.close();
530
+ } };
531
+ }
package/src/signing.ts CHANGED
@@ -19,8 +19,11 @@ export type SigningKey = { alg: 'RS256' | 'RS384' | 'RS512' | 'EdDSA'; privatePe
19
19
  /** The algorithms `jwtSign` signs with. */
20
20
  export const SIGNING_ALGORITHMS: ReadonlyArray<SigningKey['alg']> = ['RS256', 'RS384', 'RS512', 'EdDSA', 'HS256', 'HS384', 'HS512'];
21
21
  const digestOf = (alg: string): string => `sha${alg.slice(2)}`;
22
- /** Whether a token holds: its signature and its `exp`/`nbf` at `now` (seconds), with the payload when it does. */
22
+ /** A token check's result, with its payload on success. `jwtVerify` checks time as well as the signature. */
23
23
  export type JwtVerdict = { valid: boolean; payload?: Row; reason?: 'malformed' | 'unexpected_alg' | 'no_matching_key' | 'bad_signature' | 'expired' | 'not_yet_valid' };
24
+ export type JwtVerificationKey = Jwks | { alg: 'HS256' | 'HS384' | 'HS512'; secret: string };
25
+ export type JwtDecodeOptions = { strict?: boolean; integers?: 'exact' | 'number' };
26
+ export type JwtSignatureOptions = JwtDecodeOptions & { hmacSignature?: 'canonical' | 'bytes' };
24
27
 
25
28
  const base64url = (input: Buffer | string): string => Buffer.from(input).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
26
29
  const fromBase64url = (s: string): Buffer => Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
@@ -35,6 +38,11 @@ export function keyId(publicPem: string, prefix = 'ins_'): string {
35
38
  /** A JWT: `iat` and `nbf` are `now` (seconds, the World's), `exp` `now` plus the lifetime (60 s when none is given),
36
39
  * unless the claims name their own. A token of a key pair carries the key's `kid`. */
37
40
  export function jwtSign(claims: Row, key: SigningKey, opts: { now: number; expiresInSeconds?: number; kidPrefix?: string; typ?: string }): string {
41
+ return jwtSignClaims({ iat: opts.now, exp: opts.now + (opts.expiresInSeconds ?? 60), nbf: opts.now, ...claims }, key, opts);
42
+ }
43
+
44
+ /** A JWT over exactly the supplied claims, without adding temporal claims. The caller owns their policy. */
45
+ export function jwtSignClaims(claims: Row, key: SigningKey, opts: { kidPrefix?: string; typ?: string } = {}): string {
38
46
  // a key of another kind than its algorithm's is refused by name (an RSA key under EdDSA would fail inside the signer)
39
47
  if ('privatePem' in key) {
40
48
  const kind = nodeCrypto.createPrivateKey(key.privatePem).asymmetricKeyType;
@@ -44,8 +52,7 @@ export function jwtSign(claims: Row, key: SigningKey, opts: { now: number; expir
44
52
  const header = 'privatePem' in key
45
53
  ? { alg: key.alg, typ: opts.typ ?? 'JWT', kid: keyId(key.publicPem ?? nodeCrypto.createPublicKey(key.privatePem).export({ type: 'spki', format: 'pem' }).toString(), opts.kidPrefix) }
46
54
  : { alg: key.alg, typ: opts.typ ?? 'JWT' };
47
- const payload = { iat: opts.now, exp: opts.now + (opts.expiresInSeconds ?? 60), nbf: opts.now, ...claims };
48
- const input = `${jsonPart(header)}.${jsonPart(payload)}`;
55
+ const input = `${jsonPart(header)}.${jsonPart(claims)}`;
49
56
  if ('secret' in key) return `${input}.${base64url(nodeCrypto.createHmac(digestOf(key.alg), key.secret).update(input).digest())}`;
50
57
  if (key.alg === 'EdDSA') return `${input}.${base64url(nodeCrypto.sign(null, Buffer.from(input), key.privatePem))}`;
51
58
  const signer = nodeCrypto.createSign(`RSA-SHA${key.alg.slice(2)}`);
@@ -54,11 +61,14 @@ export function jwtSign(claims: Row, key: SigningKey, opts: { now: number; expir
54
61
  return `${input}.${base64url(signer.sign(key.privatePem))}`;
55
62
  }
56
63
 
57
- /** A JWT's header and payload, unverified. */
58
- export function jwtDecode(token: string): { header: Row; payload: Row } {
64
+ /** A JWT's header and payload, unverified. Strict compact framing and ordinary JSON numbers are explicit
65
+ * protocol policies; existing kernel callers retain permissive decoding and exact integer values. */
66
+ export function jwtDecode(token: string, opts: JwtDecodeOptions = {}): { header: Row; payload: Row } {
67
+ if (opts.strict && typeof token !== 'string') throw new Error('malformed jwt');
59
68
  const parts = token.split('.');
60
- if (parts.length !== 3) throw new Error('malformed jwt');
61
- const part = (s: string): Row => parseExactJson(fromBase64url(s).toString('utf8')) as Row;
69
+ if (parts.length !== 3 || opts.strict && parts.some(part => !/^[A-Za-z0-9_-]+$/.test(part))) throw new Error('malformed jwt');
70
+ const parse = opts.integers === 'number' ? JSON.parse : parseExactJson;
71
+ const part = (s: string): Row => parse(fromBase64url(s).toString('utf8')) as Row;
62
72
  return { header: part(parts[0]!), payload: part(parts[1]!) };
63
73
  }
64
74
 
@@ -70,33 +80,43 @@ function timely(payload: Row, now: number): JwtVerdict {
70
80
 
71
81
  /** A token checked: an RS256 or EdDSA one against a JWKS (its `kid`'s key), an HS256 one against its secret (constant time),
72
82
  * then its `exp`/`nbf` at `now` (seconds). */
73
- export function jwtVerify(token: string, key: Jwks | { alg: 'HS256' | 'HS384' | 'HS512'; secret: string }, opts: { now: number }): JwtVerdict {
83
+ export function jwtVerify(token: string, key: JwtVerificationKey, opts: { now: number }): JwtVerdict {
84
+ const verdict = jwtVerifySignature(token, key);
85
+ return verdict.valid ? timely(verdict.payload!, opts.now) : verdict;
86
+ }
87
+
88
+ /** A token's signature checked without an `exp`/`nbf` policy. An issuer selector sees unverified claims and may
89
+ * enforce its vendor's claim shape and key ownership before returning a key. HMAC signatures use canonical
90
+ * text by default; a protocol that compares decoded bytes selects that existing wire policy explicitly. */
91
+ export function jwtVerifySignature(token: string, key: JwtVerificationKey | ((decoded: { header: Row; payload: Row }) => JwtVerificationKey), opts: JwtSignatureOptions = {}): JwtVerdict {
74
92
  let decoded: { header: Row; payload: Row };
75
- try { decoded = jwtDecode(token); } catch { return { valid: false, reason: 'malformed' }; }
93
+ try { decoded = jwtDecode(token, opts); } catch { return { valid: false, reason: 'malformed' }; }
94
+ const selected = typeof key === 'function' ? key(decoded) : key;
76
95
  const [h, p, s] = token.split('.') as [string, string, string];
77
- if ('secret' in key) {
78
- if (decoded.header.alg !== key.alg) return { valid: false, reason: 'unexpected_alg' };
79
- const expected = Buffer.from(base64url(nodeCrypto.createHmac(digestOf(key.alg), key.secret).update(`${h}.${p}`).digest()), 'utf8');
80
- const given = Buffer.from(s, 'utf8');
96
+ if ('secret' in selected) {
97
+ if (decoded.header.alg !== selected.alg) return { valid: false, reason: 'unexpected_alg' };
98
+ const bytes = nodeCrypto.createHmac(digestOf(selected.alg), selected.secret).update(`${h}.${p}`).digest();
99
+ const expected = opts.hmacSignature === 'bytes' ? bytes : Buffer.from(base64url(bytes), 'utf8');
100
+ const given = opts.hmacSignature === 'bytes' ? fromBase64url(s) : Buffer.from(s, 'utf8');
81
101
  if (given.length !== expected.length || !nodeCrypto.timingSafeEqual(given, expected)) return { valid: false, reason: 'bad_signature' };
82
- return timely(decoded.payload, opts.now);
102
+ return { valid: true, payload: decoded.payload };
83
103
  }
84
104
  if (!['RS256', 'RS384', 'RS512', 'EdDSA'].includes(String(decoded.header.alg))) return { valid: false, reason: 'unexpected_alg' };
85
105
  // a token naming a key the set does not hold matches none; one naming no key is tried against the set's only key
86
- const jwk = decoded.header.kid !== undefined ? key.keys.find((k) => k.kid === decoded.header.kid) : key.keys.length === 1 ? key.keys[0] : undefined;
106
+ const jwk = decoded.header.kid !== undefined ? selected.keys.find((k) => k.kid === decoded.header.kid) : selected.keys.length === 1 ? selected.keys[0] : undefined;
87
107
  if (!jwk) return { valid: false, reason: 'no_matching_key' };
88
108
  if ((jwk.kty === 'OKP') !== (decoded.header.alg === 'EdDSA')) return { valid: false, reason: 'unexpected_alg' };
89
109
  if (jwk.kty === 'OKP') {
90
110
  const edKey = nodeCrypto.createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: jwk.x }, format: 'jwk' });
91
111
  if (!nodeCrypto.verify(null, Buffer.from(`${h}.${p}`), edKey, fromBase64url(s))) return { valid: false, reason: 'bad_signature' };
92
- return timely(decoded.payload, opts.now);
112
+ return { valid: true, payload: decoded.payload };
93
113
  }
94
114
  const verifier = nodeCrypto.createVerify(`RSA-SHA${String(decoded.header.alg).slice(2)}`);
95
115
  verifier.update(`${h}.${p}`);
96
116
  verifier.end();
97
117
  const publicKey = nodeCrypto.createPublicKey({ key: { kty: 'RSA', n: jwk.n, e: jwk.e }, format: 'jwk' });
98
118
  if (!verifier.verify(publicKey, fromBase64url(s))) return { valid: false, reason: 'bad_signature' };
99
- return timely(decoded.payload, opts.now);
119
+ return { valid: true, payload: decoded.payload };
100
120
  }
101
121
 
102
122
  /** The JWKS that verifies every token a public key's pair signs. */
@@ -259,7 +279,7 @@ export function certificateOf(cert: string | Uint8Array): { der: Uint8Array; sub
259
279
  }
260
280
 
261
281
  /** What a handler signs and hashes with: `ctx.crypto`. */
262
- export const handlerCrypto = { jwtSign, jwtVerify, jwtDecode, jwks, keyId, hmac, sha256, md5, digest, uuidFrom, lettersFrom, base62From, equalSecrets, sealedBoxKeyPair, sealedBoxOpen, signWith, verifyWith, publicKeyOf, certificateOf } as const;
282
+ export const handlerCrypto = { jwtSign, jwtSignClaims, jwtVerify, jwtVerifySignature, jwtDecode, jwks, keyId, hmac, sha256, md5, digest, uuidFrom, lettersFrom, base62From, equalSecrets, sealedBoxKeyPair, sealedBoxOpen, signWith, verifyWith, publicKeyOf, certificateOf } as const;
263
283
  export type HandlerCrypto = typeof handlerCrypto;
264
284
 
265
285
  /** A [KSUID](https://github.com/segmentio/ksuid) as 27 base62 characters (0-9A-Za-z, most significant first): `seconds`
package/src/sockets.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { runWithStorageTenant, storageTenant } from './request-scope.ts';
1
2
  // A vendor's persistent wire (architecture, "Other wires: sockets"): a WebSocket its clients hold open, on which the
2
3
  // vendor sends what happens as it happens (Discord's Gateway: a bot identifies, is told the guilds it is in, and is sent
3
4
  // each message, member and interaction as it lands). The manifest declares the socket (`sockets`); the pack's engine
@@ -49,7 +50,7 @@ export type SocketEngine<C> = {
49
50
  close?(session: SocketSession, ctx: C): void | Promise<void>;
50
51
  };
51
52
 
52
- type Open = { session: SocketSession; engine: SocketEngine<unknown>; context: () => Promise<unknown>; queue: Promise<void>; pending: Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout>; due: number }> };
53
+ type Open = { tenant?: string; run: <T>(fn: () => T) => T; session: SocketSession; engine: SocketEngine<unknown>; context: () => Promise<unknown>; queue: Promise<void>; pending: Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout>; due: number }> };
53
54
  const sessions = new Map<string, Set<Open>>();
54
55
  /** A World's next session number, per World (its service and root): a session's id is the World's, never the
55
56
  * process's, so a life replayed on a fresh World numbers its sessions as it did the first time. */
@@ -58,7 +59,7 @@ const keyOf = (service: string, root: string | undefined): string => `${service}
58
59
 
59
60
  /** The open sessions of one socket of the vendor in a World (a handler reaches them as `ctx.sockets(id)`). */
60
61
  export function openSessions(service: string, root: string | undefined, socket: string): SocketSession[] {
61
- return [...(sessions.get(keyOf(service, root)) ?? [])].filter((o) => o.session.socket === socket).map((o) => o.session);
62
+ return [...(sessions.get(keyOf(service, root)) ?? [])].filter((o) => o.session.socket === socket && o.tenant === storageTenant(service)).map((o) => o.session);
62
63
  }
63
64
 
64
65
  /** What a session awaits that the World's clock has run out (a life's `wait` past a relay's timeout), rejected as the
@@ -77,8 +78,9 @@ export function socketWrite(service: string, root: string | undefined, write: Ev
77
78
  if (!open?.size) return;
78
79
  for (const o of open) {
79
80
  const { engine } = o;
81
+ if (o.tenant !== storageTenant(service)) continue;
80
82
  if (!engine.write) continue;
81
- o.queue = o.queue.then(async () => engine.write!(o.session, write, await o.context())).catch((error) => o.session.close(1011, String((error as Error).message ?? error).slice(0, 120)));
83
+ o.queue = o.queue.then(() => o.run(async () => engine.write!(o.session, write, await o.context()))).catch((error) => o.session.close(1011, String((error as Error).message ?? error).slice(0, 120)));
82
84
  }
83
85
  }
84
86
 
@@ -105,8 +107,8 @@ export function socketUpgrade<C>(
105
107
  engines: Record<string, SocketEngine<C>>,
106
108
  context: (request: Request, decl: SocketDecl, params: Record<string, string>) => Promise<C>,
107
109
  hostOf: (request: Request) => string,
108
- pathOf: (request: Request) => string = (request) => new URL(request.url).pathname,
109
- decide: (asked: unknown) => Promise<ScenarioServed | undefined> = async () => undefined,
110
+ pathOf: (request: Request) => string,
111
+ decide: (asked: unknown) => Promise<ScenarioServed | undefined>,
110
112
  ): WebSocketUpgrade {
111
113
  const matchFor = (request: Request): { decl: SocketDecl; params: Record<string, string> } | undefined => {
112
114
  const path = pathOf(request).replace(/\/+$/, '') || '/';
@@ -149,16 +151,17 @@ export function socketUpgrade<C>(
149
151
  },
150
152
  decide,
151
153
  };
152
- const o: Open = { session, engine, context: () => context(request.clone(), decl, params), queue: Promise.resolve(), pending };
154
+ const tenant = storageTenant(service), run = <T>(fn: () => T): T => runWithStorageTenant(service, tenant, fn);
155
+ const o: Open = { tenant, run, session, engine, context: () => context(request.clone(), decl, params), queue: Promise.resolve(), pending };
153
156
  byPeer.set(peer, o);
154
157
  (sessions.get(key) ?? sessions.set(key, new Set()).get(key)!).add(o);
155
- if (engine.open) o.queue = o.queue.then(async () => engine.open!(session, await o.context())).catch((error) => session.close(1011, String((error as Error).message ?? error).slice(0, 120)));
158
+ if (engine.open) o.queue = o.queue.then(() => o.run(async () => engine.open!(session, await o.context()))).catch((error) => session.close(1011, String((error as Error).message ?? error).slice(0, 120)));
156
159
  },
157
160
  message: (peer, data) => {
158
161
  const o = byPeer.get(peer);
159
162
  if (!o) return;
160
163
  const text = typeof data === 'string' ? data : new TextDecoder().decode(data);
161
- o.queue = o.queue.then(async () => o.engine.message(o.session, text, await o.context())).catch((error) => o.session.close(1011, String((error as Error).message ?? error).slice(0, 120)));
164
+ o.queue = o.queue.then(() => o.run(async () => o.engine.message(o.session, text, await o.context()))).catch((error) => o.session.close(1011, String((error as Error).message ?? error).slice(0, 120)));
162
165
  },
163
166
  close: (peer) => {
164
167
  const o = byPeer.get(peer);
@@ -168,7 +171,7 @@ export function socketUpgrade<C>(
168
171
  for (const [k, p] of o.pending) { clearTimeout(p.timer); p.reject(new Error('the session closed')); o.pending.delete(k); }
169
172
  // the end is written after what the session is still doing, over its own context
170
173
  // the session is gone, so a failure has no frame to go in: it is reported, never swallowed
171
- if (o.engine.close) o.queue = o.queue.then(async () => o.engine.close!(o.session, await o.context())).catch((error) => {
174
+ if (o.engine.close) o.queue = o.queue.then(() => o.run(async () => o.engine.close!(o.session, await o.context()))).catch((error) => {
172
175
  console.error(`${service}: the ${o.session.socket} socket's end was not written: ${String((error as Error)?.message ?? error)}`);
173
176
  });
174
177
  },
@@ -0,0 +1,4 @@
1
+ /** The retained World-account name grammar, shared by registration and read-only authority selection. */
2
+ export function isWorldAccountName(value: unknown): value is string {
3
+ return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value);
4
+ }