toiljs 0.0.54 → 0.0.56
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 +5 -0
- package/build/backend/.tsbuildinfo +1 -1
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +9 -5
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/auth.js +1 -1
- package/build/client/components/Image.d.ts +1 -1
- package/build/client/dev/devtools.js +3 -1
- package/build/client/index.d.ts +2 -2
- package/build/client/index.js +2 -2
- package/build/client/routing/Router.js +1 -1
- package/build/client/routing/mount.js +1 -1
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/docs.js +1 -1
- package/build/compiler/seo.js +1 -3
- package/build/compiler/template-build.js +1 -1
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/cache.js +0 -0
- package/build/devserver/crypto.js +45 -17
- package/build/devserver/database.d.ts +8 -0
- package/build/devserver/database.js +416 -0
- package/build/devserver/email/caps.js +0 -0
- package/build/devserver/email/config.js +7 -2
- package/build/devserver/email/validate.js +1 -4
- package/build/devserver/host.d.ts +2 -0
- package/build/devserver/host.js +3 -2
- package/build/devserver/index.d.ts +1 -1
- package/build/devserver/index.js +3 -2
- package/build/devserver/module.js +52 -7
- package/build/devserver/proxy.js +2 -1
- package/build/io/.tsbuildinfo +1 -1
- package/build/io/codec.d.ts +5 -5
- package/build/io/codec.js +193 -77
- package/examples/basic/client/components/HoneycombBackground.tsx +1 -1
- package/examples/basic/client/public/images/logo.svg +37 -34
- package/examples/basic/client/public/index.html +14 -14
- package/examples/basic/client/routes/auth.tsx +18 -10
- package/examples/basic/client/routes/cookies.tsx +15 -24
- package/examples/basic/client/routes/crypto.tsx +4 -5
- package/examples/basic/client/routes/features/template/template.tsx +1 -1
- package/examples/basic/client/routes/hello.tsx +1 -1
- package/examples/basic/client/routes/pq.tsx +14 -14
- package/examples/basic/client/routes/rest.tsx +50 -1
- package/examples/basic/client/styles/main.css +25 -22
- package/examples/basic/client/toil.tsx +1 -1
- package/examples/basic/server/README.md +8 -8
- package/examples/basic/server/core/AppHandler.ts +4 -7
- 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 +50 -106
- package/examples/basic/server/routes/EnvDemo.ts +9 -3
- package/examples/basic/server/routes/Guestbook.ts +62 -0
- package/package.json +2 -2
- package/server/globals/auth.ts +3 -3
- package/server/globals/twofactor.ts +2 -1
- package/server/runtime/http/securecookies.ts +3 -2
- package/src/backend/index.ts +4 -2
- package/src/cli/doctor.ts +10 -3
- package/src/cli/notify.ts +1 -6
- package/src/cli/ui.ts +3 -3
- package/src/cli/version-check.ts +5 -1
- package/src/client/auth.ts +33 -10
- package/src/client/components/Form.tsx +2 -2
- package/src/client/components/Image.tsx +1 -1
- package/src/client/components/Script.tsx +1 -1
- package/src/client/components/Slot.tsx +1 -1
- package/src/client/dev/devtools.tsx +121 -54
- package/src/client/dev/error-overlay.tsx +7 -1
- package/src/client/head/metadata.ts +1 -1
- package/src/client/index.ts +13 -2
- package/src/client/routing/Router.tsx +2 -2
- package/src/client/routing/error-boundary.tsx +1 -1
- package/src/client/routing/loader.ts +2 -2
- package/src/client/routing/mount.tsx +5 -6
- package/src/compiler/docs.ts +1 -1
- package/src/compiler/email-preview.ts +1 -1
- package/src/compiler/generate.ts +1 -1
- package/src/compiler/seo.ts +1 -3
- package/src/compiler/ssg.ts +10 -4
- package/src/compiler/template-build.ts +2 -7
- package/src/compiler/template.ts +1 -4
- package/src/compiler/vite.ts +1 -1
- package/src/devserver/cache.ts +0 -0
- package/src/devserver/crypto.ts +140 -51
- package/src/devserver/database.ts +600 -0
- package/src/devserver/dotenv.ts +10 -2
- package/src/devserver/email/caps.ts +0 -0
- package/src/devserver/email/config.ts +8 -2
- package/src/devserver/email/index.ts +3 -3
- package/src/devserver/email/validate.ts +1 -4
- package/src/devserver/envelope.ts +3 -3
- package/src/devserver/host.ts +22 -9
- package/src/devserver/index.ts +15 -6
- package/src/devserver/module.ts +59 -11
- package/src/devserver/proxy.ts +5 -7
- package/src/io/codec.ts +226 -83
- package/test/devserver-database.test.ts +364 -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,364 @@
|
|
|
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
|
+
});
|
|
305
|
+
|
|
306
|
+
type Imports = Record<string, (...args: number[]) => number>;
|
|
307
|
+
|
|
308
|
+
describe('toildb dev emulator (capacity family)', () => {
|
|
309
|
+
// Pull the last stashed i64 available into the buffer and read it.
|
|
310
|
+
function avail(imports: Imports, buf: Buffer, h: number, kPtr: number, kLen: number): bigint {
|
|
311
|
+
expect(imports['data.capacity_available'](h, kPtr, kLen)).toBe(8);
|
|
312
|
+
expect(imports['data.take_result'](512, 16)).toBe(8);
|
|
313
|
+
return buf.readBigInt64LE(512);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
it('set_total / reserve / confirm / cancel keep the ledger consistent', () => {
|
|
317
|
+
const { imports, buf } = setup();
|
|
318
|
+
const h = resolve(imports, buf, 'App/seats');
|
|
319
|
+
const [kPtr, kLen] = put(buf, 32, 'showA');
|
|
320
|
+
|
|
321
|
+
// an empty ledger reads 0 available, never absent.
|
|
322
|
+
expect(avail(imports, buf, h, kPtr, kLen)).toBe(0n);
|
|
323
|
+
|
|
324
|
+
// restock to 10.
|
|
325
|
+
expect(imports['data.capacity_set_total'](h, kPtr, kLen, 10, 0)).toBe(0);
|
|
326
|
+
expect(avail(imports, buf, h, kPtr, kLen)).toBe(10n);
|
|
327
|
+
|
|
328
|
+
// reserve 3 -> a u64 id (> 0) is stashed, available drops to 7.
|
|
329
|
+
expect(imports['data.capacity_reserve'](h, kPtr, kLen, 3, 60000, 0)).toBe(8);
|
|
330
|
+
expect(imports['data.take_result'](512, 16)).toBe(8);
|
|
331
|
+
const id = buf.readBigUInt64LE(512);
|
|
332
|
+
expect(id > 0n).toBe(true);
|
|
333
|
+
expect(avail(imports, buf, h, kPtr, kLen)).toBe(7n);
|
|
334
|
+
|
|
335
|
+
// cancel returns the hold -> back to 10; a double-cancel is a no-op.
|
|
336
|
+
expect(imports['data.capacity_cancel'](h, kPtr, kLen, Number(id), 0)).toBe(1);
|
|
337
|
+
expect(avail(imports, buf, h, kPtr, kLen)).toBe(10n);
|
|
338
|
+
expect(imports['data.capacity_cancel'](h, kPtr, kLen, Number(id), 0)).toBe(0);
|
|
339
|
+
|
|
340
|
+
// reserve 4 then confirm -> a permanent consume; available holds at 6.
|
|
341
|
+
expect(imports['data.capacity_reserve'](h, kPtr, kLen, 4, 60000, 0)).toBe(8);
|
|
342
|
+
imports['data.take_result'](512, 16);
|
|
343
|
+
const id2 = Number(buf.readBigUInt64LE(512));
|
|
344
|
+
expect(imports['data.capacity_confirm'](h, kPtr, kLen, id2, 0)).toBe(1);
|
|
345
|
+
expect(avail(imports, buf, h, kPtr, kLen)).toBe(6n);
|
|
346
|
+
// a confirmed hold cannot be cancelled nor re-confirmed.
|
|
347
|
+
expect(imports['data.capacity_cancel'](h, kPtr, kLen, id2, 0)).toBe(0);
|
|
348
|
+
expect(imports['data.capacity_confirm'](h, kPtr, kLen, id2, 0)).toBe(0);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('never oversells (a reserve beyond available is refused)', () => {
|
|
352
|
+
const { imports, buf } = setup();
|
|
353
|
+
const h = resolve(imports, buf, 'App/tickets');
|
|
354
|
+
const [kPtr, kLen] = put(buf, 32, 'vip');
|
|
355
|
+
imports['data.capacity_set_total'](h, kPtr, kLen, 5, 0);
|
|
356
|
+
|
|
357
|
+
// a hold for all 5 succeeds; a further hold for 1 is refused (-2 -> guest 0).
|
|
358
|
+
expect(imports['data.capacity_reserve'](h, kPtr, kLen, 5, 60000, 0)).toBe(8);
|
|
359
|
+
expect(imports['data.capacity_reserve'](h, kPtr, kLen, 1, 60000, 0)).toBe(-2);
|
|
360
|
+
// a non-positive amount is refused, and an invalid handle is rejected.
|
|
361
|
+
expect(imports['data.capacity_reserve'](h, kPtr, kLen, 0, 60000, 0)).toBe(-2);
|
|
362
|
+
expect(imports['data.capacity_available'](999, kPtr, kLen)).toBe(-1001);
|
|
363
|
+
});
|
|
364
|
+
});
|
|
@@ -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
|
-
}
|