@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/dist/generated/pack-facts.json +84 -5
- package/dist/inject.cjs +2 -2
- package/dist/src/derived-core.d.ts +44 -3
- package/dist/src/derived-core.js +231 -62
- 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 +19 -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 +248 -70
- package/src/index.ts +5 -2
- package/src/machines.ts +10 -38
- package/src/pack-fetch.ts +27 -5
- package/src/packRegistry.ts +8 -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/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.
|
|
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'
|
|
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'
|
|
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
|
|
104
|
-
async files() { throw new Error('file access
|
|
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[] = []
|
|
191
|
-
if (kind
|
|
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[] = []
|
|
241
|
-
const key = `${service}\0${root ?? ''}\0${kind}
|
|
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
|
|
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:
|
|
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,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?: {
|
|
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
|
+
}
|
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`
|