toiljs 0.0.85 → 0.0.87

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.
Files changed (132) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +2 -2
  3. package/build/cli/.tsbuildinfo +1 -1
  4. package/build/cli/index.js +303 -293
  5. package/build/compiler/.tsbuildinfo +1 -1
  6. package/build/compiler/config.d.ts +2 -0
  7. package/build/compiler/config.js +1 -0
  8. package/build/compiler/docs.js +8 -24
  9. package/build/compiler/generate.js +4 -2
  10. package/build/compiler/index.d.ts +1 -1
  11. package/build/compiler/index.js +69 -6
  12. package/build/compiler/toil-docs.generated.js +64 -22
  13. package/build/devserver/.tsbuildinfo +1 -1
  14. package/build/devserver/analytics/index.js +7 -3
  15. package/build/devserver/db/database.d.ts +4 -0
  16. package/build/devserver/db/database.js +43 -1
  17. package/build/devserver/db/index.d.ts +1 -1
  18. package/build/devserver/db/index.js +1 -1
  19. package/build/devserver/db/types.d.ts +3 -0
  20. package/build/devserver/db/types.js +2 -0
  21. package/build/devserver/runtime/module.js +4 -1
  22. package/docs/README.md +104 -65
  23. package/docs/auth/README.md +102 -0
  24. package/docs/auth/configuration.md +94 -0
  25. package/docs/auth/extending.md +202 -0
  26. package/docs/auth/how-it-works.md +138 -0
  27. package/docs/auth/usage.md +188 -0
  28. package/docs/backend/README.md +143 -0
  29. package/docs/backend/data.md +351 -0
  30. package/docs/backend/rest.md +402 -0
  31. package/docs/backend/rpc.md +226 -0
  32. package/docs/background/README.md +114 -0
  33. package/docs/background/daemons.md +230 -0
  34. package/docs/background/derive.md +179 -0
  35. package/docs/cli/README.md +377 -0
  36. package/docs/concepts/config.md +416 -0
  37. package/docs/concepts/decorators.md +127 -0
  38. package/docs/concepts/security.md +108 -0
  39. package/docs/concepts/tiers.md +166 -0
  40. package/docs/concepts/types.md +216 -0
  41. package/docs/database/README.md +143 -0
  42. package/docs/database/capacity.md +350 -0
  43. package/docs/database/counters.md +174 -0
  44. package/docs/database/documents.md +255 -0
  45. package/docs/database/events.md +307 -0
  46. package/docs/database/membership.md +246 -0
  47. package/docs/database/setup.md +155 -0
  48. package/docs/database/unique.md +216 -0
  49. package/docs/database/views.md +246 -0
  50. package/docs/frontend/README.md +101 -0
  51. package/docs/frontend/data-fetching.md +243 -0
  52. package/docs/frontend/images.md +148 -0
  53. package/docs/frontend/metadata.md +344 -0
  54. package/docs/frontend/rendering.md +236 -0
  55. package/docs/frontend/routing.md +344 -0
  56. package/docs/frontend/scripts.md +118 -0
  57. package/docs/frontend/search.md +191 -0
  58. package/docs/frontend/styling.md +147 -0
  59. package/docs/getting-started/README.md +81 -0
  60. package/docs/getting-started/create-project.md +131 -0
  61. package/docs/getting-started/deploy.md +101 -0
  62. package/docs/getting-started/first-app.md +215 -0
  63. package/docs/getting-started/installation.md +106 -0
  64. package/docs/getting-started/migrating.md +125 -0
  65. package/docs/getting-started/project-structure.md +163 -0
  66. package/docs/introduction/README.md +41 -0
  67. package/docs/introduction/design-principles.md +55 -0
  68. package/docs/introduction/distributed.md +74 -0
  69. package/docs/introduction/how-it-works.md +74 -0
  70. package/docs/introduction/hyperscale.md +51 -0
  71. package/docs/introduction/modern-stack.md +36 -0
  72. package/docs/introduction/vs-other-frameworks.md +48 -0
  73. package/docs/introduction/why-toil.md +93 -0
  74. package/docs/llms.txt +90 -0
  75. package/docs/realtime/README.md +102 -0
  76. package/docs/realtime/channels.md +211 -0
  77. package/docs/realtime/streams.md +369 -0
  78. package/docs/services/README.md +60 -0
  79. package/docs/services/analytics.md +268 -0
  80. package/docs/services/caching.md +175 -0
  81. package/docs/services/cookies.md +235 -0
  82. package/docs/services/crypto.md +209 -0
  83. package/docs/services/email.md +289 -0
  84. package/docs/services/environment.md +141 -0
  85. package/docs/services/ratelimit.md +174 -0
  86. package/docs/services/time.md +85 -0
  87. package/examples/basic/client/routes/analytics.tsx +13 -12
  88. package/examples/basic/server/models/SiteAnalytics.ts +29 -17
  89. package/examples/basic/server/routes/Analytics.ts +15 -17
  90. package/examples/basic/server/routes/UserId.ts +22 -0
  91. package/package.json +15 -11
  92. package/scripts/gen-toil-docs.mjs +24 -35
  93. package/server/auth/AuthController.ts +336 -0
  94. package/server/auth/AuthUser.ts +23 -0
  95. package/server/auth/index.ts +16 -0
  96. package/server/globals/auth.ts +31 -0
  97. package/server/globals/userid.ts +107 -0
  98. package/src/compiler/config.ts +13 -0
  99. package/src/compiler/docs.ts +16 -33
  100. package/src/compiler/generate.ts +6 -2
  101. package/src/compiler/index.ts +114 -6
  102. package/src/compiler/toil-docs.generated.ts +64 -22
  103. package/src/devserver/analytics/index.ts +10 -4
  104. package/src/devserver/db/database.ts +67 -1
  105. package/src/devserver/db/index.ts +1 -0
  106. package/src/devserver/db/types.ts +13 -0
  107. package/src/devserver/runtime/module.ts +7 -0
  108. package/test/analytics-dev.test.ts +2 -1
  109. package/test/devserver-database.test.ts +113 -0
  110. package/docs/auth-todo.md +0 -149
  111. package/docs/auth.md +0 -322
  112. package/docs/caching.md +0 -115
  113. package/docs/cli.md +0 -17
  114. package/docs/client.md +0 -39
  115. package/docs/cookies.md +0 -457
  116. package/docs/crypto.md +0 -130
  117. package/docs/daemon.md +0 -123
  118. package/docs/data.md +0 -131
  119. package/docs/derive.md +0 -159
  120. package/docs/email.md +0 -326
  121. package/docs/environment.md +0 -97
  122. package/docs/getting-started.md +0 -128
  123. package/docs/index.md +0 -30
  124. package/docs/ratelimit.md +0 -95
  125. package/docs/routing.md +0 -259
  126. package/docs/rpc.md +0 -149
  127. package/docs/server.md +0 -61
  128. package/docs/ssr.md +0 -632
  129. package/docs/streams.md +0 -178
  130. package/docs/styling.md +0 -22
  131. package/docs/tiers.md +0 -133
  132. package/docs/time.md +0 -43
