toiljs 0.0.104 → 0.0.106

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.
@@ -0,0 +1,91 @@
1
+ # Customizing the auth emails
2
+
3
+ Built-in auth sends three transactional emails: the email-verification link, the password-reset link, and the two-factor code. Each ships with a plain, working default. You can replace any of them with your own branded template, and you never touch the auth code to do it.
4
+
5
+ ## The three emails
6
+
7
+ | Email | When it sends | The value it gives your template |
8
+ | --- | --- | --- |
9
+ | Email verification | On register, when email confirmation is on | `link` (the confirm URL) |
10
+ | Password reset | On `POST /auth/reset/request` | `link` (the reset URL) |
11
+ | Two-factor code | On login or 2FA setup, for the email method | `code` (the numeric code) |
12
+
13
+ The defaults are intentionally minimal so a fresh project sends real, correct mail with no setup. To match your brand, override them.
14
+
15
+ ## How to override
16
+
17
+ Drop a React template in your project's `emails/` folder with the reserved name for that email. If the file exists, the build uses it; if not, the default stands. This is the same `emails/*.tsx` system you use for your own mail (see [Email](../services/email.md)), so you get full markup and inline CSS.
18
+
19
+ | Reserved file | Overrides | Prop it must read |
20
+ | --- | --- | --- |
21
+ | `emails/auth-confirm.tsx` | Email verification | `link` |
22
+ | `emails/auth-reset.tsx` | Password reset | `link` |
23
+ | `emails/auth-2fa.tsx` | Two-factor code | `code` |
24
+
25
+ Each template is a default-exported React component. Read the one prop it is given, and it becomes a `{{token}}` hole the edge fills per send. Reading any other prop renders empty, so the build warns you.
26
+
27
+ ```tsx
28
+ // emails/auth-confirm.tsx -> overrides the email-verification message.
29
+ export const subject = 'Verify your Acme account';
30
+
31
+ export default function AuthConfirm({ link }: { link: string }) {
32
+ return (
33
+ <div style={{ fontFamily: 'system-ui', padding: 24 }}>
34
+ <h1 style={{ color: '#cb9820' }}>Welcome to Acme</h1>
35
+ <p>Tap below to verify your email and finish signing up.</p>
36
+ <p>
37
+ <a
38
+ href={link}
39
+ style={{ background: '#cb9820', color: '#fff', padding: '10px 18px', borderRadius: 8, textDecoration: 'none' }}
40
+ >
41
+ Verify my email
42
+ </a>
43
+ </p>
44
+ <p style={{ color: '#666', fontSize: 13 }}>Or paste this link: {link}</p>
45
+ </div>
46
+ );
47
+ }
48
+ ```
49
+
50
+ The reset template works the same way with the `link` prop.
51
+
52
+ ## The subject line
53
+
54
+ For verification and reset, set the subject with `export const subject = '...'`. Leave it out and the default subject is used (`Confirm your account`, `Reset your password`).
55
+
56
+ The two-factor email is different: its subject is set by the framework because it changes with context (a login code versus a setup code). Your `emails/auth-2fa.tsx` override controls the body and HTML around the `code`, and the subject stays contextual. A `subject` export in that file is ignored.
57
+
58
+ ```tsx
59
+ // emails/auth-2fa.tsx -> overrides the 2FA code message (subject is contextual).
60
+ export default function Auth2fa({ code }: { code: string }) {
61
+ return (
62
+ <div style={{ fontFamily: 'system-ui', padding: 24, textAlign: 'center' }}>
63
+ <p>Your Acme verification code:</p>
64
+ <p style={{ fontSize: 32, fontWeight: 700, letterSpacing: 6 }}>{code}</p>
65
+ <p style={{ color: '#666' }}>It expires in a few minutes.</p>
66
+ </div>
67
+ );
68
+ }
69
+ ```
70
+
71
+ ## What you can and cannot put in
72
+
73
+ Each template is rendered once, at build time, into a static string of HTML with your styles inlined, and the prop becomes a fill hole. So a fixed layout and inline styles work, but per-send logic does not: a `{items.map(...)}` or a conditional runs at build, not per email. For these three messages that is all you need, one link or one code.
74
+
75
+ The only value auth hands each template is the one in the table above (`link` or `code`). There is no user name, no app name, no expiry value. Write those into the template text directly.
76
+
77
+ Styling rules are the same as any toiljs email: write inline `style={{ ... }}`, or import a stylesheet and its rules are inlined for you. A bare CSS import with no inlining has no effect in an email client. See [Email](../services/email.md) for the details.
78
+
79
+ ## Delivery stays safe
80
+
81
+ You cannot weaken the security of these flows by overriding a template. Every auth email is sent detached (fire-and-forget), so the response time never reveals whether an address maps to an account. That holds whether the message is the default or your override. Overriding only changes what the email looks like, never how it is sent.
82
+
83
+ ## Where the templates go
84
+
85
+ There is nothing to import and nothing to register. The build reads `emails/auth-*.tsx`, bakes the effective templates into the compiled server, and the auth controller sends them. Changing a template and rebuilding (or saving under `toiljs dev`) is all it takes.
86
+
87
+ ## Related
88
+
89
+ - [Email](../services/email.md): the full `emails/*.tsx` system, `EmailService`, and `EmailTemplate`.
90
+ - [Configuration](./configuration.md): the auth secrets and the email provider a deployment must set.
91
+ - [Usage](./usage.md): enabling auth, the client API, and guarding routes.
@@ -18,20 +18,29 @@ Types tie the three together. Change a field on the server and the client stops
18
18
 
