@bongos/core 1.24.8 → 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.
@@ -1859,7 +1859,7 @@
1859
1859
  "surface": "builders",
1860
1860
  "title": "Modules",
1861
1861
  "states_read": ["browse","search-empty","lens","details","out"],
1862
- "files_hash": "5d13e25fdcd22d84",
1862
+ "files_hash": "2c777359d9db9123",
1863
1863
  "reading_hash": "d544f93125c23b24",
1864
1864
  "counts": {"lines":58,"placed":26,"shared":8,"unplaced":24},
1865
1865
  "omitted": {"data":106},
@@ -1910,8 +1910,8 @@
1910
1910
  {"key":"L0043","section":"Module catalog","text":"Name","placement":"unplaced"},
1911
1911
  {"key":"L0044","section":"Module catalog","text":"No store module matches that {…}.","placement":"unplaced","state":"search-empty"},
1912
1912
  {"key":"L0045","section":"Module catalog","text":"scores","placement":"unplaced","occurrences":2},
1913
- {"key":"L0046","section":"Module catalog","text":"strong at {…}","placement":"placed","file":"modules/hall-ui/public/modules.js","line":349,"string_id":"e294c3429c2e","occurrences":3},
1914
- {"key":"L0047","section":"Module catalog","text":"weak at {…}","placement":"placed","file":"modules/hall-ui/public/modules.js","line":351,"string_id":"20318b6da94e"},
1913
+ {"key":"L0046","section":"Module catalog","text":"strong at {…}","placement":"placed","file":"modules/hall-ui/public/modules.js","line":354,"string_id":"e294c3429c2e","occurrences":3},
1914
+ {"key":"L0047","section":"Module catalog","text":"weak at {…}","placement":"placed","file":"modules/hall-ui/public/modules.js","line":356,"string_id":"20318b6da94e"},
1915
1915
  {"key":"L0048","section":"Module catalog","text":"A part is strong at 80 of 100 or more, and weak below the listing floor. Missing data never counts against a module, and a new module is not ranked.","placement":"placed","file":"modules/hall-ui/public/modules.html","line":98,"string_id":"01857ee8cbe3"},
1916
1916
  {"key":"L0049","section":"footer","text":"Sign out","placement":"shared","file":"modules/hall-ui/public/shell.js","line":516,"string_id":"1883ad7f07fc"},
1917
1917
  {"key":"L0050","section":"Module catalog","text":"Builders hall · this session","placement":"placed","file":"modules/hall-ui/public/craft-mode.js","line":670,"string_id":"fd1903586e8e"},
@@ -0,0 +1,24 @@
1
+ -- core_287_module_prices_cash_only.sql — task 1004989 (goal 1000092; ADR 0385
2
+ -- decision 2 and its 2026-10-29 amendment).
3
+ --
4
+ -- Credits are a contribution meter: they are never spent, transferred or bought
5
+ -- with, so a module cannot carry a price in credits. A credit price would make
6
+ -- credits stored value. From now on a new price row carries cents only.
7
+ --
8
+ -- HISTORY STAYS READABLE. The CHECK is added NOT VALID, so Postgres enforces it
9
+ -- on every NEW row (and on any update) but does not re-check the rows already
10
+ -- there. A module whose newest price was set in credits keeps that row; the
11
+ -- store reads it as "price coming" — still listed, never buyable with credits —
12
+ -- until its author sets a cash price (the owner's ruling, 2026-10-29). Nothing
13
+ -- is converted, deleted or rewritten. Do not VALIDATE this constraint: the old
14
+ -- credit rows are the evidence of what each author asked, and they would fail it.
15
+ --
16
+ -- core_257's own CHECK (price_cents IS NOT NULL OR price_credits IS NOT NULL)
17
+ -- still holds, so with credits refused a new row must name a cash price.
18
+
19
+ ALTER TABLE store_module_prices DROP CONSTRAINT IF EXISTS store_module_prices_no_new_credit_price;
20
+ ALTER TABLE store_module_prices
21
+ ADD CONSTRAINT store_module_prices_no_new_credit_price CHECK (price_credits IS NULL) NOT VALID;
22
+
23
+ COMMENT ON COLUMN store_module_prices.price_credits IS
24
+ 'Historical only. Credits cannot price a module (ADR 0385 decision 2); new rows must leave this NULL (store_module_prices_no_new_credit_price, NOT VALID so old rows stay). A newest row with only credits reads as "price coming".';
@@ -342,6 +342,11 @@
342
342
  const facts = [m.is_new
343
343
  ? '<span class="fact-pill fact-pill--info" title="No real-world data yet, so it is listed but not ranked.">new</span>'
344
344
  : `<span class="ov-fact">scores <b>${escapeHtml(String(m.overall))}</b></span>`];
345
+ // A module that still carries only an old credit price stays listed, marked
346
+ // until its author sets a cash price (task 1004989): credits never buy one.
347
+ if (m.price && m.price.coming) {
348
+ facts.push('<span class="fact-pill fact-pill--info" title="Its author has been asked to set a cash price. Credits cannot buy a module.">price coming</span>');
349
+ }
345
350
  const sorted = sortedPartFact(m);
346
351
  if (sorted) facts.push(sorted);
347
352
  const strong = Array.isArray(m.strengths) ? m.strengths : [];
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.8",
3
+ "version": "1.24.9",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.24.8",
9
+ "version": "1.24.9",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.8",
3
+ "version": "1.24.9",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -10883,5 +10883,11 @@
10883
10883
  "id": "1004941",
10884
10884
  "text": "Every member can now read the Market Watch rules step by step, propose a change with a reason, and the owner (or the government, by vote) approves it into a new rules version that the next weekly run uses."
10885
10885
  }
