@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/dist/generated/pack-facts.json +84 -5
- package/dist/inject.cjs +2 -2
- package/dist/src/derived-core.d.ts +9 -1
- package/dist/src/derived-core.js +120 -57
- package/dist/src/index.d.ts +4 -2
- package/dist/src/index.js +3 -1
- package/dist/src/machines.d.ts +4 -16
- package/dist/src/machines.js +10 -44
- package/dist/src/pack-fetch.js +29 -5
- package/dist/src/packRegistry.d.ts +7 -0
- package/dist/src/protobuf.d.ts +4 -0
- package/dist/src/protobuf.js +43 -2
- package/dist/src/request-scope.d.ts +2 -0
- package/dist/src/request-scope.js +9 -0
- package/dist/src/runtime.d.ts +6 -3
- package/dist/src/runtime.js +4 -1
- package/dist/src/serve-http.d.ts +11 -0
- package/dist/src/serve-http.js +132 -0
- package/dist/src/signing.d.ts +30 -7
- package/dist/src/signing.js +32 -16
- package/dist/src/sockets.d.ts +1 -1
- package/dist/src/sockets.js +11 -7
- package/dist/src/world-account-name.d.ts +2 -0
- package/dist/src/world-account-name.js +4 -0
- package/generated/pack-facts.json +84 -5
- package/inject.cjs +2 -2
- package/package.json +1 -1
- package/src/derived-core.ts +125 -63
- package/src/index.ts +5 -2
- package/src/machines.ts +10 -38
- package/src/pack-fetch.ts +27 -5
- package/src/packRegistry.ts +2 -2
- package/src/protobuf.ts +39 -2
- package/src/request-scope.ts +9 -0
- package/src/runtime.ts +8 -3
- package/src/serve-http.ts +111 -0
- package/src/signing.ts +38 -18
- package/src/sockets.ts +12 -9
- package/src/world-account-name.ts +4 -0
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:
|
|
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 } : {})
|
|
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
|
|
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
|
|
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
|
package/src/packRegistry.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/request-scope.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
78
|
-
if (decoded.header.alg !==
|
|
79
|
-
const
|
|
80
|
-
const
|
|
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
|
|
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 ?
|
|
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
|
|
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
|
|
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
|
|
109
|
-
decide: (asked: unknown) => Promise<ScenarioServed | 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
|
|
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
|
},
|