toiljs 0.0.53 → 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.
- package/CHANGELOG.md +10 -0
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/database.d.ts +8 -0
- package/build/devserver/database.js +334 -0
- package/build/devserver/host.d.ts +2 -0
- package/build/devserver/host.js +3 -2
- package/build/devserver/module.js +7 -1
- package/examples/basic/client/routes/rest.tsx +52 -1
- package/examples/basic/server/core/AppHandler.ts +0 -18
- package/examples/basic/server/main.ts +1 -0
- package/examples/basic/server/models/GuestEntry.ts +12 -0
- package/examples/basic/server/models/GuestbookView.ts +10 -0
- package/examples/basic/server/models/NewMessage.ts +6 -0
- package/examples/basic/server/routes/Auth.ts +39 -163
- package/examples/basic/server/routes/Guestbook.ts +62 -0
- package/examples/basic/server/routes/Session.ts +5 -5
- package/package.json +2 -2
- package/server/globals/auth.ts +113 -57
- package/server/globals/twofactor.ts +2 -1
- package/server/runtime/http/securecookies.ts +3 -2
- package/src/devserver/database.ts +459 -0
- package/src/devserver/host.ts +9 -5
- package/src/devserver/module.ts +9 -3
- package/test/devserver-database.test.ts +304 -0
- package/test/devserver-pqauth.test.ts +5 -65
- package/test/example-guestbook.test.ts +78 -0
- package/test/pqauth-e2e.test.ts +6 -6
- package/build/devserver/kv.d.ts +0 -3
- package/build/devserver/kv.js +0 -53
- package/src/devserver/kv.ts +0 -93
|
@@ -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)
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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
|
+
});
|
package/test/pqauth-e2e.test.ts
CHANGED
|
@@ -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
|
|
7
|
-
* client's requests into `WasmServerModule.dispatch`, and the in-process
|
|
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 {
|
|
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
|
-
|
|
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(() =>
|
|
158
|
+
beforeEach(() => __resetDbForTests());
|
|
159
159
|
|
|
160
160
|
const oprf = ristretto255_oprf.oprf;
|
|
161
161
|
const loginStart = (m: WasmServerModule, username: string) => {
|
package/build/devserver/kv.d.ts
DELETED
package/build/devserver/kv.js
DELETED
|
@@ -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
|
-
}
|
package/src/devserver/kv.ts
DELETED
|
@@ -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
|
-
}
|