toiljs 0.0.84 → 0.0.86

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 (36) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/build/cli/.tsbuildinfo +1 -1
  3. package/build/compiler/.tsbuildinfo +1 -1
  4. package/build/compiler/config.d.ts +2 -0
  5. package/build/compiler/config.js +1 -0
  6. package/build/compiler/generate.js +3 -2
  7. package/build/compiler/index.d.ts +1 -1
  8. package/build/compiler/index.js +44 -5
  9. package/build/compiler/toil-docs.generated.js +6 -1
  10. package/build/devserver/.tsbuildinfo +1 -1
  11. package/build/devserver/analytics/index.js +113 -33
  12. package/build/devserver/runtime/module.js +1 -0
  13. package/docs/auth/configuration.md +94 -0
  14. package/docs/auth/extending.md +170 -0
  15. package/docs/auth/how-it-works.md +138 -0
  16. package/docs/auth/index.md +102 -0
  17. package/docs/auth/usage.md +188 -0
  18. package/docs/auth.md +6 -0
  19. package/examples/basic/client/routes/analytics.tsx +164 -26
  20. package/examples/basic/server/models/SiteAnalytics.ts +139 -11
  21. package/examples/basic/server/routes/Analytics.ts +89 -13
  22. package/examples/basic/server/routes/UserId.ts +22 -0
  23. package/package.json +5 -1
  24. package/scripts/gen-toil-docs.mjs +15 -6
  25. package/server/auth/AuthController.ts +335 -0
  26. package/server/auth/AuthUser.ts +49 -0
  27. package/server/auth/index.ts +16 -0
  28. package/server/globals/auth.ts +31 -0
  29. package/server/globals/userid.ts +107 -0
  30. package/src/compiler/config.ts +13 -0
  31. package/src/compiler/generate.ts +3 -2
  32. package/src/compiler/index.ts +74 -5
  33. package/src/compiler/toil-docs.generated.ts +6 -1
  34. package/src/devserver/analytics/index.ts +139 -38
  35. package/src/devserver/runtime/module.ts +1 -0
  36. package/test/analytics-dev.test.ts +76 -0
@@ -1,4 +1,6 @@
1
- import { SiteAnalytics } from '../models/SiteAnalytics';
1
+ import { RouteContext } from 'toiljs/server/runtime';
2
+
3
+ import { SeriesData, SiteAnalytics } from '../models/SiteAnalytics';
2
4
 
