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.
@@ -117,8 +117,8 @@ function __fromHex(s: string): Uint8Array {
117
117
  return out;
118
118
  }
119
119
 
120
- /** The session HMAC secret (UTF-8 of the env value or the DEV fallback). */
121
- function __resolveSessionSecret(): Uint8Array {
120
+ /** The BASE session HMAC secret (UTF-8 of the env value or the DEV fallback). */
121
+ function __resolveSessionSecretBase(): Uint8Array {
122
122
  let s = __sessionSecret;
123
123
  if (s != null) return s;
124
124
  const v = Environment.getSecure(ENV_SESSION_SECRET);
@@ -126,6 +126,48 @@ function __resolveSessionSecret(): Uint8Array {
126
126
  __sessionSecret = s;
127
127
  return s;
128
128
  }
129
+
130
+ /** A stable per-TENANT tag: the request Host the edge routes on, lowercased and
131
+ * port-stripped (matching the edge's `strip_port`), so it is identical for every
132
+ * request to one tenant and distinct across tenants. Used to bind a session to
133
+ * its tenant (below). */
134
+ function __sessionTenantTag(): string {
135
+ const req = Server.currentRequest;
136
+ if (req == null) return '';
137
+ const raw = req.header('host');
138
+ if (raw == null) return '';
139
+ let h = raw.toLowerCase();
140
+ if (h.startsWith('[')) {
141
+ // IPv6 literal `[addr]` optionally + `:port`: keep through the closing
142
+ // bracket, drop any port after it. A naive lastIndexOf(':') would slice
143
+ // inside the address (`[::1]` -> `[:`), collapsing distinct IPv6 tenants
144
+ // to the same tag. Bracket-aware, matching the edge's strip_port.
145
+ const close = h.indexOf(']');
146
+ if (close >= 0) h = h.slice(0, close + 1);
147
+ } else {
148
+ const colon = h.lastIndexOf(':');
149
+ if (colon > 0) h = h.slice(0, colon); // drop :port (mirrors edge strip_port)
150
+ }
151
+ return h;
152
+ }
153
+
154
+ /**
155
+ * The session HMAC key, BOUND TO THE TENANT. We fold the tenant tag into the base
156
+ * secret so a session sealed for tenant B does NOT verify on tenant A even if both
157
+ * resolve the SAME base secret (e.g. an unconfigured multi-tenant deployment
158
+ * sharing the published dev fallback). `mintSession` and `getSessionBytes` both
159
+ * call this within one request instance, so they derive the same key for the same
160
+ * tenant; a cross-tenant replay derives a different key and fails to open. This
161
+ * makes the base secret no longer the SOLE cross-tenant isolator — still set a
162
+ * distinct `AUTH_SESSION_SECRET` per deployment; this is defense-in-depth.
163
+ */
164
+ function __resolveSessionSecret(): Uint8Array {
165
+ const base = __resolveSessionSecretBase();
166
+ const tag = Uint8Array.wrap(
167
+ String.UTF8.encode('toil-session-tenant-bind-v1:' + __sessionTenantTag()),
168
+ );
169
+ return __hmacSha256(base, tag);
170
+ }
129
171
  /** The OPRF master seed, hashed to 32 bytes (RFC 9497 Ns) so any env value works. */
130
172
  function __resolveOprfSeed(): Uint8Array {
131
173
  let s = __oprfSeed;
@@ -24,6 +24,17 @@
24
24
  @external('env', 'email_send')
25
25
  declare function __toilEmailSend(reqPtr: usize, reqLen: i32): i32;
26
26
 
27
+ // Host import: submit one email FIRE-AND-FORGET and return immediately (does NOT
28
+ // suspend). Same wire blob as `email_send`; the host validates + queues then
29
+ // returns a queue-accept status without parking on the provider round-trip. Used
30
+ // for transactional sends whose delivery result the caller does not need, so the
31
+ // send is constant-time regardless of whether the recipient exists (this closes
32
+ // the auth email-enumeration timing oracle). A tenant that never calls
33
+ // `sendDetached` tree-shakes this import away.
34
+ // @ts-ignore: decorator
35
+ @external('env', 'email_send_detached')
36
+ declare function __toilEmailSendDetached(reqPtr: usize, reqLen: i32): i32;
37
+
27
38
  /**
28
39
  * The result of a send. Kept in sync with `EmailStatus` in toil-backend
29
40
  * `email.rs` (`#[repr(i32)]`). `Sent` and `Deduped` are success; the rest say
@@ -68,6 +79,48 @@ export namespace EmailService {
68
79
  purpose: string = 'tx',
69
80
  html: string = '',
70
81
  ): EmailStatus {
82
+ const buf = frameRequest(to, subject, body, purpose, html);
83
+ return <EmailStatus>__toilEmailSend(buf.dataStart, buf.length);
84
+ }
85
+
86
+ /**
87
+ * FIRE-AND-FORGET send: frame the SAME wire request as {@link send} but submit
88
+ * it via the NON-SUSPENDING `email_send_detached` host import, which validates
89
+ * + queues the message and returns IMMEDIATELY (constant time) without parking
90
+ * on the provider round-trip. Use it for transactional mail (confirm/reset
91
+ * links) whose delivery result the caller does not need: because it does not
92
+ * block on the send, the response time no longer reveals whether the recipient
93
+ * maps to an account (the auth email-enumeration timing oracle).
94
+ *
95
+ * The returned {@link EmailStatus} is a QUEUE-ACCEPT status, not a delivery
96
+ * result: `Sent` means "accepted/queued" (the final provider outcome is not
97
+ * awaited); `Disabled` / `BadRecipient` / `TryLater` are the pre-submit
98
+ * rejects. The host records the true final outcome for `/_admin/email`.
99
+ */
100
+ export function sendDetached(
101
+ to: string,
102
+ subject: string,
103
+ body: string,
104
+ purpose: string = 'tx',
105
+ html: string = '',
106
+ ): EmailStatus {
107
+ const buf = frameRequest(to, subject, body, purpose, html);
108
+ return <EmailStatus>__toilEmailSendDetached(buf.dataStart, buf.length);
109
+ }
110
+
111
+ /**
112
+ * Frame one email request blob (the v2 wire format) into a fresh buffer the
113
+ * caller passes straight to a host import. Shared by {@link send} and
114
+ * {@link sendDetached} so their wire encoding can never drift; the caller
115
+ * keeps the returned buffer live across the (synchronous) import call.
116
+ */
117
+ function frameRequest(
118
+ to: string,
119
+ subject: string,
120
+ body: string,
121
+ purpose: string,
122
+ html: string,
123
+ ): Uint8Array {
71
124
  const toB = Uint8Array.wrap(String.UTF8.encode(to));
72
125
  const subjB = Uint8Array.wrap(String.UTF8.encode(subject));
73
126
  const purpB = Uint8Array.wrap(String.UTF8.encode(purpose));
@@ -98,7 +151,7 @@ export namespace EmailService {
98
151
  off += bodyB.length;
99
152
  memory.copy(off, htmlB.dataStart, htmlB.length);
100
153
 
101
- return <EmailStatus>__toilEmailSend(base, total);
154
+ return buf;
102
155
  }
103
156
  }
104
157
 
@@ -207,6 +207,7 @@ export interface AuthOptions {
207
207
  export async function register(
208
208
  username: string,
209
209
  password: string,
210
+ email: string,
210
211
  opts: AuthOptions = {},
211
212
  ): Promise<void> {
212
213
  const baseUrl = opts.baseUrl ?? '/auth';
@@ -247,14 +248,20 @@ export async function register(
247
248
  }
248
249
  if (publicKey.length !== PUBLIC_KEY_LEN) throw new Error('auth: bad public key length');
249
250
 
250
- // 3. Submit the public key + proof-of-possession.
251
+ // 3. Submit the username + email + public key + proof-of-possession.
251
252
  const finish = await postBinary(
252
253
  baseUrl,
253
254
  '/register/finish',
254
- new DataWriter().writeString(username).writeBytes(publicKey).writeBytes(regProof).toBytes(),
255
+ new DataWriter()
256
+ .writeString(username)
257
+ .writeString(email)
258
+ .writeBytes(publicKey)
259
+ .writeBytes(regProof)
260
+ .toBytes(),
255
261
  );
256
262
  const finishStatus = finish.readU8();
257
263
  if (finishStatus === 1) throw new Error('auth: username already registered (log in instead)');
264
+ if (finishStatus === 2) throw new Error('auth: email already in use');
258
265
  if (finishStatus !== 0) throw new Error('auth: registration rejected');
259
266
  }
260
267
 
@@ -338,7 +345,15 @@ export async function login(
338
345
  '/login/finish',
339
346
  new DataWriter().writeBytes(cid).writeBytes(cipherText).writeBytes(signature).toBytes(),
340
347
  );
341
- if (res.readU8() !== 0) {
348
+ const loginStatus = res.readU8();
349
+ if (loginStatus === 2) {
350
+ // The credential verified, but the domain requires a confirmed email and
351
+ // this account is not confirmed yet. Distinguishable so the UI can prompt
352
+ // "confirm your email" / offer a resend, rather than a generic failure.
353
+ wipe(sharedSecret);
354
+ throw new EmailNotConfirmedError();
355
+ }
356
+ if (loginStatus !== 0) {
342
357
  wipe(sharedSecret);
343
358
  throw new Error('auth: login failed');
344
359
  }
@@ -364,5 +379,116 @@ export async function login(
364
379
  return session; // session token
365
380
  }
366
381
 
382
+ /** Thrown by {@link login} when the credential is valid but the account's email
383
+ * is not confirmed and the domain requires confirmation. Catch it to prompt the
384
+ * user to confirm (and offer {@link resendConfirmation}). */
385
+ export class EmailNotConfirmedError extends Error {
386
+ constructor() {
387
+ super('auth: email not confirmed');
388
+ this.name = 'EmailNotConfirmedError';
389
+ }
390
+ }
391
+
392
+ /** Confirm an account from the one-time token in the emailed link
393
+ * (`/confirm?token=<hex>`). Throws if the token is invalid or expired. */
394
+ export async function confirmEmail(token: string, opts: AuthOptions = {}): Promise<void> {
395
+ const baseUrl = opts.baseUrl ?? '/auth';
396
+ const res = await postBinary(
397
+ baseUrl,
398
+ '/confirm',
399
+ new DataWriter().writeBytes(fromHex(token)).toBytes(),
400
+ );
401
+ if (res.readU8() !== 0) throw new Error('auth: confirmation link invalid or expired');
402
+ }
403
+
404
+ /** Ask the server to re-send the confirmation email. Resolves regardless of
405
+ * whether the email maps to an unconfirmed account (the server never reveals
406
+ * it): treat success as "if that address needs confirming, a link is on its
407
+ * way." */
408
+ export async function resendConfirmation(email: string, opts: AuthOptions = {}): Promise<void> {
409
+ const baseUrl = opts.baseUrl ?? '/auth';
410
+ await postBinary(baseUrl, '/confirm/resend', new DataWriter().writeString(email).toBytes());
411
+ }
412
+
413
+ /** Begin a password reset: ask the server to email a reset link. Resolves
414
+ * regardless of whether the email exists (anti-enumeration) — always show the
415
+ * user "if that email exists, a reset link is on its way." */
416
+ export async function requestPasswordReset(email: string, opts: AuthOptions = {}): Promise<void> {
417
+ const baseUrl = opts.baseUrl ?? '/auth';
418
+ await postBinary(baseUrl, '/reset/request', new DataWriter().writeString(email).toBytes());
419
+ }
420
+
421
+ /**
422
+ * Complete a password reset from the one-time token in the emailed link
423
+ * (`/reset?token=<hex>`) and a NEW password. Mirrors registration's crypto: the
424
+ * new password is blinded through the OPRF, stretched with Argon2id into a fresh
425
+ * ML-DSA keypair, and only the new public key (+ a proof-of-possession) is sent;
426
+ * the server overwrites the stored login key. The username is recovered from the
427
+ * token server-side, so the caller only needs the token + the new password.
428
+ * Throws (generic) if the token is invalid or expired.
429
+ */
430
+ export async function resetPassword(
431
+ token: string,
432
+ newPassword: string,
433
+ opts: AuthOptions = {},
434
+ ): Promise<void> {
435
+ const baseUrl = opts.baseUrl ?? '/auth';
436
+ const rawToken = fromHex(token);
437
+ const oprf = ristretto255_oprf.oprf;
438
+ const pw = utf8(newPassword.normalize('NFKC'));
439
+
440
+ // 1. Peek the token + OPRF-evaluate the new blinded password. The server
441
+ // returns the account's username + KDF params (the token is not consumed
442
+ // until finish).
443
+ const { blind, blinded } = oprf.blind(pw);
444
+ const start = await postBinary(
445
+ baseUrl,
446
+ '/reset/start',
447
+ new DataWriter().writeBytes(rawToken).writeBytes(blinded).toBytes(),
448
+ );
449
+ if (start.readU8() !== 0) throw new Error('auth: reset link invalid or expired');
450
+ const username = start.readString();
451
+ const kdf = decodeKdf(start);
452
+ const evaluated = start.readBytes();
453
+
454
+ // 2. OPRF -> keyed salt -> seed -> NEW keypair; keep only the public key + a
455
+ // PoP over it. Wipe the secret key and seed immediately.
456
+ const oprfOutput = oprf.finalize(pw, blind, evaluated);
457
+ const seed = await deriveSeed(oprfOutput, kdf);
458
+ let publicKey: Uint8Array;
459
+ let regProof: Uint8Array;
460
+ try {
461
+ const kp = ml_dsa44.keygen(seed);
462
+ publicKey = kp.publicKey;
463
+ try {
464
+ regProof = ml_dsa44.sign(buildRegisterMessage(username, publicKey), kp.secretKey, {
465
+ context: utf8(REGISTER_CONTEXT),
466
+ });
467
+ } finally {
468
+ wipe(kp.secretKey);
469
+ }
470
+ } finally {
471
+ wipe(seed);
472
+ }
473
+ if (publicKey.length !== PUBLIC_KEY_LEN) throw new Error('auth: bad public key length');
474
+
475
+ // 3. Consume the token + overwrite the stored login key.
476
+ const finish = await postBinary(
477
+ baseUrl,
478
+ '/reset/finish',
479
+ new DataWriter().writeBytes(rawToken).writeBytes(publicKey).writeBytes(regProof).toBytes(),
480
+ );
481
+ if (finish.readU8() !== 0) throw new Error('auth: password reset rejected');
482
+ }
483
+
367
484
  /** The client auth surface, grouped for `Auth.register` / `Auth.login` use. */
368
- export const Auth = { register, login, buildLoginMessage, LOGIN_CONTEXT } as const;
485
+ export const Auth = {
486
+ register,
487
+ login,
488
+ confirmEmail,
489
+ resendConfirmation,
490
+ requestPasswordReset,
491
+ resetPassword,
492
+ buildLoginMessage,
493
+ LOGIN_CONTEXT,
494
+ } as const;
@@ -16,9 +16,11 @@ import type { MemoryRef } from '../runtime/host.js';
16
16
 
17
17
  /** Frame version + counter count, kept in lockstep with the edge (`analytics.rs` / `metric_id.rs`). */
18
18
  const FRAME_VERSION = 2;
19
- const METRIC_COUNTERS = 43;
20
- /** MetricId indices we seed with sample data (others default 0). Mirrors the wire contract
21
- * (counter ids 0..=42; gauge ids ConnectedStreamsAvg=43, CommittedMemoryAvg=45). */
19
+ const METRIC_COUNTERS = 42;
20
+ /** MetricId indices we seed with sample data (others default 0). Mirrors the AUTHORITATIVE edge wire
21
+ * contract EXACTLY (`toil-backend/src/analytics/metric_id.rs`): counter ids 0..=41, gauge ids
22
+ * ConnectedStreamsAvg=42, CommittedMemoryAvg=44. There is NO WasmDispatches (removed + reindexed); the
23
+ * edge/guest are 42, so this must be 42 with matching indices or the dashboard mislabels every metric. */
22
24
  const MID = {
23
25
  Requests: 0,
24
26
  BytesOutL1: 1,
@@ -26,16 +28,15 @@ const MID = {
26
28
  Status2xx: 3,
27
29
  Status4xx: 5,
28
30
  StaticHits: 7,
29
- WasmDispatches: 8,
30
- GasUsed: 12,
31
- DbOps: 13,
32
- DbReads: 14,
33
- DbWrites: 15,
34
- StreamBytesIn: 25,
35
- StreamBytesOut: 26,
36
- MemGrownBytes: 39,
37
- CacheHits: 41,
38
- CacheMisses: 42,
31
+ GasUsed: 11,
32
+ DbOps: 12,
33
+ DbReads: 13,
34
+ DbWrites: 14,
35
+ StreamBytesIn: 24,
36
+ StreamBytesOut: 25,
37
+ MemGrownBytes: 38,
38
+ CacheHits: 40,
39
+ CacheMisses: 41,
39
40
  } as const;
40
41
 
41
42
  interface DevTenantStats {
@@ -46,6 +47,14 @@ interface DevTenantStats {
46
47
  reqMinuteCap: number;
47
48
  reqDayUsed: number;
48
49
  reqDayCap: number;
50
+ // LIVE per-second rates (f64), appended after the windows. Mirrors the edge `encode_stats`.
51
+ rps: number;
52
+ bytesInPerSec: number;
53
+ bytesOutPerSec: number;
54
+ streamBytesInPerSec: number;
55
+ streamBytesOutPerSec: number;
56
+ dbOpsPerSec: number;
57
+ gasPerSec: number;
49
58
  nowMs: number;
50
59
  }
51
60
 
@@ -58,7 +67,6 @@ function devStats(): DevTenantStats {
58
67
  life[MID.Status2xx] = 40n;
59
68
  life[MID.Status4xx] = 2n;
60
69
  life[MID.StaticHits] = 8n;
61
- life[MID.WasmDispatches] = 34n;
62
70
  life[MID.GasUsed] = 1_000_000n;
63
71
  life[MID.DbOps] = 17n;
64
72
  life[MID.DbReads] = 12n;
@@ -76,33 +84,53 @@ function devStats(): DevTenantStats {
76
84
  reqMinuteCap: 100,
77
85
  reqDayUsed: 42,
78
86
  reqDayCap: 5000,
87
+ // Plausible non-zero live rates so a dev dashboard shows moving gauges.
88
+ rps: 0.7,
89
+ bytesInPerSec: 68.2,
90
+ bytesOutPerSec: 205.75,
91
+ streamBytesInPerSec: 34.1,
92
+ streamBytesOutPerSec: 136.5,
93
+ dbOpsPerSec: 0.28,
94
+ gasPerSec: 16_666.67,
79
95
  nowMs: 1_700_000_000_000,
80
96
  };
81
97
  }
82
98
 
83
99
  /**
84
100
  * Encode the v2 snapshot frame BYTE-FOR-BYTE like the edge (`analytics.rs` `encode_stats`), FIXED
85
- * layout (no strings):
86
- * u16 version | u64 now_ms | u32 count | i64 × count | i64 connectedStreams | i64 committedMemory |
87
- * i64 reqMinuteUsed | u64 reqMinuteCap | i64 reqDayUsed | u64 reqDayCap
101
+ * layout (no strings). Counters/levels are u64; the 7 trailing LIVE per-second rates are f64:
102
+ * u16 version | u64 now_ms | u32 count | u64 × count | u64 connectedStreams | u64 committedMemory |
103
+ * u64 reqMinuteUsed | u64 reqMinuteCap | u64 reqDayUsed | u64 reqDayCap |
104
+ * f64 rps | f64 bytesInPerSec | f64 bytesOutPerSec | f64 streamBytesInPerSec |
105
+ * f64 streamBytesOutPerSec | f64 dbOpsPerSec | f64 gasPerSec
106
+ * The rates are APPENDED (version-gated by length): the guest's bounds-checked reader yields 0.0 for a
107
+ * pre-rates frame, so an older edge stays decodable.
88
108
  */
89
109
  function encodeStats(s: DevTenantStats): Buffer {
90
110
  const head = Buffer.alloc(2 + 8 + 4);
91
111
  head.writeUInt16LE(FRAME_VERSION, 0);
92
112
  head.writeBigUInt64LE(BigInt(s.nowMs), 2);
93
113
  head.writeUInt32LE(s.life.length, 10);
94
- const body = Buffer.alloc(s.life.length * 8 + 48);
114
+ // 6 window u64s (48 bytes) + 7 rate f64s (56 bytes).
115
+ const body = Buffer.alloc(s.life.length * 8 + 48 + 56);
95
116
  let o = 0;
96
117
  for (const v of s.life) {
97
- body.writeBigInt64LE(v, o);
118
+ body.writeBigUInt64LE(v, o);
98
119
  o += 8;
99
120
  }
100
- body.writeBigInt64LE(BigInt(s.connectedStreams), o); o += 8;
101
- body.writeBigInt64LE(BigInt(s.committedMemory), o); o += 8;
102
- body.writeBigInt64LE(BigInt(s.reqMinuteUsed), o); o += 8;
121
+ body.writeBigUInt64LE(BigInt(s.connectedStreams), o); o += 8;
122
+ body.writeBigUInt64LE(BigInt(s.committedMemory), o); o += 8;
123
+ body.writeBigUInt64LE(BigInt(s.reqMinuteUsed), o); o += 8;
103
124
  body.writeBigUInt64LE(BigInt(s.reqMinuteCap), o); o += 8;
104
- body.writeBigInt64LE(BigInt(s.reqDayUsed), o); o += 8;
105
- body.writeBigUInt64LE(BigInt(s.reqDayCap), o);
125
+ body.writeBigUInt64LE(BigInt(s.reqDayUsed), o); o += 8;
126
+ body.writeBigUInt64LE(BigInt(s.reqDayCap), o); o += 8;
127
+ body.writeDoubleLE(s.rps, o); o += 8;
128
+ body.writeDoubleLE(s.bytesInPerSec, o); o += 8;
129
+ body.writeDoubleLE(s.bytesOutPerSec, o); o += 8;
130
+ body.writeDoubleLE(s.streamBytesInPerSec, o); o += 8;
131
+ body.writeDoubleLE(s.streamBytesOutPerSec, o); o += 8;
132
+ body.writeDoubleLE(s.dbOpsPerSec, o); o += 8;
133
+ body.writeDoubleLE(s.gasPerSec, o);
106
134
  return Buffer.concat([head, body]);
107
135
  }
108
136
 
@@ -126,13 +154,15 @@ function rangeShape(range: number, metricId: number): { count: number; bucketSec
126
154
  case 5: return { count: 168, bucketSecs: 3600 };
127
155
  case 6: return { count: 336, bucketSecs: 3600 };
128
156
  case 7: return { count: 720, bucketSecs: 3600 };
157
+ case 8: return { count: 60, bucketSecs: 86_400 }; // D60 on the DAY ring
158
+ case 9: return { count: 90, bucketSecs: 86_400 }; // D90 on the DAY ring
129
159
  default: return { count: 24, bucketSecs: 3600 };
130
160
  }
131
161
  }
132
162
 
133
163
  /**
134
- * Encode the v2 series frame BYTE-FOR-BYTE like the edge `encode_series`:
135
- * u16 version | u16 metricId | u32 bucketSecs | u64 headMs | u32 count | i64 × count
164
+ * Encode the v2 series frame BYTE-FOR-BYTE like the edge `encode_series` (points are u64 now):
165
+ * u16 version | u16 metricId | u32 bucketSecs | u64 headMs | u32 count | u64 × count
136
166
  * A synthetic gentle ramp so a dev dashboard draws a non-flat line.
137
167
  */
138
168
  function encodeSeries(metricId: number, range: number): Buffer {
@@ -146,9 +176,9 @@ function encodeSeries(metricId: number, range: number): Buffer {
146
176
  head.writeUInt32LE(count, 16);
147
177
  const body = Buffer.alloc(count * 8);
148
178
  for (let i = 0; i < count; i++) {
149
- // A smooth ramp with a little variation, deterministic per (metric, index).
179
+ // A smooth ramp with a little variation, deterministic per (metric, index). Non-negative -> u64.
150
180
  const v = BigInt(((i * 7 + metricId) % 50) + i);
151
- body.writeBigInt64LE(v, i * 8);
181
+ body.writeBigUInt64LE(v, i * 8);
152
182
  }
153
183
  return Buffer.concat([head, body]);
154
184
  }
@@ -211,8 +241,9 @@ export function buildAnalyticsImports(
211
241
  range: number,
212
242
  ): number => {
213
243
  if (domainLen > MAX_DOMAIN_LEN) return ABSENT;
214
- // Valid metric ids are 0..=46 (counters 0..=42 + the 4 gauge avg/peak series), ranges 0..=7.
215
- if (metricId < 0 || metricId > 46 || range < 0 || range > 7) return ABSENT;
244
+ // Valid metric ids are 0..=46 (counters 0..=42 + the 4 gauge avg/peak series); ranges 0..=9
245
+ // (H1..D30 plus the appended D60/D90 day ranges).
246
+ if (metricId < 0 || metricId > 46 || range < 0 || range > 9) return ABSENT;
216
247
  if (domainLen > 0) {
217
248
  if (!ref.memory) throw new Error('analytics_series called before memory was bound');
218
249
  const m = Buffer.from(ref.memory.buffer);
@@ -36,6 +36,13 @@ interface RouteLimiter {
36
36
  /** `(routeId) -> limiter`, created lazily with the route's first-seen params. */
37
37
  const registry = new Map<number, RouteLimiter>();
38
38
 
39
+ /** Drop all limiter state (tests). Each dev wasm dispatch shares this module-level
40
+ * registry, so a test that fires many `@ratelimit`-decorated routes in one file
41
+ * would otherwise carry counts across cases; reset between cases for isolation. */
42
+ export function __resetRatelimitForTests(): void {
43
+ registry.clear();
44
+ }
45
+
39
46
  export interface DevDecision {
40
47
  allowed: boolean;
41
48
  /** Whole seconds to wait before retrying (>= 1 when denied, 0 when allowed). */
@@ -74,6 +74,50 @@ export class NodeEmailService {
74
74
  }
75
75
  }
76
76
 
77
+ // --- Test-only capture seam ---------------------------------------------------
78
+
79
+ /**
80
+ * A message the dev email pipeline decided to send (a real provider send, or the
81
+ * unconfigured stub), captured for TESTS ONLY. The built-in auth controller
82
+ * delivers its one-time confirm/reset tokens via email, so an e2e test reads the
83
+ * token straight out of the emitted link here. This is NOT a production path —
84
+ * the edge never imports this module, and real sends are unchanged.
85
+ */
86
+ export interface SentEmail {
87
+ readonly to: string;
88
+ readonly subject: string;
89
+ /** Plain-text body (the confirm/reset link appears here and in `html`). */
90
+ readonly text: string;
91
+ readonly html: string;
92
+ /** Short non-PII tag the guest passed (e.g. `verify`, `reset`). */
93
+ readonly purpose: string;
94
+ }
95
+
96
+ /** Bounded ring of the most-recent dev sends (test seam). */
97
+ export const __sentEmails: SentEmail[] = [];
98
+ const SENT_EMAILS_MAX = 256;
99
+
100
+ /**
101
+ * Record one dev send into {@link __sentEmails}. Called at the exact point the
102
+ * dev pipeline decides to send/stub, so it captures precisely the messages the
103
+ * guest emitted (and nothing it dropped/deduped).
104
+ */
105
+ export function recordSentEmail(msg: ParsedEmail): void {
106
+ __sentEmails.push({
107
+ to: msg.to,
108
+ subject: msg.subject,
109
+ text: msg.body,
110
+ html: msg.html,
111
+ purpose: msg.purpose,
112
+ });
113
+ while (__sentEmails.length > SENT_EMAILS_MAX) __sentEmails.shift();
114
+ }
115
+
116
+ /** Clear the captured dev-send ring (tests call this between cases). */
117
+ export function __clearSentEmails(): void {
118
+ __sentEmails.length = 0;
119
+ }
120
+
77
121
  // --- Process-level singleton (one project per dev/self-host process) ----------
78
122
 
79
123
  let service: NodeEmailService | null = null;