@bongos/core 1.23.4 → 1.23.6

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 (41) hide show
  1. package/.bongos-core.json +67 -42
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +9 -1
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/adr/0356-a-recruit-invite-expires-30-days-after-it-is-sent-read-time.md +2 -1
  8. package/docs/api/openapi.json +204 -7
  9. package/docs/api-reference.md +4 -2
  10. package/docs/copy-inventory.md +26 -24
  11. package/docs/copy-registry.json +43 -25
  12. package/docs/module-api-changelog.md +4 -0
  13. package/docs/modules-contract.md +2 -0
  14. package/docs/page-readings.json +8 -8
  15. package/migrations/core_281_module_version_withdraw_note.sql +25 -0
  16. package/modules/government/catalog.js +1 -1
  17. package/modules/hall-ui/public/modules.js +10 -1
  18. package/modules/onboarding/routes/access-requests.js +10 -9
  19. package/package-lock.json +2 -2
  20. package/package.json +1 -1
  21. package/release-notes.json +16 -0
  22. package/scripts/bongos/login.js +13 -14
  23. package/scripts/bongos/module-store-cli.js +182 -0
  24. package/scripts/bongos/module.js +60 -134
  25. package/scripts/gds/module-store-cli.js +6 -0
  26. package/src/bongos/auth-admission.js +3 -3
  27. package/src/bongos/middleware/rate-limit.js +1 -1
  28. package/src/bongos/module-listing.js +12 -2
  29. package/src/bongos/module-store.js +90 -1
  30. package/src/bongos/route-rank-check.js +6 -0
  31. package/src/bongos/routes/auth.js +7 -11
  32. package/src/bongos/routes/modules.js +76 -3
  33. package/src/module-api.js +1 -1
  34. package/tests/account_existence_rate_limit.mjs +7 -3
  35. package/tests/application_lifecycle.mjs +4 -3
  36. package/tests/bongos_login.mjs +15 -4
  37. package/tests/hall_catalog_world.mjs +1 -1
  38. package/tests/invite_expiry_db.mjs +1 -1
  39. package/tests/module_store_cli.mjs +1 -1
  40. package/tests/module_store_price.mjs +207 -0
  41. package/tests/module_store_withdraw.mjs +201 -0
