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.
@@ -0,0 +1,304 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+
3
+ import { __resetDbForTests, buildDatabaseImports, freshDbState } from '../src/devserver/database.js';
4
+ import type { MemoryRef } from '../src/devserver/host.js';
5
+
6
+ function setup() {
7
+ const memory = new WebAssembly.Memory({ initial: 1 });
8
+ const ref: MemoryRef = { memory };
9
+ const db = freshDbState();
10
+ const imports = buildDatabaseImports(ref, db);
11
+ const buf = Buffer.from(memory.buffer);
12
+ return { ref, db, imports, buf };
13
+ }
14
+
15
+ /** Write bytes at `offset`, returning the `[ptr, len]` pair the imports expect. */
16
+ function put(buf: Buffer, offset: number, data: string): [number, number] {
17
+ const b = Buffer.from(data);
18
+ b.copy(buf, offset);
19
+ return [offset, b.length];
20
+ }
21
+
22
+ function resolve(imports: Record<string, (...a: number[]) => number>, buf: Buffer, name: string): number {
23
+ const [p, l] = put(buf, 0, name);
24
+ expect(imports['data.resolve_collection'](p, l, 16)).toBe(0);
25
+ return buf.readUInt32LE(16);
26
+ }
27
+
28
+ afterEach(() => {
29
+ __resetDbForTests();
30
+ });
31
+
32
+ describe('toildb dev emulator (record family)', () => {
33
+ it('resolve + create + get + take_result round-trips', () => {
34
+ const { imports, buf } = setup();
35
+ const h = resolve(imports, buf, 'App/users');
36
+ const [kPtr, kLen] = put(buf, 32, 'u1');
37
+ const [vPtr, vLen] = put(buf, 48, 'hello');
38
+
39
+ expect(imports['data.create'](h, kPtr, kLen, vPtr, vLen, 0)).toBe(0);
40
+ expect(imports['data.create'](h, kPtr, kLen, vPtr, vLen, 0)).toBe(-1000); // AlreadyExists
41
+ expect(imports['data.exists'](h, kPtr, kLen)).toBe(1);
42
+
43
+ expect(imports['data.get'](h, kPtr, kLen)).toBe(5);
44
+ expect(imports['data.take_result'](64, 64)).toBe(5);
45
+ expect(buf.toString('utf8', 64, 69)).toBe('hello');
46
+ });
47
+
48
+ it('patch updates and a following get sees the new value', () => {
49
+ const { imports, buf } = setup();
50
+ const h = resolve(imports, buf, 'App/users');
51
+ const [kPtr, kLen] = put(buf, 32, 'u1');
52
+ const [vPtr, vLen] = put(buf, 48, 'hello');
53
+ imports['data.create'](h, kPtr, kLen, vPtr, vLen, 0);
54
+
55
+ const [pPtr, pLen] = put(buf, 80, 'world!');
56
+ expect(imports['data.patch'](h, kPtr, kLen, pPtr, pLen, 0)).toBe(6);
57
+ expect(imports['data.get'](h, kPtr, kLen)).toBe(6);
58
+ imports['data.take_result'](128, 64);
59
+ expect(buf.toString('utf8', 128, 134)).toBe('world!');
60
+ });
61
+
62
+ it('patch on a missing key is NotFound', () => {
63
+ const { imports, buf } = setup();
64
+ const h = resolve(imports, buf, 'App/users');
65
+ const [kPtr, kLen] = put(buf, 32, 'ghost');
66
+ const [pPtr, pLen] = put(buf, 48, 'x');
67
+ expect(imports['data.patch'](h, kPtr, kLen, pPtr, pLen, 0)).toBe(-1000);
68
+ });
69
+
70
+ it('consume-once get_delete deletes exactly once', () => {
71
+ const { imports, buf } = setup();
72
+ const h = resolve(imports, buf, 'App/challenges');
73
+ const [kPtr, kLen] = put(buf, 32, 'chal');
74
+ const [vPtr, vLen] = put(buf, 48, 'nonce');
75
+ imports['data.create'](h, kPtr, kLen, vPtr, vLen, 0);
76
+
77
+ expect(imports['data.get_delete'](h, kPtr, kLen, 0)).toBe(5);
78
+ imports['data.take_result'](64, 64);
79
+ expect(buf.toString('utf8', 64, 69)).toBe('nonce');
80
+ // replay defeated
81
+ expect(imports['data.get_delete'](h, kPtr, kLen, 0)).toBe(-2);
82
+ });
83
+
84
+ it('absent / invalid handle / buffer-too-small return the edge codes', () => {
85
+ const { imports, buf } = setup();
86
+ const h = resolve(imports, buf, 'App/users');
87
+ const [kPtr, kLen] = put(buf, 32, 'k');
88
+
89
+ expect(imports['data.get'](h, kPtr, kLen)).toBe(-2); // absent
90
+ expect(imports['data.get'](999, kPtr, kLen)).toBe(-1001); // invalid handle
91
+
92
+ const [vPtr, vLen] = put(buf, 48, 'bigvalue');
93
+ imports['data.create'](h, kPtr, kLen, vPtr, vLen, 0);
94
+ imports['data.get'](h, kPtr, kLen);
95
+ expect(imports['data.take_result'](64, 2)).toBe(-1); // too small, stash kept
96
+ expect(imports['data.take_result'](64, 64)).toBe(8); // retry succeeds
97
+ });
98
+
99
+ it('unique claim: Claimed / AlreadyOwnedByCaller / AlreadyClaimed', () => {
100
+ const { imports, buf } = setup();
101
+ const h = resolve(imports, buf, 'App/usernames');
102
+ const [kPtr, kLen] = put(buf, 32, 'ada');
103
+ const [u1Ptr, u1Len] = put(buf, 48, 'user_1');
104
+ const [u2Ptr, u2Len] = put(buf, 64, 'user_2');
105
+
106
+ expect(imports['data.unique_claim'](h, kPtr, kLen, u1Ptr, u1Len, 0)).toBe(0); // Claimed
107
+ expect(imports['data.unique_claim'](h, kPtr, kLen, u1Ptr, u1Len, 0)).toBe(2); // AlreadyOwnedByCaller
108
+ // a different owner -> AlreadyClaimed, current owner stashed
109
+ expect(imports['data.unique_claim'](h, kPtr, kLen, u2Ptr, u2Len, 0)).toBe(1);
110
+ expect(imports['data.take_result'](128, 64)).toBe(6);
111
+ expect(buf.toString('utf8', 128, 134)).toBe('user_1');
112
+
113
+ // lookup returns the owner
114
+ expect(imports['data.unique_lookup'](h, kPtr, kLen)).toBe(6);
115
+ imports['data.take_result'](200, 64);
116
+ expect(buf.toString('utf8', 200, 206)).toBe('user_1');
117
+ });
118
+
119
+ it('unique release: only the owner may release', () => {
120
+ const { imports, buf } = setup();
121
+ const h = resolve(imports, buf, 'App/usernames');
122
+ const [kPtr, kLen] = put(buf, 32, 'ada');
123
+ const [u1Ptr, u1Len] = put(buf, 48, 'user_1');
124
+ const [u2Ptr, u2Len] = put(buf, 64, 'user_2');
125
+ imports['data.unique_claim'](h, kPtr, kLen, u1Ptr, u1Len, 0);
126
+
127
+ expect(imports['data.unique_release'](h, kPtr, kLen, u2Ptr, u2Len, 0)).toBe(-1000); // not owner
128
+ expect(imports['data.unique_release'](h, kPtr, kLen, u1Ptr, u1Len, 0)).toBe(0); // owner releases
129
+ expect(imports['data.unique_lookup'](h, kPtr, kLen)).toBe(-2); // gone
130
+ });
131
+
132
+ it('get_many: framed multi-get preserves order with present/absent', () => {
133
+ const { imports, buf } = setup();
134
+ const h = resolve(imports, buf, 'App/users');
135
+ const [k1, l1] = put(buf, 32, 'u1');
136
+ const [vA, lA] = put(buf, 48, 'AA');
137
+ imports['data.create'](h, k1, l1, vA, lA, 0);
138
+ const [k2, l2] = put(buf, 64, 'u2');
139
+ const [vB, lB] = put(buf, 80, 'BB');
140
+ imports['data.create'](h, k2, l2, vB, lB, 0);
141
+
142
+ // keys blob at 128: count=3, then "u1","u3","u2" (u3 absent).
143
+ let o = 128;
144
+ o = buf.writeUInt32LE(3, o);
145
+ for (const k of ['u1', 'u3', 'u2']) {
146
+ o = buf.writeUInt32LE(2, o);
147
+ o += buf.write(k, o, 'latin1');
148
+ }
149
+ const n = imports['data.get_many'](h, 128, o - 128);
150
+ expect(n).toBeGreaterThan(0);
151
+ expect(imports['data.take_result'](256, 256)).toBe(n);
152
+
153
+ let p = 256;
154
+ const count = buf.readUInt32LE(p);
155
+ p += 4;
156
+ expect(count).toBe(3);
157
+ const got: (string | null)[] = [];
158
+ for (let i = 0; i < count; i++) {
159
+ const present = buf.readUInt8(p);
160
+ p += 1;
161
+ if (present === 0) {
162
+ got.push(null);
163
+ continue;
164
+ }
165
+ const len = buf.readUInt32LE(p);
166
+ p += 4;
167
+ got.push(buf.toString('utf8', p, p + len));
168
+ p += len;
169
+ }
170
+ expect(got).toEqual(['AA', null, 'BB']);
171
+
172
+ expect(imports['data.get_many'](999, 128, o - 128)).toBe(-1001); // invalid handle
173
+ });
174
+
175
+ it('membership: add/contains/remove + sorted framed list', () => {
176
+ const { imports, buf } = setup();
177
+ const h = resolve(imports, buf, 'App/rooms');
178
+ const [sPtr, sLen] = put(buf, 32, 'room1');
179
+
180
+ const [aPtr, aLen] = put(buf, 48, 'alice');
181
+ const [bPtr, bLen] = put(buf, 64, 'bob');
182
+ expect(imports['data.membership_contains'](h, sPtr, sLen, aPtr, aLen)).toBe(0); // absent
183
+ expect(imports['data.membership_add'](h, sPtr, sLen, aPtr, aLen, 0)).toBe(0);
184
+ expect(imports['data.membership_add'](h, sPtr, sLen, bPtr, bLen, 0)).toBe(0);
185
+ expect(imports['data.membership_add'](h, sPtr, sLen, aPtr, aLen, 0)).toBe(0); // idempotent
186
+ expect(imports['data.membership_contains'](h, sPtr, sLen, aPtr, aLen)).toBe(1);
187
+
188
+ // list -> framed u32 count + per member (u32 len + bytes), sorted (alice, bob).
189
+ const total = imports['data.membership_list'](h, sPtr, sLen, 10);
190
+ expect(imports['data.take_result'](128, 128)).toBe(total);
191
+ let off = 128;
192
+ const count = buf.readUInt32LE(off);
193
+ off += 4;
194
+ expect(count).toBe(2);
195
+ const out: string[] = [];
196
+ for (let i = 0; i < count; i++) {
197
+ const len = buf.readUInt32LE(off);
198
+ off += 4;
199
+ out.push(buf.toString('utf8', off, off + len));
200
+ off += len;
201
+ }
202
+ expect(out).toEqual(['alice', 'bob']);
203
+
204
+ // remove is idempotent; the member is gone.
205
+ expect(imports['data.membership_remove'](h, sPtr, sLen, aPtr, aLen, 0)).toBe(0);
206
+ expect(imports['data.membership_remove'](h, sPtr, sLen, aPtr, aLen, 0)).toBe(0);
207
+ expect(imports['data.membership_contains'](h, sPtr, sLen, aPtr, aLen)).toBe(0);
208
+ expect(imports['data.membership_contains'](h, sPtr, sLen, bPtr, bLen)).toBe(1);
209
+
210
+ expect(imports['data.membership_add'](999, sPtr, sLen, aPtr, aLen, 0)).toBe(-1001); // bad handle
211
+ });
212
+
213
+ it('view: publish overwrites and get reads the latest (or absent)', () => {
214
+ const { imports, buf } = setup();
215
+ const h = resolve(imports, buf, 'App/pages');
216
+ const [kPtr, kLen] = put(buf, 32, 'home');
217
+
218
+ // absent before any publish
219
+ expect(imports['data.view_get'](h, kPtr, kLen)).toBe(-2);
220
+
221
+ const [v1Ptr, v1Len] = put(buf, 48, '<v1>');
222
+ expect(imports['data.view_publish'](h, kPtr, kLen, v1Ptr, v1Len, 0)).toBe(0);
223
+ expect(imports['data.view_get'](h, kPtr, kLen)).toBe(4);
224
+ expect(imports['data.take_result'](64, 64)).toBe(4);
225
+ expect(buf.toString('utf8', 64, 68)).toBe('<v1>');
226
+
227
+ // republish overwrites (latest wins)
228
+ const [v2Ptr, v2Len] = put(buf, 80, '<page2>');
229
+ expect(imports['data.view_publish'](h, kPtr, kLen, v2Ptr, v2Len, 0)).toBe(0);
230
+ expect(imports['data.view_get'](h, kPtr, kLen)).toBe(7);
231
+ imports['data.take_result'](128, 64);
232
+ expect(buf.toString('utf8', 128, 135)).toBe('<page2>');
233
+
234
+ expect(imports['data.view_get'](999, kPtr, kLen)).toBe(-1001); // invalid handle
235
+ });
236
+
237
+ it('counter: add accumulates (saturating i64) and get reads the sum', () => {
238
+ const { imports, buf } = setup();
239
+ const h = resolve(imports, buf, 'App/likes');
240
+ const [kPtr, kLen] = put(buf, 32, 'post1');
241
+
242
+ // empty counter reads as 0 (8 LE bytes), never absent.
243
+ expect(imports['data.counter_get'](h, kPtr, kLen)).toBe(8);
244
+ expect(imports['data.take_result'](64, 8)).toBe(8);
245
+ expect(buf.readBigInt64LE(64)).toBe(0n);
246
+
247
+ expect(imports['data.counter_add'](h, kPtr, kLen, 5, 0)).toBe(0);
248
+ expect(imports['data.counter_add'](h, kPtr, kLen, -2, 0)).toBe(0);
249
+ expect(imports['data.counter_get'](h, kPtr, kLen)).toBe(8);
250
+ imports['data.take_result'](72, 8);
251
+ expect(buf.readBigInt64LE(72)).toBe(3n);
252
+
253
+ // invalid handle still rejected
254
+ expect(imports['data.counter_add'](999, kPtr, kLen, 1, 0)).toBe(-1001);
255
+ });
256
+
257
+ it('events: append then latest returns newest-first, bounded by limit', () => {
258
+ const { imports, buf } = setup();
259
+ const h = resolve(imports, buf, 'App/feed');
260
+ const [kPtr, kLen] = put(buf, 32, 'room1');
261
+
262
+ for (let i = 0; i < 4; i++) {
263
+ const [eP, eL] = put(buf, 48, `ev${String(i)}`);
264
+ expect(imports['data.append'](h, kPtr, kLen, eP, eL, 0)).toBe(0);
265
+ }
266
+
267
+ // latest(2) -> framed: u32 count=2, then [len+bytes] newest-first (ev3, ev2).
268
+ const total = imports['data.latest'](h, kPtr, kLen, 2);
269
+ expect(total).toBeGreaterThan(0);
270
+ expect(imports['data.take_result'](128, 128)).toBe(total);
271
+ let off = 128;
272
+ const count = buf.readUInt32LE(off);
273
+ off += 4;
274
+ expect(count).toBe(2);
275
+ const out: string[] = [];
276
+ for (let i = 0; i < count; i++) {
277
+ const len = buf.readUInt32LE(off);
278
+ off += 4;
279
+ out.push(buf.toString('utf8', off, off + len));
280
+ off += len;
281
+ }
282
+ expect(out).toEqual(['ev3', 'ev2']);
283
+
284
+ // an empty stream frames an empty list (count 0, 4 bytes), never absent.
285
+ const [k2P, k2L] = put(buf, 256, 'empty');
286
+ expect(imports['data.latest'](h, k2P, k2L, 10)).toBe(4);
287
+ imports['data.take_result'](300, 8);
288
+ expect(buf.readUInt32LE(300)).toBe(0);
289
+ });
290
+
291
+ it('collections are isolated (no key aliasing)', () => {
292
+ const { imports, buf } = setup();
293
+ const users = resolve(imports, buf, 'App/users');
294
+ const [n2p, n2l] = put(buf, 256, 'App/posts');
295
+ expect(imports['data.resolve_collection'](n2p, n2l, 260)).toBe(0);
296
+ const posts = buf.readUInt32LE(260);
297
+
298
+ const [kPtr, kLen] = put(buf, 32, 'x');
299
+ const [vPtr, vLen] = put(buf, 48, 'inusers');
300
+ imports['data.create'](users, kPtr, kLen, vPtr, vLen, 0);
301
+ // the same logical key in another collection is absent.
302
+ expect(imports['data.get'](posts, kPtr, kLen)).toBe(-2);
303
+ });
304
+ });
@@ -1,17 +1,16 @@
1
1
  /**
2
2
  * Dev-server post-quantum auth host mocks: the ML-KEM-768 decapsulation and the
3
3
  * RFC 9497 OPRF evaluation must be byte-identical to the production Rust edge
4
- * (`toil-backend` fips203 / `voprf` crate), and the dev-only KV must behave like
5
- * an atomic fetch-and-delete store. The OPRF mock is asserted against the same
6
- * RFC 9497 Appendix A.1.1 vector the edge test uses if both match the RFC,
7
- * the noble client interops with both servers.
4
+ * (`toil-backend` fips203 / `voprf` crate). The OPRF mock is asserted against the
5
+ * same RFC 9497 Appendix A.1.1 vector the edge test uses if both match the RFC,
6
+ * the noble client interops with both servers. (Account/challenge persistence is
7
+ * ToilDB now, exercised end-to-end in `pqauth-e2e.test.ts`.)
8
8
  */
