toiljs 0.0.54 → 0.0.55

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,5 +1,6 @@
1
1
  import { Response, RouteContext } from 'toiljs/server/runtime';
2
2
  import { DataReader, DataWriter } from 'data';
3
+ import { Record } from 'toildb';
3
4
 
4
5
  import { encodeSessionUser } from './Session';
5
6
 
@@ -15,11 +16,11 @@ import { encodeSessionUser } from './Session';
15
16
  * auth). See `server/globals/auth.ts` (the `AuthService` global) and the client
16
17
  * half in `toiljs/client` (`Auth.register` / `Auth.login`).
17
18
  *
18
- * STORAGE: backed by the DEV-ONLY `kv.*` host imports (see
19
- * `src/devserver/kv.ts`) so the register -> login chain spans requests under
20
- * `toiljs dev`. REMOVE LATER: this is a stand-in; once toildb is implemented,
21
- * the Accounts/Challenges stores move onto it and this goes away. `kv.*` is not
22
- * on the production edge.
19
+ * STORAGE: backed by ToilDB (`@database AuthDb`). Accounts are a `record`
20
+ * collection keyed by username; login challenges are a `record` consumed exactly
21
+ * once with `getDelete` (atomic fetch-and-delete). The dev server emulates these
22
+ * `env.data.*` host imports in process (so register -> login spans requests under
23
+ * `toiljs dev`); the production edge backs the SAME API with ScyllaDB.
23
24
  *
24
25
  * Wire: every body/response is binary (`DataWriter`/`DataReader`), never JSON.
25
26
  */
@@ -36,19 +37,6 @@ const DEMO_PAR: u32 = 1;
36
37
  const CHALLENGE_TTL_SECS: u64 = 120;
37
38
  const SESSION_TTL_SECS: u64 = 3600;
38
39
 