19
19
  ## The problem it solves
20
20
 
21
- Most apps read fast and write slow. Pages load from caches everywhere, but a write, say a comment or an order, has to reach one database in one region. A user in Tokyo writing to Virginia waits for the trip there and back. That single region is also a single point of failure.
21
+ A good web app should be fast everywhere, secure by default, and able to grow without a rewrite. Getting there is the hard part, and most of it has nothing to do with your actual product.
22
22
 
23
- toil closes the gap in two moves. Your code runs at the edge instead of one origin. And ToilDB is built to distribute the writes, not just the reads: every key has a home region that orders its writes, while the data copies outward so reads stay local and quick.
23
+ You assemble it from rented parts: a host, serverless functions, a database, an auth provider, email, a cache, a queue, analytics, a realtime service. Each is its own account, bill, and SDK, and you keep them all in sync. Under that sits a decade of legacy tooling and a node_modules folder heavier than your app. Worse, the fast and secure version is never the default. A CDN, careful caching, region tuning, hardened auth, current crypto: all of it is extra work, so most apps ship slower and less safe than they should.
24
24
 
25
- Spreading reads is easy, and everyone does it. Spreading writes is the hard part, and it is why most "global" apps are only global for reading.
25
+ toil deletes that assembly. One framework runs your frontend, backend, and database at the edge, next to users. Auth, email, realtime, and background jobs are built in and owned. Login is post-quantum out of the box. There is nothing to wire up and no infrastructure to reason about. The good version is the only version.
26
+
27
+ Distributing writes worldwide is one of the hard problems it handles under the hood. Most stacks spread reads everywhere but pin every write to one region, so a user far from it waits for a round trip and that region is a single point of failure. ToilDB is built to distribute the writes too: every key has a home region that orders its writes while the data copies outward for fast local reads. You never set any of it up.
26
28
 
27
29
  ```mermaid
28
30
  flowchart TB
29
- subgraph Ordinary["The ordinary web"]
30
- U1["User in Tokyo"] -->|fast| E1["Edge (pages)"]
31
- E1 -->|slow, one region| DB1[("Database in Virginia")]
31
+ subgraph Assemble["The usual way: rent and wire the parts yourself"]
32
+ direction TB
33
+ A["Your app"] --> H["host"]
34
+ A --> F["functions"]
35
+ A --> D["database"]
36
+ A --> Au["auth"]
37
+ A --> Em["email"]
38
+ A --> Rt["realtime"]
39
+ A --> J["jobs"]
32
40
  end
33
- subgraph Toil["toil"]
34
- U2["User in Tokyo"] -->|fast| E2["Edge near Tokyo<br/>frontend + backend + data"]
41
+ subgraph Toil["toil: one framework, at the edge, by default"]
42
+ direction TB
43
+ A2["Your app"] --> T["frontend + backend + database + auth +<br/>email + realtime + jobs, built in and near users"]
35
44
  end
36
45
  ```
