@bongos/core 1.23.5 → 1.23.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +66 -36
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +4 -0
- package/clients/bongos-client/index.mjs +2 -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 +96 -5
- package/docs/api-reference.md +3 -2
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +3 -0
- 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-remove.js +122 -0
- package/scripts/bongos/module-store-cli.js +182 -0
- package/scripts/bongos/module.js +42 -127
- package/scripts/gds/module-remove.js +6 -0
- 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 +49 -1
- package/src/bongos/route-rank-check.js +3 -0
- package/src/bongos/routes/auth.js +7 -11
- package/src/bongos/routes/modules.js +32 -1
- 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/invite_expiry_db.mjs +1 -1
- package/tests/module_remove.mjs +121 -0
- package/tests/module_store_cli.mjs +1 -1
- package/tests/module_store_price.mjs +207 -0
package/scripts/bongos/module.js
CHANGED
|
@@ -39,6 +39,12 @@
|
|
|
39
39
|
// sub-category live in one place (src/bongos/module-listing.js), so the hall
|
|
40
40
|
// and the terminal cannot disagree about which modules are the best.
|
|
41
41
|
//
|
|
42
|
+
// bongos module price <key> [--cents N] [--credits N]
|
|
43
|
+
// Task 1004894 — the module's author sets its price in the store, in money
|
|
44
|
+
// (cents), credits or both; 0 in either makes it free. The store keeps every
|
|
45
|
+
// change and shows the newest. A paid module is listed as not yet buyable until
|
|
46
|
+
// buying arrives (task 1003788).
|
|
47
|
+
//
|
|
42
48
|
// bongos module check <key>
|
|
43
49
|
// ADR 0107 §4 — the submit-time pre-check a module must clear before it can
|
|
44
50
|
// be upstreamed into the shared core catalog. REUSES (never re-types) the
|
|
@@ -82,6 +88,8 @@ const { validateManifest, assertValidManifest, KEY_RE, SUNSET_STATUSES } = requi
|
|
|
82
88
|
const { parseVersion, cmp } = require('../../src/module-loader/semver');
|
|
83
89
|
const loader = require('../../src/module-loader/loader');
|
|
84
90
|
const catalog = require('../../src/module-loader/catalog');
|
|
91
|
+
// The store client and the `store` / `price` verbs (task 1004894 carve).
|
|
92
|
+
const { storeRequest, storeError, howtoLink, formatStore, cmdStore, priceArgs, cmdPrice } = require('./module-store-cli');
|
|
85
93
|
const { resolveModules, MODULE_REGISTRY, NEUTRAL_PATH, INSTANCE_PATH } = require('../../src/modules');
|
|
86
94
|
// The public-mirror publish boundary (ADR 0098) — REUSED, never re-typed, for
|
|
87
95
|
// the ADR 0107 §4 upstreaming pre-check. Only matchesDeny is used (not the full
|
|
@@ -958,18 +966,31 @@ async function cmdSubmit(args, {
|
|
|
958
966
|
// Distinct from `submit`, which proposes a module INTO core (ADR 0135).
|
|
959
967
|
async function cmdPublish(args, {
|
|
960
968
|
log = console.log, errlog = console.error,
|
|
961
|
-
modulesDir = MODULES_DIR, pack, upload, writeFile = fs.writeFileSync,
|
|
969
|
+
modulesDir = MODULES_DIR, pack, upload, writeFile = fs.writeFileSync, priceRequest,
|
|
962
970
|
} = {}) {
|
|
963
|
-
const
|
|
971
|
+
const PUBLISH_USAGE = 'usage: bongos module publish <key> [--dry-run] [--out <dir>] [--cents N] [--credits N]';
|
|
972
|
+
const VALUED = new Set(['--out', '--cents', '--credits']);
|
|
973
|
+
const key = args.find((a, i) => !a.startsWith('-') && !VALUED.has(args[i - 1]));
|
|
964
974
|
if (!key) {
|
|
965
|
-
errlog(
|
|
975
|
+
errlog(PUBLISH_USAGE);
|
|
966
976
|
return 2;
|
|
967
977
|
}
|
|
978
|
+
// An optional price set right after the upload (task 1004894), with the same rules
|
|
979
|
+
// as `bongos module price`, checked BEFORE anything is packed or sent.
|
|
980
|
+
const priceFlags = [];
|
|
981
|
+
for (const flag of ['--cents', '--credits']) {
|
|
982
|
+
const i = args.indexOf(flag);
|
|
983
|
+
if (i !== -1) priceFlags.push(flag, args[i + 1]);
|
|
984
|
+
}
|
|
985
|
+
if (priceFlags.length) {
|
|
986
|
+
const p = priceArgs([key, ...priceFlags]);
|
|
987
|
+
if (p.error) { errlog(`bongos module publish: ${p.error}`); errlog(PUBLISH_USAGE); return 2; }
|
|
988
|
+
}
|
|
968
989
|
const dryRun = args.includes('--dry-run');
|
|
969
990
|
const outIdx = args.indexOf('--out');
|
|
970
991
|
const outDir = outIdx !== -1 ? args[outIdx + 1] : null;
|
|
971
992
|
if (outIdx !== -1 && (!outDir || outDir.startsWith('--'))) {
|
|
972
|
-
errlog(
|
|
993
|
+
errlog(PUBLISH_USAGE);
|
|
973
994
|
return 2;
|
|
974
995
|
}
|
|
975
996
|
|
|
@@ -1037,133 +1058,22 @@ async function cmdPublish(args, {
|
|
|
1037
1058
|
if (res.data && res.data.channel === 'tester') {
|
|
1038
1059
|
log(' It goes to testing projects first, and reaches every project once it has been tested (ADR 0344).');
|
|
1039
1060
|
}
|
|
1040
|
-
return 0;
|
|
1061
|
+
if (!priceFlags.length) return 0;
|
|
1062
|
+
// The version is published whatever happens next; a price that fails is reported
|
|
1063
|
+
// with the one command that sets it, and the exit code says something needs doing.
|
|
1064
|
+
const priced = await cmdPrice([key, ...priceFlags], { log, errlog, request: priceRequest });
|
|
1065
|
+
if (priced !== 0) errlog(` The version is published, but its price was not set. Run \`bongos module price ${key} ${priceFlags.join(' ')}\` to set it.`);
|
|
1066
|
+
return priced === 0 ? 0 : 1;
|
|
1041
1067
|
}
|
|
1042
1068
|
|
|
1043
1069
|
// ---------------------------------------------------------------------------
|
|
1044
1070
|
// bongos module install <key> — acquire a module from the store (ADR 0338 D1)
|
|
1045
1071
|
// ---------------------------------------------------------------------------
|
|
1046
1072
|
|
|
1047
|
-
//
|
|
1048
|
-
//
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
// Lazy require — cli-lib's session/fetch plumbing must never load in the server
|
|
1052
|
-
// process, which requires this file too (the same reason cmdPublish gives).
|
|
1053
|
-
const { readSessionToken, dispatchedFetch, noKeepAliveDispatcher, CLI_USER_AGENT } = require('./cli-lib');
|
|
1054
|
-
const session = readSessionToken();
|
|
1055
|
-
if (!session) throw new Error('no Bongos session — run `bongos setup` first');
|
|
1056
|
-
const call = async (method, urlPath, { json, binary = false } = {}) => {
|
|
1057
|
-
const res = await dispatchedFetch(`${session.apiBase}/api/bongos${urlPath}`, {
|
|
1058
|
-
method,
|
|
1059
|
-
headers: {
|
|
1060
|
-
Authorization: `Bearer ${session.token}`, 'User-Agent': CLI_USER_AGENT,
|
|
1061
|
-
...(json ? { 'Content-Type': 'application/json' } : {}),
|
|
1062
|
-
},
|
|
1063
|
-
body: json ? JSON.stringify(json) : undefined,
|
|
1064
|
-
dispatcher: noKeepAliveDispatcher(),
|
|
1065
|
-
});
|
|
1066
|
-
if (binary && res.ok) return { ok: true, status: res.status, buf: Buffer.from(await res.arrayBuffer()) };
|
|
1067
|
-
const text = await res.text();
|
|
1068
|
-
let data;
|
|
1069
|
-
try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; }
|
|
1070
|
-
return { ok: res.ok, status: res.status, data };
|
|
1071
|
-
};
|
|
1072
|
-
// Where the store lives, so install can print a full link to the how-to page.
|
|
1073
|
-
call.apiBase = session.apiBase;
|
|
1074
|
-
return call;
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
// The hall page that shows a version's HOWTO.md (task 1004366) — the Modules tab,
|
|
1078
|
-
// opened on one version. null when the version shipped without one (published
|
|
1079
|
-
// before ADR 0347's gate), so install never prints a link that would 404.
|
|
1080
|
-
function howtoLink(key, version, files, apiBase) {
|
|
1081
|
-
if (!files.some((f) => f.path === 'HOWTO.md')) return null;
|
|
1082
|
-
const p = `/builders/modules?howto=${encodeURIComponent(key)}&version=${encodeURIComponent(version)}`;
|
|
1083
|
-
return apiBase ? `${String(apiBase).replace(/\/+$/, '')}${p}` : p;
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
function storeError(res) {
|
|
1087
|
-
const err = res.data && (res.data.error || res.data);
|
|
1088
|
-
return (err && (err.message || err.code)) || `HTTP ${res.status}`;
|
|
1089
|
-
}
|
|
1090
|
-
|
|
1091
|
-
// ---------------------------------------------------------------------------
|
|
1092
|
-
// bongos module store — what the store lists (task 1003811)
|
|
1093
|
-
// ---------------------------------------------------------------------------
|
|
1094
|
-
|
|
1095
|
-
const STORE_USAGE = 'usage: bongos module store [--sort <part>] [--max-cents N] [--max-credits N] [--json]';
|
|
1096
|
-
|
|
1097
|
-
// The query GET /store/modules takes, from argv. Returns { query } or { error }.
|
|
1098
|
-
// The server checks the values too; this only refuses what is not a value at all.
|
|
1099
|
-
function storeQuery(args) {
|
|
1100
|
-
const params = [];
|
|
1101
|
-
for (const [flag, param] of [['--sort', 'sort'], ['--max-cents', 'max_cents'], ['--max-credits', 'max_credits']]) {
|
|
1102
|
-
const i = args.indexOf(flag);
|
|
1103
|
-
if (i === -1) continue;
|
|
1104
|
-
const v = args[i + 1];
|
|
1105
|
-
if (!v || v.startsWith('--')) return { error: `${flag} needs a value` };
|
|
1106
|
-
params.push(`${param}=${encodeURIComponent(v)}`);
|
|
1107
|
-
}
|
|
1108
|
-
return { query: params.length ? `?${params.join('&')}` : '' };
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
function formatPrice(price) {
|
|
1112
|
-
if (!price || price.free) return 'free';
|
|
1113
|
-
const parts = [];
|
|
1114
|
-
if (price.cents != null) parts.push(`$${(price.cents / 100).toFixed(2)}`);
|
|
1115
|
-
if (price.credits != null) parts.push(`${price.credits} credits`);
|
|
1116
|
-
return parts.join(' or ') || 'free';
|
|
1117
|
-
}
|
|
1118
|
-
|
|
1119
|
-
function formatPlace(category) {
|
|
1120
|
-
if (!category) return '';
|
|
1121
|
-
return category.label ? `${category.parent_title} › ${category.label}` : category.parent_title;
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
// Pure renderer over the GET /store/modules body. It keeps the server's order and
|
|
1125
|
-
// adds nothing to it: listed in rank, then New, never ranked.
|
|
1126
|
-
function formatStore(body) {
|
|
1127
|
-
const out = [];
|
|
1128
|
-
out.push('bongos module store — what the store lists, best first');
|
|
1129
|
-
out.push(` floor ${body.floor} out of 100 · at most ${body.cap} per sub-category`);
|
|
1130
|
-
out.push(` ${body.ranking}`);
|
|
1131
|
-
if (body.sort) {
|
|
1132
|
-
const part = (body.sort_parts || []).find((x) => x.part === body.sort);
|
|
1133
|
-
out.push(` sorted by ${part ? part.title : body.sort}`);
|
|
1134
|
-
}
|
|
1135
|
-
const max = body.max_price;
|
|
1136
|
-
if (max) out.push(` at most ${[max.cents != null ? `$${(max.cents / 100).toFixed(2)}` : null, max.credits != null ? `${max.credits} credits` : null].filter(Boolean).join(' or ')}`);
|
|
1137
|
-
out.push('');
|
|
1138
|
-
const listed = body.listed || [];
|
|
1139
|
-
if (!listed.length) out.push(' Nothing is listed yet.');
|
|
1140
|
-
listed.forEach((m, i) => {
|
|
1141
|
-
out.push(` ${String(i + 1).padStart(2)}. ${m.key}@${m.version} ${m.overall}/100 ${formatPrice(m.price)}${m.category ? ` ${formatPlace(m.category)}` : ''}`);
|
|
1142
|
-
});
|
|
1143
|
-
const fresh = body.new || [];
|
|
1144
|
-
if (fresh.length) {
|
|
1145
|
-
out.push('');
|
|
1146
|
-
out.push(' New — not ranked until projects have installed and used them:');
|
|
1147
|
-
for (const m of fresh) out.push(` ${m.key}@${m.version} ${formatPrice(m.price)}${m.category ? ` ${formatPlace(m.category)}` : ''}`);
|
|
1148
|
-
}
|
|
1149
|
-
out.push('');
|
|
1150
|
-
out.push(' Why a module is not here: GET /store/modules/<key>/listing');
|
|
1151
|
-
return out.join('\n');
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
async function cmdStore(args, { log = console.log, errlog = console.error, request } = {}) {
|
|
1155
|
-
const q = storeQuery(args);
|
|
1156
|
-
if (q.error) { errlog(`bongos module store: ${q.error}`); errlog(STORE_USAGE); return 2; }
|
|
1157
|
-
let call;
|
|
1158
|
-
try { call = request || storeRequest(); }
|
|
1159
|
-
catch (e) { errlog(`bongos module store: ${e.message}`); return 1; }
|
|
1160
|
-
let res;
|
|
1161
|
-
try { res = await call('GET', `/store/modules${q.query}`); }
|
|
1162
|
-
catch (e) { errlog(`bongos module store: the store could not be reached — ${e.message}`); return 1; }
|
|
1163
|
-
if (!res.ok) { errlog(`bongos module store: the store answered ${storeError(res)}`); return res.status === 400 ? 2 : 1; }
|
|
1164
|
-
if (args.includes('--json')) { log(JSON.stringify(res.data, null, 2)); return 0; }
|
|
1165
|
-
log(formatStore(res.data || {}));
|
|
1166
|
-
return 0;
|
|
1073
|
+
// `bongos module remove` lives in its own file (task 1004896); this hands it the
|
|
1074
|
+
// catalog and the store client it needs.
|
|
1075
|
+
function cmdRemove(args, deps = {}) {
|
|
1076
|
+
return require('./module-remove').cmdRemove(args, { gather: gatherCatalog, storeRequest, storeError, ...deps });
|
|
1167
1077
|
}
|
|
1168
1078
|
|
|
1169
1079
|
// Where install places a module: the INSTANCE's own modules/ root (ADR 0108 §1),
|
|
@@ -1427,13 +1337,16 @@ function printHelp(out = console.log) {
|
|
|
1427
1337
|
out(' Browse the module catalog — what you can enable, with author + origin credit');
|
|
1428
1338
|
out(' bongos module store [--sort <part>] [--max-cents N] [--max-credits N] [--json]');
|
|
1429
1339
|
out(' What the store lists, best first: above the quality floor, ranked, ten per sub-category');
|
|
1340
|
+
out(' bongos module price <key> [--cents N] [--credits N]');
|
|
1341
|
+
out(' Set your module\'s price in the store (0 in either makes it free)');
|
|
1342
|
+
out(' bongos module remove <key> Take a store module off this project (switch it off first; its folder is kept aside)');
|
|
1430
1343
|
out(' bongos upgrade [--no-store] Pre-check enabled modules against the core version (fail-closed),');
|
|
1431
1344
|
out(' then list newer store versions of installed modules (never blocks)');
|
|
1432
1345
|
out(' bongos module check <key> [--sign-off "Name <email>"]');
|
|
1433
1346
|
out(' AGPL/publish pre-check before upstreaming + record a DCO-style sign-off');
|
|
1434
1347
|
out(' bongos module submit <key> [--sign-off "Name <email>"]');
|
|
1435
1348
|
out(' Pre-check + sign-off, then file into the rank-gated upstreaming review queue');
|
|
1436
|
-
out(' bongos module publish <key> [--dry-run] [--out <dir>]');
|
|
1349
|
+
out(' bongos module publish <key> [--dry-run] [--out <dir>] [--cents N] [--credits N]');
|
|
1437
1350
|
out(' Pack a module version (tarball + hash manifest) and upload it to the module store');
|
|
1438
1351
|
out(' bongos module install <key> [--version X.Y.Z]');
|
|
1439
1352
|
out(' Acquire a module from the store, verify it and place it under modules/ (not enabled)');
|
|
@@ -1447,6 +1360,8 @@ async function main(argv) {
|
|
|
1447
1360
|
if (sub === 'new') return cmdNew(args.slice(1));
|
|
1448
1361
|
if (sub === 'list' || sub === 'ls') return cmdList(args.slice(1));
|
|
1449
1362
|
if (sub === 'store') return cmdStore(args.slice(1));
|
|
1363
|
+
if (sub === 'price') return cmdPrice(args.slice(1));
|
|
1364
|
+
if (sub === 'remove') return cmdRemove(args.slice(1));
|
|
1450
1365
|
if (sub === 'upgrade') return cmdUpgrade(args.slice(1));
|
|
1451
1366
|
if (sub === 'check') return cmdCheck(args.slice(1));
|
|
1452
1367
|
if (sub === 'submit') return cmdSubmit(args.slice(1));
|
|
@@ -1463,7 +1378,7 @@ async function main(argv) {
|
|
|
1463
1378
|
}
|
|
1464
1379
|
|
|
1465
1380
|
module.exports = {
|
|
1466
|
-
main, cmdNew, cmdList, cmdStore, formatStore, cmdUpgrade, cmdCheck, cmdSubmit, cmdPublish, cmdInstall, cmdUpdate, printHelp,
|
|
1381
|
+
main, cmdNew, cmdList, cmdStore, formatStore, cmdPrice, cmdRemove, cmdUpgrade, cmdCheck, cmdSubmit, cmdPublish, cmdInstall, cmdUpdate, printHelp,
|
|
1467
1382
|
storeRequest,
|
|
1468
1383
|
gatherCatalog, formatCatalog, ownProjectName,
|
|
1469
1384
|
scaffoldModule, gatherUpgrade, evaluateUpgrade, gatherUpdates, evaluateUpdates, buildManifest,
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// scripts/gds/module-remove.js — @bongos-forwarder (task 1004848). Generated by
|
|
2
|
+
// scripts/bongos/codemod-rename-scripts-gds.js; do not edit.
|
|
3
|
+
// The real script is scripts/bongos/module-remove.js. This forwarder keeps callers that still name
|
|
4
|
+
// the old path working, and warns once so they get repointed. Task 1003706 removes it.
|
|
5
|
+
'use strict';
|
|
6
|
+
require('../bongos/forwarder.js').forward(module, 'module-remove.js');
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// scripts/gds/module-store-cli.js — @bongos-forwarder (task 1004848). Generated by
|
|
2
|
+
// scripts/bongos/codemod-rename-scripts-gds.js; do not edit.
|
|
3
|
+
// The real script is scripts/bongos/module-store-cli.js. This forwarder keeps callers that still name
|
|
4
|
+
// the old path working, and warns once so they get repointed. Task 1003706 removes it.
|
|
5
|
+
'use strict';
|
|
6
|
+
require('../bongos/forwarder.js').forward(module, 'module-store-cli.js');
|
|
@@ -373,9 +373,9 @@ function firstTimeAdmissionBlocked({ existing, approved, login }) {
|
|
|
373
373
|
// existing request-access page rather than a spinner that never resolves.
|
|
374
374
|
//
|
|
375
375
|
// NB the onboarding module's public GET /access-requests/status (used by the
|
|
376
|
-
// CLI) reports member|invited|
|
|
377
|
-
// firstAdmin bypass or open enrollment — so it reports the OWNER as
|
|
378
|
-
// forever. That is exactly why this auth-domain probe exists.
|
|
376
|
+
// CLI) reports member|invited|not_admitted but knows nothing about the
|
|
377
|
+
// firstAdmin bypass or open enrollment — so it reports the OWNER as
|
|
378
|
+
// 'not_admitted' forever. That is exactly why this auth-domain probe exists.
|
|
379
379
|
async function webAdmissionStatus(login) {
|
|
380
380
|
const name = String(login || '').trim();
|
|
381
381
|
if (!name) return { admitted: false, expectedSoon: false };
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
// by BOTH routes that answer "does this GitHub login
|
|
39
39
|
// have an account here?": the onboarding module's
|
|
40
40
|
// GET /access-requests/status (the richer
|
|
41
|
-
// member|invited|
|
|
41
|
+
// member|invited|not_admitted) and core's
|
|
42
42
|
// GET /auth/web/admission-status (its boolean twin).
|
|
43
43
|
// Task 1003339 / ADR 0209. They are ONE oracle read
|
|
44
44
|
// two ways, so they share one budget — give each its
|
|
@@ -152,6 +152,15 @@ function priceOf(row) {
|
|
|
152
152
|
return { cents, credits, free: !row || cents === 0 || credits === 0 };
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
// Whether a module can be bought today. Buying arrives with task 1003788 (area 8's
|
|
156
|
+
// wallet); until then only a free module can be had, and a paid one is listed with
|
|
157
|
+
// its price and marked not yet buyable (owner, 2026-10-26). Flip BUYING_OPEN when
|
|
158
|
+
// the buy action ships, and nothing else needs to change. Pure.
|
|
159
|
+
const BUYING_OPEN = false;
|
|
160
|
+
function buyableNow(price) {
|
|
161
|
+
return !!(price && (price.free || BUYING_OPEN));
|
|
162
|
+
}
|
|
163
|
+
|
|
155
164
|
// Within a buyer's maximum ({ cents, credits }, either may be null): a free module
|
|
156
165
|
// always is; a priced one is when it is offered at or under a cap the buyer set. Pure.
|
|
157
166
|
function withinMaxPrice(price, max) {
|
|
@@ -228,7 +237,7 @@ function newestPerModule(rows) {
|
|
|
228
237
|
}
|
|
229
238
|
|
|
230
239
|
// The catalog: { floor, cap, ranking, sort, sort_parts, max_price, listed: [...], new: [...] },
|
|
231
|
-
// each entry { key, title, version, published_at, price, category, overall, is_new,
|
|
240
|
+
// each entry { key, title, version, published_at, price, buyable, category, overall, is_new,
|
|
232
241
|
// parts, strengths, weaknesses, unmeasured } (the last four from readParts). Excluded
|
|
233
242
|
// versions are left out entirely: a module below the bar is not listed at all, nor
|
|
234
243
|
// is one past the cap of its sub-category, nor one over the buyer's `maxPrice`.
|
|
@@ -291,9 +300,10 @@ async function buildCatalog({ db, floor = QUALITY_FLOOR, sort = null, maxPrice =
|
|
|
291
300
|
const score = scores.get(String(r.version_id)) || null;
|
|
292
301
|
const { placement } = placeVersion(score, floor);
|
|
293
302
|
if (placement === 'excluded') continue;
|
|
303
|
+
const price = priceOf(prices.get(r.module_key));
|
|
294
304
|
out[placement].push({
|
|
295
305
|
key: r.module_key, title: r.title, version: r.version, published_at: r.published_at,
|
|
296
|
-
price:
|
|
306
|
+
price, category: categoryOf(cats.get(r.module_key)), buyable: buyableNow(price),
|
|
297
307
|
overall: placement === 'new' ? null : score.overall, is_new: placement === 'new',
|
|
298
308
|
...readParts(signals.get(String(r.version_id)) || [], { floor }),
|
|
299
309
|
});
|
|
@@ -383,6 +383,54 @@ async function latestVersions(keys, { db, channelsFor = null } = {}) {
|
|
|
383
383
|
return out;
|
|
384
384
|
}
|
|
385
385
|
|
|
386
|
+
// Set a module's price (task 1004894, criterion wa5-module-economy). Only its
|
|
387
|
+
// author may, and a delisted module takes no new price (it can no longer be
|
|
388
|
+
// acquired). The price is a HISTORY: each change appends a row and the newest is
|
|
389
|
+
// current, which is what the catalog (module-listing.js priceOf) and acquire
|
|
390
|
+
// (isFreePrice above) already read. `cents` / `credits`: a whole number of 0 or
|
|
391
|
+
// more, or null for "not offered in that currency"; at least one must be given
|
|
392
|
+
// (core_257's CHECK), and 0 in either is free. A paid price may be set before
|
|
393
|
+
// buying exists (owner, 2026-10-26): the module is listed with it and marked not yet
|
|
394
|
+
// buyable, and acquire keeps refusing it without an entitlement. Price is never an
|
|
395
|
+
// input to the score (ADR 0343 D6). The history IS the audit of price changes:
|
|
396
|
+
// every row names who set it and when, and the row before it is what it changed
|
|
397
|
+
// from (the audit middleware also logs the request). One transaction, the module row
|
|
398
|
+
// locked FOR UPDATE as publishVersion does, so two changes at once each report the
|
|
399
|
+
// price they really replaced. Returns { ok: true, previous, price } (each
|
|
400
|
+
// { cents, credits } or null) or { ok: false, status, code, message }.
|
|
401
|
+
async function setPrice({ key, cents = null, credits = null, builderId }, { db } = {}) {
|
|
402
|
+
const pool = db || require('./pool').pool;
|
|
403
|
+
const refuse = (status, code, message) => ({ ok: false, status, code, message });
|
|
404
|
+
const client = await pool.connect();
|
|
405
|
+
try {
|
|
406
|
+
await client.query('BEGIN');
|
|
407
|
+
const { rows: [mod] } = await client.query(
|
|
408
|
+
'SELECT module_key, author_id, status FROM store_modules WHERE module_key = $1 FOR UPDATE', [key]);
|
|
409
|
+
let out;
|
|
410
|
+
if (!mod) out = refuse(404, 'module_not_found', `The store has no module "${key}".`);
|
|
411
|
+
else if (String(mod.author_id) !== String(builderId)) out = refuse(403, 'not_module_author', `Only the author of "${key}" may set its price.`);
|
|
412
|
+
else if (mod.status === 'delisted') out = refuse(409, 'module_delisted', `"${key}" is delisted: it takes no new price.`);
|
|
413
|
+
if (out) { await client.query('ROLLBACK'); return out; }
|
|
414
|
+
const { rows: [prev] } = await client.query(
|
|
415
|
+
`SELECT price_cents, price_credits FROM store_module_prices
|
|
416
|
+
WHERE module_key = $1 ORDER BY set_at DESC, id DESC LIMIT 1`, [key]);
|
|
417
|
+
await client.query(
|
|
418
|
+
'INSERT INTO store_module_prices (module_key, price_cents, price_credits, set_by) VALUES ($1, $2, $3, $4)',
|
|
419
|
+
[key, cents, credits, builderId]);
|
|
420
|
+
await client.query('COMMIT');
|
|
421
|
+
return {
|
|
422
|
+
ok: true,
|
|
423
|
+
previous: prev ? { cents: prev.price_cents, credits: prev.price_credits } : null,
|
|
424
|
+
price: { cents, credits },
|
|
425
|
+
};
|
|
426
|
+
} catch (e) {
|
|
427
|
+
try { await client.query('ROLLBACK'); } catch { /* the connection may be gone */ }
|
|
428
|
+
throw e;
|
|
429
|
+
} finally {
|
|
430
|
+
client.release();
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
386
434
|
// Withdraw one version (task 1004895, criterion wa5-staged-rollout): the store
|
|
387
435
|
// stops handing it out. Staged rollout catches a bad version on the tester channel;
|
|
388
436
|
// this is the way back once one has reached general. It is one channel row,
|
|
@@ -468,5 +516,5 @@ function versionArtifactFile(row, { dir = storeDir() } = {}) {
|
|
|
468
516
|
module.exports = {
|
|
469
517
|
stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion,
|
|
470
518
|
isFreePrice, servedChannels, resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
|
|
471
|
-
delistModule, delistedKeySet, withdrawVersion, withdrawnVersions, storeDir, storeDirFor, migrateLegacyStore,
|
|
519
|
+
delistModule, delistedKeySet, setPrice, withdrawVersion, withdrawnVersions, storeDir, storeDirFor, migrateLegacyStore,
|
|
472
520
|
};
|
|
@@ -250,6 +250,9 @@ const EXPECTED_RANKS = {
|
|
|
250
250
|
// Delisting a store module (task 1003797, ADR 0338 D2) stops every new install of
|
|
251
251
|
// it for every instance, so it is pinned to its own atom.
|
|
252
252
|
'POST /store/modules/:key/delist': 'perm:module.delist',
|
|
253
|
+
// Setting a store module's price (task 1004894) rides the publish atom; only the
|
|
254
|
+
// module's author passes the check inside the handler.
|
|
255
|
+
'POST /store/modules/:key/price': 'perm:module.submit',
|
|
253
256
|
// Releasing a version the promotion gate held on tester (task 1003804) hands it to
|
|
254
257
|
// every project; the owner put it on the delist atom (2026-10-23), so it is pinned.
|
|
255
258
|
'POST /store/modules/:key/versions/:version/release': 'perm:module.delist',
|
|
@@ -828,19 +828,15 @@ module.exports = function buildAuthRouter() {
|
|
|
828
828
|
// bounded the honest client, not an attacker. accountExistenceReadRateLimit
|
|
829
829
|
// below is the ceiling — 120 reads/60s per IP, and the SAME instance is
|
|
830
830
|
// mounted on the onboarding module's GET /access-requests/status, which is
|
|
831
|
-
// this route's strictly richer twin (member|invited|
|
|
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, 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.
|
|
84
|
+
const CORE_VERSION = '1.23.7'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
85
85
|
|
|
86
86
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
87
87
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
//
|
|
4
4
|
// The hole this pins shut: two PUBLIC routes answer "does this GitHub login have
|
|
5
5
|
// an account here?" — the onboarding module's GET /access-requests/status (the
|
|
6
|
-
//
|
|
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
|
|
@@ -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' });
|