@volter/world-core 3.0.161 → 3.0.163

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/index.ts CHANGED
@@ -63,8 +63,8 @@ export { assetContentType, packAsset } from './pack-assets.ts';
63
63
  export { bytesResponse, contentTypeOf, fileResponse } from './file-response.ts';
64
64
 
65
65
  // ── the kernel's deterministic helpers ─────────────────────────────────────────────────────────
66
- export { certificateOf, digest, digestBytes, equalSecrets, oauth1BaseString, oauth1Encode, oauth1Header, oauth1Signature, hmac, jwks, jwtDecode, jwtSign, jwtVerify, keyId, lettersFrom, base62From, md5, publicKeyOf, sha256, signWith, SIGNING_ALGORITHMS, uuidFrom, verifyWith } from './signing.ts';
67
- export type { HandlerCrypto, Jwk, Jwks, JwtVerdict, SigningKey } from './signing.ts';
66
+ export { certificateOf, digest, digestBytes, equalSecrets, oauth1BaseString, oauth1Encode, oauth1Header, oauth1Signature, hmac, jwks, jwtDecode, jwtSign, jwtSignClaims, jwtVerify, jwtVerifySignature, keyId, lettersFrom, base62From, md5, publicKeyOf, sha256, signWith, SIGNING_ALGORITHMS, uuidFrom, verifyWith } from './signing.ts';
67
+ export type { HandlerCrypto, Jwk, Jwks, JwtDecodeOptions, JwtSignatureOptions, JwtVerdict, JwtVerificationKey, SigningKey } from './signing.ts';
68
68
  export { blobDigest } from './blob-store.ts';
69
69
  // compressed bytes a wire carries (a SARIF upload), over web streams
70
70
  export { gunzip, gzip } from './bytes.ts';