37
46
 
@@ -43,7 +52,7 @@ Read these in order. Each one builds on the last.
43
52
  2. **[What comes built in](./modern-stack.md)** The full list of what toil owns and runs for you, and what it does not.
44
53
  3. **[How toil works](./how-it-works.md)** The whole path, from a React click through WebAssembly to ToilDB and back.
45
54
  4. **[Why it scales cheaply](./hyperscale.md)** How one small program can serve the whole planet without a per-app server bill.
46
- 5. **[How toil distributes writes](./distributed.md)** The hardest problem in web infrastructure, and how ToilDB is built to solve it.
55
+ 5. **[How toil distributes writes](./distributed.md)** One of the hardest problems in web infrastructure, and how ToilDB is built to solve it.
47
56
  6. **[toil next to other stacks](./vs-other-frameworks.md)** A fair comparison with Next.js, Rails, serverless, and the rest, wins and losses both.
48
57
  7. **[The bar toil holds itself to](./design-principles.md)** The RSG rubric, and its one rule: your grade is your weakest part.
49
58
 
@@ -51,7 +60,7 @@ Read these in order. Each one builds on the last.
51
60
 
52
61
  - **Who it is for:** anyone shipping a real product who wants global speed without a platform team or ten stitched-together services.
53
62
  - **Why it is fast:** the code runs next to the user, with no trip to a distant origin.
54
- - **Why it is different:** it distributes writes, not only reads.
63
+ - **Why it is different:** the whole stack is built in and owned, so there is nothing to assemble, and it distributes writes worldwide, not only reads.
55
64
  - **Why it is safe:** the backend is sandboxed, passwords never reach the server in a usable form, secrets never ship in the code, and the browser checks every file it loads.
56
65
 
57
66
  Ready to build? Jump to [Getting started](../getting-started/README.md).
@@ -1,6 +1,6 @@
1
1
  # What makes toil hyper-scalable
2
2
 
3
- Hyper-scale means serving very large worldwide traffic at low latency without rebuilding your app as it grows. The test is simple. Traffic climbs from a thousand users to a hundred million across every continent. Do you rewrite the system, or just run more of it?
3
+ Hyper-scale means serving very large worldwide traffic at low latency without rebuilding your app as it grows. Picture traffic climbing from a thousand users to a hundred million across every continent. Do you rewrite the system, or just run more of it?
4
4
 
5
5
  Any stack can reach that scale if you spend enough: dedicated infrastructure per app, a rented vendor for each moving part, and an ops team to keep the seams from tearing. toil reaches the same scale from the other direction, cheaply. The whole design aims to make worldwide reach a default, not a budget line. This page is about cost.
6
6
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  This page compares toil to the stacks you already use, axis by axis. Every tool below is good at what it was built for. The aim is to be concrete about what you gain and what you give up. toil is younger than all of them, and that shows in a few rows.
4
4
 
5
- toil trades a large, mature ecosystem for one owned framework. That framework distributes writes and ships fast, safe defaults for free. Whether the trade fits your project is the question this page answers.
5
+ toil trades a large, mature ecosystem for one owned framework where the fast, secure, global version is the default. You give up SQL, the Node package universe, and a big integration catalog. You gain a stack that is built in and yours, runs near users, ships post-quantum auth and end-to-end types with no setup, and distributes writes worldwide instead of pinning them to one region. Whether that trade fits your project is what this page answers.
6
6
 
7
7
  ## The comparison table
8
8
 
@@ -24,13 +24,13 @@ The top rows lean toil's way. The bottom rows lean the incumbents' way. toil win
24
24
 
25
25
  ## How toil compares to each stack
26
26
 
