toiljs 0.0.88 → 0.0.89

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.
@@ -21,7 +21,7 @@ import { devEnvGet, devEnvGetSecure } from '../config/env.js';
21
21
  import { ratelimitCheck } from '../config/ratelimit.js';
22
22
  import { buildAnalyticsImports } from '../analytics/index.js';
23
23
  import { buildDatabaseImports, type DbDevState, freshDbState } from '../db/index.js';
24
- import { EmailStatus, getEmailService } from '../email/index.js';
24
+ import { EmailStatus, getEmailService, recordSentEmail } from '../email/index.js';
25
25
  import { parseEmailBlob } from '../email/wire.js';
26
26
  import { buildCryptoImports, type CryptoState, freshCryptoState } from './crypto.js';
27
27
 
@@ -190,6 +190,48 @@ function envLookup(
190
190
  return bytes.length;
191
191
  }
192
192
 
193
+ /**
194
+ * `env::email_send` / `env::email_send_detached` in dev: the FULL email pipeline
195
+ * (./email) — parse + recipient validation + dedup + per-min/day budget +
196
+ * per-recipient cap run SYNCHRONOUSLY (exact status: BadRecipient/Deduped/Budget/
197
+ * RecipientCapped), then the real provider send is FIRE-AND-FORGET (a sync wasm
198
+ * import can't await it), so the guest gets `Sent` optimistically and the true
199
+ * outcome is logged. Unconfigured email stays a log-only mock returning `Sent`.
200
+ *
201
+ * The dev path already fires-and-forgets, so the SUSPENDING `email_send` and the
202
+ * NON-SUSPENDING `email_send_detached` map to the exact same behaviour here (both
203
+ * return immediately). BOTH feed the `__sentEmails` capture seam, so the email
204
+ * confirm/reset tests find their tokens whichever import the guest used. Mirrors
205
+ * the edge's `email_send_import.rs`.
206
+ */
207
+ function devEmailSend(ref: MemoryRef, reqPtr: number, reqLen: number): number {
208
+ const raw = readBytes(ref, reqPtr, reqLen);
209
+ const svc = getEmailService();
210
+ if (svc === null) {
211
+ const parsed = parseEmailBlob(raw);
212
+ if (parsed !== null) recordSentEmail(parsed); // dev test seam
213
+ const to = parsed?.to ?? '<unparsed>';
214
+ process.stdout.write(` ✉ dev email_send -> ${to} (no email config; not sent)\n`);
215
+ return EmailStatus.Sent;
216
+ }
217
+ const { status, parsed } = svc.prepare(raw);
218
+ if (parsed === null) {
219
+ process.stdout.write(` ✉ dev email_send -> ${EmailStatus[status]}\n`);
220
+ return status;
221
+ }
222
+ recordSentEmail(parsed); // dev test seam
223
+ void svc
224
+ .deliver(parsed)
225
+ .then((s) => {
226
+ const label = s === EmailStatus.Sent ? 'sent' : EmailStatus[s];
227
+ process.stdout.write(` ✉ dev email_send -> ${parsed.to} (${label})\n`);
228
+ })
229
+ .catch((e: unknown) => {
230
+ process.stdout.write(` ✉ dev email_send -> ${parsed.to} (error: ${String(e)})\n`);
231
+ });
232
+ return EmailStatus.Sent; // optimistic; sync wasm can't await the send
233
+ }
234
+
193
235
  /**
194
236
  * The portion of the `env.*` request surface that is SHARED by the daemon (cold)
195
237
  * box: panic hook, `Environment.get`/`getSecure`, `email_send`, `thread_spawn`,
@@ -226,34 +268,13 @@ export function buildEnvImports(
226
268
  // `Date.now()` -> wall-clock milliseconds, matching the edge host.
227
269
  'Date.now': (): bigint => BigInt(Date.now()),
228
270
 
229
- // `env::email_send`: the FULL email pipeline in dev. A daemon may send
230
- // mail, so this stays in the shared subset (00 B2 / doc 08 AN-8).
231
- email_send: (reqPtr: number, reqLen: number): number => {
232
- const raw = readBytes(ref, reqPtr, reqLen);
233
- const svc = getEmailService();
234
- if (svc === null) {
235
- const to = parseEmailBlob(raw)?.to ?? '<unparsed>';
236
- process.stdout.write(` ✉ dev email_send -> ${to} (no email config; not sent)\n`);
237
- return EmailStatus.Sent;
238
- }
239
- const { status, parsed } = svc.prepare(raw);
240
- if (parsed === null) {
241
- process.stdout.write(` ✉ dev email_send -> ${EmailStatus[status]}\n`);
242
- return status;
243
- }
244
- void svc
245
- .deliver(parsed)
246
- .then((s) => {
247
- const label = s === EmailStatus.Sent ? 'sent' : EmailStatus[s];
248
- process.stdout.write(` ✉ dev email_send -> ${parsed.to} (${label})\n`);
249
- })
250
- .catch((e: unknown) => {
251
- process.stdout.write(
252
- ` ✉ dev email_send -> ${parsed.to} (error: ${String(e)})\n`,
253
- );
254
- });
255
- return EmailStatus.Sent; // optimistic; sync wasm can't await the send
256
- },
271
+ // `env::email_send` / `email_send_detached`: the FULL email pipeline in
272
+ // dev. A daemon may send mail, so this stays in the shared subset (00 B2 /
273
+ // doc 08 AN-8). Both map to the same fire-and-forget dev path.
274
+ email_send: (reqPtr: number, reqLen: number): number =>
275
+ devEmailSend(ref, reqPtr, reqLen),
276
+ email_send_detached: (reqPtr: number, reqLen: number): number =>
277
+ devEmailSend(ref, reqPtr, reqLen),
257
278
  };
258
279
  }
259
280
 
@@ -340,41 +361,14 @@ export function buildHostImports(ref: MemoryRef, state: DispatchState): WebAssem
340
361
  return d.allowed ? 1 : -Math.max(1, d.retryAfterSecs);
341
362
  },
342
363
 
343
- // `env::email_send`: the FULL email pipeline in dev (./email): parse +
344
- // recipient validation + dedup + per-min/day budget + per-recipient cap
345
- // run SYNCHRONOUSLY (exact status BadRecipient/Deduped/Budget/
346
- // RecipientCapped), then the real provider send is FIRE-AND-FORGET (a
347
- // sync wasm import can't await it), so the guest gets Sent optimistically
348
- // and the true outcome is logged. Unconfigured email stays a log-only
349
- // mock returning Sent. Mirrors the edge's `email_send_import.rs`.
350
- email_send: (reqPtr: number, reqLen: number): number => {
351
- const raw = readBytes(ref, reqPtr, reqLen);
352
- const svc = getEmailService();
353
- if (svc === null) {
354
- const to = parseEmailBlob(raw)?.to ?? '<unparsed>';
355
- process.stdout.write(
356
- ` ✉ dev email_send -> ${to} (no email config; not sent)\n`,
357
- );
358
- return EmailStatus.Sent;
359
- }
360
- const { status, parsed } = svc.prepare(raw);
361
- if (parsed === null) {
362
- process.stdout.write(` ✉ dev email_send -> ${EmailStatus[status]}\n`);
363
- return status;
364
- }
365
- void svc
366
- .deliver(parsed)
367
- .then((s) => {
368
- const label = s === EmailStatus.Sent ? 'sent' : EmailStatus[s];
369
- process.stdout.write(` ✉ dev email_send -> ${parsed.to} (${label})\n`);
370
- })
371
- .catch((e: unknown) => {
372
- process.stdout.write(
373
- ` ✉ dev email_send -> ${parsed.to} (error: ${String(e)})\n`,
374
- );
375
- });
376
- return EmailStatus.Sent; // optimistic; sync wasm can't await the send
377
- },
364
+ // `env::email_send` (suspending on the edge) / `email_send_detached`
365
+ // (non-suspending on the edge): the FULL dev email pipeline. Both map
366
+ // to the same fire-and-forget dev path (a sync wasm import can't await
367
+ // the provider send), and BOTH feed `__sentEmails`. See devEmailSend.
368
+ email_send: (reqPtr: number, reqLen: number): number =>
369
+ devEmailSend(ref, reqPtr, reqLen),
370
+ email_send_detached: (reqPtr: number, reqLen: number): number =>
371
+ devEmailSend(ref, reqPtr, reqLen),
378
372
 
379
373
  // `Environment.get` / `getSecure`: copy one tenant env value into the
380
374
  // guest buffer. Returns the byte length (0 = present-but-empty), -1 if
@@ -109,6 +109,7 @@ const PROVIDED_IMPORTS = new Set([
109
109
  'client_ip',
110
110
  'ratelimit_check',
111
111
  'email_send',
112
+ 'email_send_detached',
112
113
  'env_get',
113
114
  'env_get_secure',
114
115
  // Per-domain analytics (see ./analytics/index.ts).
@@ -26,25 +26,36 @@ describe('dev analytics stub v2 frame parity', () => {
26
26
  let p = 0;
27
27
  const u16 = () => { const v = buf.readUInt16LE(p); p += 2; return v; };
28
28
  const u32 = () => { const v = buf.readUInt32LE(p); p += 4; return v; };
29
- const i64 = () => { const v = buf.readBigInt64LE(p); p += 8; return v; };
30
29
  const u64 = () => { const v = buf.readBigUInt64LE(p); p += 8; return v; };
30
+ const f64 = () => { const v = buf.readDoubleLE(p); p += 8; return v; };
31
31
 
32
32
  expect(u16()).toBe(2); // FRAME_VERSION
33
33
  u64(); // now_ms
34
34
  const count = u32();
35
- expect(count).toBe(43); // METRIC_COUNTERS
35
+ expect(count).toBe(42); // METRIC_COUNTERS (edge N_COUNTERS; no WasmDispatches)
36
36
  const life: bigint[] = [];
37
- for (let i = 0; i < count; i++) life.push(i64());
38
- expect(life[0]).toBe(42n); // Requests
39
- expect(life[1]).toBe(12345n); // BytesOutL1
40
- expect(life[12]).toBe(1_000_000n); // GasUsed
41
- expect(life[41]).toBe(900n); // CacheHits
42
- expect(i64()).toBe(3n); // connectedStreams
43
- expect(i64()).toBe(65536n); // committedMemory
44
- expect(i64()).toBe(5n); // reqMinuteUsed
37
+ for (let i = 0; i < count; i++) life.push(u64()); // counters are u64 LE now
38
+ // Indices are the AUTHORITATIVE edge MetricId ids (metric_id.rs): a mislabel here means the dev
39
+ // dashboard shows the wrong metric for every id past StaticHits.
40
+ expect(life[0]).toBe(42n); // Requests (id 0)
41
+ expect(life[1]).toBe(12345n); // BytesOutL1 (id 1)
42
+ expect(life[11]).toBe(1_000_000n); // GasUsed (id 11, was 12 in the stale layout)
43
+ expect(life[40]).toBe(900n); // CacheHits (id 40, was 41)
44
+ expect(life[41]).toBe(100n); // CacheMisses (id 41, was 42)
45
+ expect(u64()).toBe(3n); // connectedStreams
46
+ expect(u64()).toBe(65536n); // committedMemory
47
+ expect(u64()).toBe(5n); // reqMinuteUsed
45
48
  expect(u64()).toBe(100n); // reqMinuteCap
46
- expect(i64()).toBe(42n); // reqDayUsed
49
+ expect(u64()).toBe(42n); // reqDayUsed
47
50
  expect(u64()).toBe(5000n); // reqDayCap
51
+ // 7 LIVE per-second rates (f64 LE), appended after the windows.
52
+ expect(f64()).toBeCloseTo(0.7); // rps
53
+ expect(f64()).toBeCloseTo(68.2); // bytesInPerSec
54
+ expect(f64()).toBeCloseTo(205.75); // bytesOutPerSec
55
+ expect(f64()).toBeCloseTo(34.1); // streamBytesInPerSec
56
+ expect(f64()).toBeCloseTo(136.5); // streamBytesOutPerSec
57
+ expect(f64()).toBeCloseTo(0.28); // dbOpsPerSec
58
+ expect(f64()).toBeCloseTo(16_666.67); // gasPerSec
48
59
  expect(p).toBe(buf.length); // no trailing bytes
49
60
  });
50
61
 
@@ -69,8 +80,19 @@ describe('dev analytics stub v2 frame parity', () => {
69
80
  expect(b2.readUInt32LE(4)).toBe(60); // bucketSecs
70
81
  expect(b2.readUInt32LE(16)).toBe(60); // count
71
82
 
72
- // A bad metric / range -> ABSENT (negative), no frame.
83
+ // range 8 (D60) + 9 (D90) -> DAY ring: 60/90 buckets, bucketSecs 86400.
84
+ imports.analytics_series(0, 0, 0, 8);
85
+ const b60 = db.lastResult as unknown as Buffer;
86
+ expect(b60.readUInt32LE(4)).toBe(86_400); // bucketSecs
87
+ expect(b60.readUInt32LE(16)).toBe(60); // count
88
+ imports.analytics_series(0, 0, 0, 9);
89
+ const b90 = db.lastResult as unknown as Buffer;
90
+ expect(b90.readUInt32LE(4)).toBe(86_400); // bucketSecs
91
+ expect(b90.readUInt32LE(16)).toBe(90); // count
92
+
93
+ // A bad metric / range -> ABSENT (negative), no frame. Range 10 is past D90.
73
94
  expect(imports.analytics_series(0, 0, 999, 3)).toBeLessThan(0);
74
95
  expect(imports.analytics_series(0, 0, 0, 99)).toBeLessThan(0);
96
+ expect(imports.analytics_series(0, 0, 0, 10)).toBeLessThan(0);
75
97
  });
76
98
  });
@@ -9,11 +9,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
9
9
 
10
10
  import { EmailCaps } from '../src/devserver/email/caps.js';
11
11
  import { resolveEmailConfig, type ResolvedEmailConfig } from '../src/devserver/email/config.js';
12
- import { NodeEmailService } from '../src/devserver/email/index.js';
12
+ import {
13
+ NodeEmailService,
14
+ __sentEmails,
15
+ __clearSentEmails,
16
+ resetEmailService,
17
+ } from '../src/devserver/email/index.js';
13
18
  import { sendResend } from '../src/devserver/email/providers.js';
14
19
  import { EmailStatus } from '../src/devserver/email/status.js';
15
20
  import { validFrom, validRecipient } from '../src/devserver/email/validate.js';
16
21
  import { parseEmailBlob } from '../src/devserver/email/wire.js';
22
+ import { buildHostImports, freshDispatchState, type MemoryRef } from '../src/devserver/runtime/host.js';
17
23
 
18
24
  /** Encode an `email_send` blob exactly like the guest (`server/globals/email.ts`). */
