toiljs 0.0.33 → 0.0.36

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 (130) hide show
  1. package/CHANGELOG.md +19 -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 +124 -7
  6. package/build/client/.tsbuildinfo +1 -1
  7. package/build/client/auth.d.ts +42 -0
  8. package/build/client/auth.js +179 -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/cache.d.ts +8 -0
  29. package/build/devserver/cache.js +0 -0
  30. package/build/devserver/crypto.js +15 -0
  31. package/build/devserver/host.js +1 -0
  32. package/build/devserver/index.js +10 -1
  33. package/build/devserver/module.d.ts +1 -0
  34. package/build/devserver/module.js +23 -1
  35. package/docs/README.md +56 -0
  36. package/docs/auth.md +261 -0
  37. package/docs/caching.md +115 -0
  38. package/docs/cookies.md +457 -0
  39. package/docs/crypto.md +130 -0
  40. package/docs/data.md +131 -0
  41. package/docs/getting-started.md +128 -0
  42. package/docs/routing.md +259 -0
  43. package/docs/rpc.md +149 -0
  44. package/docs/ssr.md +184 -0
  45. package/docs/time.md +43 -0
  46. package/examples/basic/client/routes/auth.tsx +198 -0
  47. package/examples/basic/client/routes/cookies.tsx +199 -0
  48. package/examples/basic/client/routes/features/index.tsx +34 -10
  49. package/examples/basic/client/routes/hello.tsx +43 -0
  50. package/examples/basic/client/routes/pq.tsx +135 -0
  51. package/examples/basic/server/AuthTestHandler.ts +15 -0
  52. package/examples/basic/server/AuthVerifyHandler.ts +23 -0
  53. package/examples/basic/server/CacheHandler.ts +25 -0
  54. package/examples/basic/server/DecoCache.ts +18 -0
  55. package/examples/basic/server/FastTrapHandler.ts +8 -0
  56. package/examples/basic/server/README.md +19 -0
  57. package/examples/basic/server/SpinHandler.ts +18 -0
  58. package/examples/basic/server/SsrGreetingRender.ts +27 -0
  59. package/examples/basic/server/authexample-main.ts +8 -0
  60. package/examples/basic/server/authtest-main.ts +8 -0
  61. package/examples/basic/server/authverify-main.ts +8 -0
  62. package/examples/basic/server/cache-main.ts +8 -0
  63. package/examples/basic/server/core/AppHandler.ts +290 -0
  64. package/examples/basic/server/core/store.ts +31 -0
  65. package/examples/basic/server/deco-main.ts +18 -0
  66. package/examples/basic/server/main.ts +13 -2
  67. package/examples/basic/server/models/NewPlayer.ts +5 -0
  68. package/examples/basic/server/models/Player.ts +8 -0
  69. package/examples/basic/server/models/ScoreDelta.ts +5 -0
  70. package/examples/basic/server/models/Standings.ts +7 -0
  71. package/examples/basic/server/routes/Auth.ts +184 -0
  72. package/examples/basic/server/routes/Leaderboard.ts +20 -0
  73. package/examples/basic/server/routes/Players.ts +53 -0
  74. package/examples/basic/server/routes/PqDemo.ts +109 -0
  75. package/examples/basic/server/routes/Session.ts +73 -0
  76. package/examples/basic/server/scheduled/README.md +7 -0
  77. package/examples/basic/server/services/Stats.ts +11 -0
  78. package/examples/basic/server/services/remotes.ts +7 -0
  79. package/examples/basic/server/spin-main.ts +13 -0
  80. package/examples/basic/server/ssr/greeting.slots.ts +19 -0
  81. package/examples/basic/server/ssr-main.ts +18 -0
  82. package/examples/basic/server/toil-server-env.d.ts +94 -0
  83. package/examples/basic/server/trap-main.ts +8 -0
  84. package/package.json +5 -3
  85. package/server/globals/auth.ts +281 -0
  86. package/server/runtime/README.md +61 -0
  87. package/server/runtime/env/Server.ts +12 -0
  88. package/server/runtime/exports/index.ts +17 -0
  89. package/server/runtime/exports/render.ts +51 -0
  90. package/server/runtime/http/base64.ts +104 -0
  91. package/server/runtime/http/cookie.ts +416 -0
  92. package/server/runtime/http/cookies.ts +197 -0
  93. package/server/runtime/http/date.ts +72 -0
  94. package/server/runtime/http/percent.ts +76 -0
  95. package/server/runtime/http/securecookies.ts +224 -0
  96. package/server/runtime/index.ts +17 -0
  97. package/server/runtime/request.ts +24 -0
  98. package/server/runtime/response.ts +85 -0
  99. package/server/runtime/ssr/Ssr.ts +43 -0
  100. package/server/runtime/ssr/encode.ts +110 -0
  101. package/server/runtime/ssr/escape.ts +83 -0
  102. package/server/runtime/ssr/slots.ts +144 -0
  103. package/server/runtime/time.ts +29 -0
  104. package/src/cli/create.ts +159 -14
  105. package/src/client/auth.ts +322 -0
  106. package/src/client/index.ts +5 -1
  107. package/src/client/routing/loader.ts +56 -0
  108. package/src/client/routing/mount.tsx +37 -1
  109. package/src/client/ssr/markers.tsx +140 -0
  110. package/src/compiler/docs.ts +88 -1
  111. package/src/compiler/generate.ts +2 -2
  112. package/src/compiler/index.ts +5 -0
  113. package/src/compiler/ssr-codegen.ts +85 -0
  114. package/src/compiler/template-build.ts +275 -0
  115. package/src/compiler/template.ts +265 -0
  116. package/src/devserver/cache.ts +0 -0
  117. package/src/devserver/crypto.ts +23 -0
  118. package/src/devserver/host.ts +4 -0
  119. package/src/devserver/index.ts +21 -1
  120. package/src/devserver/module.ts +39 -1
  121. package/test/assembly/cookie.spec.ts +302 -0
  122. package/test/assembly/example.spec.ts +5 -1
  123. package/test/assembly/ssr.spec.ts +94 -0
  124. package/test/devserver.test.ts +48 -4
  125. package/test/fixtures/bignum-wire/spec.ts +27 -0
  126. package/test/rpc-bignum-wire.test.ts +164 -0
  127. package/test/ssr-render.test.ts +128 -0
  128. package/test/ssr-template.test.tsx +348 -0
  129. package/examples/basic/server/HelloHandler.ts +0 -42
  130. package/examples/basic/server/api.ts +0 -137
