toiljs 0.0.55 → 0.0.57

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 (99) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +72 -14
  3. package/build/backend/.tsbuildinfo +1 -1
  4. package/build/cli/.tsbuildinfo +1 -1
  5. package/build/cli/index.js +293 -142
  6. package/build/client/.tsbuildinfo +1 -1
  7. package/build/client/auth.js +1 -1
  8. package/build/client/components/Image.d.ts +1 -1
  9. package/build/client/dev/devtools.js +4 -2
  10. package/build/client/index.d.ts +2 -2
  11. package/build/client/index.js +2 -2
  12. package/build/client/routing/Router.js +1 -1
  13. package/build/client/routing/hooks.js +2 -2
  14. package/build/client/routing/mount.js +1 -1
  15. package/build/compiler/.tsbuildinfo +1 -1
  16. package/build/compiler/docs.js +1 -1
  17. package/build/compiler/seo.js +1 -3
  18. package/build/compiler/template-build.d.ts +5 -2
  19. package/build/compiler/template-build.js +19 -7
  20. package/build/devserver/.tsbuildinfo +1 -1
  21. package/build/devserver/cache.js +0 -0
  22. package/build/devserver/crypto.js +45 -17
  23. package/build/devserver/database.d.ts +1 -1
  24. package/build/devserver/database.js +84 -0
  25. package/build/devserver/email/caps.js +0 -0
  26. package/build/devserver/email/config.js +7 -2
  27. package/build/devserver/email/validate.js +1 -4
  28. package/build/devserver/host.js +18 -1
  29. package/build/devserver/index.d.ts +1 -1
  30. package/build/devserver/index.js +3 -2
  31. package/build/devserver/module.js +51 -12
  32. package/build/devserver/proxy.js +2 -1
  33. package/build/io/.tsbuildinfo +1 -1
  34. package/build/io/codec.d.ts +5 -5
  35. package/build/io/codec.js +193 -77
  36. package/examples/basic/client/components/HoneycombBackground.tsx +1 -1
  37. package/examples/basic/client/public/images/logo.svg +37 -34
  38. package/examples/basic/client/public/index.html +14 -14
  39. package/examples/basic/client/routes/auth.tsx +18 -10
  40. package/examples/basic/client/routes/cookies.tsx +15 -24
  41. package/examples/basic/client/routes/crypto.tsx +4 -5
  42. package/examples/basic/client/routes/features/template/template.tsx +1 -1
  43. package/examples/basic/client/routes/hello.tsx +1 -1
  44. package/examples/basic/client/routes/pq.tsx +14 -14
  45. package/examples/basic/client/routes/rest.tsx +1 -3
  46. package/examples/basic/client/styles/main.css +25 -22
  47. package/examples/basic/client/toil.tsx +1 -1
  48. package/examples/basic/server/README.md +8 -8
  49. package/examples/basic/server/core/AppHandler.ts +4 -7
  50. package/examples/basic/server/routes/Auth.ts +13 -10
  51. package/examples/basic/server/routes/EnvDemo.ts +9 -3
  52. package/examples/basic/server/routes/Guestbook.ts +2 -4
  53. package/package.json +26 -26
  54. package/src/backend/index.ts +4 -2
  55. package/src/cli/create.ts +19 -4
  56. package/src/cli/diagnostics.ts +48 -0
  57. package/src/cli/doctor.ts +155 -9
  58. package/src/cli/notify.ts +1 -6
  59. package/src/cli/ui.ts +3 -3
  60. package/src/cli/version-check.ts +5 -1
  61. package/src/client/auth.ts +33 -10
  62. package/src/client/components/Form.tsx +2 -2
  63. package/src/client/components/Image.tsx +1 -1
  64. package/src/client/components/Script.tsx +1 -1
  65. package/src/client/components/Slot.tsx +1 -1
  66. package/src/client/dev/devtools.tsx +126 -55
  67. package/src/client/dev/error-overlay.tsx +7 -1
  68. package/src/client/head/metadata.ts +1 -1
  69. package/src/client/index.ts +13 -2
  70. package/src/client/routing/Router.tsx +2 -2
  71. package/src/client/routing/error-boundary.tsx +1 -1
  72. package/src/client/routing/hooks.ts +5 -3
  73. package/src/client/routing/loader.ts +2 -2
  74. package/src/client/routing/mount.tsx +5 -6
  75. package/src/compiler/docs.ts +1 -1
  76. package/src/compiler/email-preview.ts +1 -1
  77. package/src/compiler/generate.ts +1 -1
  78. package/src/compiler/seo.ts +1 -3
  79. package/src/compiler/ssg.ts +10 -4
  80. package/src/compiler/template-build.ts +43 -11
  81. package/src/compiler/template.ts +1 -4
  82. package/src/compiler/vite.ts +1 -1
  83. package/src/devserver/cache.ts +0 -0
  84. package/src/devserver/crypto.ts +140 -51
  85. package/src/devserver/database.ts +168 -9
  86. package/src/devserver/dotenv.ts +10 -2
  87. package/src/devserver/email/caps.ts +0 -0
  88. package/src/devserver/email/config.ts +8 -2
  89. package/src/devserver/email/index.ts +3 -3
  90. package/src/devserver/email/validate.ts +1 -4
  91. package/src/devserver/envelope.ts +3 -3
  92. package/src/devserver/host.ts +46 -6
  93. package/src/devserver/index.ts +15 -6
  94. package/src/devserver/module.ts +56 -14
  95. package/src/devserver/proxy.ts +5 -7
  96. package/src/io/codec.ts +226 -83
  97. package/test/devserver-database.test.ts +60 -0
  98. package/test/devserver-secrets.test.ts +59 -0
  99. package/test/doctor.test.ts +30 -0
