@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.
- package/.bongos-core.json +67 -42
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +9 -1
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0356-a-recruit-invite-expires-30-days-after-it-is-sent-read-time.md +2 -1
- package/docs/api/openapi.json +204 -7
- package/docs/api-reference.md +4 -2
- package/docs/copy-inventory.md +26 -24
- package/docs/copy-registry.json +43 -25
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +2 -0
- package/docs/page-readings.json +8 -8
- package/migrations/core_281_module_version_withdraw_note.sql +25 -0
- package/modules/government/catalog.js +1 -1
- package/modules/hall-ui/public/modules.js +10 -1
- package/modules/onboarding/routes/access-requests.js +10 -9
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/bongos/login.js +13 -14
- package/scripts/bongos/module-store-cli.js +182 -0
- package/scripts/bongos/module.js +60 -134
- package/scripts/gds/module-store-cli.js +6 -0
- package/src/bongos/auth-admission.js +3 -3
- package/src/bongos/middleware/rate-limit.js +1 -1
- package/src/bongos/module-listing.js +12 -2
- package/src/bongos/module-store.js +90 -1
- package/src/bongos/route-rank-check.js +6 -0
- package/src/bongos/routes/auth.js +7 -11
- package/src/bongos/routes/modules.js +76 -3
- package/src/module-api.js +1 -1
- package/tests/account_existence_rate_limit.mjs +7 -3
- package/tests/application_lifecycle.mjs +4 -3
- package/tests/bongos_login.mjs +15 -4
- package/tests/hall_catalog_world.mjs +1 -1
- package/tests/invite_expiry_db.mjs +1 -1
- package/tests/module_store_cli.mjs +1 -1
- package/tests/module_store_price.mjs +207 -0
- 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|
|
|
832
|
-
//
|
|
833
|
-
//
|
|
834
|
-
//
|
|
835
|
-
//
|
|
836
|
-
//
|
|
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 '
|
|
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
|
-
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
|
|
224
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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 () => {
|
package/tests/bongos_login.mjs
CHANGED
|
@@ -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
|
-
|
|
42
|
-
//
|
|
43
|
-
|
|
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 === '
|
|
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
|
+
});
|