toiljs 0.0.105 → 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,165 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import pc from 'picocolors';
5
+ import { createServer } from 'vite';
6
+ import { RESERVED_AUTH_EMAIL_NAMES, renderEmailFile, toPascal } from './emails.js';
7
+ import { createViteConfig } from './vite.js';
8
+ function globalsDir(root) {
9
+ const inApp = path.join(root, 'node_modules', 'toiljs', 'server', 'globals');
10
+ if (fs.existsSync(inApp))
11
+ return inApp;
12
+ const pkgDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
13
+ return path.join(pkgDir, 'server', 'globals');
14
+ }
15
+ const SPECS = {
16
+ AuthConfirm: {
17
+ fn: 'confirm',
18
+ token: 'link',
19
+ purpose: 'verify',
20
+ subjectFromArg: false,
21
+ defaultSubject: 'Confirm your account',
22
+ defaultText: 'Confirm your account by opening this link:\n{{link}}\n',
23
+ defaultHtml: '<p>Confirm your account by clicking the link below:</p>' +
24
+ '<p><a href="{{link}}">Confirm my account</a></p>' +
25
+ '<p>Or paste this into your browser:<br>{{link}}</p>',
26
+ },
27
+ AuthReset: {
28
+ fn: 'reset',
29
+ token: 'link',
30
+ purpose: 'reset',
31
+ subjectFromArg: false,
32
+ defaultSubject: 'Reset your password',
33
+ defaultText: 'Reset your password by opening this link:\n{{link}}\n',
34
+ defaultHtml: '<p>We received a request to reset your password. Click the link below:</p>' +
35
+ '<p><a href="{{link}}">Reset my password</a></p>' +
36
+ '<p>If you did not request this, you can ignore this email.</p>' +
37
+ '<p>Or paste this into your browser:<br>{{link}}</p>',
38
+ },
39
+ Auth2fa: {
40
+ fn: 'twofa',
41
+ token: 'code',
42
+ purpose: '2fa',
43
+ subjectFromArg: true,
44
+ defaultSubject: '',
45
+ defaultText: 'Your verification code is {{code}}.\n' +
46
+ 'It expires in a few minutes. If you did not request it, ignore this email.\n',
47
+ defaultHtml: '<p>Your verification code is:</p>' +
48
+ '<p style="font-size:28px;font-weight:bold;letter-spacing:4px">{{code}}</p>' +
49
+ '<p>It expires in a few minutes. If you did not request it, ignore this email.</p>',
50
+ },
51
+ };
52
+ const AUTH_NAMES = Object.keys(SPECS);
53
+ const GENERATED_BASENAME = '_auth_emails.ts';
54
+ function warn(msg) {
55
+ process.stderr.write(` toil: auth emails ${msg}\n`);
56
+ }
57
+ function asLit(s) {
58
+ return JSON.stringify(s);
59
+ }
60
+ function reservedFilesIn(emailsDir) {
61
+ const out = new Map();
62
+ if (!fs.existsSync(emailsDir))
63
+ return out;
64
+ for (const f of fs.readdirSync(emailsDir).sort()) {
65
+ if (!/\.(tsx|jsx)$/.test(f))
66
+ continue;
67
+ const name = toPascal(f.replace(/\.(tsx|jsx)$/, ''));
68
+ if (RESERVED_AUTH_EMAIL_NAMES.has(name) && !out.has(name))
69
+ out.set(name, f);
70
+ }
71
+ return out;
72
+ }
73
+ function effectiveFromOverride(name, spec, r) {
74
+ if (!r.tokens.includes(spec.token))
75
+ warn(`override emails/${name} never uses the {${spec.token}} prop, so the ` +
76
+ `${spec.token === 'link' ? 'link' : 'code'} will be missing from the email.`);
77
+ for (const t of r.tokens)
78
+ if (t !== spec.token)
79
+ warn(`override emails/${name} uses {${t}}, which auth does not provide ` +
80
+ `(only {${spec.token}}); it will render empty.`);
81
+ const subject = spec.subjectFromArg || r.subject === name ? spec.defaultSubject : r.subject;
82
+ return { subject, text: r.text, html: r.html };
83
+ }
84
+ function emitFn(spec, eff) {
85
+ const subjectExpr = spec.subjectFromArg ? 'subject' : asLit(eff.subject);
86
+ const sig = spec.fn === 'twofa'
87
+ ? 'twofa(to: string, code: string, subject: string): void'
88
+ : `${spec.fn}(to: string, ${spec.token}: string): void`;
89
+ return [
90
+ ` export function ${sig} {`,
91
+ ` const T: string = ${asLit(eff.text)};`,
92
+ ` const H: string = ${asLit(eff.html)};`,
93
+ ` const v = new Map<string, string>();`,
94
+ ` v.set(${asLit(spec.token)}, ${spec.token});`,
95
+ ` new EmailTemplate(${subjectExpr}, T, H).sendDetached(to, v, ${asLit(spec.purpose)});`,
96
+ ` }`,
97
+ ];
98
+ }
99
+ function moduleSource(parts) {
100
+ const out = [
101
+ '// GENERATED by toiljs from the built-in auth email templates -- DO NOT EDIT.',
102
+ '// Override any of these by adding emails/auth-confirm.tsx, emails/auth-reset.tsx,',
103
+ '// or emails/auth-2fa.tsx (see docs/auth/emails.md). `EmailTemplate` is a toiljs',
104
+ '// global (server/globals/email.ts); the detached send keeps auth constant-time.',
105
+ '',
106
+ 'export namespace AuthEmail {',
107
+ ];
108
+ for (const name of AUTH_NAMES)
109
+ out.push(...emitFn(SPECS[name], parts[name]));
110
+ out.push('}');
111
+ return out.join('\n') + '\n';
112
+ }
113
+ export async function renderAuthEmails(cfg, authOn) {
114
+ const generatedPath = path.join(globalsDir(cfg.root), GENERATED_BASENAME);
115
+ if (!authOn) {
116
+ if (fs.existsSync(generatedPath))
117
+ fs.rmSync(generatedPath);
118
+ return;
119
+ }
120
+ const emailsDir = path.join(cfg.root, 'emails');
121
+ const overrides = reservedFilesIn(emailsDir);
122
+ const parts = {};
123
+ for (const name of AUTH_NAMES) {
124
+ const spec = SPECS[name];
125
+ parts[name] = { subject: spec.defaultSubject, text: spec.defaultText, html: spec.defaultHtml };
126
+ }
127
+ if (overrides.size > 0) {
128
+ const { renderToStaticMarkup } = await import('react-dom/server');
129
+ const server = await createServer({
130
+ ...(await createViteConfig(cfg)),
131
+ server: { middlewareMode: true, hmr: false },
132
+ appType: 'custom',
133
+ logLevel: 'silent',
134
+ });
135
+ try {
136
+ for (const [name, file] of overrides) {
137
+ try {
138
+ const r = await renderEmailFile(server, emailsDir, file, renderToStaticMarkup);
139
+ if (r)
140
+ parts[name] = effectiveFromOverride(name, SPECS[name], r);
141
+ else
142
+ warn(`skipped ${file} (no default-exported component)`);
143
+ }
144
+ catch (err) {
145
+ warn(`skipped ${file} (${err instanceof Error ? err.message : String(err)})`);
146
+ }
147
+ }
148
+ }
149
+ finally {
150
+ await server.close();
151
+ }
152
+ }
153
+ const next = moduleSource(parts);
154
+ const prev = fs.existsSync(generatedPath) ? fs.readFileSync(generatedPath, 'utf8') : null;
155
+ if (prev === next)
156
+ return;
157
+ fs.mkdirSync(path.dirname(generatedPath), { recursive: true });
158
+ fs.writeFileSync(generatedPath, next);
159
+ if (overrides.size > 0)
160
+ process.stdout.write(pc.green(' ✓ ') +
161
+ pc.dim(`auth emails: overrode ${[...overrides.keys()].map((n) => SPECS[n].fn).join(', ')}`) +
162
+ '\n');
163
+ }
164
+ export const AUTH_EMAILS_GENERATED_BASENAME = GENERATED_BASENAME;
165
+ export const __test = { moduleSource, effectiveFromOverride, SPECS };
@@ -14,12 +14,14 @@ export interface RenderedEmail {
14
14
  tokens: string[];
15
15
  purpose: string;
16
16
  }
17
+ export declare const RESERVED_AUTH_EMAIL_NAMES: Set<string>;
17
18
  declare function tokenProps(seen: Set<string>): Record<string, unknown>;
18
19
  declare function htmlToText(html: string): string;
19
20
  declare function renderModule(name: string, mod: EmailModule, render: (el: unknown) => string, css?: string): Promise<RenderedEmail | null>;
20
21
  export declare function collectModuleCss(server: ViteDevServer, moduleId: string): Promise<string>;
21
22
  export declare function renderEmailFile(server: ViteDevServer, emailsDir: string, file: string, render: (el: unknown) => string): Promise<RenderedEmail | null>;
22
23
  export declare function toPascal(base: string): string;
24
+ export declare function serverDir(root: string): string;
23
25
  declare function renderModuleSource(rendered: RenderedEmail[]): string;
24
26
  export declare function renderEmails(cfg: ResolvedToilConfig): Promise<void>;