@@ -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;
@@ -79,10 +110,15 @@ function collOf(db: DbDevState, handle: number): string | null {
79
110
  export function buildDatabaseImports(
80
111
  ref: MemoryRef,
81
112
  db: DbDevState,
82
- ): Record<string, (...args: number[]) => number> {
113
+ ): Record<string, (...args: number[]) => number | bigint> {
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;
@@ -446,6 +586,24 @@ export function buildDatabaseImports(
446
586
  db.lastResult = null;
447
587
  return v.length;
448
588
  },
589
+
590
+ // `data.result_schema_version() -> i64`: the schema version the last
591
+ // value-returning read's row was written under, so the guest decoder can
592
+ // default new fields / reject an unknown layout. The production edge
593
+ // surfaces the real per-row version; this single-process, single-version
594
+ // dev store has no historical versions (data is always the current
595
+ // layout), so it returns -1 ("no version tracked"), which the decoder
596
+ // treats as "decode with the current layout". An i64 result returns a
597
+ // BigInt in Node's WASM ABI. (Per-row versions in dev would need catalog
598
+ // decoding; a follow-up if dev must exercise cross-version decode.)
599
+ 'data.result_schema_version': (): bigint => -1n,
600
+
601
+ // `data.write_allowed() -> i32`: 1 if the current call may write. Used by
602
+ // the rewrite-on-read convergence after a lazy migration. The dev store is
603
+ // single-version, so result_schema_version always returns -1 and no
604
+ // migration dispatch ever fires - the convergence write is never reached
605
+ // here regardless. Returns 1 (the dev store permits writes) for parity.
606
+ 'data.write_allowed': (): number => 1,
449
607
  };
450
608
  }
451
609
 
@@ -456,4 +614,5 @@ export function __resetDbForTests(): void {
456
614
  MEMBERS.clear();
457
615
  COUNTERS.clear();
458
616
  EVENTS.clear();
617
+ CAPACITY.clear();
459
618
  }
