@bongos/core 1.24.8 → 1.24.10

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 (88) hide show
  1. package/.bongos-core.json +131 -86
  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 +11 -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 +185 -6
  9. package/docs/api-reference.md +7 -3
  10. package/docs/architecture.md +2 -2
  11. package/docs/copy-inventory.md +58 -28
  12. package/docs/copy-registry.json +333 -33
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/modules-contract.md +3 -3
  15. package/docs/page-inventory.json +34 -4
  16. package/docs/page-readings.json +294 -237
  17. package/migrations/core_287_module_prices_cash_only.sql +24 -0
  18. package/modules/hall-ui/public/ai-studio.html +2 -2
  19. package/modules/hall-ui/public/atlas.html +2 -2
  20. package/modules/hall-ui/public/blockers.html +2 -2
  21. package/modules/hall-ui/public/board-room.html +2 -2
  22. package/modules/hall-ui/public/city.html +2 -2
  23. package/modules/hall-ui/public/collab.html +2 -2
  24. package/modules/hall-ui/public/commands.html +2 -2
  25. package/modules/hall-ui/public/copy-desk.html +2 -2
  26. package/modules/hall-ui/public/craft-mode.js +1 -1
  27. package/modules/hall-ui/public/deploy.html +2 -2
  28. package/modules/hall-ui/public/diagrams.html +2 -2
  29. package/modules/hall-ui/public/drachmae.html +2 -2
  30. package/modules/hall-ui/public/fleet.html +2 -2
  31. package/modules/hall-ui/public/gate.html +2 -2
  32. package/modules/hall-ui/public/goal-map.html +2 -2
  33. package/modules/hall-ui/public/goals.html +2 -2
  34. package/modules/hall-ui/public/government.html +2 -2
  35. package/modules/hall-ui/public/idea.html +2 -2
  36. package/modules/hall-ui/public/ideas.html +2 -2
  37. package/modules/hall-ui/public/index.html +2 -2
  38. package/modules/hall-ui/public/market-watch-rules.html +2 -2
  39. package/modules/hall-ui/public/market-watch-settings.css +217 -0
  40. package/modules/hall-ui/public/market-watch-settings.html +113 -0
  41. package/modules/hall-ui/public/market-watch-settings.js +230 -0
  42. package/modules/hall-ui/public/market-watch-settings.states.json +14 -0
  43. package/modules/hall-ui/public/modules.html +2 -2
  44. package/modules/hall-ui/public/modules.js +5 -0
  45. package/modules/hall-ui/public/my-agents.html +2 -2
  46. package/modules/hall-ui/public/primer.html +2 -2
  47. package/modules/hall-ui/public/profile.html +2 -2
  48. package/modules/hall-ui/public/project-settings.html +2 -2
  49. package/modules/hall-ui/public/ranks.html +2 -2
  50. package/modules/hall-ui/public/roadmap.html +2 -2
  51. package/modules/hall-ui/public/roster.html +2 -2
  52. package/modules/hall-ui/public/sessions.html +2 -2
  53. package/modules/hall-ui/public/settings.html +2 -2
  54. package/modules/hall-ui/public/shell.js +3 -0
  55. package/modules/hall-ui/public/street.html +2 -2
  56. package/modules/hall-ui/public/studio.html +2 -2
  57. package/modules/hall-ui/public/task.html +2 -2
  58. package/modules/hall-ui/public/thinking.html +2 -2
  59. package/modules/hall-ui/public/tweak-editor.html +2 -2
  60. package/modules/hall-ui/public/watch.html +2 -2
  61. package/modules/hall-ui/public/work.html +2 -2
  62. package/modules/hall-ui/records/market-watch-settings.md +11 -0
  63. package/modules/market-watch/module.json +3 -2
  64. package/modules/market-watch/routes/settings.js +128 -0
  65. package/modules/market-watch/settings.js +207 -0
  66. package/modules/provisioning/starter-bundles.js +11 -6
  67. package/modules/ui-design/kit/serve.js +1 -0
  68. package/package-lock.json +2 -2
  69. package/package.json +1 -1
  70. package/release-notes.json +12 -0
  71. package/scripts/bongos/module-store-cli.js +21 -21
  72. package/scripts/bongos/module.js +8 -8
  73. package/src/bongos/module-listing.js +47 -20
  74. package/src/bongos/module-scope-map.js +1 -0
  75. package/src/bongos/module-store.js +20 -12
  76. package/src/bongos/routes/modules.js +31 -21
  77. package/src/bongos/serve-internal.js +2 -2
  78. package/src/module-api.js +1 -1
  79. package/tests/hall_nav.mjs +4 -2
  80. package/tests/hall_page_gate_map.mjs +4 -0
  81. package/tests/market_watch_data_db.mjs +27 -0
  82. package/tests/market_watch_module.mjs +8 -4
  83. package/tests/market_watch_settings.mjs +392 -0
  84. package/tests/module_store_cli.mjs +14 -3
  85. package/tests/module_store_listing.mjs +65 -14
  86. package/tests/module_store_price.mjs +97 -26
  87. package/tests/module_store_registry_migration.mjs +11 -0
  88. package/tests/nav_permission_atoms.mjs +3 -2
@@ -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\]\+\$'\)/);
@@ -92,8 +92,9 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
92
92
  // the seats read from /me's server-resolved positions block; task 1004730: the banner
93
93
  // says a held core version was tried and rolled back; task 1004731: the one-time
94
94
  // "Upgraded to core X" notice after a move; task 1004826: Standing is Accomplishments;
95
- // task 1004846: the new logo (its bump landed late, in task 1004852).
96
- const SHELL_SHA = 'cea38eeab6b234cc';
95
+ // task 1004846: the new logo (its bump landed late, in task 1004852); task 1004938: the
96
+ // Market Watch settings nav item and its fuel glyph.
97
+ const SHELL_SHA = '4022e01fbb4045ad';
97
98
 
98
99
  test('shell.js has not changed without its cache-bust being bumped', () => {
99
100
  const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);