toiljs 0.0.34 → 0.0.37

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 (110) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -0
  3. package/as-pect.config.js +8 -2
  4. package/build/cli/.tsbuildinfo +1 -1
  5. package/build/cli/index.js +97 -0
  6. package/build/client/.tsbuildinfo +1 -1
  7. package/build/client/auth.d.ts +42 -0
  8. package/build/client/auth.js +182 -0
  9. package/build/client/index.d.ts +5 -1
  10. package/build/client/index.js +3 -1
  11. package/build/client/routing/loader.d.ts +1 -0
  12. package/build/client/routing/loader.js +37 -0
  13. package/build/client/routing/mount.js +32 -1
  14. package/build/client/ssr/markers.d.ts +34 -0
  15. package/build/client/ssr/markers.js +49 -0
  16. package/build/compiler/.tsbuildinfo +1 -1
  17. package/build/compiler/docs.js +88 -1
  18. package/build/compiler/generate.d.ts +2 -0
  19. package/build/compiler/generate.js +2 -2
  20. package/build/compiler/index.js +2 -0
  21. package/build/compiler/ssr-codegen.d.ts +2 -0
  22. package/build/compiler/ssr-codegen.js +36 -0
  23. package/build/compiler/template-build.d.ts +29 -0
  24. package/build/compiler/template-build.js +150 -0
  25. package/build/compiler/template.d.ts +22 -0
  26. package/build/compiler/template.js +169 -0
  27. package/build/devserver/.tsbuildinfo +1 -1
  28. package/build/devserver/crypto.js +15 -0
  29. package/build/devserver/host.js +1 -0
  30. package/build/devserver/module.d.ts +1 -0
  31. package/build/devserver/module.js +23 -1
  32. package/docs/README.md +56 -0
  33. package/docs/auth.md +261 -0
  34. package/docs/caching.md +115 -0
  35. package/docs/cookies.md +457 -0
  36. package/docs/crypto.md +130 -0
  37. package/docs/data.md +131 -0
  38. package/docs/getting-started.md +128 -0
  39. package/docs/routing.md +259 -0
  40. package/docs/rpc.md +149 -0
  41. package/docs/ssr.md +184 -0
  42. package/docs/time.md +43 -0
  43. package/examples/basic/client/routes/auth.tsx +198 -0
  44. package/examples/basic/client/routes/cookies.tsx +199 -0
  45. package/examples/basic/client/routes/features/index.tsx +34 -10
  46. package/examples/basic/client/routes/hello.tsx +43 -0
  47. package/examples/basic/client/routes/pq.tsx +260 -0
  48. package/examples/basic/server/AuthTestHandler.ts +15 -0
  49. package/examples/basic/server/AuthVerifyHandler.ts +23 -0
  50. package/examples/basic/server/CacheHandler.ts +25 -0
  51. package/examples/basic/server/DecoCache.ts +18 -0
  52. package/examples/basic/server/FastTrapHandler.ts +8 -0
  53. package/examples/basic/server/SpinHandler.ts +18 -0
  54. package/examples/basic/server/SsrGreetingRender.ts +27 -0
  55. package/examples/basic/server/authexample-main.ts +8 -0
  56. package/examples/basic/server/authtest-main.ts +8 -0
  57. package/examples/basic/server/authverify-main.ts +8 -0
  58. package/examples/basic/server/cache-main.ts +8 -0
  59. package/examples/basic/server/core/AppHandler.ts +243 -0
  60. package/examples/basic/server/deco-main.ts +18 -0
  61. package/examples/basic/server/main.ts +2 -0
  62. package/examples/basic/server/routes/Auth.ts +184 -0
  63. package/examples/basic/server/routes/PqDemo.ts +130 -0
  64. package/examples/basic/server/routes/Session.ts +74 -0
  65. package/examples/basic/server/spin-main.ts +13 -0
  66. package/examples/basic/server/ssr/greeting.slots.ts +19 -0
  67. package/examples/basic/server/ssr-main.ts +18 -0
  68. package/examples/basic/server/toil-server-env.d.ts +94 -0
  69. package/examples/basic/server/trap-main.ts +8 -0
  70. package/package.json +5 -3
  71. package/server/globals/auth.ts +281 -0
  72. package/server/runtime/README.md +61 -0
  73. package/server/runtime/env/Server.ts +12 -0
  74. package/server/runtime/exports/index.ts +17 -0
  75. package/server/runtime/exports/render.ts +51 -0
  76. package/server/runtime/http/base64.ts +104 -0
  77. package/server/runtime/http/cookie.ts +416 -0
  78. package/server/runtime/http/cookies.ts +197 -0
  79. package/server/runtime/http/date.ts +72 -0
  80. package/server/runtime/http/percent.ts +76 -0
  81. package/server/runtime/http/securecookies.ts +224 -0
  82. package/server/runtime/index.ts +17 -0
  83. package/server/runtime/request.ts +24 -0
  84. package/server/runtime/response.ts +29 -0
  85. package/server/runtime/ssr/Ssr.ts +43 -0
  86. package/server/runtime/ssr/encode.ts +110 -0
  87. package/server/runtime/ssr/escape.ts +83 -0
  88. package/server/runtime/ssr/slots.ts +144 -0
  89. package/server/runtime/time.ts +29 -0
  90. package/src/cli/create.ts +105 -0
  91. package/src/client/auth.ts +327 -0
  92. package/src/client/index.ts +5 -1
  93. package/src/client/routing/loader.ts +56 -0
  94. package/src/client/routing/mount.tsx +37 -1
  95. package/src/client/ssr/markers.tsx +140 -0
  96. package/src/compiler/docs.ts +88 -1
  97. package/src/compiler/generate.ts +2 -2
  98. package/src/compiler/index.ts +5 -0
  99. package/src/compiler/ssr-codegen.ts +85 -0
  100. package/src/compiler/template-build.ts +275 -0
  101. package/src/compiler/template.ts +265 -0
  102. package/src/devserver/crypto.ts +23 -0
  103. package/src/devserver/host.ts +4 -0
  104. package/src/devserver/module.ts +39 -1
  105. package/test/assembly/cookie.spec.ts +302 -0
  106. package/test/assembly/example.spec.ts +5 -1
  107. package/test/assembly/ssr.spec.ts +94 -0
  108. package/test/devserver.test.ts +42 -0
  109. package/test/ssr-render.test.ts +128 -0
  110. package/test/ssr-template.test.tsx +348 -0
