toiljs 0.0.55 → 0.0.56

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.
Files changed (87) hide show
  1. package/build/backend/.tsbuildinfo +1 -1
  2. package/build/cli/.tsbuildinfo +1 -1
  3. package/build/cli/index.js +9 -5
  4. package/build/client/.tsbuildinfo +1 -1
  5. package/build/client/auth.js +1 -1
  6. package/build/client/components/Image.d.ts +1 -1
  7. package/build/client/dev/devtools.js +3 -1
  8. package/build/client/index.d.ts +2 -2
  9. package/build/client/index.js +2 -2
  10. package/build/client/routing/Router.js +1 -1
  11. package/build/client/routing/mount.js +1 -1
  12. package/build/compiler/.tsbuildinfo +1 -1
  13. package/build/compiler/docs.js +1 -1
  14. package/build/compiler/seo.js +1 -3
  15. package/build/compiler/template-build.js +1 -1
  16. package/build/devserver/.tsbuildinfo +1 -1
  17. package/build/devserver/cache.js +0 -0
  18. package/build/devserver/crypto.js +45 -17
  19. package/build/devserver/database.js +82 -0
  20. package/build/devserver/email/caps.js +0 -0
  21. package/build/devserver/email/config.js +7 -2
  22. package/build/devserver/email/validate.js +1 -4
  23. package/build/devserver/index.d.ts +1 -1
  24. package/build/devserver/index.js +3 -2
  25. package/build/devserver/module.js +51 -12
  26. package/build/devserver/proxy.js +2 -1
  27. package/build/io/.tsbuildinfo +1 -1
  28. package/build/io/codec.d.ts +5 -5
  29. package/build/io/codec.js +193 -77
  30. package/examples/basic/client/components/HoneycombBackground.tsx +1 -1
  31. package/examples/basic/client/public/images/logo.svg +37 -34
  32. package/examples/basic/client/public/index.html +14 -14
  33. package/examples/basic/client/routes/auth.tsx +18 -10
  34. package/examples/basic/client/routes/cookies.tsx +15 -24
  35. package/examples/basic/client/routes/crypto.tsx +4 -5
  36. package/examples/basic/client/routes/features/template/template.tsx +1 -1
  37. package/examples/basic/client/routes/hello.tsx +1 -1
  38. package/examples/basic/client/routes/pq.tsx +14 -14
  39. package/examples/basic/client/routes/rest.tsx +1 -3
  40. package/examples/basic/client/styles/main.css +25 -22
  41. package/examples/basic/client/toil.tsx +1 -1
  42. package/examples/basic/server/README.md +8 -8
  43. package/examples/basic/server/core/AppHandler.ts +4 -7
  44. package/examples/basic/server/routes/Auth.ts +11 -3
  45. package/examples/basic/server/routes/EnvDemo.ts +9 -3
  46. package/package.json +1 -1
  47. package/src/backend/index.ts +4 -2
  48. package/src/cli/doctor.ts +10 -3
  49. package/src/cli/notify.ts +1 -6
  50. package/src/cli/ui.ts +3 -3
  51. package/src/cli/version-check.ts +5 -1
  52. package/src/client/auth.ts +33 -10
  53. package/src/client/components/Form.tsx +2 -2
  54. package/src/client/components/Image.tsx +1 -1
  55. package/src/client/components/Script.tsx +1 -1
  56. package/src/client/components/Slot.tsx +1 -1
  57. package/src/client/dev/devtools.tsx +121 -54
  58. package/src/client/dev/error-overlay.tsx +7 -1
  59. package/src/client/head/metadata.ts +1 -1
  60. package/src/client/index.ts +13 -2
  61. package/src/client/routing/Router.tsx +2 -2
  62. package/src/client/routing/error-boundary.tsx +1 -1
  63. package/src/client/routing/loader.ts +2 -2
  64. package/src/client/routing/mount.tsx +5 -6
  65. package/src/compiler/docs.ts +1 -1
  66. package/src/compiler/email-preview.ts +1 -1
  67. package/src/compiler/generate.ts +1 -1
  68. package/src/compiler/seo.ts +1 -3
  69. package/src/compiler/ssg.ts +10 -4
  70. package/src/compiler/template-build.ts +2 -7
  71. package/src/compiler/template.ts +1 -4
  72. package/src/compiler/vite.ts +1 -1
  73. package/src/devserver/cache.ts +0 -0
  74. package/src/devserver/crypto.ts +140 -51
  75. package/src/devserver/database.ts +149 -8
  76. package/src/devserver/dotenv.ts +10 -2
  77. package/src/devserver/email/caps.ts +0 -0
  78. package/src/devserver/email/config.ts +8 -2
  79. package/src/devserver/email/index.ts +3 -3
  80. package/src/devserver/email/validate.ts +1 -4
  81. package/src/devserver/envelope.ts +3 -3
  82. package/src/devserver/host.ts +14 -5
  83. package/src/devserver/index.ts +15 -6
  84. package/src/devserver/module.ts +56 -14
  85. package/src/devserver/proxy.ts +5 -7
  86. package/src/io/codec.ts +226 -83
  87. package/test/devserver-database.test.ts +60 -0