3
5
  /**
4
6
  * This site's own analytics, mounted at `/analytics`. On the client:
@@ -14,17 +16,91 @@ import { SiteAnalytics } from '../models/SiteAnalytics';
14
16
  class AnalyticsRoutes {
15
17
  @get('/')
16
18
  public self(): SiteAnalytics {
17
- const s = Analytics.self();
18
- const r = new SiteAnalytics();
19
- r.requests = s.lifetime.has('requests') ? s.lifetime.get('requests') : 0;
20
- r.bytesServed = s.lifetime.has('bytes_served') ? s.lifetime.get('bytes_served') : 0;
21
- r.status2xx = s.lifetime.has('status_2xx') ? s.lifetime.get('status_2xx') : 0;
22
- r.wasmDispatches = s.lifetime.has('wasm_dispatches') ? s.lifetime.get('wasm_dispatches') : 0;
23
- r.dbOps = s.lifetime.has('db_ops') ? s.lifetime.get('db_ops') : 0;
24
- r.reqMinuteUsed = s.reqMinuteUsed;
25
- r.reqMinuteCap = <i64>s.reqMinuteCap;
26
- r.reqDayUsed = s.reqDayUsed;
27
- r.reqDayCap = <i64>s.reqDayCap;
28
- return r;
19
+ const stats = Analytics.self();
20
+ const out = new SiteAnalytics();
21
+
22
+ // Requests / L1 edge (all-time totals). Guest getters are u64, model fields are u64: no casts.
23
+ out.totalRequests = stats.requests;
24
+ out.totalBytesOutL1 = stats.bytesOutL1;
25
+ out.totalBytesInL1 = stats.bytesInL1;
26
+ out.totalStatus2xx = stats.status2xx;
27
+ out.totalStatus3xx = stats.status3xx;
28
+ out.totalStatus4xx = stats.status4xx;
29
+ out.totalStatus5xx = stats.status5xx;
30
+ out.totalStaticHits = stats.staticHits;
31
+ out.totalWasmDispatches = stats.wasmDispatches;
32
+ out.totalExecutorFullRejects = stats.executorFullRejects;
33
+ out.totalUnknownHostRejects = stats.unknownHostRejects;
34
+ out.totalRateLimitedRejects = stats.rateLimitedRejects;
35
+ out.totalGasUsed = stats.gasUsed;
36
+
37
+ // Database (all-time totals).
38
+ out.totalDbOps = stats.dbOps;
39
+ out.totalDbReads = stats.dbReads;
40
+ out.totalDbWrites = stats.dbWrites;
41
+ out.totalDbErrors = stats.dbErrors;
42
+ out.totalDbLatencyNsSum = stats.dbLatencyNsSum;
43
+
44
+ // Streams / WebTransport (all-time totals).
45
+ out.totalStreamAccepts = stats.streamAccepts;
46
+ out.totalStreamRejectWrongNode = stats.streamRejectWrongNode;
47
+ out.totalStreamRejectCapacity = stats.streamRejectCapacity;
48
+ out.totalStreamRejectArtifact = stats.streamRejectArtifact;
49
+ out.totalStreamRejectGuest = stats.streamRejectGuest;
50
+ out.totalStreamTraps = stats.streamTraps;
51
+ out.totalStreamIdleTimeouts = stats.streamIdleTimeouts;
52
+ out.totalStreamBytesIn = stats.streamBytesIn;
53
+ out.totalStreamBytesOut = stats.streamBytesOut;
54
+ out.totalStreamBackpressureEvents = stats.streamBackpressureEvents;
55
+ out.totalStreamCloses = stats.streamCloses;
56
+ out.totalStreamDisconnects = stats.streamDisconnects;
57
+
58
+ // Daemons / L4 (all-time totals).
59
+ out.totalDaemonStarts = stats.daemonStarts;
60
+ out.totalDaemonStartFailures = stats.daemonStartFailures;
61
+ out.totalDaemonTicksFired = stats.daemonTicksFired;
62
+ out.totalDaemonTicksSkippedNotLeader = stats.daemonTicksSkippedNotLeader;
63
+ out.totalDaemonTicksFailed = stats.daemonTicksFailed;
64
+ out.totalDaemonLeaderAcquires = stats.daemonLeaderAcquires;
65
+ out.totalDaemonLeaderFenced = stats.daemonLeaderFenced;
66
+ out.totalDaemonHttpCallAttempts = stats.daemonHttpCallAttempts;
67
+ out.totalDaemonHttpCallFailures = stats.daemonHttpCallFailures;
68
+
69
+ // Memory / email / cache (all-time totals) + the derived cache-hit ratio.
70
+ out.totalMemGrownBytes = stats.memGrownBytes;
71
+ out.totalEmails = stats.emails;
72
+ out.totalCacheHits = stats.cacheHits;
73
+ out.totalCacheMisses = stats.cacheMisses;
74
+ out.cacheHitRatio = stats.cacheRatio;
75
+
76
+ // Live gauges (current level, not a total).
77
+ out.liveConnectedStreams = stats.connectedStreams;
78
+ out.liveCommittedMemoryBytes = stats.committedMemory;
79
+
80
+ // Request windows: current usage vs plan cap (cap 0 = unlimited).
81
+ out.requestsThisMinute = stats.reqMinuteUsed;
82
+ out.requestsThisMinuteCap = stats.reqMinuteCap;
83
+ out.requestsToday = stats.reqDayUsed;
84
+ out.requestsTodayCap = stats.reqDayCap;
85
+
86
+ return out;
87
+ }
88
+
89
+ /// Any metric's historical time-series for a range, for graphs:
90
+ /// `await Server.REST.analytics.series({ query: { metric: MetricId, range: AnalyticsRange } })`
91
+ /// `metric` is a `MetricId` (0..44), `range` an `AnalyticsRange` (0=1h .. 7=30d, default 24h). Returns
92
+ /// the per-bucket totals oldest→newest; the client derives rates (value / bucketSecs) and draws them.
93
+ @get('/series')
94
+ public series(ctx: RouteContext): SeriesData {
95
+ const metric = <MetricId>i32.parse(ctx.query('metric'));
96
+ const rangeStr = ctx.query('range');
97
+ const range = rangeStr.length > 0 ? <AnalyticsRange>i32.parse(rangeStr) : AnalyticsRange.H24;
98
+ const s = Analytics.series(metric, range);
99
+ const out = new SeriesData();
100
+ out.metric = s.metric;
101
+ out.bucketSecs = s.bucketSecs;
102
+ out.headMs = s.headMs;
103
+ out.points = s.points;
104
+ return out;
29
105
  }
30
106
  }
@@ -0,0 +1,22 @@
1
+ import { Response } from 'toiljs/server/runtime';
2
+
3
+ /**
4
+ * Demo of `ToilUserId` — the stable 256-bit user identity derived from the ML-DSA login public key +
5
+ * identifier (email/username) + tenant domain. Deterministic, opaque, O(1) to compare with `==` / `!=`.
6
+ */
7
+ @rest('userid')
8
+ class UserIdDemo {
9
+ @get('/demo')
10
+ public demo(): Response {
11
+ const key = new Uint8Array(1312); // an ML-DSA-44 public key (zeros for the demo)
12
+ const a = ToilUserId.derive(key, 'alice@example.com', 'acme.dacely.com');
13
+ const b = ToilUserId.derive(key, 'alice@example.com', 'acme.dacely.com');
14
+ const c = ToilUserId.derive(key, 'bob@example.com', 'acme.dacely.com');
15
+ // Same inputs => same id (a == b); a different identifier => a different id (a != c).
16
+ const same = a == b;
17
+ const diff = a != c;
18
+ return Response.text(
19
+ a.toHex() + '\nsame=' + same.toString() + ' diff=' + diff.toString() + '\n',
20
+ );
21
+ }
22
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "toiljs",
3
3
  "type": "module",
4
- "version": "0.0.84",
4
+ "version": "0.0.86",
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": {
@@ -67,6 +67,10 @@
67
67
  "types": "./server/runtime/*.ts",
68
68
  "default": "./server/runtime/*.ts"
69
69
  },