9
- import { describe, expect, it, beforeEach } from 'vitest';
9
+ import { describe, expect, it } from 'vitest';
10
10
 
11
11
  import { ml_kem768 } from '@dacely/noble-post-quantum/ml-kem.js';
12
12
 
13
13
  import { buildCryptoImports, freshCryptoState } from '../src/devserver/crypto.js';
14
- import { buildKvImports, __resetKvForTests } from '../src/devserver/kv.js';
15
14
 
16
15
  type Ref = { memory: WebAssembly.Memory | null };
17
16
 
@@ -92,62 +91,3 @@ describe('crypto.voprf_evaluate dev mock (RFC 9497 A.1.1, ristretto255-SHA512)',
92
91
  expect(evaluate(0, 32, 256, 8, 512, 31, 1024)).toBe(-4);
93
92
  });
94
93
  });
95
-
96
- describe('dev kv store', () => {
97
- beforeEach(() => __resetKvForTests());
98
-
99
- it('put then get round-trips a value', () => {
100
- const ref = freshMem();
101
- const kv = buildKvImports(ref);
102
- const key = Buffer.from('acct:alice', 'latin1');
103
- const val = Buffer.from([1, 2, 3, 4, 5]);
104
- put(ref, 0, key);
105
- put(ref, 64, val);
106
- kv['kv.put'](0, key.length, 64, val.length);
107
-
108
- const outPtr = 256;
109
- const n = kv['kv.get'](0, key.length, outPtr, 1024) as number;
110
- expect(n).toBe(val.length);
111
- expect(b2h(buf(ref).subarray(outPtr, outPtr + n))).toBe(b2h(val));
112
- });
113
-
114
- it('get returns -1 for an absent key, -2 when the buffer is too small', () => {
115
- const ref = freshMem();
116
- const kv = buildKvImports(ref);
117
- const key = Buffer.from('chal:missing', 'latin1');
118
- put(ref, 0, key);
119
- expect(kv['kv.get'](0, key.length, 256, 1024)).toBe(-1);
120
-
121
- const val = Buffer.alloc(100, 7);
122
- put(ref, 64, val);
123
- kv['kv.put'](0, key.length, 64, val.length);
124
- expect(kv['kv.get'](0, key.length, 256, 10)).toBe(-2); // too small, no write
125
- });
126
-
127
- it('getdel returns the value once then deletes it (atomic consume)', () => {
128
- const ref = freshMem();
129
- const kv = buildKvImports(ref);
130
- const key = Buffer.from('chal:cid', 'latin1');
131
- const val = Buffer.from([9, 8, 7]);
132
- put(ref, 0, key);
133
- put(ref, 64, val);
134
- kv['kv.put'](0, key.length, 64, val.length);
135
-
136
- expect(kv['kv.getdel'](0, key.length, 256, 1024)).toBe(val.length);
137
- // Second consume finds nothing — the replay-prevention property.
138
- expect(kv['kv.getdel'](0, key.length, 256, 1024)).toBe(-1);
139
- });
140
-
141
- it('getdel does NOT delete on a -2 probe', () => {
142
- const ref = freshMem();
143
- const kv = buildKvImports(ref);
144
- const key = Buffer.from('chal:probe', 'latin1');
145
- const val = Buffer.alloc(50, 3);
146
- put(ref, 0, key);
147
- put(ref, 64, val);
148
- kv['kv.put'](0, key.length, 64, val.length);
149
-
150
- expect(kv['kv.getdel'](0, key.length, 256, 10)).toBe(-2); // probe, too small
151
- expect(kv['kv.getdel'](0, key.length, 256, 1024)).toBe(val.length); // still there
152
- });
153
- });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The `examples/basic` Guestbook demo: a @rest controller backed by ToilDB
3
+ * (`events` + `counter`). Each dispatch runs a FRESH wasm instance (linear
4
+ * memory resets between requests), so the only thing that can carry a signature
5
+ * from one request to the next is ToilDB - which is exactly what the demo shows.
6
+ * Contrast the `Players` route, whose own comment notes "memory resets next
7
+ * request". Skips until the example server wasm is built (`npm run build:server`).
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ import { describe, expect, it, beforeEach } from 'vitest';
14
+
15
+ import { WasmServerModule } from '../src/devserver/index.js';
16
+ import { __resetDbForTests } from '../src/devserver/database.js';
17
+
18
+ const EXAMPLE_WASM = path.resolve(
19
+ path.dirname(fileURLToPath(import.meta.url)),
20
+ '../examples/basic/build/server/release.wasm',
21
+ );
22
+ const haveWasm = fs.existsSync(EXAMPLE_WASM);
23
+
24
+ function load(): WasmServerModule {
25
+ const m = new WasmServerModule(EXAMPLE_WASM);
26
+ m.refresh();
27
+ return m;
28
+ }
29
+ function sign(m: WasmServerModule, author: string, message: string) {
30
+ return m.dispatch({
31
+ method: 'POST',
32
+ path: '/guestbook',
33
+ headers: [
34
+ ['host', 'localhost:3000'],
35
+ ['content-type', 'application/json'],
36
+ ],
37
+ body: new TextEncoder().encode(JSON.stringify({ author, message })),
38
+ });
39
+ }
40
+ function list(m: WasmServerModule) {
41
+ return m.dispatch({
42
+ method: 'GET',
43
+ path: '/guestbook',
44
+ headers: [['host', 'localhost:3000']],
45
+ body: new Uint8Array(0),
46
+ });
47
+ }
48
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
49
+ const json = (r: { body: Uint8Array }): any => JSON.parse(Buffer.from(r.body).toString());
50
+
51
+ describe.skipIf(!haveWasm)('guestbook demo: ToilDB events + counter persist across requests', () => {
52
+ beforeEach(() => __resetDbForTests());
53
+
54
+ it('starts empty after a reset', () => {
55
+ const v = json(list(load()));
56
+ expect(v.total).toBe('0'); // i64 rides JSON as a decimal string
57
+ expect(v.entries.length).toBe(0);
58
+ });
59
+
60
+ it('signatures persist across separate requests (a fresh instance each time)', () => {
61
+ let r = sign(load(), 'Ada', 'first!');
62
+ expect(r.status).toBe(200);
63
+ expect(json(r).total).toBe('1');
64
+
65
+ // A brand-new wasm instance - its memory is empty - still sees the prior
66
+ // signature, because it lives in ToilDB, not module state.
67
+ r = sign(load(), 'Linus', 'second');
68
+ const v = json(r);
69
+ expect(v.total).toBe('2');
70
+ expect(v.entries.length).toBe(2);
71
+ expect(v.entries[0].author).toBe('Linus'); // events.latest is newest-first
72
+ expect(v.entries[1].author).toBe('Ada');
73
+ expect(v.entries[1].message).toBe('first!');
74
+
75
+ // A read-only GET on yet another instance sees the same persisted state.
76
+ expect(json(list(load())).total).toBe('2');
77
+ });
78
+ });
@@ -3,9 +3,9 @@
3
3
  * — OPRF blind/finalize, Argon2id, ML-DSA keygen/sign, ML-KEM encapsulate,
