toiljs 0.0.43 → 0.0.45

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 (64) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/RSG.md +334 -0
  3. package/build/cli/.tsbuildinfo +1 -1
  4. package/build/cli/index.js +2 -1
  5. package/build/client/.tsbuildinfo +1 -1
  6. package/build/client/auth.js +1 -1
  7. package/build/compiler/.tsbuildinfo +1 -1
  8. package/build/compiler/config.d.ts +4 -0
  9. package/build/compiler/config.js +1 -0
  10. package/build/compiler/generate.js +1 -0
  11. package/build/compiler/index.js +1 -0
  12. package/build/devserver/.tsbuildinfo +1 -1
  13. package/build/devserver/crypto.js +1 -1
  14. package/build/devserver/dotenv.d.ts +8 -0
  15. package/build/devserver/dotenv.js +59 -0
  16. package/build/devserver/email/caps.d.ts +9 -0
  17. package/build/devserver/email/caps.js +0 -0
  18. package/build/devserver/email/config.d.ts +21 -0
  19. package/build/devserver/email/config.js +72 -0
  20. package/build/devserver/email/index.d.ts +25 -0
  21. package/build/devserver/email/index.js +57 -0
  22. package/build/devserver/email/providers.d.ts +12 -0
  23. package/build/devserver/email/providers.js +96 -0
  24. package/build/devserver/email/status.d.ts +10 -0
  25. package/build/devserver/email/status.js +11 -0
  26. package/build/devserver/email/validate.d.ts +2 -0
  27. package/build/devserver/email/validate.js +24 -0
  28. package/build/devserver/email/wire.d.ts +8 -0
  29. package/build/devserver/email/wire.js +32 -0
  30. package/build/devserver/env.d.ts +2 -0
  31. package/build/devserver/env.js +9 -0
  32. package/build/devserver/host.js +39 -7
  33. package/build/devserver/index.d.ts +2 -0
  34. package/build/devserver/index.js +8 -0
  35. package/build/devserver/module.js +1 -1
  36. package/build/shared/.tsbuildinfo +1 -1
  37. package/build/shared/index.d.ts +13 -0
  38. package/docs/README.md +4 -1
  39. package/docs/email.md +90 -53
  40. package/docs/environment.md +97 -0
  41. package/examples/basic/server/main.ts +1 -0
  42. package/examples/basic/server/routes/EnvDemo.ts +42 -0
  43. package/package.json +4 -2
  44. package/server/globals/environment.ts +82 -0
  45. package/src/cli/create.ts +2 -1
  46. package/src/client/auth.ts +1 -1
  47. package/src/compiler/config.ts +14 -0
  48. package/src/compiler/generate.ts +1 -0
  49. package/src/compiler/index.ts +1 -0
  50. package/src/devserver/crypto.ts +1 -1
  51. package/src/devserver/dotenv.ts +94 -0
  52. package/src/devserver/email/caps.ts +0 -0
  53. package/src/devserver/email/config.ts +123 -0
  54. package/src/devserver/email/index.ts +111 -0
  55. package/src/devserver/email/providers.ts +130 -0
  56. package/src/devserver/email/status.ts +23 -0
  57. package/src/devserver/email/validate.ts +40 -0
  58. package/src/devserver/email/wire.ts +55 -0
  59. package/src/devserver/env.ts +30 -0
  60. package/src/devserver/host.ts +71 -12
  61. package/src/devserver/index.ts +20 -0
  62. package/src/devserver/module.ts +1 -1
  63. package/src/shared/index.ts +36 -0
  64. package/test/devserver-email.test.ts +241 -0
package/docs/email.md CHANGED
@@ -24,72 +24,106 @@ Everything here is an ambient **global** (no import), like `crypto` and
24
24
 
25
25
  ## Configure email
26
26
 