@@ -16,7 +16,7 @@ import { createServer } from 'vite';
16
16
  import { type ResolvedToilConfig } from './config.js';
17
17
  import { extractStaticMetadata, loadTypeScript } from './prerender.js';
18
18
  import { scanRoutes } from './routes.js';
19
- import { injectSeoHtml, joinUrl, llmsTxt, routeSeo, sitemapXml, type LlmsPage } from './seo.js';
19
+ import { injectSeoHtml, joinUrl, type LlmsPage, llmsTxt, routeSeo, sitemapXml } from './seo.js';
20
20
  import { createViteConfig } from './vite.js';
21
21
 
22
22
  /** Reads a string field off a metadata record, or undefined. */
@@ -91,7 +91,9 @@ export async function prerenderStaticParams(cfg: ResolvedToilConfig): Promise<st
91
91
  try {
92
92
  mod = (await server.ssrLoadModule(route.file)) as RouteModule;
93
93
  } catch (err) {
94
- warn(`skipped ${route.pattern} (${err instanceof Error ? err.message : String(err)})`);
94
+ warn(
95
+ `skipped ${route.pattern} (${err instanceof Error ? err.message : String(err)})`,
96
+ );
95
97
  continue;
96
98
  }
97
99
  if (typeof mod.generateStaticParams !== 'function') continue;
@@ -116,12 +118,16 @@ export async function prerenderStaticParams(cfg: ResolvedToilConfig): Promise<st
116
118
  typeof mod.loader === 'function'
117
119
  ? await mod.loader({ params, searchParams })
118
120
  : undefined;
119
- metadata = asMetadata(await mod.generateMetadata({ params, searchParams, data }));
121
+ metadata = asMetadata(
122
+ await mod.generateMetadata({ params, searchParams, data }),
123
+ );
120
124
  } else if (mod.metadata) {
121
125
  metadata = asMetadata(mod.metadata);
122
126
  }
123
127
  } catch (err) {
124
- warn(`metadata failed for ${url} (${err instanceof Error ? err.message : String(err)})`);
128
+ warn(
129
+ `metadata failed for ${url} (${err instanceof Error ? err.message : String(err)})`,
130
+ );
125
131
  }
126
132
  const html = injectSeoHtml(shell, routeSeo(cfg.seo, metadata, url));
127
133
  fs.mkdirSync(path.dirname(target), { recursive: true });
@@ -19,12 +19,7 @@
19
19
  import fs from 'node:fs';
20
20
  import path from 'node:path';
21
21
 
22
- import {
23
- createElement,
24
- type ComponentType,
25
- type Context,
26
- type ReactNode,
27
- } from 'react';
22
+ import { type ComponentType, type Context, createElement, type ReactNode } from 'react';
28
23
  import { renderToStaticMarkup } from 'react-dom/server';
29
24
  import { createServer } from 'vite';
30
25
 
@@ -36,8 +31,8 @@ import {
36
31
  assignSlotIds,
37
32
  coherenceHash,
38
33
  encodeSlots,
39
- extractFromHtml,
40
34
  type Extracted,
35
+ extractFromHtml,
41
36
  } from './template.js';
42
37
  import { createViteConfig } from './vite.js';
43
38
 
@@ -249,10 +249,7 @@ export function reactEscapeHtml(s: string): string {
249
249
  * any tooling that needs to materialise a full page from a template + values).
250
250
  * `values` maps a byte offset to the bytes inserted there (offsets may repeat
251
251
  * is not allowed; pass them in `slots` order). */
