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.
- package/CHANGELOG.md +5 -0
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/auth-emails.d.ts +28 -0
- package/build/compiler/auth-emails.js +165 -0
- package/build/compiler/emails.d.ts +2 -0
- package/build/compiler/emails.js +4 -1
- package/build/compiler/index.js +12 -0
- package/build/compiler/toil-docs.generated.js +3 -2
- package/docs/README.md +2 -1
- package/docs/auth/README.md +2 -0
- package/docs/auth/emails.md +91 -0
- package/docs/llms.txt +1 -0
- package/package.json +1 -1
- package/server/auth/AuthController.ts +16 -40
- package/server/globals/email.ts +15 -2
- package/src/compiler/auth-emails.ts +279 -0
- package/src/compiler/emails.ts +14 -1
- package/src/compiler/index.ts +24 -1
- package/src/compiler/toil-docs.generated.ts +3 -2
- package/test/auth-emails.test.ts +115 -0
- package/test/email-console.test.ts +2 -2
|
@@ -1188,19 +1188,12 @@ class Auth {
|
|
|
1188
1188
|
switch (method) {
|
|
1189
1189
|
case TWOFA_EMAIL: {
|
|
1190
1190
|
const code = randomCode(TWOFA_DIGITS);
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
'<p style="font-size:28px;font-weight:bold;letter-spacing:4px">' +
|
|
1198
|
-
code +
|
|
1199
|
-
'</p>' +
|
|
1200
|
-
'<p>It expires in a few minutes. If you did not request it, ignore this email.</p>';
|
|
1201
|
-
// DETACHED (non-suspending) send: constant-time, no provider-RTT
|
|
1202
|
-
// parking (matches the confirm/reset anti-enumeration posture).
|
|
1203
|
-
EmailService.sendDetached(email, subject, text, '2fa', html);
|
|
1191
|
+
// The subject is contextual (login vs setup) so AuthController owns it;
|
|
1192
|
+
// the body/html come from the generated `AuthEmail` — the built-in
|
|
1193
|
+
// default, or an `emails/auth-2fa.tsx` override filling `{code}`. The
|
|
1194
|
+
// send is DETACHED (constant-time, no provider-RTT parking), a property
|
|
1195
|
+
// the override cannot lose (AuthEmail.twofa uses sendDetached).
|
|
1196
|
+
AuthEmail.twofa(email, code, subject);
|
|
1204
1197
|
return twoFaCodeHash(host, username, code);
|
|
1205
1198
|
}
|
|
1206
1199
|
// >>> case TWOFA_TOTP: { return twoFaSecretBinding(username); } <<<
|
|
@@ -1267,19 +1260,11 @@ class Auth {
|
|
|
1267
1260
|
// Token in the URL FRAGMENT (see resetRequest): kept out of server/CDN
|
|
1268
1261
|
// logs and the Referer header; the confirm page reads it from location.hash.
|
|
1269
1262
|
const link = baseUrl(ctx) + '/confirm#token=' + crypto.toHex(raw);
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
link +
|
|
1276
|
-
'">Confirm my account</a></p>' +
|
|
1277
|
-
'<p>Or paste this into your browser:<br>' +
|
|
1278
|
-
link +
|
|
1279
|
-
'</p>';
|
|
1280
|
-
// DETACHED (non-suspending) send: constant-time, no provider-RTT parking on
|
|
1281
|
-
// the "account exists" path (see the enumeration note on this method).
|
|
1282
|
-
EmailService.sendDetached(email, subject, text, 'verify', html);
|
|
1263
|
+
// The built-in confirm email, or an `emails/auth-confirm.tsx` override filling
|
|
1264
|
+
// `{link}`. DETACHED (non-suspending) send: constant-time, no provider-RTT
|
|
1265
|
+
// parking on the "account exists" path (see the enumeration note on this
|
|
1266
|
+
// method); AuthEmail.confirm uses sendDetached, so an override keeps that.
|
|
1267
|
+
AuthEmail.confirm(email, link);
|
|
1283
1268
|
}
|
|
1284
1269
|
|
|
1285
1270
|
/** Email a password-reset link. Best-effort (the request path already
|
|
@@ -1292,19 +1277,10 @@ class Auth {
|
|
|
1292
1277
|
* (Residual: the reset-token mint on the exists path still does a sub-ms
|
|
1293
1278
|
* ToilDB write the miss path skips.) */
|
|
1294
1279
|
private sendResetEmail(email: string, link: string): void {
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
link +
|
|
1301
|
-
'">Reset my password</a></p>' +
|
|
1302
|
-
'<p>If you did not request this, you can ignore this email.</p>' +
|
|
1303
|
-
'<p>Or paste this into your browser:<br>' +
|
|
1304
|
-
link +
|
|
1305
|
-
'</p>';
|
|
1306
|
-
// DETACHED (non-suspending) send: constant-time, closes the reset-request
|
|
1307
|
-
// email-enumeration timing oracle (see the note above).
|
|
1308
|
-
EmailService.sendDetached(email, subject, text, 'reset', html);
|
|
1280
|
+
// The built-in reset email, or an `emails/auth-reset.tsx` override filling
|
|
1281
|
+
// `{link}`. DETACHED (non-suspending) send: constant-time, closes the
|
|
1282
|
+
// reset-request email-enumeration timing oracle (see the note above);
|
|
1283
|
+
// AuthEmail.reset uses sendDetached, so an override keeps that property.
|
|
1284
|
+
AuthEmail.reset(email, link);
|
|
1309
1285
|
}
|
|
1310
1286
|
}
|
package/server/globals/email.ts
CHANGED
|
@@ -68,7 +68,7 @@ export namespace EmailService {
|
|
|
68
68
|
* the off-core mailer reports a result.
|
|
69
69
|
*
|
|
70
70
|
* `body` is the plain-text body; pass a non-empty `html` to send an HTML
|
|
71
|
-
* message (then `body` is the plain-text alternative
|
|
71
|
+
* message (then `body` is the plain-text alternative; set both for the best
|
|
72
72
|
* deliverability, or leave `body` empty for HTML-only). `purpose` is a short,
|
|
73
73
|
* non-PII tag used for host-side dedup/abuse keying.
|
|
74
74
|
*/
|
|
@@ -181,7 +181,7 @@ export class RenderedEmail {
|
|
|
181
181
|
* welcome.send('alice@example.com', vars, 'welcome');
|
|
182
182
|
*
|
|
183
183
|
* `{{ key }}` (with surrounding spaces) is accepted; an unknown placeholder
|
|
184
|
-
* renders to the empty string. `html` is optional
|
|
184
|
+
* renders to the empty string. `html` is optional: omit it for a plain-text
|
|
185
185
|
* template.
|
|
186
186
|
*/
|
|
187
187
|
export class EmailTemplate {
|
|
@@ -209,6 +209,19 @@ export class EmailTemplate {
|
|
|
209
209
|
const r = this.render(vars);
|
|
210
210
|
return EmailService.send(to, r.subject, r.body, purpose, r.html);
|
|
211
211
|
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Render and DETACHED-send to `to`: frames the message and returns via the
|
|
215
|
+
* non-suspending {@link EmailService.sendDetached}, so the call is constant
|
|
216
|
+
* time regardless of whether the recipient exists. Use for transactional auth
|
|
217
|
+
* mail (confirm / reset links, 2FA codes) whose delivery result the caller
|
|
218
|
+
* does not need; this is what keeps the built-in auth flows free of an
|
|
219
|
+
* email-enumeration timing oracle even when an app overrides the template.
|
|
220
|
+
*/
|
|
221
|
+
sendDetached(to: string, vars: Map<string, string>, purpose: string = 'tx'): EmailStatus {
|
|
222
|
+
const r = this.render(vars);
|
|
223
|
+
return EmailService.sendDetached(to, r.subject, r.body, purpose, r.html);
|
|
224
|
+
}
|
|
212
225
|
}
|
|
213
226
|
|
|
214
227
|
/**
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in auth email templates + their per-app OVERRIDE pipeline.
|
|
3
|
+
*
|
|
4
|
+
* The built-in auth controller (`server/auth/AuthController.ts`) sends three
|
|
5
|
+
* transactional emails: the email-verification link, the password-reset link,
|
|
6
|
+
* and the 2FA code. Their default subject/text/html live here as the single
|
|
7
|
+
* source of truth. An app overrides any of them by dropping a reserved-name
|
|
8
|
+
* React template in `emails/`:
|
|
9
|
+
*
|
|
10
|
+
* emails/auth-confirm.tsx -> email verification (interpolates `{link}`)
|
|
11
|
+
* emails/auth-reset.tsx -> password reset (interpolates `{link}`)
|
|
12
|
+
* emails/auth-2fa.tsx -> 2FA code (interpolates `{code}`)
|
|
13
|
+
*
|
|
14
|
+
* Whenever auth is on, the build (re)writes an ambient `_auth_emails.ts` into the
|
|
15
|
+
* toiljs `server/globals` LIB dir, exposing `AuthEmail.confirm/reset/twofa` baked
|
|
16
|
+
* with the app's override when present and the default otherwise. Because it rides
|
|
17
|
+
* the same `lib` set as `EmailService`/`AuthService`, the node_modules-compiled
|
|
18
|
+
* `AuthController` calls it with NO import (an exported namespace in a plain app
|
|
19
|
+
* entry would be module-scoped, not global). Every send goes through
|
|
20
|
+
* `EmailTemplate.sendDetached`, so overriding a template can never reintroduce the
|
|
21
|
+
* auth email-enumeration timing oracle the detached send closes.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
|
|
28
|
+
import pc from 'picocolors';
|
|
29
|
+
import { createServer } from 'vite';
|
|
30
|
+
|
|
31
|
+
import type { ResolvedToilConfig } from './config.js';
|
|
32
|
+
import { RESERVED_AUTH_EMAIL_NAMES, renderEmailFile, toPascal, type RenderedEmail } from './emails.js';
|
|
33
|
+
import { createViteConfig } from './vite.js';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The toiljs package's `server/globals` LIB directory: the ambient, no-import
|
|
37
|
+
* surface (`EmailService`, `AuthService`, …) the toilconfig `lib` array points
|
|
38
|
+
* at. The auth-email module is generated HERE, not in the app's `server/` dir,
|
|
39
|
+
* because that is what makes `AuthEmail` resolvable from the node_modules-compiled
|
|
40
|
+
* `AuthController` with NO import (an exported namespace in a plain app entry is
|
|
41
|
+
* module-scoped, not global). Prefers the app's install so a symlinked/linked
|
|
42
|
+
* toiljs is followed; falls back to the running package (hoisted install).
|
|
43
|
+
*/
|
|
44
|
+
function globalsDir(root: string): string {
|
|
45
|
+
const inApp = path.join(root, 'node_modules', 'toiljs', 'server', 'globals');
|
|
46
|
+
if (fs.existsSync(inApp)) return inApp;
|
|
47
|
+
const pkgDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
|
48
|
+
return path.join(pkgDir, 'server', 'globals');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The reserved override names (PascalCase), one per built-in auth email. */
|
|
52
|
+
type AuthName = 'AuthConfirm' | 'AuthReset' | 'Auth2fa';
|
|
53
|
+
|
|
54
|
+
/** One built-in auth email: its reserved override name, the token it fills, the
|
|
55
|
+
* host-side `purpose` tag, and the default subject/text/html (with `{{token}}`). */
|
|
56
|
+
interface AuthEmailSpec {
|
|
57
|
+
/** The generated `AuthEmail.<fn>` this feeds. */
|
|
58
|
+
readonly fn: 'confirm' | 'reset' | 'twofa';
|
|
59
|
+
/** The single runtime value the template interpolates. */
|
|
60
|
+
readonly token: 'link' | 'code';
|
|
61
|
+
/** Host dedup/abuse tag; must match what AuthController passed before. */
|
|
62
|
+
readonly purpose: string;
|
|
63
|
+
/** Default subject. Empty (and ignored) for `twofa`: AuthController passes login/setup subjects. */
|
|
64
|
+
readonly defaultSubject: string;
|
|
65
|
+
readonly defaultText: string;
|
|
66
|
+
readonly defaultHtml: string;
|
|
67
|
+
/** Whether the subject is a runtime arg (twofa) rather than a baked default (confirm/reset). */
|
|
68
|
+
readonly subjectFromArg: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The reserved override name -> its spec. Keys mirror {@link RESERVED_AUTH_EMAIL_NAMES}. */
|
|
72
|
+
const SPECS: Record<AuthName, AuthEmailSpec> = {
|
|
73
|
+
AuthConfirm: {
|
|
74
|
+
fn: 'confirm',
|
|
75
|
+
token: 'link',
|
|
76
|
+
purpose: 'verify',
|
|
77
|
+
subjectFromArg: false,
|
|
78
|
+
defaultSubject: 'Confirm your account',
|
|
79
|
+
defaultText: 'Confirm your account by opening this link:\n{{link}}\n',
|
|
80
|
+
defaultHtml:
|
|
81
|
+
'<p>Confirm your account by clicking the link below:</p>' +
|
|
82
|
+
'<p><a href="{{link}}">Confirm my account</a></p>' +
|
|
83
|
+
'<p>Or paste this into your browser:<br>{{link}}</p>',
|
|
84
|
+
},
|
|
85
|
+
AuthReset: {
|
|
86
|
+
fn: 'reset',
|
|
87
|
+
token: 'link',
|
|
88
|
+
purpose: 'reset',
|
|
89
|
+
subjectFromArg: false,
|
|
90
|
+
defaultSubject: 'Reset your password',
|
|
91
|
+
defaultText: 'Reset your password by opening this link:\n{{link}}\n',
|
|
92
|
+
defaultHtml:
|
|
93
|
+
'<p>We received a request to reset your password. Click the link below:</p>' +
|
|
94
|
+
'<p><a href="{{link}}">Reset my password</a></p>' +
|
|
95
|
+
'<p>If you did not request this, you can ignore this email.</p>' +
|
|
96
|
+
'<p>Or paste this into your browser:<br>{{link}}</p>',
|
|
97
|
+
},
|
|
98
|
+
Auth2fa: {
|
|
99
|
+
fn: 'twofa',
|
|
100
|
+
token: 'code',
|
|
101
|
+
purpose: '2fa',
|
|
102
|
+
subjectFromArg: true,
|
|
103
|
+
// Subject is supplied by AuthController (login vs setup), so it is not baked.
|
|
104
|
+
defaultSubject: '',
|
|
105
|
+
defaultText:
|
|
106
|
+
'Your verification code is {{code}}.\n' +
|
|
107
|
+
'It expires in a few minutes. If you did not request it, ignore this email.\n',
|
|
108
|
+
defaultHtml:
|
|
109
|
+
'<p>Your verification code is:</p>' +
|
|
110
|
+
'<p style="font-size:28px;font-weight:bold;letter-spacing:4px">{{code}}</p>' +
|
|
111
|
+
'<p>It expires in a few minutes. If you did not request it, ignore this email.</p>',
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const AUTH_NAMES = Object.keys(SPECS) as AuthName[];
|
|
116
|
+
|
|
117
|
+
/** The effective (override-or-default) parts for each auth email. */
|
|
118
|
+
type Parts = Record<AuthName, Effective>;
|
|
119
|
+
interface Effective {
|
|
120
|
+
readonly subject: string;
|
|
121
|
+
readonly text: string;
|
|
122
|
+
readonly html: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const GENERATED_BASENAME = '_auth_emails.ts';
|
|
126
|
+
|
|
127
|
+
function warn(msg: string): void {
|
|
128
|
+
process.stderr.write(` toil: auth emails ${msg}\n`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A valid AS/JS string literal (double-quoted, fully escaped). */
|
|
132
|
+
function asLit(s: string): string {
|
|
133
|
+
return JSON.stringify(s);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Every reserved auth-override file present in `emails/`, by PascalCase name. */
|
|
137
|
+
function reservedFilesIn(emailsDir: string): Map<AuthName, string> {
|
|
138
|
+
const out = new Map<AuthName, string>();
|
|
139
|
+
if (!fs.existsSync(emailsDir)) return out;
|
|
140
|
+
for (const f of fs.readdirSync(emailsDir).sort()) {
|
|
141
|
+
if (!/\.(tsx|jsx)$/.test(f)) continue;
|
|
142
|
+
const name = toPascal(f.replace(/\.(tsx|jsx)$/, ''));
|
|
143
|
+
if (RESERVED_AUTH_EMAIL_NAMES.has(name) && !out.has(name as AuthName))
|
|
144
|
+
out.set(name as AuthName, f);
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Fold a rendered override into the effective parts for `name`, warning about
|
|
151
|
+
* templates that will not interpolate correctly (a missing required token means
|
|
152
|
+
* the link/code would be absent; an extra token renders empty). A `subject` the
|
|
153
|
+
* renderer defaulted to the module name is treated as "no custom subject".
|
|
154
|
+
*/
|
|
155
|
+
function effectiveFromOverride(name: AuthName, spec: AuthEmailSpec, r: RenderedEmail): Effective {
|
|
156
|
+
if (!r.tokens.includes(spec.token))
|
|
157
|
+
warn(
|
|
158
|
+
`override emails/${name} never uses the {${spec.token}} prop, so the ` +
|
|
159
|
+
`${spec.token === 'link' ? 'link' : 'code'} will be missing from the email.`,
|
|
160
|
+
);
|
|
161
|
+
for (const t of r.tokens)
|
|
162
|
+
if (t !== spec.token)
|
|
163
|
+
warn(
|
|
164
|
+
`override emails/${name} uses {${t}}, which auth does not provide ` +
|
|
165
|
+
`(only {${spec.token}}); it will render empty.`,
|
|
166
|
+
);
|
|
167
|
+
// For twofa the subject is a runtime arg; for confirm/reset use the template's
|
|
168
|
+
// subject unless it defaulted to the component name (no `export const subject`).
|
|
169
|
+
const subject = spec.subjectFromArg || r.subject === name ? spec.defaultSubject : r.subject;
|
|
170
|
+
return { subject, text: r.text, html: r.html };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Codegen one `AuthEmail.<fn>` function. `twofa` takes its subject as an arg. */
|
|
174
|
+
function emitFn(spec: AuthEmailSpec, eff: Effective): string[] {
|
|
175
|
+
const subjectExpr = spec.subjectFromArg ? 'subject' : asLit(eff.subject);
|
|
176
|
+
const sig =
|
|
177
|
+
spec.fn === 'twofa'
|
|
178
|
+
? 'twofa(to: string, code: string, subject: string): void'
|
|
179
|
+
: `${spec.fn}(to: string, ${spec.token}: string): void`;
|
|
180
|
+
return [
|
|
181
|
+
` export function ${sig} {`,
|
|
182
|
+
` const T: string = ${asLit(eff.text)};`,
|
|
183
|
+
` const H: string = ${asLit(eff.html)};`,
|
|
184
|
+
` const v = new Map<string, string>();`,
|
|
185
|
+
` v.set(${asLit(spec.token)}, ${spec.token});`,
|
|
186
|
+
` new EmailTemplate(${subjectExpr}, T, H).sendDetached(to, v, ${asLit(spec.purpose)});`,
|
|
187
|
+
` }`,
|
|
188
|
+
];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Codegen the ambient `AuthEmail` AssemblyScript module. */
|
|
192
|
+
function moduleSource(parts: Parts): string {
|
|
193
|
+
const out: string[] = [
|
|
194
|
+
'// GENERATED by toiljs from the built-in auth email templates -- DO NOT EDIT.',
|
|
195
|
+
'// Override any of these by adding emails/auth-confirm.tsx, emails/auth-reset.tsx,',
|
|
196
|
+
'// or emails/auth-2fa.tsx (see docs/auth/emails.md). `EmailTemplate` is a toiljs',
|
|
197
|
+
'// global (server/globals/email.ts); the detached send keeps auth constant-time.',
|
|
198
|
+
'',
|
|
199
|
+
'export namespace AuthEmail {',
|
|
200
|
+
];
|
|
201
|
+
for (const name of AUTH_NAMES) out.push(...emitFn(SPECS[name], parts[name]));
|
|
202
|
+
out.push('}');
|
|
203
|
+
return out.join('\n') + '\n';
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* (Re)write `_auth_emails.ts` in the toiljs lib globals dir so the built-in auth
|
|
208
|
+
* controller has its email senders, baking any reserved `emails/*.tsx` override
|
|
209
|
+
* over the default. Runs BEFORE the toilscript server build (like
|
|
210
|
+
* {@link renderEmails}) so the module is compiled in. A no-op that removes a stale
|
|
211
|
+
* module when `authOn` is false. Only spins up Vite when an override actually
|
|
212
|
+
* exists.
|
|
213
|
+
*/
|
|
214
|
+
export async function renderAuthEmails(cfg: ResolvedToilConfig, authOn: boolean): Promise<void> {
|
|
215
|
+
const generatedPath = path.join(globalsDir(cfg.root), GENERATED_BASENAME);
|
|
216
|
+
|
|
217
|
+
if (!authOn) {
|
|
218
|
+
if (fs.existsSync(generatedPath)) fs.rmSync(generatedPath);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const emailsDir = path.join(cfg.root, 'emails');
|
|
223
|
+
const overrides = reservedFilesIn(emailsDir);
|
|
224
|
+
|
|
225
|
+
// Start from every default, then render + fold in whatever the app overrides.
|
|
226
|
+
const parts = {} as Parts;
|
|
227
|
+
for (const name of AUTH_NAMES) {
|
|
228
|
+
const spec = SPECS[name];
|
|
229
|
+
parts[name] = { subject: spec.defaultSubject, text: spec.defaultText, html: spec.defaultHtml };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (overrides.size > 0) {
|
|
233
|
+
const { renderToStaticMarkup } = await import('react-dom/server');
|
|
234
|
+
const server = await createServer({
|
|
235
|
+
...(await createViteConfig(cfg)),
|
|
236
|
+
server: { middlewareMode: true, hmr: false },
|
|
237
|
+
appType: 'custom',
|
|
238
|
+
logLevel: 'silent',
|
|
239
|
+
});
|
|
240
|
+
try {
|
|
241
|
+
for (const [name, file] of overrides) {
|
|
242
|
+
try {
|
|
243
|
+
const r = await renderEmailFile(
|
|
244
|
+
server,
|
|
245
|
+
emailsDir,
|
|
246
|
+
file,
|
|
247
|
+
renderToStaticMarkup as (el: unknown) => string,
|
|
248
|
+
);
|
|
249
|
+
if (r) parts[name] = effectiveFromOverride(name, SPECS[name], r);
|
|
250
|
+
else warn(`skipped ${file} (no default-exported component)`);
|
|
251
|
+
} catch (err) {
|
|
252
|
+
warn(`skipped ${file} (${err instanceof Error ? err.message : String(err)})`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
} finally {
|
|
256
|
+
await server.close();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const next = moduleSource(parts);
|
|
261
|
+
const prev = fs.existsSync(generatedPath) ? fs.readFileSync(generatedPath, 'utf8') : null;
|
|
262
|
+
if (prev === next) return; // no change: do not bump mtime (would retrigger the dev watcher)
|
|
263
|
+
fs.mkdirSync(path.dirname(generatedPath), { recursive: true });
|
|
264
|
+
fs.writeFileSync(generatedPath, next);
|
|
265
|
+
if (overrides.size > 0)
|
|
266
|
+
process.stdout.write(
|
|
267
|
+
pc.green(' ✓ ') +
|
|
268
|
+
pc.dim(
|
|
269
|
+
`auth emails: overrode ${[...overrides.keys()].map((n) => SPECS[n].fn).join(', ')}`,
|
|
270
|
+
) +
|
|
271
|
+
'\n',
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** The generated module's basename, so the server watcher can ignore its own output. */
|
|
276
|
+
export const AUTH_EMAILS_GENERATED_BASENAME = GENERATED_BASENAME;
|
|
277
|
+
|
|
278
|
+
// Exported for unit testing the pure fold/codegen without a Vite server.
|
|
279
|
+
export const __test = { moduleSource, effectiveFromOverride, SPECS };
|
package/src/compiler/emails.ts
CHANGED
|
@@ -63,6 +63,15 @@ const REACT_INTERNAL = new Set([
|
|
|
63
63
|
'then', // so a thenable check never tokenizes
|
|
64
64
|
]);
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Reserved `emails/*.tsx` module names (by PascalCase basename) that OVERRIDE a
|
|
68
|
+
* built-in auth email instead of becoming a generic `Emails.<Name>.send(...)`
|
|
69
|
+
* entry. When one is present, the auth pipeline (src/compiler/auth-emails.ts)
|
|
70
|
+
* bakes it into `_auth_emails.ts` in place of the default; here we just make sure
|
|
71
|
+
* it does NOT also surface as an app-callable `Emails.*` template.
|
|
72
|
+
*/
|
|
73
|
+
export const RESERVED_AUTH_EMAIL_NAMES = new Set(['AuthConfirm', 'AuthReset', 'Auth2fa']);
|
|
74
|
+
|
|
66
75
|
const TOKEN_RE = /\{\{\s*([A-Za-z_$][\w$]*)\s*\}\}/g;
|
|
67
76
|
|
|
68
77
|
function extractTokens(s: string): string[] {
|
|
@@ -240,7 +249,7 @@ export function toPascal(base: string): string {
|
|
|
240
249
|
|
|
241
250
|
/** Server source dir (where the generated module must live to be compiled): the
|
|
242
251
|
* dir of the first toilconfig entry, else `<root>/server`. */
|
|
243
|
-
function serverDir(root: string): string {
|
|
252
|
+
export function serverDir(root: string): string {
|
|
244
253
|
try {
|
|
245
254
|
const cfg = JSON.parse(fs.readFileSync(path.join(root, 'toilconfig.json'), 'utf8')) as {
|
|
246
255
|
entries?: unknown;
|
|
@@ -344,6 +353,10 @@ export async function renderEmails(cfg: ResolvedToilConfig): Promise<void> {
|
|
|
344
353
|
const rendered: RenderedEmail[] = [];
|
|
345
354
|
try {
|
|
346
355
|
for (const file of files) {
|
|
356
|
+
// Reserved auth-override templates (emails/auth-confirm.tsx, …) are
|
|
357
|
+
// consumed by the auth pipeline, not surfaced as generic `Emails.*`.
|
|
358
|
+
if (RESERVED_AUTH_EMAIL_NAMES.has(toPascal(path.basename(file).replace(/\.(tsx|jsx)$/, ''))))
|
|
359
|
+
continue;
|
|
347
360
|
try {
|
|
348
361
|
const r = await renderEmailFile(
|
|
349
362
|
server,
|
package/src/compiler/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type { RunningBackend } from 'toiljs/backend';
|
|
|
14
14
|
|
|
15
15
|
import { loadConfig, type ResolvedToilConfig } from './config.js';
|
|
16
16
|
import { renderEmails } from './emails.js';
|
|
17
|
+
import { AUTH_EMAILS_GENERATED_BASENAME, renderAuthEmails } from './auth-emails.js';
|
|
17
18
|
import { generate, TOIL_SERVER_ENV_DTS } from './generate.js';
|
|
18
19
|
import { prerenderStaticParams } from './ssg.js';
|
|
19
20
|
import {
|
|
@@ -179,6 +180,20 @@ function serverImportsAuth(root: string, files: string[]): boolean {
|
|
|
179
180
|
return false;
|
|
180
181
|
}
|
|
181
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Whether the built-in auth surface is on for this project: the `server.auth` config flag OR the
|
|
185
|
+
* escape-hatch `import 'toiljs/server/auth'` in a server entry. This is the SAME predicate
|
|
186
|
+
* `buildServer` uses to decide whether to inject `AuthController` (see `authOn` there); the email
|
|
187
|
+
* pipeline reuses it so `_auth_emails.ts` is generated exactly when the controller that calls
|
|
188
|
+
* `AuthEmail.*` is compiled in (a mismatch would be a missing-symbol compile error). Client-only
|
|
189
|
+
* projects (no `toilconfig.json`) are never auth-on.
|
|
190
|
+
*/
|
|
191
|
+
function serverAuthEnabled(root: string, configAuth: boolean): boolean {
|
|
192
|
+
if (configAuth) return true;
|
|
193
|
+
if (!fs.existsSync(path.join(root, 'toilconfig.json'))) return false;
|
|
194
|
+
return serverImportsAuth(root, serverEntryFiles(root));
|
|
195
|
+
}
|
|
196
|
+
|
|
182
197
|
/**
|
|
183
198
|
* Builds the toilscript server target (which also regenerates `shared/server.ts` via
|
|
184
199
|
* `--rpcModule`) when the project has one, signalled by a `toilconfig.json` at the root. This
|
|
@@ -581,6 +596,7 @@ function watchServer(cfg: ResolvedToilConfig, watcher: ViteDevServer['watcher'])
|
|
|
581
596
|
// Recompile emails/*.tsx -> the generated module before the server build,
|
|
582
597
|
// so editing an email template hot-reloads like any other server change.
|
|
583
598
|
renderEmails(cfg)
|
|
599
|
+
.then(() => renderAuthEmails(cfg, serverAuthEnabled(root, cfg.auth)))
|
|
584
600
|
.then(() => buildServer(root, cfg.auth))
|
|
585
601
|
.then(() => process.stdout.write(pc.green(' ✓ ') + pc.dim('server rebuilt') + '\n'))
|
|
586
602
|
.catch((e: unknown) =>
|
|
@@ -599,9 +615,10 @@ function watchServer(cfg: ResolvedToilConfig, watcher: ViteDevServer['watcher'])
|
|
|
599
615
|
const isServerSource = (file: string): boolean =>
|
|
600
616
|
file.endsWith('.ts') &&
|
|
601
617
|
!file.endsWith('.d.ts') &&
|
|
602
|
-
// `_emails.ts`
|
|
618
|
+
// `_emails.ts` / `_auth_emails.ts` are GENERATED on every rebuild; reacting to
|
|
603
619
|
// our own output would loop forever (rebuild -> write -> rebuild -> ...).
|
|
604
620
|
path.basename(file) !== '_emails.ts' &&
|
|
621
|
+
path.basename(file) !== AUTH_EMAILS_GENERATED_BASENAME &&
|
|
605
622
|
dirs.some((dir) => file === dir || file.startsWith(dir + path.sep));
|
|
606
623
|
const isEmailSource = (file: string): boolean =>
|
|
607
624
|
/\.(tsx|jsx)$/.test(file) && (file === emailsDir || file.startsWith(emailsDir + path.sep));
|
|
@@ -863,6 +880,9 @@ export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer>
|
|
|
863
880
|
if (hasServer) process.stdout.write(pc.dim(' building the server (toilscript)…') + '\n');
|
|
864
881
|
// Compile emails/*.tsx -> generated server module BEFORE toilscript builds it in.
|
|
865
882
|
await renderEmails(cfg);
|
|
883
|
+
// Built-in auth: (re)generate `<server>/_auth_emails.ts` (defaults + any emails/auth-*.tsx
|
|
884
|
+
// overrides) so the injected AuthController's `AuthEmail.*` senders compile in.
|
|
885
|
+
await renderAuthEmails(cfg, serverAuthEnabled(cfg.root, cfg.auth));
|
|
866
886
|
// Generate the client codegen first so the SSR slots pre-pass can load the route graph, then
|
|
867
887
|
// emit the server-importable `<server>/_ssr/<name>.slots.ts` BEFORE the server build so its
|
|
868
888
|
// `render` can import them. Dev reuses the prior build's shell (or the template) for the HASH;
|
|
@@ -988,6 +1008,9 @@ export async function build(opts: ToilCommandOptions = {}): Promise<void> {
|
|
|
988
1008
|
process.stdout.write(pc.dim(' building the server (toilscript)…') + '\n');
|
|
989
1009
|
// Compile emails/*.tsx -> generated server module BEFORE toilscript builds it in.
|
|
990
1010
|
await renderEmails(cfg);
|
|
1011
|
+
// Built-in auth: (re)generate `<server>/_auth_emails.ts` (defaults + any emails/auth-*.tsx
|
|
1012
|
+
// overrides) so the injected AuthController's `AuthEmail.*` senders compile in.
|
|
1013
|
+
await renderAuthEmails(cfg, serverAuthEnabled(cfg.root, cfg.auth));
|
|
991
1014
|
// Generate the client codegen (`.toil/globals.ts`, `.toil/index.html`, …) NOW — before the
|
|
992
1015
|
// server build — so the SSR slots pre-pass below can load the route/layout module graph and
|
|
993
1016
|
// render the opted-in routes.
|