4
4
  * mutual-auth confirm) against the toilscript-compiled example server wasm
5
5
  * (`examples/basic` Auth route + the AuthService global), through the dev-server
6
- * host imports (OPRF + ML-KEM mocks + the dev KV). A `fetch` shim routes the
7
- * client's requests into `WasmServerModule.dispatch`, and the in-process dev KV
8
- * persists across dispatches so register -> login spans "requests".
6
+ * host imports (OPRF + ML-KEM mocks + the ToilDB emulator). A `fetch` shim routes
7
+ * the client's requests into `WasmServerModule.dispatch`, and the in-process
8
+ * ToilDB store persists across dispatches so register -> login spans "requests".
9
9
  *
10
10
  * This is the full chain interop gate: if the noble client and the
11
11
  * voprf/fips203-equivalent dev mocks + the AS AuthService disagree on a single
@@ -20,7 +20,7 @@ import { describe, expect, it, beforeEach, afterEach } from 'vitest';
20
20
  import { ristretto255_oprf } from '@noble/curves/ed25519.js';
21
21
 
22
22
  import { WasmServerModule } from '../src/devserver/index.js';
23
- import { __resetKvForTests } from '../src/devserver/kv.js';
23
+ import { __resetDbForTests } from '../src/devserver/database.js';
24
24
  import { Auth } from '../src/client/auth.js';