27
- Email is configured **per host on the edge** (it is a deployment capability, not
28
- app code), in that host's TOML config — `hosts/<host>.toml`:
29
-
30
- ```toml
31
- [email]
32
- enabled = true
33
- from = "you@example.com" # validated; single address, no CRLF
34
- provider = "resend" # "resend" | "gmail" | "smtp"
35
- secret_ref = "resend_key" # a FILENAME, never the secret itself
36
- max_per_min = 60 # per-tenant send budget
37
- max_per_recipient_per_hour = 5 # anti-abuse cap per recipient
27
+ Email is a **framework-reserved namespace of the tenant's environment** the
28
+ same out-of-band [Environment](./environment.md) store that backs
29
+ `Environment.get` / `getSecure`, but the `[email]` block is **host-only**: it is
30
+ read and used in Rust (the off-core mailer) and is **never exposed to the
31
+ `.wasm`**. The provider key never lives in the deployed module or in the
32
+ inotify-watched `hosts/<host>.toml`.
33
+
34
+ On the edge today it lives in the tenant's env secrets file,
35
+ `$TOIL_ENV_DIR/<host>.env.secrets` (default dir `/run/toil/env`), kept `0600` and
36
+ **out of `hosts/`** so the config watcher never sees a credential (the dashboard /
37
+ edge DB replaces this file later). Email config is a set of **reserved
38
+ `TOIL_EMAIL_*` keys** — host-only, stripped from the guest buckets, so a tenant
39
+ can never read them via `Environment.getSecure`:
40
+
41
+ ```bash
42
+ # $TOIL_ENV_DIR/<host>.env.secrets (mode 0600; NOT under hosts/, NOT in the .wasm)
43
+
44
+ TOIL_EMAIL_ENABLED=true
45
+ TOIL_EMAIL_FROM=you@example.com # validated; single address, no CRLF
46
+ TOIL_EMAIL_PROVIDER=resend # resend | gmail | smtp
47
+ TOIL_EMAIL_API_KEY=re_xxxxxxxxxxxx # the provider credential
48
+ TOIL_EMAIL_MAX_PER_MIN=60 # per-host send budget: rolling 1-minute cap
49
+ TOIL_EMAIL_MAX_PER_DAY=0 # per-host send budget: rolling 24-hour cap (0 = unlimited)
50
+ TOIL_EMAIL_MAX_PER_RECIPIENT_PER_HOUR=5 # anti-abuse cap per recipient
38
51
  ```
39
52
 
53
+ The same file also carries the tenant's own secrets (and `<host>.env` their plain
54
+ vars; see [Environment](./environment.md)); the `TOIL_EMAIL_*` keys are just the
55
+ reserved namespace the framework consumes.
56
+
40
57
  When `enabled` is `false` (the default) the host has no email capability and
41
- `EmailService.send` returns `Disabled`. If `enabled` is `true` but the config is
42
- invalid or the secret can't be resolved, the **deploy fails** rather than
43
- shipping a host that can never send.
58
+ `EmailService.send` returns `Disabled`. The env is loaded **lazily** (on the
59
+ first send) and the `api_key` is materialized into a zeroizing secret in host
60
+ memory never written to logs or `/_admin`. A malformed `[email]` block is
61
+ treated as "no email" (the host returns `Disabled`); validate config on the
62
+ dashboard before deploying.
44
63
 
45
- ### Secrets
64
+ ### Providers
46
65
 
47
- `secret_ref` is a **bare filename** (no path separators) under the secrets
48
- directory `$TOIL_SECRETS_DIR` (default `/run/toil/secrets`). The file holds the
49
- provider API key (Resend) or SMTP password. It is read once at load, zeroized in
50
- memory, and never written to the config, logs, or `/_admin`.
66
+ **Resend** (`provider = "resend"`) a JSON API; `api_key` holds the API key.
51
67
 
52
- ```
53
- /run/toil/secrets/resend_key # contents: re_xxxxxxxxxxxx…
68
+ **Gmail** (`TOIL_EMAIL_PROVIDER=gmail`) — SMTP with Gmail defaults:
69
+ `smtp.gmail.com`, port `587` (STARTTLS), username = `from`. `TOIL_EMAIL_API_KEY`
70
+ holds a Gmail **App Password** (create one at
71
+ <https://myaccount.google.com/apppasswords>; the account needs 2-Step
72
+ Verification). No extra keys needed:
73
+
74
+ ```bash
75
+ TOIL_EMAIL_ENABLED=true
76
+ TOIL_EMAIL_FROM=you@gmail.com
77
+ TOIL_EMAIL_PROVIDER=gmail
78
+ TOIL_EMAIL_API_KEY=abcd efgh ijkl mnop
54
79
  ```
55
80
 
56
- ### Providers
81
+ **Generic SMTP** (`TOIL_EMAIL_PROVIDER=smtp`) — any submission server (Outlook,
82
+ SendGrid/Mailgun SMTP, your own). Requires `TOIL_EMAIL_SMTP_HOST`; port defaults
83
+ to `587` (STARTTLS), or set `465` for implicit TLS. `TOIL_EMAIL_SMTP_USER`
84
+ defaults to `from`.
85
+
86
+ ```bash
87
+ TOIL_EMAIL_ENABLED=true
88
+ TOIL_EMAIL_FROM=noreply@example.com
89
+ TOIL_EMAIL_PROVIDER=smtp
90
+ TOIL_EMAIL_API_KEY=the-smtp-password
91
+ TOIL_EMAIL_SMTP_HOST=smtp.example.com
92
+ TOIL_EMAIL_SMTP_PORT=587
93
+ TOIL_EMAIL_SMTP_USER=noreply@example.com
94
+ ```
57
95
 
58
- **Resend** (`provider = "resend"`) — a JSON API; `secret_ref` holds the API key.
96
+ ### In dev
59
97
 
60
- **Gmail** (`provider = "gmail"`) SMTP with Gmail defaults: `smtp.gmail.com`,
61
- port `587` (STARTTLS), username = `from`. `secret_ref` holds a Gmail **App
62
- Password** (create one at <https://myaccount.google.com/apppasswords>; the
63
- account needs 2-Step Verification). No extra keys needed:
98
+ `toiljs dev` runs the **full email pipeline** in Node — recipient validation,
99
+ dedup, and the per-minute / per-day / per-recipient caps all behave exactly like
100
+ the edge — and **really sends** once you configure a provider. Configure it in
101
+ `toil.config.ts` (non-secret) with the API key in `.env.secrets`:
64
102
 