@@ -0,0 +1,289 @@
1
+ # Email
2
+
3
+ Send transactional email (verification codes, password resets, receipts) straight from a route handler.
4
+
5
+ Reach for email when your app needs to message one user because of something they did: confirm a signup, send a 2FA code, mail a receipt. It is built for **transactional** email (one message, triggered by an action), not bulk marketing or newsletters. There is no attachment support and no per-recipient dynamic content beyond simple placeholders (see [gotchas](#gotchas-and-limits)).
6
+
7
+ Everything here is an **ambient global**: an object you can use without importing it, exactly like [`crypto`](./crypto.md) or `Environment`. A backend that never sends email pulls none of this into its compiled program.
8
+
9
+ - **`EmailService`** sends one email.
10
+ - **`EmailTemplate`** is a reusable message with `{{placeholder}}` holes (plain text and/or HTML).
11
+ - **`emails/*.tsx`** lets you author emails as React components; the build turns each into a typed `Emails.<Name>.send(...)`.
12
+ - **`TwoFactor`** issues and checks email verification codes with no database.
13
+
14
+ ## How a send works
15
+
16
+ When your handler calls `EmailService.send(...)`, it does not talk to the email provider itself. The **edge** (the Dacely server running your code) validates the recipient, checks your sending limits, then hands the message to a single background "mailer" thread that talks to the provider. Your handler **suspends** (pauses) until the mailer reports a result, so a slow provider never freezes the worker that is serving other requests.
17
+
18
+ ```mermaid
19
+ sequenceDiagram
20
+ participant H as Your handler (wasm)
21
+ participant E as Edge host
22
+ participant M as Off-core mailer
23
+ participant P as Email provider
24
+ H->>E: EmailService.send(to, subject, body, purpose, html)
25
+ E->>E: validate recipient, check caps + dedup
26
+ E->>M: hand off the message
27
+ Note over H: your handler pauses here
28
+ M->>P: deliver over the network
29
+ P-->>M: accepted / rejected
30
+ M-->>E: status
31
+ E-->>H: EmailStatus (Sent, Budget, ...)
32
+ ```
33
+
34
+ ## Send one email
35
+
36
+ `EmailService.send` takes the recipient, a subject, a plain-text body, a short `purpose` tag, and an optional HTML body. It returns an `EmailStatus` telling you what happened.
37
+
38
+ ```ts
39
+ import { Response, RouteContext } from 'toiljs/server/runtime';
40
+
41
+ @rest('notify')
42
+ class Notify {
43
+ @post('/welcome')
44
+ welcome(ctx: RouteContext): Response {
45
+ const status = EmailService.send(
46
+ 'alice@example.com', // to: exactly one address
47
+ 'Welcome!', // subject
48
+ 'Thanks for signing up.', // plain-text body
49
+ 'welcome', // purpose tag (used for dedup + abuse limits)
50
+ '<h1>Thanks for signing up.</h1>', // optional HTML body
51
+ );
52
+
53
+ return status == EmailStatus.Sent
54
+ ? Response.text('sent\n')
55
+ : Response.text('could not send\n', 503);
56
+ }
57
+ }
58
+ ```
59
+
60
+ The full signature is `send(to, subject, body, purpose = 'tx', html = '')`. Pass a non-empty `html` to send an HTML message; then `body` is the plain-text fallback (set both for the best deliverability, or leave `body` empty for HTML-only).
61
+
62
+ `EmailStatus` is a global enum, so you can compare against it (`status == EmailStatus.Sent`) with no import. `Sent` and `Deduped` mean success; the rest tell you why the message was not delivered and whether retrying could help.
63
+
64
+ | Status | Meaning | Retry? |
65
+ | --- | --- | --- |
66
+ | `Sent` | Accepted by the provider. | done |
67
+ | `Deduped` | An identical recent `(recipient, purpose)` send was collapsed into one. | treat as sent |
68
+ | `Budget` | Your per-minute (or per-day) send budget is exhausted. | yes, later |
69
+ | `TryLater` | The mailer was momentarily saturated. | yes, back off |
70
+ | `RecipientCapped` | This recipient hit their hourly cap. | no (this window) |
71
+ | `BadRecipient` | The address failed validation (multiple addresses, control characters). | no |
72
+ | `Disabled` | This site has no email configured. | no |
73
+ | `ProviderError` | The provider rejected it, or delivery failed after retries. | no |
74
+
75
+ The `purpose` is a short, non-sensitive tag like `"welcome"` or `"reset"`. The edge folds it into two things: a **dedup key** (an identical `(site, recipient, purpose)` within about 30 seconds becomes one send, which returns `Deduped`) and the **abuse counters**. It is never logged in the clear, so keep real user data out of it.
76
+
77
+ The recipient is checked on the host: exactly one address, no carriage returns, line feeds, or angle brackets. That means a bad or hostile input can never smuggle a second recipient or inject an email header.
78
+
79
+ ## Configure email
80
+
81
+ Email is **off by default**. A site that has not configured it gets `Disabled` from every send. You configure it in two places: non-secret settings in `toil.config.ts`, and the provider credential as a **secret** in the [environment store](./environment.md) (never in your code or your compiled program).
82
+
83
+ ```ts
84
+ // toil.config.ts
85
+ import { defineConfig } from 'toiljs/compiler';
86
+
87
+ export default defineConfig({
88
+ server: {
89
+ email: {
90
+ provider: 'resend', // 'resend' | 'gmail' | 'smtp'
91
+ from: 'you@example.com', // the From address (validated; single address)
92
+ maxPerMin: 60, // per-site cap: at most 60 sends per rolling minute
93
+ },
94
+ },
95
+ });
96
+ ```
97
+
98
+ The credential lives in your secrets file as a reserved `TOIL_EMAIL_*` key. These keys are **host-only**: the edge reads them in Rust, and they are stripped out of the buckets your code can read, so a tenant can never fish the API key out with `Environment.getSecure`.
99
+
100
+ ```bash
101
+ # .env.secrets (gitignored; never committed, never in the .wasm)
102
+ TOIL_EMAIL_API_KEY=re_xxxxxxxxxxxx
103
+ ```
104
+
105
+ On the production edge the same keys live in the site's secrets file (mode `0600`, kept out of the config directory the file watcher sees), so a credential is never written to a log, to `/_admin`, or into your deployed module. See [Environment and secrets](./environment.md) for how the store works.
106
+
107
+ ### Providers
108
+
109
+ You pick one provider. Each maps `TOIL_EMAIL_API_KEY` to the right credential.
110
+
111
+ | Provider | `provider` | What `TOIL_EMAIL_API_KEY` holds | Extra keys |
112
+ | --- | --- | --- | --- |
113
+ | **Resend** | `resend` | A Resend API key (a JSON API). | none |
114
+ | **Gmail** | `gmail` | A Gmail **App Password** (SMTP over `smtp.gmail.com:587`). | none |
115
+ | **Generic SMTP** | `smtp` | The SMTP password. | `TOIL_EMAIL_SMTP_HOST` (required), optional `TOIL_EMAIL_SMTP_PORT` (defaults `587`), `TOIL_EMAIL_SMTP_USER` (defaults to `from`) |
116
+
117
+ For Gmail you need 2-Step Verification on the account, then create an App Password at `https://myaccount.google.com/apppasswords`. The username is your `from` address. For generic SMTP, port `587` uses STARTTLS and port `465` uses implicit TLS.
118
+
119
+ Every setting can also be given as a `TOIL_EMAIL_*` environment variable, and those override the `toil.config.ts` values. That means the exact same secrets file works in local dev and on the edge.
120
+
121
+ ### Local dev behavior
122
+
123
+ `toiljs dev` runs the **full email pipeline** in Node: recipient validation, dedup, and every cap behave exactly like the edge. Once you set a provider plus `TOIL_EMAIL_API_KEY`, dev **really sends** (Resend over its API, Gmail/SMTP over nodemailer). If you have **not** configured a provider, `EmailService.send` becomes a log-only mock that returns `Sent`, so a signup flow that emails a code still works end to end without any setup.
124
+
125
+ > One dev-only nuance: the dev server runs your code synchronously, so the actual network send is fire-and-forget. Validation and the caps return their exact status immediately, but a `Sent` is optimistic; the real delivery outcome (or a `ProviderError`) is logged, not returned. The programming interface is identical to the edge, so code that runs in dev runs unchanged in production.
126
+
127
+ ## Templates with `{{placeholders}}`
128
+
129
+ When you send the same shape of email with different values, define an `EmailTemplate` once. `{{name}}` holes are filled from a `Map` at send time.
130
+
131
+ ```ts
132
+ const welcome = new EmailTemplate(
133
+ 'Welcome, {{name}}!', // subject
134
+ 'Hi {{name}}, your code is {{code}}.', // plain-text body
135
+ '<h1>Welcome, {{name}}</h1><p>Code: <b>{{code}}</b></p>', // html (optional)
136
+ );
137
+
138
+ const vars = new Map<string, string>();
139
+ vars.set('name', 'Alice');
140
+ vars.set('code', '123456');
141
+
142
+ const status = welcome.send('alice@example.com', vars, 'welcome');
143
+ ```
144
+
145
+ - `{{ name }}` with surrounding spaces is accepted; an unknown placeholder renders to an empty string.
146
+ - Omit the third argument for a plain-text-only template.
147
+ - `template.render(vars)` returns the rendered `{ subject, body, html }` **without** sending, which is handy for previews and tests.
148
+
149
+ For anything richer than token substitution (real layout, brand styling), author the email as a React component instead.
150
+
151
+ ## React email templates
152
+
153
+ You can write emails as React components in an **`emails/`** folder. At `toiljs build` each one is rendered **once, at build time**, into a static string of HTML with inline styles, and its props become `{{token}}` holes. The build then generates a typed `Emails.<Name>.send(...)` for your server to call.
154
+
155
+ Why build-time? Email clients (Gmail, Outlook, Apple Mail) run **no JavaScript** and strip `<style>` blocks and external CSS. So an HTML email has to be a finished, inline-styled string. Rendering it once at build gives you React ergonomics while shipping the inbox exactly what it can display.
156
+
157
+ ```tsx
158
+ // emails/Welcome.tsx
159
+ export const subject = 'Welcome, {{name}}!';
160
+
161
+ export default function Welcome({ name, code }: { name: string; code: string }) {
162
+ return (
163
+ <table
164
+ width="100%"
165
+ style={{ fontFamily: 'Arial, sans-serif' }}>
166
+ <tbody>
167
+ <tr>
168
+ <td style={{ padding: '24px' }}>
169
+ <h1 style={{ color: '#111' }}>Welcome, {name}!</h1>
170
+ <p>
171
+ Your code is <b>{code}</b>.
172
+ </p>
173
+ </td>
174
+ </tr>
175
+ </tbody>
176
+ </table>
177
+ );
178
+ }
179
+ ```
180
+
181
+ The generated `Emails.Welcome.send(...)` takes the recipient, then one argument per `{{token}}` **in alphabetical order**, then an optional `purpose`:
182
+
183
+ ```ts
184
+ // Welcome.tsx uses {{code}} and {{name}} -> params are (code, name), alphabetical
185
+ const status = Emails.Welcome.send('alice@example.com', '123456', 'Alice');
186
+ ```
187
+
188
+ Authoring notes:
189
+
190
+ - **Styles must end up inline.** Write inline `style={{ ... }}`, or import a stylesheet and its rules are inlined into each element for you at build. A bare CSS import on its own has no effect on the rendered email.
191
+ - **Optional exports:** `subject` (a token template; defaults to the file name), `text` (a plain-text alternative; otherwise derived from the HTML), and `purpose`.
192
+ - **Field substitution only.** Because the component renders once at build time, a runtime `{items.map(...)}` bakes in at build; it does not re-run per recipient. That is perfect for verification, confirmation, and receipt emails; a truly dynamic list needs a different approach.
193
+
194
+ While `toiljs dev` runs, open **`/__toil/emails`** (the dev banner prints the link) to preview every `emails/*.tsx`, fill each `{{token}}`, and toggle the HTML and plain-text parts. It refreshes as you edit.
195
+
196
+ ## Verification codes with `TwoFactor`
197
+
198
+ `TwoFactor` issues and checks short numeric codes (for 2FA, email confirmation, or magic-code login) with **no database**. That is possible because it is **stateless**: instead of storing the code server-side, it emails the code and returns a signed **token** that commits to the code using [HMAC](./crypto.md) (a keyed fingerprint). The code itself is only ever in the email; the token holds a fingerprint of it, not the code.
199
+
200
+ To verify, the server recomputes the fingerprint from the token plus the code the user typed and compares them in constant time. A valid `(token, code)` pair can only come from someone who **both** received the email and holds the token, and the fingerprint binds the recipient, the purpose, and an expiry so a token cannot be replayed for another address, another flow, or after it expires.
201
+
202
+ ```ts
203
+ // 1. Issue and email a code. Hand the returned token to the client
204
+ // (a cookie or a hidden form field).
205
+ const challenge = TwoFactor.send('alice@example.com', 'login');
206
+ // challenge.token -> give this to the client
207
+ // challenge.status -> the EmailStatus of the send (check it was Sent/Deduped)
208
+
209
+ // 2. Later, when the user submits the code they received:
210
+ const ok: bool = TwoFactor.verify(challenge.token, 'alice@example.com', userEntered);
211
+ if (ok) {
212
+ // the code is valid and unexpired
213
+ }
214
+ ```
215
+
216
+ The three entry points:
217
+
218
+ - **`send(recipient, purpose, ttlSecs = 600, digits = 6)`** issues a code, emails it with a built-in template, and returns `{ token, status }`.
219
+ - **`issue(recipient, purpose, ttlSecs, digits)`** returns `{ code, token }` **without** sending, so you can email `code` yourself with a branded `EmailTemplate` or `Emails.*` message.
220
+ - **`verify(token, recipient, code)`** returns `true` only for a code issued for that recipient that has not expired.
221
+
222
+ One-time setup: call **`TwoFactor.setSecret(secret)`** once at startup in `main.ts`. This is the HMAC key that signs the tokens. It must be identical on every edge instance and must never end up in a client bundle. (It is separate from your email provider key.)
223
+
224
+ > **Important limitation: not single-use.** `TwoFactor` gives you integrity and expiry, but because it stores no state, a valid code verifies **repeatedly** until its TTL runs out. Keep the TTL short. If you need true single-use, record a per-recipient "last verified at" (in your database or the edge store) and reject any code at or before it.
225
+
226
+ ## Worked example: welcome email plus a 2FA code
227
+
228
+ A signup route that emails a welcome message and starts a 2FA challenge in one handler:
229
+
230
+ ```ts
231
+ import { Response, RouteContext } from 'toiljs/server/runtime';
232
+
233
+ @rest('signup')
234
+ class Signup {
235
+ @post('/start')
236
+ start(ctx: RouteContext): Response {
237
+ const email = ctx.query('email'); // in real code, validate this first
238
+
239
+ // 1. Friendly welcome (fire it, but note the status for logging).
240
+ Emails.Welcome.send(email, '123456', 'Alice');
241
+
242
+ // 2. Start a 2FA challenge; the code is emailed, the token comes back.
243
+ const challenge = TwoFactor.send(email, 'signup');
244
+ if (challenge.status != EmailStatus.Sent && challenge.status != EmailStatus.Deduped) {
245
+ return Response.text('could not send code\n', 503);
246
+ }
247
+
248
+ // Hand the token to the client (here, a JSON field; a cookie also works).
249
+ return Response.json(`{"token":${JSON.stringify(challenge.token)}}`);
250
+ }
251
+
252
+ @post('/confirm')
253
+ confirm(ctx: RouteContext): Response {
254
+ const email = ctx.query('email');
255
+ const token = ctx.query('token');
256
+ const code = ctx.query('code');
257
+
258
+ const ok: bool = TwoFactor.verify(token, email, code);
259
+ return ok ? Response.text('confirmed\n') : Response.text('bad code\n', 400);
260
+ }
261
+ }
262
+ ```
263
+
264
+ To make `TwoFactor` verify across restarts and across the fleet, set the secret once at startup:
265
+
266
+ ```ts
267
+ // main.ts
268
+ TwoFactor.setSecret(crypto.sha256Text(Environment.getSecure('TWOFA_SECRET')!));
269
+ ```
270
+
271
+ For where email and codes fit into a full login flow, see [extending auth](../auth/extending.md).
272
+
273
+ ## Gotchas and limits
274
+
275
+ - **Email is opt-in.** No config means every send returns `Disabled`. Check the status.
276
+ - **The provider key is host-only.** It never appears in your compiled module, in logs, or in `/_admin`. You cannot read it with `Environment.getSecure`; that is by design.
277
+ - **No attachments, no dynamic per-recipient lists.** The API sends a subject plus a text and/or HTML body. React templates substitute fields; they do not re-render per recipient.
278
+ - **`TwoFactor` is not single-use.** See the limitation above. Keep TTLs short.
279
+ - **Caps are enforced on the host, exactly.** A per-site per-minute cap (`maxPerMin`) and an optional per-day cap, plus a per-recipient hourly cap and about 30 seconds of dedup. Over a cap you get `Budget`, `RecipientCapped`, or `Deduped`, never a silent drop. Editing the caps takes effect on the next send with no restart.
280
+ - **Put nothing sensitive in `purpose`.** It keys dedup and abuse counters; use a short tag like `"reset"`.
281
+ - **Observability:** `GET /_admin/email` returns counts by reason (submitted, sent, deduped, budget, and so on). It exposes counts only, never a recipient, subject, body, or code.
282
+
283
+ ## Related
284
+
285
+ - [Environment and secrets](./environment.md), where `TOIL_EMAIL_API_KEY` lives.
286
+ - [Crypto](./crypto.md), the HMAC and random primitives `TwoFactor` is built on.
287
+ - [Rate limiting](./ratelimit.md), to protect any route that triggers an email (like "send me a code").
288
+ - [Extending auth](../auth/extending.md), for email and codes inside a login flow.
289
+ - [HTTP routes](../backend/rest.md), for `@rest` / `@post` and `RouteContext`.
@@ -0,0 +1,141 @@
1
+ # Environment and secrets
2
+
3
+ **`Environment` gives your app configuration values and secrets that are set outside your code**, so your compiled backend (`.wasm`) never carries a password or an API key. You read them at runtime; you never write them from code.
4
+
5
+ ## Why this exists
6
+
7
+ Your backend often needs two kinds of outside values:
8
+
9
+ - **Config**: non-sensitive settings that can change per deployment, like a public API base URL, a region name, or a feature flag.
10
+ - **Secrets**: sensitive values you must never leak, like a payment provider's API key.
11
+
12
+ The wrong way to handle these is to type them into your source code. Then your secret is in your git history, in your build output, and in the `.wasm` shipped to every edge node. The right way, and the way toiljs uses, is the **GitHub Actions model**: you set these values out of band (today a dashboard, backed by the edge database), and your code reads them by name at runtime. Your compiled program carries **no credentials**.
13
+
14
+ `Environment` is an ambient global: you use it with no import, like `Time` or `crypto`.
15
+
16
+ ## The two buckets: `get` vs `getSecure`
17
+
18
+ There are two **disjoint** buckets, exactly like GitHub Actions' `vars` versus `secrets`:
19
+
20
+ - **`Environment.get(key)`** reads a **plain variable** (config). Returns the string, or `null` if it is not set.
21
+ - **`Environment.getSecure(key)`** reads a **secret**. Returns the string, or `null` if it is not set.
22
+
23
+ "Disjoint" means the buckets never overlap: a secret is **never** returned by `get()`, and a plain var is never returned by `getSecure()`. This is a deliberate safety property. It means a secret cannot leak through a code path that logs the result of a `get()`, because `get()` can never see a secret in the first place. Keys are case-sensitive and matched exactly.
24
+
25
+ ```mermaid
26
+ flowchart LR
27
+ subgraph Store["Per-app environment (set out of band)"]
28
+ V["vars<br/>(config)"]
29
+ S["secrets"]
30
+ end
31
+ V -->|Environment.get key| G["your handler"]
32
+ S -->|Environment.getSecure key| G
33
+ V -. never .-x GS["getSecure"]
34
+ S -. never .-x GT["get"]
35
+ ```
36
+
37
+ ## How: a worked example reading a secret
38
+
39
+ Here a route reads a public config value and a secret, then uses the secret to authorize a call to a third party. Note what it does **not** do: it never logs the secret and never returns it to the client.
40
+
41
+ ```ts
42
+ import { Response, RouteContext } from 'toiljs/server/runtime';
43
+
44
+ @rest('billing')
45
+ class Billing {
46
+ @get('/status')
47
+ show(ctx: RouteContext): Response {
48
+ const base = Environment.get('PUBLIC_API_BASE'); // plain var, or null
49
+ const key = Environment.getSecure('STRIPE_KEY'); // secret, or null
50
+
51
+ if (key == null) {
52
+ return Response.text('billing not configured\n', 503);
53
+ }
54
+
55
+ // Use `key` to authorize an outbound call. NEVER log it,
56
+ // NEVER put it in the response, NEVER copy it into the client bundle.
57
+ // (Outbound calls are made from a daemon or service; see the links below.)
58
+
59
+ return Response.text(base != null ? base : 'unset');
60
+ }
61
+ }
62
+ ```
63
+
64
+ Both getters return `string | null`. Always handle the `null` case: a value that is not configured comes back as `null`, not an empty string or a crash.
65
+
66
+ > **Secrets are plaintext in your module at runtime.** That is the point: you need the actual bytes to call out to a third party. So the rule is simple. Do not log a secret, do not put it in a response, and do not copy it into anything the browser receives.
67
+
68
+ ## Where the values live
69
+
70
+ You set vars and secrets in **two separate places**, so the split is structural (the secrets store can be locked down on its own).
71
+
72
+ ### On the edge
73
+
74
+ Each host has its own environment, backed by the edge database and, as a fallback, by two dotenv files kept **outside** your deployed project (so the config watcher never even sees a credential):
75
+
76
+ ```bash
77
+ # $TOIL_ENV_DIR/<host>.env (default dir: /run/toil/env) -> Environment.get(...)
78
+ PUBLIC_API_BASE=https://api.example.com
79
+ REGION=eu
80
+
81
+ # $TOIL_ENV_DIR/<host>.env.secrets (mode 0600) -> Environment.getSecure(...)
82
+ STRIPE_KEY=sk_live_xxx
83
+ ```
84
+
85
+ A **dotenv file** is just `KEY=value`, one per line, with `#` comments, optional `export`, and optional quotes.
86
+
87
+ Framework-reserved keys use the `TOIL_` prefix (today, the `TOIL_EMAIL_*` email provider config). These are **host-only**: resolved and used in Rust where the framework needs them, and **stripped from both guest buckets**, so your code can never read them with `get` or `getSecure`. You do not read email config yourself; see [Email](./email.md).
88
+
89
+ ### In local development
90
+
91
+ `toiljs dev` reads two files at your project root:
92
+
93
+ ```bash
94
+ # .env (plain vars)
95
+ PUBLIC_API_BASE=http://localhost:4000
96
+
97
+ # .env.secrets (secrets; mode 0600; gitignored by the scaffold)
98
+ STRIPE_KEY=sk_test_xxx
99
+ ```
100
+
101
+ It also overlays `process.env` as plain vars, for convenience. The behavior is identical to the edge, so code that reads env in dev reads it the same way in production. Both files are gitignored by the project scaffold so you do not commit them by accident.
102
+
103
+ ## Secrets never enter the `.wasm`
104
+
105
+ This is the whole point, so it is worth stating plainly. Your source code contains only the **names** you look up (`'STRIPE_KEY'`), never the values. The compiler turns your names into calls to a host function; the actual value is resolved on the trusted server side, from that host's environment, at the moment you ask. The value is never baked into the compiled module and never shipped to the browser.
106
+
107
+ On the edge, a secret is **zeroized** (wiped from memory) when the host goes cold, so it does not linger.
108
+
109
+ ## How caching works (and why it is safe)
110
+
111
+ Reading env goes through a host lookup, so the edge caches values so a busy app is not paying for a fresh lookup on every request. The cache is designed to be abuse-proof:
112
+
113
+ - **Lazy**: env is loaded the first time your code reads it, not eagerly. A host that never reads env costs nothing.
114
+ - **Shared and read-only**: the data lives in one place and is never copied per request.
115
+ - **Bounded with idle eviction (a TTL cache)**: entries are dropped after they sit unused, and the total size is capped. So a flood of requests to many different hosts can never grow memory without bound, and secrets are wiped when a host goes cold.
116
+
117
+ You do not manage any of this. You just call `get` / `getSecure`.
118
+
119
+ ## Build-time config vs runtime environment
120
+
121
+ Do not confuse two different things that both feel like "configuration":
122
+
123
+ - **Build-time config** lives in your project's config file (for example `toil.config.ts`). It shapes how your app is *built and wired*: routes, tiers, which features are on. It is baked into the build. See [Configuration](../concepts/config.md).
124
+ - **Runtime environment** (this page) is resolved *while your code runs*, per host, and is never baked in. It is where secrets and per-deployment values belong.
125
+
126
+ Rule of thumb: if a value is a secret, or it differs between your dev machine and production, it belongs in the runtime environment, not in build-time config.
127
+
128
+ ## Gotchas
129
+
130
+ - **`Environment` is read-only.** There is no `set`. You configure values out of band (dashboard / dotenv files), never from the module.
131
+ - **Handle `null`.** An unset key returns `null`. Do not assume a value is present.
132
+ - **Never log or return a secret.** `getSecure` hands you real credentials; treat them like one.
133
+ - **Do not put a secret in build-time config.** That would bake it into the `.wasm`, defeating the whole design.
134
+ - **`TOIL_`-prefixed keys are invisible to your code.** They are host-only framework config; you cannot read them with `get`/`getSecure`.
135
+
136
+ ## Related
137
+
138
+ - [Email](./email.md): configured through the reserved, host-only `TOIL_EMAIL_*` keys.
139
+ - [Configuration](../concepts/config.md): build-time config, and how it differs from runtime env.
140
+ - [Crypto](./crypto.md): use a secret from `getSecure` as a signing or encryption key.
141
+ - [Cookies](./cookies.md): `SecureCookies` takes a key you can source from a secret.
@@ -0,0 +1,174 @@
1
+ # Rate limiting
2
+
3
+ **Rate limiting caps how often one client can call a route.** You add the `@ratelimit` decorator to a route, and the edge rejects a client that goes over the limit **before your code runs**.
4
+
5
+ ## Why you want it
6
+
7
+ Some routes are attractive to abuse. A login route invites password guessing (a "brute-force" attack: trying thousands of passwords). A "send me a code" route invites spam. A public write endpoint invites floods. Rate limiting puts a hard ceiling on how fast any single client can hammer a route, so an attacker cannot try millions of guesses and a script cannot drown your app.
8
+
9
+ Because it runs at the edge before your handler (and before the auth check), an over-the-limit request costs you almost nothing: your WebAssembly code never even wakes up.
10
+
11
+ ## How: the `@ratelimit` decorator
12
+
13
+ Put it above a route, alongside the verb decorator (`@get`, `@post`, and so on):
14
+
15
+ ```ts
16
+ import { Response, RouteContext } from 'toiljs/server/runtime';
17
+
18
+ @rest('auth')
19
+ class Auth {
20
+ // At most 5 login attempts per 60 seconds, per client.
21
+ @ratelimit(RateLimit.SlidingWindow, 5, 60)
22
+ @post('/login')
23
+ login(ctx: RouteContext): Response {
24
+ // ...only runs if the caller is under the limit...
25
+ return Response.text('ok\n');
26
+ }
27
+ }
28
+ ```
29
+
30
+ The shape is `@ratelimit(strategy, limit, window)`:
31
+
32
+ - **`strategy`** is a `RateLimit` value: `RateLimit.FixedWindow`, `RateLimit.SlidingWindow`, or `RateLimit.TokenBucket`. It is an ambient global, so you do not import it.
33
+ - **`limit`** and **`window`** are two whole numbers. Their exact meaning depends on the strategy (see the table below). For the window strategies, it reads as "at most `limit` requests per `window` seconds".
34
+
35
+ Both numbers must be plain integer literals. A malformed decorator emits no guard at all (it fails safe) rather than compiling something wrong, the same rule as [`@cache`](./caching.md).
36
+
37
+ ## What a rejected request looks like
38
+
39
+ When a client is over the limit, the edge returns **`429 Too Many Requests`** with a **`Retry-After`** header telling the client how many whole seconds to wait before trying again. Your handler does not run.
40
+
41
+ ```mermaid
42
+ flowchart TD
43
+ R["Request to a<br/>rate-limited route"] --> C{"This client<br/>under the limit?"}
44
+ C -->|Yes| A["Run @auth, then your handler"]
45
+ C -->|No| L["429 Too Many Requests<br/>Retry-After: 30"]
46
+ ```
47
+
48
+ A `429` is the standard "slow down" status. A well-behaved client library will read `Retry-After` and back off automatically.
49
+
50
+ ## The three strategies
51
+
52
+ A **window** is a slice of time the limiter counts within. The strategies differ in how they draw those slices.
53
+
54
+ | Strategy | `limit`, `window` mean | Behavior |
55
+ | --- | --- | --- |
56
+ | `FixedWindow` | `limit` events per `window` seconds | Cheapest. Counts in fixed wall-clock buckets (for example, each minute on the clock). A caller who times a burst around a bucket boundary can briefly get up to ~2x `limit` across two adjacent buckets. |
57
+ | `SlidingWindow` | `limit` events per `window` seconds | Smooths that boundary spike by weighting the previous window. **The best general choice** for "N per period". |
58
+ | `TokenBucket` | `limit` = burst size, `window` = refill rate **per second** | Allows an initial burst of up to `limit`, then refills at a steady `window` tokens per second. Good for APIs that are bursty but must stay bounded on average. |
59
+
60
+ Examples:
61
+
62
+ ```ts
63
+ // 100 requests per minute, smoothed. A good default for a public API.
64
+ @ratelimit(RateLimit.SlidingWindow, 100, 60)
65
+
66
+ // Burst of 20, then 5 per second sustained.
67
+ @ratelimit(RateLimit.TokenBucket, 20, 5)
68
+
69
+ // Exactly 3 per hour, cheapest counting.
70
+ @ratelimit(RateLimit.FixedWindow, 3, 3600)
71
+ ```
72
+
73
+ If you are unsure, use `SlidingWindow`. It behaves the way people intuitively expect "N per period" to behave.
74
+
75
+ ## How a client is identified
76
+
77
+ By default the limiter keys on the **client IP address**: specifically the network peer address the edge actually observed, not a header like `X-Forwarded-For` (which a client can forge). That is what makes this a real abuse control: a caller cannot reset their own bucket by lying in a header.
78
+
79
+ The count is **exact across all of the edge's workers**: a given IP always maps to one authoritative place that keeps the count, so the limit is global for the route, not per worker. And each rate-limited route has its **own independent limiter**: a limit on `/login` does not eat into `/signup`'s budget.
80
+
81
+ Only routes that opt in with `@ratelimit` pay any cost; everything else runs on the untouched fast path.
82
+
83
+ ## Per-user limits with a custom key
84
+
85
+ The `@ratelimit` decorator always keys on the client IP. Sometimes you want to limit by **account** instead: "at most 5 password changes per user per hour," no matter which IP the user comes from. The runtime supports this through a callable you invoke yourself, `RateLimitService.guardKeyed`. The decorator does not yet expose keyed limiting, so this callable is how you get it today.
86
+
87
+ Like `crypto` and `AuthService`, `RateLimitService` and the `RateLimit` enum are **ambient globals**: use them with no import.
88
+
89
+ ```ts
90
+ // Signature (ambient global; no import):
91
+ RateLimitService.guardKeyed(
92
+ routeId: i32, // a stable integer naming this limiter (see below)
93
+ strategy: i32, // a RateLimit value: FixedWindow / SlidingWindow / TokenBucket
94
+ limit: i32,
95
+ window: i32,
96
+ key: string, // the identity to limit by; an empty string falls back to the peer IP
97
+ ): Response | null
98
+ ```
99
+
100
+ It returns `null` when the caller is under the limit (proceed), or a ready-made **`429`** `Response` (with a `Retry-After` header) when they are over it. You call it near the top of your handler and **early-return** the response when it is non-null:
101
+
102
+ ```ts
103
+ import { Response, RouteContext } from 'toiljs/server/runtime';
104
+
105
+ @rest('account')
106
+ class Account {
107
+ @auth
108
+ @post('/change-password')
109
+ changePassword(ctx: RouteContext): Response {
110
+ // Key the limit on the logged-in user's stable id (hex form), so the
111
+ // budget follows the account across devices and IP addresses.
112
+ const key = AuthService.hasSession() ? AuthService.userId()!.toHex() : '';
113
+
114
+ // At most 5 attempts per user per hour. routeId 1001 names THIS limiter.
115
+ const limited = RateLimitService.guardKeyed(1001, RateLimit.SlidingWindow, 5, 3600, key);
116
+ if (limited != null) return limited; // over the limit: 429, the handler stops here
117
+
118
+ // ...under the limit and authenticated: do the work...
119
+ return Response.text('password changed\n');
120
+ }
121
+ }
122
+ ```
123
+
124
+ A few things to know:
125
+
126
+ - **`routeId` names the counter.** Each distinct `routeId` is an **independent** limiter, scoped to your app (it never collides with another tenant's). Pick a stable integer per logical limiter and keep it constant, use a different value from any other keyed limiter you add, and avoid reusing a value the `@ratelimit` decorator already assigned to a route.
127
+ - **An empty `key` falls back to the peer IP,** so `guardKeyed(..., '')` behaves like the decorator's default. Handle the not-logged-in case explicitly (as above) rather than passing a blank string by accident.
128
+ - **You control the ordering.** Unlike the decorator (which the compiler always places before `@auth`), a manual `guardKeyed` runs wherever you put it. If you key on the user id, call it **after** `@auth` has established the session, as in the example.
129
+ - **Same strategies and meanings** as the decorator: see [the three strategies](#the-three-strategies) for what `strategy`, `limit`, and `window` do.
130
+
131
+ ## Worked example: protecting login and a write route
132
+
133
+ Login is the classic case. This mirrors what the built-in auth system does for you (`register` and `login` already carry `@ratelimit(SlidingWindow, 5, 60)`), so you rarely have to add it there yourself. See [Using auth](../auth/usage.md).
134
+
135
+ For your own sensitive routes, the pattern is the same. Here a public "post a comment" endpoint is both rate-limited and auth-guarded:
136
+
137
+ ```ts
138
+ import { Response, RouteContext } from 'toiljs/server/runtime';
139
+
140
+ @rest('comments')
141
+ class Comments {
142
+ // 10 comments per minute per client, and you must be logged in.
143
+ @ratelimit(RateLimit.SlidingWindow, 10, 60)
144
+ @auth
145
+ @post('/')
146
+ create(ctx: RouteContext): Response {
147
+ // Runs only if the caller is under the limit AND authenticated.
148
+ return Response.text('posted\n');
149
+ }
150
+ }
151
+ ```
152
+
153
+ The order is fixed and safe: rate limiting runs **first** (so even unauthenticated floods are capped), then the `@auth` guard, then your handler. You do not manage that ordering.
154
+
155
+ ## Choosing a limit
156
+
157
+ - **Login / signup / "send a code":** keep it tight. 5 to 10 per minute per client is plenty for a human and painful for a guesser.
158
+ - **Public read APIs:** a `SlidingWindow` of 60 to 300 per minute is a reasonable starting point; raise it if legitimate clients hit it.
159
+ - **Bursty clients (dashboards that fire several calls on load):** consider `TokenBucket` so a short burst is allowed but the sustained rate stays bounded.
160
+ - Start conservative and loosen if you see legitimate users getting `429`s. It is easier to relax a limit than to recover from an abuse incident.
161
+
162
+ ## Gotchas and current limits
163
+
164
+ - **Route-level only.** Put `@ratelimit` on each route you want limited. There is no controller-wide form yet (unlike `@auth`, which you can put on a whole class).
165
+ - **Keyed on IP today.** The decorator keys on the client IP. Users behind a shared IP (a corporate network, a mobile carrier gateway) share a bucket, so do not set login limits so low that a busy office trips them. A per-user key (limiting by account instead of IP) exists in the runtime but is not yet exposed through the decorator. You can still get per-user limiting today by calling [`RateLimitService.guardKeyed`](#per-user-limits-with-a-custom-key) directly.
166
+ - **`TokenBucket`'s second number is a rate, not a duration.** For the bucket, `window` means "tokens refilled per second", not "seconds". Re-read the table if it surprises you.
167
+ - **Same behavior in dev.** `toiljs dev` runs a single-process mirror of all three strategies, so a limited route behaves the same locally as on the edge.
168
+
169
+ ## Related
170
+
171
+ - [Auth, sessions, and `@user`](../auth/usage.md): `@ratelimit` runs before the `@auth` guard, so it protects the login itself.
172
+ - [Email](./email.md): pair `@ratelimit` with email triggers (verification codes, password resets) to blunt abuse.
173
+ - [Caching](./caching.md): the other pre-handler guard; both fail safe on a malformed decorator.
174
+ - [Every decorator](../concepts/decorators.md).