toiljs 0.0.85 → 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.
@@ -1,25 +1,138 @@
1
- /** This site's own analytics, mapped from the toilscript `Analytics.self()` snapshot for the
2
- * client. Counts + usage are i64; the rate-limit caps are i64 too (0 = unlimited). */
1
+ /**
2
+ * This site's own analytics snapshot, mapped 1:1 from the toilscript `Analytics.self()` frame.
3
+ *
4
+ * Every `total*` field is an ALL-TIME CUMULATIVE COUNT — a running total since the site was first
5
+ * seen, monotonically increasing, never reset by a query. They are NOT "last hour" or "last minute":
6
+ * for time-windowed history use the separate `SeriesData` time-series (`/series`).
7
+ *
8
+ * All counter fields are `u64` (the guest exposes `u64` getters, so the mapping needs zero casts). The
9
+ * `live*` gauges are the current instantaneous level (not a total). The request-window fields are the
10
+ * current rate-limit bucket usage paired with the plan cap (cap `0` = unlimited).
11
+ */
3
12
  @data
4
13
  export class SiteAnalytics {
5
- requests: i64 = 0;
6
- bytesOutL1: i64 = 0;
7
- bytesInL1: i64 = 0;
8
- status2xx: i64 = 0;
9
- wasmDispatches: i64 = 0;
10
- dbOps: i64 = 0;
11
- gasUsed: i64 = 0;
12
- connectedStreams: i64 = 0;
13
- committedMemory: i64 = 0;
14
- reqMinuteUsed: i64 = 0;
15
- reqMinuteCap: i64 = 0;
16
- reqDayUsed: i64 = 0;
17
- reqDayCap: i64 = 0;
14
+ // --- Requests / L1 edge (all-time totals) ---
15
+ /** Total HTTP requests served, all-time. */
16
+ totalRequests: u64 = 0;
17
+ /** Total response bytes sent from the L1 edge, all-time. */
18
+ totalBytesOutL1: u64 = 0;
19
+ /** Total request bytes received at the L1 edge, all-time. */
20
+ totalBytesInL1: u64 = 0;
21
+ /** Total 2xx responses, all-time. */
22
+ totalStatus2xx: u64 = 0;
23
+ /** Total 3xx responses, all-time. */
24
+ totalStatus3xx: u64 = 0;
25
+ /** Total 4xx responses, all-time. */
26
+ totalStatus4xx: u64 = 0;
27
+ /** Total 5xx responses, all-time. */
28
+ totalStatus5xx: u64 = 0;
29
+ /** Total static-asset hits (served without a wasm dispatch), all-time. */
30
+ totalStaticHits: u64 = 0;
31
+ /** Total wasm handler dispatches, all-time. */
32
+ totalWasmDispatches: u64 = 0;
33
+ /** Total requests rejected because the executor pool was full, all-time. */
34
+ totalExecutorFullRejects: u64 = 0;
35
+ /** Total requests rejected for an unknown host, all-time. */
36
+ totalUnknownHostRejects: u64 = 0;
37
+ /** Total requests rejected by rate limiting, all-time. */
38
+ totalRateLimitedRejects: u64 = 0;
39
+ /** Total wasm gas consumed across all dispatches, all-time. */
40
+ totalGasUsed: u64 = 0;
41
+
42
+ // --- Database (all-time totals) ---
43
+ /** Total ToilDB operations issued, all-time. */
44
+ totalDbOps: u64 = 0;
45
+ /** Total ToilDB read operations, all-time. */
46
+ totalDbReads: u64 = 0;
47
+ /** Total ToilDB write operations, all-time. */
48
+ totalDbWrites: u64 = 0;
49
+ /** Total ToilDB operations that errored, all-time. */
50
+ totalDbErrors: u64 = 0;
51
+ /** Summed host-observed ToilDB op latency in nanoseconds, all-time (divide by totalDbOps for a mean). */
52
+ totalDbLatencyNsSum: u64 = 0;
53
+
54
+ // --- Streams / WebTransport (all-time totals) ---
55
+ /** Total stream connections accepted, all-time. */
56
+ totalStreamAccepts: u64 = 0;
57
+ /** Total streams rejected because they landed on the wrong node, all-time. */
58
+ totalStreamRejectWrongNode: u64 = 0;
59
+ /** Total streams rejected for capacity, all-time. */
60
+ totalStreamRejectCapacity: u64 = 0;
61
+ /** Total streams rejected for a missing/invalid artifact, all-time. */
62
+ totalStreamRejectArtifact: u64 = 0;
63
+ /** Total streams rejected by the guest, all-time. */
64
+ totalStreamRejectGuest: u64 = 0;
65
+ /** Total stream guest traps, all-time. */
66
+ totalStreamTraps: u64 = 0;
67
+ /** Total streams closed for idle timeout, all-time. */
68
+ totalStreamIdleTimeouts: u64 = 0;
69
+ /** Total bytes received on streams, all-time. */
70
+ totalStreamBytesIn: u64 = 0;
71
+ /** Total bytes sent on streams, all-time. */
72
+ totalStreamBytesOut: u64 = 0;
73
+ /** Total stream backpressure events, all-time. */
74
+ totalStreamBackpressureEvents: u64 = 0;
75
+ /** Total clean stream closes, all-time. */
76
+ totalStreamCloses: u64 = 0;
77
+ /** Total stream disconnects (abrupt), all-time. */
78
+ totalStreamDisconnects: u64 = 0;
79
+
80
+ // --- Daemons / L4 (all-time totals) ---
81
+ /** Total daemon starts, all-time. */
82
+ totalDaemonStarts: u64 = 0;
83
+ /** Total daemon start failures, all-time. */
84
+ totalDaemonStartFailures: u64 = 0;
85
+ /** Total daemon ticks fired, all-time. */
86
+ totalDaemonTicksFired: u64 = 0;
87
+ /** Total daemon ticks skipped because this node was not the leader, all-time. */
88
+ totalDaemonTicksSkippedNotLeader: u64 = 0;
89
+ /** Total daemon ticks that failed, all-time. */
90
+ totalDaemonTicksFailed: u64 = 0;
91
+ /** Total daemon leadership acquisitions, all-time. */
92
+ totalDaemonLeaderAcquires: u64 = 0;
93
+ /** Total daemon leadership fences (lost leadership), all-time. */
94
+ totalDaemonLeaderFenced: u64 = 0;
95
+ /** Total daemon outbound http_call attempts, all-time. */
96
+ totalDaemonHttpCallAttempts: u64 = 0;
97
+ /** Total daemon outbound http_call failures, all-time. */
98
+ totalDaemonHttpCallFailures: u64 = 0;
99
+
100
+ // --- Memory / email / cache (all-time totals) ---
101
+ /** Total wasm linear-memory bytes grown, all-time. */
102
+ totalMemGrownBytes: u64 = 0;
103
+ /** Total emails sent, all-time. */
104
+ totalEmails: u64 = 0;
105
+ /** Total responses served from the edge cache, all-time. */
106
+ totalCacheHits: u64 = 0;
107
+ /** Total cacheable responses that missed the edge cache, all-time. */
108
+ totalCacheMisses: u64 = 0;
109
+
110
+ // --- Derived ---
111
+ /** Fraction of cacheable responses served from cache: hits / (hits + misses), in 0..1. 0 when there
112
+ * were no cacheable responses. */
113
+ cacheHitRatio: f64 = 0;
114
+
115
+ // --- Live gauges (current instantaneous level, NOT a total) ---
116
+ /** Streams currently connected right now. */
117
+ liveConnectedStreams: u64 = 0;
118
+ /** Wasm linear memory currently committed right now, in bytes. */
119
+ liveCommittedMemoryBytes: u64 = 0;
120
+
121
+ // --- Request windows: current rate-limit usage vs plan cap (cap 0 = unlimited) ---
122
+ /** Requests used in the current 1-minute window. */
123
+ requestsThisMinute: u64 = 0;
124
+ /** Plan cap for the 1-minute window (0 = unlimited). */
125
+ requestsThisMinuteCap: u64 = 0;
126
+ /** Requests used in the current 24-hour window. */
127
+ requestsToday: u64 = 0;
128
+ /** Plan cap for the 24-hour window (0 = unlimited). */
129
+ requestsTodayCap: u64 = 0;
18
130
  }
