@bongos/core 1.23.5 → 1.23.7

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 (36) hide show
  1. package/.bongos-core.json +66 -36
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +4 -0
  6. package/clients/bongos-client/index.mjs +2 -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 +96 -5
  9. package/docs/api-reference.md +3 -2
  10. package/docs/module-api-changelog.md +4 -0
  11. package/docs/modules-contract.md +3 -0
  12. package/modules/onboarding/routes/access-requests.js +10 -9
  13. package/package-lock.json +2 -2
  14. package/package.json +1 -1
  15. package/release-notes.json +16 -0
  16. package/scripts/bongos/login.js +13 -14
  17. package/scripts/bongos/module-remove.js +122 -0
  18. package/scripts/bongos/module-store-cli.js +182 -0
  19. package/scripts/bongos/module.js +42 -127
  20. package/scripts/gds/module-remove.js +6 -0
  21. package/scripts/gds/module-store-cli.js +6 -0
  22. package/src/bongos/auth-admission.js +3 -3
  23. package/src/bongos/middleware/rate-limit.js +1 -1
  24. package/src/bongos/module-listing.js +12 -2
  25. package/src/bongos/module-store.js +49 -1
  26. package/src/bongos/route-rank-check.js +3 -0
  27. package/src/bongos/routes/auth.js +7 -11
  28. package/src/bongos/routes/modules.js +32 -1
  29. package/src/module-api.js +1 -1
  30. package/tests/account_existence_rate_limit.mjs +7 -3
  31. package/tests/application_lifecycle.mjs +4 -3
  32. package/tests/bongos_login.mjs +15 -4
  33. package/tests/invite_expiry_db.mjs +1 -1
  34. package/tests/module_remove.mjs +121 -0
  35. package/tests/module_store_cli.mjs +1 -1
  36. package/tests/module_store_price.mjs +207 -0
@@ -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
@@ -958,18 +966,31 @@ async function cmdSubmit(args, {
958
966
  // Distinct from `submit`, which proposes a module INTO core (ADR 0135).
959
967
  async function cmdPublish(args, {
960
968
  log = console.log, errlog = console.error,
961
- modulesDir = MODULES_DIR, pack, upload, writeFile = fs.writeFileSync,
969
+ modulesDir = MODULES_DIR, pack, upload, writeFile = fs.writeFileSync, priceRequest,
962
970
  } = {}) {
963
- 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]));
964
974
  if (!key) {
965
- errlog('usage: bongos module publish <key> [--dry-run] [--out <dir>]');
975
+ errlog(PUBLISH_USAGE);
966
976
  return 2;
967
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
+ }
968
989
  const dryRun = args.includes('--dry-run');
969
990
  const outIdx = args.indexOf('--out');
970
991
  const outDir = outIdx !== -1 ? args[outIdx + 1] : null;
971
992
  if (outIdx !== -1 && (!outDir || outDir.startsWith('--'))) {
972
- errlog('usage: bongos module publish <key> [--dry-run] [--out <dir>]');
993
+ errlog(PUBLISH_USAGE);
973
994
  return 2;
974
995
  }
975
996
 
