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.
- package/CHANGELOG.md +5 -0
- package/README.md +2 -2
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +303 -293
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/config.d.ts +2 -0
- package/build/compiler/config.js +1 -0
- package/build/compiler/docs.js +8 -24
- package/build/compiler/generate.js +4 -2
- package/build/compiler/index.d.ts +1 -1
- package/build/compiler/index.js +69 -6
- package/build/compiler/toil-docs.generated.js +64 -22
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/analytics/index.js +7 -3
- package/build/devserver/db/database.d.ts +4 -0
- package/build/devserver/db/database.js +43 -1
- package/build/devserver/db/index.d.ts +1 -1
- package/build/devserver/db/index.js +1 -1
- package/build/devserver/db/types.d.ts +3 -0
- package/build/devserver/db/types.js +2 -0
- package/build/devserver/runtime/module.js +4 -1
- package/docs/README.md +104 -65
- package/docs/auth/README.md +102 -0
- package/docs/auth/configuration.md +94 -0
- package/docs/auth/extending.md +202 -0
- package/docs/auth/how-it-works.md +138 -0
- package/docs/auth/usage.md +188 -0
- package/docs/backend/README.md +143 -0
- package/docs/backend/data.md +351 -0
- package/docs/backend/rest.md +402 -0
- package/docs/backend/rpc.md +226 -0
- package/docs/background/README.md +114 -0
- package/docs/background/daemons.md +230 -0
- package/docs/background/derive.md +179 -0
- package/docs/cli/README.md +377 -0
- package/docs/concepts/config.md +416 -0
- package/docs/concepts/decorators.md +127 -0
- package/docs/concepts/security.md +108 -0
- package/docs/concepts/tiers.md +166 -0
- package/docs/concepts/types.md +216 -0
- package/docs/database/README.md +143 -0
- package/docs/database/capacity.md +350 -0
- package/docs/database/counters.md +174 -0
- package/docs/database/documents.md +255 -0
- package/docs/database/events.md +307 -0
- package/docs/database/membership.md +246 -0
- package/docs/database/setup.md +155 -0
- package/docs/database/unique.md +216 -0
- package/docs/database/views.md +246 -0
- package/docs/frontend/README.md +101 -0
- package/docs/frontend/data-fetching.md +243 -0
- package/docs/frontend/images.md +148 -0
- package/docs/frontend/metadata.md +344 -0
- package/docs/frontend/rendering.md +236 -0
- package/docs/frontend/routing.md +344 -0
- package/docs/frontend/scripts.md +118 -0
- package/docs/frontend/search.md +191 -0
- package/docs/frontend/styling.md +147 -0
- package/docs/getting-started/README.md +81 -0
- package/docs/getting-started/create-project.md +131 -0
- package/docs/getting-started/deploy.md +101 -0
- package/docs/getting-started/first-app.md +215 -0
- package/docs/getting-started/installation.md +106 -0
- package/docs/getting-started/migrating.md +125 -0
- package/docs/getting-started/project-structure.md +163 -0
- package/docs/introduction/README.md +41 -0
- package/docs/introduction/design-principles.md +55 -0
- package/docs/introduction/distributed.md +74 -0
- package/docs/introduction/how-it-works.md +74 -0
- package/docs/introduction/hyperscale.md +51 -0
- package/docs/introduction/modern-stack.md +36 -0
- package/docs/introduction/vs-other-frameworks.md +48 -0
- package/docs/introduction/why-toil.md +93 -0
- package/docs/llms.txt +90 -0
- package/docs/realtime/README.md +102 -0
- package/docs/realtime/channels.md +211 -0
- package/docs/realtime/streams.md +369 -0
- package/docs/services/README.md +60 -0
- package/docs/services/analytics.md +268 -0
- package/docs/services/caching.md +175 -0
- package/docs/services/cookies.md +235 -0
- package/docs/services/crypto.md +209 -0
- package/docs/services/email.md +289 -0
- package/docs/services/environment.md +141 -0
- package/docs/services/ratelimit.md +174 -0
- package/docs/services/time.md +85 -0
- package/examples/basic/client/routes/analytics.tsx +13 -12
- package/examples/basic/server/models/SiteAnalytics.ts +29 -17
- package/examples/basic/server/routes/Analytics.ts +15 -17
- package/examples/basic/server/routes/UserId.ts +22 -0
- package/package.json +15 -11
- package/scripts/gen-toil-docs.mjs +24 -35
- package/server/auth/AuthController.ts +336 -0
- package/server/auth/AuthUser.ts +23 -0
- package/server/auth/index.ts +16 -0
- package/server/globals/auth.ts +31 -0
- package/server/globals/userid.ts +107 -0
- package/src/compiler/config.ts +13 -0
- package/src/compiler/docs.ts +16 -33
- package/src/compiler/generate.ts +6 -2
- package/src/compiler/index.ts +114 -6
- package/src/compiler/toil-docs.generated.ts +64 -22
- package/src/devserver/analytics/index.ts +10 -4
- package/src/devserver/db/database.ts +67 -1
- package/src/devserver/db/index.ts +1 -0
- package/src/devserver/db/types.ts +13 -0
- package/src/devserver/runtime/module.ts +7 -0
- package/test/analytics-dev.test.ts +2 -1
- package/test/devserver-database.test.ts +113 -0
- package/docs/auth-todo.md +0 -149
- package/docs/auth.md +0 -322
- package/docs/caching.md +0 -115
- package/docs/cli.md +0 -17
- package/docs/client.md +0 -39
- package/docs/cookies.md +0 -457
- package/docs/crypto.md +0 -130
- package/docs/daemon.md +0 -123
- package/docs/data.md +0 -131
- package/docs/derive.md +0 -159
- package/docs/email.md +0 -326
- package/docs/environment.md +0 -97
- package/docs/getting-started.md +0 -128
- package/docs/index.md +0 -30
- package/docs/ratelimit.md +0 -95
- package/docs/routing.md +0 -259
- package/docs/rpc.md +0 -149
- package/docs/server.md +0 -61
- package/docs/ssr.md +0 -632
- package/docs/streams.md +0 -178
- package/docs/styling.md +0 -22
- package/docs/tiers.md +0 -133
- package/docs/time.md +0 -43
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# How Toil auth works
|
|
2
|
+
|
|
3
|
+
Toil auth is a **post-quantum, password-based, mutually-authenticated** login. The design goal: the user
|
|
4
|
+
types a password, but the server never sees it, never stores it, and can't be phished into leaking a
|
|
5
|
+
verifier, and every primitive is quantum-resistant.
|
|
6
|
+
|
|
7
|
+
This page explains the protocol. You do not need to understand it to use auth (see [Usage](./usage.md)),
|
|
8
|
+
but you should understand the guarantees before you ship.
|
|
9
|
+
|
|
10
|
+
## The building blocks
|
|
11
|
+
|
|
12
|
+
| Primitive | Role |
|
|
13
|
+
| --- | --- |
|
|
14
|
+
| **OPRF** (RFC 9497, ristretto255-SHA512) | A *server-keyed* salt. The client blinds its password, the server evaluates it under a per-user key, the client unblinds. The result can't be computed without the server, so a stolen database can't be brute-forced offline. |
|
|
15
|
+
| **Argon2id** | Stretches the OPRF output into key material (memory-hard, GPU/ASIC-resistant). Runs in the browser. |
|
|
16
|
+
| **ML-DSA-44** (FIPS 204) | The user's identity key pair is *derived* from the Argon2id output. The server stores only the **public** key. Login is proved by an ML-DSA signature. |
|
|
17
|
+
| **ML-KEM-768** (FIPS 203) | Key encapsulation on login: the server proves it holds its KEM secret by returning a confirmation tag only derivable from the decapsulated shared secret → **mutual** auth (anti-phishing). |
|
|
18
|
+
| **HMAC-SHA256** | Signs the session cookie (`AUTH_SESSION_SECRET`). |
|
|
19
|
+
|
|
20
|
+
> The whole thing is sometimes called an *aPAKE* (asymmetric password-authenticated key exchange). Do not
|
|
21
|
+
> call it OPAQUE, this is Toil's own OPRF + KEM + signature construction.
|
|
22
|
+
|
|
23
|
+
## What the server stores
|
|
24
|
+
|
|
25
|
+
Per account, in ToilDB (`@database AuthDb`):
|
|
26
|
+
|
|
27
|
+
- `username`, a deterministic `salt`, the **ML-DSA public key**, and the Argon2id params.
|
|
28
|
+
|
|
29
|
+
That's it. **No password, no password hash, no verifier that can be brute-forced without the OPRF key.**
|
|
30
|
+
Login challenges are a second collection, consumed exactly once (atomic `getDelete`).
|
|
31
|
+
|
|
32
|
+
## Registration
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
Browser (client) Toil edge (server)
|
|
36
|
+
───────────────── ──────────────────
|
|
37
|
+
blind(password) OPRF key = f(seed, username)
|
|
38
|
+
│ username, blinded │
|
|
39
|
+
│ ───────── POST /auth/register/start ───────────► │
|
|
40
|
+
│ │ evaluated = OPRF(blinded)
|
|
41
|
+
│ ◄──── {mem,iters,par, salt, evaluated} ───────── │ (salt is deterministic per user)
|
|
42
|
+
│ │
|
|
43
|
+
unblind → oprfOut │
|
|
44
|
+
seed = Argon2id(oprfOut, salt, params) │
|
|
45
|
+
(pk, sk) = ML-DSA-44.keygen(seed) │
|
|
46
|
+
proof = ML-DSA.sign(sk, "register|username|pk") │
|
|
47
|
+
│ username, pk, proof │
|
|
48
|
+
│ ───────── POST /auth/register/finish ──────────► │
|
|
49
|
+
│ │ verifyRegister(pk, msg, proof) ✓ proof-of-possession
|
|
50
|
+
│ ◄──────────── {status: 0 ok | 1 taken} ──────── │ store AuthAccount{username, salt, pk, params}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The server never learns the password or the secret key `sk`, only the public key `pk` and a proof the
|
|
54
|
+
client holds the matching `sk`. A duplicate username returns a **distinguishable** `status = 1` (so the UI
|
|
55
|
+
can say "taken, log in instead"); everything else fails generically.
|
|
56
|
+
|
|
57
|
+
## Login (with mutual auth)
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
Browser (client) Toil edge (server)
|
|
61
|
+
───────────────── ──────────────────
|
|
62
|
+
blind(password) │
|
|
63
|
+
│ username, blinded │
|
|
64
|
+
│ ───────── POST /auth/login/start ──────────────► │ evaluated = OPRF(blinded) (ALWAYS, even unknown user)
|
|
65
|
+
│ │ if known: store Challenge{cid, nonce, iat, exp}
|
|
66
|
+
│ ◄─ {cid, aud, params, salt, nonce, iat, exp, ── │ (identical response whether the user exists or not)
|
|
67
|
+
│ evaluated} │
|
|
68
|
+
unblind → Argon2id → (pk, sk) = ML-DSA.keygen │
|
|
69
|
+
(ct, ssС) = ML-KEM-768.encapsulate(serverKemPublicKey) │
|
|
70
|
+
msg = "login|username|aud|cid|nonce|iat|exp|ct|params|kid" │
|
|
71
|
+
sig = ML-DSA.sign(sk, msg) │
|
|
72
|
+
│ cid, ct, sig │
|
|
73
|
+
│ ───────── POST /auth/login/finish ─────────────► │ ch = challenges.getDelete(cid) (consume once; check exp)
|
|
74
|
+
│ │ rebuild msg from OUR stored values + ct
|
|
75
|
+
│ │ verifyLogin(acct.pk, msg, sig) ✓ it's really this user
|
|
76
|
+
│ │ ssS = ML-KEM.decapsulate(ct) ✓ we hold the KEM key
|
|
77
|
+
│ │ K = deriveSessionKey(ssS, H(msg))
|
|
78
|
+
│ ◄──── {0, sessionToken, serverConfirm} ──────── │ serverConfirm = tag(K, H(msg))
|
|
79
|
+
│ + Set-Cookie: __Host-toil_sess=… │ mint session cookie
|
|
80
|
+
verify serverConfirm using ssC ✓ the server is genuine │
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Two verifications, both required:
|
|
84
|
+
1. **The server verifies the client**: the ML-DSA signature over a message bound to the challenge, the
|
|
85
|
+
Argon2id params, and the server's KEM key id. Replays fail (the challenge is consumed).
|
|
86
|
+
2. **The client verifies the server**: the `serverConfirm` tag is derivable only from the ML-KEM shared
|
|
87
|
+
secret, which only the holder of the KEM secret key can decapsulate. A phishing site can't forge it.
|
|
88
|
+
|
|
89
|
+
## Anti-enumeration
|
|
90
|
+
|
|
91
|
+
`/login/start` behaves identically for a known and an unknown user: it always runs the OPRF (a decoy key
|
|
92
|
+
for unknown users), returns a **deterministic** per-user salt and constant params, and a fresh challenge.
|
|
93
|
+
The challenge is persisted only for a real account, and `/login/finish` fails generically at consume for an
|
|
94
|
+
unknown user. So an attacker can't probe which usernames exist. Every failure path returns the same
|
|
95
|
+
`401 auth: request failed`.
|
|
96
|
+
|
|
97
|
+
## Sessions & cookies
|
|
98
|
+
|
|
99
|
+
On successful login the server mints **two** cookies:
|
|
100
|
+
|
|
101
|
+
- **`__Host-toil_sess`**: the authoritative session. HMAC-SHA256 signed with `AUTH_SESSION_SECRET`,
|
|
102
|
+
`HttpOnly`, `Secure`, `SameSite=Lax`. It carries the `@user` codec payload. `@auth` and
|
|
103
|
+
`AuthService.getUser()` open + verify this cookie server-side; a forged or tampered cookie fails.
|
|
104
|
+
- **`__Secure-toil_user`**: a **readable** companion carrying the same payload, so the browser can show
|
|
105
|
+
"logged in as …" via the client's `getUser()`. The server **never trusts it**, it is display-only.
|
|
106
|
+
|
|
107
|
+
Each request runs in a fresh wasm instance, but the signed cookie is self-contained, so no server-side
|
|
108
|
+
session store is needed. `AUTH_SESSION_SECRET` must be identical across every edge instance (it is, via the
|
|
109
|
+
env store) so a cookie minted anywhere verifies everywhere.
|
|
110
|
+
|
|
111
|
+
## The stable user identity: `ToilUserId`
|
|
112
|
+
|
|
113
|
+
At login the server derives a stable, tenant-scoped id and stores it in the session (the first field of the
|
|
114
|
+
built-in `@user`):
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
toilUserId = SHA-256( mldsaPublicKey ‖ username ‖ domain ) // 256 bits
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
- **Stable:** same login key + username on the same tenant `domain` → same id, forever, across sessions
|
|
121
|
+
and devices. Key your own data on it.
|
|
122
|
+
- **Opaque + one-way:** it's a hash: safe to store, log, or expose without leaking the key or the address.
|
|
123
|
+
- Read it anywhere with `AuthService.userId()`. See [Extending](./extending.md#toiluserid) for the
|
|
124
|
+
`ToilUserId` API (O(1) `==` / `!=`, `toHex()`, …).
|
|
125
|
+
|
|
126
|
+
## Threat-model summary
|
|
127
|
+
|
|
128
|
+
- **Server database stolen** → attacker gets public keys + salts, not passwords. Brute-forcing needs the
|
|
129
|
+
OPRF key (server-side) *and* Argon2id work per guess.
|
|
130
|
+
- **Server compromised / malicious** → still can't recover passwords (only public keys) and can't forge a
|
|
131
|
+
past session without `AUTH_SESSION_SECRET`.
|
|
132
|
+
- **Phishing site** → can't produce the `serverConfirm` tag (no KEM secret), so a correct client aborts.
|
|
133
|
+
- **Quantum adversary** → ML-DSA + ML-KEM are post-quantum; the OPRF/Argon2id/HMAC pieces are classical but
|
|
134
|
+
not the long-term identity or key-exchange.
|
|
135
|
+
- **Replay** → challenges are single-use (`getDelete`) with a short TTL.
|
|
136
|
+
|
|
137
|
+
Residual responsibilities are yours: set the [secrets](./configuration.md), pin your deployment's KEM
|
|
138
|
+
public key in the client, and raise the Argon2id params for production.
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# Using auth
|
|
2
|
+
|
|
3
|
+
## 1. Enable it
|
|
4
|
+
|
|
5
|
+
Either the config flag (canonical) or a one-line import, both do the same thing (the build appends the
|
|
6
|
+
framework's auth controller + user shape to your server as compiled entries, so `/auth/*` self-mounts):
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
// toil.config.ts, canonical
|
|
10
|
+
export default defineConfig({ server: { auth: true } });
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
// server/main.ts, escape hatch (equivalent)
|
|
15
|
+
import 'toiljs/server/auth';
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
There is also an `AuthService.enable()` no-op you can call for discoverability, but enabling is done by the
|
|
19
|
+
flag/import above (it happens at build time). Turn it off by removing the flag/import, with neither, no
|
|
20
|
+
`/auth` routes and no accounts collection are generated. It is strictly opt-in.
|
|
21
|
+
|
|
22
|
+
## 2. The client
|
|
23
|
+
|
|
24
|
+
`toiljs/client` ships the browser half, it runs the OPRF blinding, Argon2id, ML-DSA keygen, and ML-KEM
|
|
25
|
+
encapsulation, and talks to `/auth/*`. You never touch the crypto.
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { Auth } from 'toiljs/client';
|
|
29
|
+
|
|
30
|
+
// Create an account. Throws on a taken username or a transport error.
|
|
31
|
+
await Auth.register(username, password);
|
|
32
|
+
|
|
33
|
+
// Log in. On success the browser holds the signed session cookie; subsequent
|
|
34
|
+
// requests to @auth routes are authorized automatically.
|
|
35
|
+
await Auth.login(username, password);
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Options (second argument, `AuthOptions`):
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
await Auth.login(username, password, {
|
|
42
|
+
baseUrl: '/auth', // default; change if you mount elsewhere
|
|
43
|
+
serverKemPublicKey: MY_KEM_PUB, // REQUIRED in production, pin your deployment's KEM key
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
> **Production:** the client ships with the DEV KEM public key pinned. A real deployment MUST pass its own
|
|
48
|
+
> `serverKemPublicKey` (derived from `AUTH_KEM_SK`). See [Configuration](./configuration.md).
|
|
49
|
+
|
|
50
|
+
## 2b. Client-side: who's logged in, and protecting pages
|
|
51
|
+
|
|
52
|
+
`register`/`login` set the session cookies; to *render* login state on the client, use the generated
|
|
53
|
+
`getUser()` (emitted from the built-in `@user`). It reads the **readable** `__Secure-toil_user` companion
|
|
54
|
+
cookie and returns the typed user or `null`, instant, no network call. It is **display-only**; the server
|
|
55
|
+
still enforces access via `@auth`, so never trust it for authorization.
|
|
56
|
+
|
|
57
|
+
```tsx
|
|
58
|
+
import { getUser } from 'shared/server'; // generated; typed to the built-in @user
|
|
59
|
+
|
|
60
|
+
function Nav() {
|
|
61
|
+
const user = getUser(); // { toilUserId, username } | null
|
|
62
|
+
return user
|
|
63
|
+
? <span>Hi {user.username} <button onClick={logout}>Log out</button></span>
|
|
64
|
+
: <a href="/login">Log in</a>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function logout() {
|
|
68
|
+
await fetch('/auth/logout', { method: 'POST' });
|
|
69
|
+
location.href = '/login'; // cookies are cleared; bounce to login
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Gate a whole client route by redirecting when there's no session:
|
|
74
|
+
|
|
75
|
+
```tsx
|
|
76
|
+
export default function Dashboard() {
|
|
77
|
+
const user = getUser();
|
|
78
|
+
if (user == null) { location.href = '/login'; return null; } // not logged in -> to /login
|
|
79
|
+
return <main>Welcome, {user.username}</main>;
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The authoritative check is still the server: any data this page fetches should come from an `@auth` route,
|
|
84
|
+
so a user who forged/deleted the readable cookie sees the redirect OR a `401`, never real data.
|
|
85
|
+
|
|
86
|
+
## 2c. Handling errors
|
|
87
|
+
|
|
88
|
+
`Auth.register` / `Auth.login` reject with a message you can show. The important distinguishable cases:
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
try {
|
|
92
|
+
await Auth.register(username, password);
|
|
93
|
+
} catch (e) {
|
|
94
|
+
const m = String(e);
|
|
95
|
+
if (m.includes('already registered')) setError('That username is taken, log in instead.');
|
|
96
|
+
else setError('Could not register, try again.');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
await Auth.login(username, password);
|
|
101
|
+
} catch {
|
|
102
|
+
// Wrong password OR unknown user both fail generically (anti-enumeration), one message.
|
|
103
|
+
setError('Incorrect username or password.');
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
- **Username taken** → `register` throws `auth: username already registered (log in instead)` (a
|
|
108
|
+
distinguishable case so you can guide the user).
|
|
109
|
+
- **Wrong password / unknown user** → `login` throws generically (`auth: request failed`): by design,
|
|
110
|
+
the two are indistinguishable, so use ONE "incorrect username or password" message.
|
|
111
|
+
- **Rate limited** → after 5 attempts / 60s a `429` surfaces as the same generic throw; back off and tell
|
|
112
|
+
the user to wait.
|
|
113
|
+
|
|
114
|
+
## 3. Guard your routes: `@auth`
|
|
115
|
+
|
|
116
|
+
Put `@auth` on a route method or a whole `@rest` class. The generated dispatcher checks for a valid signed
|
|
117
|
+
session **before** your handler runs and returns `401` otherwise. `@auth` is unchanged by built-in auth,
|
|
118
|
+
it's the same decorator you'd use with hand-written auth.
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
@rest('account')
|
|
122
|
+
class AccountApi {
|
|
123
|
+
@auth // this route needs a session
|
|
124
|
+
@get('/settings')
|
|
125
|
+
public settings(): Response { /* … */ }
|
|
126
|
+
|
|
127
|
+
@get('/public') // this one is open
|
|
128
|
+
public open(): Response { /* … */ }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
@auth // …or guard the ENTIRE class
|
|
132
|
+
@rest('admin')
|
|
133
|
+
class AdminApi { /* every route requires a session */ }
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## 4. Read the current user
|
|
137
|
+
|
|
138
|
+
Inside any handler:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
// The typed logged-in user (the built-in `@user`: toilUserId + username), or null.
|
|
142
|
+
const user = AuthService.getUser();
|
|
143
|
+
if (user != null) {
|
|
144
|
+
user.username; // string
|
|
145
|
+
user.toilUserId; // Uint8Array(32), the stable id bytes
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The stable identity as a ToilUserId (gate on hasSession() first, see note).
|
|
149
|
+
if (AuthService.hasSession()) {
|
|
150
|
+
const id = AuthService.userId()!; // ToilUserId
|
|
151
|
+
// key your own data on id (see Extending)
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
> `ToilUserId` overloads `==`, so `AuthService.userId() == null` does not type-check. Gate with
|
|
156
|
+
> `AuthService.hasSession()` and then use `userId()!`, or use `getUser()` and null-check that.
|
|
157
|
+
|
|
158
|
+
## 5. The endpoint wire contract
|
|
159
|
+
|
|
160
|
+
You normally use the `Auth` client, but the raw endpoints are binary (`DataWriter`/`DataReader`, never
|
|
161
|
+
JSON):
|
|
162
|
+
|
|
163
|
+
| Route | Request body | Response |
|
|
164
|
+
| --- | --- | --- |
|
|
165
|
+
| `POST /auth/register/start` | `str(username) bytes(blinded)` | `u8(0) u32(mem) u32(iters) u32(par) bytes(salt) bytes(evaluated)` |
|
|
166
|
+
| `POST /auth/register/finish` | `str(username) bytes(pubkey) bytes(proof)` | `u8(status)`, `0` ok, `1` username taken |
|
|
167
|
+
| `POST /auth/login/start` | `str(username) bytes(blinded)` | `bytes(cid) str(aud) u32(mem) u32(iters) u32(par) bytes(salt) bytes(nonce) u64(iat) u64(exp) bytes(evaluated)` |
|
|
168
|
+
| `POST /auth/login/finish` | `bytes(cid) bytes(ct) bytes(sig)` | `u8(0) bytes(sessionToken) bytes(serverConfirm)` + `Set-Cookie`, or `u8(≠0)` on failure |
|
|
169
|
+
| `GET /auth/me` *(`@auth`)* | (none) | `bytes(toilUserId) str(username)` |
|
|
170
|
+
| `POST /auth/logout` *(`@auth`)* | (none) | `200` + cookie-clearing `Set-Cookie` |
|
|
171
|
+
|
|
172
|
+
Rate limiting: every register/login POST carries `@ratelimit(SlidingWindow, 5, 60)` (5 requests / 60s per
|
|
173
|
+
client) out of the box, so brute-force is throttled before it reaches the crypto.
|
|
174
|
+
|
|
175
|
+
## 6. Under `toiljs dev`
|
|
176
|
+
|
|
177
|
+
Everything runs locally with **zero setup**: the dev server emulates the ToilDB account/challenge storage
|
|
178
|
+
and the ML-DSA/ML-KEM/OPRF host functions in process, and the auth secrets fall back to insecure DEV
|
|
179
|
+
values. Register and login span requests (the accounts persist for the dev session). You'll see a warning
|
|
180
|
+
that `AUTH_SESSION_SECRET` is unset, that's expected in dev; set it before you deploy (see
|
|
181
|
+
[Configuration](./configuration.md)).
|
|
182
|
+
|
|
183
|
+
## 7. `Server.REST.auth.*`
|
|
184
|
+
|
|
185
|
+
Because the controller is a normal `@rest('auth')` class, a typed `Server.REST.auth.*` fetch client is
|
|
186
|
+
generated for free (`me`, `logout`, and the register/login methods). Use it for `/me` and `/logout`; but
|
|
187
|
+
**drive register/login through `toiljs/client` `Auth`**, not the generated client, only the `Auth` helper
|
|
188
|
+
runs the required browser-side OPRF/Argon2id/ML-DSA/ML-KEM crypto.
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# Backend overview
|
|
2
|
+
|
|
3
|
+
Your backend is TypeScript that toilscript compiles into a small, sandboxed WebAssembly program, which runs on the Dacely edge and answers every request.
|
|
4
|
+
|
|
5
|
+
## What the backend is
|
|
6
|
+
|
|
7
|
+
In a toiljs project, everything under `server/` is your backend. You write it in TypeScript, the same language as your frontend. But it does not run in a browser and it does not run in Node. Instead, a compiler called **toilscript** turns it into **WebAssembly** (often shortened to **WASM**): a compact, fast, portable binary format that many kinds of servers can run safely.
|
|
8
|
+
|
|
9
|
+
That compiled program is then deployed to the **Dacely edge**. Two pieces of jargon to unpack there:
|
|
10
|
+
|
|
11
|
+
- **The edge** means a fleet of servers spread across many cities around the world. When a user makes a request, it is served by the edge node physically closest to them. Close means fast: less distance for the data to travel, so lower latency. You do not pick a region or manage servers; your one compiled backend runs everywhere at once.
|
|
12
|
+
- **Sandboxed** means your WASM program runs inside a locked box. It cannot open files, reach the operating system, or make raw network connections on its own. The only way it can touch the outside world is through a small, fixed set of **host functions** that toiljs provides (read the request, build a response, query the database, send an email, and so on). Every one of those calls is metered and bounded, so a buggy or hostile backend cannot crash the node or read another app's data. This is what makes it safe to run thousands of different apps on the same shared edge.
|
|
13
|
+
|
|
14
|
+
You never call the WASM boundary by hand. You write normal TypeScript classes and functions, tag them with decorators (like `@rest` or `@service`), and the compiler wires everything up.
|
|
15
|
+
|
|
16
|
+
> New to decorators? A **decorator** is the `@name` you write just above a class or method. It attaches meaning to that code without changing what the code does line by line. toiljs uses decorators to say "this class is an HTTP controller" or "this method is callable from the browser." See [Decorators](../concepts/decorators.md).
|
|
17
|
+
|
|
18
|
+
## The request lifecycle
|
|
19
|
+
|
|
20
|
+
Here is what happens, end to end, when a browser talks to your backend.
|
|
21
|
+
|
|
22
|
+
```mermaid
|
|
23
|
+
flowchart TD
|
|
24
|
+
U["User's browser"] -->|"HTTP request"| E["Nearest Dacely edge node"]
|
|
25
|
+
E --> S{"Static file<br/>for this path?"}
|
|
26
|
+
S -->|"Yes (GET/HEAD)"| F["Serve the file<br/>(HTML, JS, images)"]
|
|
27
|
+
S -->|"No"| W["Your compiled backend<br/>(server.wasm handle)"]
|
|
28
|
+
W --> H["Your handler runs<br/>and returns a Response"]
|
|
29
|
+
H --> E
|
|
30
|
+
F --> E
|
|
31
|
+
E -->|"HTTP response"| U
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Step by step:
|
|
35
|
+
|
|
36
|
+
1. The request lands on the closest edge node.
|
|
37
|
+
2. The edge first checks whether the path is a **static file** it can serve directly (your built frontend: HTML pages, JavaScript bundles, images). If so, it serves the file and never wakes your code. This is fast and free.
|
|
38
|
+
3. Otherwise the edge hands the request to your compiled backend by calling its single WASM export, `handle`.
|
|
39
|
+
4. Inside, toiljs decodes the raw bytes into a friendly [`Request`](./rest.md#the-request-object) object and calls your handler's `handle(req)` method.
|
|
40
|
+
5. Your handler returns a [`Response`](./rest.md#building-a-response). toiljs encodes it back into bytes.
|
|
41
|
+
6. The edge sends that response to the browser.
|
|
42
|
+
|
|
43
|
+
The key mental model: your backend is a pure function of the request. Bytes in, bytes out, one request at a time.
|
|
44
|
+
|
|
45
|
+
## Stateless by default
|
|
46
|
+
|
|
47
|
+
A **fresh copy** of your handler serves each request. Any fields you set on a controller do not survive to the next request, and the request might even be served by a different edge node on the other side of the world. This is called being **stateless**.
|
|
48
|
+
|
|
49
|
+
That is a feature, not a limitation: it is what lets your backend scale to the whole planet with no coordination. When you need data to persist between requests (a user account, a counter, a list of posts), you store it in the built-in global database, **ToilDB**. See [the database section](../database/README.md).
|
|
50
|
+
|
|
51
|
+
```mermaid
|
|
52
|
+
flowchart LR
|
|
53
|
+
R1["Request 1"] --> B1["Handler copy A<br/>(fields reset)"]
|
|
54
|
+
R2["Request 2"] --> B2["Handler copy B<br/>(fields reset)"]
|
|
55
|
+
B1 --> DB[("ToilDB<br/>(shared, persistent)")]
|
|
56
|
+
B2 --> DB
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## The three surfaces
|
|
60
|
+
|
|
61
|
+
Your backend can expose three different kinds of endpoint. Each is opted into with a decorator, and each has its own page:
|
|
62
|
+
|
|
63
|
+
| Surface | Decorator | What it is | When to use it |
|
|
64
|
+
| --- | --- | --- | --- |
|
|
65
|
+
| **HTTP REST** | `@rest` + `@get`/`@post`/... | Plain HTTP routes with paths, methods, and status codes. | A public API, webhooks, anything a browser, `curl`, or a third party calls directly. See [REST](./rest.md). |
|
|
66
|
+
| **Typed RPC** | `@service` / `@remote` | Server functions your own frontend calls like local async functions, fully type-checked end to end. | Talking from your own React app to your own backend. See [RPC](./rpc.md). |
|
|
67
|
+
| **Realtime** | `@stream` | A long-lived connection where the server keeps state per connected client. | Chat, live cursors, notifications, anything push-based. See [Realtime](../realtime/README.md). |
|
|
68
|
+
|
|
69
|
+
REST and RPC are the everyday tools. Most apps use both: REST for anything the outside world calls, RPC for your own frontend. They are not exclusive; you can use all three in one project.
|
|
70
|
+
|
|
71
|
+
All of these share the same building block for their data: **`@data` classes**, which are typed structs that travel safely between the browser and your WASM backend. See [Data types](./data.md).
|
|
72
|
+
|
|
73
|
+
## Where your handler lives
|
|
74
|
+
|
|
75
|
+
REST, RPC, and streams self-register: you tag a class and the compiler adds it to the right dispatcher. A tiny amount of glue lives in `server/main.ts`, which imports your route files and names your top-level handler class. In a typical project you rarely touch `main.ts`; you add route and service files and they are discovered automatically.
|
|
76
|
+
|
|
77
|
+
Your handler class extends `ToilHandler` and overrides `handle`. The common pattern is to try the REST dispatcher first, then fall back to your own logic:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import { Method, Request, Response, Rest, ToilHandler } from 'toiljs/server/runtime';
|
|
81
|
+
|
|
82
|
+
export class AppHandler extends ToilHandler {
|
|
83
|
+
public handle(req: Request): Response {
|
|
84
|
+
// Try every @rest controller. Returns the first match, or null.
|
|
85
|
+
const hit = Rest.dispatch(req);
|
|
86
|
+
if (hit != null) return hit;
|
|
87
|
+
|
|
88
|
+
// Your own hand-written endpoints can go here.
|
|
89
|
+
if (req.path == '/health') return Response.text('ok\n');
|
|
90
|
+
|
|
91
|
+
// "I have no answer for this path": let the edge serve it (a static
|
|
92
|
+
// file, the client app) instead of returning a hard 404.
|
|
93
|
+
return Response.unhandled();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
RPC calls (to the reserved path `/__toil_rpc`) are handled by the framework before your `handle` runs, so you do not dispatch them yourself.
|
|
99
|
+
|
|
100
|
+
### Per-request hooks: `onRequestStarted` and `onRequestCompleted`
|
|
101
|
+
|
|
102
|
+
`ToilHandler` gives you two optional hooks that run around every request, so you can add cross-cutting logic (logging, timing, metrics) once instead of repeating it in every route, and without re-implementing `handle`:
|
|
103
|
+
|
|
104
|
+
- **`onRequestStarted(req: Request): void`** runs just **before** `handle(req)`. Override it for per-request setup: start a timer, log the incoming method and path, read a header you need everywhere.
|
|
105
|
+
- **`onRequestCompleted(req: Request, resp: Response): void`** runs just **after** `handle` returns, and it is handed the `Response` that is about to go out. It **also runs when your handler throws**, after the runtime has turned that throw into a `500`, so it is the right place for teardown you always want to happen (record the outcome, stop the timer, emit a metric).
|
|
106
|
+
|
|
107
|
+
Both are empty by default, so override only the one you need. The framework calls them around every request; you never call them yourself.
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
import { Request, Response, Rest, ToilHandler } from 'toiljs/server/runtime';
|
|
111
|
+
|
|
112
|
+
export class AppHandler extends ToilHandler {
|
|
113
|
+
public handle(req: Request): Response {
|
|
114
|
+
const hit = Rest.dispatch(req);
|
|
115
|
+
return hit != null ? hit : Response.unhandled();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Runs before every handle().
|
|
119
|
+
public onRequestStarted(req: Request): void {
|
|
120
|
+
// per-request setup, e.g. note the path you are about to serve
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Runs after every handle(), including after a throw became a 500.
|
|
124
|
+
public onRequestCompleted(req: Request, resp: Response): void {
|
|
125
|
+
// always-run teardown: record the outcome, stop a timer, emit a metric
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
If your project is REST-only, you do not even need a custom handler; toiljs ships a ready-made one. See [REST](./rest.md#dispatch-and-the-404-fallback).
|
|
131
|
+
|
|
132
|
+
## Compute tiers
|
|
133
|
+
|
|
134
|
+
The request/response backend described here is the default and most common tier, called **L1**. toiljs also has higher tiers for long-lived connections (streams) and scheduled background work (daemons), each compiled into its own WASM artifact from the same project. You opt into a tier just by adding its entry file and surface decorator. For the full picture, see [Compute tiers](../concepts/tiers.md).
|
|
135
|
+
|
|
136
|
+
## Related
|
|
137
|
+
|
|
138
|
+
- [HTTP routes (`@rest`)](./rest.md): paths, methods, params, and responses.
|
|
139
|
+
- [Typed RPC (`@service`/`@remote`)](./rpc.md): calling the server from your frontend with end-to-end types.
|
|
140
|
+
- [Data types (`@data`)](./data.md): the serializable structs everything uses.
|
|
141
|
+
- [The database (ToilDB)](../database/README.md): where persistent state lives.
|
|
142
|
+
- [Compute tiers](../concepts/tiers.md): L1 request, L2/L3 stream, L4 daemon.
|
|
143
|
+
- [Realtime streams](../realtime/README.md): the `@stream` surface.
|