@@ -0,0 +1,182 @@
1
+ 'use strict';
2
+ //
3
+ // scripts/bongos/module-store-cli.js — the module STORE from the terminal: the store
4
+ // client every store verb calls, and `bongos module store` / `bongos module price`
5
+ // (tasks 1003811, 1004894). Carved out of scripts/bongos/module.js, which requires
6
+ // and re-exports all of it, so `bongos module <verb>` and every importer are
7
+ // unchanged.
8
+ //
9
+ // WHY its own file. module.js crossed the 1,500-line ratchet (fitness.js) when the
10
+ // price command landed, and these pieces share one concern — talking to the store —
11
+ // while the rest of module.js works on the checkout's own modules/ tree.
12
+ //
13
+ // Like module.js it may be required by the server process, so cli-lib's
14
+ // session/fetch plumbing is required lazily inside storeRequest, never at load.
15
+
16
+ const { KEY_RE } = require('../../src/module-loader/manifest-schema');
17
+
18
+ // The store half of the CLI, over three calls (src/bongos/routes/modules.js):
19
+ // acquire (take the entitlement, learn the version), download the tarball, and
20
+ // once the files are placed, record the version acquired. Injected in tests.
21
+ function storeRequest() {
22
+ // Lazy require — cli-lib's session/fetch plumbing must never load in the server
23
+ // process, which requires this file too (the same reason cmdPublish gives).
24
+ const { readSessionToken, dispatchedFetch, noKeepAliveDispatcher, CLI_USER_AGENT } = require('./cli-lib');
25
+ const session = readSessionToken();
26
+ if (!session) throw new Error('no Bongos session — run `bongos setup` first');
27
+ const call = async (method, urlPath, { json, binary = false } = {}) => {
28
+ const res = await dispatchedFetch(`${session.apiBase}/api/bongos${urlPath}`, {
29
+ method,
30
+ headers: {
31
+ Authorization: `Bearer ${session.token}`, 'User-Agent': CLI_USER_AGENT,
32
+ ...(json ? { 'Content-Type': 'application/json' } : {}),
33
+ },
34
+ body: json ? JSON.stringify(json) : undefined,
35
+ dispatcher: noKeepAliveDispatcher(),
36
+ });
37
+ if (binary && res.ok) return { ok: true, status: res.status, buf: Buffer.from(await res.arrayBuffer()) };
38
+ const text = await res.text();
39
+ let data;
40
+ try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; }
41
+ return { ok: res.ok, status: res.status, data };
42
+ };
43
+ // Where the store lives, so install can print a full link to the how-to page.
44
+ call.apiBase = session.apiBase;
45
+ return call;
46
+ }
47
+
48
+ // The hall page that shows a version's HOWTO.md (task 1004366) — the Modules tab,
49
+ // opened on one version. null when the version shipped without one (published
50
+ // before ADR 0347's gate), so install never prints a link that would 404.
51
+ function howtoLink(key, version, files, apiBase) {
52
+ if (!files.some((f) => f.path === 'HOWTO.md')) return null;
53
+ const p = `/builders/modules?howto=${encodeURIComponent(key)}&version=${encodeURIComponent(version)}`;
54
+ return apiBase ? `${String(apiBase).replace(/\/+$/, '')}${p}` : p;
55
+ }
56
+
57
+ function storeError(res) {
58
+ const err = res.data && (res.data.error || res.data);
59
+ return (err && (err.message || err.code)) || `HTTP ${res.status}`;
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // bongos module store — what the store lists (task 1003811)
64
+ // ---------------------------------------------------------------------------
65
+
66
+ const STORE_USAGE = 'usage: bongos module store [--sort <part>] [--max-cents N] [--max-credits N] [--json]';
67
+
68
+ // The query GET /store/modules takes, from argv. Returns { query } or { error }.
69
+ // The server checks the values too; this only refuses what is not a value at all.
70
+ function storeQuery(args) {
71
+ const params = [];
72
+ for (const [flag, param] of [['--sort', 'sort'], ['--max-cents', 'max_cents'], ['--max-credits', 'max_credits']]) {
73
+ const i = args.indexOf(flag);
74
+ if (i === -1) continue;
75
+ const v = args[i + 1];
76
+ if (!v || v.startsWith('--')) return { error: `${flag} needs a value` };
77
+ params.push(`${param}=${encodeURIComponent(v)}`);
78
+ }
79
+ return { query: params.length ? `?${params.join('&')}` : '' };
80
+ }
81
+
82
+ function formatPrice(price) {
83
+ 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';
88
+ }
89
+
90
+ function formatPlace(category) {
91
+ if (!category) return '';
92
+ return category.label ? `${category.parent_title} › ${category.label}` : category.parent_title;
93
+ }
94
+
95
+ // Pure renderer over the GET /store/modules body. It keeps the server's order and
96
+ // adds nothing to it: listed in rank, then New, never ranked.
97
+ function formatStore(body) {
98
+ const out = [];
99
+ out.push('bongos module store — what the store lists, best first');
100
+ out.push(` floor ${body.floor} out of 100 · at most ${body.cap} per sub-category`);
101
+ out.push(` ${body.ranking}`);
102
+ if (body.sort) {
103
+ const part = (body.sort_parts || []).find((x) => x.part === body.sort);
104
+ out.push(` sorted by ${part ? part.title : body.sort}`);
105
+ }
106
+ 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 ')}`);
108
+ out.push('');
109
+ const listed = body.listed || [];
110
+ if (!listed.length) out.push(' Nothing is listed yet.');
111
+ listed.forEach((m, i) => {
112
+ out.push(` ${String(i + 1).padStart(2)}. ${m.key}@${m.version} ${m.overall}/100 ${formatPrice(m.price)}${m.buyable === false ? ' (not yet buyable)' : ''}${m.category ? ` ${formatPlace(m.category)}` : ''}`);
113
+ });
114
+ const fresh = body.new || [];
115
+ if (fresh.length) {
116
+ out.push('');
117
+ out.push(' New — not ranked until projects have installed and used them:');
118
+ for (const m of fresh) out.push(` ${m.key}@${m.version} ${formatPrice(m.price)}${m.buyable === false ? ' (not yet buyable)' : ''}${m.category ? ` ${formatPlace(m.category)}` : ''}`);
119
+ }
120
+ out.push('');
121
+ out.push(' Why a module is not here: GET /store/modules/<key>/listing');
122
+ return out.join('\n');
123
+ }
124
+
125
+ async function cmdStore(args, { log = console.log, errlog = console.error, request } = {}) {
126
+ const q = storeQuery(args);
127
+ if (q.error) { errlog(`bongos module store: ${q.error}`); errlog(STORE_USAGE); return 2; }
128
+ let call;
129
+ try { call = request || storeRequest(); }
130
+ catch (e) { errlog(`bongos module store: ${e.message}`); return 1; }
131
+ let res;
132
+ try { res = await call('GET', `/store/modules${q.query}`); }
133
+ catch (e) { errlog(`bongos module store: the store could not be reached — ${e.message}`); return 1; }
134
+ if (!res.ok) { errlog(`bongos module store: the store answered ${storeError(res)}`); return res.status === 400 ? 2 : 1; }
135
+ if (args.includes('--json')) { log(JSON.stringify(res.data, null, 2)); return 0; }
136
+ log(formatStore(res.data || {}));
137
+ return 0;
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // bongos module price — the author sets a module's price (task 1004894)
142
+ // ---------------------------------------------------------------------------
143
+
144
+ const PRICE_USAGE = 'usage: bongos module price <key> [--cents N] [--credits N] (0 in either makes it free)';
145
+
146
+ // argv -> { key, cents, credits } or { error }. Pure.
147
+ function priceArgs(args) {
148
+ const key = args[0];
149
+ 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;
160
+ }
161
+
162
+ async function cmdPrice(args, { log = console.log, errlog = console.error, request } = {}) {
163
+ const p = priceArgs(args);
164
+ if (p.error) { errlog(`bongos module price: ${p.error}`); errlog(PRICE_USAGE); return 2; }
165
+ let call;
166
+ try { call = request || storeRequest(); }
167
+ catch (e) { errlog(`bongos module price: ${e.message}`); return 1; }
168
+ let res;
169
+ try { res = await call('POST', `/store/modules/${encodeURIComponent(p.key)}/price`, { json: { cents: p.cents, credits: p.credits } }); }
170
+ catch (e) { errlog(`bongos module price: the store could not be reached — ${e.message}`); return 1; }
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 });
173
+ const shown = formatPrice(withFree(res.data.price));
174
+ const was = res.data.previous ? formatPrice(withFree(res.data.previous)) : 'free (no price set)';
175
+ log(`✓ "${p.key}" now costs ${shown} (was ${was}).`);
176
+ if (shown !== 'free') log(' Buying is not open yet, so the store lists it as not yet buyable until it is.');
177
+ return 0;
178
+ }
179
+
180
+ module.exports = {
181
+ storeRequest, storeError, howtoLink, formatStore, cmdStore, priceArgs, cmdPrice,
182
+ };
@@ -39,6 +39,12 @@
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]
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
45
+ // change and shows the newest. A paid module is listed as not yet buyable until
46
+ // buying arrives (task 1003788).
47
+ //
42
48
  // bongos module check <key>