19
25
  function encodeBlob(to: string, subject: string, purpose: string, body: string, html: string): Buffer {
@@ -239,3 +245,44 @@ describe('NodeEmailService pipeline', () => {
239
245
  expect(fetchMock).toHaveBeenCalledTimes(1);
240
246
  });
241
247
  });
248
+
249
+ describe('host imports: email_send_detached (the anti-enumeration fire-and-forget path)', () => {
250
+ afterEach(() => {
251
+ resetEmailService();
252
+ __clearSentEmails();
253
+ });
254
+
255
+ /** Build a dev host import table over a fresh wasm memory holding `blob` at offset 0. */
256
+ function wireHost(blob: Buffer): {
257
+ env: Record<string, (...a: number[]) => number>;
258
+ len: number;
259
+ } {
260
+ const memory = new WebAssembly.Memory({ initial: 1 });
261
+ Buffer.from(memory.buffer).set(blob, 0);
262
+ const ref: MemoryRef = { memory };
263
+ const imports = buildHostImports(ref, freshDispatchState());
264
+ return { env: imports.env as Record<string, (...a: number[]) => number>, len: blob.length };
265
+ }
266
+
267
+ it('is registered and returns a queue-accept status immediately (no provider round-trip)', () => {
268
+ resetEmailService(); // unconfigured: log-only stub, still constant-time
269
+ __clearSentEmails();
270
+ const { env, len } = wireHost(encodeBlob('someone@x.com', 'Subj', 'verify', 'tok', '<b>x</b>'));
271
+ // The detached import exists (mirrors the edge's env.email_send_detached).
272
+ expect(typeof env.email_send_detached).toBe('function');
273
+ // It returns synchronously with a queue-accept status (Sent == accepted/queued).
274
+ expect(env.email_send_detached(0, len)).toBe(EmailStatus.Sent);
275
+ });
276
+
277
+ it('feeds the __sentEmails capture seam so confirm/reset tests still find the token', () => {
278
+ resetEmailService();
279
+ __clearSentEmails();
280
+ const link = 'https://app.example.com/reset#token=deadbeef';
281
+ const { env, len } = wireHost(encodeBlob('user@x.com', 'Reset', 'reset', link, ''));
282
+ env.email_send_detached(0, len);
283
+ const captured = __sentEmails.find((e) => e.to === 'user@x.com');
284
+ expect(captured).toBeTruthy();
285
+ expect(captured!.purpose).toBe('reset');
286
+ expect(captured!.text).toContain('token=deadbeef'); // the emailed token survives to the seam
287
+ });
288
+ });
@@ -21,6 +21,7 @@ import { ristretto255_oprf } from '@noble/curves/ed25519.js';
21
21
 