27
- **Next.js / Vercel.** Superb React DX and global edge reads. The ceiling is the write path. Pages cache worldwide, but a write still resolves against one primary region. A comment, an order, a flash-sale click all crawl back to that box. Serverless cold starts add latency and per-invocation cost right when a spike hits. toil keeps a React-first client and moves the write to a home region near the data.
27
+ **Next.js / Vercel.** Superb React DX and global edge reads. The backend, though, is still yours to assemble: a database, auth, email, a queue, and a realtime service, each rented and wired up on its own. Serverless cold starts add latency and per-invocation cost right when a spike hits, and writes still resolve against one primary region. toil keeps a React-first client but ships the whole backend built in, running near the data with no cold start.
28
28
 
29
29
  **Rails / Django.** Mature, productive, and batteries included, with a deep hiring pool. If one region suits you, this is a great and boring choice. The default shape is a single-region monolith with one primary that every write must reach. You can add replicas and standbys to scale, but distributed writes are not in the model.
30
30
 
31
31
  **Serverless functions (Lambda, Cloud Functions).** Elastic, stateless compute that scales to zero. It still sits in front of a central database, so a write burst bottlenecks on that store. Each cold invocation bills and lags on its own.
32
32
 
33
- **Edge runtimes (Workers, Deno Deploy).** The closest in spirit to toil's compute model: your code runs near users. The catch is the database you attach. It is usually single-region, so edge compute becomes a faster front door to the same central write bottleneck.
33
+ **Edge runtimes (Workers, Deno Deploy).** The closest in spirit to toil's compute model: your code runs near users. The catch is everything you bolt on around it. The database you attach is usually single-region, and auth, email, realtime, and typed client calls are still yours to assemble. toil ships those built in, on a database designed to distribute writes.
34
34
 
35
35
  Cloudflare Durable Objects and D1 are the closest mainstream analog to toil's idea, and credit is due. A Durable Object gives one object a single-writer home that orders its writes, the same shape as ToilDB's per-key home. The difference is packaging. With the edge-runtime approach you assemble the pieces yourself: runtime, object or database product, auth, email, and realtime. toil ships distributed writes, the seven database families, auth, email, streaming, and background jobs as one owned stack. Which you prefer is a real trade-off.
36
36
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  toil is a full-stack framework. You write a React frontend and a TypeScript backend in one project, and toil runs both, plus a database, close to every user worldwide.
4
4
 
5
- The thesis is simple. toil is the modern full-stack tooling a developer actually wants: AAA-grade from the first line, and built to scale to the whole planet. Even a simple pizza site gets top-tier infrastructure with zero setup. Distributed writes are one pillar of that. The stack that just works is the core.
5
+ Shipping a fast, secure, global app normally takes a pile of rented services and a team that understands infrastructure. toil gives you that as the default: one framework, zero config, quantum-proof login, built on modern tech, running near your users from day one. The same setup serves a pizza site and a planet-scale app. Distributing writes worldwide is one of the hard things it handles for you, not the reason it exists.
6
6
 
7
7
  ## The problem with modern stacks
8
8
 
@@ -22,9 +22,7 @@ A solo builder and a funded startup hit the same wall. Top-tier infrastructure m
22
22
 
23
23
  ## What toil delivers
24
24
 
25
- Every design decision in toil serves one goal: AAA-grade infrastructure by default, not something you assemble by hand. One framework instead of ten rented vendors. Zero configuration. No distributed-systems or networking expertise required. It works out of the box, quantum-proof login included.
26
-
27
- Four pillars carry it.
25
+ toil was built to reach real scale cheaply, and every design choice serves that. You get top-tier infrastructure by default: one framework instead of ten vendors, zero configuration, and no networking knowledge required. Quantum-proof login is on from the start. Four things make it work.
28
26
 
29
27
  ### AAA-grade infrastructure by default
30
28
 
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "toiljs",
3
3
  "type": "module",
4
- "version": "0.0.104",
4
+ "version": "0.0.106",
5
5
  "author": "Dacely",
6
6
  "description": "The modern React framework: a file-based React frontend and a ToilScript-compiled WebAssembly backend.",