@@ -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
@@ -94,7 +94,10 @@ export function resolveEmailConfig(
94
94
  } else if (providerId === 'gmail' || providerId === 'smtp') {
95
95
  provider = 'smtp';
96
96
  const isGmail = providerId === 'gmail';
97
- const host = envOf(reserved, 'SMTP_HOST') ?? c.smtp?.host?.trim() ?? (isGmail ? 'smtp.gmail.com' : '');
97
+ const host =
98
+ envOf(reserved, 'SMTP_HOST') ??
99
+ c.smtp?.host?.trim() ??
100
+ (isGmail ? 'smtp.gmail.com' : '');
98
101
  if (!host) {
99
102
  return { config: null, warning: 'provider `smtp` requires TOIL_EMAIL_SMTP_HOST' };
100
103
  }
@@ -102,7 +105,10 @@ export function resolveEmailConfig(
102
105
  const user = envOf(reserved, 'SMTP_USER') ?? c.smtp?.user?.trim() ?? from;
103
106
  smtp = { host, port, user };
104
107
  } else {
105
- return { config: null, warning: `unknown email provider "${providerId}" (resend|gmail|smtp)` };
108
+ return {
109
+ config: null,
110
+ warning: `unknown email provider "${providerId}" (resend|gmail|smtp)`,
111
+ };
106
112
  }
107
113
 
108
114
  return {
@@ -11,11 +11,11 @@
11
11
  */
12
12
  import { loadEnvFiles } from '../dotenv.js';
13
13
  import { EmailCaps } from './caps.js';
14
- import { resolveEmailConfig, type ResolvedEmailConfig } from './config.js';
15
- import { sendVia, type OutboundMessage } from './providers.js';
14
+ import { type ResolvedEmailConfig, resolveEmailConfig } from './config.js';
15
+ import { type OutboundMessage, sendVia } from './providers.js';
16
16
  import { EmailStatus } from './status.js';
17
17
  import { validRecipient } from './validate.js';
18
- import { parseEmailBlob, type ParsedEmail } from './wire.js';
18
+ import { type ParsedEmail, parseEmailBlob } from './wire.js';
19
19
 
20
20
  import type { EmailBackendConfig } from 'toiljs/shared';
21
21
 
@@ -17,10 +17,7 @@ export function validRecipient(s: string): boolean {
17
17
  if (parts.length !== 2) return false; // not exactly one '@'
18
18
  const [local, domain] = parts;
19
19
  return (
20
- local.length > 0 &&
21
- domain.includes('.') &&
22
- !domain.startsWith('.') &&
23
- !domain.endsWith('.')
20
+ local.length > 0 && domain.includes('.') && !domain.startsWith('.') && !domain.endsWith('.')
24
21
  );
25
22
  }
26
23
 
@@ -69,15 +69,15 @@ export function encodeRequestEnvelope(req: EnvelopeRequest): Buffer {
69
69
  if (path.length > U16_MAX) throw new Error(`path too long: ${String(path.length)} bytes`);
70
70
  if (req.headers.length > U16_MAX)
71
71
  throw new Error(`too many headers: ${String(req.headers.length)}`);
72
- if (req.body.length > U32_MAX) throw new Error(`body too long: ${String(req.body.length)} bytes`);
72
+ if (req.body.length > U32_MAX)
73
+ throw new Error(`body too long: ${String(req.body.length)} bytes`);
73
74
 
74
75
  const headers: { name: Buffer; value: Buffer }[] = [];
75
76
  let headersSize = 0;
76
77
  for (const [name, value] of req.headers) {
77
78
  const n = Buffer.from(name, 'utf8');
78
79
  const v = Buffer.from(value, 'utf8');
79
- if (n.length > U16_MAX || v.length > U16_MAX)
80
- throw new Error(`header too long: ${name}`);
80
+ if (n.length > U16_MAX || v.length > U16_MAX) throw new Error(`header too long: ${name}`);
81
81
  headers.push({ name: n, value: v });
82
82
  headersSize += 4 + n.length + v.length;
83
83
  }
@@ -17,8 +17,8 @@
17
17
  * `WebAssembly.Instance`, so offering the full surface costs nothing.
18
18
  */
19
19
 
20
- import { buildCryptoImports, freshCryptoState, type CryptoState } from './crypto.js';
21
- import { buildDatabaseImports, freshDbState, type DbDevState } from './database.js';
20
+ import { buildCryptoImports, type CryptoState, freshCryptoState } from './crypto.js';
21
+ import { buildDatabaseImports, type DbDevState, freshDbState } from './database.js';
22
22
  import { EmailStatus, getEmailService } from './email/index.js';
23
23
  import { parseEmailBlob } from './email/wire.js';
24
24
  import { devEnvGet, devEnvGetSecure } from './env.js';
@@ -112,6 +112,34 @@ function readGuestString(ref: MemoryRef, ptr: number): string {
112
112
  return m.toString('utf16le', ptr, ptr + byteLen);
113
113
  }
114
114
 
115
+ /**
116
+ * Framework auth secrets that, when unset, SILENTLY fall back to a published,
117
+ * well-known dev default inside the guest (see `server/globals/auth.ts`). Reading
118
+ * one that is absent means the wasm is about to sign sessions / derive keys under
119
+ * a value anyone can read off npm, so we surface it. Harmless for local dev; a
120
+ * deployed node MUST set these out of band (`.env.secrets` / the dashboard).
121
+ */
122
+ const INSECURE_DEFAULT_SECRETS: Record<string, string> = {
123
+ AUTH_SESSION_SECRET:
124
+ 'session cookies will be signed with a PUBLISHED key, so anyone can forge one and skip login',
125
+ AUTH_OPRF_SEED: 'the password OPRF seed will be the published dev value',
126
+ AUTH_KEM_SK: 'the server ML-KEM secret key will be the published dev value',
127
+ };
128
+
129
+ /** Warned-once set, keyed by secret name, so a hot path cannot spam the log. */
130
+ const warnedInsecureSecrets = new Set<string>();
131
+
132
+ /** Warn (once per process) that an absent framework secret falls back to a public default. */
133
+ function warnInsecureSecretFallback(key: string): void {
134
+ if (warnedInsecureSecrets.has(key)) return;
135
+ warnedInsecureSecrets.add(key);
136
+ process.stdout.write(
137
+ ` ⚠ ${key} is not set: ${INSECURE_DEFAULT_SECRETS[key]}. ` +
138
+ `Fine for local dev, but a deployed node MUST set it in .env.secrets (or on your deploy target). ` +
139
+ `Generate one: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"\n`,
140
+ );
141
+ }
142
+
115
143
  /**
116
144
  * Resolve one `Environment.get`/`getSecure` lookup against the dev env source
117
145
  * and write it into the guest buffer, with the edge's return protocol: the value
@@ -128,7 +156,10 @@ function envLookup(
128
156
  ): number {
129
157
  const key = readBytes(ref, keyPtr, keyLen).toString('utf8');
130
158
  const val = secure ? devEnvGetSecure(key) : devEnvGet(key);
131
- if (val === null) return -2; // ABSENT
159
+ if (val === null) {
160
+ if (secure && key in INSECURE_DEFAULT_SECRETS) warnInsecureSecretFallback(key);
161
+ return -2; // ABSENT
162
+ }
132
163
  const bytes = Buffer.from(val, 'utf8');
133
164
  if (bytes.length > outCap) return -1; // TOO_SMALL
134
165
  const m = mem(ref);
@@ -158,7 +189,12 @@ export function buildHostImports(ref: MemoryRef, state: DispatchState): WebAssem
158
189
  state.status = code >= 100 && code <= 599 ? code : 500;
159
190
  },
160
191
 
161
- set_header: (namePtr: number, nameLen: number, valPtr: number, valLen: number): void => {
192
+ set_header: (
193
+ namePtr: number,
194
+ nameLen: number,
195
+ valPtr: number,
196
+ valLen: number,
197
+ ): void => {
162
198
  if (nameLen > MAX_HEADER_NAME_LEN)
163
199
  throw new Error(`header name too long: ${String(nameLen)} bytes`);
164
200
  if (valLen > MAX_HEADER_VALUE_LEN)
@@ -228,7 +264,9 @@ export function buildHostImports(ref: MemoryRef, state: DispatchState): WebAssem
228
264
  const svc = getEmailService();
229
265
  if (svc === null) {
230
266
  const to = parseEmailBlob(raw)?.to ?? '<unparsed>';
231
- process.stdout.write(` ✉ dev email_send -> ${to} (no email config; not sent)\n`);
267
+ process.stdout.write(
268
+ ` ✉ dev email_send -> ${to} (no email config; not sent)\n`,
269
+ );
232
270
  return EmailStatus.Sent;
233
271
  }
234
272
  const { status, parsed } = svc.prepare(raw);
@@ -243,7 +281,9 @@ export function buildHostImports(ref: MemoryRef, state: DispatchState): WebAssem
243
281
  process.stdout.write(` ✉ dev email_send -> ${parsed.to} (${label})\n`);
244
282
  })
245
283
  .catch((e: unknown) => {
246
- process.stdout.write(` ✉ dev email_send -> ${parsed.to} (error: ${String(e)})\n`);
284
+ process.stdout.write(
285
+ ` ✉ dev email_send -> ${parsed.to} (error: ${String(e)})\n`,
286
+ );
247
287
  });
248
288
  return EmailStatus.Sent; // optimistic; sync wasm can't await the send
249
289
  },
@@ -22,18 +22,23 @@
22
22
  import fs from 'node:fs';
23
23
  import path from 'node:path';
24
24
 
25
- import { Server, type Request, type Response } from '@dacely/hyper-express';
25
+ import { type Request, type Response, Server } from '@dacely/hyper-express';
26
26
  import pc from 'picocolors';
27
27
 
28
28
  import type { EmailBackendConfig } from 'toiljs/shared';
29
29
 
30
30
  import { applyCacheRule, lookupCache } from './cache.js';
31
31
  import { initEmailService } from './email/index.js';
32
- import { METHOD_CODES, type EnvelopeRequest } from './envelope.js';
32
+ import { type EnvelopeRequest, METHOD_CODES } from './envelope.js';
33
33
  import { WasmServerModule } from './module.js';
34
- import { proxyToVite, wireWebsocketProxy, type ViteTarget } from './proxy.js';
34
+ import { proxyToVite, type ViteTarget, wireWebsocketProxy } from './proxy.js';
35
35
 
36
- export { METHOD_CODES, encodeRequestEnvelope, decodeResponseEnvelope, unpackHandleResult } from './envelope.js';
36
+ export {
37
+ METHOD_CODES,
38
+ encodeRequestEnvelope,
39
+ decodeResponseEnvelope,
40
+ unpackHandleResult,
41
+ } from './envelope.js';
37
42
  export type { EnvelopeRequest, EnvelopeResponse } from './envelope.js';
38
43
  export { WasmServerModule, WasmAbortError, UNHANDLED_HEADER } from './module.js';
39
44
  export type { WasmDispatchResult } from './module.js';
@@ -161,7 +166,10 @@ function sendWasmResponse(
161
166
  if (!hasContentType) {
162
167
  // The edge defaults file bodies to application/octet-stream; in dev we
163
168
  // guess from the extension so a guest-served asset renders in the browser.
164
- response.header('content-type', MIME[path.extname(file).toLowerCase()] ?? 'application/octet-stream');
169
+ response.header(
170
+ 'content-type',
171
+ MIME[path.extname(file).toLowerCase()] ?? 'application/octet-stream',
172
+ );
165
173
  }
166
174
  response.sendFile(file);
167
175
  return;
@@ -267,7 +275,8 @@ export async function startDevServer(options: DevServerOptions): Promise<Running
267
275
  // A trap (ToilScript abort, OOB, malformed envelope) is isolated to
268
276
  // this request, exactly like the edge poisoning one instance.
269
277
  process.stdout.write(
270
- pc.red(` ✗ ${request.method} ${request.path} server error: ${String(e)}`) + '\n',
278
+ pc.red(` ✗ ${request.method} ${request.path} server error: ${String(e)}`) +
279
+ '\n',
271
280
  );
272
281
  response.status(500).send('internal error\n');
273
282
  return;
@@ -16,8 +16,8 @@ import fs from 'node:fs';
16
16
  import {
17
17
  decodeResponseEnvelope,
18
18
  encodeRequestEnvelope,
19
- unpackHandleResult,
20
19
  type EnvelopeRequest,
20
+ unpackHandleResult,
21
21
  } from './envelope.js';
22
22
  import { buildHostImports, freshDispatchState, type MemoryRef } from './host.js';
23
23
 
@@ -52,21 +52,60 @@ interface HandleExports {
52
52
 
53
53
  /** Host functions the dev server provides under `env` (see `host.ts`). */
54
54
  const PROVIDED_IMPORTS = new Set([
55
- 'abort', 'set_status', 'set_header', 'respond_file', 'thread_spawn', 'Date.now',
56
- 'client_ip', 'ratelimit_check', 'email_send', 'env_get', 'env_get_secure',
55
+ 'abort',
56
+ 'set_status',
57
+ 'set_header',
58
+ 'respond_file',
59
+ 'thread_spawn',
60
+ 'Date.now',
61
+ 'client_ip',
62
+ 'ratelimit_check',
63
+ 'email_send',
64
+ 'env_get',
65
+ 'env_get_secure',
57
66
  // Web Crypto host functions (see ./crypto.ts).
58
- 'crypto.fill_random', 'crypto.random_uuid', 'crypto.take_result', 'crypto.digest',
59
- 'crypto.import_key', 'crypto.export_key', 'crypto.encrypt', 'crypto.decrypt',
60
- 'crypto.sign', 'crypto.verify', 'crypto.derive_bits',
61
- 'crypto.mldsa_verify', 'crypto.mlkem_decapsulate', 'crypto.voprf_evaluate',
67
+ 'crypto.fill_random',
68
+ 'crypto.random_uuid',
69
+ 'crypto.take_result',
70
+ 'crypto.digest',
71
+ 'crypto.import_key',
72
+ 'crypto.export_key',
73
+ 'crypto.encrypt',
74
+ 'crypto.decrypt',
75
+ 'crypto.sign',
76
+ 'crypto.verify',
77
+ 'crypto.derive_bits',
78
+ 'crypto.mldsa_verify',
79
+ 'crypto.mlkem_decapsulate',
80
+ 'crypto.voprf_evaluate',
62
81
  // ToilDB data API (see ./database.ts). Backed by ScyllaDB on the production
63
82
  // edge; backs the auth example's accounts + login challenges in dev.
64
- 'data.resolve_collection', 'data.get', 'data.get_many', 'data.exists', 'data.create',
65
- 'data.patch', 'data.delete', 'data.get_delete',
66
- 'data.unique_lookup', 'data.unique_claim', 'data.unique_release',
67
- 'data.view_get', 'data.view_publish',
68
- 'data.membership_contains', 'data.membership_add', 'data.membership_remove', 'data.membership_list',
69
- 'data.counter_get', 'data.counter_add', 'data.append', 'data.latest',
83
+ 'data.resolve_collection',
84
+ 'data.get',
85
+ 'data.get_many',
86
+ 'data.exists',
87
+ 'data.create',
88
+ 'data.patch',
89
+ 'data.delete',
90
+ 'data.get_delete',
91
+ 'data.unique_lookup',
92
+ 'data.unique_claim',
93
+ 'data.unique_release',
94
+ 'data.view_get',
95
+ 'data.view_publish',
96
+ 'data.membership_contains',
97
+ 'data.membership_add',
98
+ 'data.membership_remove',
99
+ 'data.membership_list',
100
+ 'data.counter_get',
101
+ 'data.counter_add',
102
+ 'data.append',
103
+ 'data.latest',
104
+ 'data.capacity_set_total',
105
+ 'data.capacity_available',
106
+ 'data.capacity_reserve',
107
+ 'data.capacity_confirm',
108
+ 'data.capacity_cancel',
70
109
  'data.take_result',
71
110
  ]);
72
111
 
@@ -204,7 +243,10 @@ export class WasmServerModule {
204
243
  /** Fail instantiation up front, with names, when the guest needs imports we do not provide. */
205
244
  private assertImportSurface(module: WebAssembly.Module): void {
206
245
  const missing = WebAssembly.Module.imports(module)
207
- .filter((i) => i.kind === 'function' && (i.module !== 'env' || !PROVIDED_IMPORTS.has(i.name)))
246
+ .filter(
247
+ (i) =>
248
+ i.kind === 'function' && (i.module !== 'env' || !PROVIDED_IMPORTS.has(i.name)),
249
+ )
208
250
  .map((i) => `${i.module}.${i.name}`);
209
251
  if (missing.length > 0)
210
252
  throw new Error(
@@ -6,12 +6,7 @@
6
6
  * websocket through Node's built-in `WebSocket` client, both loopback-only.
7
7
  */
8
8
 
9
- import {
10
- type Request,
11
- type Response,
12
- type Server,
13
- type Websocket,
14
- } from '@dacely/hyper-express';
9
+ import { type Request, type Response, type Server, type Websocket } from '@dacely/hyper-express';
15
10
 
16
11
  /** Where the internal Vite dev server listens (always loopback). */
17
12
  export interface ViteTarget {
@@ -151,7 +146,10 @@ export function wireWebsocketProxy(app: Server, target: ViteTarget): void {
151
146
  else pending.push(m);
152
147
  });
153
148
  ws.on('close', () => {
154
- if (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING) {
149
+ if (
150
+ upstream.readyState === WebSocket.OPEN ||
151
+ upstream.readyState === WebSocket.CONNECTING
152
+ ) {
155
153
  upstream.close();
156
154
  }
157
155
  });