toiljs 0.0.85 → 0.0.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +2 -2
  3. package/build/cli/.tsbuildinfo +1 -1
  4. package/build/cli/index.js +303 -293
  5. package/build/compiler/.tsbuildinfo +1 -1
  6. package/build/compiler/config.d.ts +2 -0
  7. package/build/compiler/config.js +1 -0
  8. package/build/compiler/docs.js +8 -24
  9. package/build/compiler/generate.js +4 -2
  10. package/build/compiler/index.d.ts +1 -1
  11. package/build/compiler/index.js +69 -6
  12. package/build/compiler/toil-docs.generated.js +64 -22
  13. package/build/devserver/.tsbuildinfo +1 -1
  14. package/build/devserver/analytics/index.js +7 -3
  15. package/build/devserver/db/database.d.ts +4 -0
  16. package/build/devserver/db/database.js +43 -1
  17. package/build/devserver/db/index.d.ts +1 -1
  18. package/build/devserver/db/index.js +1 -1
  19. package/build/devserver/db/types.d.ts +3 -0
  20. package/build/devserver/db/types.js +2 -0
  21. package/build/devserver/runtime/module.js +4 -1
  22. package/docs/README.md +104 -65
  23. package/docs/auth/README.md +102 -0
  24. package/docs/auth/configuration.md +94 -0
  25. package/docs/auth/extending.md +202 -0
  26. package/docs/auth/how-it-works.md +138 -0
  27. package/docs/auth/usage.md +188 -0
  28. package/docs/backend/README.md +143 -0
  29. package/docs/backend/data.md +351 -0
  30. package/docs/backend/rest.md +402 -0
  31. package/docs/backend/rpc.md +226 -0
  32. package/docs/background/README.md +114 -0
  33. package/docs/background/daemons.md +230 -0
  34. package/docs/background/derive.md +179 -0
  35. package/docs/cli/README.md +377 -0
  36. package/docs/concepts/config.md +416 -0
  37. package/docs/concepts/decorators.md +127 -0
  38. package/docs/concepts/security.md +108 -0
  39. package/docs/concepts/tiers.md +166 -0
  40. package/docs/concepts/types.md +216 -0
  41. package/docs/database/README.md +143 -0
  42. package/docs/database/capacity.md +350 -0
  43. package/docs/database/counters.md +174 -0
  44. package/docs/database/documents.md +255 -0
  45. package/docs/database/events.md +307 -0
  46. package/docs/database/membership.md +246 -0
  47. package/docs/database/setup.md +155 -0
  48. package/docs/database/unique.md +216 -0
  49. package/docs/database/views.md +246 -0
  50. package/docs/frontend/README.md +101 -0
  51. package/docs/frontend/data-fetching.md +243 -0
  52. package/docs/frontend/images.md +148 -0
  53. package/docs/frontend/metadata.md +344 -0
  54. package/docs/frontend/rendering.md +236 -0
  55. package/docs/frontend/routing.md +344 -0
  56. package/docs/frontend/scripts.md +118 -0
  57. package/docs/frontend/search.md +191 -0
  58. package/docs/frontend/styling.md +147 -0
  59. package/docs/getting-started/README.md +81 -0
  60. package/docs/getting-started/create-project.md +131 -0
  61. package/docs/getting-started/deploy.md +101 -0
  62. package/docs/getting-started/first-app.md +215 -0
  63. package/docs/getting-started/installation.md +106 -0
  64. package/docs/getting-started/migrating.md +125 -0
  65. package/docs/getting-started/project-structure.md +163 -0
  66. package/docs/introduction/README.md +41 -0
  67. package/docs/introduction/design-principles.md +55 -0
  68. package/docs/introduction/distributed.md +74 -0
  69. package/docs/introduction/how-it-works.md +74 -0
  70. package/docs/introduction/hyperscale.md +51 -0
  71. package/docs/introduction/modern-stack.md +36 -0
  72. package/docs/introduction/vs-other-frameworks.md +48 -0
  73. package/docs/introduction/why-toil.md +93 -0
  74. package/docs/llms.txt +90 -0
  75. package/docs/realtime/README.md +102 -0
  76. package/docs/realtime/channels.md +211 -0
  77. package/docs/realtime/streams.md +369 -0
  78. package/docs/services/README.md +60 -0
  79. package/docs/services/analytics.md +268 -0
  80. package/docs/services/caching.md +175 -0
  81. package/docs/services/cookies.md +235 -0
  82. package/docs/services/crypto.md +209 -0
  83. package/docs/services/email.md +289 -0
  84. package/docs/services/environment.md +141 -0
  85. package/docs/services/ratelimit.md +174 -0
  86. package/docs/services/time.md +85 -0
  87. package/examples/basic/client/routes/analytics.tsx +13 -12
  88. package/examples/basic/server/models/SiteAnalytics.ts +29 -17
  89. package/examples/basic/server/routes/Analytics.ts +15 -17
  90. package/examples/basic/server/routes/UserId.ts +22 -0
  91. package/package.json +15 -11
  92. package/scripts/gen-toil-docs.mjs +24 -35
  93. package/server/auth/AuthController.ts +336 -0
  94. package/server/auth/AuthUser.ts +23 -0
  95. package/server/auth/index.ts +16 -0
  96. package/server/globals/auth.ts +31 -0
  97. package/server/globals/userid.ts +107 -0
  98. package/src/compiler/config.ts +13 -0
  99. package/src/compiler/docs.ts +16 -33
  100. package/src/compiler/generate.ts +6 -2
  101. package/src/compiler/index.ts +114 -6
  102. package/src/compiler/toil-docs.generated.ts +64 -22
  103. package/src/devserver/analytics/index.ts +10 -4
  104. package/src/devserver/db/database.ts +67 -1
  105. package/src/devserver/db/index.ts +1 -0
  106. package/src/devserver/db/types.ts +13 -0
  107. package/src/devserver/runtime/module.ts +7 -0
  108. package/test/analytics-dev.test.ts +2 -1
  109. package/test/devserver-database.test.ts +113 -0
  110. package/docs/auth-todo.md +0 -149
  111. package/docs/auth.md +0 -322
  112. package/docs/caching.md +0 -115
  113. package/docs/cli.md +0 -17
  114. package/docs/client.md +0 -39
  115. package/docs/cookies.md +0 -457
  116. package/docs/crypto.md +0 -130
  117. package/docs/daemon.md +0 -123
  118. package/docs/data.md +0 -131
  119. package/docs/derive.md +0 -159
  120. package/docs/email.md +0 -326
  121. package/docs/environment.md +0 -97
  122. package/docs/getting-started.md +0 -128
  123. package/docs/index.md +0 -30
  124. package/docs/ratelimit.md +0 -95
  125. package/docs/routing.md +0 -259
  126. package/docs/rpc.md +0 -149
  127. package/docs/server.md +0 -61
  128. package/docs/ssr.md +0 -632
  129. package/docs/streams.md +0 -178
  130. package/docs/styling.md +0 -22
  131. package/docs/tiers.md +0 -133
  132. package/docs/time.md +0 -43