43
49
  // ADR 0107 §4 — the submit-time pre-check a module must clear before it can
44
50
  // be upstreamed into the shared core catalog. REUSES (never re-types) the
@@ -82,6 +88,8 @@ const { validateManifest, assertValidManifest, KEY_RE, SUNSET_STATUSES } = requi
82
88
  const { parseVersion, cmp } = require('../../src/module-loader/semver');
83
89
  const loader = require('../../src/module-loader/loader');
84
90
  const catalog = require('../../src/module-loader/catalog');
91
+ // The store client and the `store` / `price` verbs (task 1004894 carve).
92
+ const { storeRequest, storeError, howtoLink, formatStore, cmdStore, priceArgs, cmdPrice } = require('./module-store-cli');
85
93
  const { resolveModules, MODULE_REGISTRY, NEUTRAL_PATH, INSTANCE_PATH } = require('../../src/modules');
86
94
  // The public-mirror publish boundary (ADR 0098) — REUSED, never re-typed, for
87
95
  // the ADR 0107 §4 upstreaming pre-check. Only matchesDeny is used (not the full
@@ -712,7 +720,7 @@ async function gatherUpdates(rows, { request } = {}) {
712
720
  // A 404 here is a server whose core predates this route, not a missing module.
713
721
  if (res.status === 404) return { checked: false, reason: "this server's store does not answer update checks yet (its core is older)" };
714
722
  if (!res.ok) return { checked: false, reason: `the store answered ${storeError(res)}` };
715
- return { checked: true, latest: (res.data && res.data.latest) || {} };
723
+ return { checked: true, latest: (res.data && res.data.latest) || {}, withdrawn: (res.data && res.data.withdrawn) || {} };
716
724
  }
717
725
 
718
726
  // Decide what is stale from rows + the store's answer (pure — tests drive it).
