@bongos/core 1.24.7 → 1.24.9

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.
Files changed (57) hide show
  1. package/.bongos-core.json +115 -50
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +13 -1
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/adr/0385-money-lives-with-a-processor-credits-stay-a-project-meter.md +1 -1
  8. package/docs/api/openapi.json +267 -7
  9. package/docs/api-reference.md +7 -3
  10. package/docs/architecture.md +2 -2
  11. package/docs/copy-inventory.md +38 -13
  12. package/docs/copy-registry.json +271 -15
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/modules-contract.md +3 -3
  15. package/docs/page-inventory.json +40 -4
  16. package/docs/page-readings.json +64 -4
  17. package/migrations/core_287_module_prices_cash_only.sql +24 -0
  18. package/modules/government/decision-board.js +5 -0
  19. package/modules/government/decision-rules.js +3 -0
  20. package/modules/government/routes/government.js +2 -0
  21. package/modules/hall-ui/public/market-watch-rules-lib.js +207 -0
  22. package/modules/hall-ui/public/market-watch-rules.css +68 -0
  23. package/modules/hall-ui/public/market-watch-rules.html +54 -0
  24. package/modules/hall-ui/public/market-watch-rules.js +129 -0
  25. package/modules/hall-ui/public/market-watch-rules.states.json +17 -0
  26. package/modules/hall-ui/public/modules.js +5 -0
  27. package/modules/market-watch/matter-acts.js +59 -0
  28. package/modules/market-watch/module.json +5 -4
  29. package/modules/market-watch/proposals.js +214 -0
  30. package/modules/market-watch/research.js +49 -8
  31. package/modules/market-watch/routes/rules.js +202 -0
  32. package/modules/market-watch/rules-logic.js +264 -0
  33. package/modules/market-watch/rules-view.js +165 -0
  34. package/package-lock.json +2 -2
  35. package/package.json +1 -1
  36. package/release-notes.json +12 -0
  37. package/scripts/bongos/government-walk.js +18 -2
  38. package/scripts/bongos/module-store-cli.js +21 -21
  39. package/scripts/bongos/module.js +8 -8
  40. package/scripts/bongos/run-unit-tests.js +3 -0
  41. package/src/bongos/module-listing.js +47 -20
  42. package/src/bongos/module-scope-map.js +2 -0
  43. package/src/bongos/module-store.js +20 -12
  44. package/src/bongos/routes/modules.js +31 -21
  45. package/src/bongos/serve-internal.js +2 -1
  46. package/src/module-api.js +1 -1
  47. package/tests/government_act_alone_buildings.mjs +3 -0
  48. package/tests/government_decision_rules.mjs +4 -1
  49. package/tests/governor_matter_acts.mjs +5 -1
  50. package/tests/hall_page_gate_map.mjs +3 -0
  51. package/tests/market_watch_module.mjs +1 -0
  52. package/tests/market_watch_rules.mjs +496 -0
  53. package/tests/market_watch_rules_db.mjs +133 -0
  54. package/tests/module_store_cli.mjs +14 -3
  55. package/tests/module_store_listing.mjs +65 -14
  56. package/tests/module_store_price.mjs +97 -26
  57. package/tests/module_store_registry_migration.mjs +11 -0