@@ -39,9 +39,252 @@ export class AppHandler extends ToilHandler {
39
39
  return Response.text(crypto.randomUUID() + '\n');
40
40
  }
41
41
 
42
+ // Cookies. `Cookie`, `Cookies`, `SecureCookies`, and `SameSite` are ambient
43
+ // globals (no import), exactly like `crypto`. The demo lives in its own
44
+ // method; the client page is `client/routes/cookies.tsx`.
45
+ const cookie = this.cookieDemo(req);
46
+ if (cookie != null) return cookie;
47
+
42
48
  // Unhandled (not a plain notFound): tells the host this server has no
43
49
  // answer for the path, so it may serve it itself. Under `toiljs dev`
44
50
  // that falls through to Vite (client routes, assets).
45
51
  return Response.unhandled();
46
52
  }
53
+
54
+ /**
55
+ * The `/api/cookies/*` demo. Each endpoint returns JSON so the client page can
56
+ * render the actual cookie output. Returns `null` for a non-cookie path.
57
+ */
58
+ private cookieDemo(req: Request): Response | null {
59
+ // GALLERY: the serialized `Set-Cookie` output of every capability, no
60
+ // round-trip needed. This is the "everything you can do" reference.
61
+ if (req.path == '/api/cookies/gallery') {
62
+ const labels = new Array<string>();
63
+ const cookies = new Array<string>();
64
+
65
+ labels.push('basic');
66
+ cookies.push(Cookie.create('id', 'abc123').serialize());
67
+ labels.push('percent-encoded (default)');
68
+ cookies.push(Cookie.create('msg', 'hello world & more!').serialize());
69
+ labels.push('base64url-encoded');
70
+ cookies.push(Cookie.create('data', 'hello').withEncoding(CookieEncoding.Base64Url).serialize());
71
+ labels.push('raw (no encoding)');
72
+ cookies.push(Cookie.create('tok', 'AAAA.BBBB').withEncoding(CookieEncoding.Raw).serialize());
73
+ labels.push('Max-Age');
74
+ cookies.push(Cookie.create('a', 'b').maxAge(3600).serialize());
75
+ labels.push('Expires (from epoch seconds)');
76
+ cookies.push(Cookie.create('a', 'b').expires(1700000000).serialize());
77
+ labels.push('Domain + Path');
78
+ cookies.push(Cookie.create('a', 'b').domain('example.com').path('/app').serialize());
79
+ labels.push('Secure + HttpOnly');
80
+ cookies.push(Cookie.create('a', 'b').secure().httpOnly().serialize());
81
+ labels.push('SameSite=Strict');
82
+ cookies.push(Cookie.create('a', 'b').sameSite(SameSite.Strict).serialize());
83
+ labels.push('SameSite=None (implies Secure)');
84
+ cookies.push(Cookie.create('a', 'b').sameSite(SameSite.None).serialize());
85
+ labels.push('Partitioned / CHIPS (implies Secure)');
86
+ cookies.push(Cookie.create('a', 'b').partitioned().serialize());
87
+ labels.push('Priority');
88
+ cookies.push(Cookie.create('a', 'b').priority('High').serialize());
89
+ labels.push('__Host- prefix (Secure + Path=/ + no Domain)');
90
+ cookies.push(Cookie.create('sid', 'x').asHostPrefixed().serialize());
91
+ labels.push('__Secure- prefix');
92
+ cookies.push(Cookie.create('sid', 'x').asSecurePrefixed().serialize());
93
+ labels.push('Max-Age clamped to the 400-day cap');
94
+ cookies.push(Cookie.create('a', 'b').maxAge(99999999).serialize());
95
+ labels.push('everything at once');
96
+ cookies.push(
97
+ Cookie.create('full', 'v')
98
+ .domain('example.com')
99
+ .path('/')
100
+ .maxAge(86400)
101
+ .secure()
102
+ .httpOnly()
103
+ .sameSite(SameSite.Lax)
104
+ .partitioned()
105
+ .priority('Medium')
106
+ .extension('CustomFlag')
107
+ .serialize(),
108
+ );
109
+
110
+ let json = '{';
111
+ for (let i = 0; i < labels.length; i++) {
112
+ if (i > 0) json += ',';
113
+ json += '"' + this.esc(labels[i]) + '":"' + this.esc(cookies[i]) + '"';
114
+ }
115
+ json += '}';
116
+ return Response.json(json);
117
+ }
118
+
119
+ // SET: store three real cookies on the browser. A plain visit counter
120
+ // (readable by JS), an HMAC-signed session, and an AES-256-GCM-encrypted
121
+ // secret (both HttpOnly, so invisible to JS but readable by the server).
122
+ if (req.path == '/api/cookies/set') {
123
+ const prev = req.cookie('visits');
124
+ let next: string;
125
+ if (prev == null) next = '1';
126
+ else next = (this.toI32(prev) + 1).toString();
127
+
128
+ const visits = Cookie.create('visits', next).path('/').sameSite(SameSite.Lax).maxAge(86400);
129
+ const session = SecureCookies.signed(this.demoKey()).seal(
130
+ Cookie.create('session', 'user-42').httpOnly().sameSite(SameSite.Strict).asHostPrefixed(),
131
+ );
132
+ const secret = SecureCookies.encrypted(this.demoKey()).seal(
133
+ Cookie.create('secret', 'top-secret-value').httpOnly().path('/'),
134
+ );
135
+
136
+ const json =
137
+ '{"visits":' +
138
+ next +
139
+ ',"emitted":["' +
140
+ this.esc(visits.serialize()) +
141
+ '","' +
142
+ this.esc(session.serialize()) +
143
+ '","' +
144
+ this.esc(secret.serialize()) +
145
+ '"]}';
146
+ return Response.json(json).setCookie(visits).setCookie(session).setCookie(secret);
147
+ }
148
+
149
+ // INSPECT: what the server sees. Parses the `Cookie` header, then verifies
150
+ // the signed session (HMAC) and decrypts the secret (AES-GCM) server-side.
151
+ if (req.path == '/api/cookies/inspect') {
152
+ const raw = req.header('cookie');
153
+ const jar = req.cookies();
154
+ const names = jar.names();
155
+
156
+ let parsed = '{';
157
+ for (let i = 0; i < names.length; i++) {
158
+ if (i > 0) parsed += ',';
159
+ const val = jar.get(names[i]);
160
+ parsed += '"' + this.esc(names[i]) + '":"' + this.esc(val == null ? '' : val) + '"';
161
+ }
162
+ parsed += '}';
163
+
164
+ const session = SecureCookies.signed(this.demoKey()).open(jar, '__Host-session');
165
+ const secret = SecureCookies.encrypted(this.demoKey()).open(jar, 'secret');
166
+
167
+ const json =
168
+ '{"raw":"' +
169
+ this.esc(raw == null ? '' : raw) +
170
+ '","count":' +
171
+ names.length.toString() +
172
+ ',"cookies":' +
173
+ parsed +
174
+ ',"session":' +
175
+ (session == null ? 'null' : '"' + this.esc(session) + '"') +
176
+ ',"secret":' +
177
+ (secret == null ? 'null' : '"' + this.esc(secret) + '"') +
178
+ '}';
179
+ return Response.json(json);
180
+ }
181
+
182
+ // CLEAR: expire the demo cookies (Max-Age=0 + epoch Expires).
183
+ if (req.path == '/api/cookies/clear') {
184
+ const json =
185
+ '{"cleared":["' +
186
+ this.esc(this.clearString('visits')) +
187
+ '","' +
188
+ this.esc(this.clearString('__Host-session')) +
189
+ '","' +
190
+ this.esc(this.clearString('secret')) +
191
+ '"]}';
192
+ return Response.json(json)
193
+ .clearCookie('visits')
194
+ .clearCookie('__Host-session')
195
+ .clearCookie('secret');
196
+ }
197
+
198
+ // SEAL: sign and encrypt a value (from `?v=`), then recover both and show
199
+ // that a tampered signature fails to verify. Pure backend crypto, no headers.
200
+ if (req.path.indexOf('/api/cookies/seal') == 0) {
201
+ const value = this.queryValue(req.path, 'v', 'hello toiljs');
202
+ const signer = SecureCookies.signed(this.demoKey());
203
+ const box = SecureCookies.encrypted(this.demoKey());
204
+
205
+ const signed = signer.sign('demo', value);
206
+ const encrypted = box.encrypt('demo', value);
207
+ const unsigned = signer.unsign('demo', signed);
208
+ const decrypted = box.decrypt('demo', encrypted);
209
+ const tampered = signer.unsign('demo', this.flip(signed));
210
+
211
+ const json =
212
+ '{"value":"' +
213
+ this.esc(value) +
214
+ '","signed":"' +
215
+ this.esc(signed) +
216
+ '","unsigned":' +
217
+ (unsigned == null ? 'null' : '"' + this.esc(unsigned) + '"') +
218
+ ',"encrypted":"' +
219
+ this.esc(encrypted) +
220
+ '","decrypted":' +
221
+ (decrypted == null ? 'null' : '"' + this.esc(decrypted) + '"') +
222
+ ',"tamperVerifies":' +
223
+ (tampered == null ? 'false' : 'true') +
224
+ '}';
225
+ return Response.json(json);
226
+ }
227
+
228
+ return null;
229
+ }
230
+
231
+ // Demo signing/encryption key: 32 bytes, valid for AES-256-GCM and HMAC. A real
232
+ // app loads a long random secret from config; never hard-code one.
233
+ private demoKey(): Uint8Array {
234
+ return Uint8Array.wrap(String.UTF8.encode('0123456789abcdef0123456789abcdef'));
235
+ }
236
+
237
+ /** The `Set-Cookie` string `clearCookie(name)` emits, for display. */
238
+ private clearString(name: string): string {
239
+ return new Cookie(name, '').path('/').maxAge(0).expires(0).serialize();
240
+ }
241
+
242
+ /** Flip the first character (tamper a sealed value while keeping it base64url). */
243
+ private flip(s: string): string {
244
+ if (s.length == 0) return 'A';
245
+ const c = s.charCodeAt(0);
246
+ return String.fromCharCode(c == 65 ? 66 : 65) + s.substring(1);
247
+ }
248
+
249
+ /** Read `?key=` (or `&key=`) from `path`, percent-decoded, or `fallback`. */
250
+ private queryValue(path: string, key: string, fallback: string): string {
251
+ const q = path.indexOf('?');
252
+ if (q < 0) return fallback;
253
+ const pairs = path.substring(q + 1).split('&');
254
+ const prefix = key + '=';
255
+ for (let i = 0; i < pairs.length; i++) {
256
+ if (pairs[i].indexOf(prefix) == 0) {
257
+ return Cookies.decodeValue(pairs[i].substring(prefix.length));
258
+ }
259
+ }
260
+ return fallback;
261
+ }
262
+
263
+ /** Parse a non-negative base-10 integer prefix of `s`. */
264
+ private toI32(s: string): i32 {
265
+ let r = 0;
266
+ for (let i = 0; i < s.length; i++) {
267
+ const c = s.charCodeAt(i);
268
+ if (c < 48 || c > 57) break;
269
+ r = r * 10 + (c - 48);
270
+ }
271
+ return r;
272
+ }
273
+
274
+ /** JSON string escaping for the demo's hand-built JSON (incl. all controls). */
275
+ private esc(s: string): string {
276
+ const hex = '0123456789abcdef';
277
+ let out = '';
278
+ for (let i = 0; i < s.length; i++) {
279
+ const c = s.charCodeAt(i);
280
+ if (c == 34) out += '\\"';
281
+ else if (c == 92) out += '\\\\';
282
+ else if (c == 10) out += '\\n';
283
+ else if (c == 13) out += '\\r';
284
+ else if (c == 9) out += '\\t';
285
+ else if (c < 0x20) out += '\\u00' + hex.charAt((c >> 4) & 0xf) + hex.charAt(c & 0xf);
286
+ else out += String.fromCharCode(c);
287
+ }
288
+ return out;
289
+ }
47
290
  }
