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.
- package/CHANGELOG.md +10 -0
- package/README.md +72 -14
- package/build/backend/.tsbuildinfo +1 -1
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +293 -142
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/auth.js +1 -1
- package/build/client/components/Image.d.ts +1 -1
- package/build/client/dev/devtools.js +4 -2
- package/build/client/index.d.ts +2 -2
- package/build/client/index.js +2 -2
- package/build/client/routing/Router.js +1 -1
- package/build/client/routing/hooks.js +2 -2
- package/build/client/routing/mount.js +1 -1
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/docs.js +1 -1
- package/build/compiler/seo.js +1 -3
- package/build/compiler/template-build.d.ts +5 -2
- package/build/compiler/template-build.js +19 -7
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/cache.js +0 -0
- package/build/devserver/crypto.js +45 -17
- package/build/devserver/database.d.ts +1 -1
- package/build/devserver/database.js +84 -0
- package/build/devserver/email/caps.js +0 -0
- package/build/devserver/email/config.js +7 -2
- package/build/devserver/email/validate.js +1 -4
- package/build/devserver/host.js +18 -1
- package/build/devserver/index.d.ts +1 -1
- package/build/devserver/index.js +3 -2
- package/build/devserver/module.js +51 -12
- package/build/devserver/proxy.js +2 -1
- package/build/io/.tsbuildinfo +1 -1
- package/build/io/codec.d.ts +5 -5
- package/build/io/codec.js +193 -77
- package/examples/basic/client/components/HoneycombBackground.tsx +1 -1
- package/examples/basic/client/public/images/logo.svg +37 -34
- package/examples/basic/client/public/index.html +14 -14
- package/examples/basic/client/routes/auth.tsx +18 -10
- package/examples/basic/client/routes/cookies.tsx +15 -24
- package/examples/basic/client/routes/crypto.tsx +4 -5
- package/examples/basic/client/routes/features/template/template.tsx +1 -1
- package/examples/basic/client/routes/hello.tsx +1 -1
- package/examples/basic/client/routes/pq.tsx +14 -14
- package/examples/basic/client/routes/rest.tsx +1 -3
- package/examples/basic/client/styles/main.css +25 -22
- package/examples/basic/client/toil.tsx +1 -1
- package/examples/basic/server/README.md +8 -8
- package/examples/basic/server/core/AppHandler.ts +4 -7
- package/examples/basic/server/routes/Auth.ts +13 -10
- package/examples/basic/server/routes/EnvDemo.ts +9 -3
- package/examples/basic/server/routes/Guestbook.ts +2 -4
- package/package.json +26 -26
- package/src/backend/index.ts +4 -2
- package/src/cli/create.ts +19 -4
- package/src/cli/diagnostics.ts +48 -0
- package/src/cli/doctor.ts +155 -9
- package/src/cli/notify.ts +1 -6
- package/src/cli/ui.ts +3 -3
- package/src/cli/version-check.ts +5 -1
- package/src/client/auth.ts +33 -10
- package/src/client/components/Form.tsx +2 -2
- package/src/client/components/Image.tsx +1 -1
- package/src/client/components/Script.tsx +1 -1
- package/src/client/components/Slot.tsx +1 -1
- package/src/client/dev/devtools.tsx +126 -55
- package/src/client/dev/error-overlay.tsx +7 -1
- package/src/client/head/metadata.ts +1 -1
- package/src/client/index.ts +13 -2
- package/src/client/routing/Router.tsx +2 -2
- package/src/client/routing/error-boundary.tsx +1 -1
- package/src/client/routing/hooks.ts +5 -3
- package/src/client/routing/loader.ts +2 -2
- package/src/client/routing/mount.tsx +5 -6
- package/src/compiler/docs.ts +1 -1
- package/src/compiler/email-preview.ts +1 -1
- package/src/compiler/generate.ts +1 -1
- package/src/compiler/seo.ts +1 -3
- package/src/compiler/ssg.ts +10 -4
- package/src/compiler/template-build.ts +43 -11
- package/src/compiler/template.ts +1 -4
- package/src/compiler/vite.ts +1 -1
- package/src/devserver/cache.ts +0 -0
- package/src/devserver/crypto.ts +140 -51
- package/src/devserver/database.ts +168 -9
- package/src/devserver/dotenv.ts +10 -2
- package/src/devserver/email/caps.ts +0 -0
- package/src/devserver/email/config.ts +8 -2
- package/src/devserver/email/index.ts +3 -3
- package/src/devserver/email/validate.ts +1 -4
- package/src/devserver/envelope.ts +3 -3
- package/src/devserver/host.ts +46 -6
- package/src/devserver/index.ts +15 -6
- package/src/devserver/module.ts +56 -14
- package/src/devserver/proxy.ts +5 -7
- package/src/io/codec.ts +226 -83
- package/test/devserver-database.test.ts +60 -0
- package/test/devserver-secrets.test.ts +59 -0
- 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': (
|
|
85
|
-
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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': (
|
|
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
|
}
|
package/src/devserver/dotenv.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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 =
|
|
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 {
|
|
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 {
|
|
15
|
-
import {
|
|
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 {
|
|
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)
|
|
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
|
}
|
package/src/devserver/host.ts
CHANGED
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
* `WebAssembly.Instance`, so offering the full surface costs nothing.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { buildCryptoImports,
|
|
21
|
-
import { buildDatabaseImports,
|
|
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)
|
|
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: (
|
|
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(
|
|
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(
|
|
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
|
},
|
package/src/devserver/index.ts
CHANGED
|
@@ -22,18 +22,23 @@
|
|
|
22
22
|
import fs from 'node:fs';
|
|
23
23
|
import path from 'node:path';
|
|
24
24
|
|
|
25
|
-
import {
|
|
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 {
|
|
32
|
+
import { type EnvelopeRequest, METHOD_CODES } from './envelope.js';
|
|
33
33
|
import { WasmServerModule } from './module.js';
|
|
34
|
-
import { proxyToVite,
|
|
34
|
+
import { proxyToVite, type ViteTarget, wireWebsocketProxy } from './proxy.js';
|
|
35
35
|
|
|
36
|
-
export {
|
|
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(
|
|
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)}`) +
|
|
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;
|
package/src/devserver/module.ts
CHANGED
|
@@ -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',
|
|
56
|
-
'
|
|
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',
|
|
59
|
-
'crypto.
|
|
60
|
-
'crypto.
|
|
61
|
-
'crypto.
|
|
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',
|
|
65
|
-
'data.
|
|
66
|
-
'data.
|
|
67
|
-
'data.
|
|
68
|
-
'data.
|
|
69
|
-
'data.
|
|
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(
|
|
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(
|
package/src/devserver/proxy.ts
CHANGED
|
@@ -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 (
|
|
149
|
+
if (
|
|
150
|
+
upstream.readyState === WebSocket.OPEN ||
|
|
151
|
+
upstream.readyState === WebSocket.CONNECTING
|
|
152
|
+
) {
|
|
155
153
|
upstream.close();
|
|
156
154
|
}
|
|
157
155
|
});
|