@@ -722,13 +730,22 @@ async function gatherUpdates(rows, { request } = {}) {
722
730
  // installed module the store has delisted: it keeps working, but no further
723
731
  // versions will come (ADR 0338 D2). Neither ever blocks — an update is news, and
724
732
  // what blocks `bongos upgrade` is still only an incompatible ENABLED module.
725
- function evaluateUpdates(rows = [], latest = {}, core) {
733
+ function evaluateUpdates(rows = [], latest = {}, core, withdrawnByKey = {}) {
726
734
  const { satisfies } = require('../../src/module-loader/semver');
727
735
  const updates = [];
728
736
  const delisted = [];
737
+ const withdrawn = [];
729
738
  for (const r of rows) {
730
739
  const store = latest[r.key];
731
740
  const have = r.version && parseVersion(String(r.version));
741
+ // WITHDRAWN (task 1004895) = the store took back the exact version installed
742
+ // here. It keeps working; the update below, when there is one, is the way off it.
743
+ const taken = have && (withdrawnByKey[r.key] || [])
744
+ .map((w) => (typeof w === 'string' ? { version: w, note: null } : w))
745
+ .find((w) => w && w.version === String(r.version));
746
+ if (taken) {
747
+ withdrawn.push({ key: r.key, installed: r.version, latest: store ? store.version : null, enabled: r.enabled, note: taken.note || null });
748
+ }
732
749
  if (!store || !have) continue;
733
750
  if (store.status === 'delisted') { delisted.push({ key: r.key, installed: r.version, enabled: r.enabled }); continue; }
734
751
  const next = parseVersion(String(store.version));
@@ -737,7 +754,7 @@ function evaluateUpdates(rows = [], latest = {}, core) {
737
754
  try { fits = !!(core && store.core_version && satisfies(core, store.core_version)); } catch { fits = false; }
738
755
  updates.push({ key: r.key, installed: r.version, latest: store.version, coreVersion: store.core_version, enabled: r.enabled, fits });
739
756
  }
740
- return { updates, delisted };
757
+ return { updates, delisted, withdrawn };
741
758
  }
742
759
 
743
760
  function reportUpdates(found, verdict, { log, errlog }) {
@@ -746,7 +763,7 @@ function reportUpdates(found, verdict, { log, errlog }) {
746
763
  log(` not checked — ${found.reason}. The compatibility check above still stands.`);
747
764
  return;
748
765
  }
749
- const { updates, delisted } = evaluateUpdates(verdict.rows, found.latest, verdict.core);
766
+ const { updates, delisted, withdrawn } = evaluateUpdates(verdict.rows, found.latest, verdict.core, found.withdrawn);
750
767
  for (const u of updates) {
751
768
  const how = u.fits
752
769
  ? `run \`bongos module update ${u.key}\``
@@ -756,7 +773,17 @@ function reportUpdates(found, verdict, { log, errlog }) {
756
773
  for (const d of delisted) {
757
774
  errlog(` ! ${d.key.padEnd(18)} ${d.installed} — delisted in the store: it keeps working, but no further versions will come`);
758
775
  }
759
- if (!updates.length && !delisted.length) log(' every installed store module is on its newest version.');
776
+ for (const w of withdrawn) {
777
+ // Update only moves forward, so an OLDER served version is no way off it: until a
778
+ // fixed version is published, say so rather than name a command that refuses.
779
+ const newer = w.latest && parseVersion(String(w.latest))
780
+ && cmp(parseVersion(String(w.latest)), parseVersion(String(w.installed))) > 0;
781
+ const next = newer
782
+ ? `move to ${w.latest} with \`bongos module update ${w.key}\``
783
+ : 'no newer version is out yet; `bongos upgrade` will offer the fix once one is published';
784
+ errlog(` ! ${w.key.padEnd(18)} ${w.installed} — withdrawn in the store${w.note ? ` (${w.note})` : ''}: it keeps working here, but ${next}`);
785
+ }
786
+ if (!updates.length && !delisted.length && !withdrawn.length) log(' every installed store module is on its newest version.');
760
787
  }
761
788
 
762
789
  async function cmdUpgrade(args = [], { log = console.log, errlog = console.error, gather = gatherUpgrade, checkUpdates = gatherUpdates } = {}) {
@@ -939,18 +966,31 @@ async function cmdSubmit(args, {
939
966
  // Distinct from `submit`, which proposes a module INTO core (ADR 0135).
940
967
  async function cmdPublish(args, {
941
968
  log = console.log, errlog = console.error,
942
- modulesDir = MODULES_DIR, pack, upload, writeFile = fs.writeFileSync,
969
+ modulesDir = MODULES_DIR, pack, upload, writeFile = fs.writeFileSync, priceRequest,
943
970
  } = {}) {
944
- const key = args.find((a, i) => !a.startsWith('-') && args[i - 1] !== '--out');
971
+ const PUBLISH_USAGE = 'usage: bongos module publish <key> [--dry-run] [--out <dir>] [--cents N] [--credits N]';
972
+ const VALUED = new Set(['--out', '--cents', '--credits']);
973
+ const key = args.find((a, i) => !a.startsWith('-') && !VALUED.has(args[i - 1]));
945
974
  if (!key) {
946
- errlog('usage: bongos module publish <key> [--dry-run] [--out <dir>]');
975
+ errlog(PUBLISH_USAGE);
947
976
  return 2;
948
977
  }
978
+ // An optional price set right after the upload (task 1004894), with the same rules
979
+ // as `bongos module price`, checked BEFORE anything is packed or sent.
980
+ const priceFlags = [];
981
+ for (const flag of ['--cents', '--credits']) {
982
+ const i = args.indexOf(flag);
983
+ if (i !== -1) priceFlags.push(flag, args[i + 1]);
984
+ }
985
+ if (priceFlags.length) {
986
+ const p = priceArgs([key, ...priceFlags]);
987
+ if (p.error) { errlog(`bongos module publish: ${p.error}`); errlog(PUBLISH_USAGE); return 2; }
988
+ }
949
989
  const dryRun = args.includes('--dry-run');
950
990
  const outIdx = args.indexOf('--out');
951
991
  const outDir = outIdx !== -1 ? args[outIdx + 1] : null;
952
992
  if (outIdx !== -1 && (!outDir || outDir.startsWith('--'))) {
953
- errlog('usage: bongos module publish <key> [--dry-run] [--out <dir>]');
993
+ errlog(PUBLISH_USAGE);
954
994
  return 2;
955
995
  }
956
996
 
@@ -1018,135 +1058,18 @@ async function cmdPublish(args, {
1018
1058
  if (res.data && res.data.channel === 'tester') {
1019
1059
  log(' It goes to testing projects first, and reaches every project once it has been tested (ADR 0344).');
1020
1060
  }
1021
- return 0;
1061
+ if (!priceFlags.length) return 0;
1062
+ // The version is published whatever happens next; a price that fails is reported
1063
+ // with the one command that sets it, and the exit code says something needs doing.
1064
+ const priced = await cmdPrice([key, ...priceFlags], { log, errlog, request: priceRequest });
1065
+ if (priced !== 0) errlog(` The version is published, but its price was not set. Run \`bongos module price ${key} ${priceFlags.join(' ')}\` to set it.`);
1066
+ return priced === 0 ? 0 : 1;
1022
1067
  }
1023
1068
 
1024
1069
  // ---------------------------------------------------------------------------
1025
1070
  // bongos module install <key> — acquire a module from the store (ADR 0338 D1)
1026
1071
  // ---------------------------------------------------------------------------
1027
1072
 
1028
- // The store half of the CLI, over three calls (src/bongos/routes/modules.js):
1029
- // acquire (take the entitlement, learn the version), download the tarball, and
1030
- // once the files are placed, record the version acquired. Injected in tests.
1031
- function storeRequest() {
1032
- // Lazy require — cli-lib's session/fetch plumbing must never load in the server
1033
- // process, which requires this file too (the same reason cmdPublish gives).
1034
- const { readSessionToken, dispatchedFetch, noKeepAliveDispatcher, CLI_USER_AGENT } = require('./cli-lib');
1035
- const session = readSessionToken();
1036
- if (!session) throw new Error('no Bongos session — run `bongos setup` first');
1037
- const call = async (method, urlPath, { json, binary = false } = {}) => {
1038
- const res = await dispatchedFetch(`${session.apiBase}/api/bongos${urlPath}`, {
1039
- method,
1040
- headers: {
1041
- Authorization: `Bearer ${session.token}`, 'User-Agent': CLI_USER_AGENT,
1042
- ...(json ? { 'Content-Type': 'application/json' } : {}),
1043
- },
1044
- body: json ? JSON.stringify(json) : undefined,
1045
- dispatcher: noKeepAliveDispatcher(),
1046
- });
1047
- if (binary && res.ok) return { ok: true, status: res.status, buf: Buffer.from(await res.arrayBuffer()) };
1048
- const text = await res.text();
1049
- let data;
1050
- try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; }
1051
- return { ok: res.ok, status: res.status, data };
1052
- };
1053
- // Where the store lives, so install can print a full link to the how-to page.
1054
- call.apiBase = session.apiBase;
1055
- return call;
1056
- }
1057
-
1058
- // The hall page that shows a version's HOWTO.md (task 1004366) — the Modules tab,
1059
- // opened on one version. null when the version shipped without one (published
1060
- // before ADR 0347's gate), so install never prints a link that would 404.
1061
- function howtoLink(key, version, files, apiBase) {
1062
- if (!files.some((f) => f.path === 'HOWTO.md')) return null;
1063
- const p = `/builders/modules?howto=${encodeURIComponent(key)}&version=${encodeURIComponent(version)}`;
1064
- return apiBase ? `${String(apiBase).replace(/\/+$/, '')}${p}` : p;
1065
- }
1066
-
1067
- function storeError(res) {
1068
- const err = res.data && (res.data.error || res.data);
1069
- return (err && (err.message || err.code)) || `HTTP ${res.status}`;
1070
- }
1071
-
1072
- // ---------------------------------------------------------------------------
1073
- // bongos module store — what the store lists (task 1003811)
1074
- // ---------------------------------------------------------------------------
1075
-
1076
- const STORE_USAGE = 'usage: bongos module store [--sort <part>] [--max-cents N] [--max-credits N] [--json]';
1077
-
1078
- // The query GET /store/modules takes, from argv. Returns { query } or { error }.
1079
- // The server checks the values too; this only refuses what is not a value at all.
1080
- function storeQuery(args) {
1081
- const params = [];
1082
- for (const [flag, param] of [['--sort', 'sort'], ['--max-cents', 'max_cents'], ['--max-credits', 'max_credits']]) {
1083
- const i = args.indexOf(flag);
1084
- if (i === -1) continue;
1085
- const v = args[i + 1];
1086
- if (!v || v.startsWith('--')) return { error: `${flag} needs a value` };
1087
- params.push(`${param}=${encodeURIComponent(v)}`);
1088
- }
1089
- return { query: params.length ? `?${params.join('&')}` : '' };
1090
- }
1091
-
1092
- function formatPrice(price) {
1093
- if (!price || price.free) return 'free';
1094
- const parts = [];
1095
- if (price.cents != null) parts.push(`$${(price.cents / 100).toFixed(2)}`);
1096
- if (price.credits != null) parts.push(`${price.credits} credits`);
1097
- return parts.join(' or ') || 'free';
1098
- }
1099
-
1100
- function formatPlace(category) {
1101
- if (!category) return '';
1102
- return category.label ? `${category.parent_title} › ${category.label}` : category.parent_title;
1103
- }
1104
-
1105
- // Pure renderer over the GET /store/modules body. It keeps the server's order and
1106
- // adds nothing to it: listed in rank, then New, never ranked.
1107
- function formatStore(body) {
1108
- const out = [];
1109
- out.push('bongos module store — what the store lists, best first');
1110
- out.push(` floor ${body.floor} out of 100 · at most ${body.cap} per sub-category`);
1111
- out.push(` ${body.ranking}`);
1112
- if (body.sort) {
1113
- const part = (body.sort_parts || []).find((x) => x.part === body.sort);
1114
- out.push(` sorted by ${part ? part.title : body.sort}`);
1115
- }
1116
- const max = body.max_price;
1117
- 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 ')}`);
1118
- out.push('');
1119
- const listed = body.listed || [];
1120
- if (!listed.length) out.push(' Nothing is listed yet.');
1121
- listed.forEach((m, i) => {
1122
- out.push(` ${String(i + 1).padStart(2)}. ${m.key}@${m.version} ${m.overall}/100 ${formatPrice(m.price)}${m.category ? ` ${formatPlace(m.category)}` : ''}`);
1123
- });
1124
- const fresh = body.new || [];
1125
- if (fresh.length) {
1126
- out.push('');
1127
- out.push(' New — not ranked until projects have installed and used them:');
1128
- for (const m of fresh) out.push(` ${m.key}@${m.version} ${formatPrice(m.price)}${m.category ? ` ${formatPlace(m.category)}` : ''}`);
1129
- }
1130
- out.push('');
1131
- out.push(' Why a module is not here: GET /store/modules/<key>/listing');
1132
- return out.join('\n');
1133
- }
1134
-
1135
- async function cmdStore(args, { log = console.log, errlog = console.error, request } = {}) {
1136
- const q = storeQuery(args);
1137
- if (q.error) { errlog(`bongos module store: ${q.error}`); errlog(STORE_USAGE); return 2; }
1138
- let call;
1139
- try { call = request || storeRequest(); }
1140
- catch (e) { errlog(`bongos module store: ${e.message}`); return 1; }
1141
- let res;
1142
- try { res = await call('GET', `/store/modules${q.query}`); }
1143
- catch (e) { errlog(`bongos module store: the store could not be reached — ${e.message}`); return 1; }
1144
- if (!res.ok) { errlog(`bongos module store: the store answered ${storeError(res)}`); return res.status === 400 ? 2 : 1; }
1145
- if (args.includes('--json')) { log(JSON.stringify(res.data, null, 2)); return 0; }
1146
- log(formatStore(res.data || {}));
1147
- return 0;
1148
- }
1149
-
1150
1073
  // Where install places a module: the INSTANCE's own modules/ root (ADR 0108 §1),
1151
1074
  // which the loader discovers. In a single checkout that is this repo's modules/.
1152
1075
  function instanceModulesDir() {
@@ -1408,13 +1331,15 @@ function printHelp(out = console.log) {
1408
1331
  out(' Browse the module catalog — what you can enable, with author + origin credit');
1409
1332
  out(' bongos module store [--sort <part>] [--max-cents N] [--max-credits N] [--json]');
1410
1333
  out(' What the store lists, best first: above the quality floor, ranked, ten per sub-category');
1334
+ out(' bongos module price <key> [--cents N] [--credits N]');
1335
+ out(' Set your module\'s price in the store (0 in either makes it free)');
1411
1336
  out(' bongos upgrade [--no-store] Pre-check enabled modules against the core version (fail-closed),');
1412
1337
  out(' then list newer store versions of installed modules (never blocks)');
1413
1338
  out(' bongos module check <key> [--sign-off "Name <email>"]');
1414
1339
  out(' AGPL/publish pre-check before upstreaming + record a DCO-style sign-off');
1415
1340
  out(' bongos module submit <key> [--sign-off "Name <email>"]');
1416
1341
  out(' Pre-check + sign-off, then file into the rank-gated upstreaming review queue');
1417
- out(' bongos module publish <key> [--dry-run] [--out <dir>]');
1342
+ out(' bongos module publish <key> [--dry-run] [--out <dir>] [--cents N] [--credits N]');
1418
1343
  out(' Pack a module version (tarball + hash manifest) and upload it to the module store');
1419
1344
  out(' bongos module install <key> [--version X.Y.Z]');
1420
1345
  out(' Acquire a module from the store, verify it and place it under modules/ (not enabled)');
@@ -1428,6 +1353,7 @@ async function main(argv) {
1428
1353
  if (sub === 'new') return cmdNew(args.slice(1));
1429
1354
  if (sub === 'list' || sub === 'ls') return cmdList(args.slice(1));
1430
1355
  if (sub === 'store') return cmdStore(args.slice(1));
1356
+ if (sub === 'price') return cmdPrice(args.slice(1));
1431
1357
  if (sub === 'upgrade') return cmdUpgrade(args.slice(1));
1432
1358
  if (sub === 'check') return cmdCheck(args.slice(1));
1433
1359
  if (sub === 'submit') return cmdSubmit(args.slice(1));
@@ -1444,7 +1370,7 @@ async function main(argv) {
1444
1370
  }
1445
1371
 
1446
1372
  module.exports = {
1447
- main, cmdNew, cmdList, cmdStore, formatStore, cmdUpgrade, cmdCheck, cmdSubmit, cmdPublish, cmdInstall, cmdUpdate, printHelp,
1373
+ main, cmdNew, cmdList, cmdStore, formatStore, cmdPrice, cmdUpgrade, cmdCheck, cmdSubmit, cmdPublish, cmdInstall, cmdUpdate, printHelp,
1448
1374
  storeRequest,
1449
1375
  gatherCatalog, formatCatalog, ownProjectName,
1450
1376
  scaffoldModule, gatherUpgrade, evaluateUpgrade, gatherUpdates, evaluateUpdates, buildManifest,
@@ -0,0 +1,6 @@
1
+ // scripts/gds/module-store-cli.js — @bongos-forwarder (task 1004848). Generated by
2
+ // scripts/bongos/codemod-rename-scripts-gds.js; do not edit.
3
+ // The real script is scripts/bongos/module-store-cli.js. This forwarder keeps callers that still name
4
+ // the old path working, and warns once so they get repointed. Task 1003706 removes it.
5
+ 'use strict';
6
+ require('../bongos/forwarder.js').forward(module, 'module-store-cli.js');
@@ -373,9 +373,9 @@ function firstTimeAdmissionBlocked({ existing, approved, login }) {
373
373
  // existing request-access page rather than a spinner that never resolves.
374
374
  //
375
375
  // NB the onboarding module's public GET /access-requests/status (used by the
376
- // CLI) reports member|invited|pending|none but knows nothing about the
377
- // firstAdmin bypass or open enrollment — so it reports the OWNER as 'none'
378
- // forever. That is exactly why this auth-domain probe exists.
376
+ // CLI) reports member|invited|not_admitted but knows nothing about the
377
+ // firstAdmin bypass or open enrollment — so it reports the OWNER as
378
+ // 'not_admitted' forever. That is exactly why this auth-domain probe exists.
379
379
  async function webAdmissionStatus(login) {
380
380
  const name = String(login || '').trim();
381
381
  if (!name) return { admitted: false, expectedSoon: false };
@@ -38,7 +38,7 @@
38
38
  // by BOTH routes that answer "does this GitHub login
39
39
  // have an account here?": the onboarding module's
40
40
  // GET /access-requests/status (the richer
41
- // member|invited|pending|dismissed|none) and core's
41
+ // member|invited|not_admitted) and core's
42
42
  // GET /auth/web/admission-status (its boolean twin).
43
43
  // Task 1003339 / ADR 0209. They are ONE oracle read
44
44
  // two ways, so they share one budget — give each its
@@ -152,6 +152,15 @@ function priceOf(row) {
152
152
  return { cents, credits, free: !row || cents === 0 || credits === 0 };
153
153
  }
154
154
 
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.
159
+ const BUYING_OPEN = false;
160
+ function buyableNow(price) {
161
+ return !!(price && (price.free || BUYING_OPEN));
162
+ }
163
+
155
164
  // Within a buyer's maximum ({ cents, credits }, either may be null): a free module
156
165
  // always is; a priced one is when it is offered at or under a cap the buyer set. Pure.
157
166
  function withinMaxPrice(price, max) {
@@ -228,7 +237,7 @@ function newestPerModule(rows) {
228
237
  }
229
238
 
230
239
  // The catalog: { floor, cap, ranking, sort, sort_parts, max_price, listed: [...], new: [...] },
231
- // each entry { key, title, version, published_at, price, category, overall, is_new,
240
+ // each entry { key, title, version, published_at, price, buyable, category, overall, is_new,
232
241
  // parts, strengths, weaknesses, unmeasured } (the last four from readParts). Excluded
233
242
  // versions are left out entirely: a module below the bar is not listed at all, nor
234
243
  // is one past the cap of its sub-category, nor one over the buyer's `maxPrice`.
@@ -291,9 +300,10 @@ async function buildCatalog({ db, floor = QUALITY_FLOOR, sort = null, maxPrice =
291
300
  const score = scores.get(String(r.version_id)) || null;
292
301
  const { placement } = placeVersion(score, floor);
293
302
  if (placement === 'excluded') continue;
303
+ const price = priceOf(prices.get(r.module_key));
294
304
  out[placement].push({
295
305
  key: r.module_key, title: r.title, version: r.version, published_at: r.published_at,
296
- price: priceOf(prices.get(r.module_key)), category: categoryOf(cats.get(r.module_key)),
306
+ price, category: categoryOf(cats.get(r.module_key)), buyable: buyableNow(price),
297
307
  overall: placement === 'new' ? null : score.overall, is_new: placement === 'new',
298
308
  ...readParts(signals.get(String(r.version_id)) || [], { floor }),
299
309
  });
@@ -383,6 +383,95 @@ async function latestVersions(keys, { db, channelsFor = null } = {}) {
383
383
  return out;
384
384
  }
385
385
 
386
+ // Set a module's price (task 1004894, criterion wa5-module-economy). Only its
387
+ // author may, and a delisted module takes no new price (it can no longer be
388
+ // acquired). The price is a HISTORY: each change appends a row and the newest is
389
+ // 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
+ // buying exists (owner, 2026-10-26): the module is listed with it and marked not yet
394
+ // buyable, and acquire keeps refusing it without an entitlement. Price is never an
395
+ // input to the score (ADR 0343 D6). The history IS the audit of price changes:
396
+ // every row names who set it and when, and the row before it is what it changed
397
+ // from (the audit middleware also logs the request). One transaction, the module row
398
+ // 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 } = {}) {
402
+ const pool = db || require('./pool').pool;
403
+ const refuse = (status, code, message) => ({ ok: false, status, code, message });
404
+ const client = await pool.connect();
405
+ try {
406
+ await client.query('BEGIN');
407
+ const { rows: [mod] } = await client.query(
408
+ 'SELECT module_key, author_id, status FROM store_modules WHERE module_key = $1 FOR UPDATE', [key]);
409
+ let out;
410
+ if (!mod) out = refuse(404, 'module_not_found', `The store has no module "${key}".`);
411
+ else if (String(mod.author_id) !== String(builderId)) out = refuse(403, 'not_module_author', `Only the author of "${key}" may set its price.`);
412
+ else if (mod.status === 'delisted') out = refuse(409, 'module_delisted', `"${key}" is delisted: it takes no new price.`);
413
+ if (out) { await client.query('ROLLBACK'); return out; }
414
+ const { rows: [prev] } = await client.query(
415
+ `SELECT price_cents, price_credits FROM store_module_prices
416
+ WHERE module_key = $1 ORDER BY set_at DESC, id DESC LIMIT 1`, [key]);
417
+ 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]);
420
+ await client.query('COMMIT');
421
+ return {
422
+ ok: true,
423
+ previous: prev ? { cents: prev.price_cents, credits: prev.price_credits } : null,
424
+ price: { cents, credits },
425
+ };
426
+ } catch (e) {
427
+ try { await client.query('ROLLBACK'); } catch { /* the connection may be gone */ }
428
+ throw e;
429
+ } finally {
430
+ client.release();
431
+ }
432
+ }
433
+
434
+ // Withdraw one version (task 1004895, criterion wa5-staged-rollout): the store
435
+ // stops handing it out. Staged rollout catches a bad version on the tester channel;
436
+ // this is the way back once one has reached general. It is one channel row,
437
+ // 'withdrawn' (core_276), which servedChannels never serves, so acquire, install,
438
+ // update and the update check all stop offering it with no other change. Like a
439
+ // delist it reaches nothing a project already holds: an installed copy keeps
440
+ // working (ADR 0338 D2), and `bongos upgrade` and the hall tell its holder, with
441
+ // `note` — the reason the person gave, kept on the row (core_281). The insert only
442
+ // lands while the version is not already withdrawn, so two people withdrawing at
443
+ // once write one row. Returns { ok: true, channel } or { ok: false, status, code, message }.
444
+ const WITHDRAWN_CHANNEL = 'withdrawn';
445
+
446
+ async function withdrawVersion(versionId, builderId, note, { db } = {}) {
447
+ const pool = db || require('./pool').pool;
448
+ const { rowCount } = await pool.query(
449
+ `INSERT INTO store_module_version_channels (version_id, channel, reason, set_by, note)
450
+ SELECT $1, '${WITHDRAWN_CHANNEL}', 'withdrawn_by_person', $2, $3
451
+ WHERE (SELECT c.channel FROM store_module_version_channels c
452
+ WHERE c.version_id = $1 ORDER BY c.set_at DESC, c.id DESC LIMIT 1)
453
+ IS DISTINCT FROM '${WITHDRAWN_CHANNEL}'`, [versionId, builderId, note]);
454
+ if (rowCount === 1) return { ok: true, channel: WITHDRAWN_CHANNEL };
455
+ return { ok: false, status: 409, code: 'already_withdrawn', message: 'This version is already withdrawn: the store does not hand it out.' };
456
+ }
457
+
458
+ // Which versions of these module keys are withdrawn, as { key: [{ version, note }] }
459
+ // — so the update check and the hall can tell a project that the version it runs
460
+ // was taken back, and why. `note` is the reason from the withdrawing row.
461
+ async function withdrawnVersions(keys, { db } = {}) {
462
+ if (!keys.length) return {};
463
+ const pool = db || require('./pool').pool;
464
+ const { rows } = await pool.query(
465
+ `SELECT v.module_key, v.version,
466
+ (SELECT c.note FROM store_module_version_channels c
467
+ WHERE c.version_id = v.id ORDER BY c.set_at DESC, c.id DESC LIMIT 1) AS note
468
+ FROM store_module_versions v
469
+ WHERE v.module_key = ANY($1) AND ${currentChannelSql('v.id')} = '${WITHDRAWN_CHANNEL}'`, [keys]);
470
+ const out = {};
471
+ for (const r of rows) (out[r.module_key] = out[r.module_key] || []).push({ version: r.version, note: r.note || null });
472
+ return out;
473
+ }
474
+
386
475
  // Delist a module (task 1003797, ADR 0338 D2): it leaves the catalog, takes no new
387
476
  // acquisitions or versions, and serves no further downloads — every read above
388
477
  // already refuses on status 'delisted', so this one UPDATE is the whole act. It
@@ -427,5 +516,5 @@ function versionArtifactFile(row, { dir = storeDir() } = {}) {
427
516
  module.exports = {
428
517
  stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion,
429
518
  isFreePrice, servedChannels, resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
430
- delistModule, delistedKeySet, storeDir, storeDirFor, migrateLegacyStore,
519
+ delistModule, delistedKeySet, setPrice, withdrawVersion, withdrawnVersions, storeDir, storeDirFor, migrateLegacyStore,
431
520
  };