@@ -0,0 +1,133 @@
1
+ // tests/market_watch_rules_db.mjs — the rules page's round trip against a REAL Postgres
2
+ // (task 1004941, goal 1000137).
3
+ //
4
+ // What only a real engine can say: a member's proposal is filed under the table's CHECKs
5
+ // (a rule change must name its version), approving it supersedes the version in use and
6
+ // writes the next one in one transaction without tripping the one-active index, the new
7
+ // row records who proposed it, who approved it, when and why, and the weekly run's own
8
+ // rules loader (store.createStore(pool).activeRules, what run.js calls) then reads it.
9
+ // Also: an approved add-item proposal creates the tracked item, and the page's read
10
+ // (rules-view.readView, with its last-run-cost and builder-name SQL) runs.
11
+ //
12
+ // The module is default:false, so CI's migrate.sh never applies its SQL: this file
13
+ // applies market_watch_001 and 002 itself. Rows live under a project origin no instance
14
+ // has and are deleted before and after. Self-skips without a reachable Postgres; in the
15
+ // INTEGRATION set (scripts/bongos/run-unit-tests.js). DB-free half: tests/market_watch_rules.mjs.
16
+ //
17
+ // Run: DATABASE_URL=... node tests/market_watch_rules_db.mjs
18
+
19
+ import { createRequire } from 'node:module';
20
+ import { readFileSync } from 'node:fs';
21
+ import { strict as assert } from 'node:assert';
22
+ import { insertTestBuilders } from './helpers.mjs';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const { Pool } = require('pg');
26
+ const seed = require('../modules/market-watch/seed.js');
27
+ const store = require('../modules/market-watch/store.js');
28
+ const proposals = require('../modules/market-watch/proposals.js');
29
+ const view = require('../modules/market-watch/rules-view.js');
30
+ const research = require('../modules/market-watch/research.js');
31
+ const pricing = require('../src/bongos/llm-pricing.js');
32
+
33
+ const MIGRATION = readFileSync(new URL('../modules/market-watch/migrations/market_watch_001_tables.sql', import.meta.url), 'utf8');
34
+ const MIGRATION_002 = readFileSync(new URL('../modules/market-watch/migrations/market_watch_002_run.sql', import.meta.url), 'utf8');
35
+
36
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
37
+ try {
38
+ await pool.query('SELECT 1');
39
+ } catch (e) {
40
+ console.log(`market_watch_rules_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
41
+ await pool.end().catch(() => {});
42
+ process.exit(0);
43
+ }
44
+
45
+ const P = 'https://t1004941-rules.example';
46
+ const OWNER = 91049411, ADA = 91049412;
47
+ const q = (sql, params) => pool.query(sql, params);
48
+ const withTx = async (fn) => {
49
+ const client = await pool.connect();
50
+ try {
51
+ await client.query('BEGIN');
52
+ const out = await fn(client);
53
+ await client.query('COMMIT');
54
+ return out;
55
+ } catch (e) {
56
+ await client.query('ROLLBACK').catch(() => {});
57
+ throw e;
58
+ } finally { client.release(); }
59
+ };
60
+
61
+ async function cleanup() {
62
+ await q(`DELETE FROM market_watch_proposals WHERE project = $1`, [P]).catch(() => {});
63
+ await q(`DELETE FROM market_watch_items WHERE project = $1`, [P]).catch(() => {});
64
+ await q(`DELETE FROM market_watch_rule_versions WHERE project = $1`, [P]).catch(() => {});
65
+ await q(`DELETE FROM builders WHERE id = ANY($1::bigint[])`, [[OWNER, ADA]]).catch(() => {});
66
+ }
67
+
68
+ let failed = 0;
69
+ async function check(name, fn) {
70
+ try { await fn(); console.log(`ok - ${name}`); } catch (e) { failed += 1; console.log(`not ok - ${name}\n ${e && e.stack}`); }
71
+ }
72
+
73
+ try {
74
+ await q(MIGRATION);
75
+ await q(MIGRATION_002);
76
+ await cleanup();
77
+ await insertTestBuilders(pool, [{ id: OWNER, login: 't1004941-owner' }, { id: ADA, login: 't1004941-ada' }]);
78
+ const r = seed.RULES_V1;
79
+ await q(`INSERT INTO market_watch_rule_versions (project, version, status, search_queries, sources, rubric, instructions, summary, reason, approved_at)
80
+ VALUES ($1, 1, 'active', $2::jsonb, $3::jsonb, $4::jsonb, $5, $6, $7, now())`,
81
+ [P, JSON.stringify(r.searchQueries), JSON.stringify(r.sources), JSON.stringify(seed.RUBRIC), r.instructions, r.summary, r.reason]);
82
+
83
+ await check('a proposal round-trips to a new version, and the weekly run\'s rules loader reads it', async () => {
84
+ const filed = await proposals.createProposal(pool, {
85
+ project: P, step: 'searches', change: { op: 'add', text: 'open-source bounty boards' }, reason: 'finds Algora and Opire', proposedBy: ADA,
86
+ });
87
+ assert.equal(filed.ok, true, JSON.stringify(filed));
88
+ const out = await proposals.decideProposal({ project: P, id: filed.proposal.id, decision: 'approve', decidedBy: OWNER, withTx });
89
+ assert.equal(out.ok, true, JSON.stringify(out));
90
+ const { rows } = await q(`SELECT version, status, author_id, approved_by, approved_at, reason, search_queries
91
+ FROM market_watch_rule_versions WHERE project = $1 ORDER BY version`, [P]);
92
+ assert.deepEqual(rows.map((v) => [v.version, v.status]), [[1, 'superseded'], [2, 'active']]);
93
+ assert.equal(String(rows[1].author_id), String(ADA));
94
+ assert.equal(String(rows[1].approved_by), String(OWNER));
95
+ assert.ok(rows[1].approved_at instanceof Date);
96
+ assert.equal(rows[1].reason, 'finds Algora and Opire');
97
+ const loaded = await store.createStore(pool).activeRules(P);
98
+ assert.equal(loaded.version, 2);
99
+ assert.ok(loaded.search_queries.includes('open-source bounty boards'));
100
+ const { rows: [p] } = await q(`SELECT status, decided_by, rule_version_id FROM market_watch_proposals WHERE id = $1`, [filed.proposal.id]);
101
+ assert.equal(p.status, 'applied');
102
+ assert.equal(String(p.rule_version_id), String(out.version.id));
103
+ });
104
+
105
+ await check('an approved add-item proposal creates the tracked item', async () => {
106
+ const filed = await proposals.createProposal(pool, {
107
+ project: P, step: 'names', change: { op: 'add', name: 'Algora', url: 'https://algora.io', kind: 'competitor', why: 'bounties' }, reason: 'they pay per task', proposedBy: ADA,
108
+ });
109
+ const out = await proposals.decideProposal({ project: P, id: filed.proposal.id, decision: 'approve', decidedBy: OWNER, withTx });
110
+ assert.equal(out.status, 'applied');
111
+ const { rows: [it] } = await q(`SELECT kind, url, added_via, added_by, status FROM market_watch_items WHERE project = $1 AND name = 'Algora'`, [P]);
112
+ assert.deepEqual([it.kind, it.url, it.added_via, String(it.added_by), it.status], ['competitor', 'https://algora.io', 'proposal', String(ADA), 'active']);
113
+ });
114
+
115
+ await check('the page read runs on the real schema', async () => {
116
+ await proposals.createProposal(pool, { project: P, step: 'brief', change: { op: 'set', text: 'Write five lines.' }, reason: 'shorter', proposedBy: ADA });
117
+ const page = await view.readView(pool, { project: P, decide: { mode: 'owner' }, pricing, model: research.MODEL });
118
+ assert.deepEqual(page.history.map((h) => h.version), [1, 2]);
119
+ assert.equal(page.history[1].by, 't1004941-ada');
120
+ assert.equal(page.next.version, 3);
121
+ assert.equal(page.cost.basis, 'estimate', 'no run has cost anything here');
122
+ assert.equal(page.steps.find((s) => s.key === 'names').value, 1);
123
+ });
124
+ } finally {
125
+ await cleanup();
126
+ await pool.end();
127
+ }
128
+
129
+ if (failed) {
130
+ console.log(`market_watch_rules_db.mjs: ${failed} failed`);
131
+ process.exit(1);
132
+ }
133
+ console.log('market_watch_rules_db.mjs: all passed');
@@ -133,8 +133,8 @@ test('the same query through both: a sort and a maximum price give the same modu
133
133
  for (const [args, query] of [
134
134
  [['--sort', 'tests'], { sort: 'tests' }],
135
135
  [['--max-cents', '500'], { max_cents: '500' }],
136
- [['--max-credits', '0'], { max_credits: '0' }],
137
- [['--sort', 'docs', '--max-cents', '1000', '--max-credits', '100'], { sort: 'docs', max_cents: '1000', max_credits: '100' }],
136
+ [['--max-cents', '0'], { max_cents: '0' }],
137
+ [['--sort', 'docs', '--max-cents', '1000'], { sort: 'docs', max_cents: '1000' }],
138
138
  ]) {
139
139
  const api = (await apiGet(query)).body;
140
140
  const cli = await cliJson(args);
@@ -144,6 +144,15 @@ test('the same query through both: a sort and a maximum price give the same modu
144
144
  }
145
145
  });
146
146
 
147
+ test('there is no maximum in credits, in the CLI or the API (task 1004989)', async () => {
148
+ seed();
149
+ const errs = [];
150
+ const never = async () => { throw new Error('must not be called'); };
151
+ assert.equal(await mod.cmdStore(['--max-credits', '100'], { log: () => {}, errlog: (l) => errs.push(l), request: never }), 2);
152
+ assert.match(errs.join('\n'), /credits do not price modules/);
153
+ assert.equal((await apiGet({ max_credits: '100' })).statusCode, 400);
154
+ });
155
+
147
156
  test('the text view keeps the API order: listed ranked, then New, and says the floor and the cap', async () => {
148
157
  seed();
149
158
  const api = (await apiGet({})).body;
@@ -158,7 +167,9 @@ test('the text view keeps the API order: listed ranked, then New, and says the f
158
167
  assert.equal(at('stripe-10'), -1, 'the eleventh is not shown');
159
168
  assert.match(text, /stripe-00@1\.0\.0 {2}99\/100 {2}free {2}Money › Stripe payments/);
160
169
  assert.match(text, /stripe-01@1\.0\.0 {2}98\/100 {2}\$9\.00/);
161
- assert.match(text, /loose@1\.0\.0 {2}88\/100 {2}40 credits \(not yet buyable\)$/m, 'no category, no place; paid is not yet buyable (task 1004894)');
170
+ assert.match(text, /loose@1\.0\.0 {2}88\/100 {2}price coming \(not yet buyable\)$/m,
171
+ 'an old credit-only price shows as price coming, never as credits (task 1004989); no category, no place');
172
+ assert.doesNotMatch(text, /\d credits/, 'no credit price anywhere in the text view');
162
173
  assert.match(text, /waiting@1\.0\.0 {2}60\/100 {2}free {2}Money$/m, 'a proposed sub-category shows its parent only');
163
174
  assert.match(text, /New — not ranked[\s\S]*fresh@1\.0\.0/);
164
175
  });
@@ -347,11 +347,27 @@ test('the criterion: two modules with the same overall score read differently',
347
347
  // the ranking: quality first, price breaks ties, optional max price (task 1003808)
348
348
  // ---------------------------------------------------------------------------
349
349
 
350
- test('priceOf: no price row is free; 0 in either currency is free; null is not offered', () => {
351
- assert.deepEqual(priceOf(null), { cents: null, credits: null, free: true });
352
- assert.deepEqual(priceOf({ price_cents: 0, price_credits: null }), { cents: 0, credits: null, free: true });
353
- assert.deepEqual(priceOf({ price_cents: 500, price_credits: 0 }), { cents: 500, credits: 0, free: true });
354
- assert.deepEqual(priceOf({ price_cents: 500, price_credits: null }), { cents: 500, credits: null, free: false });
350
+ test('priceOf: no price row is free; a cash price of 0 is free; never a credit amount', () => {
351
+ assert.deepEqual(priceOf(null), { cents: null, free: true, coming: false });
352
+ assert.deepEqual(priceOf({ price_cents: 0, price_credits: null }), { cents: 0, free: true, coming: false });
353
+ assert.deepEqual(priceOf({ price_cents: 500, price_credits: null }), { cents: 500, free: false, coming: false });
354
+ });
355
+
356
+ // Task 1004989 (ADR 0385 decision 2; owner, 2026-10-29): OLD rows may carry a
357
+ // credit price. They are read, never shown as a credit price, and never buyable
358
+ // with credits.
359
+ test('priceOf on old credit rows: credit-only is "price coming", 0 credits stays free, cash wins', () => {
360
+ assert.deepEqual(priceOf({ price_cents: null, price_credits: 40 }), { cents: null, free: false, coming: true },
361
+ 'a module priced only in credits is still listed, with its cash price coming');
362
+ assert.deepEqual(priceOf({ price_cents: null, price_credits: 0 }), { cents: null, free: true, coming: false },
363
+ 'an old 0-credit offer stays free: no credit moves to have it');
364
+ assert.deepEqual(priceOf({ price_cents: 500, price_credits: 0 }), { cents: 500, free: true, coming: false },
365
+ 'an old row offered at 0 credits stays free, as it was');
366
+ assert.deepEqual(priceOf({ price_cents: 500, price_credits: 40 }), { cents: 500, free: false, coming: false },
367
+ 'with a cash price the credit amount is ignored');
368
+ for (const row of [{ price_credits: 40 }, { price_cents: 500, price_credits: 40 }]) {
369
+ assert.equal('credits' in priceOf(row), false, 'no credit amount leaves priceOf');
370
+ }
355
371
  });
356
372
 
357
373
  test('rankListed: a higher score always wins, whatever it costs — price never buys a place', () => {
@@ -362,7 +378,7 @@ test('rankListed: a higher score always wins, whatever it costs — price never
362
378
  assert.deepEqual(ranked.map((e) => e.key), ['dear', 'cheap']);
363
379
  });
364
380
 
365
- test('rankListed: between equal scores, free first, then lowest cash, then lowest credits, then key', () => {
381
+ test('rankListed: between equal scores, free first, then lowest cash, a price still coming last, then key', () => {
366
382
  const input = [
367
383
  { key: 'z-credits', overall: 80, price: priceOf({ price_credits: 5 }) },
368
384
  { key: 'cents-900', overall: 80, price: priceOf({ price_cents: 900 }) },
@@ -374,14 +390,14 @@ test('rankListed: between equal scores, free first, then lowest cash, then lowes
374
390
  assert.equal(input[0].key, 'z-credits', 'the input is untouched');
375
391
  });
376
392
 
377
- test('withinMaxPrice: free always passes; a priced module must be offered at or under a cap the buyer set', () => {
393
+ test('withinMaxPrice: free always passes; a priced module must cost at or under the cash cap', () => {
378
394
  const p = priceOf({ price_cents: 500, price_credits: 7 });
379
395
  assert.equal(withinMaxPrice(p, null), true, 'no cap, no filter');
380
- assert.equal(withinMaxPrice(p, { cents: 500, credits: null }), true, 'at the cap is in');
381
- assert.equal(withinMaxPrice(p, { cents: 499, credits: null }), false);
382
- assert.equal(withinMaxPrice(p, { cents: 1, credits: 7 }), true, 'either currency under its cap is enough');
383
- assert.equal(withinMaxPrice(priceOf({ price_cents: 500 }), { cents: null, credits: 100 }), false, 'not offered in credits, so a credit cap cannot let it in');
384
- assert.equal(withinMaxPrice(priceOf(null), { cents: 0, credits: null }), true);
396
+ assert.equal(withinMaxPrice(p, { cents: 500 }), true, 'at the cap is in');
397
+ assert.equal(withinMaxPrice(p, { cents: 499 }), false);
398
+ assert.equal(withinMaxPrice(p, { cents: 1, credits: 7 }), false, 'a credit cap is ignored: credits never price a module');
399
+ assert.equal(withinMaxPrice(priceOf({ price_credits: 7 }), { cents: 100000 }), false, 'a price still coming has no cash price to compare');
400
+ assert.equal(withinMaxPrice(priceOf(null), { cents: 0 }), true);
385
401
  });
386
402
 
387
403
  test('GET /store/modules: ranks by score then price, and ?max_cents= leaves out what costs more', async () => {
@@ -416,7 +432,7 @@ test('GET /store/modules: ranks by score then price, and ?max_cents= leaves out
416
432
  assert.equal(all.body.max_price, null);
417
433
 
418
434
  const capped = await invoke(handle, { query: { max_cents: '1000' } });
419
- assert.deepEqual(capped.body.max_price, { cents: 1000, credits: null });
435
+ assert.deepEqual(capped.body.max_price, { cents: 1000 });
420
436
  assert.deepEqual(capped.body.listed.map((m) => m.key), ['tie-free', 'tie-paid']);
421
437
  assert.deepEqual(capped.body.new, [], 'the cap applies to New modules too');
422
438
 
@@ -427,13 +443,48 @@ test('GET /store/modules: ranks by score then price, and ?max_cents= leaves out
427
443
 
428
444
  test('GET /store/modules: a maximum price that is not a whole number of 0 or more is refused', async () => {
429
445
  const { handle } = handlerFor(buildModulesRouter(), 'get', '/store/modules');
430
- for (const query of [{ max_cents: '-1' }, { max_cents: '5.5' }, { max_credits: 'ten' }, { max_cents: ['1', '2'] }]) {
446
+ for (const query of [{ max_cents: '-1' }, { max_cents: '5.5' }, { max_cents: ['1', '2'] }]) {
431
447
  const res = await invoke(handle, { query });
432
448
  assert.equal(res.statusCode, 400, JSON.stringify(query));
433
449
  assert.match(res.body.message, /maximum price/);
434
450
  }
435
451
  });
436
452
 
453
+ test('GET /store/modules: there is no maximum in credits (task 1004989)', async () => {
454
+ const { handle } = handlerFor(buildModulesRouter(), 'get', '/store/modules');
455
+ for (const query of [{ max_credits: '100' }, { max_credits: 'ten' }, { max_cents: '500', max_credits: '1' }]) {
456
+ const res = await invoke(handle, { query });
457
+ assert.equal(res.statusCode, 400, JSON.stringify(query));
458
+ assert.match(res.body.message, /Credits do not price modules/);
459
+ }
460
+ });
461
+
462
+ test('GET /store/modules: an old credit-only price is listed as "price coming", never as credits, and not buyable', async () => {
463
+ state.modules = { legacy: { status: 'listed', title: 'Legacy' }, cash: { status: 'listed', title: 'Cash' } };
464
+ state.versions = [
465
+ { id: 41, module_key: 'legacy', version: '1.0.0', published_at: 't' },
466
+ { id: 42, module_key: 'cash', version: '1.0.0', published_at: 't' },
467
+ ];
468
+ state.scores = [
469
+ { id: 41, version_id: 41, computed_at: 1, ...score({ overall: 80 }) },
470
+ { id: 42, version_id: 42, computed_at: 1, ...score({ overall: 80 }) },
471
+ ];
472
+ state.signals = [];
473
+ state.prices = [
474
+ { module_key: 'legacy', price_cents: null, price_credits: 40, set_at: 1 },
475
+ { module_key: 'cash', price_cents: 900, price_credits: null, set_at: 1 },
476
+ ];
477
+ const { handle } = handlerFor(buildModulesRouter(), 'get', '/store/modules');
478
+ const res = await invoke(handle, { query: {} });
479
+ const legacy = res.body.listed.find((m) => m.key === 'legacy');
480
+ assert.ok(legacy, 'a credit-priced module stays visible (owner, 2026-10-29)');
481
+ assert.deepEqual(legacy.price, { cents: null, free: false, coming: true });
482
+ assert.equal(legacy.buyable, false, 'credits never buy it');
483
+ assert.doesNotMatch(JSON.stringify(res.body), /credits/, 'no credit price appears anywhere in the catalog');
484
+ assert.deepEqual(res.body.listed.map((m) => m.key), ['cash', 'legacy'], 'between equal scores a price still coming sorts after a cash price');
485
+ state.prices = [];
486
+ });
487
+
437
488
  // ---------------------------------------------------------------------------
438
489
  // why a module is or is not listed (task 1003810)
439
490
  // ---------------------------------------------------------------------------
@@ -30,8 +30,11 @@ let state = fresh();
30
30
  const newestPrice = (key) => state.prices.filter((p) => p.module_key === key).at(-1) || null;
31
31
  const query = async (sql, params = []) => {
32
32
  if (/^INSERT INTO store_module_prices/.test(sql)) {
33
- const [module_key, price_cents, price_credits, set_by] = params;
34
- state.prices.push({ module_key, price_cents, price_credits, set_by, set_at: ++state.setAt });
33
+ // Reads the column list rather than assuming it, so a writer that still sent
34
+ // price_credits would be recorded as such and fail the cash-only cases below.
35
+ const cols = sql.match(/\(([^)]*)\)/)[1].split(',').map((c) => c.trim());
36
+ const row = Object.fromEntries(cols.map((c, i) => [c, params[i]]));
37
+ state.prices.push({ price_credits: null, ...row, set_at: ++state.setAt });
35
38
  return { rows: [] };
36
39
  }
37
40
  if (/FROM store_module_prices\s+WHERE module_key = \$1/.test(sql)) {
@@ -105,9 +108,9 @@ test('only the author may set the price; a stranger and a delisted module are re
105
108
  assert.equal(state.prices.length, 0, 'nothing was written');
106
109
  });
107
110
 
108
- test('a price must be whole numbers of 0 or more, and name at least one currency', async () => {
111
+ test('a price must be a whole number of cents, 0 or more', async () => {
109
112
  state = fresh();
110
- for (const body of [{}, { cents: null, credits: null }, { cents: -1 }, { cents: 1.5 }, { credits: '10' }, { cents: 1e12 }]) {
113
+ for (const body of [{}, { cents: null }, { cents: -1 }, { cents: 1.5 }, { cents: '10' }, { cents: 1e12 }]) {
111
114
  const r = await setPrice(body);
112
115
  assert.equal(r.statusCode, 400, JSON.stringify(body));
113
116
  assert.ok(['bad_price', 'validation_failed'].includes(r.body.error.code), r.body.error.code);
@@ -115,21 +118,34 @@ test('a price must be whole numbers of 0 or more, and name at least one currency
115
118
  assert.equal(state.prices.length, 0);
116
119
  });
117
120
 
118
- test('each change appends a row naming who set it, and the newest is what acquire reads', async () => {
121
+ // Task 1004989 (ADR 0385 decision 2): credits never price or buy a module.
122
+ test('a credit price is refused by name, with or without cents, and nothing is written', async () => {
123
+ state = fresh();
124
+ for (const body of [{ credits: 40 }, { credits: 0 }, { cents: 500, credits: 40 }, { credits: '10' }]) {
125
+ const r = await setPrice(body);
126
+ assert.equal(r.statusCode, 400, JSON.stringify(body));
127
+ assert.equal(r.body.error.code, 'credit_price_refused', JSON.stringify(body));
128
+ assert.match(r.body.error.message, /Credits cannot price a module/);
129
+ }
130
+ assert.equal(state.prices.length, 0, 'no row, not even the cash half');
131
+ });
132
+
133
+ test('each change appends a cash-only row naming who set it, and the newest is what acquire reads', async () => {
119
134
  state = fresh();
120
135
  const first = await setPrice({ cents: 900 });
121
136
  assert.equal(first.statusCode, 200);
122
- assert.deepEqual(first.body, { ok: true, module_key: 'weather', previous: null, price: { cents: 900, credits: null } });
123
- const second = await setPrice({ cents: 500, credits: 40 });
124
- assert.deepEqual(second.body.previous, { cents: 900, credits: null });
137
+ assert.deepEqual(first.body, { ok: true, module_key: 'weather', previous: null, price: { cents: 900 } });
138
+ const second = await setPrice({ cents: 500 });
139
+ assert.deepEqual(second.body.previous, { cents: 900, coming: false });
125
140
  assert.equal(state.prices.length, 2, 'the history is kept');
126
141
  assert.equal(state.prices[1].set_by, 42);
142
+ assert.ok(state.prices.every((p) => p.price_credits == null), 'no new row carries a credit price');
127
143
  const acq = await store.resolveAcquirable('weather', {});
128
144
  assert.equal(acq.ok, true);
129
- assert.deepEqual([acq.price.price_cents, acq.price.price_credits], [500, 40]);
145
+ assert.equal(acq.price.price_cents, 500);
130
146
  assert.equal(acq.free, false);
131
- await setPrice({ credits: 0 });
132
- assert.equal((await store.resolveAcquirable('weather', {})).free, true, '0 in either currency is free');
147
+ await setPrice({ cents: 0 });
148
+ assert.equal((await store.resolveAcquirable('weather', {})).free, true, 'a cash price of 0 is free');
133
149
  });
134
150
 
135
151
  test('the catalog shows the price, and marks a paid module not yet buyable until buying opens', async () => {
@@ -139,26 +155,79 @@ test('the catalog shows the price, and marks a paid module not yet buyable until
139
155
  assert.equal(cat.listed[0].buyable, true, 'a free module can be had today');
140
156
  await setPrice({ cents: 900 });
141
157
  cat = await listing.listCatalog({});
142
- assert.deepEqual(cat.listed[0].price, { cents: 900, credits: null, free: false });
158
+ assert.deepEqual(cat.listed[0].price, { cents: 900, free: false, coming: false });
143
159
  assert.equal(cat.listed[0].buyable, false, 'buying arrives with task 1003788');
144
160
  });
145
161
 
146
- test('bongos module price: sends the price, says what it was and now is, and that paid is not yet buyable', async () => {
162
+ test('an OLD credit-only row: listed as price coming, never free, acquire refuses with no credit path, the author is asked', async () => {
163
+ state = fresh();
164
+ state.prices.push({ module_key: 'weather', price_cents: null, price_credits: 40, set_by: 42, set_at: ++state.setAt });
165
+ const cat = await listing.listCatalog({});
166
+ assert.deepEqual(cat.listed[0].price, { cents: null, free: false, coming: true });
167
+ assert.equal(cat.listed[0].buyable, false);
168
+ const acq = await store.resolveAcquirable('weather', {});
169
+ assert.equal(acq.free, false, 'credits never make it free or buyable');
170
+ // The author's listing read carries the ask; a stranger's does not.
171
+ const mine = await listing.readListing('weather', { viewerId: 42 });
172
+ assert.equal(mine.price_prompt, listing.PRICE_COMING_PROMPT);
173
+ assert.match(mine.price_prompt, /set a cash price/);
174
+ const theirs = await listing.readListing('weather', { viewerId: 7 });
175
+ assert.equal(theirs.price_prompt, null);
176
+ assert.deepEqual(theirs.price, { cents: null, free: false, coming: true });
177
+ // Setting a cash price replaces it; the reply says what it was.
178
+ const set = await setPrice({ cents: 300 });
179
+ assert.deepEqual(set.body.previous, { cents: null, coming: true });
180
+ assert.equal((await listing.readListing('weather', { viewerId: 42 })).price_prompt, null, 'the ask goes away');
181
+ // An old 0-credit offer stays free, as it was.
182
+ state = fresh();
183
+ state.prices.push({ module_key: 'weather', price_cents: null, price_credits: 0, set_by: 42, set_at: ++state.setAt });
184
+ assert.equal((await store.resolveAcquirable('weather', {})).free, true);
185
+ });
186
+
187
+ // The hall store row: the real storeFacts lifted out of modules.js and run over a
188
+ // real catalog entry, so the page and the read cannot disagree about the flag.
189
+ test('the hall store row marks a price-coming module, and nothing else', async () => {
190
+ const src = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/modules.js'), 'utf8');
191
+ const start = src.indexOf(' function storeFacts(m) {');
192
+ const end = src.indexOf('\n }\n', start);
193
+ assert.ok(start > 0 && end > start, 'storeFacts is in modules.js');
194
+ const storeFacts = new Function('escapeHtml', 'sortedPartFact', 'partsList',
195
+ `${src.slice(start, end + 4)}; return storeFacts;`)((s) => String(s), () => '', () => '');
196
+ state = fresh();
197
+ state.prices.push({ module_key: 'weather', price_cents: null, price_credits: 40, set_by: 42, set_at: ++state.setAt });
198
+ const coming = (await listing.listCatalog({})).listed[0];
199
+ assert.match(storeFacts(coming), /price coming/);
200
+ assert.doesNotMatch(storeFacts(coming), /40/, 'the credit amount is never drawn');
201
+ state = fresh();
202
+ await setPrice({ cents: 900 });
203
+ assert.doesNotMatch(storeFacts((await listing.listCatalog({})).listed[0]), /price coming/);
204
+ });
205
+
206
+ test('the acquire refusal never offers credits as a way to pay', () => {
207
+ const src = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/modules.js'), 'utf8');
208
+ const at = src.indexOf("'payment_required'");
209
+ const block = src.slice(at, at + 600);
210
+ assert.ok(at > 0);
211
+ assert.doesNotMatch(block, /credit economy/);
212
+ assert.match(block, /has no cash price yet/);
213
+ });
214
+
215
+ test('bongos module price: sends the cash price, says what it was and now is, and that paid is not yet buyable', async () => {
147
216
  const sent = [];
148
217
  const request = async (method, url, opts) => {
149
218
  sent.push({ method, url, json: opts && opts.json });
150
- return { ok: true, status: 200, data: { ok: true, previous: { cents: 0, credits: null }, price: { cents: 900, credits: 40 } } };
219
+ return { ok: true, status: 200, data: { ok: true, previous: { cents: null, coming: true }, price: { cents: 900 } } };
151
220
  };
152
221
  const out = [];
153
- assert.equal(await mod.cmdPrice(['weather', '--cents', '900', '--credits', '40'], { log: (l) => out.push(l), errlog: () => {}, request }), 0);
154
- assert.deepEqual(sent, [{ method: 'POST', url: '/store/modules/weather/price', json: { cents: 900, credits: 40 } }]);
155
- assert.match(out.join('\n'), /"weather" now costs \$9\.00 or 40 credits \(was free\)/);
222
+ assert.equal(await mod.cmdPrice(['weather', '--cents', '900'], { log: (l) => out.push(l), errlog: () => {}, request }), 0);
223
+ assert.deepEqual(sent, [{ method: 'POST', url: '/store/modules/weather/price', json: { cents: 900 } }]);
224
+ assert.match(out.join('\n'), /"weather" now costs \$9\.00 \(was price coming\)/);
156
225
  assert.match(out.join('\n'), /not yet buyable/);
157
226
  });
158
227
 
159
- test('bongos module price: refuses no key, no amount and a non-number before asking the store', async () => {
228
+ test('bongos module price: refuses no key, no amount, a non-number and any credit price before asking the store', async () => {
160
229
  const request = async () => { throw new Error('must not be called'); };
161
- for (const args of [[], ['--cents', '5'], ['weather'], ['weather', '--cents', 'five'], ['weather', '--credits', '-3']]) {
230
+ for (const args of [[], ['--cents', '5'], ['weather'], ['weather', '--cents', 'five'], ['weather', '--credits', '40'], ['weather', '--cents', '5', '--credits', '1']]) {
162
231
  const errs = [];
163
232
  assert.equal(await mod.cmdPrice(args, { log: () => {}, errlog: (l) => errs.push(l), request }), 2, args.join(' '));
164
233
  assert.match(errs.join('\n'), /usage: bongos module price/);
@@ -183,7 +252,7 @@ test('the price history is the audit: who set each price and when, and what it r
183
252
  assert.match(src, /FROM store_modules WHERE module_key = \$1 FOR UPDATE/, 'one transaction, the module row locked');
184
253
  });
185
254
 
186
- test('bongos module publish --cents/--credits sets the price after the upload, and checks it before packing', async () => {
255
+ test('bongos module publish --cents sets the price after the upload, and checks it (credits refused) before packing', async () => {
187
256
  const packed = { tgz: Buffer.from('x'), sidecar: { module_version: '1.0.0', core_version: '^1.0.0', file_count: 1, tree_sha256: 'a'.repeat(64), tarball: { name: 'x.tgz' } } };
188
257
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'publish-price-'));
189
258
  fs.mkdirSync(path.join(dir, 'weather'));
@@ -195,13 +264,15 @@ test('bongos module publish --cents/--credits sets the price after the upload, a
195
264
  const out = [];
196
265
  const code = await mod.cmdPublish(['--cents', '900', 'weather'], { log: (l) => out.push(l), errlog: (l) => out.push(l), modulesDir: dir, pack: () => packed, upload, priceRequest });
197
266
  assert.equal(code, 0, out.join('\n'));
198
- assert.deepEqual(sent, [['POST', '/store/modules/weather/price', { cents: 900, credits: null }]], 'the key is found even after a valued flag');
267
+ assert.deepEqual(sent, [['POST', '/store/modules/weather/price', { cents: 900 }]], 'the key is found even after a valued flag');
199
268
  let packedAtAll = false;
200
- const bad = await mod.cmdPublish(['weather', '--cents', 'nine'], { log: () => {}, errlog: () => {}, modulesDir: dir, pack: () => { packedAtAll = true; return packed; }, upload, priceRequest });
201
- assert.equal(bad, 2);
202
- assert.equal(packedAtAll, false, 'a bad price refuses before anything is packed or sent');
269
+ for (const flags of [['--cents', 'nine'], ['--credits', '40']]) {
270
+ const bad = await mod.cmdPublish(['weather', ...flags], { log: () => {}, errlog: () => {}, modulesDir: dir, pack: () => { packedAtAll = true; return packed; }, upload, priceRequest });
271
+ assert.equal(bad, 2, flags.join(' '));
272
+ }
273
+ assert.equal(packedAtAll, false, 'a bad or credit price refuses before anything is packed or sent');
203
274
  const failing = async () => ({ ok: false, status: 403, data: { error: { message: 'Only the author' } } });
204
275
  const errs = [];
205
- assert.equal(await mod.cmdPublish(['weather', '--credits', '40'], { log: () => {}, errlog: (l) => errs.push(l), modulesDir: dir, pack: () => packed, upload, priceRequest: failing }), 1);
206
- assert.match(errs.join('\n'), /The version is published, but its price was not set\. Run `bongos module price weather --credits 40`/);
276
+ assert.equal(await mod.cmdPublish(['weather', '--cents', '40'], { log: () => {}, errlog: (l) => errs.push(l), modulesDir: dir, pack: () => packed, upload, priceRequest: failing }), 1);
277
+ assert.match(errs.join('\n'), /The version is published, but its price was not set\. Run `bongos module price weather --cents 40`/);
207
278
  });
@@ -43,6 +43,17 @@ test('a price of zero is allowed in both currencies — free modules are first-c
43
43
  assert.match(t, /CHECK \(price_cents IS NOT NULL OR price_credits IS NOT NULL\)/);
44
44
  });
45
45
 
46
+ // Task 1004989 (ADR 0385 decision 2): a NEW price row may not carry credits, and
47
+ // the old rows stay readable — so the CHECK is NOT VALID and must stay that way.
48
+ test('core_287: new price rows are cash only; old credit rows are kept, not re-checked', () => {
49
+ const sql = readFileSync(path.join(ROOT, 'migrations', 'core_287_module_prices_cash_only.sql'), 'utf8')
50
+ .replace(/--[^\n]*/g, '');
51
+ assert.match(sql, /ADD CONSTRAINT store_module_prices_no_new_credit_price CHECK \(price_credits IS NULL\) NOT VALID;/);
52
+ assert.match(sql, /DROP CONSTRAINT IF EXISTS store_module_prices_no_new_credit_price;/, 'idempotent on a re-run');
53
+ assert.doesNotMatch(sql, /VALIDATE CONSTRAINT|UPDATE store_module_prices|DELETE FROM store_module_prices/,
54
+ 'no old row is validated, rewritten or deleted');
55
+ });
56
+
46
57
  test('a version is exact semver and can be published only once per module', () => {
47
58
  const t = table('store_module_versions');
48
59
  assert.match(t, /version\s+text NOT NULL\s+CHECK \(version ~ '\^\[0-9\]\+\\\.\[0-9\]\+\\\.\[0-9\]\+\$'\)/);