toiljs 0.0.105 → 0.0.107
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/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/auth-emails.d.ts +29 -0
- package/build/compiler/auth-emails.js +169 -0
- package/build/compiler/emails.d.ts +3 -1
- package/build/compiler/emails.js +6 -2
- package/build/compiler/index.js +20 -3
- package/build/compiler/toil-docs.generated.js +3 -2
- package/docs/README.md +2 -1
- package/docs/auth/README.md +2 -0
- package/docs/auth/emails.md +91 -0
- package/docs/llms.txt +1 -0
- package/package.json +1 -1
- package/server/auth/AuthController.ts +57 -44
- package/server/globals/auth.ts +17 -3
- package/server/globals/email.ts +15 -2
- package/server/globals/userid.ts +25 -4
- package/src/compiler/auth-emails.ts +296 -0
- package/src/compiler/emails.ts +20 -2
- package/src/compiler/index.ts +39 -4
- package/src/compiler/toil-docs.generated.ts +3 -2
- package/test/auth-emails.test.ts +115 -0
- package/test/email-console.test.ts +2 -2
package/docs/llms.txt
CHANGED
|
@@ -61,6 +61,7 @@ This is the canonical documentation, organized by section. Each link points to a
|
|
|
61
61
|
- [Authentication](auth/README.md): Toil ships a complete, **post-quantum password login** you turn on with one line.
|
|
62
62
|
- [Configuring auth for production](auth/configuration.md): Built-in auth runs locally with **zero configuration**, it falls back to published, insecure DEV secrets so `toiljs dev` Just Works.
|
|
63
63
|
- [Extending & integrating auth](auth/extending.md): Built-in auth is deliberately opinionated so the common case is one line.
|
|
64
|
+
- [Customizing the auth emails](auth/emails.md): Override the built-in verification, password-reset, and 2FA emails with your own branded React templates by adding reserved emails/auth-*.tsx files (tokens: {link} for confirm/reset, {code} for 2FA); sends stay detached (constant-time).
|
|
64
65
|
- [How Toil auth works](auth/how-it-works.md): Toil auth is a **post-quantum, password-based, mutually-authenticated** login.
|
|
65
66
|
- [Using auth](auth/usage.md): Either the config flag (canonical) or a one-line import, both do the same thing (the build appends the framework's auth controller + user shape to your server as compiled entries, so `/auth/*` self-mounts):
|
|
66
67
|
|
package/package.json
CHANGED
|
@@ -443,6 +443,19 @@ class EmailOwner {
|
|
|
443
443
|
}
|
|
444
444
|
}
|
|
445
445
|
|
|
446
|
+
/** Reverse-index key for the STABLE ToilUserId uniqueness guard: (realm host,
|
|
447
|
+
* 32-byte id). Realm-scoped, so uniqueness is enforced PER TENANT — two sites
|
|
448
|
+
* never share an id namespace. */
|
|
449
|
+
@data
|
|
450
|
+
class UserIdKey {
|
|
451
|
+
host: string = '';
|
|
452
|
+
id: Uint8Array = new Uint8Array(0);
|
|
453
|
+
constructor(host: string = '', id: Uint8Array = new Uint8Array(0)) {
|
|
454
|
+
this.host = host;
|
|
455
|
+
this.id = id;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
446
459
|
@data
|
|
447
460
|
class TokenId {
|
|
448
461
|
hash: Uint8Array = new Uint8Array(0);
|
|
@@ -488,6 +501,9 @@ class AuthAccount {
|
|
|
488
501
|
// TWOFA_NONE (2FA off), the safe default, so existing accounts stay logged in
|
|
489
502
|
// by password alone until they opt in via /auth/2fa/setup.
|
|
490
503
|
twoFactorMethod: u8 = 0;
|
|
504
|
+
// Append-only: the STABLE ToilUserId, derived once at registration. Reset rotates
|
|
505
|
+
// publicKey but leaves this, so the id is stable across a reset.
|
|
506
|
+
toilUserId: Uint8Array = new Uint8Array(0);
|
|
491
507
|
}
|
|
492
508
|
|
|
493
509
|
@data
|
|
@@ -534,6 +550,7 @@ class TwoFaChallenge {
|
|
|
534
550
|
class AuthDb {
|
|
535
551
|
@collection static accounts: Documents<Username, AuthAccount>;
|
|
536
552
|
@collection static emails: Documents<EmailKey, EmailOwner>; // email -> username (uniqueness index)
|
|
553
|
+
@collection static userIds: Documents<UserIdKey, EmailOwner>; // toilUserId -> username (per-tenant uniqueness guard)
|
|
537
554
|
@collection static challenges: Documents<ChallengeId, Challenge>;
|
|
538
555
|
@collection static confirmTokens: Documents<TokenId, TokenRec>;
|
|
539
556
|
@collection static resetTokens: Documents<TokenId, TokenRec>;
|
|
@@ -633,6 +650,10 @@ class Auth {
|
|
|
633
650
|
a.memKiB = MEM_KIB;
|
|
634
651
|
a.iterations = ITERS;
|
|
635
652
|
a.parallelism = PAR;
|
|
653
|
+
// Derive + persist the stable id once, here. Tenant-scoped (framed domain +
|
|
654
|
+
// realm-keyed reverse index below).
|
|
655
|
+
const uid = ToilUserId.derive(pk, username, authDomain(ctx)).toBytes();
|
|
656
|
+
a.toilUserId = uid;
|
|
636
657
|
// create-if-absent: a racing duplicate registration loses here, not above.
|
|
637
658
|
if (!AuthDb.accounts.create(new Username(h, username), a)) {
|
|
638
659
|
return Response.bytes(new DataWriter().writeU8(ST_TAKEN).toBytes());
|
|
@@ -645,6 +666,13 @@ class Auth {
|
|
|
645
666
|
AuthDb.accounts.delete(new Username(h, username));
|
|
646
667
|
return Response.bytes(new DataWriter().writeU8(ST_EMAIL_TAKEN).toBytes());
|
|
647
668
|
}
|
|
669
|
+
// Per-tenant id-uniqueness guard: atomic create of toilUserId -> username
|
|
670
|
+
// (race-safe at the key's home). A lost race rolls back email + account.
|
|
671
|
+
if (!AuthDb.userIds.create(new UserIdKey(h, uid), new EmailOwner(username))) {
|
|
672
|
+
AuthDb.emails.delete(new EmailKey(h, email));
|
|
673
|
+
AuthDb.accounts.delete(new Username(h, username));
|
|
674
|
+
return fail();
|
|
675
|
+
}
|
|
648
676
|
|
|
649
677
|
if (mustConfirm) {
|
|
650
678
|
this.issueConfirmation(h, ctx, username, email);
|
|
@@ -841,8 +869,7 @@ class Auth {
|
|
|
841
869
|
// extended one). `__toilEncodeAuthUser` is injected by `--authUser`: it constructs the `@user`
|
|
842
870
|
// (app fields at their defaults), sets the reserved identity, and encodes it. The stable
|
|
843
871
|
// ToilUserId is derived from the login public key + username + tenant domain.
|
|
844
|
-
const
|
|
845
|
-
const toilUserId = ToilUserId.derive(acct.publicKey, ch.username, domain).toBytes();
|
|
872
|
+
const toilUserId = this.stableUserId(ctx, h, ch.username, acct);
|
|
846
873
|
const userData = __toilEncodeAuthUser(toilUserId, ch.username);
|
|
847
874
|
const w = new DataWriter();
|
|
848
875
|
w.writeU8(ST_OK);
|
|
@@ -958,6 +985,17 @@ class Auth {
|
|
|
958
985
|
return Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
|
|
959
986
|
}
|
|
960
987
|
|
|
988
|
+
/** The account's STABLE ToilUserId, persisted at registration (so reset never
|
|
989
|
+
* changes it). An empty stored id is derived once, persisted, and indexed. */
|
|
990
|
+
private stableUserId(ctx: RouteContext, h: string, username: string, acct: AuthAccount): Uint8Array {
|
|
991
|
+
if (acct.toilUserId.length > 0) return acct.toilUserId;
|
|
992
|
+
const uid = ToilUserId.derive(acct.publicKey, username, authDomain(ctx)).toBytes();
|
|
993
|
+
acct.toilUserId = uid;
|
|
994
|
+
AuthDb.accounts.patch(new Username(h, username), acct);
|
|
995
|
+
AuthDb.userIds.create(new UserIdKey(h, uid), new EmailOwner(username));
|
|
996
|
+
return uid;
|
|
997
|
+
}
|
|
998
|
+
|
|
961
999
|
/** GET /auth/me (@auth: 401 without a valid session) -> the typed user
|
|
962
1000
|
* (bytes(toilUserId) str(username)). `AuthService.getUser()` is auto-typed
|
|
963
1001
|
* to the built-in `@user` with no type argument. */
|
|
@@ -1037,8 +1075,7 @@ class Auth {
|
|
|
1037
1075
|
AuthDb.twoFaLogins.getDelete(key);
|
|
1038
1076
|
const acct = AuthDb.accounts.get(new Username(h, ch.username));
|
|
1039
1077
|
if (acct == null) return fail();
|
|
1040
|
-
const
|
|
1041
|
-
const toilUserId = ToilUserId.derive(acct.publicKey, ch.username, domain).toBytes();
|
|
1078
|
+
const toilUserId = this.stableUserId(ctx, h, ch.username, acct);
|
|
1042
1079
|
const userData = __toilEncodeAuthUser(toilUserId, ch.username);
|
|
1043
1080
|
const w = new DataWriter();
|
|
1044
1081
|
w.writeU8(ST_OK);
|
|
@@ -1188,19 +1225,12 @@ class Auth {
|
|
|
1188
1225
|
switch (method) {
|
|
1189
1226
|
case TWOFA_EMAIL: {
|
|
1190
1227
|
const code = randomCode(TWOFA_DIGITS);
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
'<p style="font-size:28px;font-weight:bold;letter-spacing:4px">' +
|
|
1198
|
-
code +
|
|
1199
|
-
'</p>' +
|
|
1200
|
-
'<p>It expires in a few minutes. If you did not request it, ignore this email.</p>';
|
|
1201
|
-
// DETACHED (non-suspending) send: constant-time, no provider-RTT
|
|
1202
|
-
// parking (matches the confirm/reset anti-enumeration posture).
|
|
1203
|
-
EmailService.sendDetached(email, subject, text, '2fa', html);
|
|
1228
|
+
// The subject is contextual (login vs setup) so AuthController owns it;
|
|
1229
|
+
// the body/html come from the generated `AuthEmail` — the built-in
|
|
1230
|
+
// default, or an `emails/auth-2fa.tsx` override filling `{code}`. The
|
|
1231
|
+
// send is DETACHED (constant-time, no provider-RTT parking), a property
|
|
1232
|
+
// the override cannot lose (AuthEmail.twofa uses sendDetached).
|
|
1233
|
+
AuthEmail.twofa(email, code, subject);
|
|
1204
1234
|
return twoFaCodeHash(host, username, code);
|
|
1205
1235
|
}
|
|
1206
1236
|
// >>> case TWOFA_TOTP: { return twoFaSecretBinding(username); } <<<
|
|
@@ -1267,19 +1297,11 @@ class Auth {
|
|
|
1267
1297
|
// Token in the URL FRAGMENT (see resetRequest): kept out of server/CDN
|
|
1268
1298
|
// logs and the Referer header; the confirm page reads it from location.hash.
|
|
1269
1299
|
const link = baseUrl(ctx) + '/confirm#token=' + crypto.toHex(raw);
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
link +
|
|
1276
|
-
'">Confirm my account</a></p>' +
|
|
1277
|
-
'<p>Or paste this into your browser:<br>' +
|
|
1278
|
-
link +
|
|
1279
|
-
'</p>';
|
|
1280
|
-
// DETACHED (non-suspending) send: constant-time, no provider-RTT parking on
|
|
1281
|
-
// the "account exists" path (see the enumeration note on this method).
|
|
1282
|
-
EmailService.sendDetached(email, subject, text, 'verify', html);
|
|
1300
|
+
// The built-in confirm email, or an `emails/auth-confirm.tsx` override filling
|
|
1301
|
+
// `{link}`. DETACHED (non-suspending) send: constant-time, no provider-RTT
|
|
1302
|
+
// parking on the "account exists" path (see the enumeration note on this
|
|
1303
|
+
// method); AuthEmail.confirm uses sendDetached, so an override keeps that.
|
|
1304
|
+
AuthEmail.confirm(email, link);
|
|
1283
1305
|
}
|
|
1284
1306
|
|
|
1285
1307
|
/** Email a password-reset link. Best-effort (the request path already
|
|
@@ -1292,19 +1314,10 @@ class Auth {
|
|
|
1292
1314
|
* (Residual: the reset-token mint on the exists path still does a sub-ms
|
|
1293
1315
|
* ToilDB write the miss path skips.) */
|
|
1294
1316
|
private sendResetEmail(email: string, link: string): void {
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
link +
|
|
1301
|
-
'">Reset my password</a></p>' +
|
|
1302
|
-
'<p>If you did not request this, you can ignore this email.</p>' +
|
|
1303
|
-
'<p>Or paste this into your browser:<br>' +
|
|
1304
|
-
link +
|
|
1305
|
-
'</p>';
|
|
1306
|
-
// DETACHED (non-suspending) send: constant-time, closes the reset-request
|
|
1307
|
-
// email-enumeration timing oracle (see the note above).
|
|
1308
|
-
EmailService.sendDetached(email, subject, text, 'reset', html);
|
|
1317
|
+
// The built-in reset email, or an `emails/auth-reset.tsx` override filling
|
|
1318
|
+
// `{link}`. DETACHED (non-suspending) send: constant-time, closes the
|
|
1319
|
+
// reset-request email-enumeration timing oracle (see the note above);
|
|
1320
|
+
// AuthEmail.reset uses sendDetached, so an override keeps that property.
|
|
1321
|
+
AuthEmail.reset(email, link);
|
|
1309
1322
|
}
|
|
1310
1323
|
}
|
package/server/globals/auth.ts
CHANGED
|
@@ -228,8 +228,16 @@ function __labelled(label: string, transcriptHash: Uint8Array): Uint8Array {
|
|
|
228
228
|
function __reqIsSecure(): bool {
|
|
229
229
|
const req = Server.currentRequest;
|
|
230
230
|
if (req == null) return false;
|
|
231
|
+
// Proxied deployments: trust the terminating proxy's scheme header.
|
|
231
232
|
const proto = req.header('x-forwarded-proto');
|
|
232
|
-
|
|
233
|
+
if (proto != null) return proto == 'https';
|
|
234
|
+
// Direct-origin deployments: the edge terminates TLS itself and sends no
|
|
235
|
+
// x-forwarded-proto, so fall back to the configured public origin. An https
|
|
236
|
+
// PUBLIC_BASE_URL means the site is served over TLS, so the auth cookies must
|
|
237
|
+
// be Secure + __Host-/__Secure- prefixed. Without this a direct-origin HTTPS
|
|
238
|
+
// edge sets bare cookies and the client's getUser() misses the __Secure- name.
|
|
239
|
+
const base = Environment.get('PUBLIC_BASE_URL');
|
|
240
|
+
return base != null && base.startsWith('https://');
|
|
233
241
|
}
|
|
234
242
|
|
|
235
243
|
export namespace AuthService {
|
|
@@ -402,7 +410,10 @@ export namespace AuthService {
|
|
|
402
410
|
let cookie = Cookie.create(USER_BASE, base64UrlEncode(userData))
|
|
403
411
|
.sameSite(SameSite.Lax)
|
|
404
412
|
.maxAge(<i64>ttlSecs);
|
|
405
|
-
|
|
413
|
+
// Path=/ in BOTH branches: asSecurePrefixed() sets no path, so without this
|
|
414
|
+
// the cookie is scoped to the /auth request path and getUser() can't read it
|
|
415
|
+
// on /login or /dashboard.
|
|
416
|
+
cookie = secure ? cookie.asSecurePrefixed().path('/') : cookie.path('/');
|
|
406
417
|
return cookie;
|
|
407
418
|
}
|
|
408
419
|
|
|
@@ -412,7 +423,10 @@ export namespace AuthService {
|
|
|
412
423
|
let cookie = Cookie.create(USER_BASE, '')
|
|
413
424
|
.sameSite(SameSite.Lax)
|
|
414
425
|
.maxAge(0);
|
|
415
|
-
|
|
426
|
+
// Path=/ in BOTH branches: asSecurePrefixed() sets no path, so without this
|
|
427
|
+
// the cookie is scoped to the /auth request path and getUser() can't read it
|
|
428
|
+
// on /login or /dashboard.
|
|
429
|
+
cookie = secure ? cookie.asSecurePrefixed().path('/') : cookie.path('/');
|
|
416
430
|
return cookie;
|
|
417
431
|
}
|
|
418
432
|
|
package/server/globals/email.ts
CHANGED
|
@@ -68,7 +68,7 @@ export namespace EmailService {
|
|
|
68
68
|
* the off-core mailer reports a result.
|
|
69
69
|
*
|
|
70
70
|
* `body` is the plain-text body; pass a non-empty `html` to send an HTML
|
|
71
|
-
* message (then `body` is the plain-text alternative
|
|
71
|
+
* message (then `body` is the plain-text alternative; set both for the best
|
|
72
72
|
* deliverability, or leave `body` empty for HTML-only). `purpose` is a short,
|
|
73
73
|
* non-PII tag used for host-side dedup/abuse keying.
|
|
74
74
|
*/
|
|
@@ -181,7 +181,7 @@ export class RenderedEmail {
|
|
|
181
181
|
* welcome.send('alice@example.com', vars, 'welcome');
|
|
182
182
|
*
|
|
183
183
|
* `{{ key }}` (with surrounding spaces) is accepted; an unknown placeholder
|
|
184
|
-
* renders to the empty string. `html` is optional
|
|
184
|
+
* renders to the empty string. `html` is optional: omit it for a plain-text
|
|
185
185
|
* template.
|
|
186
186
|
*/
|
|
187
187
|
export class EmailTemplate {
|
|
@@ -209,6 +209,19 @@ export class EmailTemplate {
|
|
|
209
209
|
const r = this.render(vars);
|
|
210
210
|
return EmailService.send(to, r.subject, r.body, purpose, r.html);
|
|
211
211
|
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Render and DETACHED-send to `to`: frames the message and returns via the
|
|
215
|
+
* non-suspending {@link EmailService.sendDetached}, so the call is constant
|
|
216
|
+
* time regardless of whether the recipient exists. Use for transactional auth
|
|
217
|
+
* mail (confirm / reset links, 2FA codes) whose delivery result the caller
|
|
218
|
+
* does not need; this is what keeps the built-in auth flows free of an
|
|
219
|
+
* email-enumeration timing oracle even when an app overrides the template.
|
|
220
|
+
*/
|
|
221
|
+
sendDetached(to: string, vars: Map<string, string>, purpose: string = 'tx'): EmailStatus {
|
|
222
|
+
const r = this.render(vars);
|
|
223
|
+
return EmailService.sendDetached(to, r.subject, r.body, purpose, r.html);
|
|
224
|
+
}
|
|
212
225
|
}
|
|
213
226
|
|
|
214
227
|
/**
|
package/server/globals/userid.ts
CHANGED
|
@@ -30,12 +30,18 @@ export class ToilUserId {
|
|
|
30
30
|
* unambiguous, and `domain` is the trusted tail supplied by the host, not the user.
|
|
31
31
|
*/
|
|
32
32
|
static derive(mldsaPublicKey: Uint8Array, identifier: string, domain: string): ToilUserId {
|
|
33
|
+
// Length-framed, domain-separated preimage: a u32 length prefix on every
|
|
34
|
+
// field keeps ids collision-free across tenants; the context tag separates
|
|
35
|
+
// this hash from other sha256 uses.
|
|
36
|
+
const ctx = Uint8Array.wrap(String.UTF8.encode(USERID_CONTEXT));
|
|
33
37
|
const id = Uint8Array.wrap(String.UTF8.encode(identifier));
|
|
34
38
|
const dom = Uint8Array.wrap(String.UTF8.encode(domain));
|
|
35
|
-
const buf = new Uint8Array(mldsaPublicKey.length + id.length + dom.length);
|
|
36
|
-
|
|
37
|
-
buf
|
|
38
|
-
buf
|
|
39
|
+
const buf = new Uint8Array(16 + ctx.length + mldsaPublicKey.length + id.length + dom.length);
|
|
40
|
+
let off = 0;
|
|
41
|
+
off = putField(buf, off, ctx);
|
|
42
|
+
off = putField(buf, off, mldsaPublicKey);
|
|
43
|
+
off = putField(buf, off, id);
|
|
44
|
+
off = putField(buf, off, dom);
|
|
39
45
|
return ToilUserId.fromBytes(crypto.sha256(buf));
|
|
40
46
|
}
|
|
41
47
|
|
|
@@ -105,3 +111,18 @@ export class ToilUserId {
|
|
|
105
111
|
return !this.equals(other);
|
|
106
112
|
}
|
|
107
113
|
}
|
|
114
|
+
|
|
115
|
+
/** Domain-separation tag for {@link ToilUserId.derive}; versioned. */
|
|
116
|
+
const USERID_CONTEXT: string = 'toil.userid.v1';
|
|
117
|
+
|
|
118
|
+
/** Append a u32-LE length prefix + `src` to `buf` at `off`; return the new offset. */
|
|
119
|
+
function putField(buf: Uint8Array, off: i32, src: Uint8Array): i32 {
|
|
120
|
+
const n = src.length;
|
|
121
|
+
buf[off] = <u8>(n & 0xff);
|
|
122
|
+
buf[off + 1] = <u8>((n >> 8) & 0xff);
|
|
123
|
+
buf[off + 2] = <u8>((n >> 16) & 0xff);
|
|
124
|
+
buf[off + 3] = <u8>((n >> 24) & 0xff);
|
|
125
|
+
off += 4;
|
|
126
|
+
buf.set(src, off);
|
|
127
|
+
return off + n;
|
|
128
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in auth email templates + their per-app OVERRIDE pipeline.
|
|
3
|
+
*
|
|
4
|
+
* The built-in auth controller (`server/auth/AuthController.ts`) sends three
|
|
5
|
+
* transactional emails: the email-verification link, the password-reset link,
|
|
6
|
+
* and the 2FA code. Their default subject/text/html live here as the single
|
|
7
|
+
* source of truth. An app overrides any of them by dropping a reserved-name
|
|
8
|
+
* React template in `emails/`:
|
|
9
|
+
*
|
|
10
|
+
* emails/auth-confirm.tsx -> email verification (interpolates `{link}`)
|
|
11
|
+
* emails/auth-reset.tsx -> password reset (interpolates `{link}`)
|
|
12
|
+
* emails/auth-2fa.tsx -> 2FA code (interpolates `{code}`)
|
|
13
|
+
*
|
|
14
|
+
* Whenever auth is on, the build (re)writes an ambient `_auth_emails.ts` into the
|
|
15
|
+
* toiljs `server/globals` LIB dir, exposing `AuthEmail.confirm/reset/twofa` baked
|
|
16
|
+
* with the app's override when present and the default otherwise. Because it rides
|
|
17
|
+
* the same `lib` set as `EmailService`/`AuthService`, the node_modules-compiled
|
|
18
|
+
* `AuthController` calls it with NO import (an exported namespace in a plain app
|
|
19
|
+
* entry would be module-scoped, not global). Every send goes through
|
|
20
|
+
* `EmailTemplate.sendDetached`, so overriding a template can never reintroduce the
|
|
21
|
+
* auth email-enumeration timing oracle the detached send closes.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
|
|
27
|
+
import pc from 'picocolors';
|
|
28
|
+
import { createServer } from 'vite';
|
|
29
|
+
|
|
30
|
+
import type { ResolvedToilConfig } from './config.js';
|
|
31
|
+
import { RESERVED_AUTH_EMAIL_NAMES, renderEmailFile, toPascal, type RenderedEmail } from './emails.js';
|
|
32
|
+
import { createViteConfig } from './vite.js';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The APP-LOCAL library directory the auth-email module is generated into,
|
|
36
|
+
* relative to the project root (posix, for the toilscript `--lib` CLI arg). It is
|
|
37
|
+
* passed to the compiler via `--lib` (see runToilscriptPass), which "uses exports
|
|
38
|
+
* of all top-level files at this path as globals". That is what makes the
|
|
39
|
+
* generated `AuthEmail` namespace resolvable from the node_modules-compiled
|
|
40
|
+
* `AuthController` with NO import, the same way `EmailService`/`AuthService` are
|
|
41
|
+
* ambient. Generating HERE (the app's own gitignored `.toil/`) rather than into
|
|
42
|
+
* `node_modules/toiljs/server/globals` keeps it PER-APP: a pnpm/workspace/linked
|
|
43
|
+
* install physically shares that package dir, so writing there would let one app
|
|
44
|
+
* clobber another's templates (or delete the file another app is compiling), and
|
|
45
|
+
* a read-only store would fail the build. `.toil` is always app-writable.
|
|
46
|
+
*/
|
|
47
|
+
export const AUTH_EMAILS_LIB_SUBDIR = '.toil/authlib';
|
|
48
|
+
|
|
49
|
+
/** The reserved override names (PascalCase), one per built-in auth email. */
|
|
50
|
+
type AuthName = 'AuthConfirm' | 'AuthReset' | 'Auth2fa';
|
|
51
|
+
|
|
52
|
+
/** One built-in auth email: its reserved override name, the token it fills, the
|
|
53
|
+
* host-side `purpose` tag, and the default subject/text/html (with `{{token}}`). */
|
|
54
|
+
interface AuthEmailSpec {
|
|
55
|
+
/** The generated `AuthEmail.<fn>` this feeds. */
|
|
56
|
+
readonly fn: 'confirm' | 'reset' | 'twofa';
|
|
57
|
+
/** The single runtime value the template interpolates. */
|
|
58
|
+
readonly token: 'link' | 'code';
|
|
59
|
+
/** Host dedup/abuse tag; must match what AuthController passed before. */
|
|
60
|
+
readonly purpose: string;
|
|
61
|
+
/** Default subject. Empty (and ignored) for `twofa`: AuthController passes login/setup subjects. */
|
|
62
|
+
readonly defaultSubject: string;
|
|
63
|
+
readonly defaultText: string;
|
|
64
|
+
readonly defaultHtml: string;
|
|
65
|
+
/** Whether the subject is a runtime arg (twofa) rather than a baked default (confirm/reset). */
|
|
66
|
+
readonly subjectFromArg: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The reserved override name -> its spec. Keys mirror {@link RESERVED_AUTH_EMAIL_NAMES}. */
|
|
70
|
+
const SPECS: Record<AuthName, AuthEmailSpec> = {
|
|
71
|
+
AuthConfirm: {
|
|
72
|
+
fn: 'confirm',
|
|
73
|
+
token: 'link',
|
|
74
|
+
purpose: 'verify',
|
|
75
|
+
subjectFromArg: false,
|
|
76
|
+
defaultSubject: 'Confirm your account',
|
|
77
|
+
defaultText: 'Confirm your account by opening this link:\n{{link}}\n',
|
|
78
|
+
defaultHtml:
|
|
79
|
+
'<p>Confirm your account by clicking the link below:</p>' +
|
|
80
|
+
'<p><a href="{{link}}">Confirm my account</a></p>' +
|
|
81
|
+
'<p>Or paste this into your browser:<br>{{link}}</p>',
|
|
82
|
+
},
|
|
83
|
+
AuthReset: {
|
|
84
|
+
fn: 'reset',
|
|
85
|
+
token: 'link',
|
|
86
|
+
purpose: 'reset',
|
|
87
|
+
subjectFromArg: false,
|
|
88
|
+
defaultSubject: 'Reset your password',
|
|
89
|
+
defaultText: 'Reset your password by opening this link:\n{{link}}\n',
|
|
90
|
+
defaultHtml:
|
|
91
|
+
'<p>We received a request to reset your password. Click the link below:</p>' +
|
|
92
|
+
'<p><a href="{{link}}">Reset my password</a></p>' +
|
|
93
|
+
'<p>If you did not request this, you can ignore this email.</p>' +
|
|
94
|
+
'<p>Or paste this into your browser:<br>{{link}}</p>',
|
|
95
|
+
},
|
|
96
|
+
Auth2fa: {
|
|
97
|
+
fn: 'twofa',
|
|
98
|
+
token: 'code',
|
|
99
|
+
purpose: '2fa',
|
|
100
|
+
subjectFromArg: true,
|
|
101
|
+
// Subject is supplied by AuthController (login vs setup), so it is not baked.
|
|
102
|
+
defaultSubject: '',
|
|
103
|
+
defaultText:
|
|
104
|
+
'Your verification code is {{code}}.\n' +
|
|
105
|
+
'It expires in a few minutes. If you did not request it, ignore this email.\n',
|
|
106
|
+
defaultHtml:
|
|
107
|
+
'<p>Your verification code is:</p>' +
|
|
108
|
+
'<p style="font-size:28px;font-weight:bold;letter-spacing:4px">{{code}}</p>' +
|
|
109
|
+
'<p>It expires in a few minutes. If you did not request it, ignore this email.</p>',
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const AUTH_NAMES = Object.keys(SPECS) as AuthName[];
|
|
114
|
+
|
|
115
|
+
/** The effective (override-or-default) parts for each auth email. */
|
|
116
|
+
type Parts = Record<AuthName, Effective>;
|
|
117
|
+
interface Effective {
|
|
118
|
+
readonly subject: string;
|
|
119
|
+
readonly text: string;
|
|
120
|
+
readonly html: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const GENERATED_BASENAME = '_auth_emails.ts';
|
|
124
|
+
|
|
125
|
+
function warn(msg: string): void {
|
|
126
|
+
process.stderr.write(` toil: auth emails ${msg}\n`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A valid AS/JS string literal (double-quoted, fully escaped). */
|
|
130
|
+
function asLit(s: string): string {
|
|
131
|
+
return JSON.stringify(s);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Every reserved auth-override file present in `emails/`, by PascalCase name. */
|
|
135
|
+
function reservedFilesIn(emailsDir: string): Map<AuthName, string> {
|
|
136
|
+
const out = new Map<AuthName, string>();
|
|
137
|
+
if (!fs.existsSync(emailsDir)) return out;
|
|
138
|
+
for (const f of fs.readdirSync(emailsDir).sort()) {
|
|
139
|
+
if (!/\.(tsx|jsx)$/.test(f)) continue;
|
|
140
|
+
const name = toPascal(f.replace(/\.(tsx|jsx)$/, ''));
|
|
141
|
+
if (RESERVED_AUTH_EMAIL_NAMES.has(name) && !out.has(name as AuthName))
|
|
142
|
+
out.set(name as AuthName, f);
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Fold a rendered override into the effective parts for `name`, warning about
|
|
149
|
+
* templates that will not interpolate correctly (a missing required token means
|
|
150
|
+
* the link/code would be absent; an extra token renders empty). A `subject` the
|
|
151
|
+
* renderer defaulted to the module name is treated as "no custom subject".
|
|
152
|
+
*/
|
|
153
|
+
function effectiveFromOverride(name: AuthName, spec: AuthEmailSpec, r: RenderedEmail): Effective {
|
|
154
|
+
if (!r.tokens.includes(spec.token))
|
|
155
|
+
warn(
|
|
156
|
+
`override emails/${name} never uses the {${spec.token}} prop, so the ` +
|
|
157
|
+
`${spec.token === 'link' ? 'link' : 'code'} will be missing from the email.`,
|
|
158
|
+
);
|
|
159
|
+
for (const t of r.tokens)
|
|
160
|
+
if (t !== spec.token)
|
|
161
|
+
warn(
|
|
162
|
+
`override emails/${name} uses {${t}}, which auth does not provide ` +
|
|
163
|
+
`(only {${spec.token}}); it will render empty.`,
|
|
164
|
+
);
|
|
165
|
+
// For twofa the subject is a runtime arg; for confirm/reset use the template's
|
|
166
|
+
// subject unless it defaulted to the component name (no `export const subject`).
|
|
167
|
+
const subject = spec.subjectFromArg || r.subject === name ? spec.defaultSubject : r.subject;
|
|
168
|
+
return { subject, text: r.text, html: r.html };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Codegen one `AuthEmail.<fn>` function. `twofa` takes its subject as an arg. */
|
|
172
|
+
function emitFn(spec: AuthEmailSpec, eff: Effective): string[] {
|
|
173
|
+
const subjectExpr = spec.subjectFromArg ? 'subject' : asLit(eff.subject);
|
|
174
|
+
const sig =
|
|
175
|
+
spec.fn === 'twofa'
|
|
176
|
+
? 'twofa(to: string, code: string, subject: string): void'
|
|
177
|
+
: `${spec.fn}(to: string, ${spec.token}: string): void`;
|
|
178
|
+
return [
|
|
179
|
+
` export function ${sig} {`,
|
|
180
|
+
` const T: string = ${asLit(eff.text)};`,
|
|
181
|
+
` const H: string = ${asLit(eff.html)};`,
|
|
182
|
+
` const v = new Map<string, string>();`,
|
|
183
|
+
` v.set(${asLit(spec.token)}, ${spec.token});`,
|
|
184
|
+
` new EmailTemplate(${subjectExpr}, T, H).sendDetached(to, v, ${asLit(spec.purpose)});`,
|
|
185
|
+
` }`,
|
|
186
|
+
];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Codegen the ambient `AuthEmail` AssemblyScript module. */
|
|
190
|
+
function moduleSource(parts: Parts): string {
|
|
191
|
+
const out: string[] = [
|
|
192
|
+
'// GENERATED by toiljs from the built-in auth email templates -- DO NOT EDIT.',
|
|
193
|
+
'// Override any of these by adding emails/auth-confirm.tsx, emails/auth-reset.tsx,',
|
|
194
|
+
'// or emails/auth-2fa.tsx (see docs/auth/emails.md). `EmailTemplate` is a toiljs',
|
|
195
|
+
'// global (server/globals/email.ts); the detached send keeps auth constant-time.',
|
|
196
|
+
'',
|
|
197
|
+
'export namespace AuthEmail {',
|
|
198
|
+
];
|
|
199
|
+
for (const name of AUTH_NAMES) out.push(...emitFn(SPECS[name], parts[name]));
|
|
200
|
+
out.push('}');
|
|
201
|
+
return out.join('\n') + '\n';
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Best-effort removal of a stale copy from the pre-0.0.107 location
|
|
206
|
+
* (`node_modules/toiljs/server/globals/_auth_emails.ts`). Leaving it there would
|
|
207
|
+
* define a SECOND `AuthEmail` namespace alongside the new app-local one and break
|
|
208
|
+
* the compile with a duplicate symbol, so a project upgraded in place (without a
|
|
209
|
+
* fresh `npm i`) is cleaned up here. Never throws (a read-only store just skips).
|
|
210
|
+
*/
|
|
211
|
+
function removeLegacyModule(root: string): void {
|
|
212
|
+
try {
|
|
213
|
+
const legacy = path.join(root, 'node_modules', 'toiljs', 'server', 'globals', GENERATED_BASENAME);
|
|
214
|
+
if (fs.existsSync(legacy)) fs.rmSync(legacy);
|
|
215
|
+
} catch {
|
|
216
|
+
// read-only / missing: nothing to clean, not fatal
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* (Re)write `_auth_emails.ts` into the app-local `.toil/authlib` LIB dir so the
|
|
222
|
+
* built-in auth controller has its email senders, baking any reserved
|
|
223
|
+
* `emails/*.tsx` override over the default. Runs BEFORE the toilscript server
|
|
224
|
+
* build (like {@link renderEmails}) so the module is compiled in via `--lib`. A
|
|
225
|
+
* no-op that removes a stale module when auth is off or there is no server. Only
|
|
226
|
+
* spins up Vite when an override actually exists.
|
|
227
|
+
*/
|
|
228
|
+
export async function renderAuthEmails(cfg: ResolvedToilConfig, authOn: boolean): Promise<void> {
|
|
229
|
+
const generatedPath = path.join(cfg.root, AUTH_EMAILS_LIB_SUBDIR, GENERATED_BASENAME);
|
|
230
|
+
removeLegacyModule(cfg.root);
|
|
231
|
+
|
|
232
|
+
// No server to compile into, or auth off: ensure no stale module lingers.
|
|
233
|
+
const hasServer = fs.existsSync(path.join(cfg.root, 'toilconfig.json'));
|
|
234
|
+
if (!authOn || !hasServer) {
|
|
235
|
+
if (fs.existsSync(generatedPath)) fs.rmSync(generatedPath);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const emailsDir = path.join(cfg.root, 'emails');
|
|
240
|
+
const overrides = reservedFilesIn(emailsDir);
|
|
241
|
+
|
|
242
|
+
// Start from every default, then render + fold in whatever the app overrides.
|
|
243
|
+
const parts = {} as Parts;
|
|
244
|
+
for (const name of AUTH_NAMES) {
|
|
245
|
+
const spec = SPECS[name];
|
|
246
|
+
parts[name] = { subject: spec.defaultSubject, text: spec.defaultText, html: spec.defaultHtml };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (overrides.size > 0) {
|
|
250
|
+
const { renderToStaticMarkup } = await import('react-dom/server');
|
|
251
|
+
const server = await createServer({
|
|
252
|
+
...(await createViteConfig(cfg)),
|
|
253
|
+
server: { middlewareMode: true, hmr: false },
|
|
254
|
+
appType: 'custom',
|
|
255
|
+
logLevel: 'silent',
|
|
256
|
+
});
|
|
257
|
+
try {
|
|
258
|
+
for (const [name, file] of overrides) {
|
|
259
|
+
try {
|
|
260
|
+
const r = await renderEmailFile(
|
|
261
|
+
server,
|
|
262
|
+
emailsDir,
|
|
263
|
+
file,
|
|
264
|
+
renderToStaticMarkup as (el: unknown) => string,
|
|
265
|
+
);
|
|
266
|
+
if (r) parts[name] = effectiveFromOverride(name, SPECS[name], r);
|
|
267
|
+
else warn(`skipped ${file} (no default-exported component)`);
|
|
268
|
+
} catch (err) {
|
|
269
|
+
warn(`skipped ${file} (${err instanceof Error ? err.message : String(err)})`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
} finally {
|
|
273
|
+
await server.close();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const next = moduleSource(parts);
|
|
278
|
+
const prev = fs.existsSync(generatedPath) ? fs.readFileSync(generatedPath, 'utf8') : null;
|
|
279
|
+
if (prev === next) return; // no change: do not bump mtime (would retrigger the dev watcher)
|
|
280
|
+
fs.mkdirSync(path.dirname(generatedPath), { recursive: true });
|
|
281
|
+
fs.writeFileSync(generatedPath, next);
|
|
282
|
+
if (overrides.size > 0)
|
|
283
|
+
process.stdout.write(
|
|
284
|
+
pc.green(' ✓ ') +
|
|
285
|
+
pc.dim(
|
|
286
|
+
`auth emails: overrode ${[...overrides.keys()].map((n) => SPECS[n].fn).join(', ')}`,
|
|
287
|
+
) +
|
|
288
|
+
'\n',
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** The generated module's basename, so the server watcher can ignore its own output. */
|
|
293
|
+
export const AUTH_EMAILS_GENERATED_BASENAME = GENERATED_BASENAME;
|
|
294
|
+
|
|
295
|
+
// Exported for unit testing the pure fold/codegen without a Vite server.
|
|
296
|
+
export const __test = { moduleSource, effectiveFromOverride, SPECS };
|
package/src/compiler/emails.ts
CHANGED
|
@@ -63,6 +63,15 @@ const REACT_INTERNAL = new Set([
|
|
|
63
63
|
'then', // so a thenable check never tokenizes
|
|
64
64
|
]);
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Reserved `emails/*.tsx` module names (by PascalCase basename) that OVERRIDE a
|
|
68
|
+
* built-in auth email instead of becoming a generic `Emails.<Name>.send(...)`
|
|
69
|
+
* entry. When one is present, the auth pipeline (src/compiler/auth-emails.ts)
|
|
70
|
+
* bakes it into `_auth_emails.ts` in place of the default; here we just make sure
|
|
71
|
+
* it does NOT also surface as an app-callable `Emails.*` template.
|
|
72
|
+
*/
|
|
73
|
+
export const RESERVED_AUTH_EMAIL_NAMES = new Set(['AuthConfirm', 'AuthReset', 'Auth2fa']);
|
|
74
|
+
|
|
66
75
|
const TOKEN_RE = /\{\{\s*([A-Za-z_$][\w$]*)\s*\}\}/g;
|
|
67
76
|
|
|
68
77
|
function extractTokens(s: string): string[] {
|
|
@@ -240,7 +249,7 @@ export function toPascal(base: string): string {
|
|
|
240
249
|
|
|
241
250
|
/** Server source dir (where the generated module must live to be compiled): the
|
|
242
251
|
* dir of the first toilconfig entry, else `<root>/server`. */
|
|
243
|
-
function serverDir(root: string): string {
|
|
252
|
+
export function serverDir(root: string): string {
|
|
244
253
|
try {
|
|
245
254
|
const cfg = JSON.parse(fs.readFileSync(path.join(root, 'toilconfig.json'), 'utf8')) as {
|
|
246
255
|
entries?: unknown;
|
|
@@ -316,7 +325,7 @@ function warn(msg: string): void {
|
|
|
316
325
|
* there is no `emails/` directory. Removes a stale generated module when the
|
|
317
326
|
* directory becomes empty.
|
|
318
327
|
*/
|
|
319
|
-
export async function renderEmails(cfg: ResolvedToilConfig): Promise<void> {
|
|
328
|
+
export async function renderEmails(cfg: ResolvedToilConfig, authOn = false): Promise<void> {
|
|
320
329
|
const emailsDir = path.join(cfg.root, 'emails');
|
|
321
330
|
const generatedPath = path.join(serverDir(cfg.root), GENERATED_BASENAME);
|
|
322
331
|
|
|
@@ -344,6 +353,15 @@ export async function renderEmails(cfg: ResolvedToilConfig): Promise<void> {
|
|
|
344
353
|
const rendered: RenderedEmail[] = [];
|
|
345
354
|
try {
|
|
346
355
|
for (const file of files) {
|
|
356
|
+
// Reserved auth-override templates (emails/auth-confirm.tsx, …) are
|
|
357
|
+
// consumed by the auth pipeline, not surfaced as generic `Emails.*`.
|
|
358
|
+
// Only reserved WHEN AUTH IS ON: a non-auth project keeping an email
|
|
359
|
+
// by that name still gets its normal `Emails.<Name>.send(...)`.
|
|
360
|
+
if (
|
|
361
|
+
authOn &&
|
|
362
|
+
RESERVED_AUTH_EMAIL_NAMES.has(toPascal(path.basename(file).replace(/\.(tsx|jsx)$/, '')))
|
|
363
|
+
)
|
|
364
|
+
continue;
|
|
347
365
|
try {
|
|
348
366
|
const r = await renderEmailFile(
|
|
349
367
|
server,
|