@@ -1037,133 +1058,22 @@ async function cmdPublish(args, {
1037
1058
  if (res.data && res.data.channel === 'tester') {
1038
1059
  log(' It goes to testing projects first, and reaches every project once it has been tested (ADR 0344).');
1039
1060
  }
1040
- 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;
1041
1067
  }
1042
1068
 
1043
1069
  // ---------------------------------------------------------------------------
1044
1070
  // bongos module install <key> — acquire a module from the store (ADR 0338 D1)
1045
1071
  // ---------------------------------------------------------------------------
1046
1072
 
1047
- // The store half of the CLI, over three calls (src/bongos/routes/modules.js):
1048
- // acquire (take the entitlement, learn the version), download the tarball, and
1049
- // once the files are placed, record the version acquired. Injected in tests.
1050
- function storeRequest() {
1051
- // Lazy require — cli-lib's session/fetch plumbing must never load in the server
1052
- // process, which requires this file too (the same reason cmdPublish gives).
1053
- const { readSessionToken, dispatchedFetch, noKeepAliveDispatcher, CLI_USER_AGENT } = require('./cli-lib');
1054
- const session = readSessionToken();
1055
- if (!session) throw new Error('no Bongos session — run `bongos setup` first');
1056
- const call = async (method, urlPath, { json, binary = false } = {}) => {
1057
- const res = await dispatchedFetch(`${session.apiBase}/api/bongos${urlPath}`, {
1058
- method,
1059
- headers: {
1060
- Authorization: `Bearer ${session.token}`, 'User-Agent': CLI_USER_AGENT,
1061
- ...(json ? { 'Content-Type': 'application/json' } : {}),
1062
- },
1063
- body: json ? JSON.stringify(json) : undefined,
1064
- dispatcher: noKeepAliveDispatcher(),
1065
- });
1066
- if (binary && res.ok) return { ok: true, status: res.status, buf: Buffer.from(await res.arrayBuffer()) };
1067
- const text = await res.text();
1068
- let data;
1069
- try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; }
1070
- return { ok: res.ok, status: res.status, data };
1071
- };
1072
- // Where the store lives, so install can print a full link to the how-to page.
1073
- call.apiBase = session.apiBase;
1074
- return call;
1075
- }
1076
-
1077
- // The hall page that shows a version's HOWTO.md (task 1004366) — the Modules tab,
1078
- // opened on one version. null when the version shipped without one (published
1079
- // before ADR 0347's gate), so install never prints a link that would 404.
1080
- function howtoLink(key, version, files, apiBase) {
1081
- if (!files.some((f) => f.path === 'HOWTO.md')) return null;
1082
- const p = `/builders/modules?howto=${encodeURIComponent(key)}&version=${encodeURIComponent(version)}`;
1083
- return apiBase ? `${String(apiBase).replace(/\/+$/, '')}${p}` : p;
1084
- }
1085
-
1086
- function storeError(res) {
1087
- const err = res.data && (res.data.error || res.data);
1088
- return (err && (err.message || err.code)) || `HTTP ${res.status}`;
1089
- }
1090
-
1091
- // ---------------------------------------------------------------------------
1092
- // bongos module store — what the store lists (task 1003811)
1093
- // ---------------------------------------------------------------------------
1094
-
1095
- const STORE_USAGE = 'usage: bongos module store [--sort <part>] [--max-cents N] [--max-credits N] [--json]';
1096
-
1097
- // The query GET /store/modules takes, from argv. Returns { query } or { error }.
1098
- // The server checks the values too; this only refuses what is not a value at all.
1099
- function storeQuery(args) {
1100
- const params = [];
1101
- for (const [flag, param] of [['--sort', 'sort'], ['--max-cents', 'max_cents'], ['--max-credits', 'max_credits']]) {
1102
- const i = args.indexOf(flag);
1103
- if (i === -1) continue;
1104
- const v = args[i + 1];
1105
- if (!v || v.startsWith('--')) return { error: `${flag} needs a value` };
1106
- params.push(`${param}=${encodeURIComponent(v)}`);
1107
- }
1108
- return { query: params.length ? `?${params.join('&')}` : '' };
1109
- }
1110
-
1111
- function formatPrice(price) {
1112
- if (!price || price.free) return 'free';
1113
- const parts = [];
1114
- if (price.cents != null) parts.push(`$${(price.cents / 100).toFixed(2)}`);
1115
- if (price.credits != null) parts.push(`${price.credits} credits`);
1116
- return parts.join(' or ') || 'free';
1117
- }
1118
-
1119
- function formatPlace(category) {
1120
- if (!category) return '';
1121
- return category.label ? `${category.parent_title} › ${category.label}` : category.parent_title;
1122
- }
1123
-
1124
- // Pure renderer over the GET /store/modules body. It keeps the server's order and
1125
- // adds nothing to it: listed in rank, then New, never ranked.
1126
- function formatStore(body) {
1127
- const out = [];
1128
- out.push('bongos module store — what the store lists, best first');
1129
- out.push(` floor ${body.floor} out of 100 · at most ${body.cap} per sub-category`);
1130
- out.push(` ${body.ranking}`);
1131
- if (body.sort) {
1132
- const part = (body.sort_parts || []).find((x) => x.part === body.sort);
1133
- out.push(` sorted by ${part ? part.title : body.sort}`);
1134
- }
1135
- const max = body.max_price;
1136
- 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 ')}`);
1137
- out.push('');
1138
- const listed = body.listed || [];
1139
- if (!listed.length) out.push(' Nothing is listed yet.');
1140
- listed.forEach((m, i) => {
1141
- out.push(` ${String(i + 1).padStart(2)}. ${m.key}@${m.version} ${m.overall}/100 ${formatPrice(m.price)}${m.category ? ` ${formatPlace(m.category)}` : ''}`);
1142
- });
1143
- const fresh = body.new || [];
1144
- if (fresh.length) {
1145
- out.push('');
1146
- out.push(' New — not ranked until projects have installed and used them:');
1147
- for (const m of fresh) out.push(` ${m.key}@${m.version} ${formatPrice(m.price)}${m.category ? ` ${formatPlace(m.category)}` : ''}`);
1148
- }
1149
- out.push('');
1150
- out.push(' Why a module is not here: GET /store/modules/<key>/listing');
1151
- return out.join('\n');
1152
- }
1153
-
1154
- async function cmdStore(args, { log = console.log, errlog = console.error, request } = {}) {
1155
- const q = storeQuery(args);
1156
- if (q.error) { errlog(`bongos module store: ${q.error}`); errlog(STORE_USAGE); return 2; }
1157
- let call;
1158
- try { call = request || storeRequest(); }
1159
- catch (e) { errlog(`bongos module store: ${e.message}`); return 1; }
1160
- let res;
1161
- try { res = await call('GET', `/store/modules${q.query}`); }
1162
- catch (e) { errlog(`bongos module store: the store could not be reached — ${e.message}`); return 1; }
1163
- if (!res.ok) { errlog(`bongos module store: the store answered ${storeError(res)}`); return res.status === 400 ? 2 : 1; }
1164
- if (args.includes('--json')) { log(JSON.stringify(res.data, null, 2)); return 0; }
1165
- log(formatStore(res.data || {}));
1166
- return 0;
1073
+ // `bongos module remove` lives in its own file (task 1004896); this hands it the
1074
+ // catalog and the store client it needs.
1075
+ function cmdRemove(args, deps = {}) {
1076
+ return require('./module-remove').cmdRemove(args, { gather: gatherCatalog, storeRequest, storeError, ...deps });
1167
1077
  }
1168
1078
 
1169
1079
  // Where install places a module: the INSTANCE's own modules/ root (ADR 0108 §1),
@@ -1427,13 +1337,16 @@ function printHelp(out = console.log) {
1427
1337
  out(' Browse the module catalog — what you can enable, with author + origin credit');
1428
1338
  out(' bongos module store [--sort <part>] [--max-cents N] [--max-credits N] [--json]');
1429
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]');
1341
+ out(' Set your module\'s price in the store (0 in either makes it free)');
1342
+ out(' bongos module remove <key> Take a store module off this project (switch it off first; its folder is kept aside)');
1430
1343
  out(' bongos upgrade [--no-store] Pre-check enabled modules against the core version (fail-closed),');
1431
1344
  out(' then list newer store versions of installed modules (never blocks)');
1432
1345
  out(' bongos module check <key> [--sign-off "Name <email>"]');
1433
1346
  out(' AGPL/publish pre-check before upstreaming + record a DCO-style sign-off');
1434
1347
  out(' bongos module submit <key> [--sign-off "Name <email>"]');
1435
1348
  out(' Pre-check + sign-off, then file into the rank-gated upstreaming review queue');
1436
- out(' bongos module publish <key> [--dry-run] [--out <dir>]');
1349
+ out(' bongos module publish <key> [--dry-run] [--out <dir>] [--cents N] [--credits N]');
1437
1350
  out(' Pack a module version (tarball + hash manifest) and upload it to the module store');
1438
1351
  out(' bongos module install <key> [--version X.Y.Z]');
1439
1352
  out(' Acquire a module from the store, verify it and place it under modules/ (not enabled)');
@@ -1447,6 +1360,8 @@ async function main(argv) {
1447
1360
  if (sub === 'new') return cmdNew(args.slice(1));
1448
1361
  if (sub === 'list' || sub === 'ls') return cmdList(args.slice(1));
1449
1362
  if (sub === 'store') return cmdStore(args.slice(1));
1363
+ if (sub === 'price') return cmdPrice(args.slice(1));
1364
+ if (sub === 'remove') return cmdRemove(args.slice(1));
1450
1365
  if (sub === 'upgrade') return cmdUpgrade(args.slice(1));
1451
1366
  if (sub === 'check') return cmdCheck(args.slice(1));
1452
1367
  if (sub === 'submit') return cmdSubmit(args.slice(1));
@@ -1463,7 +1378,7 @@ async function main(argv) {
1463
1378
  }
1464
1379
 
1465
1380
  module.exports = {
1466
- main, cmdNew, cmdList, cmdStore, formatStore, cmdUpgrade, cmdCheck, cmdSubmit, cmdPublish, cmdInstall, cmdUpdate, printHelp,
1381
+ main, cmdNew, cmdList, cmdStore, formatStore, cmdPrice, cmdRemove, cmdUpgrade, cmdCheck, cmdSubmit, cmdPublish, cmdInstall, cmdUpdate, printHelp,
1467
1382
  storeRequest,
1468
1383
  gatherCatalog, formatCatalog, ownProjectName,
1469
1384
  scaffoldModule, gatherUpgrade, evaluateUpgrade, gatherUpdates, evaluateUpdates, buildManifest,
@@ -0,0 +1,6 @@
1
+ // scripts/gds/module-remove.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-remove.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-remove.js');
@@ -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,54 @@ 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
+
386
434
  // Withdraw one version (task 1004895, criterion wa5-staged-rollout): the store
387
435
  // stops handing it out. Staged rollout catches a bad version on the tester channel;
388
436
  // this is the way back once one has reached general. It is one channel row,
@@ -468,5 +516,5 @@ function versionArtifactFile(row, { dir = storeDir() } = {}) {
468
516
  module.exports = {
469
517
  stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion,
470
518
  isFreePrice, servedChannels, resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
471
- delistModule, delistedKeySet, withdrawVersion, withdrawnVersions, storeDir, storeDirFor, migrateLegacyStore,
519
+ delistModule, delistedKeySet, setPrice, withdrawVersion, withdrawnVersions, storeDir, storeDirFor, migrateLegacyStore,
472
520
  };
@@ -250,6 +250,9 @@ const EXPECTED_RANKS = {
250
250
  // Delisting a store module (task 1003797, ADR 0338 D2) stops every new install of
251
251
  // it for every instance, so it is pinned to its own atom.
252
252
  'POST /store/modules/:key/delist': 'perm:module.delist',
253
+ // Setting a store module's price (task 1004894) rides the publish atom; only the
254
+ // module's author passes the check inside the handler.
255
+ 'POST /store/modules/:key/price': 'perm:module.submit',
253
256
  // Releasing a version the promotion gate held on tester (task 1003804) hands it to
254
257
  // every project; the owner put it on the delist atom (2026-10-23), so it is pinned.
255
258
  'POST /store/modules/:key/versions/:version/release': 'perm:module.delist',
@@ -828,19 +828,15 @@ module.exports = function buildAuthRouter() {
828
828
  // bounded the honest client, not an attacker. accountExistenceReadRateLimit
829
829
  // below is the ceiling — 120 reads/60s per IP, and the SAME instance is
830
830
  // mounted on the onboarding module's GET /access-requests/status, which is
831
- // this route's strictly richer twin (member|invited|pending|dismissed|none
832
- // plus its own `admitted`). One budget across both, because they are one
833
- // oracle: hardening either alone would accomplish nothing, and giving each its
834
- // own budget would just let an enumerator alternate for double the rate.
835
- //
836
- // WHAT IS STILL OPEN and is deliberately NOT decided here: whether the answer
837
- // itself should collapse, so a caller who is not the subject cannot tell
838
- // pending/dismissed/none apart from a bare "not admitted". That changes what a
839
- // real invited user sees during onboarding, so it is the owner's call — filed
840
- // as a blocker against task 1003339, not settled by a comment in this file.
831
+ // this route's strictly richer twin (member|invited|not_admitted plus its own
832
+ // `admitted`). One budget across both, because they are one oracle: hardening
833
+ // either alone would accomplish nothing, and giving each its own budget would
834
+ // just let an enumerator alternate for double the rate. That twin once told
835
+ // pending/declined/none apart too; it collapsed to `not_admitted` on the
836
+ // owner's call (ADR 0217 D4, task 1003359).
841
837
  //
842
838
  // That endpoint is also why this one exists: it models neither the firstAdmin
843
- // bypass nor open enrollment, so it reports the OWNER as 'none' forever —
839
+ // bypass nor open enrollment, so it reports the OWNER as 'not_admitted' forever —
844
840
  // the exact case this page has to resolve.
845
841
  router.get('/auth/web/admission-status', accountExistenceReadRateLimit, async (req, res) => {
846
842
  const login = typeof req.query.login === 'string' ? req.query.login.slice(0, 100) : '';
@@ -97,7 +97,7 @@ const { checkEntitlement, listEntitlements, grantEntitlement, recordAcquired } =
97
97
  const {
98
98
  stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion,
99
99
  servedChannels, resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
100
- delistModule, delistedKeySet, withdrawVersion, withdrawnVersions, migrateLegacyStore,
100
+ delistModule, delistedKeySet, setPrice, withdrawVersion, withdrawnVersions, migrateLegacyStore,
101
101
  } = require('../module-store');
102
102
  const {
103
103
  isOptedOut, setReporting, installReportError, errorCountError, recordInstallReport, recordErrorCount,
@@ -575,6 +575,37 @@ module.exports = function buildModulesRouter() {
575
575
  res.status(202).json({ ok: true, queued: true, module_key: key, version, version_id: r.version.id });
576
576
  }));
577
577
 
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
582
+ // newest is what the catalog shows and what acquire charges. A paid price may be
583
+ // set before buying exists (owner, 2026-10-26): the catalog marks it not yet
584
+ // buyable. Price never moves the score (ADR 0343 D6). The audit middleware records
585
+ // who changed it and to what.
586
+ //
587
+ // rank: metic+archon — the same gate as publishing (module.submit), plus the
588
+ // author check inside setPrice: only the person who published the module prices it.
589
+ router.post('/store/modules/:key/price', auth.requireBuilder, auth.requirePermission('module.submit'),
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;
593
+ const { key } = req.params;
594
+ if (!KEY_RE.test(key)) {
595
+ return res.fail('bad_module_key', { status: 400, message: 'A module key is lowercase kebab-case.' });
596
+ }
597
+ const body = req.body || {};
598
+ 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.' });
602
+ }
603
+ const out = await setPrice({ key, cents, credits, builderId: req.builder.id });
604
+ 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`);
606
+ res.json({ ok: true, module_key: key, previous: out.previous, price: out.price });
607
+ }));
608
+
578
609
  // POST /api/bongos/store/modules/:key/delist — a Metic+ person takes a module out
579
610
  // of the store (task 1003797, ADR 0338 D2). It leaves the catalog, cannot be newly
580
611
  // acquired, takes no new versions and serves no further downloads: every 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.23.5'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.23.7'; // 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');
@@ -3,7 +3,7 @@
3
3
  //
4
4
  // The hole this pins shut: two PUBLIC routes answer "does this GitHub login have
5
5
  // an account here?" — the onboarding module's GET /access-requests/status (the
6
- // rich member|invited|pending|dismissed|none) and core's
6
+ // richer member|invited|not_admitted) and core's
7
7
  // GET /auth/web/admission-status (its boolean twin) — and neither was throttled.
8
8
  // The global limiter returns early for non-write methods, so every GET bypassed
9
9
  // it. Scripted at line rate, either route turns a list of GitHub usernames into a
@@ -220,8 +220,12 @@ await t('under the ceiling both probes still answer truthfully — this is a cap
220
220
  assert.deepEqual(member.body, { github_login: 'ada', status: 'member', admitted: true });
221
221
  const invited = await call(RICH('bo'), ip);
222
222
  assert.equal(invited.body.status, 'invited');
223
- const stranger = await call(RICH('zzz'), ip);
224
- assert.equal(stranger.body.status, 'none');
223
+ // task 1003359 (ADR 0217 D4): a stranger, a pending applicant and a declined one
224
+ // get the same body, so the probe says nothing about who applied here.
225
+ for (const login of ['zzz', 'cy', 'dez']) {
226
+ const r = await call(RICH(login), ip);
227
+ assert.deepEqual(r.body, { github_login: login, status: 'not_admitted', admitted: false }, login);
228
+ }
225
229
  const bool = await call(BOOL('ada'), ip);
226
230
  assert.deepEqual(bool.body, { admitted: true });
227
231
  assert.equal((await call(BOOL('zzz'), ip)).body.admitted, false);
@@ -1087,17 +1087,18 @@ test('expiry: another sender re-inviting the login retires your expired line fro
1087
1087
  assert.deepEqual(theirs.json.invites.map((x) => [x.github_login, x.expired]), [['fay', false]]);
1088
1088
  });
1089
1089
 
1090
- test('expiry: the public status probe says expired, and an expired invitee may apply', async () => {
1090
+ test('expiry: the public status probe says not_admitted, and an expired invitee may apply', async () => {
1091
1091
  reset();
1092
1092
  builders.push(...ARCHONS);
1093
1093
  await call('POST', '/api/gds/access-requests/invite', { github_login: 'fay' });
1094
1094
  age(rows.find((x) => x.github_login === 'fay'), 31);
1095
1095
  const st = await call('GET', '/api/gds/access-requests/status?github_login=fay');
1096
- assert.deepEqual(st.json, { github_login: 'fay', status: 'expired', admitted: false });
1096
+ // task 1003359 (ADR 0217 D4): a lapsed invite is not told apart from no request at all
1097
+ assert.deepEqual(st.json, { github_login: 'fay', status: 'not_admitted', admitted: false });
1097
1098
  const asked = await call('POST', '/api/gds/access-requests', { github_login: 'fay' });
1098
1099
  assert.equal(asked.status, 201, 'not already_approved: an expired invite is not an approval');
1099
1100
  const st2 = await call('GET', '/api/gds/access-requests/status?github_login=fay');
1100
- assert.equal(st2.json.status, 'pending', 'the newer request is what the probe reports');
1101
+ assert.deepEqual(st2.json, { github_login: 'fay', status: 'not_admitted', admitted: false }, 'nor is a pending request');
1101
1102
  });
1102
1103
 
1103
1104
  test('expiry: rescinding the last live invite clears the hub notice even though an expired one remains', async () => {
@@ -13,7 +13,7 @@ import path from 'node:path';
13
13
  import { fileURLToPath } from 'node:url';
14
14
 
15
15
  const require = createRequire(import.meta.url);
16
- const { normalizeInstanceBase, pollDecision } = require('../scripts/bongos/login.js');
16
+ const { normalizeInstanceBase, pollDecision, waitTimeoutMessage } = require('../scripts/bongos/login.js');
17
17
  const LOGIN_JS = path.join(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'), 'scripts', 'bongos', 'login.js');
18
18
 
19
19
  test('normalizeInstanceBase adds https, strips trailing slashes + a pasted /api/bongos', () => {
@@ -38,15 +38,26 @@ test('pollDecision maps the /access-requests/status body to a loop action', () =
38
38
  assert.equal(pollDecision({ admitted: true, status: 'invited' }), 'admitted');
39
39
  assert.equal(pollDecision({ status: 'invited' }), 'admitted');
40
40
  assert.equal(pollDecision({ status: 'member', admitted: true }), 'admitted');
41
- assert.equal(pollDecision({ status: 'dismissed' }), 'dismissed');
42
- // task 1002969: an invite past its 30 days stops the loop rather than waiting on a shut door
43
- assert.equal(pollDecision({ status: 'expired', admitted: false }), 'expired');
41
+ // task 1003359 (ADR 0217 D4): the probe answers only admitted or not, so the loop
42
+ // cannot tell a decline or a lapsed invite from a wait — nor can an older
43
+ // instance's richer answer make it claim to.
44
+ assert.equal(pollDecision({ status: 'not_admitted', admitted: false }), 'wait');
45
+ assert.equal(pollDecision({ status: 'dismissed' }), 'wait');
46
+ assert.equal(pollDecision({ status: 'expired', admitted: false }), 'wait');
44
47
  assert.equal(pollDecision({ status: 'pending', admitted: false }), 'wait');
45
48
  assert.equal(pollDecision({ status: 'none' }), 'wait');
46
49
  assert.equal(pollDecision(null), 'wait');
47
50
  assert.equal(pollDecision({}), 'wait');
48
51
  });
49
52
 
53
+ test('giving up on the wait promises no approval and names who to ask', () => {
54
+ const msg = waitTimeoutMessage('https://x.example');
55
+ assert.doesNotMatch(msg, /once an Archon has approved/i, 'a declined person must not be told an approval is coming');
56
+ assert.match(msg, /waiting longer won.t change/i);
57
+ assert.match(msg, /owner/i);
58
+ assert.match(msg, /bongos login https:\/\/x\.example/);
59
+ });
60
+
50
61
  // ---- the Device-Flow dead end (task 1003881) -------------------------------
51
62
  //
52
63
  // Device Flow is OFF on every GitHub App created from a manifest and GitHub offers no
@@ -141,7 +141,7 @@ try {
141
141
  'and says when each invite closes; an application has no such day');
142
142
 
143
143
  const probe = (await call('GET', '/api/gds/access-requests/status?github_login=fay')).json;
144
- ok(probe.status === 'expired' && probe.admitted === false, 'the status probe reports expired, not invited');
144
+ ok(probe.status === 'not_admitted' && probe.admitted === false, 'the status probe reports not_admitted, not invited (ADR 0217 D4)');
145
145
 
146
146
  notices = [];
147
147
  const again = await call('POST', '/api/gds/access-requests/invite', { github_login: 'fay' });