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.
- package/CHANGELOG.md +9 -0
- package/RSG.md +334 -0
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +2 -1
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/auth.js +1 -1
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/config.d.ts +4 -0
- package/build/compiler/config.js +1 -0
- package/build/compiler/generate.js +1 -0
- package/build/compiler/index.js +1 -0
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/crypto.js +1 -1
- package/build/devserver/dotenv.d.ts +8 -0
- package/build/devserver/dotenv.js +59 -0
- package/build/devserver/email/caps.d.ts +9 -0
- package/build/devserver/email/caps.js +0 -0
- package/build/devserver/email/config.d.ts +21 -0
- package/build/devserver/email/config.js +72 -0
- package/build/devserver/email/index.d.ts +25 -0
- package/build/devserver/email/index.js +57 -0
- package/build/devserver/email/providers.d.ts +12 -0
- package/build/devserver/email/providers.js +96 -0
- package/build/devserver/email/status.d.ts +10 -0
- package/build/devserver/email/status.js +11 -0
- package/build/devserver/email/validate.d.ts +2 -0
- package/build/devserver/email/validate.js +24 -0
- package/build/devserver/email/wire.d.ts +8 -0
- package/build/devserver/email/wire.js +32 -0
- package/build/devserver/env.d.ts +2 -0
- package/build/devserver/env.js +9 -0
- package/build/devserver/host.js +39 -7
- package/build/devserver/index.d.ts +2 -0
- package/build/devserver/index.js +8 -0
- package/build/devserver/module.js +1 -1
- package/build/shared/.tsbuildinfo +1 -1
- package/build/shared/index.d.ts +13 -0
- package/docs/README.md +4 -1
- package/docs/email.md +90 -53
- package/docs/environment.md +97 -0
- package/examples/basic/server/main.ts +1 -0
- package/examples/basic/server/routes/EnvDemo.ts +42 -0
- package/package.json +4 -2
- package/server/globals/environment.ts +82 -0
- package/src/cli/create.ts +2 -1
- package/src/client/auth.ts +1 -1
- package/src/compiler/config.ts +14 -0
- package/src/compiler/generate.ts +1 -0
- package/src/compiler/index.ts +1 -0
- package/src/devserver/crypto.ts +1 -1
- package/src/devserver/dotenv.ts +94 -0
- package/src/devserver/email/caps.ts +0 -0
- package/src/devserver/email/config.ts +123 -0
- package/src/devserver/email/index.ts +111 -0
- package/src/devserver/email/providers.ts +130 -0
- package/src/devserver/email/status.ts +23 -0
- package/src/devserver/email/validate.ts +40 -0
- package/src/devserver/email/wire.ts +55 -0
- package/src/devserver/env.ts +30 -0
- package/src/devserver/host.ts +71 -12
- package/src/devserver/index.ts +20 -0
- package/src/devserver/module.ts +1 -1
- package/src/shared/index.ts +36 -0
- 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
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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`.
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
###
|
|
64
|
+
### Providers
|
|
46
65
|
|
|
47
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
96
|
+
### In dev
|
|
59
97
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
```
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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 `
|
|
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-
|
|
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
|
+
```
|
|
@@ -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.
|
|
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; }
|
package/src/client/auth.ts
CHANGED
package/src/compiler/config.ts
CHANGED
|
@@ -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
|
};
|
package/src/compiler/generate.ts
CHANGED
|
@@ -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` +
|
package/src/compiler/index.ts
CHANGED
|
@@ -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();
|
package/src/devserver/crypto.ts
CHANGED
|
@@ -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
|