@@ -0,0 +1,336 @@
1
+ import { Response, RouteContext } from 'toiljs/server/runtime';
2
+ import { DataReader, DataWriter } from 'data';
3
+
4
+ /**
5
+ * Built-in Toil PQ-Auth controller: the full post-quantum password-login API
6
+ * (`/auth/register|login/start|finish`) plus sessions (`/auth/me`, `/auth/logout`),
7
+ * mounted automatically when an app opts in with `server: { auth: true }` (or
8
+ * `import 'toiljs/server/auth'`). It is a framework-shipped SOURCE file the build
9
+ * APPENDS to the toilscript entry set, so its `@data`/`@database`/`@rest` decorators
10
+ * weave and its `@rest` class self-mounts at `/auth/*` — no hand-written boilerplate.
11
+ *
12
+ * The password never leaves the browser. The client blinds it through the
13
+ * server-keyed OPRF (precomputation-resistant keyed salt), stretches the OPRF
14
+ * output with Argon2id into an ML-DSA-44 keypair, and registers only the public
15
+ * key (+ a proof-of-possession). Login is a challenge-response that also runs an
16
+ * ML-KEM-768 key encapsulation: the server proves its identity by returning a
17
+ * confirmation tag only derivable from the decapsulated shared secret (mutual
18
+ * auth). See `server/globals/auth.ts` (the `AuthService` global) and the client
19
+ * half in `toiljs/client` (`Auth.register` / `Auth.login`).
20
+ *
21
+ * STORAGE: backed by ToilDB (`@database AuthDb`). Accounts are a `record`
22
+ * collection keyed by username; login challenges are a `record` consumed exactly
23
+ * once with `getDelete` (atomic fetch-and-delete). The dev server emulates these
24
+ * `env.data.*` host imports in process (so register -> login spans requests under
25
+ * `toiljs dev`); the production edge backs the SAME API with ScyllaDB.
26
+ *
27
+ * Wire: every body/response is binary (`DataWriter`/`DataReader`), never JSON.
28
+ *
29
+ * Tuning (audience, TTLs, Argon2id params, rate-limit tuples) is fixed here for
30
+ * now; config-driven overrides are a documented follow-up. The secret trio
31
+ * (`AUTH_SESSION_SECRET` / `AUTH_OPRF_SEED` / `AUTH_KEM_SK`) resolves lazily from
32
+ * the tenant env store with DEV fallbacks, so this runs with zero config.
33
+ */
34
+
35
+ // Demo-light Argon2id params (responsive in a browser tab). A real deployment
36
+ // uses >= 256 MiB / >= 3 iterations. The client derives against whatever it is
37
+ // handed, so this is the single source of truth.
38
+ const MEM_KIB: u32 = 32768; // 32 MiB
39
+ const ITERS: u32 = 2;
40
+ const PAR: u32 = 1;
41
+
42
+ const CHALLENGE_TTL_SECS: u64 = 120;
43
+ const SESSION_TTL_SECS: u64 = 3600;
44
+
45
+ // Resolved-and-cached per instance; the audience id (server config, never
46
+ // client-echoed). Read lazily (not at module top level) so the env host import
47
+ // runs during a dispatch, when the request host is bound — the same pattern
48
+ // AuthService uses for its secrets.
49
+ let __aud: string | null = null;
50
+ function aud(): string {
51
+ const cached = __aud;
52
+ if (cached != null) return cached;
53
+ const v = Environment.getSecure('TOIL_AUTH_AUDIENCE');
54
+ const resolved = v != null ? v : 'toil';
55
+ __aud = resolved;
56
+ return resolved;
57
+ }
58
+
59
+ /**
60
+ * The tenant domain the stable {@link ToilUserId} is scoped to: the configured
61
+ * `TOIL_AUTH_DOMAIN` if set, else the request's `Host` header, else `localhost`.
62
+ * It only needs to be stable per deployment (it is one of the SHA-256 inputs of
63
+ * the user id), so any robust per-deployment host source is fine.
64
+ */
65
+ function authDomain(ctx: RouteContext): string {
66
+ const configured = Environment.getSecure('TOIL_AUTH_DOMAIN');
67
+ if (configured != null) return configured;
68
+ const host = ctx.request.header('host');
69
+ if (host != null) return host;
70
+ return 'localhost';
71
+ }
72
+
73
+ function randomBytes(n: i32): Uint8Array {
74
+ const b = new Uint8Array(n);
75
+ crypto.getRandomValues(b);
76
+ return b;
77
+ }
78
+
79
+ function nowSecs(): u64 {
80
+ return <u64>(Date.now() / 1000);
81
+ }
82
+
83
+ /** One generic error on every failure path (anti-enumeration, anti-oracle). */
84
+ function fail(): Response {
85
+ return Response.text('auth: request failed\n', 401);
86
+ }
87
+
88
+ /**
89
+ * Deterministic per-user Argon2id salt (16 bytes). With the OPRF providing
90
+ * precomputation resistance, a public/deterministic salt is fine: it only needs
91
+ * to be unique per user (the OPRF output already differs per user). Making it
92
+ * deterministic means register and login agree with NO stored salt, and an
93
+ * unknown user yields the SAME stable salt as a known one would -- no
94
+ * enumeration oracle.
95
+ */
96
+ function deriveSalt(username: string): Uint8Array {
97
+ return crypto.sha256Text('toil-auth-salt-v1:' + username).slice(0, 16);
98
+ }
99
+
100
+ @data
101
+ class Username {
102
+ name: string = '';
103
+ constructor(name: string = '') {
104
+ this.name = name;
105
+ }
106
+ }
107
+
108
+ @data
109
+ class ChallengeId {
110
+ cid: Uint8Array = new Uint8Array(0);
111
+ constructor(cid: Uint8Array = new Uint8Array(0)) {
112
+ this.cid = cid;
113
+ }
114
+ }
115
+
116
+ @data
117
+ class AuthAccount {
118
+ username: string = '';
119
+ salt: Uint8Array = new Uint8Array(0);
120
+ publicKey: Uint8Array = new Uint8Array(0);
121
+ memKiB: u32 = 0;
122
+ iterations: u32 = 0;
123
+ parallelism: u32 = 0;
124
+ }
125
+
126
+ @data
127
+ class Challenge {
128
+ cid: Uint8Array = new Uint8Array(0);
129
+ username: string = '';
130
+ nonce: Uint8Array = new Uint8Array(0);
131
+ iat: u64 = 0;
132
+ exp: u64 = 0;
133
+ }
134
+
135
+ @database
136
+ class AuthDb {
137
+ @collection static accounts: Documents<Username, AuthAccount>;
138
+ @collection static challenges: Documents<ChallengeId, Challenge>;
139
+ }
140
+
141
+ @rest('auth')
142
+ class Auth {
143
+ /** POST /auth/register/start body: str(username) bytes(blinded)
144
+ * resp: u8(status=0) u32(mem) u32(iters) u32(par) bytes(salt) bytes(evaluated)
145
+ * No taken-oracle: always succeeds; register/finish rejects a duplicate. */
146
+ @ratelimit(RateLimit.SlidingWindow, 5, 60)
147
+ @post('/register/start')
148
+ public registerStart(ctx: RouteContext): Response {
149
+ const r = new DataReader(ctx.request.body);
150
+ const username = r.readString();
151
+ const blinded = r.readBytes();
152
+ if (!r.ok) return fail();
153
+ const evaluated = AuthService.oprfEvaluate(username, blinded);
154
+ if (evaluated.length != AuthService.OPRF_ELEMENT_LEN) return fail();
155
+
156
+ const w = new DataWriter();
157
+ w.writeU8(0);
158
+ w.writeU32(MEM_KIB);
159
+ w.writeU32(ITERS);
160
+ w.writeU32(PAR);
161
+ w.writeBytes(deriveSalt(username));
162
+ w.writeBytes(evaluated);
163
+ return Response.bytes(w.toBytes());
164
+ }
165
+
166
+ /** POST /auth/register/finish body: str(username) bytes(pk) bytes(regProof)
167
+ * resp: u8(status) -- 0 = ok, 1 = username already registered. Verifies
168
+ * proof-of-possession before storing the key. */
169
+ @ratelimit(RateLimit.SlidingWindow, 5, 60)
170
+ @post('/register/finish')
171
+ public registerFinish(ctx: RouteContext): Response {
172
+ const r = new DataReader(ctx.request.body);
173
+ const username = r.readString();
174
+ const pk = r.readBytes();
175
+ const proof = r.readBytes();
176
+ if (!r.ok) return fail();
177
+ if (pk.length != AuthService.PUBLIC_KEY_LEN) return fail();
178
+ // Already registered: a distinguishable status (not the generic 401) so the
179
+ // client can say "username taken, log in instead" rather than a blank error.
180
+ if (AuthDb.accounts.exists(new Username(username))) {
181
+ return Response.bytes(new DataWriter().writeU8(1).toBytes());
182
+ }
183
+
184
+ // Proof-of-possession: the client signed buildRegisterMessage with the
185
+ // matching secret key, so we confirm it actually holds it.
186
+ const regMsg = AuthService.buildRegisterMessage(username, pk);
187
+ if (!AuthService.verifyRegister(pk, regMsg, proof)) return fail();
188
+
189
+ const a = new AuthAccount();
190
+ a.username = username;
191
+ a.salt = deriveSalt(username);
192
+ a.publicKey = pk;
193
+ a.memKiB = MEM_KIB;
194
+ a.iterations = ITERS;
195
+ a.parallelism = PAR;
196
+ // create-if-absent: a racing duplicate registration loses here, not above.
197
+ if (!AuthDb.accounts.create(new Username(username), a)) {
198
+ return Response.bytes(new DataWriter().writeU8(1).toBytes());
199
+ }
200
+ return Response.bytes(new DataWriter().writeU8(0).toBytes());
201
+ }
202
+
203
+ /** POST /auth/login/start body: str(username) bytes(blinded)
204
+ * resp: bytes(cid) str(aud) u32(mem) u32(iters) u32(par) bytes(salt)
205
+ * bytes(nonce) u64(iat) u64(exp) bytes(evaluated)
206
+ * Anti-enumeration: ALWAYS OPRF-evaluates (real or decoy key from the same
207
+ * seed+username), returns a deterministic per-user salt + constant params,
208
+ * and a fresh challenge -- a known and an unknown user are indistinguishable. */
209
+ @ratelimit(RateLimit.SlidingWindow, 5, 60)
210
+ @post('/login/start')
211
+ public loginStart(ctx: RouteContext): Response {
212
+ const r = new DataReader(ctx.request.body);
213
+ const username = r.readString();
214
+ const blinded = r.readBytes();
215
+ if (!r.ok) return fail();
216
+ const evaluated = AuthService.oprfEvaluate(username, blinded);
217
+ if (evaluated.length != AuthService.OPRF_ELEMENT_LEN) return fail();
218
+
219
+ const known = AuthDb.accounts.exists(new Username(username));
220
+ const cid = randomBytes(16);
221
+ const nonce = randomBytes(32);
222
+ const iat = nowSecs();
223
+ const exp = iat + CHALLENGE_TTL_SECS;
224
+
225
+ // Persist only for a real account; the response is identical either way,
226
+ // and login/finish for an unknown user fails generically at consume.
227
+ if (known) {
228
+ const c = new Challenge();
229
+ c.cid = cid;
230
+ c.username = username;
231
+ c.nonce = nonce;
232
+ c.iat = iat;
233
+ c.exp = exp;
234
+ AuthDb.challenges.create(new ChallengeId(cid), c);
235
+ }
236
+
237
+ const w = new DataWriter();
238
+ w.writeBytes(cid);
239
+ w.writeString(aud());
240
+ w.writeU32(MEM_KIB);
241
+ w.writeU32(ITERS);
242
+ w.writeU32(PAR);
243
+ w.writeBytes(deriveSalt(username));
244
+ w.writeBytes(nonce);
245
+ w.writeU64(iat);
246
+ w.writeU64(exp);
247
+ w.writeBytes(evaluated);
248
+ return Response.bytes(w.toBytes());
249
+ }
250
+
251
+ /** POST /auth/login/finish body: bytes(cid) bytes(ct) bytes(sig)
252
+ * resp: u8(status) [+ bytes(sessionToken) bytes(serverConfirm)] + Set-Cookie */
253
+ @ratelimit(RateLimit.SlidingWindow, 5, 60)
254
+ @post('/login/finish')
255
+ public loginFinish(ctx: RouteContext): Response {
256
+ const r = new DataReader(ctx.request.body);
257
+ const cid = r.readBytes();
258
+ const ct = r.readBytes();
259
+ const sig = r.readBytes();
260
+ if (!r.ok) return fail();
261
+
262
+ // 1. CONSUME FIRST: atomic fetch-and-delete. Unknown/used/expired => fail.
263
+ const ch = AuthDb.challenges.getDelete(new ChallengeId(cid));
264
+ if (ch == null) return fail();
265
+ if (nowSecs() >= ch.exp) return fail();
266
+
267
+ // 2. Rebuild the message from OUR stored values + the client's ct (and
268
+ // the bound params + server key id), load the account key, verify.
269
+ const acct = AuthDb.accounts.get(new Username(ch.username));
270
+ if (acct == null) return fail();
271
+ const message = AuthService.buildLoginMessage(
272
+ ch.username,
273
+ aud(),
274
+ cid,
275
+ ch.nonce,
276
+ ch.iat,
277
+ ch.exp,
278
+ ct,
279
+ MEM_KIB,
280
+ ITERS,
281
+ PAR,
282
+ AuthService.serverKemKeyId(),
283
+ );
284
+ if (!AuthService.verifyLogin(acct.publicKey, message, sig)) return fail();
285
+
286
+ // 3. Decapsulate (proves WE hold the KEM key), derive the session key K
287
+ // bound to the transcript, and build the confirmation tag the client
288
+ // verifies for mutual auth.
289
+ const sharedSecret = AuthService.mlkemDecapsulate(ct);
290
+ if (sharedSecret.length != AuthService.SHARED_SECRET_LEN) return fail();
291
+ const transcriptHash = AuthService.sha256(message);
292
+ const sessionKey = AuthService.deriveSessionKey(sharedSecret, transcriptHash);
293
+ const confirm = AuthService.serverConfirmTag(sessionKey, transcriptHash);
294
+
295
+ // 4. Success: mint the session for whatever `@user` this program has (built-in or the app's own
296
+ // extended one). `__toilEncodeAuthUser` is injected by `--authUser`: it constructs the `@user`
297
+ // (app fields at their defaults), sets the reserved identity, and encodes it. The stable
298
+ // ToilUserId is derived from the login public key + username + tenant domain.
299
+ const domain = authDomain(ctx);
300
+ const toilUserId = ToilUserId.derive(acct.publicKey, ch.username, domain).toBytes();
301
+ const userData = __toilEncodeAuthUser(toilUserId, ch.username);
302
+ const w = new DataWriter();
303
+ w.writeU8(0);
304
+ w.writeBytes(userData); // opaque session token (the readable user payload)
305
+ w.writeBytes(confirm);
306
+ const resp = Response.bytes(w.toBytes());
307
+ resp.setCookie(AuthService.mintSession(userData, SESSION_TTL_SECS));
308
+ resp.setCookie(AuthService.userCookie(userData, SESSION_TTL_SECS));
309
+ return resp;
310
+ }
311
+
312
+ /** GET /auth/me (@auth: 401 without a valid session) -> the typed user
313
+ * (bytes(toilUserId) str(username)). `AuthService.getUser()` is auto-typed
314
+ * to the built-in `@user` with no type argument. */
315
+ @auth
316
+ @get('/me')
317
+ public me(_ctx: RouteContext): Response {
318
+ const u = AuthService.getUser();
319
+ if (u == null) return Response.text('no session\n', 401);
320
+ const w = new DataWriter();
321
+ w.writeBytes(u.toilUserId);
322
+ w.writeString(u.username);
323
+ return Response.bytes(w.toBytes());
324
+ }
325
+
326
+ /** POST /auth/logout (@auth) -> clears both the signed session and the
327
+ * readable companion cookie. */
328
+ @auth
329
+ @post('/logout')
330
+ public logout(_ctx: RouteContext): Response {
331
+ const resp = Response.text('bye\n', 200);
332
+ resp.setCookie(AuthService.clearSession());
333
+ resp.setCookie(AuthService.clearUserCookie());
334
+ return resp;
335
+ }
336
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The framework's built-in authenticated-user shape for `server.auth` (the zero-config `/auth/*` PQ-login
3
+ * controller). Shipped as a SOURCE file the `server.auth` build APPENDS to the toilscript entry set in
4
+ * BUILTIN mode (the app declares no `@user` of its own), so its `@user` decorator weaves as a user entry.
5
+ *
6
+ * The class is deliberately EMPTY. The build runs with `--authUser`, which injects the reserved
7
+ * `toilUserId` + `username` identity fields (toilUserId FIRST, so `AuthService.userId()` reads it straight
8
+ * from the session codec) and a shape-agnostic `__toilEncodeAuthUser(id, username)` global the controller
9
+ * uses to mint a session. This keeps the wire layout identical whether the user is this built-in shape or an
10
+ * app's own.
11
+ *
12
+ * EXTEND mode: an app that wants a richer authenticated user just declares its OWN `@user` (with extra
13
+ * fields like `admin` or `tenant`). The build detects it, extends THAT class with the same reserved fields,
14
+ * and does not append this file. Either way there is exactly one `@user` per program.
15
+ *
16
+ * Named `SessionUser`, NOT `AuthUser`: the toilscript `@user` transform injects a `@global class AuthUser
17
+ * extends <thisClass>`, so naming this class `AuthUser` would self-extend (a compile error).
18
+ */
19
+
20
+ // @user: the authenticated-user shape. The reserved `toilUserId` + `username` fields are INJECTED by the
21
+ // build (`--authUser`); do not declare them here.
22
+ @user
23
+ class SessionUser {}
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Escape-hatch marker for the built-in auth surface. A bare
3
+ * `import 'toiljs/server/auth';` in `server/main.ts` is the lighter-weight opt-in
4
+ * (an alternative to the canonical `server: { auth: true }` config flag): the
5
+ * toiljs build DETECTS this import and appends the shipped `@user` shape +
6
+ * `@rest('auth')` controller to the toilscript ENTRY set, so their decorators
7
+ * weave and the controller self-mounts at `/auth/*`.
8
+ *
9
+ * This module is intentionally EMPTY (a pure marker). Framework-shipped
10
+ * decorator sources under `node_modules` only weave when handed to toilscript as
11
+ * explicit ENTRIES; a transitive `import` resolves them under the `~lib/` LIBRARY
12
+ * prefix, where `@data`/`@rest`/`@user` do NOT weave — and, worse, would then
13
+ * collide with the entry-injected copies as duplicate modules. So the barrel must
14
+ * NOT itself import the controller; the build does the entry injection instead.
15
+ */
16
+ export {};
@@ -278,6 +278,37 @@ export namespace AuthService {
278
278
  return bytes == null ? null : __toilDecodeAuthUser(bytes);
279
279
  }