7
7
  "repository": {
@@ -1188,19 +1188,12 @@ class Auth {
1188
1188
  switch (method) {
1189
1189
  case TWOFA_EMAIL: {
1190
1190
  const code = randomCode(TWOFA_DIGITS);
1191
- const text =
1192
- 'Your verification code is ' +
1193
- code +
1194
- '.\nIt expires in a few minutes. If you did not request it, ignore this email.\n';
1195
- const html =
1196
- '<p>Your verification code is:</p>' +
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);
1191
+ // The subject is contextual (login vs setup) so AuthController owns it;
1192
+ // the body/html come from the generated `AuthEmail` — the built-in
1193
+ // default, or an `emails/auth-2fa.tsx` override filling `{code}`. The
1194
+ // send is DETACHED (constant-time, no provider-RTT parking), a property
1195
+ // the override cannot lose (AuthEmail.twofa uses sendDetached).
1196
+ AuthEmail.twofa(email, code, subject);
1204
1197
  return twoFaCodeHash(host, username, code);
1205
1198
  }
1206
1199
  // >>> case TWOFA_TOTP: { return twoFaSecretBinding(username); } <<<
@@ -1267,19 +1260,11 @@ class Auth {
1267
1260
  // Token in the URL FRAGMENT (see resetRequest): kept out of server/CDN
1268
1261
  // logs and the Referer header; the confirm page reads it from location.hash.
1269
1262
  const link = baseUrl(ctx) + '/confirm#token=' + crypto.toHex(raw);
1270
- const subject = 'Confirm your account';
1271
- const text = 'Confirm your account by opening this link:\n' + link + '\n';
1272
- const html =
1273
- '<p>Confirm your account by clicking the link below:</p>' +
1274
- '<p><a href="' +
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);
1263
+ // The built-in confirm email, or an `emails/auth-confirm.tsx` override filling
1264
+ // `{link}`. DETACHED (non-suspending) send: constant-time, no provider-RTT
1265
+ // parking on the "account exists" path (see the enumeration note on this
1266
+ // method); AuthEmail.confirm uses sendDetached, so an override keeps that.
1267
+ AuthEmail.confirm(email, link);
1283
1268
  }
1284
1269
 
1285
1270
  /** Email a password-reset link. Best-effort (the request path already
@@ -1292,19 +1277,10 @@ class Auth {
1292
1277
  * (Residual: the reset-token mint on the exists path still does a sub-ms
1293
1278
  * ToilDB write the miss path skips.) */
1294
1279
  private sendResetEmail(email: string, link: string): void {
1295
- const subject = 'Reset your password';
1296
- const text = 'Reset your password by opening this link:\n' + link + '\n';
1297
- const html =
1298
- '<p>We received a request to reset your password. Click the link below:</p>' +
1299
- '<p><a href="' +
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);
1280
+ // The built-in reset email, or an `emails/auth-reset.tsx` override filling
1281
+ // `{link}`. DETACHED (non-suspending) send: constant-time, closes the
1282
+ // reset-request email-enumeration timing oracle (see the note above);
1283
+ // AuthEmail.reset uses sendDetached, so an override keeps that property.
1284
+ AuthEmail.reset(email, link);
1309
1285
  }
1310
1286
  }
