@bongos/core 1.23.5 → 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.
@@ -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.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
@@ -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
+ });