252
- export function spliceTemplate(
253
- tmpl: Buffer,
254
- inserts: { offset: number; value: Buffer }[],
255
- ): Buffer {
252
+ export function spliceTemplate(tmpl: Buffer, inserts: { offset: number; value: Buffer }[]): Buffer {
256
253
  const parts: Buffer[] = [];
257
254
  let prev = 0;
258
255
  for (const ins of inserts) {
@@ -7,7 +7,7 @@ import pc from 'picocolors';
7
7
  import react from '@vitejs/plugin-react';
8
8
  import { imagetools } from 'vite-imagetools';
9
9
  import { nodePolyfills } from 'vite-plugin-node-polyfills';
10
- import { createLogger, mergeConfig, type InlineConfig, type Logger, type PluginOption } from 'vite';
10
+ import { createLogger, type InlineConfig, type Logger, mergeConfig, type PluginOption } from 'vite';
11
11
 
12
12
  import { type ResolvedToilConfig } from './config.js';
13
13
  import { fontPreloadPlugin } from './fonts.js';
Binary file
@@ -25,9 +25,24 @@ import type { MemoryRef } from './host.js';
25
25
 
26
26
  // --- ABI id tables (must match the std + Rust backend) ----------------------
27
27
  const ALG = {
28
- SHA1: 1, SHA256: 2, SHA384: 3, SHA512: 4, SHA3_256: 5, SHA3_384: 6, SHA3_512: 7,
29
- AES_GCM: 10, AES_CBC: 11, AES_CTR: 12, AES_KW: 13, HMAC: 20,
30
- ECDSA: 32, ED25519: 33, ECDH: 50, X25519: 51, HKDF: 52, PBKDF2: 53,
28
+ SHA1: 1,
29
+ SHA256: 2,
30
+ SHA384: 3,
31
+ SHA512: 4,
32
+ SHA3_256: 5,
33
+ SHA3_384: 6,
34
+ SHA3_512: 7,
35
+ AES_GCM: 10,
36
+ AES_CBC: 11,
37
+ AES_CTR: 12,
38
+ AES_KW: 13,
39
+ HMAC: 20,
40
+ ECDSA: 32,
41
+ ED25519: 33,
42
+ ECDH: 50,
43
+ X25519: 51,
44
+ HKDF: 52,
45
+ PBKDF2: 53,
31
46
  } as const;
32
47
  const FMT = { RAW: 0, PKCS8: 1, SPKI: 2, JWK: 3 } as const;
33
48
 
@@ -77,7 +92,9 @@ function readBytes(ref: MemoryRef, ptr: number, len: number): Buffer {
77
92
  function writeBytes(ref: MemoryRef, ptr: number, bytes: Buffer | Uint8Array): void {
78
93
  const m = memBuf(ref);
79
94
  if (ptr < 0 || ptr + bytes.length > m.length)
80
- throw new Error(`crypto write out of bounds: ptr=${String(ptr)} len=${String(bytes.length)}`);
95
+ throw new Error(
96
+ `crypto write out of bounds: ptr=${String(ptr)} len=${String(bytes.length)}`,
97
+ );
81
98
  m.set(bytes, ptr);
82
99
  }
83
100
 
@@ -85,25 +102,21 @@ function writeBytes(ref: MemoryRef, ptr: number, bytes: Buffer | Uint8Array): vo
85
102
  class ParamReader {
86
103
  private pos = 0;
87
104
  constructor(private readonly buf: Buffer) {}
88
- /** Bounds-check before a read so a malformed buffer throws a controlled
89
- * error (trap-equivalent, caught by the dispatcher) rather than a raw
90
- * Node RangeError. */
91
- private need(n: number): void {
92
- if (n < 0 || this.pos + n > this.buf.length)
93
- throw new Error('crypto: malformed params buffer (truncated)');
94
- }
105
+
95
106
  readI32(): number {
96
107
  this.need(4);
97
108
  const v = this.buf.readInt32LE(this.pos);
98
109
  this.pos += 4;
99
110
  return v;
100
111
  }
112
+
101
113
  readU32(): number {
102
114
  this.need(4);
103
115
  const v = this.buf.readUInt32LE(this.pos);
104
116
  this.pos += 4;
105
117
  return v;
106
118
  }
119
+
107
120
  readBlob(): Buffer {
108
121
  const n = this.readU32();
109
122
  this.need(n);
@@ -111,18 +124,34 @@ class ParamReader {
111
124
  this.pos += n;
112
125
  return s;
113
126
  }
127
+
128
+ /** Bounds-check before a read so a malformed buffer throws a controlled
129
+ * error (trap-equivalent, caught by the dispatcher) rather than a raw
130
+ * Node RangeError. */
131
+ private need(n: number): void {
132
+ if (n < 0 || this.pos + n > this.buf.length)
133
+ throw new Error('crypto: malformed params buffer (truncated)');
134
+ }
114
135
  }
115
136
 
116
137
  function hashName(id: number): string {
117
138
  switch (id) {
118
- case ALG.SHA1: return 'sha1';
119
- case ALG.SHA256: return 'sha256';
120
- case ALG.SHA384: return 'sha384';
121
- case ALG.SHA512: return 'sha512';
122
- case ALG.SHA3_256: return 'sha3-256';
123
- case ALG.SHA3_384: return 'sha3-384';
124
- case ALG.SHA3_512: return 'sha3-512';
125
- default: throw new Error(`crypto: bad hash id ${String(id)}`);
139
+ case ALG.SHA1:
140
+ return 'sha1';
141
+ case ALG.SHA256:
142
+ return 'sha256';
143
+ case ALG.SHA384:
144
+ return 'sha384';
145
+ case ALG.SHA512:
146
+ return 'sha512';
147
+ case ALG.SHA3_256:
148
+ return 'sha3-256';
149
+ case ALG.SHA3_384:
150
+ return 'sha3-384';
151
+ case ALG.SHA3_512:
152
+ return 'sha3-512';
153
+ default:
154
+ throw new Error(`crypto: bad hash id ${String(id)}`);
126
155
  }
127
156
  }
128
157
 
@@ -151,8 +180,7 @@ export function buildCryptoImports(
151
180
 
152
181
  'crypto.take_result': (outPtr: number, outLen: number): number => {
153
182
  const r = cs.lastResult;
154
- if (!r || r.length !== outLen)
155
- throw new Error('crypto.take_result: length mismatch');
183
+ if (!r || r.length !== outLen) throw new Error('crypto.take_result: length mismatch');
156
184
  writeBytes(ref, outPtr, r);
157
185
  cs.lastResult = null;
158
186
  return r.length;
@@ -191,9 +219,15 @@ export function buildCryptoImports(
191
219
  if (e.raw && format === FMT.RAW) return stash(cs, e.raw);
192
220
  if (e.keyObject) {
193
221
  if (format === FMT.PKCS8 && e.isPrivate)
194
- return stash(cs, e.keyObject.export({ format: 'der', type: 'pkcs8' }) as Buffer);
222
+ return stash(
223
+ cs,
224
+ e.keyObject.export({ format: 'der', type: 'pkcs8' }) as Buffer,
225
+ );
195
226
  if (format === FMT.SPKI && !e.isPrivate)
196
- return stash(cs, e.keyObject.export({ format: 'der', type: 'spki' }) as Buffer);
227
+ return stash(
228
+ cs,
229
+ e.keyObject.export({ format: 'der', type: 'spki' }) as Buffer,
230
+ );
197
231
  }
198
232
  return ERR_UNSUPPORTED;
199
233
  } catch {
@@ -210,7 +244,13 @@ export function buildCryptoImports(
210
244
  signOp(cs, ref, h, pp, pl, dp, dl),
211
245
 
212
246
  'crypto.verify': (
213
- h: number, pp: number, pl: number, sp: number, sl: number, dp: number, dl: number,
247
+ h: number,
248
+ pp: number,
249
+ pl: number,
250
+ sp: number,
251
+ sl: number,
252
+ dp: number,
253
+ dl: number,
214
254
  ): number => verifyOp(cs, ref, h, pp, pl, sp, sl, dp, dl),
215
255
 
216
256
  'crypto.derive_bits': (h: number, pp: number, pl: number, lengthBits: number): number =>
@@ -220,10 +260,14 @@ export function buildCryptoImports(
220
260
  // host (`mldsa_verify_import.rs`): same size asserts, 1/0/neg result.
221
261
  // Backed by the same noble lib the client signs with, so dev == prod.
222
262
  'crypto.mldsa_verify': (
223
- pkPtr: number, pkLen: number,
224
- msgPtr: number, msgLen: number,
225
- sigPtr: number, sigLen: number,
226
- ctxPtr: number, ctxLen: number,
263
+ pkPtr: number,
264
+ pkLen: number,
265
+ msgPtr: number,
266
+ msgLen: number,
267
+ sigPtr: number,
268
+ sigLen: number,
269
+ ctxPtr: number,
270
+ ctxLen: number,
227
271
  ): number => {
228
272
  if (pkLen !== 1312 || sigLen !== 2420 || ctxLen > 255) return -4;
229
273
  try {
@@ -243,8 +287,10 @@ export function buildCryptoImports(
243
287
  // the server's static secret key, write it to `outPtr`, return 0 / neg.
244
288
  // Backed by the same noble lib the client encapsulates with (dev == prod).
245
289
  'crypto.mlkem_decapsulate': (
246
- ctPtr: number, ctLen: number,
247
- skPtr: number, skLen: number,
290
+ ctPtr: number,
291
+ ctLen: number,
292
+ skPtr: number,
293
+ skLen: number,
248
294
  outPtr: number,
249
295
  ): number => {
250
296
  if (ctLen !== 1088 || skLen !== 2400) return -4;
@@ -267,9 +313,12 @@ export function buildCryptoImports(
267
313
  // `@noble/curves` ristretto255_oprf, which matches the edge byte-for-byte
268
314
  // (both RFC 9497), so dev == prod.
269
315
  'crypto.voprf_evaluate': (
270
- seedPtr: number, seedLen: number,
271
- infoPtr: number, infoLen: number,
272
- blindedPtr: number, blindedLen: number,
316
+ seedPtr: number,
317
+ seedLen: number,
318
+ infoPtr: number,
319
+ infoLen: number,
320
+ blindedPtr: number,
321
+ blindedLen: number,
273
322
  outPtr: number,
274
323
  ): number => {
275
324
  // seedLen MUST be exactly 32 (RFC 9497 Ns; noble deriveKeyPair rejects
@@ -309,8 +358,13 @@ function importKey(
309
358
  try {
310
359
  // Symmetric / MAC / KDF: raw bytes.
311
360
  if (
312
- alg === ALG.AES_GCM || alg === ALG.AES_CBC || alg === ALG.AES_CTR ||
313
- alg === ALG.AES_KW || alg === ALG.HMAC || alg === ALG.PBKDF2 || alg === ALG.HKDF
361
+ alg === ALG.AES_GCM ||
362
+ alg === ALG.AES_CBC ||
363
+ alg === ALG.AES_CTR ||
364
+ alg === ALG.AES_KW ||
365
+ alg === ALG.HMAC ||
366
+ alg === ALG.PBKDF2 ||
367
+ alg === ALG.HKDF
314
368
  ) {
315
369
  if (format !== FMT.RAW) return ERR_UNSUPPORTED;
316
370
  return newEntry({ raw: key, keyObject: null, alg, hash, isPrivate: false });
@@ -339,8 +393,14 @@ function aesAlgName(keyLen: number, mode: 'gcm' | 'cbc' | 'ctr'): string {
339
393
  }
340
394
 
341
395
  function aesOp(
342
- cs: CryptoState, ref: MemoryRef, encrypt: boolean,
343
- handle: number, pp: number, pl: number, dp: number, dl: number,
396
+ cs: CryptoState,
397
+ ref: MemoryRef,
398
+ encrypt: boolean,
399
+ handle: number,
400
+ pp: number,
401
+ pl: number,
402
+ dp: number,
403
+ dl: number,
344
404
  ): number {
345
405
  const e = cs.keys.get(handle);
346
406
  if (!e || !e.raw) throw new Error('crypto: invalid AES key handle');
@@ -356,7 +416,9 @@ function aesOp(
356
416
  if (tagBits !== 0 && tagBits !== 128) return ERR_INVALID_PARAMS;
357
417
  if (encrypt) {
358
418
  const c = nodeCrypto.createCipheriv(
359
- aesAlgName(e.raw.length, 'gcm'), e.raw, iv,
419
+ aesAlgName(e.raw.length, 'gcm'),
420
+ e.raw,
421
+ iv,
360
422
  ) as nodeCrypto.CipherGCM;
361
423
  if (aad.length) c.setAAD(aad);
362
424
  const ct = Buffer.concat([c.update(data), c.final()]);
@@ -366,7 +428,9 @@ function aesOp(
366
428
  // never authenticate.
367
429
  if (data.length < 16) return ERR_OPERATION_FAILED;
368
430
  const d = nodeCrypto.createDecipheriv(
369
- aesAlgName(e.raw.length, 'gcm'), e.raw, iv,
431
+ aesAlgName(e.raw.length, 'gcm'),
432
+ e.raw,
433
+ iv,
370
434
  ) as nodeCrypto.DecipherGCM;
371
435
  if (aad.length) d.setAAD(aad);
372
436
  const tag = data.subarray(data.length - 16);
@@ -395,8 +459,13 @@ function aesOp(
395
459
  }
396
460
 
397
461
  function signOp(
398
- cs: CryptoState, ref: MemoryRef,
399
- handle: number, pp: number, pl: number, dp: number, dl: number,
462
+ cs: CryptoState,
463
+ ref: MemoryRef,
464
+ handle: number,
465
+ pp: number,
466
+ pl: number,
467
+ dp: number,
468
+ dl: number,
400
469
  ): number {
401
470
  const e = cs.keys.get(handle);
402
471
  if (!e) throw new Error('crypto.sign: invalid handle');
@@ -426,8 +495,15 @@ function signOp(
426
495
  }
427
496
 
428
497
  function verifyOp(
429
- cs: CryptoState, ref: MemoryRef,
430
- handle: number, pp: number, pl: number, sp: number, sl: number, dp: number, dl: number,
498
+ cs: CryptoState,
499
+ ref: MemoryRef,
500
+ handle: number,
501
+ pp: number,
502
+ pl: number,
503
+ sp: number,
504
+ sl: number,
505
+ dp: number,
506
+ dl: number,
431
507
  ): number {
432
508
  const e = cs.keys.get(handle);
433
509
  if (!e) throw new Error('crypto.verify: invalid handle');
@@ -442,10 +518,15 @@ function verifyOp(
442
518
  return mac.length === sig.length && nodeCrypto.timingSafeEqual(mac, sig) ? 1 : 0;
443
519
  }
444
520
  if (e.alg === ALG.ECDSA && e.keyObject) {
445
- const ok = nodeCrypto.verify(hashName(hash), data, {
446
- key: e.keyObject,
447
- dsaEncoding: 'ieee-p1363',
448
- }, sig);
521
+ const ok = nodeCrypto.verify(
522
+ hashName(hash),
523
+ data,
524
+ {
525
+ key: e.keyObject,
526
+ dsaEncoding: 'ieee-p1363',
527
+ },
528
+ sig,
529
+ );
449
530
  return ok ? 1 : 0;
450
531
  }
451
532
  if (e.alg === ALG.ED25519 && e.keyObject) {
@@ -458,8 +539,12 @@ function verifyOp(
458
539
  }
459
540
 
460
541
  function deriveBitsOp(
461
- cs: CryptoState, ref: MemoryRef,
462
- handle: number, pp: number, pl: number, lengthBits: number,
542
+ cs: CryptoState,
543
+ ref: MemoryRef,
544
+ handle: number,
545
+ pp: number,
546
+ pl: number,
547
+ lengthBits: number,
463
548
  ): number {
464
549
  if (lengthBits < 0 || lengthBits % 8 !== 0) return ERR_INVALID_PARAMS;
465
550
  const outLen = lengthBits / 8;
@@ -473,7 +558,10 @@ function deriveBitsOp(
473
558
  if (alg === ALG.PBKDF2 && e.raw) {
474
559
  const iterations = pr.readU32();
475
560
  const salt = pr.readBlob();
476
- return stash(cs, nodeCrypto.pbkdf2Sync(e.raw, salt, iterations, outLen, hashName(hash)));
561
+ return stash(
562
+ cs,
563
+ nodeCrypto.pbkdf2Sync(e.raw, salt, iterations, outLen, hashName(hash)),
564
+ );
477
565
  }
478
566
  if (alg === ALG.HKDF && e.raw) {
479
567
  const salt = pr.readBlob();
@@ -484,7 +572,8 @@ function deriveBitsOp(
484
572
  if ((alg === ALG.ECDH || alg === ALG.X25519) && e.keyObject) {
485
573
  const peerHandle = pr.readI32();
486
574
  const peer = cs.keys.get(peerHandle);
487
- if (!peer || !peer.keyObject) throw new Error('crypto.derive_bits: invalid peer handle');
575
+ if (!peer || !peer.keyObject)
576
+ throw new Error('crypto.derive_bits: invalid peer handle');
488
577
  const shared = nodeCrypto.diffieHellman({
489
578
  privateKey: e.keyObject,
490
579
  publicKey: peer.keyObject,
@@ -27,6 +27,37 @@ const MEMBERS = new Map<string, Map<string, Buffer>>();
27
27
  const COUNTERS = new Map<string, bigint>();
28
28
  /** Events family: `"collection\0key"` -> append-ordered event blobs (oldest first). */
29
29
  const EVENTS = new Map<string, Buffer[]>();
30
+ /** Capacity family: `"collection\0key"` -> an escrow ledger (total/holds/confirmed). */
31
+ const CAPACITY = new Map<string, CapLedger>();
32
+
33
+ /** A finite-resource escrow: a ceiling, in-flight holds, and confirmed consumes. */
34
+ interface CapLedger {
35
+ total: bigint;
36
+ confirmed: bigint;
37
+ holds: Map<bigint, { amount: bigint; expiresMs: number }>;
38
+ nextId: bigint;
39
+ }
40
+
41
+ function capLedger(sk: string): CapLedger {
42
+ let l = CAPACITY.get(sk);
43
+ if (l === undefined) {
44
+ l = { total: 0n, confirmed: 0n, holds: new Map(), nextId: 1n };
45
+ CAPACITY.set(sk, l);
46
+ }
47
+ return l;
48
+ }
49
+
50
+ /** Drop holds whose TTL has elapsed (self-heal, mirrors the edge's now-based prune). */
51
+ function capPrune(l: CapLedger, nowMs: number): void {
52
+ for (const [id, h] of l.holds) if (h.expiresMs <= nowMs) l.holds.delete(id);
53
+ }
54
+
55
+ /** Units currently held (un-expired, unconfirmed). */
56
+ function capHeld(l: CapLedger): bigint {
57
+ let sum = 0n;
58
+ for (const h of l.holds.values()) sum += h.amount;
59
+ return sum;
60
+ }
30
61
 
31
62
  const MAX_NAME = 512;
32
63
  const MAX_KEY = 4096;
@@ -81,8 +112,13 @@ export function buildDatabaseImports(
81
112
  db: DbDevState,
82
113
  ): Record<string, (...args: number[]) => number> {
83
114
  return {
84
- 'data.resolve_collection': (namePtr: number, nameLen: number, outHandlePtr: number): number => {
85
- if (nameLen < 0 || nameLen > MAX_NAME) throw new Error('data: collection name too long');
115
+ 'data.resolve_collection': (
116
+ namePtr: number,
117
+ nameLen: number,
118
+ outHandlePtr: number,
119
+ ): number => {
120
+ if (nameLen < 0 || nameLen > MAX_NAME)
121
+ throw new Error('data: collection name too long');
86
122
  const name = readCopy(ref, namePtr, nameLen).toString('utf8');
87
123
  const handle = db.handles.length;
88
124
  db.handles.push(name);
@@ -153,7 +189,8 @@ export function buildDatabaseImports(
153
189
  ): number => {
154
190
  const coll = collOf(db, handle);
155
191
  if (coll === null) return INVALID_HANDLE;
156
- if (keyLen > MAX_KEY || valLen > MAX_VALUE) throw new Error('data: key/value too large');
192
+ if (keyLen > MAX_KEY || valLen > MAX_VALUE)
193
+ throw new Error('data: key/value too large');
157
194
  const sk = storeKey(coll, readCopy(ref, keyPtr, keyLen));
158
195
  if (STORE.has(sk)) return PRODUCT_ERR; // AlreadyExists
159
196
  STORE.set(sk, readCopy(ref, valPtr, valLen));
@@ -170,7 +207,8 @@ export function buildDatabaseImports(
170
207
  ): number => {
171
208
  const coll = collOf(db, handle);
172
209
  if (coll === null) return INVALID_HANDLE;
173
- if (keyLen > MAX_KEY || patchLen > MAX_VALUE) throw new Error('data: key/patch too large');
210
+ if (keyLen > MAX_KEY || patchLen > MAX_VALUE)
211
+ throw new Error('data: key/patch too large');
174
212
  const sk = storeKey(coll, readCopy(ref, keyPtr, keyLen));
175
213
  if (!STORE.has(sk)) return PRODUCT_ERR; // NotFound
176
214
  const v = readCopy(ref, patchPtr, patchLen);
@@ -230,7 +268,8 @@ export function buildDatabaseImports(
230
268
  ): number => {
231
269
  const coll = collOf(db, handle);
232
270
  if (coll === null) return INVALID_HANDLE;
233
- if (keyLen > MAX_KEY || valLen > MAX_VALUE) throw new Error('data: key/value too large');
271
+ if (keyLen > MAX_KEY || valLen > MAX_VALUE)
272
+ throw new Error('data: key/value too large');
234
273
  const sk = storeKey(coll, readCopy(ref, keyPtr, keyLen));
235
274
  const owner = readCopy(ref, valPtr, valLen);
236
275
  const existing = STORE.get(sk);
@@ -287,7 +326,8 @@ export function buildDatabaseImports(
287
326
  ): number => {
288
327
  const coll = collOf(db, handle);
289
328
  if (coll === null) return INVALID_HANDLE;
290
- if (setLen > MAX_KEY || memberLen > MAX_VALUE) throw new Error('data: set/member too large');
329
+ if (setLen > MAX_KEY || memberLen > MAX_VALUE)
330
+ throw new Error('data: set/member too large');
291
331
  const sk = storeKey(coll, readCopy(ref, setPtr, setLen));
292
332
  const member = readCopy(ref, memberPtr, memberLen);
293
333
  let set = MEMBERS.get(sk);
@@ -310,13 +350,19 @@ export function buildDatabaseImports(
310
350
  const coll = collOf(db, handle);
311
351
  if (coll === null) return INVALID_HANDLE;
312
352
  const set = MEMBERS.get(storeKey(coll, readCopy(ref, setPtr, setLen)));
313
- if (set !== undefined) set.delete(readCopy(ref, memberPtr, memberLen).toString('latin1'));
353
+ if (set !== undefined)
354
+ set.delete(readCopy(ref, memberPtr, memberLen).toString('latin1'));
314
355
  return 0;
315
356
  },
316
357
 
317
358
  // Frame the members (sorted by bytes, matching the edge BTreeMap) as
318
359
  // `u32 count` + per member `u32 len + bytes`; stash + return the length.
319
- 'data.membership_list': (handle: number, setPtr: number, setLen: number, limit: number): number => {
360
+ 'data.membership_list': (
361
+ handle: number,
362
+ setPtr: number,
363
+ setLen: number,
364
+ limit: number,
365
+ ): number => {
320
366
  const coll = collOf(db, handle);
321
367
  if (coll === null) return INVALID_HANDLE;
322
368
  const set = MEMBERS.get(storeKey(coll, readCopy(ref, setPtr, setLen)));
@@ -434,6 +480,100 @@ export function buildDatabaseImports(
434
480
  return out.length;
435
481
  },
436
482
 
483
+ // --- capacity family (escrow: set_total / available / reserve / confirm / cancel) ---
484
+
485
+ // Set the ceiling (restock / reduce). Job/derive only (kind-gated upstream).
486
+ // A ceiling is never negative.
487
+ 'data.capacity_set_total': (
488
+ handle: number,
489
+ keyPtr: number,
490
+ keyLen: number,
491
+ total: number | bigint,
492
+ _idemPtr: number,
493
+ ): number => {
494
+ const coll = collOf(db, handle);
495
+ if (coll === null) return INVALID_HANDLE;
496
+ const l = capLedger(storeKey(coll, readCopy(ref, keyPtr, keyLen)));
497
+ const t = BigInt(total);
498
+ l.total = satI64(t < 0n ? 0n : t);
499
+ return 0;
500
+ },
501
+
502
+ // Stash the i64 available (total - confirmed - active holds, floored at 0).
503
+ 'data.capacity_available': (handle: number, keyPtr: number, keyLen: number): number => {
504
+ const coll = collOf(db, handle);
505
+ if (coll === null) return INVALID_HANDLE;
506
+ const l = capLedger(storeKey(coll, readCopy(ref, keyPtr, keyLen)));
507
+ capPrune(l, Date.now());
508
+ const avail = l.total - l.confirmed - capHeld(l);
509
+ const out = Buffer.alloc(8);
510
+ out.writeBigInt64LE(avail < 0n ? 0n : avail);
511
+ db.lastResult = out;
512
+ return out.length;
513
+ },
514
+
515
+ // Hold `amount` for `ttlMs`: stash the u64 reservation id (8 bytes) on
516
+ // success, or return ABSENT (-2) when there is not enough available (the
517
+ // guest maps that to reservation 0 = no oversell). `now` is the HOST clock.
518
+ 'data.capacity_reserve': (
519
+ handle: number,
520
+ keyPtr: number,
521
+ keyLen: number,
522
+ amount: number | bigint,
523
+ ttlMs: number | bigint,
524
+ _idemPtr: number,
525
+ ): number => {
526
+ const coll = collOf(db, handle);
527
+ if (coll === null) return INVALID_HANDLE;
528
+ const want = BigInt(amount);
529
+ if (want <= 0n) return ABSENT;
530
+ const l = capLedger(storeKey(coll, readCopy(ref, keyPtr, keyLen)));
531
+ const now = Date.now();
532
+ capPrune(l, now);
533
+ if (l.total - l.confirmed - capHeld(l) < want) return ABSENT; // never oversell
534
+ const id = l.nextId++;
535
+ l.holds.set(id, { amount: want, expiresMs: now + Math.max(0, Number(ttlMs)) });
536
+ const out = Buffer.alloc(8);
537
+ out.writeBigUInt64LE(id);
538
+ db.lastResult = out;
539
+ return out.length;
540
+ },
541
+
542
+ // Finalize a hold into a permanent consume. 1 if the id was a live hold,
543
+ // 0 if it was unknown / expired / already settled.
544
+ 'data.capacity_confirm': (
545
+ handle: number,
546
+ keyPtr: number,
547
+ keyLen: number,
548
+ reservationId: number | bigint,
549
+ _idemPtr: number,
550
+ ): number => {
551
+ const coll = collOf(db, handle);
552
+ if (coll === null) return INVALID_HANDLE;
553
+ const l = capLedger(storeKey(coll, readCopy(ref, keyPtr, keyLen)));
554
+ capPrune(l, Date.now());
555
+ const h = l.holds.get(BigInt(reservationId));
556
+ if (h === undefined) return 0;
557
+ l.holds.delete(BigInt(reservationId));
558
+ l.confirmed = satI64(l.confirmed + h.amount);
559
+ return 1;
560
+ },
561
+
562
+ // Release a hold back to available (a confirmed sale cannot be cancelled).
563
+ 'data.capacity_cancel': (
564
+ handle: number,
565
+ keyPtr: number,
566
+ keyLen: number,
567
+ reservationId: number | bigint,
568
+ _idemPtr: number,
569
+ ): number => {
570
+ const coll = collOf(db, handle);
571
+ if (coll === null) return INVALID_HANDLE;
572
+ const l = capLedger(storeKey(coll, readCopy(ref, keyPtr, keyLen)));
573
+ capPrune(l, Date.now());
574
+ return l.holds.delete(BigInt(reservationId)) ? 1 : 0;
575
+ },
576
+
437
577
  // Drain the last stashed variable-length result into the caller buffer.
438
578
  'data.take_result': (outPtr: number, outCap: number): number => {
439
579
  const v = db.lastResult;
@@ -456,4 +596,5 @@ export function __resetDbForTests(): void {
456
596
  MEMBERS.clear();
457
597
  COUNTERS.clear();
458
598
  EVENTS.clear();
599
+ CAPACITY.clear();
459
600
  }
@@ -39,7 +39,11 @@ function parseValue(rest: string): string {
39
39
  * Parse dotenv text into `plain` (non-reserved) and `reserved` (`TOIL_*`):
40
40
  * `KEY=value`, `#` comments, optional `export`, optional surrounding quotes.
41
41
  */
42
- function parseDotenv(text: string, plain: Map<string, string>, reserved: Map<string, string>): void {
42
+ function parseDotenv(
43
+ text: string,
44
+ plain: Map<string, string>,
45
+ reserved: Map<string, string>,
46
+ ): void {
43
47
  for (const raw of text.split('\n')) {
44
48
  let line = raw.trim();
45
49
  if (line.length === 0 || line.startsWith('#')) continue;
@@ -53,7 +57,11 @@ function parseDotenv(text: string, plain: Map<string, string>, reserved: Map<str
53
57
  }
54
58
  }
55
59
 
56
- function readFileInto(file: string, plain: Map<string, string>, reserved: Map<string, string>): void {
60
+ function readFileInto(
61
+ file: string,
62
+ plain: Map<string, string>,
63
+ reserved: Map<string, string>,
64
+ ): void {
57
65
  try {
58
66
  parseDotenv(fs.readFileSync(file, 'utf8'), plain, reserved);
59
67
  } catch {
Binary file