toiljs 0.0.88 → 0.0.90
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 +5 -0
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/auth.d.ts +12 -1
- package/build/client/auth.js +82 -4
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/analytics/index.js +42 -21
- package/build/devserver/config/ratelimit.d.ts +1 -0
- package/build/devserver/config/ratelimit.js +3 -0
- package/build/devserver/email/index.d.ts +10 -0
- package/build/devserver/email/index.js +16 -0
- package/build/devserver/runtime/host.js +33 -49
- package/build/devserver/runtime/module.js +1 -0
- package/examples/basic/client/routes/pq.tsx +12 -2
- package/examples/basic/server/main.ts +0 -1
- package/examples/basic/server/routes/Session.ts +5 -11
- package/package.json +3 -3
- package/server/auth/AuthController.ts +467 -24
- package/server/globals/auth.ts +44 -2
- package/server/globals/email.ts +54 -1
- package/src/client/auth.ts +130 -4
- package/src/devserver/analytics/index.ts +68 -33
- package/src/devserver/config/ratelimit.ts +7 -0
- package/src/devserver/email/index.ts +44 -0
- package/src/devserver/runtime/host.ts +58 -64
- package/src/devserver/runtime/module.ts +1 -0
- package/test/analytics-dev.test.ts +49 -12
- package/test/devserver-email.test.ts +48 -1
- package/test/pqauth-e2e.test.ts +18 -9
- package/test/pqauth-email.test.ts +333 -0
- package/examples/basic/server/routes/Auth.ts +0 -268
package/server/globals/auth.ts
CHANGED
|
@@ -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
|
|
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;
|
package/server/globals/email.ts
CHANGED
|
@@ -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
|
|
154
|
+
return buf;
|
|
102
155
|
}
|
|
103
156
|
}
|
|
104
157
|
|
package/src/client/auth.ts
CHANGED
|
@@ -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()
|
|
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
|
-
|
|
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 = {
|
|
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 =
|
|
20
|
-
/** MetricId indices we seed with sample data (others default 0). Mirrors the wire
|
|
21
|
-
* (counter ids 0..=
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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,39 +84,63 @@ 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 |
|
|
87
|
-
*
|
|
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
|
-
|
|
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.
|
|
118
|
+
body.writeBigUInt64LE(v, o);
|
|
98
119
|
o += 8;
|
|
99
120
|
}
|
|
100
|
-
body.
|
|
101
|
-
body.
|
|
102
|
-
body.
|
|
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.
|
|
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
|
|
|
109
|
-
/** The metric ids carried in the per-minute ring (mirrors the edge `MINUTE_RING_METRICS`
|
|
110
|
-
* minute resolution on the 1h/6h ranges;
|
|
111
|
-
|
|
137
|
+
/** The metric ids carried in the per-minute ring (mirrors the edge `MINUTE_RING_METRICS` in
|
|
138
|
+
* `toil-backend/src/analytics/ring.rs` EXACTLY). Only these get minute resolution on the 1h/6h ranges;
|
|
139
|
+
* every other metric falls back to the hour ring there. These are the AUTHORITATIVE edge ids
|
|
140
|
+
* (Requests=0, BytesInL1=2, BytesOutL1=1, GasUsed=11, DbOps=12, StreamBytesIn=24, StreamBytesOut=25,
|
|
141
|
+
* MemGrownBytes=38, ConnectedStreamsAvg=42, CommittedMemoryAvg=44) -- must match the reindexed layout or
|
|
142
|
+
* the dev 1h/6h graphs pick the wrong ring for these metrics. */
|
|
143
|
+
const MINUTE_RING_METRICS = new Set<number>([0, 1, 2, 11, 12, 24, 25, 38, 42, 44]);
|
|
112
144
|
|
|
113
145
|
/**
|
|
114
146
|
* Range id -> (bucketCount, bucketSecs), matching the edge `Range` + minute-ring fallback: 1h/6h are
|
|
@@ -126,13 +158,15 @@ function rangeShape(range: number, metricId: number): { count: number; bucketSec
|
|
|
126
158
|
case 5: return { count: 168, bucketSecs: 3600 };
|
|
127
159
|
case 6: return { count: 336, bucketSecs: 3600 };
|
|
128
160
|
case 7: return { count: 720, bucketSecs: 3600 };
|
|
161
|
+
case 8: return { count: 60, bucketSecs: 86_400 }; // D60 on the DAY ring
|
|
162
|
+
case 9: return { count: 90, bucketSecs: 86_400 }; // D90 on the DAY ring
|
|
129
163
|
default: return { count: 24, bucketSecs: 3600 };
|
|
130
164
|
}
|
|
131
165
|
}
|
|
132
166
|
|
|
133
167
|
/**
|
|
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 |
|
|
168
|
+
* Encode the v2 series frame BYTE-FOR-BYTE like the edge `encode_series` (points are u64 now):
|
|
169
|
+
* u16 version | u16 metricId | u32 bucketSecs | u64 headMs | u32 count | u64 × count
|
|
136
170
|
* A synthetic gentle ramp so a dev dashboard draws a non-flat line.
|
|
137
171
|
*/
|
|
138
172
|
function encodeSeries(metricId: number, range: number): Buffer {
|
|
@@ -146,9 +180,9 @@ function encodeSeries(metricId: number, range: number): Buffer {
|
|
|
146
180
|
head.writeUInt32LE(count, 16);
|
|
147
181
|
const body = Buffer.alloc(count * 8);
|
|
148
182
|
for (let i = 0; i < count; i++) {
|
|
149
|
-
// A smooth ramp with a little variation, deterministic per (metric, index).
|
|
183
|
+
// A smooth ramp with a little variation, deterministic per (metric, index). Non-negative -> u64.
|
|
150
184
|
const v = BigInt(((i * 7 + metricId) % 50) + i);
|
|
151
|
-
body.
|
|
185
|
+
body.writeBigUInt64LE(v, i * 8);
|
|
152
186
|
}
|
|
153
187
|
return Buffer.concat([head, body]);
|
|
154
188
|
}
|
|
@@ -211,8 +245,9 @@ export function buildAnalyticsImports(
|
|
|
211
245
|
range: number,
|
|
212
246
|
): number => {
|
|
213
247
|
if (domainLen > MAX_DOMAIN_LEN) return ABSENT;
|
|
214
|
-
// Valid metric ids are 0..=
|
|
215
|
-
|
|
248
|
+
// Valid metric ids are 0..=45 (counters 0..=41 + the 4 gauge avg/peak series 42..=45), matching
|
|
249
|
+
// the edge `MetricId::from_u16` (v < N_METRICS=46); ranges 0..=9 (H1..D30 + D60/D90).
|
|
250
|
+
if (metricId < 0 || metricId > 45 || range < 0 || range > 9) return ABSENT;
|
|
216
251
|
if (domainLen > 0) {
|
|
217
252
|
if (!ref.memory) throw new Error('analytics_series called before memory was bound');
|
|
218
253
|
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;
|