@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
@@ -250,9 +250,15 @@ 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',
259
+ // Withdrawing a version (task 1004895) stops it being handed to every project, the
260
+ // same reach as a delist, so it rides the delist atom and is pinned.
261
+ 'POST /store/modules/:key/versions/:version/withdraw': 'perm:module.delist',
256
262
  };
257
263
 
258
264
  // The authz-core route pins (ADR 0174). Paths and the gating atom were renamed
@@ -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, migrateLegacyStore,
100
+ delistModule, delistedKeySet, setPrice, withdrawVersion, withdrawnVersions, migrateLegacyStore,
101
101
  } = require('../module-store');
102
102
  const {
103
103
  isOptedOut, setReporting, installReportError, errorCountError, recordInstallReport, recordErrorCount,
@@ -272,6 +272,11 @@ module.exports = function buildModulesRouter() {
272
272
  let delistedKeys = new Set();
273
273
  try { delistedKeys = await delistedKeySet(all.filter((m) => m.source === 'instance').map((m) => m.key)); }
274
274
  catch (e) { log.warn({ err: e.message }, 'store status unreadable for the modules list'); }
275
+ // Which installed store modules run a version the store took back (task 1004895),
276
+ // so the row says so, with the reason. Same tolerance.
277
+ let withdrawn = {};
278
+ try { withdrawn = await withdrawnVersions(all.filter((m) => m.source === 'instance').map((m) => m.key)); }
279
+ catch (e) { log.warn({ err: e.message }, 'withdrawn versions unreadable for the modules list'); }
275
280
  for (const m of all) {
276
281
  m.enabled_source = sources[m.key] || 'default';
277
282
  m.override = byKey.has(m.key)
@@ -283,6 +288,9 @@ module.exports = function buildModulesRouter() {
283
288
  ? `/builders/modules?howto=${encodeURIComponent(m.key)}&version=${encodeURIComponent(String(m.version))}`
284
289
  : null;
285
290
  m.store_delisted = m.source === 'instance' && delistedKeys.has(m.key);
291
+ const taken = m.source === 'instance' && m.version
292
+ ? (withdrawn[m.key] || []).find((w) => w.version === String(m.version)) : null;
293
+ m.store_withdrawn = taken ? { version: taken.version, note: taken.note } : null;
286
294
  // what of this module still waits for a restart to match its toggle (task 1004463)
287
295
  m.restart_pending = restartPendingFor(m.key, m.enabled === true);
288
296
  }
@@ -567,6 +575,37 @@ module.exports = function buildModulesRouter() {
567
575
  res.status(202).json({ ok: true, queued: true, module_key: key, version, version_id: r.version.id });
568
576
  }));
569
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
+
570
609
  // POST /api/bongos/store/modules/:key/delist — a Metic+ person takes a module out
571
610
  // of the store (task 1003797, ADR 0338 D2). It leaves the catalog, cannot be newly
572
611
  // acquired, takes no new versions and serves no further downloads: every store
@@ -637,6 +676,35 @@ module.exports = function buildModulesRouter() {
637
676
  res.json({ ok: true, module_key: key, version, channel: out.channel, reason: out.reason });
638
677
  }));
639
678
 
679
+ // POST /api/bongos/store/modules/:key/versions/:version/withdraw — a Metic+ person
680
+ // takes one version back (task 1004895, criterion wa5-staged-rollout). The store
681
+ // stops handing it out: acquire, install, update and the update check skip it, and
682
+ // the newest version still served is offered instead. A project that already runs
683
+ // it keeps it, and `bongos upgrade` and the hall's Modules tab tell it the version
684
+ // was withdrawn, with the reason. Withdrawing the only served version is allowed;
685
+ // acquire then says nothing can be handed out. A reason is required: it is kept on
686
+ // the channel row (core_281) and shown to holders, and the audit middleware also
687
+ // records it with who asked. The permission is module.delist, the one that already
688
+ // guards delist and release.
689
+ router.post('/store/modules/:key/versions/:version/withdraw', auth.requireBuilder, auth.requirePermission('module.delist'),
690
+ asyncHandler('POST /store/modules/:key/versions/:version/withdraw', async (req, res) => {
691
+ if (validateOrRespond(req, res, { reason: { type: 'string', maxLength: 2000 } })) return;
692
+ const { key, version } = req.params;
693
+ if (!KEY_RE.test(key) || !parseVersion(version)) {
694
+ return res.fail('bad_request', { status: 400, message: 'A module key is lowercase kebab-case and a version is exact X.Y.Z.' });
695
+ }
696
+ const reason = String(req.body?.reason || '').trim();
697
+ if (!reason) {
698
+ return res.fail('reason_required', { status: 400, message: 'Say why this version is being taken back, in reason. Projects that already run it keep it.' });
699
+ }
700
+ const r = await resolveReadable(key, version);
701
+ if (!r.ok) return res.fail(r.code, { status: r.status, message: r.message });
702
+ const out = await withdrawVersion(r.version.id, req.builder.id, reason);
703
+ if (!out.ok) return res.fail(out.code, { status: out.status, message: out.message });
704
+ log.info({ versionId: r.version.id, builderId: req.builder.id, reason }, `${key} ${version} withdrawn from the store`);
705
+ res.json({ ok: true, module_key: key, version, channel: out.channel, reason });
706
+ }));
707
+
640
708
  // GET /api/bongos/store/entitlements and /store/modules/:key/entitlement — the