25
27
  export declare const __test: {
@@ -15,6 +15,7 @@ const REACT_INTERNAL = new Set([
15
15
  'constructor',
16
16
  'then',
17
17
  ]);
18
+ export const RESERVED_AUTH_EMAIL_NAMES = new Set(['AuthConfirm', 'AuthReset', 'Auth2fa']);
18
19
  const TOKEN_RE = /\{\{\s*([A-Za-z_$][\w$]*)\s*\}\}/g;
19
20
  function extractTokens(s) {
20
21
  const out = [];
@@ -133,7 +134,7 @@ export function toPascal(base) {
133
134
  .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
134
135
  .join('');
135
136
  }
136
- function serverDir(root) {
137
+ export function serverDir(root) {
137
138
  try {
138
139
  const cfg = JSON.parse(fs.readFileSync(path.join(root, 'toilconfig.json'), 'utf8'));
139
140
  const first = Array.isArray(cfg.entries)
@@ -216,6 +217,8 @@ export async function renderEmails(cfg) {
216
217
  const rendered = [];
217
218
  try {
218
219
  for (const file of files) {
220
+ if (RESERVED_AUTH_EMAIL_NAMES.has(toPascal(path.basename(file).replace(/\.(tsx|jsx)$/, ''))))
221
+ continue;
219
222
  try {
220
223
  const r = await renderEmailFile(server, emailsDir, file, renderToStaticMarkup);
221
224
  if (r)
@@ -8,6 +8,7 @@ import pc from 'picocolors';
8
8
  import { build as viteBuild, createServer, mergeConfig } from 'vite';
9
9
  import { loadConfig } from './config.js';
10
10
  import { renderEmails } from './emails.js';
11
+ import { AUTH_EMAILS_GENERATED_BASENAME, renderAuthEmails } from './auth-emails.js';
11
12
  import { generate, TOIL_SERVER_ENV_DTS } from './generate.js';
12
13
  import { prerenderStaticParams } from './ssg.js';
13
14
  import { extractDevSsrTemplates, extractServerSlots, extractTemplates, } from './template-build.js';
@@ -118,6 +119,13 @@ function serverImportsAuth(root, files) {
118
119
  }
119
120
  return false;
120
121
  }
122
+ function serverAuthEnabled(root, configAuth) {
123
+ if (configAuth)
124
+ return true;
125
+ if (!fs.existsSync(path.join(root, 'toilconfig.json')))
126
+ return false;
127
+ return serverImportsAuth(root, serverEntryFiles(root));
128
+ }
121
129
  export async function buildServer(root, auth = false) {
122
130
  if (!fs.existsSync(path.join(root, 'toilconfig.json')))
123
131
  return;
@@ -361,6 +369,7 @@ function watchServer(cfg, watcher) {
361
369
  building = true;
362
370
  process.stdout.write(pc.dim(' server changed, rebuilding…') + '\n');
363
371
  renderEmails(cfg)
372
+ .then(() => renderAuthEmails(cfg, serverAuthEnabled(root, cfg.auth)))
364
373
  .then(() => buildServer(root, cfg.auth))
365
374
  .then(() => process.stdout.write(pc.green(' ✓ ') + pc.dim('server rebuilt') + '\n'))
366
375
  .catch((e) => process.stdout.write(pc.red(` ✗ server rebuild failed: ${String(e)}`) + '\n'))
@@ -376,6 +385,7 @@ function watchServer(cfg, watcher) {
376
385
  const isServerSource = (file) => file.endsWith('.ts') &&
377
386
  !file.endsWith('.d.ts') &&
378
387
  path.basename(file) !== '_emails.ts' &&
388
+ path.basename(file) !== AUTH_EMAILS_GENERATED_BASENAME &&
379
389
  dirs.some((dir) => file === dir || file.startsWith(dir + path.sep));
380
390
  const isEmailSource = (file) => /\.(tsx|jsx)$/.test(file) && (file === emailsDir || file.startsWith(emailsDir + path.sep));
381
391
  watcher.on('error', (err) => {
@@ -552,6 +562,7 @@ export async function dev(opts = {}) {
552
562
  if (hasServer)
553
563
  process.stdout.write(pc.dim(' building the server (toilscript)…') + '\n');
554
564
  await renderEmails(cfg);
565
+ await renderAuthEmails(cfg, serverAuthEnabled(cfg.root, cfg.auth));
555
566
  generate(cfg);
556
567
  if (hasServer)
557
568
  await extractServerSlots(cfg);
@@ -636,6 +647,7 @@ export async function build(opts = {}) {
636
647
  if (hasServer && !opts.serverOnly)
637
648
  process.stdout.write(pc.dim(' building the server (toilscript)…') + '\n');
638
649
  await renderEmails(cfg);
650
+ await renderAuthEmails(cfg, serverAuthEnabled(cfg.root, cfg.auth));
639
651
  generate(cfg);
640
652
  const priorServerSlots = hasServer ? await extractServerSlots(cfg) : new Map();
641
653
  await buildServer(cfg.root, cfg.auth);
@@ -1,9 +1,10 @@
1
1
  export const TOIL_DOCS = {
2
- "README.md": "# toiljs\n\ntoiljs is a full-stack web framework. You write your **frontend in React** and your **backend in\nTypeScript**, and toiljs turns the backend into a tiny, fast **WebAssembly** program that runs at the edge\n(on servers close to your users, all over the world). One language, one project, one deploy.\n\nIf you have used Next.js, this will feel familiar: file-based routes, server code next to client code, a dev\nserver with hot reload. The difference is what happens underneath. Your server code is compiled by\n**toilscript** (a TypeScript-to-WebAssembly compiler) into a sandboxed `.wasm` module, and it runs on the\n**Dacely edge** with a built-in worldwide database (**ToilDB**), streaming, background jobs, and auth all\nincluded.\n\n## The mental model\n\n```mermaid\nflowchart LR\n A[\"Your project<br/>(TypeScript + React)\"] -->|toiljs build| B[\"client bundle<br/>(React, runs in the browser)\"]\n A -->|toilscript compile| C[\"server.wasm<br/>(your backend, sandboxed)\"]\n B --> U[\"User's browser\"]\n C --> E[\"Dacely edge<br/>(worldwide)\"]\n E --> D[(\"ToilDB<br/>global database\")]\n U <-->|HTTP / WebTransport| E\n```\n\n- **client/** is your React app (pages, components, styles). It runs in the browser.\n- **server/** is your backend (routes, database, auth). It compiles to WebAssembly and runs on the edge.\n- **shared/** is the typed bridge: toiljs generates a client here so the browser calls your server with full\n type safety.\n\n## Hello, toiljs\n\n```ts\n// server/routes/Hello.ts (a backend HTTP route)\nimport { Response } from 'toiljs/server/runtime';\n\n@rest('hello')\nclass Hello {\n @get('/')\n public hi(): Response {\n return Response.text('Hello from the edge!\\n');\n }\n}\n```\n\n```tsx\n// client/routes/index.tsx (a frontend page)\nexport default function Home() {\n return <main><h1>Welcome</h1></main>;\n}\n```\n\nRun `toiljs dev`, open the browser, and both are live with hot reload. That is the whole loop.\n\n## Learn toiljs\n\n**Understand toil first**\n- [Understanding toil](./introduction/README.md): what toil is and the one big idea, then\n [why toil and who it is for](./introduction/why-toil.md),\n [the modern stack you get](./introduction/modern-stack.md),\n [how it works](./introduction/how-it-works.md),\n [what makes it hyper-scalable](./introduction/hyperscale.md),\n [how it is distributed](./introduction/distributed.md),\n [toil versus other frameworks](./introduction/vs-other-frameworks.md), and\n [why it is built this way](./introduction/design-principles.md).\n\n**Start here**\n- [Getting started](./getting-started/README.md): install, create a project, project structure, your first\n app, and migrating an existing React app.\n- [The CLI](./cli/README.md): `dev`, `build`, `create`, `doctor`, and every flag.\n- [Deploy](./getting-started/deploy.md): build for production, self-host it, and how the managed edge fits in.\n\n**Build the frontend**\n- [Frontend overview](./frontend/README.md), [Routing](./frontend/routing.md),\n [Navigation](./frontend/navigation.md), [Components](./frontend/components.md),\n [Rendering and SSR](./frontend/rendering.md), [Styling](./frontend/styling.md),\n [Images](./frontend/images.md), [Metadata and SEO](./frontend/metadata.md),\n [Fetching data](./frontend/data-fetching.md), [Scripts](./frontend/scripts.md),\n [Search](./frontend/search.md), [The Toil global (reference)](./frontend/toil-global.md).\n\n**Build the backend**\n- [Backend overview](./backend/README.md), [HTTP routes (`@rest`)](./backend/rest.md),\n [Typed RPC (`@service`/`@remote`)](./backend/rpc.md), [Data types (`@data`)](./backend/data.md).\n\n**The database (ToilDB)**\n- [Database overview and choosing a family](./database/README.md), [Setup (`@database`)](./database/setup.md),\n [Documents](./database/documents.md), [Unique](./database/unique.md), [Counters](./database/counters.md),\n [Events](./database/events.md), [Views and `@derive`](./database/views.md),\n [Membership](./database/membership.md), [Capacity](./database/capacity.md).\n\n**Auth**: [the full auth guide](./auth/README.md) covers post-quantum login, sessions, and `ToilUserId`.\n\n**Realtime and background**\n- [Streams](./realtime/README.md) and [channels](./realtime/channels.md), [Daemons and scheduled\n jobs](./background/daemons.md), [Derived views (`@derive`)](./background/derive.md).\n\n**Platform services**\n- [Caching](./services/caching.md), [Rate limiting](./services/ratelimit.md),\n [Environment and secrets](./services/environment.md), [Email and 2FA](./services/email.md),\n [Analytics](./services/analytics.md), [Crypto](./services/crypto.md), [Cookies](./services/cookies.md),\n [Time](./services/time.md).\n\n**Concepts and reference**\n- [Writing toiljs correctly (AI + human quick rules)](./concepts/ai-guide.md),\n [Compute tiers (L1 to L4)](./concepts/tiers.md), [Types (u64, u256, and friends)](./concepts/types.md),\n [Every decorator](./concepts/decorators.md), [Configuration](./concepts/config.md),\n [Security and SRI](./concepts/security.md).\n",
2
+ "README.md": "# toiljs\n\ntoiljs is a full-stack web framework. You write your **frontend in React** and your **backend in\nTypeScript**, and toiljs turns the backend into a tiny, fast **WebAssembly** program that runs at the edge\n(on servers close to your users, all over the world). One language, one project, one deploy.\n\nIf you have used Next.js, this will feel familiar: file-based routes, server code next to client code, a dev\nserver with hot reload. The difference is what happens underneath. Your server code is compiled by\n**toilscript** (a TypeScript-to-WebAssembly compiler) into a sandboxed `.wasm` module, and it runs on the\n**Dacely edge** with a built-in worldwide database (**ToilDB**), streaming, background jobs, and auth all\nincluded.\n\n## The mental model\n\n```mermaid\nflowchart LR\n A[\"Your project<br/>(TypeScript + React)\"] -->|toiljs build| B[\"client bundle<br/>(React, runs in the browser)\"]\n A -->|toilscript compile| C[\"server.wasm<br/>(your backend, sandboxed)\"]\n B --> U[\"User's browser\"]\n C --> E[\"Dacely edge<br/>(worldwide)\"]\n E --> D[(\"ToilDB<br/>global database\")]\n U <-->|HTTP / WebTransport| E\n```\n\n- **client/** is your React app (pages, components, styles). It runs in the browser.\n- **server/** is your backend (routes, database, auth). It compiles to WebAssembly and runs on the edge.\n- **shared/** is the typed bridge: toiljs generates a client here so the browser calls your server with full\n type safety.\n\n## Hello, toiljs\n\n```ts\n// server/routes/Hello.ts (a backend HTTP route)\nimport { Response } from 'toiljs/server/runtime';\n\n@rest('hello')\nclass Hello {\n @get('/')\n public hi(): Response {\n return Response.text('Hello from the edge!\\n');\n }\n}\n```\n\n```tsx\n// client/routes/index.tsx (a frontend page)\nexport default function Home() {\n return <main><h1>Welcome</h1></main>;\n}\n```\n\nRun `toiljs dev`, open the browser, and both are live with hot reload. That is the whole loop.\n\n## Learn toiljs\n\n**Understand toil first**\n- [Understanding toil](./introduction/README.md): what toil is and the one big idea, then\n [why toil and who it is for](./introduction/why-toil.md),\n [the modern stack you get](./introduction/modern-stack.md),\n [how it works](./introduction/how-it-works.md),\n [what makes it hyper-scalable](./introduction/hyperscale.md),\n [how it is distributed](./introduction/distributed.md),\n [toil versus other frameworks](./introduction/vs-other-frameworks.md), and\n [why it is built this way](./introduction/design-principles.md).\n\n**Start here**\n- [Getting started](./getting-started/README.md): install, create a project, project structure, your first\n app, and migrating an existing React app.\n- [The CLI](./cli/README.md): `dev`, `build`, `create`, `doctor`, and every flag.\n- [Deploy](./getting-started/deploy.md): build for production, self-host it, and how the managed edge fits in.\n\n**Build the frontend**\n- [Frontend overview](./frontend/README.md), [Routing](./frontend/routing.md),\n [Navigation](./frontend/navigation.md), [Components](./frontend/components.md),\n [Rendering and SSR](./frontend/rendering.md), [Styling](./frontend/styling.md),\n [Images](./frontend/images.md), [Metadata and SEO](./frontend/metadata.md),\n [Fetching data](./frontend/data-fetching.md), [Scripts](./frontend/scripts.md),\n [Search](./frontend/search.md), [The Toil global (reference)](./frontend/toil-global.md).\n\n**Build the backend**\n- [Backend overview](./backend/README.md), [HTTP routes (`@rest`)](./backend/rest.md),\n [Typed RPC (`@service`/`@remote`)](./backend/rpc.md), [Data types (`@data`)](./backend/data.md).\n\n**The database (ToilDB)**\n- [Database overview and choosing a family](./database/README.md), [Setup (`@database`)](./database/setup.md),\n [Documents](./database/documents.md), [Unique](./database/unique.md), [Counters](./database/counters.md),\n [Events](./database/events.md), [Views and `@derive`](./database/views.md),\n [Membership](./database/membership.md), [Capacity](./database/capacity.md).\n\n**Auth**: [the full auth guide](./auth/README.md) covers post-quantum login, sessions, and `ToilUserId`;\n[customizing the auth emails](./auth/emails.md) shows how to brand the verification, reset, and 2FA mail.\n\n**Realtime and background**\n- [Streams](./realtime/README.md) and [channels](./realtime/channels.md), [Daemons and scheduled\n jobs](./background/daemons.md), [Derived views (`@derive`)](./background/derive.md).\n\n**Platform services**\n- [Caching](./services/caching.md), [Rate limiting](./services/ratelimit.md),\n [Environment and secrets](./services/environment.md), [Email and 2FA](./services/email.md),\n [Analytics](./services/analytics.md), [Crypto](./services/crypto.md), [Cookies](./services/cookies.md),\n [Time](./services/time.md).\n\n**Concepts and reference**\n- [Writing toiljs correctly (AI + human quick rules)](./concepts/ai-guide.md),\n [Compute tiers (L1 to L4)](./concepts/tiers.md), [Types (u64, u256, and friends)](./concepts/types.md),\n [Every decorator](./concepts/decorators.md), [Configuration](./concepts/config.md),\n [Security and SRI](./concepts/security.md).\n",
3
3
  "auth/configuration.md": "# Configuring auth for production\n\nBuilt-in auth runs locally with **zero configuration**, it falls back to published, insecure DEV secrets\nso `toiljs dev` Just Works. **A deployment MUST replace all three secrets and pin its KEM key.** This page\nis the checklist.\n\n## The secrets\n\nAuth reads these from the tenant environment store (locally, `.env.secrets`; on the edge, the per-host\nsecure env). They resolve **lazily** the first time auth runs, so no startup wiring is needed.\n\n| Key | What it is | Dev fallback |\n| --- | --- | --- |\n| `AUTH_SESSION_SECRET` | HMAC-SHA256 key that signs the session cookie. Must be identical on every edge instance (a cookie minted anywhere must verify everywhere). | a public constant, **anyone can forge a session** |\n| `AUTH_OPRF_SEED` | Master seed for the per-user OPRF salt key. Rotating it invalidates every password (users must re-register). | a hashed public constant |\n| `AUTH_KEM_SK` | The server's ML-KEM-768 **secret** key (hex). Its public half is what the client encapsulates to. | a pinned dev key pair |\n\n```bash\n# .env.secrets (gitignored; mode 0600 on the edge, NEVER under hosts/, NEVER in the .wasm)\nAUTH_SESSION_SECRET=…64 hex chars (32 bytes)…\nAUTH_OPRF_SEED=…64 hex chars (32 bytes)…\nAUTH_KEM_SK=…hex of an ML-KEM-768 secret key…\n```\n\n### Generating them\n\n`AUTH_SESSION_SECRET` and `AUTH_OPRF_SEED` are just 32 random bytes each:\n\n```bash\nnode -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"\n```\n\n`AUTH_KEM_SK` is an ML-KEM-768 key pair. Generate it and keep BOTH halves, the secret goes in the env, the\npublic half is pinned in the client (next section):\n\n```ts\nimport { ml_kem768 } from '@dacely/noble-post-quantum/ml-kem';\nconst { secretKey, publicKey } = ml_kem768.keygen();\nconsole.log('AUTH_KEM_SK =', Buffer.from(secretKey).toString('hex'));\nconsole.log('client serverKemPublicKey =', Buffer.from(publicKey).toString('hex'));\n```\n\n## Pin the client's KEM public key\n\nThe browser must know the server's genuine KEM public key to run the mutual-auth handshake, this is the\nanti-phishing anchor. The `toiljs/client` `Auth` helper ships with the **dev** key pinned, so a deployment\nMUST pass its own:\n\n```ts\nimport { Auth } from 'toiljs/client';\n\nconst SERVER_KEM_PUBLIC_KEY = /* the publicKey bytes from AUTH_KEM_SK */;\n\nawait Auth.login(username, password, { serverKemPublicKey: SERVER_KEM_PUBLIC_KEY });\nawait Auth.register(username, password, { serverKemPublicKey: SERVER_KEM_PUBLIC_KEY });\n```\n\nShip the public key with your client bundle (it's public, safe to embed). If it doesn't match the server's\n`AUTH_KEM_SK`, login's `serverConfirm` check fails and the client aborts.\n\n## Optional: audience & domain\n\nBoth are optional and have sensible defaults; set them for stability across host aliases:\n\n| Key | Meaning | Default |\n| --- | --- | --- |\n| `TOIL_AUTH_AUDIENCE` | The service audience bound into the signed login message. | `\"toil\"` |\n| `TOIL_AUTH_DOMAIN` | The `domain` input of the stable `ToilUserId` (`sha256(pubkey ‖ username ‖ domain)`). | the request `Host` header, else `localhost` |\n\nSet `TOIL_AUTH_DOMAIN` explicitly if your site answers on multiple hostnames, otherwise the same user could\nget different `ToilUserId`s from different aliases. Once users exist, changing it changes everyone's id, so\npick it before launch.\n\n## Email confirmation\n\nTwo plain env vars (tenant-readable, like `PUBLIC_BASE_URL`) control the built-in email-verification flow. They are separate on purpose: sending a verify email at registration is one decision, blocking login until confirmed is another.\n\n| Key | What it does |\n| --- | --- |\n| `AUTH_EMAIL_CONFIRMATION` | Registration emails a one-time confirm link (`/confirm#token=…`) and stores the account **unconfirmed**. Login is **not** blocked: the user can sign in, and you nudge them to verify. Use this for \"verify at signup, but let people in\". |\n| `AUTH_REQUIRE_EMAIL_CONFIRMATION` | The stricter form: does everything above AND **refuses login** until the email is confirmed (a distinguishable status the client surfaces as `EmailNotConfirmedError`, so the UI can prompt \"confirm your email / resend\"). Implies `AUTH_EMAIL_CONFIRMATION`. On the managed edge this is force-set from the per-domain Dacely setting. |\n\nWith neither set, accounts are auto-confirmed at registration and no confirm email is sent. The one-time link is delivered by email; in `toiljs dev` it is drawn in the console (no mailer needed). Both use the standard non-enumerating responses (a generic 200 that never reveals whether an address exists).\n\n## Argon2id strength (known limitation)\n\nThe built-in controller currently uses **demo-light** Argon2id params (32 MiB, 2 iterations, 1 lane) so it\nstays responsive in a browser tab. These are baked into the shipped controller today; **config-driven\ntuning is a planned follow-up.** For a high-value production deployment you should either wait for the\nconfig knob or hand-write your own controller with `≥ 256 MiB / ≥ 3 iterations`. The OPRF still provides the\nprimary offline-attack resistance regardless, but raise these before protecting anything sensitive.\n\nThe client always derives against whatever params the server returns in `/login/start`, so when the config\nknob lands you can raise them server-side with **no client change**.\n\n## Deploy checklist\n\n- [ ] `AUTH_SESSION_SECRET` set (32 random bytes), identical on every edge instance.\n- [ ] `AUTH_OPRF_SEED` set (32 random bytes).\n- [ ] `AUTH_KEM_SK` set (an ML-KEM-768 secret key), and its **public** half pinned in the client via\n `serverKemPublicKey`.\n- [ ] `TOIL_AUTH_DOMAIN` set if you serve multiple hostnames (stable `ToilUserId`).\n- [ ] (Recommended) Argon2id params reviewed for your threat model.\n\nThe CLI doctor warns when `server.auth` is on and the secrets are missing, run it before you ship.\n",
4
+ "auth/emails.md": "# Customizing the auth emails\n\nBuilt-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.\n\n## The three emails\n\n| Email | When it sends | The value it gives your template |\n| --- | --- | --- |\n| Email verification | On register, when email confirmation is on | `link` (the confirm URL) |\n| Password reset | On `POST /auth/reset/request` | `link` (the reset URL) |\n| Two-factor code | On login or 2FA setup, for the email method | `code` (the numeric code) |\n\nThe defaults are intentionally minimal so a fresh project sends real, correct mail with no setup. To match your brand, override them.\n\n## How to override\n\nDrop 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.\n\n| Reserved file | Overrides | Prop it must read |\n| --- | --- | --- |\n| `emails/auth-confirm.tsx` | Email verification | `link` |\n| `emails/auth-reset.tsx` | Password reset | `link` |\n| `emails/auth-2fa.tsx` | Two-factor code | `code` |\n\nEach 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.\n\n```tsx\n// emails/auth-confirm.tsx -> overrides the email-verification message.\nexport const subject = 'Verify your Acme account';\n\nexport default function AuthConfirm({ link }: { link: string }) {\n return (\n <div style={{ fontFamily: 'system-ui', padding: 24 }}>\n <h1 style={{ color: '#cb9820' }}>Welcome to Acme</h1>\n <p>Tap below to verify your email and finish signing up.</p>\n <p>\n <a\n href={link}\n style={{ background: '#cb9820', color: '#fff', padding: '10px 18px', borderRadius: 8, textDecoration: 'none' }}\n >\n Verify my email\n </a>\n </p>\n <p style={{ color: '#666', fontSize: 13 }}>Or paste this link: {link}</p>\n </div>\n );\n}\n```\n\nThe reset template works the same way with the `link` prop.\n\n## The subject line\n\nFor 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`).\n\nThe 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.\n\n```tsx\n// emails/auth-2fa.tsx -> overrides the 2FA code message (subject is contextual).\nexport default function Auth2fa({ code }: { code: string }) {\n return (\n <div style={{ fontFamily: 'system-ui', padding: 24, textAlign: 'center' }}>\n <p>Your Acme verification code:</p>\n <p style={{ fontSize: 32, fontWeight: 700, letterSpacing: 6 }}>{code}</p>\n <p style={{ color: '#666' }}>It expires in a few minutes.</p>\n </div>\n );\n}\n```\n\n## What you can and cannot put in\n\nEach 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.\n\nThe 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.\n\nStyling 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.\n\n## Delivery stays safe\n\nYou 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.\n\n## Where the templates go\n\nThere 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.\n\n## Related\n\n- [Email](../services/email.md): the full `emails/*.tsx` system, `EmailService`, and `EmailTemplate`.\n- [Configuration](./configuration.md): the auth secrets and the email provider a deployment must set.\n- [Usage](./usage.md): enabling auth, the client API, and guarding routes.\n",
4
5
  "auth/extending.md": "# Extending & integrating auth\n\nBuilt-in auth is deliberately opinionated so the common case is one line. This page covers the identity\nyou build ON, `ToilUserId`, and how to go beyond the defaults: keying your own data on a user, a custom\nuser shape, and hand-writing auth from the same primitives.\n\n## `ToilUserId`\n\nThe stable, tenant-scoped user identity: `sha256(mldsaPublicKey ‖ identifier ‖ domain)`, a 256-bit value.\nIt's a **global** (no import), like `crypto`.\n\n```ts\n// Read the current user's id in any handler (gate on hasSession(), see the null note below).\nconst id: ToilUserId = AuthService.userId()!;\n\n// Or derive one yourself.\nconst id2 = ToilUserId.derive(mldsaPublicKey, 'alice@example.com', 'acme.dacely.com');\n```\n\n| Member | Description |\n| --- | --- |\n| `ToilUserId.derive(pk, identifier, domain)` | Derive from an ML-DSA public key + email/username + tenant domain. Deterministic. |\n| `ToilUserId.fromBytes(b)` | Rebuild from a 32-byte digest (from `toBytes()` or storage). |\n| `toBytes(): Uint8Array` | The 32 identity bytes. |\n| `toHex(): string` | Lowercase 64-char hex, a convenient string key. |\n| `isZero(): bool` | True for the unset / anonymous id. |\n| `equals(other): bool` | Value equality. |\n| `a == b` / `a != b` | Overloaded value comparison, **O(1)** (four `u64` word compares, no byte loop, no allocation). |\n\n```ts\nconst a = ToilUserId.derive(pk, 'alice', 'acme.com');\nconst b = ToilUserId.derive(pk, 'alice', 'acme.com');\nconst c = ToilUserId.derive(pk, 'bob', 'acme.com');\na == b; // true, same inputs, same id\na != c; // true, different user\n```\n\n> **Null-check gotcha:** because `ToilUserId` overloads `==`, `AuthService.userId() == null` does NOT\n> type-check (`==` expects a `ToilUserId`). Gate with `AuthService.hasSession()` and then `userId()!`, or\n> compare with `getUser()` (a plain nullable). `===` is reference identity in AssemblyScript and is not\n> overloadable, use `==` for value equality.\n\n## Keying your own data on the user\n\n`toilUserId` is the right key for per-user data, it's stable across sessions/devices and opaque. Use the\nhex as a string key, or the bytes in a `@data` key class:\n\n```ts\n@data\nclass UserKey {\n id: Uint8Array = new Uint8Array(0); // toilUserId bytes\n constructor(id: Uint8Array = new Uint8Array(0)) { this.id = id; }\n}\n\n@data class Profile { displayName: string = ''; bio: string = ''; }\n\n@database\nclass AppDb {\n @collection static profiles: Documents<UserKey, Profile>;\n}\n\n@rest('profile')\nclass ProfileApi {\n @auth\n @post('/')\n public save(ctx: RouteContext): Response {\n const key = new UserKey(AuthService.userId()!.toBytes());\n const p = Profile.decode(ctx.request.body);\n // Save this user's profile. create is insert-only, so the first save creates\n // the record and later saves overwrite the existing one with enqueue.\n if (!AppDb.profiles.create(key, p)) {\n AppDb.profiles.enqueue(key, p);\n }\n return Response.text('saved\\n');\n }\n}\n```\n\n## Extending the user: add your own fields\n\nBuilt-in auth reserves exactly two fields on the authenticated user: `toilUserId` and `username`. To carry\nmore (a role, a display name, a tenant), just declare your OWN `@user` in your server code while\n`server.auth` is on. The build detects it and **extends** it: it injects the reserved `toilUserId` +\n`username` as the first two fields and mints sessions for your shape automatically. You do not opt out of\nanything, and there is still exactly one `@user` per program.\n\n```ts\n@user\nclass Account {\n admin: bool = false;\n displayName: string = '';\n tenant: string = '';\n // toilUserId + username are INJECTED by the build; do not declare them here.\n}\n```\n\nRules:\n\n- Do **not** declare `toilUserId` or `username` yourself. They are reserved and injected; declaring either is\n a compile error (`'username' is reserved by built-in auth`).\n- Your `@user` must be default-constructible (give every field an initializer), like any `@data` class.\n\n`AuthService.getUser()` is now typed to YOUR shape:\n\n```ts\nconst user = AuthService.getUser(); // { toilUserId, username, admin, displayName, tenant } | null\n```\n\n**Populating your fields.** Login fills `toilUserId` + `username`; your extra fields start at their declared\ndefaults (`admin = false`, and so on). The built-in controller cannot know your business fields, so you set\nthem yourself on one of your own `@auth` routes by reading the user, updating it, and re-minting the session:\n\n```ts\n@rest('account')\nclass AccountApi {\n @auth @post('/promote')\n public promote(): Response {\n const user = AuthService.getUser()!;\n user.admin = true;\n const resp = Response.text('promoted\\n');\n resp.setCookie(AuthService.mintSession(user.encode())); // re-sign the session with the new fields\n resp.setCookie(AuthService.userCookie(user.encode())); // update the readable companion cookie\n return resp;\n }\n}\n```\n\nIf you would rather hand-write the whole controller instead, you still can: do **not** enable `server.auth`,\nand build your own `@user` + routes from the [AuthService primitives](#the-authservice-primitive-reference)\nbelow. But for most apps, extending is all you need.\n\n## Adding email verification / 2FA\n\nLayer a second factor on top of the session with `TwoFactor` (stateless email codes, no DB; see\n[email](../services/email.md)). Typical flow: after login, require a verified email before granting access to\nsensitive routes.\n\n```ts\n@rest('2fa')\nclass TwoFactorApi {\n // Step 1: email a code to the logged-in user, hand back the signed token.\n @auth @post('/send')\n public send(): Response {\n const email = /* the user's email, e.g. their username, or a stored profile field */;\n const ch = TwoFactor.send(email, 'login'); // emails the code, returns { token, status }\n return Response.bytes(new DataWriter().writeString(ch.token).toBytes());\n }\n\n // Step 2: verify the code the user typed against the token.\n @auth @post('/verify')\n public verify(ctx: RouteContext): Response {\n const r = new DataReader(ctx.request.body);\n const token = r.readString(); const email = r.readString(); const code = r.readString();\n if (!TwoFactor.verify(token, email, code)) return Response.text('bad code\\n', 401);\n // Mark this session 2FA-verified: re-mint the session with a flag in your own @user, or store a\n // per-user \"verified\" record keyed on AuthService.userId().\n return Response.text('verified\\n');\n }\n}\n```\n\n`TwoFactor` gives integrity + expiry but not single-use (a code re-verifies within its TTL); keep the TTL\nshort. For a branded email, use `TwoFactor.issue(...)` (returns the code without sending) + your own\n`Emails.*` template. Call `TwoFactor.setSecret(...)` once at startup in production.\n\n## The `AuthService` primitive reference\n\nEverything the built-in controller is built from, available for hand-written auth. All are ambient globals\n(no import).\n\n**Sessions & cookies**\n- `mintSession(userData: Uint8Array, ttlSecs?: u64): Cookie`: the signed `__Host-toil_sess` cookie.\n- `userCookie(userData, ttlSecs?): Cookie`: the readable `__Secure-toil_user` companion.\n- `clearSession(): Cookie` / `clearUserCookie(): Cookie`.\n- `hasSession(): bool`: the `@auth` predicate.\n- `getSessionBytes(): Uint8Array | null`: the verified `@user` payload bytes.\n- `getUser(): <your @user> | null`: decoded, typed to your `@user`.\n- `userId(): ToilUserId | null`: the stable id (built-in `@user` layout).\n- `setSecret(secret: Uint8Array)`: override the session HMAC key programmatically.\n\n**Post-quantum login crypto**\n- `oprfEvaluate(username, blinded): Uint8Array`: server-keyed OPRF eval.\n- `buildRegisterMessage(username, pk)` / `verifyRegister(pk, msg, sig): bool`: proof-of-possession.\n- `buildLoginMessage(sub, aud, cid, nonce, iat, exp, ct, memKiB, iterations, parallelism, serverKemKeyId)`\n / `verifyLogin(pk, msg, sig): bool`.\n- `mlkemDecapsulate(ct): Uint8Array`, `serverKemKeyId(): Uint8Array`.\n- `deriveSessionKey(sharedSecret, transcriptHash)`, `serverConfirmTag(sessionKey, transcriptHash)`: the\n mutual-auth confirmation.\n- `sha256(data): Uint8Array`.\n- `setOprfSeed(seed)`, `setServerKemSecretKey(sk)`, `setServerKemPublicKey(pk)`: override the seeds/keys.\n- Sizes: `PUBLIC_KEY_LEN`, `SIGNATURE_LEN`, `OPRF_ELEMENT_LEN`, `KEM_CIPHERTEXT_LEN`, `SHARED_SECRET_LEN`, …\n\n**Related globals**, `TwoFactor` (email codes; see [email](../services/email.md)), `RateLimitService` (used by\n`@ratelimit`), `Environment` (the secret store).\n\n## Two ways in, one behavior\n\nThe config flag and the import are identical at build time, both make the build append the shipped\n`@user` + `@rest('auth')` controller to the toilscript **entry** set, where their decorators weave and the\n`@rest` class self-mounts. (A framework decorator source only weaves as an entry; that's why a plain\n`import` of the controller isn't enough on its own, the marker import is detected by the build, which does\nthe entry injection.) This is why built-in auth needs no runtime registration call.\n",
5
6
  "auth/how-it-works.md": "# How Toil auth works\n\nToil auth is a **post-quantum, password-based, mutually-authenticated** login. The design goal: the user\ntypes a password, but the server never sees it, never stores it, and can't be phished into leaking a\nverifier, and every primitive is quantum-resistant.\n\nThis page explains the protocol. You do not need to understand it to use auth (see [Usage](./usage.md)),\nbut you should understand the guarantees before you ship.\n\n## The building blocks\n\n| Primitive | Role |\n| --- | --- |\n| **OPRF** (RFC 9497, ristretto255-SHA512) | A *server-keyed* salt. The client blinds its password, the server evaluates it under a per-user key, the client unblinds. The result can't be computed without the server, so a stolen database can't be brute-forced offline. |\n| **Argon2id** | Stretches the OPRF output into key material (memory-hard, GPU/ASIC-resistant). Runs in the browser. |\n| **ML-DSA-44** (FIPS 204) | The user's identity key pair is *derived* from the Argon2id output. The server stores only the **public** key. Login is proved by an ML-DSA signature. |\n| **ML-KEM-768** (FIPS 203) | Key encapsulation on login: the server proves it holds its KEM secret by returning a confirmation tag only derivable from the decapsulated shared secret → **mutual** auth (anti-phishing). |\n| **HMAC-SHA256** | Signs the session cookie (`AUTH_SESSION_SECRET`). |\n\n> The whole thing is sometimes called an *aPAKE* (asymmetric password-authenticated key exchange). Do not\n> call it OPAQUE, this is Toil's own OPRF + KEM + signature construction.\n\n## What the server stores\n\nPer account, in ToilDB (`@database AuthDb`):\n\n- `username`, a deterministic `salt`, the **ML-DSA public key**, and the Argon2id params.\n\nThat's it. **No password, no password hash, no verifier that can be brute-forced without the OPRF key.**\nLogin challenges are a second collection, consumed exactly once (atomic `getDelete`).\n\n## Registration\n\n```\n Browser (client) Toil edge (server)\n ───────────────── ──────────────────\n blind(password) OPRF key = f(seed, username)\n │ username, blinded │\n │ ───────── POST /auth/register/start ───────────► │\n │ │ evaluated = OPRF(blinded)\n │ ◄──── {mem,iters,par, salt, evaluated} ───────── │ (salt is deterministic per user)\n │ │\n unblind → oprfOut │\n seed = Argon2id(oprfOut, salt, params) │\n (pk, sk) = ML-DSA-44.keygen(seed) │\n proof = ML-DSA.sign(sk, \"register|username|pk\") │\n │ username, pk, proof │\n │ ───────── POST /auth/register/finish ──────────► │\n │ │ verifyRegister(pk, msg, proof) ✓ proof-of-possession\n │ ◄──────────── {status: 0 ok | 1 taken} ──────── │ store AuthAccount{username, salt, pk, params}\n```\n\nThe server never learns the password or the secret key `sk`, only the public key `pk` and a proof the\nclient holds the matching `sk`. A duplicate username returns a **distinguishable** `status = 1` (so the UI\ncan say \"taken, log in instead\"); everything else fails generically.\n\n## Login (with mutual auth)\n\n```\n Browser (client) Toil edge (server)\n ───────────────── ──────────────────\n blind(password) │\n │ username, blinded │\n │ ───────── POST /auth/login/start ──────────────► │ evaluated = OPRF(blinded) (ALWAYS, even unknown user)\n │ │ if known: store Challenge{cid, nonce, iat, exp}\n │ ◄─ {cid, aud, params, salt, nonce, iat, exp, ── │ (identical response whether the user exists or not)\n │ evaluated} │\n unblind → Argon2id → (pk, sk) = ML-DSA.keygen │\n (ct, ssС) = ML-KEM-768.encapsulate(serverKemPublicKey) │\n msg = \"login|username|aud|cid|nonce|iat|exp|ct|params|kid\" │\n sig = ML-DSA.sign(sk, msg) │\n │ cid, ct, sig │\n │ ───────── POST /auth/login/finish ─────────────► │ ch = challenges.getDelete(cid) (consume once; check exp)\n │ │ rebuild msg from OUR stored values + ct\n │ │ verifyLogin(acct.pk, msg, sig) ✓ it's really this user\n │ │ ssS = ML-KEM.decapsulate(ct) ✓ we hold the KEM key\n │ │ K = deriveSessionKey(ssS, H(msg))\n │ ◄──── {0, sessionToken, serverConfirm} ──────── │ serverConfirm = tag(K, H(msg))\n │ + Set-Cookie: __Host-toil_sess=… │ mint session cookie\n verify serverConfirm using ssC ✓ the server is genuine │\n```\n\nTwo verifications, both required:\n1. **The server verifies the client**: the ML-DSA signature over a message bound to the challenge, the\n Argon2id params, and the server's KEM key id. Replays fail (the challenge is consumed).\n2. **The client verifies the server**: the `serverConfirm` tag is derivable only from the ML-KEM shared\n secret, which only the holder of the KEM secret key can decapsulate. A phishing site can't forge it.\n\n## Anti-enumeration\n\n`/login/start` behaves identically for a known and an unknown user: it always runs the OPRF (a decoy key\nfor unknown users), returns a **deterministic** per-user salt and constant params, and a fresh challenge.\nThe challenge is persisted only for a real account, and `/login/finish` fails generically at consume for an\nunknown user. So an attacker can't probe which usernames exist. Every failure path returns the same\n`401 auth: request failed`.\n\n## Sessions & cookies\n\nOn successful login the server mints **two** cookies:\n\n- **`__Host-toil_sess`**: the authoritative session. HMAC-SHA256 signed with `AUTH_SESSION_SECRET`,\n `HttpOnly`, `Secure`, `SameSite=Lax`. It carries the `@user` codec payload. `@auth` and\n `AuthService.getUser()` open + verify this cookie server-side; a forged or tampered cookie fails.\n- **`__Secure-toil_user`**: a **readable** companion carrying the same payload, so the browser can show\n \"logged in as …\" via the client's `getUser()`. The server **never trusts it**, it is display-only.\n\nEach request runs in a fresh wasm instance, but the signed cookie is self-contained, so no server-side\nsession store is needed. `AUTH_SESSION_SECRET` must be identical across every edge instance (it is, via the\nenv store) so a cookie minted anywhere verifies everywhere.\n\n## The stable user identity: `ToilUserId`\n\nAt login the server derives a stable, tenant-scoped id and stores it in the session (the first field of the\nbuilt-in `@user`):\n\n```\ntoilUserId = SHA-256( mldsaPublicKey ‖ username ‖ domain ) // 256 bits\n```\n\n- **Stable:** same login key + username on the same tenant `domain` → same id, forever, across sessions\n and devices. Key your own data on it.\n- **Opaque + one-way:** it's a hash: safe to store, log, or expose without leaking the key or the address.\n- Read it anywhere with `AuthService.userId()`. See [Extending](./extending.md#toiluserid) for the\n `ToilUserId` API (O(1) `==` / `!=`, `toHex()`, …).\n\n## Threat-model summary\n\n- **Server database stolen** → attacker gets public keys + salts, not passwords. Brute-forcing needs the\n OPRF key (server-side) *and* Argon2id work per guess.\n- **Server compromised / malicious** → still can't recover passwords (only public keys) and can't forge a\n past session without `AUTH_SESSION_SECRET`.\n- **Phishing site** → can't produce the `serverConfirm` tag (no KEM secret), so a correct client aborts.\n- **Quantum adversary** → ML-DSA + ML-KEM are post-quantum; the OPRF/Argon2id/HMAC pieces are classical but\n not the long-term identity or key-exchange.\n- **Replay** → challenges are single-use (`getDelete`) with a short TTL.\n\nResidual responsibilities are yours: set the [secrets](./configuration.md), pin your deployment's KEM\npublic key in the client, and raise the Argon2id params for production.\n",
6
- "auth/README.md": "# Authentication\n\nToil ships a complete, **post-quantum password login** you turn on with one line. No passwords on your\nserver, no third-party identity provider, no hand-written crypto: enable it and you get a `/auth/*` API,\nsigned sessions, `@auth`-guarded routes, and a stable per-user identity.\n\n```ts\n// toil.config.ts\nimport { defineConfig } from 'toiljs/compiler';\n\nexport default defineConfig({\n server: { auth: true }, // ← mounts the full /auth/* API + sessions\n});\n```\n\nThat is the whole setup. The build appends the framework's auth controller to your server, so `/auth/*`\nis live and `@auth` works everywhere.\n\n## What you get\n\n| Endpoint | Purpose |\n| --- | --- |\n| `POST /auth/register/start`, `/register/finish` | Create an account (password never leaves the browser) |\n| `POST /auth/login/start`, `/login/finish` | Log in; sets a signed session cookie |\n| `GET /auth/me` *(`@auth`)* | The current user (`toilUserId` + `username`) |\n| `POST /auth/logout` *(`@auth`)* | Clear the session |\n\nPlus, in your own code:\n\n- **`@auth`** on any route (or a whole `@rest` class) → 401 unless there's a valid session.\n- **`AuthService.getUser()`** → the typed logged-in user.\n- **`AuthService.userId()`** → the stable [`ToilUserId`](./extending.md#toiluserid) (a 256-bit id you can\n key your data on).\n- **The client**: `import { Auth } from 'toiljs/client'`, then `Auth.register(username, password)` /\n `Auth.login(username, password)`. It does all the browser-side crypto and talks to `/auth/*` for you.\n\n## A login page in full\n\n```tsx\n// client/routes/login.tsx\nimport { useState } from 'react';\nimport { Auth } from 'toiljs/client';\n\nexport default function Login() {\n const [u, setU] = useState('');\n const [p, setP] = useState('');\n const [msg, setMsg] = useState('');\n\n const register = async () => {\n try { await Auth.register(u, p); setMsg('registered, now log in'); }\n catch (e) { setMsg(parseError(e)); }\n };\n const login = async () => {\n try { await Auth.login(u, p); setMsg('logged in'); } // sets the session cookie\n catch (e) { setMsg(parseError(e)); }\n };\n\n return (\n <main>\n <input value={u} onChange={(e) => setU(e.currentTarget.value)} placeholder=\"username\" />\n <input value={p} type=\"password\" onChange={(e) => setP(e.currentTarget.value)} placeholder=\"password\" />\n <button onClick={register}>Register</button>\n <button onClick={login}>Log in</button>\n <p>{msg}</p>\n </main>\n );\n}\n```\n\n```ts\n// server/routes/Secret.ts, a route only a logged-in user can reach\nimport { Response } from 'toiljs/server/runtime';\n\n@rest('secret')\nclass Secret {\n @auth // 401 without a valid session\n @get('/')\n public secret(): Response {\n const user = AuthService.getUser()!; // typed: { toilUserId, username }\n return Response.text('hello ' + user.username + '\\n');\n }\n}\n```\n\nThat's a real, production-grade auth system, the password is stretched with Argon2id in the browser into\nan ML-DSA-44 key pair, your server only ever stores a public key, and login is a mutually-authenticated\nML-KEM-768 challenge. You didn't write any of it.\n\n## Where to go next\n\n- **[How it works](./how-it-works.md)**: the protocol (OPRF + Argon2id + ML-DSA + ML-KEM), sessions,\n cookies, and the `ToilUserId`, with sequence diagrams.\n- **[Usage](./usage.md)**: enabling it, the client API, guarding routes, reading the user, the full wire\n contract of each endpoint.\n- **[Configuration](./configuration.md)**: the secrets a deployment MUST set, the audience/domain, tuning\n Argon2id, and the deploy checklist.\n- **[Extending & integrating](./extending.md)**: `ToilUserId`, keying your own data on a user, a custom\n user shape / opting out, and the `AuthService` primitive reference.\n\n> **One rule before you ship:** built-in auth runs with **insecure DEV fallback secrets** so it Just Works\n> locally. A deployment MUST set `AUTH_SESSION_SECRET`, `AUTH_OPRF_SEED`, and `AUTH_KEM_SK`. See\n> [Configuration](./configuration.md).\n",
7
+ "auth/README.md": "# Authentication\n\nToil ships a complete, **post-quantum password login** you turn on with one line. No passwords on your\nserver, no third-party identity provider, no hand-written crypto: enable it and you get a `/auth/*` API,\nsigned sessions, `@auth`-guarded routes, and a stable per-user identity.\n\n```ts\n// toil.config.ts\nimport { defineConfig } from 'toiljs/compiler';\n\nexport default defineConfig({\n server: { auth: true }, // ← mounts the full /auth/* API + sessions\n});\n```\n\nThat is the whole setup. The build appends the framework's auth controller to your server, so `/auth/*`\nis live and `@auth` works everywhere.\n\n## What you get\n\n| Endpoint | Purpose |\n| --- | --- |\n| `POST /auth/register/start`, `/register/finish` | Create an account (password never leaves the browser) |\n| `POST /auth/login/start`, `/login/finish` | Log in; sets a signed session cookie |\n| `GET /auth/me` *(`@auth`)* | The current user (`toilUserId` + `username`) |\n| `POST /auth/logout` *(`@auth`)* | Clear the session |\n\nPlus, in your own code:\n\n- **`@auth`** on any route (or a whole `@rest` class) → 401 unless there's a valid session.\n- **`AuthService.getUser()`** → the typed logged-in user.\n- **`AuthService.userId()`** → the stable [`ToilUserId`](./extending.md#toiluserid) (a 256-bit id you can\n key your data on).\n- **The client**: `import { Auth } from 'toiljs/client'`, then `Auth.register(username, password)` /\n `Auth.login(username, password)`. It does all the browser-side crypto and talks to `/auth/*` for you.\n\n## A login page in full\n\n```tsx\n// client/routes/login.tsx\nimport { useState } from 'react';\nimport { Auth } from 'toiljs/client';\n\nexport default function Login() {\n const [u, setU] = useState('');\n const [p, setP] = useState('');\n const [msg, setMsg] = useState('');\n\n const register = async () => {\n try { await Auth.register(u, p); setMsg('registered, now log in'); }\n catch (e) { setMsg(parseError(e)); }\n };\n const login = async () => {\n try { await Auth.login(u, p); setMsg('logged in'); } // sets the session cookie\n catch (e) { setMsg(parseError(e)); }\n };\n\n return (\n <main>\n <input value={u} onChange={(e) => setU(e.currentTarget.value)} placeholder=\"username\" />\n <input value={p} type=\"password\" onChange={(e) => setP(e.currentTarget.value)} placeholder=\"password\" />\n <button onClick={register}>Register</button>\n <button onClick={login}>Log in</button>\n <p>{msg}</p>\n </main>\n );\n}\n```\n\n```ts\n// server/routes/Secret.ts, a route only a logged-in user can reach\nimport { Response } from 'toiljs/server/runtime';\n\n@rest('secret')\nclass Secret {\n @auth // 401 without a valid session\n @get('/')\n public secret(): Response {\n const user = AuthService.getUser()!; // typed: { toilUserId, username }\n return Response.text('hello ' + user.username + '\\n');\n }\n}\n```\n\nThat's a real, production-grade auth system, the password is stretched with Argon2id in the browser into\nan ML-DSA-44 key pair, your server only ever stores a public key, and login is a mutually-authenticated\nML-KEM-768 challenge. You didn't write any of it.\n\n## Where to go next\n\n- **[How it works](./how-it-works.md)**: the protocol (OPRF + Argon2id + ML-DSA + ML-KEM), sessions,\n cookies, and the `ToilUserId`, with sequence diagrams.\n- **[Usage](./usage.md)**: enabling it, the client API, guarding routes, reading the user, the full wire\n contract of each endpoint.\n- **[Configuration](./configuration.md)**: the secrets a deployment MUST set, the audience/domain, tuning\n Argon2id, and the deploy checklist.\n- **[Extending & integrating](./extending.md)**: `ToilUserId`, keying your own data on a user, a custom\n user shape / opting out, and the `AuthService` primitive reference.\n- **[Customizing the auth emails](./emails.md)**: replace the verification, password-reset, and 2FA\n emails with your own branded React templates by dropping `emails/auth-*.tsx` files.\n\n> **One rule before you ship:** built-in auth runs with **insecure DEV fallback secrets** so it Just Works\n> locally. A deployment MUST set `AUTH_SESSION_SECRET`, `AUTH_OPRF_SEED`, and `AUTH_KEM_SK`. See\n> [Configuration](./configuration.md).\n",
7
8
  "auth/usage.md": "# Using auth\n\n## 1. Enable it\n\nEither the config flag (canonical) or a one-line import, both do the same thing (the build appends the\nframework's auth controller + user shape to your server as compiled entries, so `/auth/*` self-mounts):\n\n```ts\n// toil.config.ts, canonical\nexport default defineConfig({ server: { auth: true } });\n```\n\n```ts\n// server/main.ts, escape hatch (equivalent)\nimport 'toiljs/server/auth';\n```\n\nThere is also an `AuthService.enable()` no-op you can call for discoverability, but enabling is done by the\nflag/import above (it happens at build time). Turn it off by removing the flag/import, with neither, no\n`/auth` routes and no accounts collection are generated. It is strictly opt-in.\n\n## 2. The client\n\n`toiljs/client` ships the browser half, it runs the OPRF blinding, Argon2id, ML-DSA keygen, and ML-KEM\nencapsulation, and talks to `/auth/*`. You never touch the crypto.\n\n```ts\nimport { Auth } from 'toiljs/client';\n\n// Create an account. Throws on a taken username or a transport error.\nawait Auth.register(username, password);\n\n// Log in. On success the browser holds the signed session cookie; subsequent\n// requests to @auth routes are authorized automatically.\nawait Auth.login(username, password);\n```\n\nOptions (second argument, `AuthOptions`):\n\n```ts\nawait Auth.login(username, password, {\n baseUrl: '/auth', // default; change if you mount elsewhere\n serverKemPublicKey: MY_KEM_PUB, // REQUIRED in production, pin your deployment's KEM key\n});\n```\n\n> **Production:** the client ships with the DEV KEM public key pinned. A real deployment MUST pass its own\n> `serverKemPublicKey` (derived from `AUTH_KEM_SK`). See [Configuration](./configuration.md).\n\n## 2b. Client-side: who's logged in, and protecting pages\n\n`register`/`login` set the session cookies; to *render* login state on the client, use the generated\n`getUser()` (emitted from the built-in `@user`). It reads the **readable** `__Secure-toil_user` companion\ncookie and returns the typed user or `null`, instant, no network call. It is **display-only**; the server\nstill enforces access via `@auth`, so never trust it for authorization.\n\n```tsx\nimport { getUser } from 'shared/server'; // generated; typed to the built-in @user\n\nfunction Nav() {\n const user = getUser(); // { toilUserId, username } | null\n return user\n ? <span>Hi {user.username} <button onClick={logout}>Log out</button></span>\n : <a href=\"/login\">Log in</a>;\n}\n\nasync function logout() {\n await fetch('/auth/logout', { method: 'POST' });\n location.href = '/login'; // cookies are cleared; bounce to login\n}\n```\n\nGate a whole client route by redirecting when there's no session:\n\n```tsx\nexport default function Dashboard() {\n const user = getUser();\n if (user == null) { location.href = '/login'; return null; } // not logged in -> to /login\n return <main>Welcome, {user.username}</main>;\n}\n```\n\nThe authoritative check is still the server: any data this page fetches should come from an `@auth` route,\nso a user who forged/deleted the readable cookie sees the redirect OR a `401`, never real data.\n\n## 2c. Handling errors\n\n`Auth.register` / `Auth.login` reject with a message you can show. The important distinguishable cases:\n\n```ts\ntry {\n await Auth.register(username, password);\n} catch (e) {\n const m = String(e);\n if (m.includes('already registered')) setError('That username is taken, log in instead.');\n else setError('Could not register, try again.');\n}\n\ntry {\n await Auth.login(username, password);\n} catch {\n // Wrong password OR unknown user both fail generically (anti-enumeration), one message.\n setError('Incorrect username or password.');\n}\n```\n\n- **Username taken** → `register` throws `auth: username already registered (log in instead)` (a\n distinguishable case so you can guide the user).\n- **Wrong password / unknown user** → `login` throws generically (`auth: request failed`): by design,\n the two are indistinguishable, so use ONE \"incorrect username or password\" message.\n- **Rate limited** → after 5 attempts / 60s a `429` surfaces as the same generic throw; back off and tell\n the user to wait.\n\n## 3. Guard your routes: `@auth`\n\nPut `@auth` on a route method or a whole `@rest` class. The generated dispatcher checks for a valid signed\nsession **before** your handler runs and returns `401` otherwise. `@auth` is unchanged by built-in auth,\nit's the same decorator you'd use with hand-written auth.\n\n```ts\n@rest('account')\nclass AccountApi {\n @auth // this route needs a session\n @get('/settings')\n public settings(): Response { /* … */ }\n\n @get('/public') // this one is open\n public open(): Response { /* … */ }\n}\n\n@auth // …or guard the ENTIRE class\n@rest('admin')\nclass AdminApi { /* every route requires a session */ }\n```\n\n## 4. Read the current user\n\nInside any handler:\n\n```ts\n// The typed logged-in user (the built-in `@user`: toilUserId + username), or null.\nconst user = AuthService.getUser();\nif (user != null) {\n user.username; // string\n user.toilUserId; // Uint8Array(32), the stable id bytes\n}\n\n// The stable identity as a ToilUserId (gate on hasSession() first, see note).\nif (AuthService.hasSession()) {\n const id = AuthService.userId()!; // ToilUserId\n // key your own data on id (see Extending)\n}\n```\n\n> `ToilUserId` overloads `==`, so `AuthService.userId() == null` does not type-check. Gate with\n> `AuthService.hasSession()` and then use `userId()!`, or use `getUser()` and null-check that.\n\n## 5. The endpoint wire contract\n\nYou normally use the `Auth` client, but the raw endpoints are binary (`DataWriter`/`DataReader`, never\nJSON):\n\n| Route | Request body | Response |\n| --- | --- | --- |\n| `POST /auth/register/start` | `str(username) bytes(blinded)` | `u8(0) u32(mem) u32(iters) u32(par) bytes(salt) bytes(evaluated)` |\n| `POST /auth/register/finish` | `str(username) bytes(pubkey) bytes(proof)` | `u8(status)`, `0` ok, `1` username taken |\n| `POST /auth/login/start` | `str(username) bytes(blinded)` | `bytes(cid) str(aud) u32(mem) u32(iters) u32(par) bytes(salt) bytes(nonce) u64(iat) u64(exp) bytes(evaluated)` |\n| `POST /auth/login/finish` | `bytes(cid) bytes(ct) bytes(sig)` | `u8(0) bytes(sessionToken) bytes(serverConfirm)` + `Set-Cookie`, or `u8(≠0)` on failure |\n| `GET /auth/me` *(`@auth`)* | (none) | `bytes(toilUserId) str(username)` |\n| `POST /auth/logout` *(`@auth`)* | (none) | `200` + cookie-clearing `Set-Cookie` |\n\nRate limiting: every register/login POST carries `@ratelimit(SlidingWindow, 5, 60)` (5 requests / 60s per\nclient) out of the box, so brute-force is throttled before it reaches the crypto.\n\n## 6. Under `toiljs dev`\n\nEverything runs locally with **zero setup**: the dev server emulates the ToilDB account/challenge storage\nand the ML-DSA/ML-KEM/OPRF host functions in process, and the auth secrets fall back to insecure DEV\nvalues. Register and login span requests (the accounts persist for the dev session). You'll see a warning\nthat `AUTH_SESSION_SECRET` is unset, that's expected in dev; set it before you deploy (see\n[Configuration](./configuration.md)).\n\n## 7. `Server.REST.auth.*`\n\nBecause the controller is a normal `@rest('auth')` class, a typed `Server.REST.auth.*` fetch client is\ngenerated for free (`me`, `logout`, and the register/login methods). Use it for `/me` and `/logout`; but\n**drive register/login through `toiljs/client` `Auth`**, not the generated client, only the `Auth` helper\nruns the required browser-side OPRF/Argon2id/ML-DSA/ML-KEM crypto.\n",
8
9
  "backend/data.md": "# Data types (`@data`)\n\n`@data` turns a plain class into a typed value that can travel safely between your frontend and your WASM backend, and in and out of the database, with both a binary and a JSON codec generated for you.\n\n## What `@data` is\n\nA **codec** is a pair of functions: one that turns a value into bytes (encode), and one that turns those bytes back into a value (decode). Any time data crosses a boundary (browser to server, server to database, one WASM call to another) it has to become bytes and back. Writing that by hand is tedious and easy to get wrong.\n\n`@data` writes it for you. You declare a class with typed fields, tag it `@data`, and the compiler synthesizes a deterministic binary codec and a JSON codec on the class. The exact same class is also generated into your `shared/server.ts`, so the browser and the WASM backend agree on the format down to the byte.\n\n```ts\n@data\nclass Player {\n username: string = '';\n admin: bool = false;\n score: u64 = 0;\n}\n```\n\nThat is a complete, serializable type. Note every field has a **default value**; that is required (the generated decoder and the client constructor use it).\n\n## Why and when\n\n`@data` is the backbone of almost everything that moves in a toiljs app:\n\n- **Request and response bodies** for [`@rest`](./rest.md) routes.\n- **Arguments and return values** for [`@service` / `@remote`](./rpc.md) calls.\n- **Values stored in [ToilDB](../database/README.md)**.\n- **Sessions**, [stream](../realtime/README.md) messages, and any custom payload you design.\n\nWhenever a route, an RPC method, or the database needs a structured value, that value is a `@data` class. You will define these constantly.\n\n```mermaid\nflowchart LR\n D[\"@data class<br/>(one definition)\"] --> R[\"REST route body\"]\n D --> P[\"RPC argument / result\"]\n D --> B[(\"ToilDB value\")]\n D --> S[\"Stream message\"]\n```\n\n## What the compiler generates\n\nFrom your `@data` class, the compiler adds these members:\n\n| Member | What it does |\n| --- | --- |\n| `encode(): Uint8Array` | Serialize to bytes, with a 4-byte type-id prefix. |\n| `static decode(buf): T` | Rebuild a value from bytes produced by `encode()`. |\n| `encodeInto(w: DataWriter)` | Serialize without the type-id frame, for nesting inside another value. |\n| `static decodeFrom(r: DataReader): T` | The matching read, for nested values. |\n| `toJSON()` / `static fromJSON(v)` | The JSON codec (large integers become decimal strings, so `JSON.parse` keeps them exact). |\n| `static dataId(): u32` | A stable hash (FNV-1a) of the class name, written as the type-id prefix. |\n\nYou mostly do not call these directly; routes, RPC, and the database call them for you. But they are there when you need them (for example `value.toJSON().toString()` to build a `Response.json`).\n\n## Supported field types\n\nA `@data` field may be:\n\n- a scalar: `u8` through `u256`, `i8` through `i256`, `f32`, `f64`, `bool`;\n- a `string`;\n- a `Uint8Array` (a raw byte buffer);\n- a nested `@data` class;\n- an array `T[]` of any of the above.\n\nFor the number types (why `u64` is a `bigint` on the client, when to reach for `u256`, and so on), see [Types](../concepts/types.md).\n\nGive every field a default. The layout is exactly the field declaration order (this matters, see [gotchas](#gotchas)).\n\n## Nested `@data`, arrays, and bytes\n\n`@data` classes compose. A field can be another `@data` class, an array, or a byte buffer, and it all encodes and decodes as one value:\n\n```ts\n@data\nclass Tag {\n label: string = '';\n weight: f32 = 0;\n}\n\n@data\nclass Document {\n id: u64 = 0;\n title: string = '';\n tags: Tag[] = []; // array of nested @data\n authors: string[] = []; // array of strings\n thumbnail: Uint8Array = new Uint8Array(0); // raw bytes\n}\n```\n\n`Document.encode()` walks the whole tree: it writes `id`, `title`, each `Tag` (via `encodeInto`), each author string, and the raw `thumbnail` bytes, in field order. `Document.decode(bytes)` reads them back in the same order. On the JSON side, a `Uint8Array` field becomes a JSON array of byte numbers, and nested `@data` fields become nested JSON objects.\n\n## Using `@data` in a route\n\nA route's body parameter and its return value are `@data` values. Which codec runs depends on the route's stream mode (see [REST bodies](./rest.md#request-and-response-bodies)):\n\n```ts\n// JSON route (the default): body from JSON.parse, result via toJSON()\n@post('/')\npublic create(input: NewPlayer): Player { /* ... */ }\n\n// Binary route: body via decode(), result via encode()\n@route({ method: Methods.POST, path: '/blob', stream: DataStream.Binary })\npublic blob(input: FileData): FileResult { /* ... */ }\n```\n\n- In a **JSON** route, the incoming body is `JSON.parse`d and revived with the type's `fromJSON`, and the returned value is serialized with `toJSON()`.\n- In a **Binary** route, the incoming body is `decode`d and the returned value is `encode`d.\n\nYou do not call the codec yourself in either case; you just declare the types.\n\n## The raw codec: `DataWriter` and `DataReader`\n\nSometimes you want to lay out bytes by hand: a custom body, a session token, a challenge message, a wire format someone else defined. For that, use the codec directly. It lives in the `data` module:\n\n```ts\nimport { DataWriter, DataReader } from 'data';\n```\n\nThis is the same codec `@data` classes are built from, and it has a byte-for-byte identical TypeScript version in `toiljs/io` (`src/io/codec.ts`), so your browser code can read and write the exact same bytes the WASM backend does.\n\n### `DataWriter`\n\nEvery write method returns the writer, so calls chain.\n\n| Method | Signature | Wire format |\n| --- | --- | --- |\n| `writeU8` / `writeI8` | `(v): DataWriter` | 1 byte |\n| `writeU16` / `writeI16` | `(v): DataWriter` | 2 bytes, little-endian |\n| `writeU32` / `writeI32` | `(v): DataWriter` | 4 bytes, LE |\n| `writeU64` / `writeI64` | `(v): DataWriter` | 8 bytes, LE |\n| `writeF32` / `writeF64` | `(v): DataWriter` | 4 / 8 bytes, IEEE-754 LE |\n| `writeBool` | `(v): DataWriter` | 1 byte (`1` / `0`) |\n| `writeBytes` | `(b: Uint8Array): DataWriter` | `u32` length (LE) + the raw bytes |\n| `writeString` | `(s: string): DataWriter` | `u32` length (LE) + UTF-8 bytes |\n| `writeU128` / `writeI128` | `(v): DataWriter` | two `u64` limbs (lo, hi) |\n| `writeU256` / `writeI256` | `(v): DataWriter` | four `u64` limbs |\n| `length` | `(): i32` | bytes written so far |\n| `toBytes` | `(): Uint8Array` | an exact-length copy of the buffer |\n\n### `DataReader`\n\nReads are **bounds-safe**: reading past the end of the buffer never crashes. Instead the read returns a zero or empty default and flips the reader's public `ok` flag to `false`. Check `ok` after a sequence of reads to catch a truncated or malformed buffer.\n\n| Method | Signature | On over-read |\n| --- | --- | --- |\n| `readU8` / `readI8` | `(): integer` | `0` |\n| `readU16`..`readU64`, `readI16`..`readI64` | `(): integer` | `0` |\n| `readF32` / `readF64` | `(): float` | `0` |\n| `readBool` | `(): bool` | `false` |\n| `readBytes` | `(): Uint8Array` | empty array |\n| `readString` | `(): string` | `\"\"` |\n| `readU128` / `readI128` / `readU256` / `readI256` | `(): bignum` | `0` |\n| `remaining` | `(): i32` | bytes left unread |\n| `ok` | `bool` (field) | `false` once any read over-ran |\n\n### Encode and decode, both directions\n\n```ts\nimport { DataWriter, DataReader } from 'data';\n\n// Encode: a version byte, a name, a score, then a blob.\nconst out = new DataWriter()\n .writeU8(1)\n .writeString('alice')\n .writeU64(1234)\n .writeBytes(payload)\n .toBytes();\n\n// Decode: read the fields back in the exact same order.\nconst r = new DataReader(out);\nconst version = r.readU8();\nconst name = r.readString();\nconst score = r.readU64();\nconst blob = r.readBytes();\nif (!r.ok) return Response.badRequest('truncated');\n```\n\nThe order of reads must match the order of writes exactly. The layout is the format.\n\n## JSON vs binary: which to use\n\nYou usually pick this at the route level (see [REST bodies](./rest.md#request-and-response-bodies)), but the trade-off is the same everywhere:\n\n- **JSON** is human-readable and understood by every tool. Pick it for endpoints a browser or a third party calls directly. Large integers ride as decimal strings so they stay exact.\n- **Binary** is smaller, faster, and lossless for big numbers. Pick it for app-to-app traffic and anything performance sensitive. RPC always uses binary under the hood.\n\n## Evolving a `@data` format: `@migrate`\n\nOnce records are stored, changing a `@data` **value** type is a problem: as the [gotchas](#gotchas) explain, the binary layout *is* the field order, so adding or changing a field means the bytes already on disk no longer match the new shape and would fail to decode. `@migrate` is how you evolve a stored type without a backfill and without downtime.\n\n**What it is.** `@migrate` marks a plain (free) function that upgrades one record written under an **old** version of a value type into the **current** shape. The compiler weaves that function into the value type's decoder as a version dispatch: every stored record carries the schema version it was written under, so when a read hits an old record, the decoder decodes it as its old shape and runs your `@migrate` forward to today's shape. This happens **lazily**, per row, only when a record is actually read (nothing rewrites your whole collection up front).\n\n**When to use it.** Any time you change a `@data` type that is already stored in ToilDB: you added a field, renamed one, changed a type. New writes use the new layout; old rows keep working because the migration upgrades them on the way out.\n\nHere is the whole story. You shipped this value type:\n\n```ts\n// server/models/GuestEntry.ts (version 1: what you shipped first)\n@data\nexport class GuestEntry {\n author: string = '';\n message: string = '';\n}\n```\n\nLater you add an `at` timestamp:\n\n```ts\n// server/models/GuestEntry.ts (version 2: gained an `at` field)\n@data\nexport class GuestEntry {\n author: string = '';\n message: string = '';\n at: u64 = 0; // NEW field\n}\n```\n\nEvery entry already stored was written without `at`, so its bytes cannot decode as the new `GuestEntry`. You add a migration file to bridge them:\n\n```ts\n// server/migrations/GuestEntry.migration.ts\nimport { GuestEntry } from '../models/GuestEntry';\n\n// Keep the ORIGINAL layout as its own class so old rows still decode.\n// One kept class per past version.\n@data\nexport class GuestEntryV1 {\n author: string = '';\n message: string = '';\n}\n\n// Upgrade a v1 row to the current GuestEntry. This is the DELTA form:\n// (old, into). The compiler pre-copies the fields the two layouts share\n// (author, message), so your body fills only what is new.\n@migrate\nexport function up(old: GuestEntryV1, into: GuestEntry): void {\n into.at = 0; // unknown for entries written before the timestamp existed\n}\n```\n\nThat is it. Old entries now surface as fully-formed `GuestEntry` values with `at = 0`; new entries carry a real timestamp; the same read path serves both.\n\nA `@migrate` function comes in two shapes, and you pick whichever reads better:\n\n- **Delta form** `up(old: OldType, into: NewType): void` (used above). The compiler copies every field the two layouts share by name and type, and your body fills only the changed or new fields. Least to write when most fields carry over.\n- **Full form** `up(old: OldType): NewType`. Your body builds and returns the whole new value itself. Use it when the transform is not a simple field-for-field copy.\n\n```ts\n// The same migration written in the full form.\n@migrate\nexport function up(old: GuestEntryV1): GuestEntry {\n const e = new GuestEntry();\n e.author = old.author;\n e.message = old.message;\n e.at = 0;\n return e;\n}\n```\n\nGotchas specific to `@migrate`:\n\n- **Location is enforced.** Every `@migrate` must live in a `migrations/<Type>.migration.ts` file (a `*.migration.ts` file under a `migrations/` folder). The build auto-discovers it (nothing imports it), and a `@migrate` placed anywhere else is a hard compile error, because it would silently never run.\n- **A migration is a pure value transform.** It may not touch the database or any host service; it only turns old fields into new ones. Trying to read or write ToilDB from inside a `@migrate` is a compile error.\n- **Migrations chain.** If you evolve a type more than once, keep one old class and one `@migrate` per step (`V0 -> V1`, `V1 -> V2`). The compiler walks the chain, so a row written under the oldest layout is carried all the way forward to the current shape, shortest path first.\n- **When it actually runs.** On any read that decodes an old row (`get`, `getMany`, a view read, events `latest`). It also runs when a [`@derive`](../background/derive.md) rebuilds a view on box load and re-reads old stored events (see [Views](../database/views.md)).\n\n## Dynamic JSON on the server: the `JSON` value tree\n\n`@data` is for shapes you know ahead of time. Sometimes you do not: a webhook whose body varies, a third-party payload with optional fields, or a response you assemble on the fly. For those, toiljs gives you a `JSON` **value tree**: an in-memory value that can be a null, a bool, a number, a string, an array, or an object, which you read and build at runtime.\n\n**What it is.** `JSON` is an ambient global class (no import needed). It represents one dynamic JSON value. `JSON.parse(text)` turns JSON text into one of these trees, and a `@data` class's `toJSON()` actually returns one too. It is the **untyped** counterpart to `@data`'s typed, fixed-shape codec: reach for `@data` when the shape is known and you want type safety, and for `JSON` when the shape is dynamic.\n\nThe statics that make a value:\n\n| Static | What it does |\n| --- | --- |\n| `JSON.parse(text: string): JSON` | Parse JSON text into a value tree (returns an error value on malformed input). |\n| `JSON.obj(): JSON` | A new empty object; fill it with `.set(key, value)`. |\n| `JSON.arr(): JSON` | A new empty array; fill it with `.push(value)`. |\n| `JSON.of<T>(value: T): JSON` | Wrap a scalar, string, bool, or array as a JSON value. |\n| `JSON.nul(): JSON` | A JSON null. |\n| `JSON.stringify<T>(value: T): string` | Serialize a scalar / string / bool / array value straight to a JSON string. |\n\nThe instance methods that read and build a value:\n\n| Method | What it does |\n| --- | --- |\n| `.isObject()` / `.isArray()` / `.isString()` / `.isNumber()` / `.isBool()` / `.isNull(): bool` | Test the value's type before you read it. |\n| `.has(key: string): bool` | Whether an object has `key`. |\n| `.get(key: string): JSON` | The value for `key` on an object. |\n| `.objectKeys(): Array<string>` | The keys of an object. |\n| `.at(index: i32): JSON` | The element at `index` of an array. |\n| `.length(): i32` | The element count of an array (0 otherwise). |\n| `.asString(): string` | Read the value as a string. |\n| `.asF64(): f64` / `.asI64(): i64` / `.asU64(): u64` | Read the value as a number. |\n| `.asBool(): bool` | Read the value as a bool. |\n| `.set(key: string, value: JSON): JSON` | Set a key on an object; returns `this`, so calls chain. |\n| `.push(value: JSON): JSON` | Append to an array; returns `this`, so calls chain. |\n| `.toString(): string` | Serialize this tree back to a JSON string. |\n\nReading an untyped body and building a reply, together:\n\n```ts\nimport { Response, RouteContext } from 'toiljs/server/runtime';\n\n@rest('echo')\nclass Echo {\n // POST /echo with an arbitrary JSON body we do not model as a @data class.\n @post('/')\n public handle(ctx: RouteContext): Response {\n const body = JSON.parse(ctx.text()); // ctx.text() is the raw body as text\n if (!body.isObject() || !body.has('name')) {\n return Response.badRequest('expected an object with a \"name\" field');\n }\n\n // Read fields out of the tree, guarding types and optional keys.\n const name = body.get('name').asString();\n const age = body.has('age') ? body.get('age').asI64() : 0;\n\n // Build a fresh JSON object to send back (chaining .set).\n const out = JSON.obj()\n .set('greeting', JSON.of<string>('hello, ' + name))\n .set('age', JSON.of<i64>(age));\n return Response.json(out.toString());\n }\n}\n```\n\nGotchas specific to `JSON`:\n\n- **Check the type before you read.** `JSON.parse` never throws; a malformed input or a wrong-typed field yields an error or default value rather than a crash. Use `.isObject()` / `.has(...)` / `.isString()` and friends to validate untrusted input before you trust it.\n- **It is a value tree, not a typed struct.** You get no compile-time field checking. When a shape is stable, prefer a `@data` class so the compiler catches typos and the client gets a typed type for free.\n- **Big-integer care still applies.** As with any JSON, integers above 2^53 are best carried as strings; read them with `.asString()` when exactness matters.\n\n## Gotchas\n\n- **Field order is the format.** The binary layout is exactly your field declaration order. Reordering fields, or changing a field's type, is a breaking change: old bytes will decode wrong. To evolve a format safely, add new fields at the **end** (and, for hand-rolled payloads, bump a leading version byte).\n- **Every field needs a default.** The generated decoder and the client constructor rely on it. A field with no default will not compile as `@data`.\n- **`encode()` carries a type id; `encodeInto` does not.** The 4-byte `dataId()` prefix lets a decoder confirm it is reading the type it expected. When nesting one `@data` inside another, `encodeInto` / `decodeFrom` skip that frame (the outer type already identifies the whole value).\n- **Endianness.** The WASM codec is little-endian. The `toiljs/io` codec defaults to little-endian too, and also accepts a per-call big-endian flag for network formats. Keep both ends on the same setting.\n- **Plain JSON numbers lose precision above 2^53.** That is why `@data` sends 64-bit-and-larger integers as decimal strings over JSON. If you hand-build JSON, do the same, or use the binary codec.\n- **`DataReader` never throws; check `ok`.** An over-read returns a default and sets `ok = false`. Always check `ok` after decoding untrusted bytes.\n\n## Related\n\n- [Types](../concepts/types.md): `u64`, `u256`, `f64`, and how each maps to `number` or `bigint`.\n- [HTTP routes (`@rest`)](./rest.md): where `@data` bodies and return values are used, and the JSON vs binary route modes.\n- [Typed RPC](./rpc.md): `@data` as RPC arguments and results, and the generated client classes.\n- [The database](../database/README.md): storing `@data` values in ToilDB.\n- [Backend overview](./README.md): where `@data` fits in the request lifecycle.\n",
9
10
  "backend/README.md": "# Backend overview\n\nYour backend is TypeScript that toilscript compiles into a small, sandboxed WebAssembly program, which runs on the Dacely edge and answers every request.\n\n## What the backend is\n\nIn a toiljs project, everything under `server/` is your backend. You write it in TypeScript, the same language as your frontend. But it does not run in a browser and it does not run in Node. Instead, a compiler called **toilscript** turns it into **WebAssembly** (often shortened to **WASM**): a compact, fast, portable binary format that many kinds of servers can run safely.\n\nThat compiled program is then deployed to the **Dacely edge**. Two pieces of jargon to unpack there:\n\n- **The edge** means a fleet of servers spread across many cities around the world. When a user makes a request, it is served by the edge node physically closest to them. Close means fast: less distance for the data to travel, so lower latency. You do not pick a region or manage servers; your one compiled backend runs everywhere at once.\n- **Sandboxed** means your WASM program runs inside a locked box. It cannot open files, reach the operating system, or make raw network connections on its own. The only way it can touch the outside world is through a small, fixed set of **host functions** that toiljs provides (read the request, build a response, query the database, send an email, and so on). Every one of those calls is metered and bounded, so a buggy or hostile backend cannot crash the node or read another app's data. This is what makes it safe to run thousands of different apps on the same shared edge.\n\nYou never call the WASM boundary by hand. You write normal TypeScript classes and functions, tag them with decorators (like `@rest` or `@service`), and the compiler wires everything up.\n\n> New to decorators? A **decorator** is the `@name` you write just above a class or method. It attaches meaning to that code without changing what the code does line by line. toiljs uses decorators to say \"this class is an HTTP controller\" or \"this method is callable from the browser.\" See [Decorators](../concepts/decorators.md).\n\n## The request lifecycle\n\nHere is what happens, end to end, when a browser talks to your backend.\n\n```mermaid\nflowchart TD\n U[\"User's browser\"] -->|\"HTTP request\"| E[\"Nearest Dacely edge node\"]\n E --> S{\"Static file<br/>for this path?\"}\n S -->|\"Yes (GET/HEAD)\"| F[\"Serve the file<br/>(HTML, JS, images)\"]\n S -->|\"No\"| W[\"Your compiled backend<br/>(server.wasm handle)\"]\n W --> H[\"Your handler runs<br/>and returns a Response\"]\n H --> E\n F --> E\n E -->|\"HTTP response\"| U\n```\n\nStep by step:\n\n1. The request lands on the closest edge node.\n2. The edge first checks whether the path is a **static file** it can serve directly (your built frontend: HTML pages, JavaScript bundles, images). If so, it serves the file and never wakes your code. This is fast and free.\n3. Otherwise the edge hands the request to your compiled backend by calling its single WASM export, `handle`.\n4. Inside, toiljs decodes the raw bytes into a friendly [`Request`](./rest.md#the-request-object) object and calls your handler's `handle(req)` method.\n5. Your handler returns a [`Response`](./rest.md#building-a-response). toiljs encodes it back into bytes.\n6. The edge sends that response to the browser.\n\nThe key mental model: your backend is a pure function of the request. Bytes in, bytes out, one request at a time.\n\n## Stateless by default\n\nA **fresh copy** of your handler serves each request. Any fields you set on a controller do not survive to the next request, and the request might even be served by a different edge node on the other side of the world. This is called being **stateless**.\n\nThat is a feature, not a limitation: it is what lets your backend scale to the whole planet with no coordination. When you need data to persist between requests (a user account, a counter, a list of posts), you store it in the built-in global database, **ToilDB**. See [the database section](../database/README.md).\n\n```mermaid\nflowchart LR\n R1[\"Request 1\"] --> B1[\"Handler copy A<br/>(fields reset)\"]\n R2[\"Request 2\"] --> B2[\"Handler copy B<br/>(fields reset)\"]\n B1 --> DB[(\"ToilDB<br/>(shared, persistent)\")]\n B2 --> DB\n```\n\n## The three surfaces\n\nYour backend can expose three different kinds of endpoint. Each is opted into with a decorator, and each has its own page:\n\n| Surface | Decorator | What it is | When to use it |\n| --- | --- | --- | --- |\n| **HTTP REST** | `@rest` + `@get`/`@post`/... | Plain HTTP routes with paths, methods, and status codes. | A public API, webhooks, anything a browser, `curl`, or a third party calls directly. See [REST](./rest.md). |\n| **Typed RPC** | `@service` / `@remote` | Server functions your own frontend calls like local async functions, fully type-checked end to end. | Talking from your own React app to your own backend. See [RPC](./rpc.md). |\n| **Realtime** | `@stream` | A long-lived connection where the server keeps state per connected client. | Chat, live cursors, notifications, anything push-based. See [Realtime](../realtime/README.md). |\n\nREST and RPC are the everyday tools. Most apps use both: REST for anything the outside world calls, RPC for your own frontend. They are not exclusive; you can use all three in one project.\n\nAll of these share the same building block for their data: **`@data` classes**, which are typed structs that travel safely between the browser and your WASM backend. See [Data types](./data.md).\n\n## Where your handler lives\n\nREST, RPC, and streams self-register: you tag a class and the compiler adds it to the right dispatcher. A tiny amount of glue lives in `server/main.ts`, which imports your route files and names your top-level handler class. In a typical project you rarely touch `main.ts`; you add route and service files and they are discovered automatically.\n\nYour handler class extends `ToilHandler` and overrides `handle`. The common pattern is to try the REST dispatcher first, then fall back to your own logic:\n\n```ts\nimport { Method, Request, Response, Rest, ToilHandler } from 'toiljs/server/runtime';\n\nexport class AppHandler extends ToilHandler {\n public handle(req: Request): Response {\n // Try every @rest controller. Returns the first match, or null.\n const hit = Rest.dispatch(req);\n if (hit != null) return hit;\n\n // Your own hand-written endpoints can go here.\n if (req.path == '/health') return Response.text('ok\\n');\n\n // \"I have no answer for this path\": let the edge serve it (a static\n // file, the client app) instead of returning a hard 404.\n return Response.unhandled();\n }\n}\n```\n\nRPC calls (to the reserved path `/__toil_rpc`) are handled by the framework before your `handle` runs, so you do not dispatch them yourself.\n\n### Per-request hooks: `onRequestStarted` and `onRequestCompleted`\n\n`ToilHandler` gives you two optional hooks that run around every request, so you can add cross-cutting logic (logging, timing, metrics) once instead of repeating it in every route, and without re-implementing `handle`:\n\n- **`onRequestStarted(req: Request): void`** runs just **before** `handle(req)`. Override it for per-request setup: start a timer, log the incoming method and path, read a header you need everywhere.\n- **`onRequestCompleted(req: Request, resp: Response): void`** runs just **after** `handle` returns, and it is handed the `Response` that is about to go out. It **also runs when your handler throws**, after the runtime has turned that throw into a `500`, so it is the right place for teardown you always want to happen (record the outcome, stop the timer, emit a metric).\n\nBoth are empty by default, so override only the one you need. The framework calls them around every request; you never call them yourself.\n\n```ts\nimport { Request, Response, Rest, ToilHandler } from 'toiljs/server/runtime';\n\nexport class AppHandler extends ToilHandler {\n public handle(req: Request): Response {\n const hit = Rest.dispatch(req);\n return hit != null ? hit : Response.unhandled();\n }\n\n // Runs before every handle().\n public onRequestStarted(req: Request): void {\n // per-request setup, e.g. note the path you are about to serve\n }\n\n // Runs after every handle(), including after a throw became a 500.\n public onRequestCompleted(req: Request, resp: Response): void {\n // always-run teardown: record the outcome, stop a timer, emit a metric\n }\n}\n```\n\nIf your project is REST-only, you do not even need a custom handler; toiljs ships a ready-made one. See [REST](./rest.md#dispatch-and-the-404-fallback).\n\n## Compute tiers\n\nThe request/response backend described here is the default and most common tier, called **L1**. toiljs also has higher tiers for long-lived connections (streams) and scheduled background work (daemons), each compiled into its own WASM artifact from the same project. You opt into a tier just by adding its entry file and surface decorator. For the full picture, see [Compute tiers](../concepts/tiers.md).\n\n## Related\n\n- [HTTP routes (`@rest`)](./rest.md): paths, methods, params, and responses.\n- [Typed RPC (`@service`/`@remote`)](./rpc.md): calling the server from your frontend with end-to-end types.\n- [Data types (`@data`)](./data.md): the serializable structs everything uses.\n- [The database (ToilDB)](../database/README.md): where persistent state lives.\n- [Compute tiers](../concepts/tiers.md): L1 request, L2/L3 stream, L4 daemon.\n- [Realtime streams](../realtime/README.md): the `@stream` surface.\n",
package/docs/README.md CHANGED
@@ -87,7 +87,8 @@ Run `toiljs dev`, open the browser, and both are live with hot reload. That is t
87
87
  [Events](./database/events.md), [Views and `@derive`](./database/views.md),
88
88
  [Membership](./database/membership.md), [Capacity](./database/capacity.md).
89
89
 
90
- **Auth**: [the full auth guide](./auth/README.md) covers post-quantum login, sessions, and `ToilUserId`.
90
+ **Auth**: [the full auth guide](./auth/README.md) covers post-quantum login, sessions, and `ToilUserId`;
91
+ [customizing the auth emails](./auth/emails.md) shows how to brand the verification, reset, and 2FA mail.
91
92
 
92
93
  **Realtime and background**
93
94
  - [Streams](./realtime/README.md) and [channels](./realtime/channels.md), [Daemons and scheduled
@@ -96,6 +96,8 @@ ML-KEM-768 challenge. You didn't write any of it.
96
96
  Argon2id, and the deploy checklist.
97
97
  - **[Extending & integrating](./extending.md)**: `ToilUserId`, keying your own data on a user, a custom
98
98
  user shape / opting out, and the `AuthService` primitive reference.
99
+ - **[Customizing the auth emails](./emails.md)**: replace the verification, password-reset, and 2FA
100
+ emails with your own branded React templates by dropping `emails/auth-*.tsx` files.
99
101
 
100
102
  > **One rule before you ship:** built-in auth runs with **insecure DEV fallback secrets** so it Just Works
101
103
  > locally. A deployment MUST set `AUTH_SESSION_SECRET`, `AUTH_OPRF_SEED`, and `AUTH_KEM_SK`. See
@@ -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.
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.105",
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": {