@@ -0,0 +1,290 @@
1
+ import { Method, Request, Response, Rest, ToilHandler } from 'toiljs/server/runtime';
2
+
3
+ /**
4
+ * The app's request handler: every request enters here. `@rest` controllers (see
5
+ * `routes/`) are tried first via `Rest.dispatch`; whatever they do not claim falls
6
+ * through to the hand-rolled demo endpoints below, then yields to the client.
7
+ */
8
+ export class AppHandler extends ToilHandler {
9
+ public handle(req: Request): Response {
10
+ // Rest.dispatch returns the first matching route's Response, or null if nothing
11
+ // matched - then we fall through to our own logic. REST composes; it never takes
12
+ // over handle().
13
+ const hit = Rest.dispatch(req);
14
+ if (hit != null) {
15
+ return hit;
16
+ }
17
+
18
+ if (req.method != Method.GET && req.method != Method.HEAD) {
19
+ return Response.empty(405).setHeader('allow', 'GET, HEAD');
20
+ }
21
+
22
+ if (req.path == '/json') {
23
+ return Response.json('{"hello":"toiljs"}\n');
24
+ }
25
+
26
+ if (req.path == '/echo') {
27
+ return Response.text('you GET ' + req.path + '\n');
28
+ }
29
+
30
+ // Web Crypto demo. `crypto` is a global (no import), synchronous: the
31
+ // same SubtleCrypto-style API the browser has, running in the server
32
+ // wasm via metered host functions.
33
+ if (req.path == '/api/hash') {
34
+ const digest = crypto.sha256Text(req.path);
35
+ return Response.json('{"sha256":"' + crypto.toHex(digest) + '"}\n');
36
+ }
37
+
38
+ if (req.path == '/api/uuid') {
39
+ return Response.text(crypto.randomUUID() + '\n');
40
+ }
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
+
48
+ // Unhandled (not a plain notFound): tells the host this server has no
49
+ // answer for the path, so it may serve it itself. Under `toiljs dev`
50
+ // that falls through to Vite (client routes, assets).
51
+ return Response.unhandled();
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
+ }
290
+ }
@@ -0,0 +1,31 @@
1
+ // The demo's shared state, used by the routes and services.
2
+ //
3
+ // IMPORTANT - the server runs with a FRESH WebAssembly instance per request, so linear memory
4
+ // (and any module-level state, like the `store` below) is reset on every request. It does NOT
5
+ // persist across requests. We seed a few players at module init so the read routes always have
6
+ // data; writes take effect only for the current request's response. For real persistence, call
7
+ // out to a database or KV store from your handler.
8
+
9
+ import { Player } from '../models/Player';
10
+
11
+ /** Players by id, re-seeded on EVERY request (see the note above). */
12
+ export const store = new Map<u64, Player>();
13
+
14
+ let nextId: u64 = 1;
15
+
16
+ /** The next fresh player id (module-local so callers cannot desync it). */
17
+ export function allocId(): u64 {
18
+ return nextId++;
19
+ }
20
+
21
+ function seed(name: string, score: i64): void {
22
+ const p = new Player();
23
+ p.id = u256.fromU64(allocId());
24
+ p.name = name;
25
+ p.score = score;
26
+ store.set(p.id.toU64(), p);
27
+ }
28
+
29
+ seed('Ada', 120);
30
+ seed('Linus', 95);
31
+ seed('Grace', 140);
@@ -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
+ }
@@ -1,13 +1,24 @@
1
1
  import { Server } from 'toiljs/server/runtime';