10886
+ ],
10887
+ "1.24.9": [
10888
+ {
10889
+ "id": "1004989",
10890
+ "text": "Modules can now only be priced in cash: credits can no longer price or buy one, and modules that still have an old credit price stay listed as \"price coming\" while their authors are asked to set a cash price."
10891
+ }
10886
10892
  ]
10887
10893
  }
@@ -63,13 +63,17 @@ function storeError(res) {
63
63
  // bongos module store — what the store lists (task 1003811)
64
64
  // ---------------------------------------------------------------------------
65
65
 
66
- const STORE_USAGE = 'usage: bongos module store [--sort <part>] [--max-cents N] [--max-credits N] [--json]';
66
+ const STORE_USAGE = 'usage: bongos module store [--sort <part>] [--max-cents N] [--json]';
67
+
68
+ // Credits never price or buy a module (task 1004989, ADR 0385 decision 2).
69
+ const NO_CREDIT_PRICE = 'credits do not price modules: use cents';
67
70
 
68
71
  // The query GET /store/modules takes, from argv. Returns { query } or { error }.
69
72
  // The server checks the values too; this only refuses what is not a value at all.
70
73
  function storeQuery(args) {
74
+ if (args.includes('--max-credits')) return { error: NO_CREDIT_PRICE };
71
75
  const params = [];
72
- for (const [flag, param] of [['--sort', 'sort'], ['--max-cents', 'max_cents'], ['--max-credits', 'max_credits']]) {
76
+ for (const [flag, param] of [['--sort', 'sort'], ['--max-cents', 'max_cents']]) {
73
77
  const i = args.indexOf(flag);
74
78
  if (i === -1) continue;
75
79
  const v = args[i + 1];
@@ -79,12 +83,12 @@ function storeQuery(args) {
79
83
  return { query: params.length ? `?${params.join('&')}` : '' };
80
84
  }
81
85
 
86
+ // A cash price, "free", or "price coming" for a module that still carries only an
87
+ // old credit price. Never a credit amount.
82
88
  function formatPrice(price) {
83
89
  if (!price || price.free) return 'free';
84
- const parts = [];
85
- if (price.cents != null) parts.push(`$${(price.cents / 100).toFixed(2)}`);
86
- if (price.credits != null) parts.push(`${price.credits} credits`);
87
- return parts.join(' or ') || 'free';
90
+ if (price.cents != null) return `$${(price.cents / 100).toFixed(2)}`;
91
+ return 'price coming';
88
92
  }
89
93
 
90
94
  function formatPlace(category) {
@@ -104,7 +108,7 @@ function formatStore(body) {
104
108
  out.push(` sorted by ${part ? part.title : body.sort}`);
105
109
  }
106
110
  const max = body.max_price;
107
- if (max) out.push(` at most ${[max.cents != null ? `$${(max.cents / 100).toFixed(2)}` : null, max.credits != null ? `${max.credits} credits` : null].filter(Boolean).join(' or ')}`);
111
+ if (max && max.cents != null) out.push(` at most $${(max.cents / 100).toFixed(2)}`);
108
112
  out.push('');
109
113
  const listed = body.listed || [];
110
114
  if (!listed.length) out.push(' Nothing is listed yet.');
@@ -141,22 +145,18 @@ async function cmdStore(args, { log = console.log, errlog = console.error, reque
141
145
  // bongos module price — the author sets a module's price (task 1004894)
142
146
  // ---------------------------------------------------------------------------
143
147
 
144
- const PRICE_USAGE = 'usage: bongos module price <key> [--cents N] [--credits N] (0 in either makes it free)';
148
+ const PRICE_USAGE = 'usage: bongos module price <key> --cents N (0 makes it free)';
145
149
 
146
- // argv -> { key, cents, credits } or { error }. Pure.
150
+ // argv -> { key, cents } or { error }. Pure.
147
151
  function priceArgs(args) {
148
152
  const key = args[0];
149
153
  if (!key || key.startsWith('--') || !KEY_RE.test(key)) return { error: 'name the module: a lowercase kebab-case key' };
150
- const out = { key, cents: null, credits: null };
151
- for (const [flag, field] of [['--cents', 'cents'], ['--credits', 'credits']]) {
152
- const i = args.indexOf(flag);
153
- if (i === -1) continue;
154
- const v = args[i + 1];
155
- if (!v || !/^\d{1,9}$/.test(v)) return { error: `${flag} needs a whole number, 0 or more` };
156
- out[field] = Number(v);
157
- }
158
- if (out.cents === null && out.credits === null) return { error: 'give --cents, --credits or both' };
159
- return out;
154
+ if (args.includes('--credits')) return { error: NO_CREDIT_PRICE };
155
+ const i = args.indexOf('--cents');
156
+ if (i === -1) return { error: 'give --cents' };
157
+ const v = args[i + 1];
158
+ if (!v || !/^\d{1,9}$/.test(v)) return { error: '--cents needs a whole number, 0 or more' };
159
+ return { key, cents: Number(v) };
160
160
  }
161
161
 
162
162
  async function cmdPrice(args, { log = console.log, errlog = console.error, request } = {}) {
@@ -166,10 +166,10 @@ async function cmdPrice(args, { log = console.log, errlog = console.error, reque
166
166
  try { call = request || storeRequest(); }
167
167
  catch (e) { errlog(`bongos module price: ${e.message}`); return 1; }
168
168
  let res;
169
- try { res = await call('POST', `/store/modules/${encodeURIComponent(p.key)}/price`, { json: { cents: p.cents, credits: p.credits } }); }
169
+ try { res = await call('POST', `/store/modules/${encodeURIComponent(p.key)}/price`, { json: { cents: p.cents } }); }
170
170
  catch (e) { errlog(`bongos module price: the store could not be reached — ${e.message}`); return 1; }
171
171
  if (!res.ok) { errlog(`bongos module price: the store answered ${storeError(res)}`); return res.status === 400 ? 2 : 1; }
172
- const withFree = (p) => ({ ...p, free: p.cents === 0 || p.credits === 0 });
172
+ const withFree = (p) => ({ ...p, free: p.cents === 0 });
173
173
  const shown = formatPrice(withFree(res.data.price));
174
174
  const was = res.data.previous ? formatPrice(withFree(res.data.previous)) : 'free (no price set)';
175
175
  log(`✓ "${p.key}" now costs ${shown} (was ${was}).`);
@@ -31,7 +31,7 @@
31
31
  // model as the hall's Modules tab (src/module-loader/catalog.js), so the two
32
32
  // surfaces cannot disagree about who built what.
33
33
  //
34
- // bongos module store [--sort <part>] [--max-cents N] [--max-credits N] [--json]
34
+ // bongos module store [--sort <part>] [--max-cents N] [--json]
35
35
  // Task 1003811 (criterion wa5-curated-listing) — the STORE from the terminal:
36
36
  // what the catalog lists, in its order. It asks GET /store/modules, the same
37
37
  // read the hall's "In the store" group makes, and never filters, ranks or caps
@@ -39,9 +39,9 @@
39
39
  // sub-category live in one place (src/bongos/module-listing.js), so the hall
40
40
  // and the terminal cannot disagree about which modules are the best.
41
41
  //
42
- // bongos module price <key> [--cents N] [--credits N]
42
+ // bongos module price <key> --cents N (credits never price a module)
43
43
  // Task 1004894 — the module's author sets its price in the store, in money
44
- // (cents), credits or both; 0 in either makes it free. The store keeps every
44
+ // (cents); 0 makes it free. A credit price is refused (task 1004989). The store keeps every
45
45
  // change and shows the newest. A paid module is listed as not yet buyable until
46
46
  // buying arrives (task 1003788).
47
47
  //
@@ -72,7 +72,7 @@
72
72
  // bongos module new <key> scaffold modules/<key>/
73
73
  // bongos module new <key> --force overwrite an existing modules/<key>/
74
74
  // bongos module list [--catalog] browse the module catalog (author + origin credit)
75
- // bongos module store [--sort <part>] [--max-cents N] [--max-credits N] what the store lists, best first
75
+ // bongos module store [--sort <part>] [--max-cents N] what the store lists, best first
76
76
  // bongos upgrade [--no-store] pre-check enabled modules vs the core version + list store updates
77
77
  // bongos module check <key> [--sign-off "Name <email>"] AGPL/publish pre-check + DCO sign-off
78
78
  // bongos module install <key> [--version X.Y.Z] acquire from the store, verify, place (not enabled)
@@ -968,7 +968,7 @@ async function cmdPublish(args, {
968
968
  log = console.log, errlog = console.error,
969
969
  modulesDir = MODULES_DIR, pack, upload, writeFile = fs.writeFileSync, priceRequest,
970
970
  } = {}) {
971
- const PUBLISH_USAGE = 'usage: bongos module publish <key> [--dry-run] [--out <dir>] [--cents N] [--credits N]';
971
+ const PUBLISH_USAGE = 'usage: bongos module publish <key> [--dry-run] [--out <dir>] [--cents N]';
972
972
  const VALUED = new Set(['--out', '--cents', '--credits']);
973
973
  const key = args.find((a, i) => !a.startsWith('-') && !VALUED.has(args[i - 1]));
974
974
  if (!key) {
@@ -1335,9 +1335,9 @@ function printHelp(out = console.log) {
1335
1335
  out(' bongos module new <key> Scaffold modules/<key>/ (manifest + skeleton + sample route + HOWTO.md + CLAUDE.md)');
1336
1336
  out(' bongos module list [--catalog] [--search <text>] [--json]');
1337
1337
  out(' Browse the module catalog — what you can enable, with author + origin credit');
1338
- out(' bongos module store [--sort <part>] [--max-cents N] [--max-credits N] [--json]');
1338
+ out(' bongos module store [--sort <part>] [--max-cents N] [--json]');
1339
1339
  out(' What the store lists, best first: above the quality floor, ranked, ten per sub-category');
1340
- out(' bongos module price <key> [--cents N] [--credits N]');
1340
+ out(' bongos module price <key> --cents N');
1341
1341
  out(' Set your module\'s price in the store (0 in either makes it free)');
1342
1342
  out(' bongos module remove <key> Take a store module off this project (switch it off first; its folder is kept aside)');
1343
1343
  out(' bongos upgrade [--no-store] Pre-check enabled modules against the core version (fail-closed),');
@@ -1346,7 +1346,7 @@ function printHelp(out = console.log) {
1346
1346
  out(' AGPL/publish pre-check before upstreaming + record a DCO-style sign-off');
1347
1347
  out(' bongos module submit <key> [--sign-off "Name <email>"]');
1348
1348
  out(' Pre-check + sign-off, then file into the rank-gated upstreaming review queue');
1349
- out(' bongos module publish <key> [--dry-run] [--out <dir>] [--cents N] [--credits N]');
1349
+ out(' bongos module publish <key> [--dry-run] [--out <dir>] [--cents N]');
1350
1350
  out(' Pack a module version (tarball + hash manifest) and upload it to the module store');
1351
1351
  out(' bongos module install <key> [--version X.Y.Z]');
1352
1352
  out(' Acquire a module from the store, verify it and place it under modules/ (not enabled)');
@@ -135,39 +135,57 @@ function sortByPart(entries, part) {
135
135
  // can both check in one sentence:
136
136
  //
137
137
  // highest overall score first; between equal scores the cheaper module first
138
- // (free first, then the lowest cash price, then the lowest credit price); then
139
- // the key.
138
+ // (free first, then the lowest cash price, a module whose cash price is still
139
+ // coming last); then the key.
140
140
  //
141
141
  // Price never buys a better place: it only orders modules the score could not tell
142
142
  // apart (price is never an input to the score, ADR 0343 D6). A buyer may also set a
143
143
  // maximum price, which leaves out what costs more — a filter, not a ranking.
144
144
  const RANKING = 'Highest overall score first; between equal scores, free modules and then the cheapest come first.';
145
145
 
146
- // A module's current price row ({ price_cents, price_credits } or none) as the
147
- // catalog shows it. No row is free (isFreePrice, module-store.js); 0 in either
148
- // currency is free; null means not offered in that currency (core_257). Pure.
146
+ // CREDITS NEVER PRICE A MODULE (task 1004989, ADR 0385 decision 2). Credits are a
147
+ // contribution meter: a credit price would make them stored value. New price rows
148
+ // carry cents only (core_287 refuses price_credits). An OLD row may still carry a
149
+ // credit price; the owner's ruling (2026-10-29) is that such a module stays
150
+ // VISIBLE with a "price coming" note and can NOT be bought with credits, while
151
+ // its author is asked to set a cash price.
152
+ //
153
+ // A module's current price row as the catalog shows it: { cents, free, coming }.
154
+ // Never a credit amount. No row is free (isFreePrice, module-store.js); a cash
155
+ // price of 0 is free. An old row that offered it at 0 credits stays free, as it
156
+ // was: its author offered it at nothing, and no credit moves to have it. An old row whose
157
+ // only price was a positive number of credits is `coming` — no cash price yet,
158
+ // so not free and not buyable. Pure.
149
159
  function priceOf(row) {
150
160
  const cents = row && row.price_cents != null ? Number(row.price_cents) : null;
151
- const credits = row && row.price_credits != null ? Number(row.price_credits) : null;
152
- return { cents, credits, free: !row || cents === 0 || credits === 0 };
161
+ const legacyCredits = row && row.price_credits != null ? Number(row.price_credits) : null;
162
+ const free = !row || cents === 0 || legacyCredits === 0;
163
+ return { cents, free, coming: !free && cents == null };
153
164
  }
154
165
 
155
- // Whether a module can be bought today. Buying arrives with task 1003788 (area 8's
156
- // wallet); until then only a free module can be had, and a paid one is listed with
157
- // its price and marked not yet buyable (owner, 2026-10-26). Flip BUYING_OPEN when
158
- // the buy action ships, and nothing else needs to change. Pure.
166
+ // What the author of a "price coming" module is asked (task 1004989). One string,
167
+ // so the listing read, the CLI and the hall say the same thing.
168
+ const PRICE_COMING_PROMPT = 'This module still has a price in credits, and credits can no longer price or buy a module. '
169
+ + 'It stays listed with "price coming" until you set a cash price: bongos module price <key> --cents N (0 makes it free).';
170
+
171
+ // Whether a module can be bought today. Buying arrives with task 1003788; until
172
+ // then only a free module can be had, and a paid one is listed with its price and
173
+ // marked not yet buyable (owner, 2026-10-26). Flip BUYING_OPEN when the buy action
174
+ // ships. A module whose cash price is still coming stays unbuyable even then:
175
+ // there is nothing to pay in but credits, and credits do not buy. Pure.
159
176
  const BUYING_OPEN = false;
160
177
  function buyableNow(price) {
161
- return !!(price && (price.free || BUYING_OPEN));
178
+ return !!(price && (price.free || (BUYING_OPEN && price.cents != null)));
162
179
  }
163
180
 
164
- // Within a buyer's maximum ({ cents, credits }, either may be null): a free module
165
- // always is; a priced one is when it is offered at or under a cap the buyer set. Pure.
181
+ // Within a buyer's maximum cash price ({ cents }, or null for no cap): a free
182
+ // module always is; a priced one is when its cash price is at or under the cap.
183
+ // A module whose price is still coming has no cash price to compare, so a cap
184
+ // leaves it out. Pure.
166
185
  function withinMaxPrice(price, max) {
167
- if (!max || (max.cents == null && max.credits == null)) return true;
186
+ if (!max || max.cents == null) return true;
168
187
  if (price.free) return true;
169
- return (max.cents != null && price.cents != null && price.cents <= max.cents)
170
- || (max.credits != null && price.credits != null && price.credits <= max.credits);
188
+ return price.cents != null && price.cents <= max.cents;
171
189
  }
172
190
 
173
191
  // The default order of `listed`, per RANKING. Returns a new array. Pure.
@@ -179,7 +197,6 @@ function rankListed(entries) {
179
197
  return ((b.overall ?? -1) - (a.overall ?? -1))
180
198
  || (Number(pb.free) - Number(pa.free))
181
199
  || (cheap(pa.cents) - cheap(pb.cents))
182
- || (cheap(pa.credits) - cheap(pb.credits))
183
200
  || a.key.localeCompare(b.key);
184
201
  });
185
202
  }
@@ -292,8 +309,8 @@ async function buildCatalog({ db, floor = QUALITY_FLOOR, sort = null, maxPrice =
292
309
  signals.get(k).push(s);
293
310
  }
294
311
  }
295
- const max = maxPrice && (maxPrice.cents != null || maxPrice.credits != null)
296
- ? { cents: maxPrice.cents ?? null, credits: maxPrice.credits ?? null } : null;
312
+ // A cash cap only (task 1004989): credits are never a price, so never a cap.
313
+ const max = maxPrice && maxPrice.cents != null ? { cents: maxPrice.cents } : null;
297
314
  const cats = await readModuleCategories(newest.map((r) => r.module_key), { db: pool });
298
315
  const out = { floor, cap: CAP_PER_SUBCATEGORY, ranking: RANKING, sort: sort || null, sort_parts: SORT_PARTS, max_price: max, listed: [], new: [] };
299
316
  for (const r of newest.sort((a, b) => a.module_key.localeCompare(b.module_key))) {
@@ -421,14 +438,24 @@ async function readListing(key, { db, floor = QUALITY_FLOOR, viewerId = null } =
421
438
  if (held) placed = { placement: 'excluded', reason: 'over_cap' };
422
439
  }
423
440
  const overall = score && placed.placement !== 'new' ? score.overall : null;
441
+ // The price as the catalog shows it, and — to the author only — the ask to set a
442
+ // cash price when the module still carries only an old credit price (task
443
+ // 1004989; the owner's ruling, 2026-10-29). Read by idx_store_module_prices_current.
444
+ const { rows: [priceRow] } = await pool.query(
445
+ `SELECT price_cents, price_credits FROM store_module_prices
446
+ WHERE module_key = $1 ORDER BY set_at DESC, id DESC LIMIT 1`, [key]);
447
+ const price = priceOf(priceRow || null);
424
448
  return {
425
449
  ...head, version: newest.version, version_id: newest.version_id, overall,
426
450
  ...explainPlacement(placed, { overall, floor, security, weaknesses: reading.weaknesses, held }),
427
451
  security, ...reading,
452
+ price, buyable: buyableNow(price),
453
+ price_prompt: isAuthor && price.coming ? PRICE_COMING_PROMPT : null,
428
454
  };
429
455
  }
430
456
 
431
457
  module.exports = {
432
458
  QUALITY_FLOOR, STRONG_AT, SORT_PARTS, RANKING, CAP_PER_SUBCATEGORY, placeVersion, readParts,
433
459
  sortByPart, priceOf, withinMaxPrice, rankListed, applyCap, listCatalog, explainPlacement, readListing,
460
+ PRICE_COMING_PROMPT,
434
461
  };
@@ -254,8 +254,11 @@ async function publishVersion(input, { db, beforeCommit } = {}) {
254
254
  // A module with NO price row is free. The owner decided this (2026-09-29): nothing
255
255
  // sets a price yet and who may sell is still open, so an unpriced module is
256
256
  // acquirable at no cost, and an entitlement taken then is kept if a price is added
257
- // later (ADR 0338 D2). A priced module is free when either currency it is offered in
258
- // is zero (0 = free, NULL = not offered in that currency; core_257).
257
+ // later (ADR 0338 D2). A priced module is free when its cash price is zero. An old
258
+ // row that offered it at 0 credits also stays free, as it was — its author offered
259
+ // it at nothing and no credit moves to have it (task 1004989; module-listing.js priceOf
260
+ // reads the same rule). An old row priced only in credits above 0 is NOT free:
261
+ // its cash price is still coming, and credits never buy a module.
259
262
  function isFreePrice(priceRow) {
260
263
  if (!priceRow) return true;
261
264
  return priceRow.price_cents === 0 || priceRow.price_credits === 0;
@@ -387,20 +390,25 @@ async function latestVersions(keys, { db, channelsFor = null } = {}) {
387
390
  // author may, and a delisted module takes no new price (it can no longer be
388
391
  // acquired). The price is a HISTORY: each change appends a row and the newest is
389
392
  // current, which is what the catalog (module-listing.js priceOf) and acquire
390
- // (isFreePrice above) already read. `cents` / `credits`: a whole number of 0 or
391
- // more, or null for "not offered in that currency"; at least one must be given
392
- // (core_257's CHECK), and 0 in either is free. A paid price may be set before
393
+ // (isFreePrice above) already read. `cents`: a whole number of 0 or more; 0 is
394
+ // free. A paid price may be set before
393
395
  // buying exists (owner, 2026-10-26): the module is listed with it and marked not yet
394
396
  // buyable, and acquire keeps refusing it without an entitlement. Price is never an
395
397
  // input to the score (ADR 0343 D6). The history IS the audit of price changes:
396
398
  // every row names who set it and when, and the row before it is what it changed
397
399
  // from (the audit middleware also logs the request). One transaction, the module row
398
400
  // locked FOR UPDATE as publishVersion does, so two changes at once each report the
399
- // price they really replaced. Returns { ok: true, previous, price } (each
400
- // { cents, credits } or null) or { ok: false, status, code, message }.
401
- async function setPrice({ key, cents = null, credits = null, builderId }, { db } = {}) {
401
+ // price they really replaced. Returns { ok: true, previous, price } or
402
+ // { ok: false, status, code, message }.
403
+ //
404
+ // CASH ONLY (task 1004989, ADR 0385 decision 2): credits never price a module, so
405
+ // a new row carries cents and nothing else (core_287 refuses price_credits in the
406
+ // database too). `previous` reports the replaced row's cash price, and `coming:
407
+ // true` when that row priced the module in credits only.
408
+ async function setPrice({ key, cents, builderId }, { db } = {}) {
402
409
  const pool = db || require('./pool').pool;
403
410
  const refuse = (status, code, message) => ({ ok: false, status, code, message });
411
+ if (!Number.isInteger(cents) || cents < 0) return refuse(400, 'bad_price', 'A price is a whole number of cents, 0 or more. 0 makes the module free.');
404
412
  const client = await pool.connect();
405
413
  try {
406
414
  await client.query('BEGIN');
@@ -415,13 +423,13 @@ async function setPrice({ key, cents = null, credits = null, builderId }, { db }
415
423
  `SELECT price_cents, price_credits FROM store_module_prices
416
424
  WHERE module_key = $1 ORDER BY set_at DESC, id DESC LIMIT 1`, [key]);
417
425
  await client.query(
418
- 'INSERT INTO store_module_prices (module_key, price_cents, price_credits, set_by) VALUES ($1, $2, $3, $4)',
419
- [key, cents, credits, builderId]);
426
+ 'INSERT INTO store_module_prices (module_key, price_cents, set_by) VALUES ($1, $2, $3)',
427
+ [key, cents, builderId]);
420
428
  await client.query('COMMIT');
421
429
  return {
422
430
  ok: true,
423
- previous: prev ? { cents: prev.price_cents, credits: prev.price_credits } : null,
424
- price: { cents, credits },
431
+ previous: prev ? { cents: prev.price_cents, coming: prev.price_cents == null && Number(prev.price_credits) > 0 } : null,
432
+ price: { cents },
425
433
  };
426
434
  } catch (e) {
427
435
  try { await client.query('ROLLBACK'); } catch { /* the connection may be gone */ }
@@ -576,9 +576,9 @@ module.exports = function buildModulesRouter() {
576
576
  }));
577
577
 
578
578
  // POST /api/bongos/store/modules/:key/price — the module's author sets its price
579
- // (task 1004894, criterion wa5-module-economy). Body: { cents?, credits? }, each a
580
- // whole number of 0 or more, or null for "not offered in that currency"; at least
581
- // one is given, and 0 in either is free. Appends a row to the price history; the
579
+ // (task 1004894, criterion wa5-module-economy). Body: { cents }, a whole number of
580
+ // 0 or more; 0 is free. A credit price is refused (task 1004989, ADR 0385
581
+ // decision 2: credits never price or buy a module). Appends a row to the price history; the
582
582
  // newest is what the catalog shows and what acquire charges. A paid price may be
583
583
  // set before buying exists (owner, 2026-10-26): the catalog marks it not yet
584
584
  // buyable. Price never moves the score (ADR 0343 D6). The audit middleware records
@@ -588,21 +588,24 @@ module.exports = function buildModulesRouter() {
588
588
  // author check inside setPrice: only the person who published the module prices it.
589
589
  router.post('/store/modules/:key/price', auth.requireBuilder, auth.requirePermission('module.submit'),
590
590
  asyncHandler('POST /store/modules/:key/price', async (req, res) => {
591
- const AMOUNT = { type: 'integer', min: 0, max: 100000000 };
592
- if (validateOrRespond(req, res, { cents: AMOUNT, credits: AMOUNT })) return;
591
+ const body = req.body || {};
592
+ // Refused before anything else, by name, so an older CLI that still sends
593
+ // credits is told why rather than seeing a generic validation error.
594
+ if (body.credits != null) {
595
+ return res.fail('credit_price_refused', { status: 400, message: 'Credits cannot price a module: they measure contribution and are never spent. Set a cash price in cents (0 makes it free).' });
596
+ }
597
+ if (validateOrRespond(req, res, { cents: { type: 'integer', min: 0, max: 100000000 } })) return;
593
598
  const { key } = req.params;
594
599
  if (!KEY_RE.test(key)) {
595
600
  return res.fail('bad_module_key', { status: 400, message: 'A module key is lowercase kebab-case.' });
596
601
  }
597
- const body = req.body || {};
598
602
  const cents = body.cents ?? null;
599
- const credits = body.credits ?? null;
600
- if (cents === null && credits === null) {
601
- return res.fail('bad_price', { status: 400, message: 'Give a price in cents, credits or both. 0 in either makes the module free.' });
603
+ if (cents === null) {
604
+ return res.fail('bad_price', { status: 400, message: 'Give a price in cents. 0 makes the module free.' });
602
605
  }
603
- const out = await setPrice({ key, cents, credits, builderId: req.builder.id });
606
+ const out = await setPrice({ key, cents, builderId: req.builder.id });
604
607
  if (!out.ok) return res.fail(out.code, { status: out.status, message: out.message });
605
- log.info({ key, builderId: req.builder.id, cents, credits }, `${key} price set`);
608
+ log.info({ key, builderId: req.builder.id, cents }, `${key} price set`);
606
609
  res.json({ ok: true, module_key: key, previous: out.previous, price: out.price });
607
610
  }));
608
611
 
@@ -772,9 +775,14 @@ module.exports = function buildModulesRouter() {
772
775
  let held = await checkEntitlement(holder);
773
776
  if (!held.entitled) {
774
777
  if (!r.free) {
778
+ // Never "pay with credits" (task 1004989): credits are never spent. A
779
+ // module still carrying only an old credit price has no cash price yet.
780
+ const coming = r.price && r.price.price_cents == null;
775
781
  return res.fail('payment_required', {
776
782
  status: 402,
777
- message: `"${key}" is priced, and buying a module is not available yet (it arrives with the credit economy, area 8).`,
783
+ message: coming
784
+ ? `"${key}" has no cash price yet: its author has been asked to set one. Credits cannot buy a module.`
785
+ : `"${key}" is priced, and buying a module is not available yet.`,
778
786
  });
779
787
  }
780
788
  const g = await grantEntitlement({ ...holder, source: 'free', grantedBy: req.builder.id });
@@ -917,8 +925,7 @@ module.exports = function buildModulesRouter() {
917
925
  // `?sort=<part>` orders the listed modules by the part a buyer cares about
918
926
  // (task 1003800, sortByPart); the parts it accepts come back as sort_parts.
919
927
  // Without it the listed modules are ranked quality first, price breaking ties
920
- // (task 1003808, rankListed); `?max_cents=` / `?max_credits=` leave out modules
921
- // that cost more.
928
+ // (task 1003808, rankListed); `?max_cents=` leaves out modules that cost more.
922
929
  //
923
930
  // rank: any-builder — a read of public store metadata, like /store/modules/latest:
924
931
  // no entitlement taken, nothing changed.
@@ -930,16 +937,19 @@ module.exports = function buildModulesRouter() {
930
937
  const names = SORT_PARTS.map((p) => `${p.title} (${p.part})`).join(', ');
931
938
  return res.fail('bad_request', { status: 400, message: `The store can be sorted by one part of the score: ${names}.` });
932
939
  }
933
- // An optional maximum price (task 1003808): ?max_cents= and/or ?max_credits=,
934
- // each a whole number of at least 0. Free modules always pass.
940
+ // An optional maximum price (task 1003808): ?max_cents=, a whole number of at
941
+ // least 0. Free modules always pass. There is no credit cap (task 1004989):
942
+ // credits never price a module, so ?max_credits= is refused by name.
943
+ if (req.query && req.query.max_credits != null) {
944
+ return res.fail('bad_request', { status: 400, message: 'Credits do not price modules, so there is no maximum in credits. Use max_cents.' });
945
+ }
935
946
  const maxPrice = {};
936
- for (const [param, field] of [['max_cents', 'cents'], ['max_credits', 'credits']]) {
937
- const v = req.query && req.query[param];
938
- if (v == null || v === '') continue;
947
+ const v = req.query && req.query.max_cents;
948
+ if (v != null && v !== '') {
939
949
  if (typeof v !== 'string' || !/^\d{1,9}$/.test(v)) {
940
- return res.fail('bad_request', { status: 400, message: `${param} is a maximum price: a whole number, 0 or more.` });
950
+ return res.fail('bad_request', { status: 400, message: 'max_cents is a maximum price: a whole number, 0 or more.' });
941
951
  }
942
- maxPrice[field] = Number(v);
952
+ maxPrice.cents = Number(v);
943
953
  }
944
954
  const listing = await listCatalog({ sort, maxPrice });
945
955
  res.set('Cache-Control', 'no-store');
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.24.8'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.9'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -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
  });