65
- ```toml
66
- [email]
67
- enabled = true
68
- from = "you@gmail.com"
69
- provider = "gmail"
70
- secret_ref = "gmail_app_password"
103
+ ```ts
104
+ // toil.config.ts
105
+ import { defineConfig } from 'toiljs/compiler';
106
+ export default defineConfig({
107
+ server: {
108
+ email: { provider: 'resend', from: 'you@example.com', maxPerMin: 60 },
109
+ },
110
+ });
71
111
  ```
72
-
73
- **Generic SMTP** (`provider = "smtp"`) — any submission server (Outlook,
74
- SendGrid/Mailgun SMTP, your own). Requires `smtp_host`; port defaults to `587`
75
- (STARTTLS), or set `465` for implicit TLS. `smtp_user` defaults to `from`.
76
-
77
- ```toml
78
- [email]
79
- enabled = true
80
- from = "noreply@example.com"
81
- provider = "smtp"
82
- secret_ref = "smtp_password"
83
- smtp_host = "smtp.example.com"
84
- smtp_port = 587
85
- smtp_user = "noreply@example.com"
112
+ ```bash
113
+ # .env.secrets (gitignored)
114
+ TOIL_EMAIL_API_KEY=re_xxxxxxxxxxxx
86
115
  ```
87
116
 
88
- ### In dev
117
+ `TOIL_EMAIL_*` env vars override the config file (so the same `.env.secrets` the
118
+ edge uses works in dev too). Supports `resend` and `gmail`/`smtp` (SMTP via
119
+ nodemailer). **Not configured?** `EmailService.send` stays a log-only mock and
120
+ returns `Sent`, so a flow that sends email still works without setup.
89
121
 
90
- `toiljs dev` has no real provider: `EmailService.send` logs `✉ dev email_send ->
91
- <recipient> (not actually sent)` and returns `Sent`, so your flow proceeds. The
92
- ABI is identical to the edge, so code that runs in dev runs on the edge.
122
+ > Because the dev server runs the guest **synchronously**, the actual network
123
+ > send is fire-and-forget: validation + caps return their exact status
124
+ > immediately, but a `Sent` is optimistic and the real delivery outcome (or
125
+ > `ProviderError`) is logged, not returned. The ABI is identical to the edge, so
126
+ > code that runs in dev runs on the edge.
93
127
 
94
128
  ## Sending email
95
129
 