@@ -0,0 +1,18 @@
1
+ import { Server } from 'toiljs/server/runtime';
2
+ import { revertOnError } from 'toiljs/server/runtime/abort/abort';
3
+ import { Request, Response, Rest, ToilHandler } from 'toiljs/server/runtime';
4
+ import './DecoCache';
5
+
6
+ class DecoHandler extends ToilHandler {
7
+ public handle(req: Request): Response {
8
+ const hit = Rest.dispatch(req);
9
+ if (hit != null) return hit;
10
+ return Response.notFound();
11
+ }
12
+ }
13
+
14
+ Server.handler = () => { return new DecoHandler(); };
15
+ export * from 'toiljs/server/runtime/exports';
16
+ export function abort(message: string, fileName: string, line: u32, column: u32): void {
17
+ revertOnError(message, fileName, line, column);
18
+ }
@@ -8,6 +8,8 @@ import { AppHandler } from './core/AppHandler';
8
8
  // run (which only sees the toilconfig entries) building the exact same server.
9
9
  import './routes/Players';
10
10
  import './routes/Leaderboard';
11
+ import './routes/Session';
12
+ import './routes/PqDemo';
11
13
  import './services/Stats';
12
14
  import './services/remotes';
13
15
 
@@ -0,0 +1,184 @@
1
+ import { Method, Response, RouteContext } from 'toiljs/server/runtime';
2
+ import { DataReader, DataWriter } from 'data';
3
+
4
+ /**
5
+ * Post-quantum auth, illustrative. Shows how a tenant wires the no-import
6
+ * `AuthService` global into a challenge-response login. ML-DSA-44 keypairs are
7
+ * derived client-side from the password (Argon2id); the server only ever stores
8
+ * and verifies PUBLIC material.
9
+ *
10
+ * STORAGE IS THE APP'S, AND THIS EXAMPLE DOES NOT PROVIDE IT. A tenant's wasm
11
+ * memory is wiped after every request, so the account record and the login
12
+ * challenges CANNOT live in this module across the two round trips. A real
13
+ * deployment must back `Accounts` and `Challenges` with an external store, and
14
+ * the challenge "consume" MUST be a single atomic fetch-and-delete shared by
15
+ * all instances (Redis `GETDEL`, or SQL `DELETE ... RETURNING`) -- never a
16
+ * read-then-delete, or a sniffed signature replays across a race. The stubs
17
+ * below throw to make that explicit; swap them for your store + a host KV/db
18
+ * binding. The crypto and encoding (`AuthService`) are production-ready; the
19
+ * orchestration here is a template.
20
+ *
21
+ * Wire: every body/response is binary (`DataWriter`/`DataReader`), never JSON.
22
+ * The client half lives in `toiljs/client` (`Auth.register` / `Auth.login`).
23
+ */
24
+
25
+ const AUD = 'toil-demo'; // this service's audience id (server config; never client-echoed)
26
+ const MIN_MEM_KIB = 256 * 1024; // 256 MiB floor (KDF-params-as-credential)
27
+ const MIN_ITERATIONS = 3;
28
+
29
+ class AccountRecord {
30
+ username: string = '';
31
+ salt: Uint8Array = new Uint8Array(0);
32
+ publicKey: Uint8Array = new Uint8Array(0);
33
+ memKiB: u32 = 0;
34
+ iterations: u32 = 0;
35
+ parallelism: u32 = 0;
36
+ }
37
+
38
+ class ChallengeRecord {
39
+ cid: Uint8Array = new Uint8Array(0);
40
+ username: string = '';
41
+ nonce: Uint8Array = new Uint8Array(0);
42
+ iat: u64 = 0;
43
+ exp: u64 = 0;
44
+ }
45
+
46
+ // --- the storage the app MUST provide (external; these throw on purpose) -----
47
+ namespace Accounts {
48
+ export function get(_username: string): AccountRecord | null {
49
+ throw new Error('wire Accounts to your store');
50
+ }
51
+ export function exists(_username: string): bool {
52
+ throw new Error('wire Accounts to your store');
53
+ }
54
+ export function put(_a: AccountRecord): void {
55
+ throw new Error('wire Accounts to your store');
56
+ }
57
+ }
58
+ namespace Challenges {
59
+ export function put(_c: ChallengeRecord): void {
60
+ throw new Error('wire Challenges to your store');
61
+ }
62
+ /** Atomic fetch-and-delete by cid (Redis GETDEL / SQL DELETE RETURNING). */
63
+ export function consume(_cid: Uint8Array): ChallengeRecord | null {
64
+ throw new Error('wire Challenges to an ATOMIC store');
65
+ }
66
+ }
67
+
68
+ function randomBytes(n: i32): Uint8Array {
69
+ const b = new Uint8Array(n);
70
+ crypto.getRandomValues(b);
71
+ return b;
72
+ }
73
+
74
+ function fail(): Response {
75
+ // One generic error on every failure path (anti-enumeration, anti-oracle).
76
+ return Response.text('auth: request failed\n', 401);
77
+ }
78
+
79
+ @rest('auth')
80
+ class Auth {
81
+ /** POST /auth/register/start body: str(username)
82
+ * resp: u8(status=0) + u32(mem) u32(iters) u32(par) bytes(salt) */
83
+ @post('/register/start')
84
+ public registerStart(ctx: RouteContext): Response {
85
+ const username = new DataReader(ctx.request.body).readString();
86
+ if (Accounts.exists(username)) {
87
+ return new Response(200, new DataWriter().writeU8(1).toBytes().slice(0)); // taken
88
+ }
89
+ const salt = randomBytes(16);
90
+ const w = new DataWriter();
91
+ w.writeU8(0);
92
+ w.writeU32(<u32>MIN_MEM_KIB);
93
+ w.writeU32(<u32>MIN_ITERATIONS);
94
+ w.writeU32(1);
95
+ w.writeBytes(salt);
96
+ // NOTE: the salt must be persisted with the pending registration so
97
+ // registerFinish stores the same one; omitted here (no store).
98
+ return Response.bytes(w.toBytes());
99
+ }
100
+
101
+ /** POST /auth/register/finish body: str(username) bytes(pk) resp: u8(status) */
102
+ @post('/register/finish')
103
+ public registerFinish(ctx: RouteContext): Response {
104
+ const r = new DataReader(ctx.request.body);
105
+ const username = r.readString();
106
+ const pk = r.readBytes();
107
+ if (Accounts.exists(username) || pk.length != 1312) return fail(); // ML-DSA-44 pk
108
+ const a = new AccountRecord();
109
+ a.username = username;
110
+ a.publicKey = pk;
111
+ a.memKiB = <u32>MIN_MEM_KIB;
112
+ a.iterations = <u32>MIN_ITERATIONS;
113
+ a.parallelism = 1;
114
+ // a.salt = <the salt issued in registerStart>;
115
+ Accounts.put(a);
116
+ return Response.bytes(new DataWriter().writeU8(0).toBytes());
117
+ }
118
+
119
+ /** POST /auth/login/start body: str(username)
120
+ * resp: bytes(cid) str(aud) u32(mem) u32(iters) u32(par) bytes(salt) bytes(nonce) u64(iat) u64(exp) */
121
+ @post('/login/start')
122
+ public loginStart(ctx: RouteContext): Response {
123
+ const username = new DataReader(ctx.request.body).readString();
124
+ const acct = Accounts.get(username);
125
+
126
+ const cid = randomBytes(16);
127
+ const nonce = randomBytes(32);
128
+ const iat = <u64>(Date.now() / 1000);
129
+ const exp = iat + 120;
130
+
131
+ // Anti-enumeration: unknown user still gets a fully-formed challenge with
132
+ // a throwaway salt; the eventual verify just fails.
133
+ const salt = acct != null ? acct.salt : randomBytes(16);
134
+ const mem = acct != null ? acct.memKiB : <u32>MIN_MEM_KIB;
135
+ const iters = acct != null ? acct.iterations : <u32>MIN_ITERATIONS;
136
+ const par = acct != null ? acct.parallelism : 1;
137
+
138
+ if (acct != null) {
139
+ const c = new ChallengeRecord();
140
+ c.cid = cid;
141
+ c.username = username;
142
+ c.nonce = nonce;
143
+ c.iat = iat;
144
+ c.exp = exp;
145
+ Challenges.put(c);
146
+ }
147
+
148
+ const w = new DataWriter();
149
+ w.writeBytes(cid);
150
+ w.writeString(AUD);
151
+ w.writeU32(mem);
152
+ w.writeU32(iters);
153
+ w.writeU32(par);
154
+ w.writeBytes(salt);
155
+ w.writeBytes(nonce);
156
+ w.writeU64(iat);
157
+ w.writeU64(exp);
158
+ return Response.bytes(w.toBytes());
159
+ }
160
+
161
+ /** POST /auth/login/finish body: bytes(cid) bytes(sig) resp: u8(status) [+ bytes(session)] */
162
+ @post('/login/finish')
163
+ public loginFinish(ctx: RouteContext): Response {
164
+ const r = new DataReader(ctx.request.body);
165
+ const cid = r.readBytes();
166
+ const sig = r.readBytes();
167
+
168
+ // 1. CONSUME FIRST: atomic fetch-and-delete. Unknown/used/expired => fail.
169
+ const ch = Challenges.consume(cid);
170
+ if (ch == null) return fail();
171
+ const now = <u64>(Date.now() / 1000);
172
+ if (now >= ch.exp) return fail();
173
+
174
+ // 2. Rebuild the message from OUR stored values (never client-echoed),
175
+ // load the account's public key, verify under the login context.
176
+ const acct = Accounts.get(ch.username);
177
+ if (acct == null) return fail();
178
+ const message = AuthService.buildLoginMessage(ch.username, AUD, cid, ch.nonce, ch.iat, ch.exp);
179
+ if (!AuthService.verifyLogin(acct.publicKey, message, sig)) return fail();
180
+
181
+ // 3. Success: mint a session (cookie / token). App-specific.
182
+ return Response.bytes(new DataWriter().writeU8(0).toBytes());
183
+ }
184
+ }
@@ -0,0 +1,130 @@
1
+ import { Response, RouteContext, SecureCookies, base64UrlEncode, base64UrlDecode } from 'toiljs/server/runtime';
2
+ import { DataReader, DataWriter } from 'data';
3
+
4
+ import { Account } from './Session';
5
+
6
+ /**
7
+ * Post-quantum identity demo (server half), challenge-response.
8
+ *
9
+ * 1. GET /pq/challenge -> the edge mints a fresh nonce + cid + iat/exp and
10
+ * returns them PLUS an HMAC-signed `token` over those values. The token is
11
+ * the server-issued challenge: signed with a server-only key, it proves
12
+ * "the edge issued exactly this" WITHOUT any cross-request storage (the
13
+ * guest's memory is wiped every request).
14
+ * 2. POST /pq/verify -> the client signs the login message built from the
15
+ * SERVER's nonce/cid/iat/exp (ML-DSA-44, derived from the password) and
16
+ * returns {sub, token, publicKey, signature}. The edge re-opens the token
17
+ * (rejecting a forged or expired one), rebuilds the message from the values
18
+ * INSIDE the token (never client-echoed), and verifies the signature via
19
+ * `crypto.mldsa_verify` (`AuthService.verifyLogin`).
20
+ *
21
+ * The nonce is server-chosen and tamper-proof, and the challenge is time-bound,
22
+ * so a client cannot pre-sign or substitute its own nonce. What this stateless
23
+ * form does NOT have is single-use: within the TTL a captured {token, signature}
24
+ * could be replayed, because that needs an atomic consume against a store (Redis
25
+ * GETDEL / SQL DELETE RETURNING). The production login in server/routes/Auth.ts
26
+ * does exactly that; see docs/auth.md. Pairs with client/routes/pq.tsx.
27
+ */
28
+
29
+ const AUD = 'pq-demo'; // this demo's audience id (server config; never client-echoed)
30
+ const CHALLENGE_TTL_SECS: u64 = 120;
31
+
32
+ /** Server-only key for signing challenge tokens (demo constant; a real
33
+ * deployment uses a per-deployment secret, like the session secret). */
34
+ function challengeKey(): Uint8Array {
35
+ return crypto.sha256Text('toil-pq-demo-challenge-key-v1');
36
+ }
37
+
38
+ function randomBytes(n: i32): Uint8Array {
39
+ const b = new Uint8Array(n);
40
+ crypto.getRandomValues(b);
41
+ return b;
42
+ }
43
+
44
+ @rest('pq')
45
+ class PqDemo {
46
+ /** GET /pq/challenge
47
+ * resp: str(aud) bytes(cid) bytes(nonce) u64(iat) u64(exp) str(token) */
48
+ @get('/challenge')
49
+ public challenge(_ctx: RouteContext): Response {
50
+ const cid = randomBytes(16);
51
+ const nonce = randomBytes(32);
52
+ const iat = Time.nowSeconds();
53
+ const exp = iat + CHALLENGE_TTL_SECS;
54
+
55
+ // Sign (iat, exp, cid, nonce) so /verify can confirm the edge issued
56
+ // this exact challenge with no stored state.
57
+ const blob = new DataWriter()
58
+ .writeU64(iat)
59
+ .writeU64(exp)
60
+ .writeBytes(cid)
61
+ .writeBytes(nonce)
62
+ .toBytes();
63
+ const token = SecureCookies.signed(challengeKey()).sign('pqchal', base64UrlEncode(blob));
64
+
65
+ const w = new DataWriter();
66
+ w.writeString(AUD);
67
+ w.writeBytes(cid);
68
+ w.writeBytes(nonce);
69
+ w.writeU64(iat);
70
+ w.writeU64(exp);
71
+ w.writeString(token);
72
+ return Response.bytes(w.toBytes());
73
+ }
74
+
75
+ /** POST /pq/verify
76
+ * body: str(sub) str(token) bytes(publicKey 1312) bytes(signature 2420)
77
+ * resp: text VALID / INVALID */
78
+ @post('/verify')
79
+ public verify(ctx: RouteContext): Response {
80
+ const r = new DataReader(ctx.request.body);
81
+ const sub = r.readString();
82
+ const token = r.readString();
83
+ const pk = r.readBytes();
84
+ const sig = r.readBytes();
85
+ if (!r.ok) return Response.text('malformed envelope\n', 400);
86
+
87
+ // 1. Re-open the challenge token: must be server-issued + untampered.
88
+ const blobB64 = SecureCookies.signed(challengeKey()).unsign('pqchal', token);
89
+ if (blobB64 == null) return Response.text('INVALID: forged or unsigned challenge\n', 401);
90
+ const blob = base64UrlDecode(blobB64);
91
+ if (blob == null) return Response.text('INVALID: malformed challenge\n', 401);
92
+ const br = new DataReader(blob);
93
+ const iat = br.readU64();
94
+ const exp = br.readU64();
95
+ const cid = br.readBytes();
96
+ const nonce = br.readBytes();
97
+ if (!br.ok) return Response.text('INVALID: malformed challenge\n', 401);
98
+ if (Time.nowSeconds() >= exp) return Response.text('INVALID: challenge expired\n', 401);
99
+
100
+ // TODO(db): single-use consume is NOT implemented yet (no KV/DB host
101
+ // binding available). The real fix is an ATOMIC consume of `cid` here --
102
+ // Redis GETDEL / SQL DELETE ... RETURNING -- so a given challenge verifies
103
+ // at most once. Until that exists, this token is replayable within its
104
+ // TTL: a captured {token, signature} re-verifies until `exp`. The
105
+ // production login (server/routes/Auth.ts) shows the atomic-consume shape.
106
+
107
+ // 2. Rebuild the message from the SERVER's values (inside the token,
108
+ // never client-echoed) and verify the ML-DSA-44 signature.
109
+ const message = AuthService.buildLoginMessage(sub, AUD, cid, nonce, iat, exp);
110
+ if (!AuthService.verifyLogin(pk, message, sig)) {
111
+ return Response.text('INVALID: signature did not verify\n', 401);
112
+ }
113
+
114
+ // 3. FULL AUTH: a valid post-quantum proof logs the user in. Mint the
115
+ // signed session for the proven `sub` via the @user codec, plus the
116
+ // readable companion. Every `@auth` route now recognises this user
117
+ // and `AuthService.getUser()` returns `{ username: sub, ... }`.
118
+ const account = new Account();
119
+ account.username = sub;
120
+ account.admin = sub == 'root';
121
+ const userData = account.encode();
122
+ const resp = Response.text(
123
+ 'VALID: ML-DSA-44 (FIPS 204) verified; session established (@auth ready)\n',
124
+ 200,
125
+ );
126
+ resp.setCookie(AuthService.mintSession(userData, 3600));
127
+ resp.setCookie(AuthService.userCookie(userData, 3600));
128
+ return resp;
129
+ }
130
+ }
@@ -0,0 +1,74 @@
1
+ import { Response, RouteContext } from 'toiljs/server/runtime';
2
+ import { DataReader, DataWriter } from 'data';
3
+
4
+ /**
5
+ * Session demo: the `@user` / `@auth` / typed `AuthService.getUser()` surface.
6
+ *
7
+ * `@user` declares the authenticated user's shape; it becomes a binary codec
8
+ * (like `@data`) AND registers the type of `AuthService.getUser()` everywhere,
9
+ * server and generated client, with NO type argument.
10
+ *
11
+ * `@auth` on a route (or a whole `@rest` class) makes the generated dispatcher
12
+ * verify a valid signed session BEFORE the handler runs (401 otherwise).
13
+ *
14
+ * The session is an HMAC-signed `__Host-` cookie minted by `AuthService.mintSession`.
15
+ * In a real app you mint it in `Auth.loginFinish` AFTER `verifyLogin` succeeds;
16
+ * this `/session/dev-login` mints one for a caller-named demo user so the flow is
17
+ * runnable without the external account store the login example stubs out.
18
+ *
19
+ * The server secret defaults to a well-known DEV placeholder; a real deployment
20
+ * calls `AuthService.setSecret(...)` once at startup (see server/main.ts).
21
+ */
22
+
23
+ // @user: the authenticated-user shape. Exactly one per program. Exported so
24
+ // other routes (the PQ login) can mint a session via its generated codec.
25
+ @user
26
+ export class Account {
27
+ username: string = '';
28
+ admin: bool = false;
29
+ score: u64 = 0;
30
+ }
31
+
32
+ @rest('session')
33
+ class Session {
34
+ /** POST /session/dev-login body: str(username) -> sets the session cookie.
35
+ * DEV ONLY: a real app mints in loginFinish after the signature verifies. */
36
+ @post('/dev-login')
37
+ public devLogin(ctx: RouteContext): Response {
38
+ const username = new DataReader(ctx.request.body).readString();
39
+ const u = new Account();
40
+ u.username = username;
41
+ u.admin = username == 'root';
42
+ u.score = 0;
43
+
44
+ const data = u.encode();
45
+ const resp = Response.text('ok\n', 200);
46
+ resp.setCookie(AuthService.mintSession(data, 3600)); // HttpOnly signed session
47
+ resp.setCookie(AuthService.userCookie(data, 3600)); // readable companion (client getUser)
48
+ return resp;
49
+ }
50
+
51
+ /** GET /session/me (@auth: 401 without a valid session) -> the typed user.
52
+ * `AuthService.getUser()` is auto-typed to `Account` with no type argument. */
53
+ @auth
54
+ @get('/me')
55
+ public me(_ctx: RouteContext): Response {
56
+ const u = AuthService.getUser();
57
+ if (u == null) return Response.text('no session\n', 401);
58
+ const w = new DataWriter();
59
+ w.writeString(u.username);
60
+ w.writeBool(u.admin);
61
+ w.writeU64(u.score);
62
+ return Response.bytes(w.toBytes());
63
+ }
64
+
65
+ /** POST /session/logout (@auth) -> clears the session cookie. */
66
+ @auth
67
+ @post('/logout')
68
+ public logout(_ctx: RouteContext): Response {
69
+ const resp = Response.text('bye\n', 200);
70
+ resp.setCookie(AuthService.clearSession());
71
+ resp.setCookie(AuthService.clearUserCookie());
72
+ return resp;
73
+ }
74
+ }