25
25
  import { DataReader, DataWriter } from '../src/io/codec.js';
26
26
 
@@ -84,7 +84,7 @@ describe.skipIf(!haveWasm)('post-quantum auth end-to-end (client <-> example was
84
84
  let mod: WasmServerModule;
85
85
 
86
86
  beforeEach(() => {
87
- __resetKvForTests();
87
+ __resetDbForTests();
88
88
  mod = loadModule();
89
89
  restoreFetch = installFetchShim(mod);
90
90
  });
@@ -155,7 +155,7 @@ describe.skipIf(!haveWasm)('post-quantum auth end-to-end (client <-> example was
155
155
 
156
156
  // Lower-level wire checks that don't need the heavy Argon2id derivation.
157
157
  describe.skipIf(!haveWasm)('post-quantum auth wire-level (anti-enumeration, replay)', () => {
158
- beforeEach(() => __resetKvForTests());
158
+ beforeEach(() => __resetDbForTests());
159
159
 
160
160
  const oprf = ristretto255_oprf.oprf;
161
161
  const loginStart = (m: WasmServerModule, username: string) => {
@@ -1,3 +0,0 @@
1
- import type { MemoryRef } from './host.js';
2
- export declare function buildKvImports(ref: MemoryRef): Record<string, (...args: number[]) => number | void>;
3
- export declare function __resetKvForTests(): void;
@@ -1,53 +0,0 @@
1
- const STORE = new Map();
2
- const MAX_VALUE_BYTES = 64 * 1024;
3
- const MAX_KEY_BYTES = 1024;
4
- function mem(ref) {
5
- if (!ref.memory)
6
- throw new Error('kv host import called before memory was bound');
7
- return Buffer.from(ref.memory.buffer);
8
- }
9
- function readBytes(ref, ptr, len) {
10
- const m = mem(ref);
11
- if (ptr < 0 || len < 0 || ptr + len > m.length)
12
- throw new Error(`kv read out of bounds: ptr=${String(ptr)} len=${String(len)}`);
13
- return Buffer.from(m.subarray(ptr, ptr + len));
14
- }
15
- function keyOf(ref, ptr, len) {
16
- if (len > MAX_KEY_BYTES)
17
- throw new Error(`kv key too long: ${String(len)} bytes`);
18
- return readBytes(ref, ptr, len).toString('latin1');
19
- }
20
- function emit(ref, value, outPtr, outCap) {
21
- if (value === undefined)
22
- return -1;
23
- if (value.length > outCap)
24
- return -2;
25
- const m = mem(ref);
26
- if (outPtr < 0 || outPtr + value.length > m.length)
27
- throw new Error('kv get write out of bounds');
28
- value.copy(m, outPtr);
29
- return value.length;
30
- }
31
- export function buildKvImports(ref) {
32
- return {
33
- 'kv.put': (keyPtr, keyLen, valPtr, valLen) => {
34
- if (valLen > MAX_VALUE_BYTES)
35
- throw new Error(`kv value too long: ${String(valLen)} bytes`);
36
- STORE.set(keyOf(ref, keyPtr, keyLen), readBytes(ref, valPtr, valLen));
37
- },
38
- 'kv.get': (keyPtr, keyLen, outPtr, outCap) => {
39
- return emit(ref, STORE.get(keyOf(ref, keyPtr, keyLen)), outPtr, outCap);
40
- },
41
- 'kv.getdel': (keyPtr, keyLen, outPtr, outCap) => {
42
- const key = keyOf(ref, keyPtr, keyLen);
43
- const value = STORE.get(key);
44
- const n = emit(ref, value, outPtr, outCap);
45
- if (n >= 0)
46
- STORE.delete(key);
47
- return n;
48
- },
49
- };
50
- }
51
- export function __resetKvForTests() {
52
- STORE.clear();
53
- }
@@ -1,93 +0,0 @@
1
- /**
2
- * DEV-ONLY persistent key-value store host imports (`env::kv.*`).
3
- *
4
- * REMOVE LATER. This exists ONLY so the post-quantum auth example can run the
5
- * full register -> login chain end-to-end under `toiljs dev`. A tenant's wasm
6
- * linear memory is wiped after every request, so account records and login
7
- * challenges cannot live in the guest across the two round trips; they need an
8
- * external store. This module is a single-process `Map` standing in for that.
9
- *
10
- * It is intentionally NOT registered on the production Rust edge
11
- * (`toil-backend` `HOST_IMPORTS`), so a module using `kv.*` will not instantiate
12
- * there. Once toildb is implemented, move the example's account + challenge
13
- * stores onto toildb (which provides the atomic fetch-and-delete the challenge
14
- * consume needs) and delete this whole module. DO NOT ship this `Map` as a
15
- * production storage path: it is single-instance, unbounded, and lost on restart.
16
- *
17
- * Wire ABI (mirrors the crypto imports' caller-allocated-buffer convention):
18
- * kv.put(keyPtr, keyLen, valPtr, valLen) -> void
19
- * kv.get(keyPtr, keyLen, outPtr, outCap) -> i32 len | -1 absent | -2 too small
20
- * kv.getdel(keyPtr, keyLen, outPtr, outCap) -> i32 len | -1 absent | -2 too small
21
- * `getdel` is the atomic fetch-and-delete used to consume a login challenge
22
- * exactly once; it deletes only on a successful read (never on a -2 probe).
23
- */
24
-
25
- import type { MemoryRef } from './host.js';
26
-
27
- /** Process-lifetime store, shared across every dispatch (the whole point). */
28
- const STORE = new Map<string, Buffer>();
29
-
30
- /** Hard cap on a single value (bounds the dev process RAM). Account records are
31
- * ~1.5 KiB (1312-byte ML-DSA key + salt + params); 64 KiB is generous. */
32
- const MAX_VALUE_BYTES = 64 * 1024;
33
- /** Hard cap on a key. */
34
- const MAX_KEY_BYTES = 1024;
35
-
36
- function mem(ref: MemoryRef): Buffer {
37
- if (!ref.memory) throw new Error('kv host import called before memory was bound');
38
- return Buffer.from(ref.memory.buffer);
39
- }
40
-
41
- function readBytes(ref: MemoryRef, ptr: number, len: number): Buffer {
42
- const m = mem(ref);
43
- if (ptr < 0 || len < 0 || ptr + len > m.length)
44
- throw new Error(`kv read out of bounds: ptr=${String(ptr)} len=${String(len)}`);
45
- return Buffer.from(m.subarray(ptr, ptr + len)); // copy out
46
- }
47
-
48
- /** Map key from raw guest bytes (latin1 is a lossless 1:1 byte<->char mapping). */
49
- function keyOf(ref: MemoryRef, ptr: number, len: number): string {
50
- if (len > MAX_KEY_BYTES) throw new Error(`kv key too long: ${String(len)} bytes`);
51
- return readBytes(ref, ptr, len).toString('latin1');
52
- }
53
-
54
- /** Write a stored value into the caller buffer (if it fits) and return its
55
- * length; -1 if absent, -2 if the value exceeds `outCap` (no write, no delete). */
56
- function emit(ref: MemoryRef, value: Buffer | undefined, outPtr: number, outCap: number): number {
57
- if (value === undefined) return -1;
58
- if (value.length > outCap) return -2;
59
- const m = mem(ref);
60
- if (outPtr < 0 || outPtr + value.length > m.length)
61
- throw new Error('kv get write out of bounds');
62
- value.copy(m, outPtr);
63
- return value.length;
64
- }
65
-
66
- export function buildKvImports(ref: MemoryRef): Record<string, (...args: number[]) => number | void> {
67
- return {
68
- 'kv.put': (keyPtr: number, keyLen: number, valPtr: number, valLen: number): void => {
69
- if (valLen > MAX_VALUE_BYTES) throw new Error(`kv value too long: ${String(valLen)} bytes`);
70
- STORE.set(keyOf(ref, keyPtr, keyLen), readBytes(ref, valPtr, valLen));
71
- },
72
-
73
- 'kv.get': (keyPtr: number, keyLen: number, outPtr: number, outCap: number): number => {
74
- return emit(ref, STORE.get(keyOf(ref, keyPtr, keyLen)), outPtr, outCap);
75
- },
76
-
77
- // Atomic fetch-and-delete: deletes ONLY when the value is actually
78
- // returned (a -2 "buffer too small" probe leaves the entry intact), so a
79
- // login challenge is consumed exactly once.
80
- 'kv.getdel': (keyPtr: number, keyLen: number, outPtr: number, outCap: number): number => {
81
- const key = keyOf(ref, keyPtr, keyLen);
82
- const value = STORE.get(key);
83
- const n = emit(ref, value, outPtr, outCap);
84
- if (n >= 0) STORE.delete(key);
85
- return n;
86
- },
87
- };
88
- }
89
-
90
- /** Test-only: clear the store between unit tests. */
91
- export function __resetKvForTests(): void {
92
- STORE.clear();
93
- }