Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_typstpack.mjs

30.8 KiB, 1 run

created by r2519314175:773, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// verify_typstpack.mjs — typesetting is bought, and every door to it knows.
2//
3// Typst typesetting moved from the free belt to a purchasable pack. THE CAPABILITY IS THE
4// PRODUCT, not the function the model calls, and the capability has three doors:
5//
6// 1. the model's `typst_compile` tool, dispatched through `Tool::guard` in `src/tools.rs`;
7// 2. the ⚙ Compile button in the Doc panel, which involves no model, costs nothing, and calls
8// `window.DaimondTypst.compile` straight from JavaScript;
9// 3. the driver itself, `window.DaimondTypst`, which the other two share so the 30 MB compiler
10// wasm is built once between them.
11//
12// A gate on (1) alone stops the model and leaves the person compiling free, which is a gate on one
13// door and not on the product. So each door is driven here, separately, and each is asked the
14// question that matters: NOT "did it say something" but "did a PDF appear".
15//
16// The properties, each with its control:
17//
18// A. Unlocked, every door compiles. Without this the refusals below prove nothing -- a build
19// whose compiler was simply broken would pass every locked check.
20// B. Locked, no door compiles, and NO PDF IS WRITTEN. The file is removed before each attempt,
21// so the assertion is on the world and not on the wording.
22// C. The refusals are useful and in the reader's language: the tool's answers the MODEL in
23// English naming the tool and the pack, the button's answers a PERSON from the catalogue the
24// page has been translated into -- checked by reading it in a second language.
25// D. The catalogue and the build name the SAME pack. `gateway/app.jdat` says what is on sale
26// and `Tool::pack` says what a sale unlocks; they ship separately, and a build older than the
27// catalogue fails open -- the page pushes a lock nothing recognises and the pack runs free.
28// E. The SALE and not merely the gate: with the price standing in `gateway/app.jdat` the page
29// reads it, the engine locks the tool and the compile is refused; with the price taken out of
30// that same string the pack is no longer on sale AND IS STILL LOCKED, because what a build
31// gates is decided by the build. The price buys a way out of the lock; it does not set it.
32// F. SOLD IS NOT LOCKED. An EMPTY catalogue must lock the pack, not give it away -- and the
33// control for it is the defect itself, reproduced in the same run: a listing assembled only
34// from the catalogue, for that same empty string, unlocks the tool and compiles a PDF. That
35// is the state the production gateway was in while a priced pack ran free on every device,
36// and it is why E above is not enough on its own -- E only ever pushes the catalogue towards
37// HAVING a price, and every failure that mattered was the catalogue losing one.
38//
39// node dev/verify_typstpack.mjs
40//
41// Prove it red before trusting it green. The two gates are layered, and each removal turns a
42// DIFFERENT check red, which is the point:
43//
44// Remove `if let Some(refusal) = self.pack_refusal()` from `Tool::guard` and rebuild: no PDF
45// still appears, because the driver stops it -- so "the tool does not compile" stays green and
46// the ANSWER check goes red, the model having been handed a raw `UpstreamErr` naming a source
47// line instead of something it can relay. Gated, and still broken.
48//
49// Remove the `if (await packLocked())` block from `www/js/typst.js`: the model's tool is still
50// refused by Rust, and both of the other two doors -- the driver and the ⚙ button -- go red,
51// compiling a PDF for an account that did not buy one.
52//
53// Neither removal turns everything red, and a verifier that only watched one of them would have
54// signed off a half-gated product.
55
56import fs from 'node:fs';
57import path from 'node:path';
58import { fileURLToPath } from 'node:url';
59import { open, shot, SCRATCH } from './harness.mjs';
60
61const HERE = path.dirname(fileURLToPath(import.meta.url));
62
63const ok = [], bad = [];
64const check = (name, pass, detail) => {
65 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
66 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
67};
68const sleep = ms => new Promise(r => setTimeout(r, ms));
69
70// A namespace of this verifier's own, so the files it makes and removes are nobody else's.
71const NS = 'd~typstpack';
72const SRC = 'paper.typ';
73const PDF = 'paper.pdf';
74const BODY = '= A heading\n\nA paragraph, and $x^2 + y^2$.\n';
75
76/// Every pack key the gateway's shipped catalogue sells, read out of `gateway/app.jdat` -- the
77/// configuration a customer would actually be charged against.
78///
79/// This file used to hard-code the key. It cannot any more, because the key is now half of an
80/// agreement between two files that are deployed separately: the catalogue names the pack, and the
81/// BUILD decides which tool that pack locks. A catalogue selling `drop01` against a build whose
82/// `Tool::pack` still answers an earlier key fails OPEN -- the page pushes a lock the engine does
83/// not recognise, and the pack runs free on every device. That is the same defect this gate was
84/// written to close, wearing a different hat, so the two are read independently here and compared.
85/// It is also why the key is the pack's DROP rather than its theme: the display name may be
86/// rewritten in the catalogue on the day, and nothing here or in the build moves when it is.
87/// The `tools` string exactly as `gateway/app.jdat` states it.
88function catalogueString() {
89 const src = fs.readFileSync(path.join(HERE, '..', 'gateway', 'app.jdat'), 'utf8');
90 const m = /"tools":\s*"([^"]*)"/.exec(src);
91 return m ? m[1] : '';
92}
93
94/// The catalogue string read the way `gateway/src/catalogue.rs::parse` reads it:
95/// `tool:price_minor:Name:Blurb`, comma separated, and an entry with no price is DROPPED --
96/// "a tool with no price is a tool nobody can buy". Deliberately dumb, because it is a mirror of
97/// Rust that Rust's own tests already cover; what it is here for is to put the SHIPPED
98/// configuration in front of the page rather than a fixture somebody typed.
99function parseCatalogue(str) {
100 return str.split(',').map((entry) => {
101 const parts = entry.trim().split(':');
102 const tool = (parts.shift() || '').trim();
103 const price = parseInt((parts.shift() || '').trim(), 10);
104 const name = (parts.shift() || '').trim();
105 const blurb = parts.join(':').trim();
106 if (!tool || !(price > 0)) return null;
107 return { tool, name: name || 'Daimond tool', blurb, price_minor: price };
108 }).filter(Boolean);
109}
110
111const catalogueKeys = () => parseCatalogue(catalogueString()).map(t => t.tool);
112
113/// Every pack key the GATEWAY gates, read out of `gateway/src/catalogue.rs`.
114///
115/// A third source, and it has to be third: the catalogue says what is on SALE and the build says
116/// what a sale unlocks, and neither of those is what the gateway locks. That register is compiled
117/// into the gateway precisely so that a catalogue edit cannot empty it -- which means it can now
118/// disagree with the build, and a gateway gating `drop02` against a build gating `drop01` fails
119/// open exactly as an empty catalogue used to. So all three are read here and compared.
120///
121/// The scan is on the `key` arms rather than on the enum, because the arms are the mapping and the
122/// enum is only its spelling.
123function gatedKeys() {
124 const src = fs.readFileSync(path.join(HERE, '..', 'gateway', 'src', 'catalogue.rs'), 'utf8');
125 const body = /pub const fn key\(self\)[\s\S]*?match self \{([\s\S]*?)\n\s*\}/.exec(src);
126 if (!body) return [];
127 const out = [];
128 const re = /Self::\w+\s*=>\s*"([^"]+)"/g;
129 let m;
130 while ((m = re.exec(body[1])) !== null) out.push(m[1]);
131 return out;
132}
133
134const s = await open({ name: 'typstpack', connect: false });
135const p = s.page;
136await p.waitForTimeout(1500);
137
138// ── The instrument, before anything is measured with it ──────────────
139//
140// Everything below reads the world through these three helpers. If any of them is lying -- a
141// namespace that is not applied, an existence check that always answers no -- every "no PDF was
142// written" is vacuous. So they are proved against a file that IS there before they are trusted to
143// report one that is not.
144
145/// Point the wasm at this verifier's namespace and hand back the module.
146await p.evaluate(async (ns) => {
147 const mod = await import('../pkg/oxedyne_daimond.js');
148 mod.set_account_ns(ns);
149}, NS);
150
151/// Whether a file exists in the namespace, asked of OPFS directly rather than of the app.
152const exists = (name) => p.evaluate(async ({ ns, n }) => {
153 const root = await navigator.storage.getDirectory();
154 let dir;
155 try { dir = await root.getDirectoryHandle(ns); } catch (e) { return false; }
156 try { await dir.getFileHandle(n); return true; } catch (e) { return false; }
157}, { ns: NS, n: name });
158
159/// Remove a file if it is there, so the next attempt starts from nothing.
160const remove = (name) => p.evaluate(async ({ ns, n }) => {
161 const root = await navigator.storage.getDirectory();
162 try {
163 const dir = await root.getDirectoryHandle(ns);
164 await dir.removeEntry(n);
165 } catch (e) { /* already absent */ }
166}, { ns: NS, n: name });
167
168/// Lock or unlock the pack, exactly as the page does after reading `/api/tools`.
169const setLocked = (csv) => p.evaluate(async (v) => {
170 const mod = await import('../pkg/oxedyne_daimond.js');
171 mod.set_locked_packs(v);
172 return { locked: mod.locked_packs(), tool: mod.tool_locked('typst_compile') };
173}, csv);
174
175/// Run a tool the way the model does: through the registry's dispatch, which is what applies the
176/// guard. NOT a direct call to the compile -- that would test a path no model takes.
177const runTool = (name, args) => p.evaluate(async ({ n, a }) => {
178 const mod = await import('../pkg/oxedyne_daimond.js');
179 const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true);
180 return await app.run_tool(n, JSON.stringify(a));
181}, { n: name, a: args });
182
183// Prove the instrument on a file that is definitely there, then on its absence.
184await runTool('file_write', { path: SRC, content: BODY });
185const seesPresent = await exists(SRC);
186await remove(PDF);
187const seesAbsent = await exists(PDF);
188check('the existence check can see a file that is there, and only that file',
189 seesPresent === true && seesAbsent === false,
190 `source seen: ${seesPresent}, absent PDF seen: ${seesAbsent}`);
191if (!seesPresent || seesAbsent) {
192 console.log('\nthe instrument is unsound, so nothing below would mean anything');
193 await s.close();
194 process.exit(1);
195}
196
197// ── The key the two halves have to agree on ──────────────────────────
198
199const beltEarly = await p.evaluate(async () => {
200 const mod = await import('../pkg/oxedyne_daimond.js');
201 return JSON.parse(mod.builtin_tools());
202});
203const beltEntry = beltEarly.find(t => t.tool === 'typst_compile');
204const PACK = beltEntry && beltEntry.pack ? beltEntry.pack : '';
205const SELLS = catalogueKeys();
206const GATES = gatedKeys();
207check('the build locks on the pack key the gateway\'s catalogue actually sells',
208 !!PACK && SELLS.indexOf(PACK) >= 0,
209 `the build locks on "${PACK || '(nothing)'}", the catalogue sells ${
210 SELLS.length ? SELLS.map(k => '"' + k + '"').join(', ') : '(nothing)'
211 } — a build older than the catalogue fails OPEN, so rebuild the wasm before switching the price on`);
212check('and the gateway GATES that same key, whatever the catalogue is doing',
213 !!PACK && GATES.indexOf(PACK) >= 0,
214 `the gateway's register gates ${
215 GATES.length ? GATES.map(k => '"' + k + '"').join(', ') : '(nothing)'
216 } — a register that does not name "${PACK}" reports the pack to nobody, so nothing locks it`);
217if (!PACK) {
218 console.log('\nthe build sells nothing, so there is no gate below to measure');
219 await s.close();
220 process.exit(1);
221}
222
223const armed = await setLocked(PACK);
224check('the build can be told the pack is locked, and says so about the tool',
225 armed.locked === PACK && armed.tool === true, JSON.stringify(armed));
226
227// ── A. Unlocked, every door compiles ─────────────────────────────────
228//
229// The control for everything after it. This is the slow part: the first compile through any door
230// builds the 30 MB compiler, and all three doors then share it.
231
232await setLocked('');
233
234await remove(PDF);
235const toolFree = await runTool('typst_compile', { path: SRC });
236const toolFreePdf = await exists(PDF);
237check('unlocked: the model\'s tool compiles, and a PDF lands in the workspace',
238 toolFreePdf === true, toolFree.slice(0, 160));
239
240await remove(PDF);
241const driverFree = await p.evaluate(async (body) => {
242 const r = await window.DaimondTypst.compile(body);
243 return {
244 error: r.error || '',
245 magic: r.pdf ? String.fromCharCode.apply(null, Array.from(r.pdf.slice(0, 5))) : '',
246 };
247}, BODY);
248check('unlocked: the driver compiles a real document to real PDF bytes',
249 driverFree.error === '' && driverFree.magic === '%PDF-',
250 driverFree.error || `magic "${driverFree.magic}"`);
251
252// ── The button, which involves no model at all ───────────────────────
253//
254// Opened the way a person opens it: the row in the Workspace tree, then the ⚙ in the header. The
255// panel is what draws the button, so a fixture that called `compileTypst` directly would be
256// testing a function nobody clicks.
257
258async function openTheTypFile() {
259 await p.evaluate(() => window.DaimondPanels.show('work'));
260 await sleep(400);
261 await p.evaluate(() => {
262 const r = document.querySelector('#panel-work [data-act="refresh"]');
263 if (r) r.click();
264 });
265 await sleep(900);
266 return await p.evaluate((src) => {
267 const rows = Array.from(document.querySelectorAll('#panel-work .files-row'));
268 const row = rows.find(r => new RegExp(src.replace('.', '\\.')).test(r.textContent || ''));
269 if (!row) return 'not in the tree: ' + rows.map(r => (r.textContent || '').trim()).join(',');
270 row.click();
271 return true;
272 }, SRC);
273}
274
275/// Click ⚙ and wait for the header line to settle on an answer, whichever kind it is.
276async function clickCompile(waitMs = 90000) {
277 const clicked = await p.evaluate(() => {
278 const b = document.querySelector('[data-act="compile"]');
279 if (!b) return false;
280 b.click();
281 return true;
282 });
283 if (!clicked) return { clicked: false, msg: '' };
284 const t0 = Date.now();
285 for (;;) {
286 const st = await p.evaluate(() => {
287 const b = document.querySelector('[data-act="compile"]');
288 const m = document.querySelector('.files-view-msg');
289 return { busy: !!(b && b.disabled), msg: m ? (m.textContent || '') : '' };
290 });
291 // The handler re-enables the button in its `finally`, so "not busy" is the answer having
292 // arrived by either route -- compiled, or refused.
293 if (!st.busy && st.msg && !/^…/.test(st.msg)) return { clicked: true, msg: st.msg };
294 if (Date.now() - t0 > waitMs) return { clicked: true, msg: st.msg, timedOut: true };
295 await sleep(400);
296 }
297}
298
299const inTree = await openTheTypFile();
300// The view renders after the click resolves, so the button is waited FOR rather than looked for
301// once. Looked for once, this reported "no Compile button" on a build that has one -- which is the
302// verifier failing, not the app, and the kind of red that gets a real check deleted.
303let hasButton = false;
304for (let i = 0; i < 40 && !hasButton; i++) {
305 hasButton = await p.evaluate(() => !!document.querySelector('[data-act="compile"]'));
306 if (!hasButton) await sleep(250);
307}
308check('the .typ file opens in the Doc panel and offers a Compile button',
309 inTree === true && hasButton, `in the tree: ${inTree}, button drawn: ${hasButton}`);
310
311await remove(PDF);
312const btnFree = await clickCompile();
313const btnFreePdf = await exists(PDF);
314check('unlocked: clicking ⚙ compiles, and a PDF lands in the workspace',
315 btnFree.clicked === true && btnFreePdf === true, btnFree.msg.slice(0, 160));
316
317// ── B. Locked, no door compiles and no PDF is written ────────────────
318
319await setLocked(PACK);
320
321await remove(PDF);
322const toolLocked = await runTool('typst_compile', { path: SRC });
323const toolLockedPdf = await exists(PDF);
324check('LOCKED: the model\'s tool does not compile — no PDF was written',
325 toolLockedPdf === false, `a PDF at ${PDF}: ${toolLockedPdf}`);
326// The refusal is the model's to relay, so it has to carry what the model needs to say: which tool
327// was refused, and which pack it is sold in. Asserted as content, not as a fixed sentence.
328//
329// AND it must be an ANSWER rather than an error. This is the check that the Rust gate earns its
330// place with: take `pack_refusal` out of `Tool::guard` and the driver below still stops the
331// compile, so no PDF appears and the check above stays green -- but the model is handed a raw
332// `UpstreamErr` naming a source line, which it cannot relay to anyone. The capability would still
333// be gated and the product would still be broken, and only this assertion says so.
334check('and it tells the model which tool was refused and which pack sells it, as an answer not an error',
335 toolLocked.includes('typst_compile') && toolLocked.includes(PACK)
336 && /has not bought/.test(toolLocked)
337 && !/^Error:/.test(toolLocked.trim()),
338 toolLocked.slice(0, 200));
339
340await remove(PDF);
341const driverLocked = await p.evaluate(async (body) => {
342 const r = await window.DaimondTypst.compile(body);
343 return { error: r.error || '', bytes: r.pdf ? r.pdf.length : 0 };
344}, BODY);
345check('LOCKED: the driver itself refuses, and hands back no bytes at all',
346 driverLocked.bytes === 0 && driverLocked.error !== '',
347 `${driverLocked.bytes} bytes, error: ${driverLocked.error.slice(0, 120)}`);
348
349await remove(PDF);
350const btnLocked = await clickCompile(20000);
351const btnLockedPdf = await exists(PDF);
352check('LOCKED: clicking ⚙ does not compile — no PDF was written',
353 btnLocked.clicked === true && btnLockedPdf === false,
354 `clicked: ${btnLocked.clicked}, said: ${btnLocked.msg.slice(0, 140)}`);
355
356// ── C. The refusal a person reads, in the language they read in ──────
357//
358// The button's message is shown to a PERSON, so it comes out of the catalogue rather than out of
359// Rust. Two claims: it IS the catalogue's sentence, and it MOVES when the language does. The
360// second is what catches a "localised" string that was hard-coded in English -- a bug class this
361// app has shipped before, where a locale change left the text where it was.
362
363const englishSaid = await p.evaluate(() => ({
364 msg: (document.querySelector('.files-view-msg') || {}).textContent || '',
365 cat: window.DaimondI18n ? window.DaimondI18n.t('typst.pack_locked') : '',
366 loc: window.DaimondI18n ? window.DaimondI18n.locale() : '',
367}));
368check('the refusal a person reads is the catalogue\'s sentence, not one built in the code',
369 englishSaid.msg.trim() === englishSaid.cat.trim() && englishSaid.cat.length > 20,
370 `shown "${englishSaid.msg.slice(0, 60)}" vs catalogue "${englishSaid.cat.slice(0, 60)}"`);
371// It has to say the three things a refused reader needs: that it is a pack, where to get it, and
372// that it is not their credits being spent.
373check('and it says it is a pack, names where to get it, and that credits are not spent',
374 /pack/i.test(englishSaid.cat) && /Tools/.test(englishSaid.cat) && /credits/i.test(englishSaid.cat),
375 englishSaid.cat);
376// No dialog ambushes the reader: the answer arrives in the header line where they clicked.
377const noDialog = await p.evaluate(() => {
378 const open = Array.from(document.querySelectorAll('dialog')).filter(d => d.open);
379 return open.length === 0;
380});
381check('and nothing is put up in front of them to deliver it', noDialog === true);
382
383await p.evaluate(async () => {
384 if (window.DaimondI18n) await window.DaimondI18n.setLocale('de');
385});
386await sleep(600);
387await remove(PDF);
388const btnDe = await clickCompile(20000);
389const germanSaid = await p.evaluate(() => ({
390 cat: window.DaimondI18n ? window.DaimondI18n.t('typst.pack_locked') : '',
391 loc: window.DaimondI18n ? window.DaimondI18n.locale() : '',
392}));
393check('a reader in another language is refused in THEIR language',
394 germanSaid.loc === 'de'
395 && btnDe.msg.trim() === germanSaid.cat.trim()
396 && germanSaid.cat.trim() !== englishSaid.cat.trim(),
397 `locale ${germanSaid.loc}, shown "${btnDe.msg.slice(0, 60)}"`);
398check('and it is still refused in that language — no PDF was written',
399 (await exists(PDF)) === false);
400
401await p.evaluate(async () => {
402 if (window.DaimondI18n) await window.DaimondI18n.setLocale('en');
403});
404await sleep(400);
405
406// ── Buying it gives it back ──────────────────────────────────────────
407//
408// The last control, and the one that proves the gate is the ENTITLEMENT and not the code path: the
409// same click, on the same file, in the same session, once the pack is held.
410
411await setLocked('');
412await remove(PDF);
413const btnBought = await clickCompile();
414const btnBoughtPdf = await exists(PDF);
415check('bought: the very same click compiles again, in the same sitting',
416 btnBought.clicked === true && btnBoughtPdf === true, btnBought.msg.slice(0, 160));
417
418await remove(PDF);
419const toolBought = await runTool('typst_compile', { path: SRC });
420check('bought: and so does the model\'s tool',
421 (await exists(PDF)) === true, toolBought.slice(0, 160));
422
423// ── E. Driven by the PRICE in the shipped catalogue ──────────────────
424//
425// Everything above sets the lock by hand, which proves the gate and not the SALE. This phase
426// starts from `gateway/app.jdat` -- the file an operator edits to put a pack on sale -- and lets
427// the page do the rest: `/api/tools` answers what that catalogue sells, `www/js/tools.js` reads
428// it, pushes the shortfall into the wasm, and the compile is attempted for real. Nothing between
429// the price and the refusal is simulated except the gateway's own parse, which its Rust tests
430// cover.
431//
432// And then the control, which CHANGED when the gate did. Taking the price out used to make the
433// same run compile: the listing was built from the catalogue alone, so an entry nobody could buy
434// was an entry nobody was locked out of. That was the defect, not the design -- phase F below
435// reproduces it deliberately -- and the fix put the gated register in the BUILD, where a catalogue
436// edit cannot empty it. So a pack with no price is still a gated pack, and what the price decides
437// now is whether it can be BOUGHT. The two checks below assert exactly that, which is the property
438// the sale rests on: the lock does not move with the price. Revert the fix and they go red, because
439// the pack would fall out of the listing with its price.
440//
441// The anti-vacuity control for the whole phase is therefore not here but in phase A above, which
442// compiles through all three doors unlocked, and in phase F's first pair, which reaches the same
443// engine through the same listing route and DOES unlock it.
444
445/// Serve `/api/tools` with exactly this listing and let the panel push what it makes of it into
446/// the engine. The low half of `serveCatalogue`, separated so the OLD rule can be served too.
447async function serveListing(tools) {
448 await p.unroute('**/api/tools').catch(() => {});
449 await p.route('**/api/tools', r => r.fulfill({
450 status: 200, contentType: 'application/json',
451 body: JSON.stringify({ ok: true, credits_minor: 0, tools }),
452 }));
453 await p.evaluate(() => window.DaimondTools && window.DaimondTools.reload());
454 await sleep(900);
455 return p.evaluate(async () => {
456 const mod = await import('../pkg/oxedyne_daimond.js');
457 return { locked: mod.locked_packs(), tool: mod.tool_locked('typst_compile') };
458 });
459}
460
461/// The listing the gateway builds from a catalogue string, for an account holding nothing.
462///
463/// A UNION of two sets and not a walk over one, mirroring `catalogue::listing`: everything the
464/// catalogue sells, then every gated pack nothing sells. The mirror is deliberately dumb -- the
465/// gateway's own tests cover the rule -- and what it is here for is to put the shape the gateway
466/// now emits in front of the real page and the real engine.
467function listingFor(str) {
468 const sold = parseCatalogue(str).map(t => ({
469 tool: t.tool, name: t.name, blurb: t.blurb, price_minor: t.price_minor,
470 unlocked: false, purchasable: true, gated: GATES.indexOf(t.tool) >= 0, currency: 'usd',
471 }));
472 GATES.forEach((k) => {
473 if (sold.some(t => t.tool.toLowerCase() === k.toLowerCase())) return;
474 sold.push({
475 tool: k, name: '', blurb: '', price_minor: 0,
476 unlocked: false, purchasable: false, gated: true, currency: 'usd',
477 });
478 });
479 return sold;
480}
481
482/// Serve `/api/tools` from a catalogue string, as the gateway would for an account holding
483/// nothing, and let the panel push what it makes of it into the engine.
484async function serveCatalogue(str) {
485 return serveListing(listingFor(str));
486}
487
488const SHIPPED = catalogueString();
489if (SELLS.indexOf(PACK) < 0) {
490 console.log(' skip the shipped catalogue sells no pack this build locks on, so the '
491 + 'end-to-end phase was NOT RUN — see the first failure above');
492} else {
493 await setLocked('');
494 const engPriced = await serveCatalogue(SHIPPED);
495 check('priced in the catalogue: the page reads it and the engine locks the tool',
496 engPriced.tool === true && engPriced.locked.split(',').indexOf(PACK) >= 0,
497 `catalogue "${SHIPPED.slice(0, 60)}…" → ${JSON.stringify(engPriced)}`);
498
499 await remove(PDF);
500 const soldRefusal = await runTool('typst_compile', { path: SRC });
501 check('priced and unbought: the compile is refused and no PDF is written',
502 (await exists(PDF)) === false && soldRefusal.includes(PACK),
503 soldRefusal.slice(0, 140));
504
505 // The control: the same catalogue with the price taken out. The entry is dropped from the
506 // sale -- a tool with no price is a tool nobody can buy -- and the pack is then reported by
507 // the BUILD's register instead, unbought and so still locked.
508 const unpriced = SHIPPED.replace(/^([^:,]+):\d+:/, '$1::');
509 const engFree = await serveCatalogue(unpriced);
510 check('price removed: the pack drops out of the sale and STAYS locked',
511 engFree.tool === true && engFree.locked.split(',').indexOf(PACK) >= 0,
512 `catalogue "${unpriced.slice(0, 60)}…" → ${JSON.stringify(engFree)}`
513 + ' — a listing built from the catalogue alone reports nothing here and gives the pack away');
514
515 await remove(PDF);
516 const stillRefused = await runTool('typst_compile', { path: SRC });
517 check('price removed: the very same call is still refused, and no PDF is written',
518 (await exists(PDF)) === false && stillRefused.includes(PACK),
519 stillRefused.slice(0, 140));
520
521 await p.unroute('**/api/tools').catch(() => {});
522 await setLocked('');
523}
524
525// ── F. Sold is not locked ────────────────────────────────────────────
526//
527// Phase E moves the catalogue towards HAVING a price. Every failure that has actually happened
528// moved it the other way: the running gateway's catalogue was the empty string, so nothing was on
529// sale, so nothing was locked, so a $15 pack compiled documents for free on every device and no
530// log line said so. This phase therefore runs OUTSIDE the guard above -- a shipped catalogue that
531// has lost its price is exactly the state it is here to measure, and skipping it then would skip
532// it precisely when it matters.
533//
534// The control is run FIRST and is the defect itself, so the phase cannot pass by refusing
535// everything: the same empty catalogue, listed the old way -- one line per entry, which for an
536// empty catalogue is no lines at all -- unlocks the tool and writes a PDF.
537
538await setLocked('');
539const engOldRule = await serveListing([]);
540check('THE DEFECT, reproduced: a listing built only from the catalogue unlocks the pack',
541 engOldRule.tool === false && engOldRule.locked === '',
542 `empty listing → ${JSON.stringify(engOldRule)}`);
543await remove(PDF);
544const gaveItAway = await runTool('typst_compile', { path: SRC });
545check('and the tool compiles for an account that bought nothing — this is what was live',
546 (await exists(PDF)) === true, gaveItAway.slice(0, 140));
547
548// And now the same empty catalogue through the rule that separates the two.
549const engEmpty = await serveCatalogue('');
550check('EMPTY catalogue: the pack is still gated, so the engine still locks the tool',
551 engEmpty.tool === true && engEmpty.locked.split(',').indexOf(PACK) >= 0,
552 `empty catalogue → ${JSON.stringify(engEmpty)}`);
553await remove(PDF);
554const emptyRefusal = await runTool('typst_compile', { path: SRC });
555check('EMPTY catalogue: the compile is refused and no PDF is written',
556 (await exists(PDF)) === false && emptyRefusal.includes(PACK),
557 emptyRefusal.slice(0, 140));
558
559// A catalogue nobody can parse an entry out of is the same case wearing a different hat: a
560// mistyped price is not a price, so nothing is on sale -- and nothing being on sale must not be
561// what decides whether the tool runs.
562const engJunk = await serveCatalogue(PACK + ':free:Publishing,,:1500:Nameless');
563check('UNPARSEABLE catalogue: the pack is locked rather than released',
564 engJunk.tool === true && engJunk.locked.split(',').indexOf(PACK) >= 0,
565 `junk catalogue → ${JSON.stringify(engJunk)}`);
566await remove(PDF);
567const junkRefusal = await runTool('typst_compile', { path: SRC });
568check('UNPARSEABLE catalogue: the compile is refused and no PDF is written',
569 (await exists(PDF)) === false && junkRefusal.includes(PACK),
570 junkRefusal.slice(0, 140));
571
572// NOT checked here, and deliberately: what the PANEL draws for a pack it cannot sell. The gateway
573// now says `purchasable: false` and quotes no price, and `www/js/tools.js` does not read that
574// field yet -- it draws a Buy button from `unlocked` alone, so in this state it offers the pack at
575// $0.00 and the checkout refuses the click. That is a cosmetic fault in a state that only exists
576// when the catalogue is broken, it belongs to the panel's own lane, and a red line here would
577// block a release on it. `dev/verify_toolspanel.mjs` is where it should land once the panel
578// honours the field.
579
580await p.unroute('**/api/tools').catch(() => {});
581await setLocked('');
582
583// ── The panel no longer calls it free ────────────────────────────────
584//
585// The belt is what the Tools panel draws from, so a sold tool has to be MARKED sold there or the
586// panel keeps listing it under "Built in" with no price on it.
587
588const belt = await p.evaluate(async () => {
589 const mod = await import('../pkg/oxedyne_daimond.js');
590 return JSON.parse(mod.builtin_tools());
591});
592const typstEntry = belt.find(t => t.tool === 'typst_compile');
593const freeEntries = belt.filter(t => !t.pack);
594check('the belt marks the sold tool with its pack, and marks nothing else',
595 !!typstEntry && typstEntry.pack === PACK
596 && freeEntries.length === belt.length - 1
597 && !freeEntries.some(t => t.tool === 'typst_compile'),
598 typstEntry ? `pack "${typstEntry.pack}", ${freeEntries.length} free of ${belt.length}` : 'no entry');
599
600await shot(s, 'typstpack');
601
602await p.evaluate(async () => {
603 const mod = await import('../pkg/oxedyne_daimond.js');
604 mod.set_locked_packs('');
605 mod.set_account_ns('');
606});
607
608const errs = s.errs.filter(e => !/favicon|404|401|net::ERR/.test(e));
609console.log('\nconsole errors:', errs.slice(0, 4));
610console.log('scratch:', SCRATCH);
611await s.close();
612
613console.log(`\n${ok.length} passed, ${bad.length} failed`);
614if (bad.length) console.log('FAILED:\n ' + bad.join('\n '));
615process.exit(bad.length ? 1 : 0);