2
2
  import { revertOnError } from 'toiljs/server/runtime/abort/abort';
3
- import { HelloHandler } from './HelloHandler';
3
+
4
+ import { AppHandler } from './core/AppHandler';
5
+
6
+ // Surface modules: @rest routes and @service/@remote RPC. `toiljs build` discovers every
7
+ // decorated file under server/ on its own; importing them here keeps a direct `toilscript`
8
+ // run (which only sees the toilconfig entries) building the exact same server.
9
+ import './routes/Players';
10
+ import './routes/Leaderboard';
11
+ import './routes/Session';
12
+ import './routes/PqDemo';
13
+ import './services/Stats';
14
+ import './services/remotes';
4
15
 
5
16
  // DO NOT TOUCH THIS.
6
17
  Server.handler = () => {
7
18
  // ONLY CHANGE THE HANDLER CLASS NAME.
8
19
  // DO NOT ADD CUSTOM LOGIC HERE.
9
20
 
10
- return new HelloHandler();
21
+ return new AppHandler();
11
22
  };
12
23
 
13
24
  // VERY IMPORTANT
@@ -0,0 +1,5 @@
1
+ /** Request body for `POST /players` - the fields a client supplies to create a player. */
2
+ @data
3
+ export class NewPlayer {
4
+ name: string = '';
5
+ }
@@ -0,0 +1,8 @@
1
+ /** A leaderboard player. The `u256` id shows native bignums riding the wire: it crosses
2
+ * JSON as four 64-bit limbs and lands on the client as one `bigint`. */
3
+ @data
4
+ export class Player {
5
+ id: u256 = u256.Zero;
6
+ name: string = '';
7
+ score: i64 = 0;
8
+ }
@@ -0,0 +1,5 @@
1
+ /** Request body for `POST /players/:id/score` - points to add to a player's score. */
2
+ @data
3
+ export class ScoreDelta {
4
+ points: i64 = 0;
5
+ }
@@ -0,0 +1,7 @@
1
+ import { Player } from './Player';
2
+
3
+ /** A leaderboard page. A `@data` wrapper so the `Player[]` round-trips through the codec. */
4
+ @data
5
+ export class Standings {
6
+ players: Player[] = [];
7
+ }
@@ -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,20 @@
1
+ import { store } from '../core/store';
2
+ import { Player } from '../models/Player';
3
+ import { Standings } from '../models/Standings';
4
+
5
+ /**
6
+ * The leaderboard, mounted at `/leaderboard`. On the client:
7
+ * const board = await Server.REST.leaderboard.top(); // typed Standings { players: Player[] }
8
+ */
9
+ @rest('leaderboard')
10
+ class Leaderboard {
11
+ /** `GET /leaderboard` - the seeded players, highest score first. */
12
+ @get('/')
13
+ public top(): Standings {
14
+ const board = new Standings();
15
+ const all = store.values();
16
+ for (let i = 0; i < all.length; i++) board.players.push(all[i]);
17
+ board.players.sort((a: Player, b: Player): i32 => (a.score < b.score ? 1 : a.score > b.score ? -1 : 0));
18
+ return board;
19
+ }
20
+ }
@@ -0,0 +1,53 @@
1
+ import { Response, RouteContext } from 'toiljs/server/runtime';
2
+
3
+ import { allocId, store } from '../core/store';
4
+ import { NewPlayer } from '../models/NewPlayer';
5
+ import { Player } from '../models/Player';
6
+ import { ScoreDelta } from '../models/ScoreDelta';
7
+
8
+ /**
9
+ * Players, mounted at `/players`. On the client:
10
+ * await Server.REST.players.get({ params: { id } })
11
+ * await Server.REST.players.create({ body: new NewPlayer('Bob') })
12
+ * await Server.REST.players.addScore({ params: { id }, body: new ScoreDelta(10n) })
13
+ */
14
+ @rest('players')
15
+ class Players {
16
+ /** `GET /players/:id` - returns a `Response` for full control: a real 404 for a missing id,
17
+ * a custom header, and the `@data` body serialized with `toJSON()`. (The toilscript editor
18
+ * plugin types the compiler-injected `toJSON()`, so this is clean; return the `@data` type
19
+ * directly, like the other routes, when you do not need that control.) */
20
+ @get('/:id')
21
+ public get(ctx: RouteContext): Response {
22
+ const id = u64.parse(ctx.param('id'));
23
+ if (!store.has(id)) return Response.notFound();
24
+ const p = store.get(id);
25
+
26
+ return Response.json(p.toJSON().toString()).setHeader('cache-control', 'no-store');
27
+ }
28
+
29
+ /** `POST /players` - build a player from the request body and return it with a fresh id.
30
+ * Note: it is NOT saved (memory resets next request); persist to a real store to keep it. */
31
+ @post('/')
32
+ public create(input: NewPlayer): Player {
33
+ const p = new Player();
34
+ p.id = u256.fromU64(allocId());
35
+ p.name = input.name;
36
+ p.score = 0;
37
+ store.set(p.id.toU64(), p);
38
+
39
+ return p;
40
+ }
41
+
42
+ /** `POST /players/:id/score` - add `points` (from the body) to the seeded player named by
43
+ * `:id` and return it. The change applies to this response only (memory resets next request). */
44
+ @post('/:id/score')
45
+ public addScore(input: ScoreDelta, ctx: RouteContext): Player {
46
+ const id = u64.parse(ctx.param('id'));
47
+ if (!store.has(id)) return new Player();
48
+ const p = store.get(id);
49
+ p.score += input.points;
50
+
51
+ return p;
52
+ }
53
+ }