39
- function utf8(s: string): Uint8Array {
40
- return Uint8Array.wrap(String.UTF8.encode(s));
41
- }
42
-
43
- function toHex(b: Uint8Array): string {
44
- let s = '';
45
- for (let i = 0; i < b.length; i++) {
46
- const v = b[i];
47
- s += (v < 16 ? '0' : '') + (<u32>v).toString(16);
48
- }
49
- return s;
50
- }
51
-
52
40
  function randomBytes(n: i32): Uint8Array {
53
41
  const b = new Uint8Array(n);
54
42
  crypto.getRandomValues(b);
@@ -77,44 +65,28 @@ function deriveSalt(username: string): Uint8Array {
77
65
  }
78
66
 
79
67
 
80
- // @ts-ignore: decorator
81
- @external('env', 'kv.put')
82
- declare function __kvPut(keyPtr: usize, keyLen: i32, valPtr: usize, valLen: i32): void;
83
- // @ts-ignore: decorator
84
- @external('env', 'kv.get')
85
- declare function __kvGet(keyPtr: usize, keyLen: i32, outPtr: usize, outCap: i32): i32;
86
- // @ts-ignore: decorator
87
- @external('env', 'kv.getdel')
88
- declare function __kvGetDel(keyPtr: usize, keyLen: i32, outPtr: usize, outCap: i32): i32;
68
+ // ToilDB collections (the `kv.*` dev placeholder is gone). The key + value are
69
+ // `@data` types: the binary codec is generated, the host marshals it, and the
70
+ // challenge is consumed exactly once with `getDelete`.
89
71
 
90
- const KV_CAP: i32 = 8192; // bounds account (~1.5 KiB) + challenge (~100 B) records
91
-
92
- function kvPut(key: Uint8Array, val: Uint8Array): void {
93
- __kvPut(key.dataStart, key.length, val.dataStart, val.length);
94
- }
95
- function kvGet(key: Uint8Array): Uint8Array | null {
96
- const out = new Uint8Array(KV_CAP);
97
- const n = __kvGet(key.dataStart, key.length, out.dataStart, KV_CAP);
98
- if (n < 0) return null;
99
- return out.slice(0, n);
100
- }
101
- /** Atomic fetch-and-delete: consumes a login challenge exactly once. */
102
- function kvGetDel(key: Uint8Array): Uint8Array | null {
103
- const out = new Uint8Array(KV_CAP);
104
- const n = __kvGetDel(key.dataStart, key.length, out.dataStart, KV_CAP);
105
- if (n < 0) return null;
106
- return out.slice(0, n);
72
+ @data
73
+ class Username {
74
+ name: string = '';
75
+ constructor(name: string = '') {
76
+ this.name = name;
77
+ }
107
78
  }
108
79
 
109
- function acctKey(username: string): Uint8Array {
110
- return utf8('acct:' + username);
111
- }
112
- function chalKey(cid: Uint8Array): Uint8Array {
113
- return utf8('chal:' + toHex(cid));
80
+ @data
81
+ class ChallengeId {
82
+ cid: Uint8Array = new Uint8Array(0);
83
+ constructor(cid: Uint8Array = new Uint8Array(0)) {
84
+ this.cid = cid;
85
+ }
114
86
  }
115
87
 
116
-
117
- class Account {
88
+ @data
89
+ class AuthAccount {
118
90
  username: string = '';
119
91
  salt: Uint8Array = new Uint8Array(0);
120
92
  publicKey: Uint8Array = new Uint8Array(0);
@@ -123,30 +95,7 @@ class Account {
123
95
  parallelism: u32 = 0;
124
96
  }
125
97
 
126
- function putAccount(a: Account): void {
127
- const w = new DataWriter();
128
- w.writeString(a.username);
129
- w.writeBytes(a.salt);
130
- w.writeBytes(a.publicKey);
131
- w.writeU32(a.memKiB);
132
- w.writeU32(a.iterations);
133
- w.writeU32(a.parallelism);
134
- kvPut(acctKey(a.username), w.toBytes());
135
- }
136
- function getAccount(username: string): Account | null {
137
- const raw = kvGet(acctKey(username));
138
- if (raw == null) return null;
139
- const r = new DataReader(raw);
140
- const a = new Account();
141
- a.username = r.readString();
142
- a.salt = r.readBytes();
143
- a.publicKey = r.readBytes();
144
- a.memKiB = r.readU32();
145
- a.iterations = r.readU32();
146
- a.parallelism = r.readU32();
147
- return r.ok ? a : null;
148
- }
149
-
98
+ @data
150
99
  class Challenge {
151
100
  cid: Uint8Array = new Uint8Array(0);
152
101
  username: string = '';
@@ -155,26 +104,10 @@ class Challenge {
155
104
  exp: u64 = 0;
156
105
  }
157
106
 
158
- function putChallenge(c: Challenge): void {
159
- const w = new DataWriter();
160
- w.writeBytes(c.cid);
161
- w.writeString(c.username);
162
- w.writeBytes(c.nonce);
163
- w.writeU64(c.iat);
164
- w.writeU64(c.exp);
165
- kvPut(chalKey(c.cid), w.toBytes());
166
- }
167
- function consumeChallenge(cid: Uint8Array): Challenge | null {
168
- const raw = kvGetDel(chalKey(cid));
169
- if (raw == null) return null;
170
- const r = new DataReader(raw);
171
- const c = new Challenge();
172
- c.cid = r.readBytes();
173
- c.username = r.readString();
174
- c.nonce = r.readBytes();
175
- c.iat = r.readU64();
176
- c.exp = r.readU64();
177
- return r.ok ? c : null;
107
+ @database
108
+ class AuthDb {
109
+ @collection accounts!: Record<AuthAccount, Username>;
110
+ @collection challenges!: Record<Challenge, ChallengeId>;
178
111
  }
179
112
 
180
113
  @rest('auth')
@@ -214,7 +147,7 @@ class Auth {
214
147
  if (pk.length != AuthService.PUBLIC_KEY_LEN) return fail();
215
148
  // Already registered: a distinguishable status (not the generic 401) so the
216
149
  // client can say "username taken, log in instead" rather than a blank error.
217
- if (getAccount(username) != null) {
150
+ if (AuthDb.accounts.exists(new Username(username))) {
218
151
  return Response.bytes(new DataWriter().writeU8(1).toBytes());
219
152
  }
220
153
 
@@ -223,14 +156,17 @@ class Auth {
223
156
  const regMsg = AuthService.buildRegisterMessage(username, pk);
224
157
  if (!AuthService.verifyRegister(pk, regMsg, proof)) return fail();
225
158
 
226
- const a = new Account();
159
+ const a = new AuthAccount();
227
160
  a.username = username;
228
161
  a.salt = deriveSalt(username);
229
162
  a.publicKey = pk;
230
163
  a.memKiB = DEMO_MEM_KIB;
231
164
  a.iterations = DEMO_ITERS;
232
165
  a.parallelism = DEMO_PAR;
233
- putAccount(a);
166
+ // create-if-absent: a racing duplicate registration loses here, not above.
167
+ if (!AuthDb.accounts.create(new Username(username), a)) {
168
+ return Response.bytes(new DataWriter().writeU8(1).toBytes());
169
+ }
234
170
  return Response.bytes(new DataWriter().writeU8(0).toBytes());
235
171
  }
236
172
 
@@ -249,7 +185,7 @@ class Auth {
249
185
  const evaluated = AuthService.oprfEvaluate(username, blinded);
250
186
  if (evaluated.length != AuthService.OPRF_ELEMENT_LEN) return fail();
251
187
 
252
- const acct = getAccount(username);
188
+ const known = AuthDb.accounts.exists(new Username(username));
253
189
  const cid = randomBytes(16);
254
190
  const nonce = randomBytes(32);
255
191
  const iat = nowSecs();
@@ -257,14 +193,14 @@ class Auth {
257
193
 
258
194
  // Persist only for a real account; the response is identical either way,
259
195
  // and login/finish for an unknown user fails generically at consume.
260
- if (acct != null) {
196
+ if (known) {
261
197
  const c = new Challenge();
262
198
  c.cid = cid;
263
199
  c.username = username;
264
200
  c.nonce = nonce;
265
201
  c.iat = iat;
266
202
  c.exp = exp;
267
- putChallenge(c);
203
+ AuthDb.challenges.create(new ChallengeId(cid), c);
268
204
  }
269
205
 
270
206
  const w = new DataWriter();
@@ -292,13 +228,13 @@ class Auth {
292
228
  if (!r.ok) return fail();
293
229
 
294
230
  // 1. CONSUME FIRST: atomic fetch-and-delete. Unknown/used/expired => fail.
295
- const ch = consumeChallenge(cid);
231
+ const ch = AuthDb.challenges.getDelete(new ChallengeId(cid));
296
232
  if (ch == null) return fail();
297
233
  if (nowSecs() >= ch.exp) return fail();
298
234
 
299
235
  // 2. Rebuild the message from OUR stored values + the client's ct (and
300
236
  // the bound params + server key id), load the account key, verify.
301
- const acct = getAccount(ch.username);
237
+ const acct = AuthDb.accounts.get(new Username(ch.username));
302
238
  if (acct == null) return fail();
303
239
  const message = AuthService.buildLoginMessage(
304
240
  ch.username, AUD, cid, ch.nonce, ch.iat, ch.exp,
@@ -0,0 +1,62 @@
1
+ import { Counter, Events } from 'toildb';
2
+
3
+ import { GuestEntry } from '../models/GuestEntry';
4
+ import { GuestbookView } from '../models/GuestbookView';
5
+ import { NewMessage } from '../models/NewMessage';
6
+
7
+ /**
8
+ * A PERSISTENT guestbook, mounted at `/guestbook`, backed by ToilDB.
9
+ *
10
+ * The contrast with `Players` (whose comment notes "memory resets next request")
11
+ * is the whole point: every signature is appended to an `events` stream and
12
+ * tallied in a `counter`, so the data SURVIVES across requests under `toiljs dev`
13
+ * (the in-process ToilDB emulator) and runs on ScyllaDB at the edge - same code,
14
+ * no connection string. On the client:
15
+ *
16
+ * await Server.REST.guestbook.sign({ body: new NewMessage('Ada', 'hi!') });
17
+ * const book = await Server.REST.guestbook.list(); // { total, entries: [...] }
18
+ */
19
+
20
+ // The guestbook is one global stream; a single fixed key addresses it.
21
+ @data
22
+ class GuestKey {
23
+ room: string = 'main';
24
+ constructor(room: string = 'main') {
25
+ this.room = room;
26
+ }
27
+ }
28
+
29
+ @database
30
+ class GuestbookDb {
31
+ @collection entries!: Events<GuestEntry, GuestKey>;
32
+ @collection totals!: Counter<GuestKey>;
33
+ }
34
+
35
+ /** The current total + the 10 newest entries. */
36
+ function snapshot(): GuestbookView {
37
+ const key = new GuestKey('main');
38
+ const view = new GuestbookView();
39
+ view.total = GuestbookDb.totals.get(key);
40
+ view.entries = GuestbookDb.entries.latest(key, 10);
41
+ return view;
42
+ }
43
+
44
+ @rest('guestbook')
45
+ class Guestbook {
46
+ /** `GET /guestbook` - the running total + the most recent signatures. */
47
+ @get('/')
48
+ public list(): GuestbookView {
49
+ return snapshot();
50
+ }
51
+
52
+ /** `POST /guestbook` - append a signature (PERSISTED) and return the
53
+ * updated book. Sign twice and the total keeps climbing across requests. */
54
+ @post('/')
55
+ public sign(input: NewMessage): GuestbookView {
56
+ const key = new GuestKey('main');
57
+ const at = <u64>(Date.now() / 1000);
58
+ GuestbookDb.entries.append(key, new GuestEntry(input.author, input.message, at));
59
+ GuestbookDb.totals.add(key, 1);
60
+ return snapshot();
61
+ }
62
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "toiljs",
3
3
  "type": "module",
4
- "version": "0.0.54",
4
+ "version": "0.0.55",
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": {
@@ -131,7 +131,7 @@
131
131
  "nodemailer": "^9.0.0",
132
132
  "picocolors": "^1.1.1",
133
133
  "sharp": "^0.35.0",
134
- "toilscript": "^0.1.27",
134
+ "toilscript": "^0.1.28",
135
135
  "typescript-eslint": "^8.60.0",
136
136
  "vite": "^8.0.14",
137
137
  "vite-imagetools": "^10.0.0",
@@ -10,7 +10,7 @@
10
10
  // and the toiljs dev-server mock).
11
11
 
12
12
  import { DataWriter, DataReader } from 'data';
13
- import { HmacImportParams, HmacParams, ALG_SHA_256, USAGE_SIGN, USAGE_VERIFY } from 'crypto';
13
+ import { HmacImportParams, HmacParams, ALG_SHA_256, FMT_RAW, USAGE_SIGN, USAGE_VERIFY } from 'crypto';
14
14
 
15
15
  import {
16
16
  Server,
@@ -160,7 +160,7 @@ function __resolveServerKemPk(): Uint8Array {
160
160
  // both keyed PRFs over the transcript; the client mirrors this with hash-wasm.
161
161
  function __hmacSha256(key: Uint8Array, msg: Uint8Array): Uint8Array {
162
162
  const k = crypto.subtle.importKey(
163
- 'raw',
163
+ FMT_RAW,
164
164
  key,
165
165
  new HmacImportParams(ALG_SHA_256),
166
166
  false,
@@ -511,7 +511,7 @@ export namespace AuthService {
511
511
 
512
512
  /** SHA-256 over `data` (ambient Web Crypto), for transcript/confirm hashing. */
513
513
  export function sha256(data: Uint8Array): Uint8Array {
514
- return crypto.subtle.digest('SHA-256', data);
514
+ return crypto.subtle.digest(ALG_SHA_256, data);
515
515
  }
516
516
 
517
517
  /** `SHA-256(serverKemPublicKey)` -- the key identity bound into the login
@@ -24,6 +24,7 @@ import {
24
24
  HmacImportParams,
25
25
  HmacParams,
26
26
  ALG_SHA_256,
27
+ FMT_RAW,
27
28
  USAGE_SIGN,
28
29
  USAGE_VERIFY,
29
30
  } from 'crypto';
@@ -45,7 +46,7 @@ const TWOFA_VERSION: u8 = 1;
45
46
 
46
47
  function importHmac(key: Uint8Array): CryptoKey {
47
48
  return crypto.subtle.importKey(
48
- 'raw',
49
+ FMT_RAW,
49
50
  key,
50
51
  new HmacImportParams(ALG_SHA_256),
51
52
  false,
@@ -30,6 +30,7 @@ import {
30
30
  HmacParams,
31
31
  ALG_AES_GCM,
32
32
  ALG_SHA_256,
33
+ FMT_RAW,
33
34
  USAGE_SIGN,
34
35
  USAGE_VERIFY,
35
36
  USAGE_ENCRYPT,
@@ -104,7 +105,7 @@ export class SecureCookies {
104
105
 
105
106
  private importHmac(key: Uint8Array): CryptoKey {
106
107
  return crypto.subtle.importKey(
107
- 'raw',
108
+ FMT_RAW,
108
109
  key,
109
110
  new HmacImportParams(ALG_SHA_256),
110
111
  false,
@@ -114,7 +115,7 @@ export class SecureCookies {
114
115
 
115
116
  private importAes(key: Uint8Array): CryptoKey {
116
117
  return crypto.subtle.importKey(
117
- 'raw',
118
+ FMT_RAW,
118
119
  key,
119
120
  new AesKeyParams(),
120
121
  false,