641
709
  // read side of the entitlement record (task 1003813, ADR 0338 D1). Own-scoped
642
710
  // like /me/sessions: the holder is ALWAYS the caller (holder_kind 'builder',
@@ -832,9 +900,14 @@ module.exports = function buildModulesRouter() {
832
900
  if (!keys.length || keys.length > STORE_LATEST_MAX_KEYS || !keys.every((k) => KEY_RE.test(k))) {
833
901
  return res.fail('bad_request', { status: 400, message: `keys is a comma-separated list of 1 to ${STORE_LATEST_MAX_KEYS} module keys (lowercase kebab-case).` });
834
902
  }
835
- const latest = await latestVersions(keys, { channelsFor: await projectChannels() });
903
+ // Which of these keys have versions taken back (task 1004895), so a project
904
+ // running one is told, with the reason; a key with none is absent.
905
+ const [latest, withdrawn] = await Promise.all([
906
+ latestVersions(keys, { channelsFor: await projectChannels() }),
907
+ withdrawnVersions(keys),
908
+ ]);
836
909
  res.set('Cache-Control', 'no-store');
837
- res.json({ latest });
910
+ res.json({ latest, withdrawn });
838
911
  }));
839
912
 
840
913
  // The store catalog under the quality floor (task 1003807, ADR 0343 D2/D5): each
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.4'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.23.6'; // 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
@@ -58,7 +58,7 @@ test('modules: the row is the oversight row — title with the mono key, clamped
58
58
  assert.match(js, /<span class="ov-row__title">\$\{escapeHtml\(m\.title \|\| m\.key\)\}<span class="ov-mono">\$\{escapeHtml\(m\.key\)\}<\/span>/, 'the key rides inside the title in mono');
59
59
  assert.match(js, /class="ov-row__sub mod-row__desc"/, 'the description is the sub line');
60
60
  assert.match(js, /<details class="mod-more"/, 'a details disclosure opens the rest');
61
- assert.match(js, /<div class="ov-row__facts">\$\{provenancePill\(m\)\}\$\{categoryFact\(m\)\}\$\{attributionFacts\(m\)\}\$\{upkeepFact\(m\)\}\$\{howtoFact\(m\)\}\$\{delistedFact\(m\)\}\$\{restartFact\(m\)\}<\/div>/, 'provenance, (task 1003784) the category, credit, upkeep, (for a store version, task 1004366) the how-to link and (task 1003797) the delisted pill and (task 1004463) the waits-for-a-restart pill are the facts line');
61
+ assert.match(js, /<div class="ov-row__facts">\$\{provenancePill\(m\)\}\$\{categoryFact\(m\)\}\$\{attributionFacts\(m\)\}\$\{upkeepFact\(m\)\}\$\{howtoFact\(m\)\}\$\{delistedFact\(m\)\}\$\{withdrawnFact\(m\)\}\$\{restartFact\(m\)\}<\/div>/, 'provenance, (task 1003784) the category, credit, upkeep, (for a store version, task 1004366) the how-to link and (task 1003797) the delisted pill, (task 1004895) the withdrawn pill and (task 1004463) the waits-for-a-restart pill are the facts line');
62
62
  assert.match(js, /<div class="ov-row__right mod-row__right">\$\{rightHtml\(m\)\}<\/div>/, 'the right column is one function');
63
63
  const css = read('modules.css');
64
64
  assert.match(css, /\.mod-row__desc \{[^}]*-webkit-line-clamp: 2/, 'the description clamps to two lines');
@@ -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' });
@@ -158,7 +158,7 @@ test('the text view keeps the API order: listed ranked, then New, and says the f
158
158
  assert.equal(at('stripe-10'), -1, 'the eleventh is not shown');
159
159
  assert.match(text, /stripe-00@1\.0\.0 {2}99\/100 {2}free {2}Money › Stripe payments/);
160
160
  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$/m, 'no category, no place');
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)');
162
162
  assert.match(text, /waiting@1\.0\.0 {2}60\/100 {2}free {2}Money$/m, 'a proposed sub-category shows its parent only');