19
131
 
20
- /** One metric's historical time-series for a range, from `Analytics.series(metric, range)`: the raw
132
+ /** One metric's historical TIME-SERIES for a range, from `Analytics.series(metric, range)`: the raw
21
133
  * per-bucket totals (oldest→newest) plus the bucket width + newest-bucket end, so the client can draw a
22
- * graph and derive per-second rates. Demonstrates the typed series API. */
134
+ * graph and derive per-second rates. This is per-bucket HISTORY — distinct from the all-time totals in
135
+ * `SiteAnalytics`. Demonstrates the typed series API. */
23
136
  @data
24
137
  export class SeriesData {
25
138
  metric: i32 = 0;
@@ -16,23 +16,74 @@ import { SeriesData, SiteAnalytics } from '../models/SiteAnalytics';
16
16
  class AnalyticsRoutes {
17
17
  @get('/')
18
18
  public self(): SiteAnalytics {
19
- const s = Analytics.self();
20
- const r = new SiteAnalytics();
21
- // Typed getters: no magic strings, no `.has()/.get()` map dance.
22
- r.requests = s.requests;
23
- r.bytesOutL1 = s.bytesOutL1;
24
- r.bytesInL1 = s.bytesInL1;
25
- r.status2xx = s.status2xx;
26
- r.wasmDispatches = s.wasmDispatches;
27
- r.dbOps = s.dbOps;
28
- r.gasUsed = s.gasUsed;
29
- r.connectedStreams = s.connectedStreams;
30
- r.committedMemory = s.committedMemory;
31
- r.reqMinuteUsed = s.reqMinuteUsed;
32
- r.reqMinuteCap = <i64>s.reqMinuteCap;
33
- r.reqDayUsed = s.reqDayUsed;
34
- r.reqDayCap = <i64>s.reqDayCap;
35
- 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;
36
87
  }
37
88
 
38
89
  /// Any metric's historical time-series for a range, for graphs:
@@ -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.85",
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
+ }