70
+ "./server/auth": {
71
+ "types": "./server/auth/index.ts",
72
+ "default": "./server/auth/index.ts"
73
+ },
70
74
  "./tsconfig": "./presets/tsconfig.json",
71
75
  "./eslint": "./presets/eslint.js",
72
76
  "./prettier": "./presets/prettier.json",
@@ -58,13 +58,22 @@ const ORDER = [
58
58
  ];
59
59
 
60
60
  function collect() {
61
- const all = fs
62
- .readdirSync(docsDir)
63
- .filter((f) => f.endsWith('.md') && !EXCLUDE.has(f))
64
- .sort();
61
+ // Recurse one+ levels so a topic FOLDER (e.g. `docs/auth/*.md`) is bundled too, keyed by its
62
+ // repo-relative path (`auth/index.md`). Adding a `docs/**/*.md` is picked up automatically.
63
+ const files = [];
64
+ const walk = (dir, prefix) => {
65
+ for (const ent of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
66
+ if (ent.isDirectory()) {
67
+ walk(path.join(dir, ent.name), prefix + ent.name + '/');
68
+ } else if (ent.name.endsWith('.md') && !EXCLUDE.has(prefix + ent.name) && !EXCLUDE.has(ent.name)) {
69
+ files.push(prefix + ent.name);
70
+ }
71
+ }
72
+ };
73
+ walk(docsDir, '');
65
74
  const ordered = [
66
- ...ORDER.filter((f) => all.includes(f)),
67
- ...all.filter((f) => !ORDER.includes(f)),
75
+ ...ORDER.filter((f) => files.includes(f)),
76
+ ...files.filter((f) => !ORDER.includes(f)),
68
77
  ];
69
78
  const out = {};
70
79
  for (const name of ordered) {
@@ -0,0 +1,335 @@
1
+ import { Response, RouteContext } from 'toiljs/server/runtime';
2
+ import { DataReader, DataWriter } from 'data';
3
+
4
+ import { encodeSessionUser } from './AuthUser';
5
+
6
+ /**
7
+ * Built-in Toil PQ-Auth controller: the full post-quantum password-login API
8
+ * (`/auth/register|login/start|finish`) plus sessions (`/auth/me`, `/auth/logout`),
9
+ * mounted automatically when an app opts in with `server: { auth: true }` (or
10
+ * `import 'toiljs/server/auth'`). It is a framework-shipped SOURCE file the build
11
+ * APPENDS to the toilscript entry set, so its `@data`/`@database`/`@rest` decorators
12
+ * weave and its `@rest` class self-mounts at `/auth/*` — no hand-written boilerplate.
13
+ *
14
+ * The password never leaves the browser. The client blinds it through the
15
+ * server-keyed OPRF (precomputation-resistant keyed salt), stretches the OPRF
16
+ * output with Argon2id into an ML-DSA-44 keypair, and registers only the public
17
+ * key (+ a proof-of-possession). Login is a challenge-response that also runs an
18
+ * ML-KEM-768 key encapsulation: the server proves its identity by returning a
19
+ * confirmation tag only derivable from the decapsulated shared secret (mutual
20
+ * auth). See `server/globals/auth.ts` (the `AuthService` global) and the client
21
+ * half in `toiljs/client` (`Auth.register` / `Auth.login`).
22
+ *
23
+ * STORAGE: backed by ToilDB (`@database AuthDb`). Accounts are a `record`
24
+ * collection keyed by username; login challenges are a `record` consumed exactly
25
+ * once with `getDelete` (atomic fetch-and-delete). The dev server emulates these
26
+ * `env.data.*` host imports in process (so register -> login spans requests under
27
+ * `toiljs dev`); the production edge backs the SAME API with ScyllaDB.
28
+ *
29
+ * Wire: every body/response is binary (`DataWriter`/`DataReader`), never JSON.
30
+ *
31
+ * Tuning (audience, TTLs, Argon2id params, rate-limit tuples) is fixed here for
32
+ * now; config-driven overrides are a documented follow-up. The secret trio
33
+ * (`AUTH_SESSION_SECRET` / `AUTH_OPRF_SEED` / `AUTH_KEM_SK`) resolves lazily from
34
+ * the tenant env store with DEV fallbacks, so this runs with zero config.
35
+ */
36
+
37
+ // Demo-light Argon2id params (responsive in a browser tab). A real deployment
38
+ // uses >= 256 MiB / >= 3 iterations. The client derives against whatever it is
39
+ // handed, so this is the single source of truth.
40
+ const MEM_KIB: u32 = 32768; // 32 MiB
41
+ const ITERS: u32 = 2;
42
+ const PAR: u32 = 1;
43
+
44
+ const CHALLENGE_TTL_SECS: u64 = 120;
45
+ const SESSION_TTL_SECS: u64 = 3600;
46
+
47
+ // Resolved-and-cached per instance; the audience id (server config, never
48
+ // client-echoed). Read lazily (not at module top level) so the env host import
49
+ // runs during a dispatch, when the request host is bound — the same pattern
50
+ // AuthService uses for its secrets.
51
+ let __aud: string | null = null;
52
+ function aud(): string {
53
+ const cached = __aud;
54
+ if (cached != null) return cached;
55
+ const v = Environment.getSecure('TOIL_AUTH_AUDIENCE');
56
+ const resolved = v != null ? v : 'toil';
57
+ __aud = resolved;
58
+ return resolved;
59
+ }
60
+
61
+ /**
62
+ * The tenant domain the stable {@link ToilUserId} is scoped to: the configured
63
+ * `TOIL_AUTH_DOMAIN` if set, else the request's `Host` header, else `localhost`.
64
+ * It only needs to be stable per deployment (it is one of the SHA-256 inputs of
65
+ * the user id), so any robust per-deployment host source is fine.
66
+ */
67
+ function authDomain(ctx: RouteContext): string {
68
+ const configured = Environment.getSecure('TOIL_AUTH_DOMAIN');
69
+ if (configured != null) return configured;
70
+ const host = ctx.request.header('host');
71
+ if (host != null) return host;
72
+ return 'localhost';
73
+ }
74
+
75
+ function randomBytes(n: i32): Uint8Array {
76
+ const b = new Uint8Array(n);
77
+ crypto.getRandomValues(b);
78
+ return b;
79
+ }
80
+
81
+ function nowSecs(): u64 {
82
+ return <u64>(Date.now() / 1000);
83
+ }
84
+
85
+ /** One generic error on every failure path (anti-enumeration, anti-oracle). */
86
+ function fail(): Response {
87
+ return Response.text('auth: request failed\n', 401);
88
+ }
89
+
90
+ /**
91
+ * Deterministic per-user Argon2id salt (16 bytes). With the OPRF providing
92
+ * precomputation resistance, a public/deterministic salt is fine: it only needs
93
+ * to be unique per user (the OPRF output already differs per user). Making it
94
+ * deterministic means register and login agree with NO stored salt, and an
95
+ * unknown user yields the SAME stable salt as a known one would -- no
96
+ * enumeration oracle.
97
+ */
98
+ function deriveSalt(username: string): Uint8Array {
99
+ return crypto.sha256Text('toil-auth-salt-v1:' + username).slice(0, 16);
100
+ }
101
+
102
+ @data
103
+ class Username {
104
+ name: string = '';
105
+ constructor(name: string = '') {
106
+ this.name = name;
107
+ }
108
+ }
109
+
110
+ @data
111
+ class ChallengeId {
112
+ cid: Uint8Array = new Uint8Array(0);
113
+ constructor(cid: Uint8Array = new Uint8Array(0)) {
114
+ this.cid = cid;
115
+ }
116
+ }
117
+
118
+ @data
119
+ class AuthAccount {
120
+ username: string = '';
121
+ salt: Uint8Array = new Uint8Array(0);
122
+ publicKey: Uint8Array = new Uint8Array(0);
123
+ memKiB: u32 = 0;
124
+ iterations: u32 = 0;
125
+ parallelism: u32 = 0;
126
+ }
127
+
128
+ @data
129
+ class Challenge {
130
+ cid: Uint8Array = new Uint8Array(0);
131
+ username: string = '';
132
+ nonce: Uint8Array = new Uint8Array(0);
133
+ iat: u64 = 0;
134
+ exp: u64 = 0;
135
+ }
136
+
137
+ @database
138
+ class AuthDb {
139
+ @collection static accounts: Documents<Username, AuthAccount>;
140
+ @collection static challenges: Documents<ChallengeId, Challenge>;
141
+ }
142
+
143
+ @rest('auth')
144
+ class Auth {
145
+ /** POST /auth/register/start body: str(username) bytes(blinded)
146
+ * resp: u8(status=0) u32(mem) u32(iters) u32(par) bytes(salt) bytes(evaluated)
147
+ * No taken-oracle: always succeeds; register/finish rejects a duplicate. */
148
+ @ratelimit(RateLimit.SlidingWindow, 5, 60)
149
+ @post('/register/start')
150
+ public registerStart(ctx: RouteContext): Response {
151
+ const r = new DataReader(ctx.request.body);
152
+ const username = r.readString();
153
+ const blinded = r.readBytes();
154
+ if (!r.ok) return fail();
155
+ const evaluated = AuthService.oprfEvaluate(username, blinded);
156
+ if (evaluated.length != AuthService.OPRF_ELEMENT_LEN) return fail();
157
+
158
+ const w = new DataWriter();
159
+ w.writeU8(0);
160
+ w.writeU32(MEM_KIB);
161
+ w.writeU32(ITERS);
162
+ w.writeU32(PAR);
163
+ w.writeBytes(deriveSalt(username));
164
+ w.writeBytes(evaluated);
165
+ return Response.bytes(w.toBytes());
166
+ }
167
+
168
+ /** POST /auth/register/finish body: str(username) bytes(pk) bytes(regProof)
169
+ * resp: u8(status) -- 0 = ok, 1 = username already registered. Verifies
170
+ * proof-of-possession before storing the key. */
171
+ @ratelimit(RateLimit.SlidingWindow, 5, 60)
172
+ @post('/register/finish')
173
+ public registerFinish(ctx: RouteContext): Response {
174
+ const r = new DataReader(ctx.request.body);
175
+ const username = r.readString();
176
+ const pk = r.readBytes();
177
+ const proof = r.readBytes();
178
+ if (!r.ok) return fail();
179
+ if (pk.length != AuthService.PUBLIC_KEY_LEN) return fail();
180
+ // Already registered: a distinguishable status (not the generic 401) so the
181
+ // client can say "username taken, log in instead" rather than a blank error.
182
+ if (AuthDb.accounts.exists(new Username(username))) {
183
+ return Response.bytes(new DataWriter().writeU8(1).toBytes());
184
+ }
185
+
186
+ // Proof-of-possession: the client signed buildRegisterMessage with the
187
+ // matching secret key, so we confirm it actually holds it.
188
+ const regMsg = AuthService.buildRegisterMessage(username, pk);
189
+ if (!AuthService.verifyRegister(pk, regMsg, proof)) return fail();
190
+
191
+ const a = new AuthAccount();
192
+ a.username = username;
193
+ a.salt = deriveSalt(username);
194
+ a.publicKey = pk;
195
+ a.memKiB = MEM_KIB;
196
+ a.iterations = ITERS;
197
+ a.parallelism = PAR;
198
+ // create-if-absent: a racing duplicate registration loses here, not above.
199
+ if (!AuthDb.accounts.create(new Username(username), a)) {
200
+ return Response.bytes(new DataWriter().writeU8(1).toBytes());
201
+ }
202
+ return Response.bytes(new DataWriter().writeU8(0).toBytes());
203
+ }
204
+
205
+ /** POST /auth/login/start body: str(username) bytes(blinded)
206
+ * resp: bytes(cid) str(aud) u32(mem) u32(iters) u32(par) bytes(salt)
207
+ * bytes(nonce) u64(iat) u64(exp) bytes(evaluated)
208
+ * Anti-enumeration: ALWAYS OPRF-evaluates (real or decoy key from the same
209
+ * seed+username), returns a deterministic per-user salt + constant params,
210
+ * and a fresh challenge -- a known and an unknown user are indistinguishable. */
211
+ @ratelimit(RateLimit.SlidingWindow, 5, 60)
212
+ @post('/login/start')
213
+ public loginStart(ctx: RouteContext): Response {
214
+ const r = new DataReader(ctx.request.body);
215
+ const username = r.readString();
216
+ const blinded = r.readBytes();
217
+ if (!r.ok) return fail();
218
+ const evaluated = AuthService.oprfEvaluate(username, blinded);
219
+ if (evaluated.length != AuthService.OPRF_ELEMENT_LEN) return fail();
220
+
221
+ const known = AuthDb.accounts.exists(new Username(username));
222
+ const cid = randomBytes(16);
223
+ const nonce = randomBytes(32);
224
+ const iat = nowSecs();
225
+ const exp = iat + CHALLENGE_TTL_SECS;
226
+
227
+ // Persist only for a real account; the response is identical either way,
228
+ // and login/finish for an unknown user fails generically at consume.
229
+ if (known) {
230
+ const c = new Challenge();
231
+ c.cid = cid;
232
+ c.username = username;
233
+ c.nonce = nonce;
234
+ c.iat = iat;
235
+ c.exp = exp;
236
+ AuthDb.challenges.create(new ChallengeId(cid), c);
237
+ }
238
+
239
+ const w = new DataWriter();
240
+ w.writeBytes(cid);
241
+ w.writeString(aud());
242
+ w.writeU32(MEM_KIB);
243
+ w.writeU32(ITERS);
244
+ w.writeU32(PAR);
245
+ w.writeBytes(deriveSalt(username));
246
+ w.writeBytes(nonce);
247
+ w.writeU64(iat);
248
+ w.writeU64(exp);
249
+ w.writeBytes(evaluated);
250
+ return Response.bytes(w.toBytes());
251
+ }
252
+
253
+ /** POST /auth/login/finish body: bytes(cid) bytes(ct) bytes(sig)
254
+ * resp: u8(status) [+ bytes(sessionToken) bytes(serverConfirm)] + Set-Cookie */
255
+ @ratelimit(RateLimit.SlidingWindow, 5, 60)
256
+ @post('/login/finish')
257
+ public loginFinish(ctx: RouteContext): Response {
258
+ const r = new DataReader(ctx.request.body);
259
+ const cid = r.readBytes();
260
+ const ct = r.readBytes();
261
+ const sig = r.readBytes();
262
+ if (!r.ok) return fail();
263
+
264
+ // 1. CONSUME FIRST: atomic fetch-and-delete. Unknown/used/expired => fail.
265
+ const ch = AuthDb.challenges.getDelete(new ChallengeId(cid));
266
+ if (ch == null) return fail();
267
+ if (nowSecs() >= ch.exp) return fail();
268
+
269
+ // 2. Rebuild the message from OUR stored values + the client's ct (and
270
+ // the bound params + server key id), load the account key, verify.
271
+ const acct = AuthDb.accounts.get(new Username(ch.username));
272
+ if (acct == null) return fail();
273
+ const message = AuthService.buildLoginMessage(
274
+ ch.username,
275
+ aud(),
276
+ cid,
277
+ ch.nonce,
278
+ ch.iat,
279
+ ch.exp,
280
+ ct,
281
+ MEM_KIB,
282
+ ITERS,
283
+ PAR,
284
+ AuthService.serverKemKeyId(),
285
+ );
286
+ if (!AuthService.verifyLogin(acct.publicKey, message, sig)) return fail();
287
+
288
+ // 3. Decapsulate (proves WE hold the KEM key), derive the session key K
289
+ // bound to the transcript, and build the confirmation tag the client
290
+ // verifies for mutual auth.
291
+ const sharedSecret = AuthService.mlkemDecapsulate(ct);
292
+ if (sharedSecret.length != AuthService.SHARED_SECRET_LEN) return fail();
293
+ const transcriptHash = AuthService.sha256(message);
294
+ const sessionKey = AuthService.deriveSessionKey(sharedSecret, transcriptHash);
295
+ const confirm = AuthService.serverConfirmTag(sessionKey, transcriptHash);
296
+
297
+ // 4. Success: mint the session (the built-in @user codec, carrying the
298
+ // stable ToilUserId) and return {0, sessionToken, confirm}.
299
+ const domain = authDomain(ctx);
300
+ const userData = encodeSessionUser(acct.publicKey, ch.username, domain);
301
+ const w = new DataWriter();
302
+ w.writeU8(0);
303
+ w.writeBytes(userData); // opaque session token (the readable user payload)
304
+ w.writeBytes(confirm);
305
+ const resp = Response.bytes(w.toBytes());
306
+ resp.setCookie(AuthService.mintSession(userData, SESSION_TTL_SECS));
307
+ resp.setCookie(AuthService.userCookie(userData, SESSION_TTL_SECS));
308
+ return resp;
309
+ }
310
+
311
+ /** GET /auth/me (@auth: 401 without a valid session) -> the typed user
312
+ * (bytes(toilUserId) str(username)). `AuthService.getUser()` is auto-typed
313
+ * to the built-in `@user` with no type argument. */
314
+ @auth
315
+ @get('/me')
316
+ public me(_ctx: RouteContext): Response {
317
+ const u = AuthService.getUser();
318
+ if (u == null) return Response.text('no session\n', 401);
319
+ const w = new DataWriter();
320
+ w.writeBytes(u.toilUserId);
321
+ w.writeString(u.username);
322
+ return Response.bytes(w.toBytes());
323
+ }
324
+
325
+ /** POST /auth/logout (@auth) -> clears both the signed session and the
326
+ * readable companion cookie. */
327
+ @auth
328
+ @post('/logout')
329
+ public logout(_ctx: RouteContext): Response {
330
+ const resp = Response.text('bye\n', 200);
331
+ resp.setCookie(AuthService.clearSession());
332
+ resp.setCookie(AuthService.clearUserCookie());
333
+ return resp;
334
+ }
335
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The framework's built-in authenticated-user shape for `server.auth` (the
3
+ * zero-config `/auth/*` PQ-login controller). Shipped as a SOURCE file that the
4
+ * `server.auth` build APPENDS to the toilscript entry set, so its `@user`
5
+ * decorator weaves exactly as a hand-written one would (a file under
6
+ * `server/globals` is a toilscript `lib` source, where decorators do NOT weave;
7
+ * this file is compiled as a user ENTRY instead).
8
+ *
9
+ * `@user` declares the authenticated user's shape: it becomes a binary codec
10
+ * (like `@data`) AND registers the type of `AuthService.getUser()` everywhere,
11
+ * server and generated client, with NO type argument. There is exactly ONE
12
+ * `@user` per program — the built-in owns it. An app that needs a different user
13
+ * shape hand-writes its own auth controller (and its own `@user`) instead of
14
+ * opting into `server.auth`.
15
+ *
16
+ * The class is named `SessionUser`, NOT `AuthUser`: the toilscript `@user`
17
+ * transform injects a `@global class AuthUser extends <thisClass>` binding for
18
+ * `AuthService.getUser()`, so naming this class `AuthUser` would produce
19
+ * `class AuthUser extends AuthUser` (a duplicate-identifier / self-extension
20
+ * compile error).
21
+ */
22
+
23
+ // @user: the authenticated-user shape. Exactly one per program; the built-in
24
+ // owns it. `toilUserId` is FIRST so `AuthService.userId()` can recover the
25
+ // stable id straight from the session codec (see server/globals/auth.ts).
26
+ @user
27
+ class SessionUser {
28
+ toilUserId: Uint8Array = new Uint8Array(0);
29
+ username: string = '';
30
+ }
31
+
32
+ /**
33
+ * Encode the built-in `@user` session payload for a just-authenticated user.
34
+ * Derives the stable, tenant-scoped {@link ToilUserId} from the user's ML-DSA
35
+ * login public key + their username + the request's tenant `domain`, so the same
36
+ * user on the same site always maps to the same id. Exported as a FUNCTION (not
37
+ * the class, which would warn AS235) so the login controller mints a session via
38
+ * the same generated `@user` codec.
39
+ */
40
+ export function encodeSessionUser(
41
+ mldsaPublicKey: Uint8Array,
42
+ username: string,
43
+ domain: string,
44
+ ): Uint8Array {
45
+ const u = new SessionUser();
46
+ u.toilUserId = ToilUserId.derive(mldsaPublicKey, username, domain).toBytes();
47
+ u.username = username;
48
+ return u.encode();
49
+ }
@@ -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