@@ -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 set both for the best
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 omit it for a plain-text
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
  /**
@@ -0,0 +1,279 @@
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
+ import { fileURLToPath } from 'node:url';
27
+
28
+ import pc from 'picocolors';
29
+ import { createServer } from 'vite';
30
+
31
+ import type { ResolvedToilConfig } from './config.js';
32
+ import { RESERVED_AUTH_EMAIL_NAMES, renderEmailFile, toPascal, type RenderedEmail } from './emails.js';
33
+ import { createViteConfig } from './vite.js';
34
+
35
+ /**
36
+ * The toiljs package's `server/globals` LIB directory: the ambient, no-import
37
+ * surface (`EmailService`, `AuthService`, …) the toilconfig `lib` array points
38
+ * at. The auth-email module is generated HERE, not in the app's `server/` dir,
39
+ * because that is what makes `AuthEmail` resolvable from the node_modules-compiled
40
+ * `AuthController` with NO import (an exported namespace in a plain app entry is
41
+ * module-scoped, not global). Prefers the app's install so a symlinked/linked
42
+ * toiljs is followed; falls back to the running package (hoisted install).
43
+ */
44
+ function globalsDir(root: string): string {
45
+ const inApp = path.join(root, 'node_modules', 'toiljs', 'server', 'globals');
46
+ if (fs.existsSync(inApp)) return inApp;
47
+ const pkgDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
48
+ return path.join(pkgDir, 'server', 'globals');
49
+ }
50
+
51
+ /** The reserved override names (PascalCase), one per built-in auth email. */
52
+ type AuthName = 'AuthConfirm' | 'AuthReset' | 'Auth2fa';
53
+
54
+ /** One built-in auth email: its reserved override name, the token it fills, the
55
+ * host-side `purpose` tag, and the default subject/text/html (with `{{token}}`). */
56
+ interface AuthEmailSpec {
57
+ /** The generated `AuthEmail.<fn>` this feeds. */
58
+ readonly fn: 'confirm' | 'reset' | 'twofa';
59
+ /** The single runtime value the template interpolates. */
60
+ readonly token: 'link' | 'code';
61
+ /** Host dedup/abuse tag; must match what AuthController passed before. */
62
+ readonly purpose: string;
63
+ /** Default subject. Empty (and ignored) for `twofa`: AuthController passes login/setup subjects. */
64
+ readonly defaultSubject: string;
65
+ readonly defaultText: string;
66
+ readonly defaultHtml: string;
67
+ /** Whether the subject is a runtime arg (twofa) rather than a baked default (confirm/reset). */
68
+ readonly subjectFromArg: boolean;
69
+ }
70
+
71
+ /** The reserved override name -> its spec. Keys mirror {@link RESERVED_AUTH_EMAIL_NAMES}. */
72
+ const SPECS: Record<AuthName, AuthEmailSpec> = {
73
+ AuthConfirm: {
74
+ fn: 'confirm',
75
+ token: 'link',
76
+ purpose: 'verify',
77
+ subjectFromArg: false,
78
+ defaultSubject: 'Confirm your account',
79
+ defaultText: 'Confirm your account by opening this link:\n{{link}}\n',
80
+ defaultHtml:
81
+ '<p>Confirm your account by clicking the link below:</p>' +
82
+ '<p><a href="{{link}}">Confirm my account</a></p>' +
83
+ '<p>Or paste this into your browser:<br>{{link}}</p>',
84
+ },
85
+ AuthReset: {
86
+ fn: 'reset',
87
+ token: 'link',
88
+ purpose: 'reset',
89
+ subjectFromArg: false,
90
+ defaultSubject: 'Reset your password',
91
+ defaultText: 'Reset your password by opening this link:\n{{link}}\n',
92
+ defaultHtml:
93
+ '<p>We received a request to reset your password. Click the link below:</p>' +
94
+ '<p><a href="{{link}}">Reset my password</a></p>' +
95
+ '<p>If you did not request this, you can ignore this email.</p>' +
96
+ '<p>Or paste this into your browser:<br>{{link}}</p>',
97
+ },
98
+ Auth2fa: {
99
+ fn: 'twofa',
100
+ token: 'code',
101
+ purpose: '2fa',
102
+ subjectFromArg: true,
103
+ // Subject is supplied by AuthController (login vs setup), so it is not baked.
104
+ defaultSubject: '',
105
+ defaultText:
106
+ 'Your verification code is {{code}}.\n' +
107
+ 'It expires in a few minutes. If you did not request it, ignore this email.\n',
108
+ defaultHtml:
109
+ '<p>Your verification code is:</p>' +
110
+ '<p style="font-size:28px;font-weight:bold;letter-spacing:4px">{{code}}</p>' +
111
+ '<p>It expires in a few minutes. If you did not request it, ignore this email.</p>',
112
+ },
113
+ };
114
+
115
+ const AUTH_NAMES = Object.keys(SPECS) as AuthName[];
116
+
117
+ /** The effective (override-or-default) parts for each auth email. */
118
+ type Parts = Record<AuthName, Effective>;
119
+ interface Effective {
120
+ readonly subject: string;
121
+ readonly text: string;
122
+ readonly html: string;
123
+ }
124
+
125
+ const GENERATED_BASENAME = '_auth_emails.ts';
126
+
127
+ function warn(msg: string): void {
128
+ process.stderr.write(` toil: auth emails ${msg}\n`);
129
+ }
130
+
131
+ /** A valid AS/JS string literal (double-quoted, fully escaped). */
132
+ function asLit(s: string): string {
133
+ return JSON.stringify(s);
134
+ }
135
+
136
+ /** Every reserved auth-override file present in `emails/`, by PascalCase name. */
137
+ function reservedFilesIn(emailsDir: string): Map<AuthName, string> {
138
+ const out = new Map<AuthName, string>();
139
+ if (!fs.existsSync(emailsDir)) return out;
140
+ for (const f of fs.readdirSync(emailsDir).sort()) {
141
+ if (!/\.(tsx|jsx)$/.test(f)) continue;
142
+ const name = toPascal(f.replace(/\.(tsx|jsx)$/, ''));
143
+ if (RESERVED_AUTH_EMAIL_NAMES.has(name) && !out.has(name as AuthName))
144
+ out.set(name as AuthName, f);
145
+ }
146
+ return out;
147
+ }
148
+
149
+ /**
150
+ * Fold a rendered override into the effective parts for `name`, warning about
151
+ * templates that will not interpolate correctly (a missing required token means
152
+ * the link/code would be absent; an extra token renders empty). A `subject` the
153
+ * renderer defaulted to the module name is treated as "no custom subject".
154
+ */
155
+ function effectiveFromOverride(name: AuthName, spec: AuthEmailSpec, r: RenderedEmail): Effective {
156
+ if (!r.tokens.includes(spec.token))
157
+ warn(
158
+ `override emails/${name} never uses the {${spec.token}} prop, so the ` +
159
+ `${spec.token === 'link' ? 'link' : 'code'} will be missing from the email.`,
160
+ );
161
+ for (const t of r.tokens)
162
+ if (t !== spec.token)
163
+ warn(
164
+ `override emails/${name} uses {${t}}, which auth does not provide ` +
165
+ `(only {${spec.token}}); it will render empty.`,
166
+ );
167
+ // For twofa the subject is a runtime arg; for confirm/reset use the template's
168
+ // subject unless it defaulted to the component name (no `export const subject`).
169
+ const subject = spec.subjectFromArg || r.subject === name ? spec.defaultSubject : r.subject;
170
+ return { subject, text: r.text, html: r.html };
171
+ }
172
+
173
+ /** Codegen one `AuthEmail.<fn>` function. `twofa` takes its subject as an arg. */
174
+ function emitFn(spec: AuthEmailSpec, eff: Effective): string[] {
175
+ const subjectExpr = spec.subjectFromArg ? 'subject' : asLit(eff.subject);
176
+ const sig =
177
+ spec.fn === 'twofa'
178
+ ? 'twofa(to: string, code: string, subject: string): void'
179
+ : `${spec.fn}(to: string, ${spec.token}: string): void`;
180
+ return [
181
+ ` export function ${sig} {`,
182
+ ` const T: string = ${asLit(eff.text)};`,
183
+ ` const H: string = ${asLit(eff.html)};`,
184
+ ` const v = new Map<string, string>();`,
185
+ ` v.set(${asLit(spec.token)}, ${spec.token});`,
186
+ ` new EmailTemplate(${subjectExpr}, T, H).sendDetached(to, v, ${asLit(spec.purpose)});`,
187
+ ` }`,
188
+ ];
189
+ }
190
+
191
+ /** Codegen the ambient `AuthEmail` AssemblyScript module. */
192
+ function moduleSource(parts: Parts): string {
193
+ const out: string[] = [
194
+ '// GENERATED by toiljs from the built-in auth email templates -- DO NOT EDIT.',
195
+ '// Override any of these by adding emails/auth-confirm.tsx, emails/auth-reset.tsx,',
196
+ '// or emails/auth-2fa.tsx (see docs/auth/emails.md). `EmailTemplate` is a toiljs',
197
+ '// global (server/globals/email.ts); the detached send keeps auth constant-time.',
198
+ '',
199
+ 'export namespace AuthEmail {',
200
+ ];
201
+ for (const name of AUTH_NAMES) out.push(...emitFn(SPECS[name], parts[name]));
202
+ out.push('}');
203
+ return out.join('\n') + '\n';
204
+ }
205
+
206
+ /**
207
+ * (Re)write `_auth_emails.ts` in the toiljs lib globals dir so the built-in auth
208
+ * controller has its email senders, baking any reserved `emails/*.tsx` override
209
+ * over the default. Runs BEFORE the toilscript server build (like
210
+ * {@link renderEmails}) so the module is compiled in. A no-op that removes a stale
211
+ * module when `authOn` is false. Only spins up Vite when an override actually
212
+ * exists.
213
+ */
214
+ export async function renderAuthEmails(cfg: ResolvedToilConfig, authOn: boolean): Promise<void> {
215
+ const generatedPath = path.join(globalsDir(cfg.root), GENERATED_BASENAME);
216
+
217
+ if (!authOn) {
218
+ if (fs.existsSync(generatedPath)) fs.rmSync(generatedPath);
219
+ return;
220
+ }
221
+
222
+ const emailsDir = path.join(cfg.root, 'emails');
223
+ const overrides = reservedFilesIn(emailsDir);
224
+
225
+ // Start from every default, then render + fold in whatever the app overrides.
226
+ const parts = {} as Parts;
227
+ for (const name of AUTH_NAMES) {
228
+ const spec = SPECS[name];
229
+ parts[name] = { subject: spec.defaultSubject, text: spec.defaultText, html: spec.defaultHtml };
230
+ }
231
+
232
+ if (overrides.size > 0) {
233
+ const { renderToStaticMarkup } = await import('react-dom/server');
234
+ const server = await createServer({
235
+ ...(await createViteConfig(cfg)),
236
+ server: { middlewareMode: true, hmr: false },
237
+ appType: 'custom',
238
+ logLevel: 'silent',
239
+ });
240
+ try {
241
+ for (const [name, file] of overrides) {
242
+ try {
243
+ const r = await renderEmailFile(
244
+ server,
245
+ emailsDir,
246
+ file,
247
+ renderToStaticMarkup as (el: unknown) => string,
248
+ );
249
+ if (r) parts[name] = effectiveFromOverride(name, SPECS[name], r);
250
+ else warn(`skipped ${file} (no default-exported component)`);
251
+ } catch (err) {
252
+ warn(`skipped ${file} (${err instanceof Error ? err.message : String(err)})`);
253
+ }
254
+ }
255
+ } finally {
256
+ await server.close();
257
+ }
258
+ }
259
+
260
+ const next = moduleSource(parts);
261
+ const prev = fs.existsSync(generatedPath) ? fs.readFileSync(generatedPath, 'utf8') : null;
262
+ if (prev === next) return; // no change: do not bump mtime (would retrigger the dev watcher)
263
+ fs.mkdirSync(path.dirname(generatedPath), { recursive: true });
264
+ fs.writeFileSync(generatedPath, next);
265
+ if (overrides.size > 0)
266
+ process.stdout.write(
267
+ pc.green(' ✓ ') +
268
+ pc.dim(
269
+ `auth emails: overrode ${[...overrides.keys()].map((n) => SPECS[n].fn).join(', ')}`,
270
+ ) +
271
+ '\n',
272
+ );
273
+ }
274
+
275
+ /** The generated module's basename, so the server watcher can ignore its own output. */
276
+ export const AUTH_EMAILS_GENERATED_BASENAME = GENERATED_BASENAME;
277
+
278
+ // Exported for unit testing the pure fold/codegen without a Vite server.
279
+ export const __test = { moduleSource, effectiveFromOverride, SPECS };