22
22
  import { WasmServerModule } from '../src/devserver/index.js';
23
23
  import { __resetDbForTests } from '../src/devserver/db/index.js';
24
+ import { __resetRatelimitForTests } from '../src/devserver/config/ratelimit.js';
24
25
  import { Auth } from '../src/client/auth.js';
25
26
  import { DataReader, DataWriter } from '../src/io/codec.js';
26
27
 
@@ -85,6 +86,9 @@ describe.skipIf(!haveWasm)('post-quantum auth end-to-end (client <-> example was
85
86
 
86
87
  beforeEach(() => {
87
88
  __resetDbForTests();
89
+ // The built-in controller decorates every route with `@ratelimit`; the dev limiter is a
90
+ // module-level singleton, so reset it per case to keep the many dispatches in this file isolated.
91
+ __resetRatelimitForTests();
88
92
  mod = loadModule();
89
93
  restoreFetch = installFetchShim(mod);
90
94
  });
@@ -93,7 +97,7 @@ describe.skipIf(!haveWasm)('post-quantum auth end-to-end (client <-> example was
93
97
  it(
94
98
  'registers then logs in (full OPRF + ML-DSA + ML-KEM mutual-auth chain)',
95
99
  async () => {
96
- await Auth.register('ada', 'correct horse battery staple');
100
+ await Auth.register('ada', 'correct horse battery staple', 'ada@example.com');
97
101
  // login resolves ONLY if the server's mutual-auth confirmation tag
98
102
  // verified against the client's own shared secret.
99
103
  const session = await Auth.login('ada', 'correct horse battery staple');
@@ -125,7 +129,7 @@ describe.skipIf(!haveWasm)('post-quantum auth end-to-end (client <-> example was
125
129
  it(
126
130
  'rejects a wrong password at login',
127
131
  async () => {
128
- await Auth.register('bob', 'hunter2-correct');
132
+ await Auth.register('bob', 'hunter2-correct', 'bob@example.com');
129
133
  await expect(Auth.login('bob', 'hunter2-WRONG')).rejects.toThrow(/login failed|request failed/);
130
134
  },
131
135
  60_000,
@@ -142,12 +146,13 @@ describe.skipIf(!haveWasm)('post-quantum auth end-to-end (client <-> example was
142
146
  it(
143
147
  'rejects a duplicate registration with a clear (not generic) message',
144
148
  async () => {
145
- await Auth.register('dupe', 'correct horse battery staple');
149
+ await Auth.register('dupe', 'correct horse battery staple', 'dupe@example.com');
146
150
  // Second registration of the same username must say so explicitly,
147
- // not return the generic "request failed".
148
- await expect(Auth.register('dupe', 'correct horse battery staple')).rejects.toThrow(
149
- /already registered/,
150
- );
151
+ // not return the generic "request failed". (The username-taken check fires
152
+ // before the email check, so it stays `already registered` even with the same email.)
153
+ await expect(
154
+ Auth.register('dupe', 'correct horse battery staple', 'dupe@example.com'),
155
+ ).rejects.toThrow(/already registered/);
151
156
  },
152
157
  60_000,
153
158
  );
@@ -155,7 +160,10 @@ describe.skipIf(!haveWasm)('post-quantum auth end-to-end (client <-> example was
155
160
 
156
161
  // Lower-level wire checks that don't need the heavy Argon2id derivation.
157
162
  describe.skipIf(!haveWasm)('post-quantum auth wire-level (anti-enumeration, replay)', () => {
158
- beforeEach(() => __resetDbForTests());
163
+ beforeEach(() => {
164
+ __resetDbForTests();
165
+ __resetRatelimitForTests();
166
+ });
159
167
 
160
168
  const oprf = ristretto255_oprf.oprf;
161
169
  const loginStart = (m: WasmServerModule, username: string) => {
@@ -193,7 +201,8 @@ describe.skipIf(!haveWasm)('post-quantum auth wire-level (anti-enumeration, repl
193
201
  expect(a.salt.length).toBe(16);
194
202
  expect(a.nonce.length).toBe(32);
195
203
  expect(a.evaluated.length).toBe(32);
196
- expect(a.aud).toBe('toil-demo');
204
+ // The built-in controller returns `TOIL_AUTH_AUDIENCE` or the literal `'toil'` (dev has no secret set).
205
+ expect(a.aud).toBe('toil');
197
206
  // The randomized fields DO differ (fresh challenge each call).
198
207
  expect(hex(a.cid)).not.toBe(hex(b.cid));
199
208
  });