@@ -109,3 +109,6 @@ export { assertBrowserName, browserOrigin, validateBrowserState, validateBrowser
109
109
 
110
110
  export * as managedOriginalInputs from './managed-original-inputs.ts';
111
111
  export * as managedGoPrograms from './managed-go-programs.ts';
112
+
113
+ export { worldSecret, worldAccount } from './derived-core.ts';
114
+ export { isWorldAccountName } from './world-account-name.ts';
package/src/machines.ts CHANGED
@@ -3,14 +3,12 @@
3
3
  // `POST /_twin/machine-pool`, recorded in the pack's own tree; a handler reaches it as `ctx.machines`. `docker` runs
4
4
  // each machine as a local container with its internal ports published on loopback; `none` runs nothing; `fake`
5
5
  // records what it was asked (capability verification). Nothing here runs at module scope, and `node:child_process`
6
- // is reached only when a compute pool is asked to act. The light pool delegates to the host's Substrate adapter.
6
+ // is reached only when a compute pool is asked to act.
7
7
  import { refuseReadOnlyWrite } from './request-scope.ts';
8
8
  import { nodeBuiltin } from './serve-http.ts';
9
9
  import { refuseRequiredMachineTransport } from '../required-credential-transport.cjs';
10
10
 
11
11
  export type MachineSpec = { name: string; image: string; env?: Record<string, string>; ports?: number[]; command?: string[]; checks?: Array<{ port: number; type: 'http' | 'tcp'; path?: string }>; resetOnStop?: boolean; mounts?: Array<{ name: string; path: string }>; signal?: AbortSignal; startTimeoutMs?: number };
12
- export type GuestImage = { context: string; dockerfile?: string; runtime?: 'node' | 'bun' | 'python' | 'deno'; command?: string[]; cwd?: string; env?: Record<string, string>; prepared?: boolean };
13
- export type LightPoolOptions = { images?: Record<string, GuestImage> };
14
12
  export type MachineRun = { ports: Array<{ internal: number; host: number; url?: string }> };
15
13
  export type MachineStatus = { state: 'started' | 'paused' | 'stopped' | 'exited' | 'failed'; exitCode?: number; reason?: string };
16
14
  export type MachineFileStat = { type: 'file' | 'directory'; size: number; mode: number; mtime: string };
@@ -27,7 +25,7 @@ export type MachineProcess = { pid: number; events: AsyncIterable<{ stdout?: str
27
25
  export type ExecSpec = { args: string[]; env?: Record<string, string>; cwd?: string };
28
26
  export type MachineHttpReply = { port: number; method: string; path: string; requestText?: string; status: number; headers?: Record<string, string>; body?: string };
29
27
  export type MachinePool = {
30
- readonly kind: 'docker' | 'none' | 'fake' | 'light';
28
+ readonly kind: 'docker' | 'none' | 'fake';
31
29
  /** Optional capability admission, before vendor resource creation; starts no process. */
32
30
  admitRun?(spec: MachineSpec): Promise<void>;
33
31
  /** runs the image; a failure (no container runtime, an image that will not start) rejects with the runtime's message */
@@ -51,33 +49,7 @@ export function isMachineCapabilityGap(error: unknown): boolean {
51
49
  return typeof error === 'object' && error !== null && 'code' in error && error.code === 'WORLD_MACHINE_CAPABILITY_GAP';
52
50
  }
53
51
  export const MACHINE_POOL = '_machine_pool';
54
- export const POOL_KINDS = ['docker', 'none', 'fake', 'light'] as const;
55
-
56
- /** The host owns provider bootstrapping. Browser/kernel imports stay inert, and a missing
57
- * host provider refuses rather than falling back to metadata-only success. */
58
- function lightPool(world: string, root: string | undefined, options: LightPoolOptions): MachinePool {
59
- let ready: Promise<MachinePool> | undefined;
60
- const load = (): Promise<MachinePool> => ready ??= (async () => {
61
- const entry = typeof process === 'undefined' ? undefined : process.env.VOLTER_WORLD_GUEST_RUNNER;
62
- if (!entry) throw new Error('light guest runner is unavailable: run this twin inside a World');
63
- const provider = await import(entry);
64
- return provider.createLightGuestPool({ world, root, ...options }) as MachinePool;
65
- })();
66
- return {
67
- kind: 'light',
68
- admitRun: async (spec) => { await (await load()).admitRun?.(spec); },
69
- run: async (spec) => (await load()).run(spec),
70
- exec: async (name, spec) => (await load()).exec(name, spec),
71
- request: async (name, port, request) => (await load()).request(name, port, request),
72
- status: async (name) => (await load()).status(name),
73
- ports: async (name) => (await load()).ports(name),
74
- files: async (name) => (await load()).files(name),
75
- pause: async (name) => (await load()).pause(name),
76
- resume: async (name) => (await load()).resume(name),
77
- stop: async (name) => (await load()).stop(name),
78
- remove: async (name) => (await load()).remove(name),
79
- };
80
- }
52
+ export const POOL_KINDS = ['docker', 'none', 'fake'] as const;
81
53
 
82
54
  /** A container's name for a machine: the World's prefix, the World's own mark (a machine id repeats across Worlds and
83
55
  * branches, which mint alike), then the vendor's machine name made safe for docker. */
@@ -100,8 +72,8 @@ function dockerPool(world: string): MachinePool {
100
72
  const state = JSON.parse(await docker(['inspect', '--format', '{{json .State}}', containerName(world, name)]));
101
73
  return state.Running ? { state: 'started' } : { state: 'exited', exitCode: state.ExitCode, ...(state.Error ? { reason: state.Error } : {}) };
102
74
  },
103
- async ports() { throw new Error('port discovery requires the light guest backing'); },
104
- async files() { throw new Error('file access requires the light guest backing'); },
75
+ async ports() { throw new Error('port discovery is unavailable'); },
76
+ async files() { throw new Error('file access is unavailable'); },
105
77
  async run(spec) {
106
78
  refuseRequiredMachineTransport(process.env, 'Docker machine application transport');
107
79
  const name = containerName(world, spec.name);
@@ -187,8 +159,8 @@ function dockerPool(world: string): MachinePool {
187
159
  }
188
160
 
189
161
  /** The pool of a kind, for a World (`world`: the mark its containers carry). */
190
- export function machinePool(kind: MachinePool['kind'], world = 'world', httpReplies: readonly MachineHttpReply[] = [], light: LightPoolOptions = {}, root?: string): MachinePool {
191
- if (kind === 'light') return lightPool(world, root, light);
162
+ export function machinePool(kind: MachinePool['kind'], world = 'world', httpReplies: readonly MachineHttpReply[] = []): MachinePool {
163
+ if (!POOL_KINDS.includes(kind)) throw new Error(`kind must be one of ${POOL_KINDS.join(', ')}`);
192
164
  if (kind === 'docker') return dockerPool(world);
193
165
  if (kind === 'fake') {
194
166
  const asked: NonNullable<MachinePool['asked']> = [];
@@ -237,14 +209,14 @@ export function machinePool(kind: MachinePool['kind'], world = 'world', httpRepl
237
209
 
238
210
  /** A vendor's pool in a World, one per World and kind for the life of the process (a fake pool keeps what it was asked). */
239
211
  const pools = new Map<string, MachinePool>();
240
- export function poolFor(service: string, root: string | undefined, kind: MachinePool['kind'], httpReplies: readonly MachineHttpReply[] = [], light: LightPoolOptions = {}): MachinePool {
241
- const key = `${service}\0${root ?? ''}\0${kind}${kind === 'light' ? JSON.stringify(light) : ''}`;
212
+ export function poolFor(service: string, root: string | undefined, kind: MachinePool['kind'], httpReplies: readonly MachineHttpReply[] = []): MachinePool {
213
+ const key = `${service}\0${root ?? ''}\0${kind}`;
242
214
  let pool = pools.get(key);
243
215
  if (!pool) {
244
216
  // the World's mark: a short digest of the vendor and the World's tree, so two Worlds' machines never share a name
245
217
  let h = 0;
246
218
  for (const ch of `${service}:${root ?? ''}`) h = (Math.imul(h, 31) + ch.charCodeAt(0)) >>> 0;
247
- pool = machinePool(kind, h.toString(16).padStart(8, '0'), httpReplies, light, root);
219
+ pool = machinePool(kind, h.toString(16).padStart(8, '0'), httpReplies);
248
220
  pools.set(key, pool);
249
221
  }
250
222
  return pool;
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,18 @@ 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?: {
225
+ path: string;
226
+ body: Record<string, unknown>;
227
+ fill: Record<string, string>;
228
+ account?: { field: string } & ({ tenancy?: never; source?: never } | { tenancy: string; source: string });
229
+ deployment?: { field: string; object: string; source: string; organizationOutputs?: string[] };
230
+ };
225
231
  /** Another pack of this vendor issues the API's credentials; architecture, descriptor World wiring. */
226
232
  credentialIssuer?: string;
227
233
  prismaAdapter?: { adapter: string; export: string; urlEnv: string };
228
234
  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 };
235
+ managedDatabase?: { kind: 'postgres'; arg: string; user: string; database: string; clock: 'wall'; credentialField: string; note: string };
230
236
  sidePorts?: Array<{ name: string; option: string; flag: string; path?: string; scheme?: string; note: string }>;
231
237
  stateSystem?: StateSystemAdapters;
232
238
  /** 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`