280
280
 
281
+ /**
282
+ * The stable, tenant-scoped {@link ToilUserId} of the current request's
283
+ * authenticated user, or `null` if there is no valid session. Recovered from
284
+ * the session codec directly (the built-in `server.auth` `@user` shape puts
285
+ * the 32-byte id FIRST, see `server/auth/AuthUser.ts`), so this stays generic
286
+ * — it does NOT depend on the app's injected `@user` type carrying a
287
+ * `toilUserId` field — while matching the built-in layout. An app whose custom
288
+ * `@user` does not lead with the id should read `getUser()` instead.
289
+ *
290
+ * NULL CHECK: `ToilUserId` overloads `==` for value equality, so `userId() ==
291
+ * null` does NOT type-check. Gate on {@link hasSession} first (then the result
292
+ * is non-null), e.g. `if (AuthService.hasSession()) { const id =
293
+ * AuthService.userId()!; ... }`.
294
+ */
295
+ export function userId(): ToilUserId | null {
296
+ const bytes = getSessionBytes();
297
+ if (bytes == null) return null;
298
+ return ToilUserId.fromBytes(new DataReader(bytes).readBytes());
299
+ }
300
+
301
+ /**
302
+ * A no-op discoverability marker for the built-in auth surface. The REAL
303
+ * opt-in is `server: { auth: true }` in `toil.config.ts` (or a bare
304
+ * `import 'toiljs/server/auth'`), which appends the shipped `@user` shape +
305
+ * `@rest('auth')` controller to the build's toilscript entry set so they weave
306
+ * and self-mount at `/auth/*`. `enable()` exists so `AuthService.enable()` in
307
+ * `main.ts` type-checks and the surface is discoverable via this namespace; it
308
+ * mounts nothing on its own.
309
+ */
310
+ export function enable(): void {}
311
+
281
312
  /**
282
313
  * Mint a signed session cookie carrying `userData` (the `@user` codec bytes,
283
314
  * i.e. `myUser.encode()`), valid for `ttlSecs`. Set it on the response with
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `ToilUserId` — the stable, tenant-scoped 256-bit user identity.
3
+ *
4
+ * Derived once as `sha256(mldsaPublicKey || identifier || domain)`, so the SAME user (same ML-DSA login
5
+ * public key + the same email/username) on the SAME tenant domain always maps to the SAME id — independent
6
+ * of session, cookie, or device. It is opaque and one-way (a hash), so it is safe to store, log, or use as
7
+ * a stable database key without leaking the underlying key or address.
8
+ *
9
+ * Backed by four 64-bit words (a 256-bit value), NOT a byte array, so equality is four word compares with
10
+ * early-out — no allocation, no byte loop. `==` / `!=` are overloaded to value equality, and `equals()` is
11
+ * the explicit form. (`===` in AssemblyScript is reference identity and is NOT overloadable; use `==` for
12
+ * value equality.)
13
+ *
14
+ * A global (no import), like `crypto` / `AuthService`. `AuthService` mints the current user's id from the
15
+ * login key + identifier + the request's tenant domain, so `@auth` handlers can key on a stable user.
16
+ */
17
+ export class ToilUserId {
18
+ constructor(
19
+ public w0: u64 = 0,
20
+ public w1: u64 = 0,
21
+ public w2: u64 = 0,
22
+ public w3: u64 = 0,
23
+ ) {}
24
+
25
+ /**
26
+ * Derive the id from the user's ML-DSA public key, their identifier (email or username), and the
27
+ * tenant `domain` (the site on dacely). Deterministic: identical inputs → identical id. The three
28
+ * inputs are concatenated in order and SHA-256'd; length-framing is unnecessary because the ML-DSA
29
+ * public key is a FIXED length (1312 bytes for ML-DSA-44), so the boundary before `identifier` is
30
+ * unambiguous, and `domain` is the trusted tail supplied by the host, not the user.
31
+ */
32
+ static derive(mldsaPublicKey: Uint8Array, identifier: string, domain: string): ToilUserId {
33
+ const id = Uint8Array.wrap(String.UTF8.encode(identifier));
34
+ const dom = Uint8Array.wrap(String.UTF8.encode(domain));
35
+ const buf = new Uint8Array(mldsaPublicKey.length + id.length + dom.length);
36
+ buf.set(mldsaPublicKey, 0);
37
+ buf.set(id, mldsaPublicKey.length);
38
+ buf.set(dom, mldsaPublicKey.length + id.length);
39
+ return ToilUserId.fromBytes(crypto.sha256(buf));
40
+ }
41
+
42
+ /** Build from a 32-byte digest. Bytes past 32 are ignored; a shorter array zero-pads the tail. */
43
+ static fromBytes(b: Uint8Array): ToilUserId {
44
+ if (b.length >= 32) {
45
+ const p = b.dataStart;
46
+ return new ToilUserId(load<u64>(p, 0), load<u64>(p, 8), load<u64>(p, 16), load<u64>(p, 24));
47
+ }
48
+ const tmp = new Uint8Array(32);
49
+ tmp.set(b, 0);
50
+ const p = tmp.dataStart;
51
+ return new ToilUserId(load<u64>(p, 0), load<u64>(p, 8), load<u64>(p, 16), load<u64>(p, 24));
52
+ }
53
+
54
+ /** The 32-byte identity (the original SHA-256 digest bytes; wasm is little-endian, so this round-trips). */
55
+ toBytes(): Uint8Array {
56
+ const out = new Uint8Array(32);
57
+ const p = out.dataStart;
58
+ store<u64>(p, this.w0, 0);
59
+ store<u64>(p, this.w1, 8);
60
+ store<u64>(p, this.w2, 16);
61
+ store<u64>(p, this.w3, 24);
62
+ return out;
63
+ }
64
+
65
+ /** Lowercase hex, 64 chars. */
66
+ toHex(): string {
67
+ const b = this.toBytes();
68
+ const HEX = '0123456789abcdef';
69
+ let s = '';
70
+ for (let i = 0; i < 32; i++) {
71
+ const v = <i32>b[i];
72
+ s += HEX.charAt(v >> 4);
73
+ s += HEX.charAt(v & 0xf);
74
+ }
75
+ return s;
76
+ }
77
+
78
+ /** True when unset (the all-zero id, e.g. an absent / anonymous user). */
79
+ isZero(): bool {
80
+ return (this.w0 | this.w1 | this.w2 | this.w3) == 0;
81
+ }
82
+
83
+ /** Value equality: four u64 compares, short-circuiting. O(1), no allocation. */
84
+ @inline
85
+ equals(other: ToilUserId): bool {
86
+ return (
87
+ this.w0 == other.w0 &&
88
+ this.w1 == other.w1 &&
89
+ this.w2 == other.w2 &&
90
+ this.w3 == other.w3
91
+ );
92
+ }
93
+
94
+ /** `a == b` value equality (overloaded). */
95
+ @operator('==')
96
+ @inline
97
+ eq(other: ToilUserId): bool {
98
+ return this.equals(other);
99
+ }
100
+
101
+ /** `a != b` value inequality (overloaded). */
102
+ @operator('!=')
103
+ @inline
104
+ ne(other: ToilUserId): bool {
105
+ return !this.equals(other);
106
+ }
107
+ }
@@ -140,6 +140,16 @@ export interface ServerConfig {
140
140
  readonly email?: EmailBackendConfig;
141
141
  /** Which layer the dev process emulates. Default `all`. */
142
142
  readonly nodeMode?: DevNodeMode;
143
+ /**
144
+ * Built-in auth. `true` opts into the framework's post-quantum login: the
145
+ * build appends a shipped `@rest('auth')` controller + its `@user` shape to
146
+ * the toilscript entry set, so the app gets the full `/auth/register|login`
147
+ * API + sessions (`/auth/me`, `/auth/logout`) with no hand-written boilerplate.
148
+ * Default `false`. (The escape hatch `import 'toiljs/server/auth'` in
149
+ * `server/main.ts` does the same without this flag.) An app that opts in must
150
+ * NOT declare its own `@user` — the built-in owns the single per-program one.
151
+ */
152
+ readonly auth?: boolean;
143
153
  /** Daemon (L4) config mirror (dev / self-host). */
144
154
  readonly daemon?: DaemonConfig;
145
155
  /**
@@ -202,6 +212,8 @@ export interface ResolvedToilConfig {
202
212
  readonly email: EmailBackendConfig | null;
203
213
  /** Which layer the dev process emulates (dev / self-host). Default `all`. */
204
214
  readonly nodeMode: DevNodeMode;
215
+ /** Whether the built-in `/auth/*` PQ-login controller is compiled + mounted. */
216
+ readonly auth: boolean;
205
217
  /** Daemon (L4) config mirror (dev / self-host), every field resolved. */
206
218
  readonly daemon: ResolvedDaemonConfig;
207
219
  /** Self-host HTTP worker count for `toiljs start`. */
@@ -279,6 +291,7 @@ export async function loadConfig(
279
291
  seo: client.seo ?? null,
280
292
  email: user.server?.email ?? null,
281
293
  nodeMode: resolveNodeMode(user.server?.nodeMode),
294
+ auth: user.server?.auth === true,
282
295
  daemon: resolveDaemonConfig(user.server?.daemon),
283
296
  threads: resolveThreads(user.server?.threads),
284
297
  runtimePath: resolveRuntimePath(),
@@ -10,45 +10,23 @@ import { TOIL_DOCS } from './toil-docs.generated.js';
10
10
 
11
11
  export { TOIL_DOCS };
12
12
 
13
- /**
14
- * One-line summaries for the pointer files' bullet list, keyed by `.toil/docs/` filename.
15
- * A doc with no entry here still appears in the list (using its `# ` heading as the label),
16
- * so adding a `docs/*.md` is picked up automatically without re-listing it by hand.
17
- */
18
- const DOC_SUMMARIES: Record<string, string> = {
19
- 'index.md': 'overview and project layout',
20
- 'getting-started.md': 'the two halves, build/dev, and the request lifecycle',
21
- 'routing.md': 'file-based routing, nested layouts, loading / error files',
22
- 'client.md': 'the `Toil` global, Link / NavLink, router hooks',
23
- 'styling.md': 'CSS / Sass / Less / Stylus / Tailwind (via `toiljs configure`)',
24
- 'server.md': 'the toilscript server target, `@data` / `@remote` / `@rest`',
25
- 'ssr.md': 'server-side rendering (`ssr = true`, hole markers, the server `render`)',
26
- 'rpc.md': '`@service` / `@remote` and the generated typed client',
27
- 'data.md': 'the `@data` decorator and the binary codec',
28
- 'caching.md': 'the `@cache` decorator and `Response.cache(...)`',
29
- 'ratelimit.md': 'the `@ratelimit` decorator',
30
- 'auth.md': '`@auth` guards, `@user`, sessions, the client half',
31
- 'environment.md': '`Environment.get` / `getSecure` config + secrets',
32
- 'email.md': '`EmailService`, templates, provider config',
33
- 'cookies.md': 'the `Cookie` builder, `SecureCookies`, signing / encryption',
34
- 'crypto.md': 'the synchronous `crypto` global and `crypto.subtle`',
35
- 'time.md': '`Time.nowMillis()` / `Time.nowSeconds()`',
36
- 'cli.md': 'toiljs CLI commands',
37
- };
38
-
39
- /** First-level `# ` heading of a Markdown doc, used as a fallback bullet label. */
13
+ /** First-level `# ` heading of a Markdown doc, used as the bullet label. */
40
14
  function docTitle(content: string): string {
41
15
  const m = content.match(/^#\s+(.+)$/m);
42
16
  return m ? m[1].trim() : '';
43
17
  }
44
18
 
45
19
  /**
46
- * The pointer files' bullet list, derived from the generated `TOIL_DOCS` map so it never
47
- * drifts from the docs actually written into `.toil/docs/` (no second hand-kept list).
20
+ * The pointer files' navigation list, derived from the generated `TOIL_DOCS` map so it never
21
+ * drifts from the docs actually written into `.toil/docs/` (no second hand-kept list). We list
22
+ * the section landing pages (each folder's `README.md`, plus the root one) rather than all
23
+ * fifty-plus pages; every landing links onward to its own topic pages, and
24
+ * `.toil/docs/README.md` is the root map.
48
25
  */
49
26
  const DOC_POINTERS = Object.keys(TOIL_DOCS)
27
+ .filter((name) => name === 'README.md' || name.endsWith('/README.md'))
50
28
  .map((name) => {
51
- const summary = DOC_SUMMARIES[name] ?? docTitle(TOIL_DOCS[name]);
29
+ const summary = docTitle(TOIL_DOCS[name]);
52
30
  return `- \`.toil/docs/${name}\`${summary ? `, ${summary}` : ''}`;
53
31
  })
54
32
  .join('\n');
@@ -59,8 +37,9 @@ const POINTER_BODY = `# toiljs, AI assistant guide
59
37
  This is a **toiljs** project, a full-stack React framework (React + Vite client, file-based
60
38
  routing, and a toilscript→WebAssembly server).
61
39
 
62
- **Before editing this project, read the generated documentation in \`.toil/docs/\`.** It describes
63
- the conventions you must follow:
40
+ **Before editing this project, read the generated documentation in \`.toil/docs/\`, starting at
41
+ \`.toil/docs/README.md\`.** It describes the conventions you must follow. The sections, each a
42
+ folder whose \`README.md\` links onward to its topic pages:
64
43
 
65
44
  ${DOC_POINTERS}
66
45
 
@@ -113,6 +92,10 @@ export function writeDocs(toilDir: string): void {
113
92
  const dir = path.join(toilDir, 'docs');
114
93
  fs.mkdirSync(dir, { recursive: true });
115
94
  for (const [name, content] of Object.entries(TOIL_DOCS)) {
116
- fs.writeFileSync(path.join(dir, name), content);
95
+ const dest = path.join(dir, name);
96
+ // Doc keys can be nested (e.g. "auth/configuration.md"), so ensure the
97
+ // file's parent directory exists before writing it.
98
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
99
+ fs.writeFileSync(dest, content);
117
100
  }
118
101
  }
@@ -125,8 +125,12 @@ export const TOIL_SERVER_ENV_DTS =
125
125
  `declare class TwoFactorIssue { code: string; token: string; constructor(code: string, token: string); }\n` +
126
126
  `declare class TwoFactorChallenge { token: string; status: EmailStatus; constructor(token: string, status: EmailStatus); }\n` +
127
127
  `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` +
128
- `declare namespace AuthService { const SESSION_COOKIE: string; const USER_COOKIE: string; const LOGIN_CONTEXT: string; const PUBLIC_KEY_LEN: i32; const SIGNATURE_LEN: i32; const DEFAULT_SESSION_TTL_SECS: u64; function setSecret(secret: Uint8Array): void; function hasSession(): bool; function getSessionBytes(): Uint8Array | null; function getUser(): __ToilAuthUser | null; function mintSession(userData: Uint8Array, ttlSecs?: u64): Cookie; function clearSession(): Cookie; function userCookie(userData: Uint8Array, ttlSecs?: u64): Cookie; function clearUserCookie(): Cookie; function buildLoginMessage(sub: string, aud: string, cid: Uint8Array, nonce: Uint8Array, iat: u64, exp: u64, ciphertext: Uint8Array, memKiB: u32, iterations: u32, parallelism: u32, serverKemKeyId: Uint8Array): Uint8Array; function verifyLogin(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): bool; const KEM_CIPHERTEXT_LEN: i32; const KEM_SECRET_KEY_LEN: i32; const KEM_PUBLIC_KEY_LEN: i32; const SHARED_SECRET_LEN: i32; const OPRF_ELEMENT_LEN: i32; const OPRF_SEED_LEN: i32; const SESSION_KEY_LABEL: string; const SERVER_CONFIRM_LABEL: string; function setOprfSeed(seed: Uint8Array): void; function setServerKemSecretKey(secretKey: Uint8Array): void; function setServerKemPublicKey(publicKey: Uint8Array): void; function serverKemKeyId(): Uint8Array; function oprfEvaluate(username: string, blinded: Uint8Array): Uint8Array; function mlkemDecapsulate(ciphertext: Uint8Array): Uint8Array; function sha256(data: Uint8Array): Uint8Array; function deriveSessionKey(sharedSecret: Uint8Array, transcriptHash: Uint8Array): Uint8Array; function serverConfirmTag(sessionKey: Uint8Array, transcriptHash: Uint8Array): Uint8Array; const REGISTER_CONTEXT: string; function buildRegisterMessage(username: string, publicKey: Uint8Array): Uint8Array; function verifyRegister(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): bool; }\n` +
129
- `interface __ToilAuthUser {}\n`;
128
+ `declare namespace AuthService { const SESSION_COOKIE: string; const USER_COOKIE: string; const LOGIN_CONTEXT: string; const PUBLIC_KEY_LEN: i32; const SIGNATURE_LEN: i32; const DEFAULT_SESSION_TTL_SECS: u64; function setSecret(secret: Uint8Array): void; function hasSession(): bool; function getSessionBytes(): Uint8Array | null; function getUser(): __ToilAuthUser | null; function userId(): ToilUserId | null; function enable(): void; function mintSession(userData: Uint8Array, ttlSecs?: u64): Cookie; function clearSession(): Cookie; function userCookie(userData: Uint8Array, ttlSecs?: u64): Cookie; function clearUserCookie(): Cookie; function buildLoginMessage(sub: string, aud: string, cid: Uint8Array, nonce: Uint8Array, iat: u64, exp: u64, ciphertext: Uint8Array, memKiB: u32, iterations: u32, parallelism: u32, serverKemKeyId: Uint8Array): Uint8Array; function verifyLogin(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): bool; const KEM_CIPHERTEXT_LEN: i32; const KEM_SECRET_KEY_LEN: i32; const KEM_PUBLIC_KEY_LEN: i32; const SHARED_SECRET_LEN: i32; const OPRF_ELEMENT_LEN: i32; const OPRF_SEED_LEN: i32; const SESSION_KEY_LABEL: string; const SERVER_CONFIRM_LABEL: string; function setOprfSeed(seed: Uint8Array): void; function setServerKemSecretKey(secretKey: Uint8Array): void; function setServerKemPublicKey(publicKey: Uint8Array): void; function serverKemKeyId(): Uint8Array; function oprfEvaluate(username: string, blinded: Uint8Array): Uint8Array; function mlkemDecapsulate(ciphertext: Uint8Array): Uint8Array; function sha256(data: Uint8Array): Uint8Array; function deriveSessionKey(sharedSecret: Uint8Array, transcriptHash: Uint8Array): Uint8Array; function serverConfirmTag(sessionKey: Uint8Array, transcriptHash: Uint8Array): Uint8Array; const REGISTER_CONTEXT: string; function buildRegisterMessage(username: string, publicKey: Uint8Array): Uint8Array; function verifyRegister(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): bool; }\n` +
129
+ `interface __ToilAuthUser {}\n` +
130
+ `declare class ToilUserId { w0: u64; w1: u64; w2: u64; w3: u64; constructor(w0?: u64, w1?: u64, w2?: u64, w3?: u64); static derive(mldsaPublicKey: Uint8Array, identifier: string, domain: string): ToilUserId; static fromBytes(b: Uint8Array): ToilUserId; toBytes(): Uint8Array; toHex(): string; isZero(): bool; equals(other: ToilUserId): bool; }\n` +
131
+ // Built-in auth (server.auth): injected by --authUser. Mints a session for the program's single @user
132
+ // (built-in or the app's extended one): constructs it, sets the reserved identity, encodes it.
133
+ `declare function __toilEncodeAuthUser(toilUserId: Uint8Array, username: string): Uint8Array;\n`;
130
134
 
131
135
  /**
132
136
  * Returns a `./`-prefixed, **extensionless** POSIX module specifier from `.toil` to `abs`, for use