163
163
  assert.match(text, /New — not ranked[\s\S]*fresh@1\.0\.0/);
164
164
  });
@@ -0,0 +1,207 @@
1
+ // tests/module_store_price.mjs — a store module's author sets its price (task
2
+ // 1004894, criterion wa5-module-economy). Drives the real POST
3
+ // /store/modules/:key/price handler off the real router, the real setPrice and
4
+ // isFreePrice behind it, the real catalog read, and `bongos module price`, against a
5
+ // stand-in pool that keeps the price history (no port, no real DB — the
6
+ // module_store_delist.mjs harness).
7
+ //
8
+ // Run: node tests/module_store_price.mjs
9
+
10
+ import { strict as assert } from 'node:assert';
11
+ import { test } from 'node:test';
12
+ import { createRequire } from 'node:module';
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+
21
+ const fresh = () => ({
22
+ modules: {
23
+ weather: { module_key: 'weather', title: 'Weather', author_id: 42, status: 'listed' },
24
+ gone: { module_key: 'gone', title: 'Gone', author_id: 42, status: 'delisted' },
25
+ },
26
+ prices: [],
27
+ setAt: 0,
28
+ });
29
+ let state = fresh();
30
+ const newestPrice = (key) => state.prices.filter((p) => p.module_key === key).at(-1) || null;
31
+ const query = async (sql, params = []) => {
32
+ if (/^INSERT INTO store_module_prices/.test(sql)) {
33
+ const [module_key, price_cents, price_credits, set_by] = params;
34
+ state.prices.push({ module_key, price_cents, price_credits, set_by, set_at: ++state.setAt });
35
+ return { rows: [] };
36
+ }
37
+ if (/FROM store_module_prices\s+WHERE module_key = \$1/.test(sql)) {
38
+ const p = newestPrice(params[0]); return { rows: p ? [p] : [] };
39
+ }
40
+ if (/FROM store_module_prices\s+WHERE module_key = ANY/.test(sql)) {
41
+ return { rows: params[0].map(newestPrice).filter(Boolean) };
42
+ }
43
+ if (/FROM store_modules WHERE module_key = \$1/.test(sql)) {
44
+ const m = state.modules[params[0]]; return { rows: m ? [m] : [] };
45
+ }
46
+ if (/FROM store_module_versions WHERE module_key = \$1/.test(sql)) {
47
+ return { rows: [{ id: 1, module_key: params[0], version: '1.0.0', channel: 'general' }] };
48
+ }
49
+ if (/FROM store_modules m JOIN store_module_versions v/.test(sql)) {
50
+ return { rows: [{ module_key: 'weather', title: 'Weather', version_id: 1, version: '1.0.0', published_at: 't' }] };
51
+ }
52
+ if (/FROM module_assessment_scores/.test(sql)) {
53
+ return { rows: [{ version_id: 1, overall: 80, security_passed: true, is_new: false }] };
54
+ }
55
+ return { rows: [] };
56
+ };
57
+ const pool = { connect: async () => ({ query, release() {} }), query };
58
+ const poolPath = require.resolve('../src/bongos/pool.js');
59
+ require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool } };
60
+
61
+ const store = require('../src/bongos/module-store.js');
62
+ const listing = require('../src/bongos/module-listing.js');
63
+ const buildModulesRouter = require('../src/bongos/routes/modules.js');
64
+ const mod = require('../scripts/bongos/module.js');
65
+
66
+ const PRICE = '/store/modules/:key/price';
67
+ function handlerFor() {
68
+ const layer = buildModulesRouter().stack.find((l) => l.route && l.route.path === PRICE && l.route.methods.post);
69
+ assert.ok(layer, `no POST ${PRICE} on the router`);
70
+ const stack = layer.route.stack;
71
+ return { handle: stack[stack.length - 1].handle, depth: stack.length };
72
+ }
73
+ async function setPrice(body, { key = 'weather', builderId = 42 } = {}) {
74
+ const res = {
75
+ statusCode: 200, body: undefined,
76
+ json(b) { this.body = b; return this; },
77
+ status(c) { this.statusCode = c; return this; },
78
+ set() { return this; },
79
+ fail(code, opts, details) {
80
+ const o = typeof opts === 'number' ? { status: opts, details } : (opts || {});
81
+ this.statusCode = o.status; this.body = { error: { code, message: o.message } }; return this;
82
+ },
83
+ };
84
+ let nextErr = null;
85
+ await handlerFor().handle({ params: { key }, body, builder: { id: builderId } }, res, (err) => { nextErr = err; });
86
+ if (nextErr) throw nextErr;
87
+ return res;
88
+ }
89
+
90
+ test('the route rides the publish atom and is pinned', () => {
91
+ assert.ok(handlerFor().depth >= 3, 'requireBuilder + requirePermission sit in front of the handler');
92
+ const rank = fs.readFileSync(path.join(ROOT, 'src/bongos/route-rank-check.js'), 'utf8');
93
+ assert.match(rank, /'POST \/store\/modules\/:key\/price': 'perm:module\.submit'/);
94
+ });
95
+
96
+ test('only the author may set the price; a stranger and a delisted module are refused', async () => {
97
+ state = fresh();
98
+ const stranger = await setPrice({ cents: 500 }, { builderId: 7 });
99
+ assert.equal(stranger.statusCode, 403);
100
+ assert.equal(stranger.body.error.code, 'not_module_author');
101
+ const gone = await setPrice({ cents: 500 }, { key: 'gone' });
102
+ assert.equal(gone.statusCode, 409);
103
+ const missing = await setPrice({ cents: 500 }, { key: 'nope' });
104
+ assert.equal(missing.statusCode, 404);
105
+ assert.equal(state.prices.length, 0, 'nothing was written');
106
+ });
107
+
108
+ test('a price must be whole numbers of 0 or more, and name at least one currency', async () => {
109
+ state = fresh();
110
+ for (const body of [{}, { cents: null, credits: null }, { cents: -1 }, { cents: 1.5 }, { credits: '10' }, { cents: 1e12 }]) {
111
+ const r = await setPrice(body);
112
+ assert.equal(r.statusCode, 400, JSON.stringify(body));
113
+ assert.ok(['bad_price', 'validation_failed'].includes(r.body.error.code), r.body.error.code);
114
+ }
115
+ assert.equal(state.prices.length, 0);
116
+ });
117
+
118
+ test('each change appends a row naming who set it, and the newest is what acquire reads', async () => {
119
+ state = fresh();
120
+ const first = await setPrice({ cents: 900 });
121
+ assert.equal(first.statusCode, 200);
122
+ assert.deepEqual(first.body, { ok: true, module_key: 'weather', previous: null, price: { cents: 900, credits: null } });
123
+ const second = await setPrice({ cents: 500, credits: 40 });
124
+ assert.deepEqual(second.body.previous, { cents: 900, credits: null });
125
+ assert.equal(state.prices.length, 2, 'the history is kept');
126
+ assert.equal(state.prices[1].set_by, 42);
127
+ const acq = await store.resolveAcquirable('weather', {});
128
+ assert.equal(acq.ok, true);
129
+ assert.deepEqual([acq.price.price_cents, acq.price.price_credits], [500, 40]);
130
+ assert.equal(acq.free, false);
131
+ await setPrice({ credits: 0 });
132
+ assert.equal((await store.resolveAcquirable('weather', {})).free, true, '0 in either currency is free');
133
+ });
134
+
135
+ test('the catalog shows the price, and marks a paid module not yet buyable until buying opens', async () => {
136
+ state = fresh();
137
+ let cat = await listing.listCatalog({});
138
+ assert.equal(cat.listed[0].price.free, true);
139
+ assert.equal(cat.listed[0].buyable, true, 'a free module can be had today');
140
+ await setPrice({ cents: 900 });
141
+ cat = await listing.listCatalog({});
142
+ assert.deepEqual(cat.listed[0].price, { cents: 900, credits: null, free: false });
143
+ assert.equal(cat.listed[0].buyable, false, 'buying arrives with task 1003788');
144
+ });
145
+
146
+ test('bongos module price: sends the price, says what it was and now is, and that paid is not yet buyable', async () => {
147
+ const sent = [];
148
+ const request = async (method, url, opts) => {
149
+ sent.push({ method, url, json: opts && opts.json });
150
+ return { ok: true, status: 200, data: { ok: true, previous: { cents: 0, credits: null }, price: { cents: 900, credits: 40 } } };
151
+ };
152
+ const out = [];
153
+ assert.equal(await mod.cmdPrice(['weather', '--cents', '900', '--credits', '40'], { log: (l) => out.push(l), errlog: () => {}, request }), 0);
154
+ assert.deepEqual(sent, [{ method: 'POST', url: '/store/modules/weather/price', json: { cents: 900, credits: 40 } }]);
155
+ assert.match(out.join('\n'), /"weather" now costs \$9\.00 or 40 credits \(was free\)/);
156
+ assert.match(out.join('\n'), /not yet buyable/);
157
+ });
158
+
159
+ test('bongos module price: refuses no key, no amount and a non-number before asking the store', async () => {
160
+ const request = async () => { throw new Error('must not be called'); };
161
+ for (const args of [[], ['--cents', '5'], ['weather'], ['weather', '--cents', 'five'], ['weather', '--credits', '-3']]) {
162
+ const errs = [];
163
+ assert.equal(await mod.cmdPrice(args, { log: () => {}, errlog: (l) => errs.push(l), request }), 2, args.join(' '));
164
+ assert.match(errs.join('\n'), /usage: bongos module price/);
165
+ }
166
+ });
167
+
168
+ test('bongos module price: the store refusing is reported, not swallowed', async () => {
169
+ const errs = [];
170
+ const request = async () => ({ ok: false, status: 403, data: { error: { code: 'not_module_author', message: 'Only the author of "weather" may set its price.' } } });
171
+ assert.equal(await mod.cmdPrice(['weather', '--cents', '100'], { log: () => {}, errlog: (l) => errs.push(l), request }), 1);
172
+ assert.match(errs.join('\n'), /Only the author of "weather" may set its price/);
173
+ });
174
+
175
+ test('the price history is the audit: who set each price and when, and what it replaced', async () => {
176
+ state = fresh();
177
+ await setPrice({ cents: 900 });
178
+ await setPrice({ cents: 500 }, { builderId: 42 });
179
+ const rows = state.prices.filter((r) => r.module_key === 'weather');
180
+ assert.deepEqual(rows.map((r) => [r.price_cents, r.set_by]), [[900, 42], [500, 42]], 'every change is a row naming who set it');
181
+ assert.ok(rows[1].set_at > rows[0].set_at, 'in order, so the row before is what it changed from');
182
+ const src = fs.readFileSync(path.join(ROOT, 'src/bongos/module-store.js'), 'utf8');
183
+ assert.match(src, /FROM store_modules WHERE module_key = \$1 FOR UPDATE/, 'one transaction, the module row locked');
184
+ });
185
+
186
+ test('bongos module publish --cents/--credits sets the price after the upload, and checks it before packing', async () => {
187
+ const packed = { tgz: Buffer.from('x'), sidecar: { module_version: '1.0.0', core_version: '^1.0.0', file_count: 1, tree_sha256: 'a'.repeat(64), tarball: { name: 'x.tgz' } } };
188
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'publish-price-'));
189
+ fs.mkdirSync(path.join(dir, 'weather'));
190
+ const artifact = require('../scripts/bongos/module-artifact.js');
191
+ fs.writeFileSync(path.join(dir, 'weather', artifact.HOWTO_FILE), artifact.HOWTO_SECTIONS.map((h) => `## ${h}\n\nText.\n`).join('\n'));
192
+ const sent = [];
193
+ const priceRequest = async (method, url, opts) => { sent.push([method, url, opts.json]); return { ok: true, status: 200, data: { previous: null, price: opts.json } }; };
194
+ const upload = async () => ({ ok: true, status: 201, data: { version: { version: '1.0.0' }, channel: 'tester' } });
195
+ const out = [];
196
+ const code = await mod.cmdPublish(['--cents', '900', 'weather'], { log: (l) => out.push(l), errlog: (l) => out.push(l), modulesDir: dir, pack: () => packed, upload, priceRequest });
197
+ assert.equal(code, 0, out.join('\n'));
198
+ assert.deepEqual(sent, [['POST', '/store/modules/weather/price', { cents: 900, credits: null }]], 'the key is found even after a valued flag');
199
+ let packedAtAll = false;
200
+ const bad = await mod.cmdPublish(['weather', '--cents', 'nine'], { log: () => {}, errlog: () => {}, modulesDir: dir, pack: () => { packedAtAll = true; return packed; }, upload, priceRequest });
201
+ assert.equal(bad, 2);
202
+ assert.equal(packedAtAll, false, 'a bad price refuses before anything is packed or sent');
203
+ const failing = async () => ({ ok: false, status: 403, data: { error: { message: 'Only the author' } } });
204
+ const errs = [];
205
+ assert.equal(await mod.cmdPublish(['weather', '--credits', '40'], { log: () => {}, errlog: (l) => errs.push(l), modulesDir: dir, pack: () => packed, upload, priceRequest: failing }), 1);
206
+ assert.match(errs.join('\n'), /The version is published, but its price was not set\. Run `bongos module price weather --credits 40`/);
207
+ });