@@ -240,7 +274,7 @@ const ok: bool = TwoFactor.verify(challenge.token, 'alice@example.com', userEnte
240
274
  recipient that hasn't expired. Constant-time compare.
241
275
  - **`TwoFactor.setSecret(secret)`** — the HMAC secret for the tokens. Call once
242
276
  at startup in `main.ts`; it must be identical on every edge instance and out of
243
- any client bundle. (This is separate from the provider `secret_ref`.)
277
+ any client bundle. (This is separate from the provider `api_key`.)
244
278
 
245
279
  **Limitation:** this gives integrity + expiry but **not single-use** — a valid
246
280
  code verifies repeatedly within its TTL, because there is no server state to burn
@@ -252,7 +286,10 @@ last-verified-at and reject at or before it.
252
286
  All enforced authoritatively in the single mailer (so the counts are exact across
253
287
  all workers):
254
288
 
255
- - **Per-tenant budget** — `max_per_min` (a token bucket). Over it `Budget`.
289
+ - **Per-host budget** — two rolling windows, both enforced: a 1-minute cap
290
+ (`max_per_min`) and a 24-hour cap (`max_per_day`, `0` = unlimited). Over either
291
+ one → `Budget`. Each host's caps, in-window sends, and reject counts are visible
292
+ per host at `GET /_admin/email`.
256
293
  - **Per-recipient cap** — `max_per_recipient_per_hour`. Over it →
257
294
  `RecipientCapped`.
258
295
  - **Dedup** — identical `(host, recipient, purpose)` within ~30s → `Deduped`.
@@ -0,0 +1,97 @@
1
+ # Environment variables & secrets
2
+
3
+ `Environment` gives a tenant **per-app environment variables and secrets**, set
4
+ out of band (a dashboard, like GitHub Actions) so the deployed `.wasm` carries
5
+ **no credentials**. It is read-only from app code — there is no `set`; values are
6
+ configured on the deployment side, never from the module.
7
+
8
+ ```ts
9
+ import { Response, RouteContext } from 'toiljs/server/runtime';
10
+
11
+ @rest('cfg')
12
+ class Cfg {
13
+ @get('/')
14
+ show(ctx: RouteContext): Response {
15
+ const base = Environment.get('PUBLIC_API_BASE'); // plain var, or null
16
+ const key = Environment.getSecure('STRIPE_KEY'); // secret, or null
17
+ // Use `key` to call a third party; never log it or return it to a client.
18
+ return Response.text(base != null ? base : 'unset');
19
+ }
20
+ }
21
+ ```
22
+
23
+ `Environment` is a global — no import needed (like `EmailService` / `AuthService`).
24
+
25
+ ## Two disjoint buckets
26
+
27
+ Just like GitHub Actions' `vars` vs `secrets`:
28
+
29
+ - **`Environment.get(key)`** reads **plain vars** — non-sensitive config (a public
30
+ API base URL, a feature flag, a region). Returns the string, or `null`.
31
+ - **`Environment.getSecure(key)`** reads **secrets** — sensitive values (a
32
+ third-party API key). Returns the string, or `null`.
33
+
34
+ The buckets are **disjoint**: a secret is **never** returned by `get()`, and a
35
+ plain var is never returned by `getSecure()`. That keeps a secret from leaking
36
+ through a code path that logs the result of a `get()`. Keys are case-sensitive,
37
+ exact-match.
38
+
39
+ > Secrets you read with `getSecure` are plaintext in your module at runtime
40
+ > (that's the point — you need them to call out). Don't log them, don't put them
41
+ > in a response, and don't copy them into a client bundle.
42
+
43
+ ## What is NOT here
44
+
45
+ Framework-reserved namespaces (today: **email** provider config) are **host-only**
46
+ — resolved and used in Rust where the framework needs them, and **never exposed to
47
+ the `.wasm`**. There is no `Environment.email`; you configure email in the
48
+ `[email]` block of the same env file and the platform uses it for you (see
49
+ [Email](./email.md)). The env imports only ever see your own `vars` / `secrets`.
50
+
51
+ ## Where values live
52
+
53
+ Vars and secrets live in **two separate dotenv (`.env`) files**, so the disjoint
54
+ split is structural and the secrets file can be locked down on its own. On the
55
+ edge they are per host, **out of `hosts/`** (so the config watcher never sees a
56
+ credential) — the dashboard / edge database replaces them later:
57
+
58
+ ```bash
59
+ # $TOIL_ENV_DIR/<host>.env (default dir /run/toil/env)
60
+ PUBLIC_API_BASE=https://api.example.com # -> Environment.get("PUBLIC_API_BASE")
61
+ REGION=eu
62
+
63
+ # $TOIL_ENV_DIR/<host>.env.secrets (mode 0600)
64
+ STRIPE_KEY=sk_live_xxx # -> Environment.getSecure("STRIPE_KEY")
65
+
66
+ # host-only email config — reserved TOIL_EMAIL_* keys, NEVER exposed to the .wasm
67
+ TOIL_EMAIL_ENABLED=true
68
+ TOIL_EMAIL_PROVIDER=resend
69
+ TOIL_EMAIL_FROM=noreply@example.com
70
+ TOIL_EMAIL_API_KEY=re_xxx
71
+ ```
72
+
73
+ Each file is plain dotenv: `KEY=value` per line, `#` comments, optional `export`,
74
+ optional quotes. Keys with the reserved **`TOIL_`** prefix are framework/host-only
75
+ and are stripped from BOTH guest buckets — a tenant can never read them via
76
+ `get`/`getSecure` (see [Email](./email.md) for `TOIL_EMAIL_*`).
77
+
78
+ On the edge, env is loaded **lazily** (the first time your code reads it) into a
79
+ **bounded, shared, read-only cache** with idle eviction: the data lives in one
80
+ place and is never copied per request, a host that never reads env costs nothing,
81
+ secrets are wiped when a host goes cold, and a flood of requests to many distinct
82
+ hosts can never grow memory without bound.
83
+
84
+ ## In dev
85
+
86
+ `toiljs dev` reads `.env` (vars) and `.env.secrets` (secrets) at your project
87
+ root, and overlays `process.env` as plain vars for convenience. Both are
88
+ gitignored by the scaffold. The ABI is identical to the edge, so code that runs
89
+ in dev runs on the edge.
90
+
91
+ ```bash
92
+ # .env (vars)
93
+ PUBLIC_API_BASE=http://localhost:4000
94
+
95
+ # .env.secrets (secrets; 0600; gitignored)
96
+ STRIPE_KEY=sk_test_xxx
97
+ ```
@@ -11,6 +11,7 @@ import './routes/Players';
11
11
  import './routes/Leaderboard';
12
12
  import './routes/Session';
13
13
  import './routes/PqDemo';
14
+ import './routes/EnvDemo';
14
15
  import './services/Stats';
15
16
  import './services/remotes';
16
17
 
@@ -0,0 +1,42 @@
1
+ import { Response, RouteContext } from 'toiljs/server/runtime';
2
+
3
+ /**
4
+ * Environment demo: per-tenant config + secrets, read from server code with no
5
+ * import (`Environment` is a server global).
6
+ *
7
+ * Values come from out-of-band dotenv files the edge loads lazily and never
8
+ * bundles into the `.wasm`: plain vars from `<host>.env`, secrets from
9
+ * `<host>.env.secrets`. In `toiljs dev` those are `.env` / `.env.secrets` at the
10
+ * project root — copy `.env.example` / `.env.secrets.example` to see this populate.
11
+ *
12
+ * Environment.get("KEY") -> a plain var, or null
13
+ * Environment.getSecure("KEY") -> a secret, or null (a secret is NEVER
14
+ * returned by get(), the buckets are disjoint)
15
+ *
16
+ * Reserved `TOIL_*` keys (e.g. the `TOIL_EMAIL_*` mailer config) are host-only:
17
+ * the edge reads them, but they are unreachable through get()/getSecure().
18
+ */
19
+ @rest('env')
20
+ class EnvDemo {
21
+ /** GET /env/show -> the public vars, plus WHETHER the demo secret is set.
22
+ * We return the secret's presence, not its value: getSecure() hands the guest
23
+ * the real secret, but a demo must never echo a secret back over the wire. */
24
+ @get('/show')
25
+ public show(_ctx: RouteContext): Response {
26
+ let greeting = '(unset)';
27
+ const g = Environment.get('PUBLIC_GREETING');
28
+ if (g != null) greeting = g;
29
+
30
+ let region = '(unset)';
31
+ const r = Environment.get('REGION');
32
+ if (r != null) region = r;
33
+
34
+ const apiKeySet = Environment.getSecure('DEMO_API_KEY') != null;
35
+
36
+ const body =
37
+ 'PUBLIC_GREETING=' + greeting + '\n' +
38
+ 'REGION=' + region + '\n' +
39
+ 'DEMO_API_KEY set=' + (apiKeySet ? 'yes' : 'no') + '\n';
40
+ return Response.text(body, 200);
41
+ }
42
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "toiljs",
3
3
  "type": "module",
4
- "version": "0.0.43",
4
+ "version": "0.0.45",
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": {
@@ -116,8 +116,8 @@
116
116
  "setup": "npm i && npm run build"
117
117
  },
118
118
  "dependencies": {
119
- "@btc-vision/post-quantum": "^0.5.3",
120
119
  "@dacely/hyper-express": "6.17.4",
120
+ "@dacely/noble-post-quantum": "^0.6.1",
121
121
  "@dacely/toilscript-loader": "^0.1.0",
122
122
  "@eslint-react/eslint-plugin": "^5.8.8",
123
123
  "@eslint/js": "^10.0.1",
@@ -127,6 +127,7 @@
127
127
  "eslint-plugin-react-refresh": "^0.5.2",
128
128
  "hash-wasm": "^4.12.0",
129
129
  "juice": "^12.1.0",
130
+ "nodemailer": "^9.0.0",
130
131
  "picocolors": "^1.1.1",
131
132
  "sharp": "^0.35.0",
132
133
  "toilscript": "^0.1.25",
@@ -156,6 +157,7 @@
156
157
  "@testing-library/dom": "^10.4.1",
157
158
  "@testing-library/react": "^16.3.2",
158
159
  "@types/node": "^25.9.1",
160
+ "@types/nodemailer": "^8.0.1",
159
161
  "@types/react": "^19.2.15",
160
162
  "@types/react-dom": "^19.2.3",
161
163
  "@vitest/coverage-v8": "^4.1.7",
@@ -0,0 +1,82 @@
1
+ // Environment: the per-tenant environment variables + secrets a tenant
2
+ // configures OUT OF BAND (a dashboard today, backed by a file; the edge DB
3
+ // later) so the deployed `.wasm` carries no credentials — the GitHub-Actions
4
+ // model. Available as a no-import global (the toilscript `--lib` mechanism, like
5
+ // `AuthService` / `EmailService`).
6
+ //
7
+ // const base = Environment.get("PUBLIC_API_BASE"); // plain var, or null
8
+ // const key = Environment.getSecure("STRIPE_KEY"); // secret, or null
9
+ //
10
+ // Two DISJOINT buckets (like GitHub `vars.*` vs `secrets.*`): `get` reads ONLY
11
+ // plain vars, `getSecure` reads ONLY secrets, so a secret can never come back
12
+ // through `get()` (and leak into a log of a `get`). READ-ONLY — there is no
13
+ // `set`; a tenant sets values on their dashboard, never from the `.wasm`.
14
+ //
15
+ // Framework-reserved namespaces (e.g. the email provider config) are HOST-ONLY:
16
+ // they are resolved and consumed in Rust where the framework uses them and are
17
+ // NEVER reachable here — these imports only see the tenant's own vars/secrets.
18
+ //
19
+ // Backed by the `env_get` / `env_get_secure` host imports (toil-backend
20
+ // `env_get_import.rs`, reading the lazy + bounded `env_cache`, plus the toiljs
21
+ // dev-server mock). A tenant that never reads env imports neither, so
22
+ // AssemblyScript tree-shakes this away.
23
+
24
+ // Host imports: copy one value into `outPtr[0..outCap]`. Return the value's byte
25
+ // length (`0` = present but empty), `-1` if `outCap` is too small (retry with a
26
+ // bigger buffer), `-2` if the key is absent. Resolved from the trusted request
27
+ // host, never anything the guest passes.
28
+ // @ts-ignore: decorator
29
+ @external('env', 'env_get')
30
+ declare function __toilEnvGet(keyPtr: usize, keyLen: i32, outPtr: usize, outCap: i32): i32;
31
+ // @ts-ignore: decorator
32
+ @external('env', 'env_get_secure')
33
+ declare function __toilEnvGetSecure(keyPtr: usize, keyLen: i32, outPtr: usize, outCap: i32): i32;
34
+
35
+ export namespace Environment {
36
+ /** The key is absent from the bucket. */
37
+ const ABSENT: i32 = -2;
38
+ /** The output buffer was too small; retry with a bigger one. */
39
+ const TOO_SMALL: i32 = -1;
40
+ /** First attempt buffer; doubles on `TOO_SMALL`. */
41
+ const INITIAL_CAP: i32 = 256;
42
+ /** Upper bound on a value, matching the host's per-value cap. */
43
+ const MAX_CAP: i32 = 64 * 1024;
44
+
45
+ /** Shared reader for both buckets; `secure` picks the secret import. */
46
+ function read(key: string, secure: bool): string | null {
47
+ const keyB = Uint8Array.wrap(String.UTF8.encode(key));
48
+ let cap = INITIAL_CAP;
49
+ while (cap <= MAX_CAP) {
50
+ const buf = new Uint8Array(cap);
51
+ const n = secure
52
+ ? __toilEnvGetSecure(keyB.dataStart, keyB.length, buf.dataStart, cap)
53
+ : __toilEnvGet(keyB.dataStart, keyB.length, buf.dataStart, cap);
54
+ if (n == ABSENT) return null;
55
+ if (n == TOO_SMALL) {
56
+ cap = cap * 2;
57
+ continue;
58
+ }
59
+ if (n < 0) return null; // unknown negative: fail closed
60
+ return String.UTF8.decodeUnsafe(buf.dataStart, n);
61
+ }
62
+ return null; // value larger than MAX_CAP: treat as absent
63
+ }
64
+
65
+ /**
66
+ * The plain environment variable `key` (a tenant var, set on the dashboard),
67
+ * or `null` if it is not set. NEVER returns a secret — use {@link getSecure}
68
+ * for those.
69
+ */
70
+ export function get(key: string): string | null {
71
+ return read(key, false);
72
+ }
73
+
74
+ /**
75
+ * The secret `key` (a tenant secret, set on the dashboard), or `null` if it
76
+ * is not set. Disjoint from {@link get}: a plain var is never returned here.
77
+ * The value is sensitive — do not log it.
78
+ */
79
+ export function getSecure(key: string): string | null {
80
+ return read(key, true);
81
+ }
82
+ }
package/src/cli/create.ts CHANGED
@@ -165,7 +165,7 @@ function scaffold(
165
165
  '.prettierignore':
166
166
  'node_modules\nbuild\n.toil\nshared/server.ts\ntoil-env.d.ts\ntoil-routes.d.ts\n',
167
167
  '.gitignore':
168
- 'node_modules\nbuild\n.toil\nshared/server.ts\ntoil-env.d.ts\ntoil-routes.d.ts\n',
168
+ 'node_modules\nbuild\n.toil\nshared/server.ts\ntoil-env.d.ts\ntoil-routes.d.ts\n# Local dev env vars/secrets (never commit)\n.env\n.env.secrets\n',
169
169
  // Use the project's pinned TypeScript (node_modules) instead of VS Code's bundled version.
170
170
  '.vscode/settings.json':
171
171
  JSON.stringify({ 'typescript.tsdk': 'node_modules/typescript/lib' }, null, 4) + '\n',
@@ -304,6 +304,7 @@ declare class RenderedEmail { subject: string; body: string; html: string; const
304
304
  declare class EmailTemplate { constructor(subject: string, body: string, html?: string); render(vars: Map<string, string>): RenderedEmail; send(to: string, vars: Map<string, string>, purpose?: string): EmailStatus; }
305
305
  declare enum RateLimit { FixedWindow, SlidingWindow, TokenBucket }
306
306
  declare namespace RateLimitService { function guard(routeId: i32, strategy: i32, limit: i32, window: i32): import('toiljs/server/runtime/response').Response | null; function guardKeyed(routeId: i32, strategy: i32, limit: i32, window: i32, key: string): import('toiljs/server/runtime/response').Response | null; }
307
+ declare namespace Environment { function get(key: string): string | null; function getSecure(key: string): string | null; }
307
308
  declare class TwoFactorIssue { code: string; token: string; constructor(code: string, token: string); }
308
309
  declare class TwoFactorChallenge { token: string; status: EmailStatus; constructor(token: string, status: EmailStatus); }
309
310
  declare namespace TwoFactor { const DEFAULT_TTL_SECS: u64; const DEFAULT_DIGITS: i32; function setSecret(secret: Uint8Array): void; function issue(recipient: string, purpose: string, ttlSecs?: u64, digits?: i32): TwoFactorIssue; function send(recipient: string, purpose: string, ttlSecs?: u64, digits?: i32): TwoFactorChallenge; function verify(token: string, recipient: string, code: string): bool; }
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import { argon2id, sha256 } from 'hash-wasm';
15
- import { ml_dsa44 } from '@btc-vision/post-quantum/ml-dsa.js';
15
+ import { ml_dsa44 } from '@dacely/noble-post-quantum/ml-dsa.js';
16
16
 
17
17
  import { DataReader, DataWriter } from 'toiljs/io';
18
18
 
@@ -3,10 +3,12 @@ import path from 'node:path';
3
3
  import { fileURLToPath, pathToFileURL } from 'node:url';
4
4
 
5
5
  import { type InlineConfig } from 'vite';
6
+ import { type EmailBackendConfig } from 'toiljs/shared';
6
7
 
7
8
  import { type SeoConfig } from './seo.js';
8
9
 
9
10
  export type { SeoConfig } from './seo.js';
11
+ export type { EmailBackendConfig, SmtpBackendConfig } from 'toiljs/shared';
10
12
 
11
13
  /** Built-in AI providers the dev toolbar can proxy to. */
12
14
  export enum AiProvider {
@@ -103,6 +105,15 @@ export interface ServerConfig {
103
105
  readonly srcDir?: string;
104
106
  /** Server build output directory, relative to root. Default `build/server`. */
105
107
  readonly outDir?: string;
108
+ /**
109
+ * Email backend config (the dev server and the future Node self-host). The
110
+ * non-secret pieces — provider, `from`, send caps, SMTP host/port/user. The
111
+ * API key / SMTP password is a SECRET and lives ONLY in `.env.secrets`
112
+ * (`TOIL_EMAIL_API_KEY`); any `TOIL_EMAIL_*` env var overrides the matching
113
+ * field here. The production edge ignores this (it reads `TOIL_EMAIL_*` from
114
+ * the per-tenant env store); this drives `toiljs dev` / self-host.
115
+ */
116
+ readonly email?: EmailBackendConfig;
106
117
  }
107
118
 
108
119
  /**
@@ -144,6 +155,8 @@ export interface ResolvedToilConfig {
144
155
  readonly devtoolsAi: DevtoolsAiConfig | null;
145
156
  /** Build-time SEO config, or `null` when not configured. */
146
157
  readonly seo: SeoConfig | null;
158
+ /** The `server.email` backend config (dev / self-host), or `null` when unset. */
159
+ readonly email: EmailBackendConfig | null;
147
160
  /** Absolute path to the framework client runtime (`toiljs/client`). */
148
161
  readonly runtimePath: string;
149
162
  readonly vite: InlineConfig;
@@ -215,6 +228,7 @@ export async function loadConfig(
215
228
  ? (client.devtools.ai ?? null)
216
229
  : null,
217
230
  seo: client.seo ?? null,
231
+ email: user.server?.email ?? null,
218
232
  runtimePath: resolveRuntimePath(),
219
233
  vite: client.vite ?? {},
220
234
  };
@@ -117,6 +117,7 @@ export const TOIL_SERVER_ENV_DTS =
117
117
  `declare class EmailTemplate { constructor(subject: string, body: string, html?: string); render(vars: Map<string, string>): RenderedEmail; send(to: string, vars: Map<string, string>, purpose?: string): EmailStatus; }\n` +
118
118
  `declare enum RateLimit { FixedWindow, SlidingWindow, TokenBucket }\n` +
119
119
  `declare namespace RateLimitService { function guard(routeId: i32, strategy: i32, limit: i32, window: i32): import('toiljs/server/runtime/response').Response | null; function guardKeyed(routeId: i32, strategy: i32, limit: i32, window: i32, key: string): import('toiljs/server/runtime/response').Response | null; }\n` +
120
+ `declare namespace Environment { function get(key: string): string | null; function getSecure(key: string): string | null; }\n` +
120
121
  `declare class TwoFactorIssue { code: string; token: string; constructor(code: string, token: string); }\n` +
121
122
  `declare class TwoFactorChallenge { token: string; status: EmailStatus; constructor(token: string, status: EmailStatus); }\n` +
122
123
  `declare namespace TwoFactor { const DEFAULT_TTL_SECS: u64; const DEFAULT_DIGITS: i32; function setSecret(secret: Uint8Array): void; function issue(recipient: string, purpose: string, ttlSecs?: u64, digits?: i32): TwoFactorIssue; function send(recipient: string, purpose: string, ttlSecs?: u64, digits?: i32): TwoFactorChallenge; function verify(token: string, recipient: string, code: string): bool; }\n` +
@@ -310,6 +310,7 @@ export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer>
310
310
  port: cfg.port,
311
311
  wasmFile: serverWasmFile(cfg.root),
312
312
  vite: { host: '127.0.0.1', port: vitePort },
313
+ email: cfg.email ?? undefined,
313
314
  });
314
315
  server.httpServer?.once('close', () => {
315
316
  void front.close();
@@ -17,7 +17,7 @@
17
17
 
18
18
  import * as nodeCrypto from 'node:crypto';
19
19
 
20
- import { ml_dsa44 } from '@btc-vision/post-quantum/ml-dsa.js';
20
+ import { ml_dsa44 } from '@dacely/noble-post-quantum/ml-dsa.js';
21
21
 
22
22
  import type { MemoryRef } from './host.js';
23
23
 
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Shared dotenv loader for the dev server (and the future Node self-host): reads
3
+ * a project's `.env` (plain vars) + `.env.secrets` (secrets) and splits out the
4
+ * framework-reserved `TOIL_*` keys (host-only), mirroring the edge's `env_store`.
5
+ *
6
+ * Vite/devserver-free on purpose — both the `Environment.get/getSecure` dev
7
+ * source (`./env.ts`) and the email backend config (`./email/config.ts`) consume
8
+ * this one loader, and the self-host will too.
9
+ */
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+
13
+ /** Keys with this prefix are framework-reserved/host-only (never a tenant var/secret). */
14
+ export const RESERVED_PREFIX = 'TOIL_';
15
+
16
+ export interface LoadedEnv {
17
+ /** Non-reserved `.env` entries + `process.env` overlay → `Environment.get`. */
18
+ readonly vars: Map<string, string>;
19
+ /** Non-reserved `.env.secrets` entries → `Environment.getSecure`. */
20
+ readonly secrets: Map<string, string>;
21
+ /** Reserved `TOIL_*` entries from either file (+ `process.env`) → host-only config. */
22
+ readonly reserved: Map<string, string>;
23
+ }
24
+
25
+ const cache = new Map<string, LoadedEnv>();
26
+
27
+ /** Parse one dotenv value: take inside matching quotes, else cut an inline ` #`. */
28
+ function parseValue(rest: string): string {
29
+ const q = rest[0];
30
+ if (q === '"' || q === "'") {
31
+ const end = rest.indexOf(q, 1);
32
+ return end < 0 ? rest.slice(1) : rest.slice(1, end);
33
+ }
34
+ const hash = rest.indexOf(' #');
35
+ return (hash < 0 ? rest : rest.slice(0, hash)).trimEnd();
36
+ }
37
+
38
+ /**
39
+ * Parse dotenv text into `plain` (non-reserved) and `reserved` (`TOIL_*`):
40
+ * `KEY=value`, `#` comments, optional `export`, optional surrounding quotes.
41
+ */
42
+ function parseDotenv(text: string, plain: Map<string, string>, reserved: Map<string, string>): void {
43
+ for (const raw of text.split('\n')) {
44
+ let line = raw.trim();
45
+ if (line.length === 0 || line.startsWith('#')) continue;
46
+ if (line.startsWith('export ')) line = line.slice('export '.length);
47
+ const eq = line.indexOf('=');
48
+ if (eq < 0) continue;
49
+ const key = line.slice(0, eq).trim();
50
+ if (key.length === 0) continue;
51
+ const val = parseValue(line.slice(eq + 1).trim());
52
+ (key.startsWith(RESERVED_PREFIX) ? reserved : plain).set(key, val);
53
+ }
54
+ }
55
+
56
+ function readFileInto(file: string, plain: Map<string, string>, reserved: Map<string, string>): void {
57
+ try {
58
+ parseDotenv(fs.readFileSync(file, 'utf8'), plain, reserved);
59
+ } catch {
60
+ /* file absent: skip */
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Load `<root>/.env` + `<root>/.env.secrets`, cached by resolved root. `process.env`
66
+ * overlays first (non-reserved → vars, `TOIL_*` → reserved), then the files take
67
+ * precedence. Secrets come only from `.env.secrets`.
68
+ */
69
+ export function loadEnvFiles(root: string): LoadedEnv {
70
+ const key = path.resolve(root);
71
+ const hit = cache.get(key);
72
+ if (hit) return hit;
73
+
74
+ const vars = new Map<string, string>();
75
+ const secrets = new Map<string, string>();
76
+ const reserved = new Map<string, string>();
77
+
78
+ // process.env overlay: non-reserved as plain vars, TOIL_* as reserved config.
79
+ for (const [k, v] of Object.entries(process.env)) {
80
+ if (typeof v !== 'string') continue;
81
+ (k.startsWith(RESERVED_PREFIX) ? reserved : vars).set(k, v);
82
+ }
83
+ readFileInto(path.join(key, '.env'), vars, reserved);
84
+ readFileInto(path.join(key, '.env.secrets'), secrets, reserved);
85
+
86
+ const out: LoadedEnv = { vars, secrets, reserved };
87
+ cache.set(key, out);
88
+ return out;
89
+ }
90
+
91
+ /** Drop the cache (tests). */
92
+ export function clearEnvCache(): void {
93
+ cache.clear();
94
+ }
Binary file