@bongos/core 1.19.1077 → 1.19.1079
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 +45 -25
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +10 -0
- package/clients/bongos-client/index.cjs +10 -0
- package/clients/bongos-client/index.d.ts +15 -0
- package/clients/bongos-client/index.mjs +10 -0
- package/docs/api/openapi.json +338 -3
- package/docs/api-reference.md +7 -2
- package/docs/architecture.md +17 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +10 -0
- package/migrations/core_259_module_entitlements.sql +58 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/module-artifact.js +9 -2
- package/scripts/gds/module.js +142 -1
- package/src/bongos/module-entitlements.js +127 -0
- package/src/bongos/module-store.js +55 -0
- package/src/bongos/routes/modules.js +147 -1
- package/src/module-api.js +1 -1
- package/tests/module_entitlements.mjs +204 -0
- package/tests/module_install.mjs +224 -0
package/scripts/gds/module.js
CHANGED
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
// bongos module list [--catalog] browse the module catalog (author + origin credit)
|
|
61
61
|
// bongos upgrade pre-check enabled modules vs the core version
|
|
62
62
|
// bongos module check <key> [--sign-off "Name <email>"] AGPL/publish pre-check + DCO sign-off
|
|
63
|
+
// bongos module install <key> [--version X.Y.Z] acquire from the store, verify, place (not enabled)
|
|
63
64
|
|
|
64
65
|
const fs = require('node:fs');
|
|
65
66
|
const path = require('node:path');
|
|
@@ -862,6 +863,143 @@ async function cmdPublish(args, {
|
|
|
862
863
|
return 0;
|
|
863
864
|
}
|
|
864
865
|
|
|
866
|
+
// ---------------------------------------------------------------------------
|
|
867
|
+
// bongos module install <key> — acquire a module from the store (ADR 0338 D1)
|
|
868
|
+
// ---------------------------------------------------------------------------
|
|
869
|
+
|
|
870
|
+
// The store half of the CLI, over three calls (src/bongos/routes/modules.js):
|
|
871
|
+
// acquire (take the entitlement, learn the version), download the tarball, and
|
|
872
|
+
// once the files are placed, record the version acquired. Injected in tests.
|
|
873
|
+
function storeRequest() {
|
|
874
|
+
// Lazy require — cli-lib's session/fetch plumbing must never load in the server
|
|
875
|
+
// process, which requires this file too (the same reason cmdPublish gives).
|
|
876
|
+
const { readSessionToken, dispatchedFetch, noKeepAliveDispatcher, CLI_USER_AGENT } = require('./cli-lib');
|
|
877
|
+
const session = readSessionToken();
|
|
878
|
+
if (!session) throw new Error('no Bongos session — run `bongos setup` first');
|
|
879
|
+
return async (method, urlPath, { json, binary = false } = {}) => {
|
|
880
|
+
const res = await dispatchedFetch(`${session.apiBase}/api/bongos${urlPath}`, {
|
|
881
|
+
method,
|
|
882
|
+
headers: {
|
|
883
|
+
Authorization: `Bearer ${session.token}`, 'User-Agent': CLI_USER_AGENT,
|
|
884
|
+
...(json ? { 'Content-Type': 'application/json' } : {}),
|
|
885
|
+
},
|
|
886
|
+
body: json ? JSON.stringify(json) : undefined,
|
|
887
|
+
dispatcher: noKeepAliveDispatcher(),
|
|
888
|
+
});
|
|
889
|
+
if (binary && res.ok) return { ok: true, status: res.status, buf: Buffer.from(await res.arrayBuffer()) };
|
|
890
|
+
const text = await res.text();
|
|
891
|
+
let data;
|
|
892
|
+
try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; }
|
|
893
|
+
return { ok: res.ok, status: res.status, data };
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function storeError(res) {
|
|
898
|
+
const err = res.data && (res.data.error || res.data);
|
|
899
|
+
return (err && (err.message || err.code)) || `HTTP ${res.status}`;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// Where install places a module: the INSTANCE's own modules/ root (ADR 0108 §1),
|
|
903
|
+
// which the loader discovers. In a single checkout that is this repo's modules/.
|
|
904
|
+
function instanceModulesDir() {
|
|
905
|
+
return path.join(require('../../src/instance-config').resolveInstanceRoot(), 'modules');
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// Write the verified files into a private staging dir beside the target, then
|
|
909
|
+
// rename it into place, so a crash leaves a staging dir to delete, never a
|
|
910
|
+
// half-written module the loader would try to mount.
|
|
911
|
+
function placeModule(files, target) {
|
|
912
|
+
const staging = `${target}.installing-${process.pid}-${Date.now()}`;
|
|
913
|
+
fs.mkdirSync(staging, { recursive: true });
|
|
914
|
+
try {
|
|
915
|
+
for (const f of files) {
|
|
916
|
+
const dest = path.join(staging, ...f.path.split('/'));
|
|
917
|
+
if (!dest.startsWith(staging + path.sep)) throw new Error(`"${f.path}" would land outside the module`);
|
|
918
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
919
|
+
fs.writeFileSync(dest, f.buf, { mode: parseInt(f.mode, 8) || 0o644 });
|
|
920
|
+
}
|
|
921
|
+
fs.renameSync(staging, target);
|
|
922
|
+
} catch (e) {
|
|
923
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
924
|
+
throw e;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// `bongos module install <key> [--version X.Y.Z]` — task 1003785. Acquires the
|
|
929
|
+
// module (a free one is granted on the spot; a priced one waits on the buy action,
|
|
930
|
+
// area 8), downloads its tarball, verifies it with verifyModuleArtifact — the one
|
|
931
|
+
// checker the store itself uses on publish — and places it under the instance's
|
|
932
|
+
// modules/. It never enables: the loader discovers what is on disk and the hall's
|
|
933
|
+
// Modules tab decides what mounts. A module already on disk is refused; moving an
|
|
934
|
+
// installed module to a newer version is `update` (task 1003786).
|
|
935
|
+
async function cmdInstall(args, {
|
|
936
|
+
log = console.log, errlog = console.error,
|
|
937
|
+
modulesDir, request, core,
|
|
938
|
+
} = {}) {
|
|
939
|
+
const usage = 'usage: bongos module install <key> [--version X.Y.Z]';
|
|
940
|
+
const vIdx = args.indexOf('--version');
|
|
941
|
+
const wanted = vIdx !== -1 ? args[vIdx + 1] : null;
|
|
942
|
+
const key = args.find((a, i) => !a.startsWith('-') && args[i - 1] !== '--version');
|
|
943
|
+
if (!key || (vIdx !== -1 && (!wanted || wanted.startsWith('--')))) { errlog(usage); return 2; }
|
|
944
|
+
if (!KEY_RE.test(key)) { errlog(`REFUSING: "${key}" is not a module key (lowercase kebab-case).`); return 2; }
|
|
945
|
+
const { parseVersion, satisfies } = require('../../src/module-loader/semver');
|
|
946
|
+
if (wanted && !parseVersion(wanted)) { errlog(`REFUSING: --version must be exact X.Y.Z, not "${wanted}".`); return 2; }
|
|
947
|
+
|
|
948
|
+
const dir = modulesDir || instanceModulesDir();
|
|
949
|
+
const target = path.join(dir, key);
|
|
950
|
+
if (fs.existsSync(target)) {
|
|
951
|
+
errlog(`REFUSING: "${key}" is already on disk at ${target}. Install only adds a new module; updating one is a separate command.`);
|
|
952
|
+
return 1;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
let call;
|
|
956
|
+
try { call = request || storeRequest(); }
|
|
957
|
+
catch (e) { errlog(`bongos module install: ${e.message}`); return 1; }
|
|
958
|
+
|
|
959
|
+
const enc = encodeURIComponent(key);
|
|
960
|
+
let acq;
|
|
961
|
+
try { acq = await call('POST', `/store/modules/${enc}/acquire`, { json: wanted ? { version: wanted } : {} }); }
|
|
962
|
+
catch (e) { errlog(`bongos module install: the store could not be reached — ${e.message}`); return 1; }
|
|
963
|
+
if (!acq.ok) { errlog(`REFUSING: the store would not hand over "${key}" (HTTP ${acq.status}): ${storeError(acq)}`); return 1; }
|
|
964
|
+
const a = acq.data;
|
|
965
|
+
log(`bongos module install — "${key}" ${a.version} (needs core ${a.core_version})`);
|
|
966
|
+
|
|
967
|
+
const coreV = core || require('../../src/module-api').CORE_VERSION;
|
|
968
|
+
if (!satisfies(coreV, a.core_version)) {
|
|
969
|
+
errlog(`REFUSING: "${key}" ${a.version} needs core ${a.core_version}; this core is ${coreV}, so it could not mount.`);
|
|
970
|
+
return 1;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
let dl;
|
|
974
|
+
try { dl = await call('GET', `/store/modules/${enc}/versions/${encodeURIComponent(a.version)}/tarball`, { binary: true }); }
|
|
975
|
+
catch (e) { errlog(`bongos module install: download failed — ${e.message}`); return 1; }
|
|
976
|
+
if (!dl.ok) { errlog(`REFUSING: the download was refused (HTTP ${dl.status}): ${storeError(dl)}`); return 1; }
|
|
977
|
+
|
|
978
|
+
const artifact = require('./module-artifact');
|
|
979
|
+
const v = await artifact.verifyModuleArtifact(dl.buf, { key, includeFiles: true });
|
|
980
|
+
if (!v.ok) { errlog(`REFUSING: the downloaded tarball failed verification (${v.code}): ${v.message}`); return 1; }
|
|
981
|
+
if (v.version !== a.version || v.tarballSha256 !== a.tarball_sha256 || v.treeSha256 !== a.tree_sha256) {
|
|
982
|
+
errlog(`REFUSING: the download is not the ${a.version} the store described (version or hash differs).`);
|
|
983
|
+
return 1;
|
|
984
|
+
}
|
|
985
|
+
log(` ✓ verified ${v.files.length} file(s), ${v.bytes} bytes — tree ${v.treeSha256.slice(0, 12)}…`);
|
|
986
|
+
|
|
987
|
+
try { placeModule(v.files, target); }
|
|
988
|
+
catch (e) { errlog(`bongos module install: could not place the module — ${e.message}`); return 1; }
|
|
989
|
+
log(` ✓ placed at ${target}`);
|
|
990
|
+
|
|
991
|
+
let rec;
|
|
992
|
+
try { rec = await call('POST', `/store/modules/${enc}/acquired`, { json: { version: a.version } }); }
|
|
993
|
+
catch (e) { rec = { ok: false, status: 0, data: { error: { message: e.message } } }; }
|
|
994
|
+
if (!rec.ok) {
|
|
995
|
+
errlog(`WARNING: "${key}" is installed, but the store did not record the version (${storeError(rec)}). Nothing is lost; the next install or update records it.`);
|
|
996
|
+
return 1;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
log(`\n✓ Installed "${key}" ${a.version}. It is not switched on: enable it from the hall's Modules tab when you want it.`);
|
|
1000
|
+
return 0;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
865
1003
|
function printHelp(out = console.log) {
|
|
866
1004
|
out('bongos module — scaffold + manage Cloud Bongos modules\n');
|
|
867
1005
|
out('Usage:');
|
|
@@ -875,6 +1013,8 @@ function printHelp(out = console.log) {
|
|
|
875
1013
|
out(' Pre-check + sign-off, then file into the rank-gated upstreaming review queue');
|
|
876
1014
|
out(' bongos module publish <key> [--dry-run] [--out <dir>]');
|
|
877
1015
|
out(' Pack a module version (tarball + hash manifest) and upload it to the module store');
|
|
1016
|
+
out(' bongos module install <key> [--version X.Y.Z]');
|
|
1017
|
+
out(' Acquire a module from the store, verify it and place it under modules/ (not enabled)');
|
|
878
1018
|
}
|
|
879
1019
|
|
|
880
1020
|
async function main(argv) {
|
|
@@ -886,6 +1026,7 @@ async function main(argv) {
|
|
|
886
1026
|
if (sub === 'check') return cmdCheck(args.slice(1));
|
|
887
1027
|
if (sub === 'submit') return cmdSubmit(args.slice(1));
|
|
888
1028
|
if (sub === 'publish') return cmdPublish(args.slice(1));
|
|
1029
|
+
if (sub === 'install') return cmdInstall(args.slice(1));
|
|
889
1030
|
if (!sub || sub === 'help' || sub === '--help' || sub === '-h') {
|
|
890
1031
|
printHelp();
|
|
891
1032
|
return sub ? 0 : 2;
|
|
@@ -896,7 +1037,7 @@ async function main(argv) {
|
|
|
896
1037
|
}
|
|
897
1038
|
|
|
898
1039
|
module.exports = {
|
|
899
|
-
main, cmdNew, cmdList, cmdUpgrade, cmdCheck, cmdSubmit, cmdPublish, printHelp,
|
|
1040
|
+
main, cmdNew, cmdList, cmdUpgrade, cmdCheck, cmdSubmit, cmdPublish, cmdInstall, printHelp,
|
|
900
1041
|
gatherCatalog, formatCatalog, ownProjectName,
|
|
901
1042
|
scaffoldModule, gatherUpgrade, evaluateUpgrade, buildManifest,
|
|
902
1043
|
formatMaintenanceHint, isPastRemoveAfter,
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// src/bongos/module-entitlements.js — the entitlement record: who holds which
|
|
2
|
+
// module, at which version (task 1003813, ADR 0338 D1; table core_259).
|
|
3
|
+
//
|
|
4
|
+
// This is the ONE place install, update and the telemetry signals ask "does this
|
|
5
|
+
// holder own this module?" — the store checks it before it serves a tarball. It is
|
|
6
|
+
// the record of ownership, not of payment: a grant needs no money to have moved,
|
|
7
|
+
// so free and core-bundled modules hold an entitlement through the same rows a
|
|
8
|
+
// paid one will (area 8 writes source 'purchase' + payment_ref when it lands).
|
|
9
|
+
//
|
|
10
|
+
// Rules the functions enforce, from ADR 0338 D2:
|
|
11
|
+
// - ownership is per MODULE, the acquired version moves forward under it, so an
|
|
12
|
+
// update never asks for a second purchase;
|
|
13
|
+
// - a delist never reaches this table — only revokeEntitlement ends a hold (the
|
|
14
|
+
// refund path, task 1003789), and it keeps the row rather than deleting it.
|
|
15
|
+
//
|
|
16
|
+
// Every function takes { db } (a pg Pool or anything with query()) so tests run
|
|
17
|
+
// against a fake client, the way module-store.js does.
|
|
18
|
+
|
|
19
|
+
const { parseVersion, cmp } = require('../module-loader/semver');
|
|
20
|
+
const { KEY_RE } = require('../module-loader/manifest-schema');
|
|
21
|
+
|
|
22
|
+
const HOLDER_KINDS = ['builder', 'instance'];
|
|
23
|
+
const SOURCES = ['free', 'core', 'grant', 'purchase'];
|
|
24
|
+
|
|
25
|
+
const COLS = `id, module_key, holder_kind, holder_ref, source, payment_ref, version,
|
|
26
|
+
acquired_at, granted_by, granted_at, revoked_at, revoked_reason`;
|
|
27
|
+
|
|
28
|
+
function poolOf(db) { return db || require('./pool').pool; }
|
|
29
|
+
|
|
30
|
+
// A bad argument is a programming error in the caller, not a user error: throw.
|
|
31
|
+
function assertHolder(moduleKey, holderKind, holderRef) {
|
|
32
|
+
if (!KEY_RE.test(String(moduleKey))) throw new Error('entitlement: a bad module key');
|
|
33
|
+
if (!HOLDER_KINDS.includes(holderKind)) throw new Error(`entitlement: holder_kind must be one of ${HOLDER_KINDS.join(', ')}`);
|
|
34
|
+
if (typeof holderRef !== 'string' || !holderRef.length) throw new Error('entitlement: holder_ref is required');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Give a holder a module. Idempotent: granting a holder that already holds it is a
|
|
38
|
+
// no-op that returns the existing row (an earlier source is never rewritten), and
|
|
39
|
+
// granting one whose hold was revoked lifts the revocation on the same row.
|
|
40
|
+
// `source: 'purchase'` is area 8's to use; nothing here checks a payment.
|
|
41
|
+
async function grantEntitlement({ moduleKey, holderKind, holderRef, source, grantedBy = null, paymentRef = null }, { db } = {}) {
|
|
42
|
+
assertHolder(moduleKey, holderKind, holderRef);
|
|
43
|
+
if (!SOURCES.includes(source)) throw new Error(`entitlement: source must be one of ${SOURCES.join(', ')}`);
|
|
44
|
+
const pool = poolOf(db);
|
|
45
|
+
const { rows: [fresh] } = await pool.query(
|
|
46
|
+
`INSERT INTO module_entitlements (module_key, holder_kind, holder_ref, source, payment_ref, granted_by)
|
|
47
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
48
|
+
ON CONFLICT (module_key, holder_kind, holder_ref) DO NOTHING
|
|
49
|
+
RETURNING ${COLS}`,
|
|
50
|
+
[moduleKey, holderKind, holderRef, source, paymentRef, grantedBy]);
|
|
51
|
+
if (fresh) return { entitlement: fresh, created: true };
|
|
52
|
+
const { rows: [held] } = await pool.query(
|
|
53
|
+
`UPDATE module_entitlements
|
|
54
|
+
SET revoked_at = NULL, revoked_reason = NULL, source = $4, payment_ref = $5,
|
|
55
|
+
granted_by = $6, granted_at = now()
|
|
56
|
+
WHERE module_key = $1 AND holder_kind = $2 AND holder_ref = $3 AND revoked_at IS NOT NULL
|
|
57
|
+
RETURNING ${COLS}`,
|
|
58
|
+
[moduleKey, holderKind, holderRef, source, paymentRef, grantedBy]);
|
|
59
|
+
if (held) return { entitlement: held, created: false, reinstated: true };
|
|
60
|
+
const { rows: [same] } = await pool.query(
|
|
61
|
+
`SELECT ${COLS} FROM module_entitlements WHERE module_key = $1 AND holder_kind = $2 AND holder_ref = $3`,
|
|
62
|
+
[moduleKey, holderKind, holderRef]);
|
|
63
|
+
return { entitlement: same, created: false };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Record the exact version a holder acquired. Refuses a holder with no live
|
|
67
|
+
// entitlement (nothing is handed over without one) and a version older than the
|
|
68
|
+
// one already held (an update moves forward; a rollback is a separate act, so it
|
|
69
|
+
// must be asked for with allowDowngrade).
|
|
70
|
+
async function recordAcquired({ moduleKey, holderKind, holderRef, version, allowDowngrade = false }, { db } = {}) {
|
|
71
|
+
assertHolder(moduleKey, holderKind, holderRef);
|
|
72
|
+
const wanted = parseVersion(String(version));
|
|
73
|
+
if (!wanted) throw new Error('entitlement: version must be exact X.Y.Z');
|
|
74
|
+
const pool = poolOf(db);
|
|
75
|
+
const { rows: [cur] } = await pool.query(
|
|
76
|
+
`SELECT ${COLS} FROM module_entitlements
|
|
77
|
+
WHERE module_key = $1 AND holder_kind = $2 AND holder_ref = $3`,
|
|
78
|
+
[moduleKey, holderKind, holderRef]);
|
|
79
|
+
if (!cur || cur.revoked_at) return { ok: false, code: 'not_entitled' };
|
|
80
|
+
const held = cur.version && parseVersion(cur.version);
|
|
81
|
+
if (held && !allowDowngrade && cmp(wanted, held) < 0) return { ok: false, code: 'version_older_than_held' };
|
|
82
|
+
const { rows: [row] } = await pool.query(
|
|
83
|
+
`UPDATE module_entitlements SET version = $2, acquired_at = now()
|
|
84
|
+
WHERE id = $1 AND revoked_at IS NULL
|
|
85
|
+
RETURNING ${COLS}`,
|
|
86
|
+
[cur.id, version]);
|
|
87
|
+
return row ? { ok: true, entitlement: row } : { ok: false, code: 'not_entitled' };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// End a hold. Only the refund path calls this (task 1003789); a delist must not.
|
|
91
|
+
// The row stays, stamped with when and why.
|
|
92
|
+
async function revokeEntitlement({ moduleKey, holderKind, holderRef, reason }, { db } = {}) {
|
|
93
|
+
assertHolder(moduleKey, holderKind, holderRef);
|
|
94
|
+
if (typeof reason !== 'string' || !reason.trim()) throw new Error('entitlement: a revocation needs a reason');
|
|
95
|
+
const { rows: [row] } = await poolOf(db).query(
|
|
96
|
+
`UPDATE module_entitlements SET revoked_at = now(), revoked_reason = $4
|
|
97
|
+
WHERE module_key = $1 AND holder_kind = $2 AND holder_ref = $3 AND revoked_at IS NULL
|
|
98
|
+
RETURNING ${COLS}`,
|
|
99
|
+
[moduleKey, holderKind, holderRef, reason.trim()]);
|
|
100
|
+
return row ? { ok: true, entitlement: row } : { ok: false, code: 'not_entitled' };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The question the store asks before it serves: does this holder hold this module
|
|
104
|
+
// right now? `entitled` is false for no row and for a revoked row; the row itself
|
|
105
|
+
// comes back either way so the caller can say WHY.
|
|
106
|
+
async function checkEntitlement({ moduleKey, holderKind, holderRef }, { db } = {}) {
|
|
107
|
+
assertHolder(moduleKey, holderKind, holderRef);
|
|
108
|
+
const { rows: [row] } = await poolOf(db).query(
|
|
109
|
+
`SELECT ${COLS} FROM module_entitlements
|
|
110
|
+
WHERE module_key = $1 AND holder_kind = $2 AND holder_ref = $3`,
|
|
111
|
+
[moduleKey, holderKind, holderRef]);
|
|
112
|
+
return { entitled: !!row && !row.revoked_at, entitlement: row || null };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Everything one holder holds (live and revoked), newest grant first.
|
|
116
|
+
async function listEntitlements({ holderKind, holderRef }, { db } = {}) {
|
|
117
|
+
assertHolder('x', holderKind, holderRef);
|
|
118
|
+
const { rows } = await poolOf(db).query(
|
|
119
|
+
`SELECT ${COLS} FROM module_entitlements
|
|
120
|
+
WHERE holder_kind = $1 AND holder_ref = $2 ORDER BY granted_at DESC, id DESC`,
|
|
121
|
+
[holderKind, holderRef]);
|
|
122
|
+
return rows;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = {
|
|
126
|
+
grantEntitlement, recordAcquired, revokeEntitlement, checkEntitlement, listEntitlements,
|
|
127
|
+
};
|
|
@@ -136,6 +136,61 @@ async function publishVersion(input, { db, beforeCommit } = {}) {
|
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
// ---- the read side: what install acquires (task 1003785) ----------------------
|
|
140
|
+
//
|
|
141
|
+
// A module with NO price row is free. The owner decided this (2026-09-29): nothing
|
|
142
|
+
// sets a price yet and who may sell is still open, so an unpriced module is
|
|
143
|
+
// acquirable at no cost, and an entitlement taken then is kept if a price is added
|
|
144
|
+
// later (ADR 0338 D2). A priced module is free when either currency it is offered in
|
|
145
|
+
// is zero (0 = free, NULL = not offered in that currency; core_257).
|
|
146
|
+
function isFreePrice(priceRow) {
|
|
147
|
+
if (!priceRow) return true;
|
|
148
|
+
return priceRow.price_cents === 0 || priceRow.price_credits === 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Resolve what an acquire of `key` (optionally at an exact `version`) would hand
|
|
152
|
+
// over. Refuses an unknown or delisted module (ADR 0338 D2: a delisted module takes
|
|
153
|
+
// no new acquisitions and serves no further versions) and an unknown version. With
|
|
154
|
+
// no version asked for, the newest published version (semver order, not text).
|
|
155
|
+
// Returns { ok: true, module, version, price, free } or { ok: false, status, code, message }.
|
|
156
|
+
async function resolveAcquirable(key, { version = null, db } = {}) {
|
|
157
|
+
const pool = db || require('./pool').pool;
|
|
158
|
+
const refuse = (status, code, message) => ({ ok: false, status, code, message });
|
|
159
|
+
const { rows: [mod] } = await pool.query(
|
|
160
|
+
'SELECT module_key, title, author_id, status FROM store_modules WHERE module_key = $1', [key]);
|
|
161
|
+
if (!mod) return refuse(404, 'module_not_found', `The store has no module "${key}".`);
|
|
162
|
+
if (mod.status === 'delisted') return refuse(409, 'module_delisted', `"${key}" is delisted: it can no longer be acquired or downloaded (ADR 0338 D2).`);
|
|
163
|
+
const { rows: versions } = await pool.query(
|
|
164
|
+
`SELECT id, module_key, version, core_version, tree_sha256, tarball_sha256, tarball_bytes,
|
|
165
|
+
artifact_path, published_at
|
|
166
|
+
FROM store_module_versions WHERE module_key = $1`, [key]);
|
|
167
|
+
let row;
|
|
168
|
+
if (version) {
|
|
169
|
+
row = versions.find((r) => r.version === version);
|
|
170
|
+
if (!row) return refuse(404, 'version_not_found', `"${key}" has no published version ${version}.`);
|
|
171
|
+
} else {
|
|
172
|
+
row = versions.filter((r) => parseVersion(r.version))
|
|
173
|
+
.sort((a, b) => cmp(parseVersion(a.version), parseVersion(b.version))).pop();
|
|
174
|
+
if (!row) return refuse(404, 'no_versions', `"${key}" has no published version yet.`);
|
|
175
|
+
}
|
|
176
|
+
const { rows: [price] } = await pool.query(
|
|
177
|
+
`SELECT price_cents, price_credits, set_at FROM store_module_prices
|
|
178
|
+
WHERE module_key = $1 ORDER BY set_at DESC, id DESC LIMIT 1`, [key]);
|
|
179
|
+
return { ok: true, module: mod, version: row, price: price || null, free: isFreePrice(price) };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// The tarball on disk for a version row. artifact_path is relative to the store dir
|
|
183
|
+
// (relativeArtifactPath), and artifactPath() re-validates key + version, so a row
|
|
184
|
+
// can never point a read outside the store.
|
|
185
|
+
function versionArtifactFile(row, { dir = storeDir() } = {}) {
|
|
186
|
+
const file = artifactPath(row.module_key, row.version, { dir });
|
|
187
|
+
if (relativeArtifactPath(file, { dir }) !== row.artifact_path) {
|
|
188
|
+
throw new Error(`store row for ${row.module_key} ${row.version} names an unexpected artifact path`);
|
|
189
|
+
}
|
|
190
|
+
return file;
|
|
191
|
+
}
|
|
192
|
+
|
|
139
193
|
module.exports = {
|
|
140
194
|
stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion,
|
|
195
|
+
isFreePrice, resolveAcquirable, versionArtifactFile,
|
|
141
196
|
};
|
|
@@ -23,6 +23,15 @@
|
|
|
23
23
|
// server end of `bongos module publish`. Not the
|
|
24
24
|
// upstreaming door above: submit proposes a module
|
|
25
25
|
// INTO core; publish puts a version in the store.
|
|
26
|
+
// GET /store/entitlements the modules the caller holds, and at which version
|
|
27
|
+
// GET /store/modules/:key/entitlement
|
|
28
|
+
// does the caller hold this module (task 1003813).
|
|
29
|
+
// POST /store/modules/:key/acquire
|
|
30
|
+
// GET /store/modules/:key/versions/:version/tarball
|
|
31
|
+
// POST /store/modules/:key/acquired
|
|
32
|
+
// the server end of `bongos module install` (task
|
|
33
|
+
// 1003785): take the entitlement, download the
|
|
34
|
+
// checked tarball, record the version placed.
|
|
26
35
|
//
|
|
27
36
|
// Read is open to any signed-in builder (same gate as the atlas/primer pages);
|
|
28
37
|
// every write (enable/disable, submit) is metic+archon, same rank as
|
|
@@ -50,6 +59,7 @@
|
|
|
50
59
|
// cross-repo call to the extracted core repo): see
|
|
51
60
|
// docs/adr/0135-module-upstream-submission-interim-queue.md.
|
|
52
61
|
|
|
62
|
+
const fs = require('node:fs');
|
|
53
63
|
const express = require('express');
|
|
54
64
|
const auth = require('../auth');
|
|
55
65
|
const { asyncHandler, validateOrRespond, LIMITS } = require('./_helpers');
|
|
@@ -65,8 +75,14 @@ const modulesLib = require('../../modules');
|
|
|
65
75
|
const moduleCli = require('../../../scripts/gds/module');
|
|
66
76
|
const moduleSubmissions = require('../module-submissions');
|
|
67
77
|
const moduleArtifact = require('../../../scripts/gds/module-artifact');
|
|
68
|
-
const {
|
|
78
|
+
const { checkEntitlement, listEntitlements, grantEntitlement, recordAcquired } = require('../module-entitlements');
|
|
79
|
+
const {
|
|
80
|
+
stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion,
|
|
81
|
+
resolveAcquirable, versionArtifactFile,
|
|
82
|
+
} = require('../module-store');
|
|
69
83
|
const { KEY_RE } = require('../../module-loader/manifest-schema');
|
|
84
|
+
const { parseVersion } = require('../../module-loader/semver');
|
|
85
|
+
const log = require('../logger').logger.child({ src: 'modules' });
|
|
70
86
|
// Named imports, not a namespace object (task 1004052 strand walk): knip cannot follow
|
|
71
87
|
// property access on a CommonJS namespace, so reaching these through one made them read
|
|
72
88
|
// as unused exports and tripped the dead-code ratchet that held this branch out of main.
|
|
@@ -347,6 +363,136 @@ module.exports = function buildModulesRouter() {
|
|
|
347
363
|
res.status(201).json({ ok: true, created_module: result.created, version: result.version });
|
|
348
364
|
}));
|
|
349
365
|
|
|
366
|
+
// GET /api/bongos/store/entitlements and /store/modules/:key/entitlement — the
|
|
367
|
+
// read side of the entitlement record (task 1003813, ADR 0338 D1). Own-scoped
|
|
368
|
+
// like /me/sessions: the holder is ALWAYS the caller (holder_kind 'builder',
|
|
369
|
+
// holder_ref = req.builder.id), never named in the query or path, so requireBuilder
|
|
370
|
+
// alone is the gate and one builder cannot read another's holdings. Granting has no
|
|
371
|
+
// route yet: install (task 1003785) grants a free module, area 8's buy action
|
|
372
|
+
// grants a paid one — both through src/bongos/module-entitlements.js.
|
|
373
|
+
router.get('/store/entitlements', auth.requireBuilder,
|
|
374
|
+
asyncHandler('GET /store/entitlements', async (req, res) => {
|
|
375
|
+
const entitlements = await listEntitlements({ holderKind: 'builder', holderRef: String(req.builder.id) });
|
|
376
|
+
res.set('Cache-Control', 'no-store');
|
|
377
|
+
res.json({ entitlements });
|
|
378
|
+
}));
|
|
379
|
+
|
|
380
|
+
router.get('/store/modules/:key/entitlement', auth.requireBuilder,
|
|
381
|
+
asyncHandler('GET /store/modules/:key/entitlement', async (req, res) => {
|
|
382
|
+
const { key } = req.params;
|
|
383
|
+
if (!KEY_RE.test(key)) {
|
|
384
|
+
return res.fail('bad_module_key', { status: 400, message: 'A module key is lowercase kebab-case.' });
|
|
385
|
+
}
|
|
386
|
+
const held = await checkEntitlement({ moduleKey: key, holderKind: 'builder', holderRef: String(req.builder.id) });
|
|
387
|
+
res.set('Cache-Control', 'no-store');
|
|
388
|
+
res.json(held);
|
|
389
|
+
}));
|
|
390
|
+
|
|
391
|
+
// ---- install: acquire → download → record (task 1003785, ADR 0338 D1) ----
|
|
392
|
+
//
|
|
393
|
+
// The server end of `bongos module install`. Three steps, so the store never
|
|
394
|
+
// records a version as acquired that the caller did not verify and place:
|
|
395
|
+
// 1. POST acquire — resolve the version, and take the entitlement: a free
|
|
396
|
+
// module (no price, or a zero price) is granted here with
|
|
397
|
+
// source 'free'; a priced one needs the buy action, which is
|
|
398
|
+
// area 8's (tasks 1003788/1003789) and answers 402 until then.
|
|
399
|
+
// 2. GET tarball — served only to a LIVE entitlement holder, never for a
|
|
400
|
+
// delisted module (ADR 0338 D2: no further versions served).
|
|
401
|
+
// 3. POST acquired — the CLI has verified the bytes with verifyModuleArtifact and
|
|
402
|
+
// placed them; recordAcquired stamps the exact version.
|
|
403
|
+
// Own-scoped like the entitlement reads: the holder is always the caller
|
|
404
|
+
// (holder_kind 'builder'), never named in the request, so requireBuilder is the
|
|
405
|
+
// gate — any signed-in builder may take a free module for themselves, and nothing
|
|
406
|
+
// here changes what runs on this instance (install never enables; the CLI places
|
|
407
|
+
// files on the CALLER's checkout).
|
|
408
|
+
//
|
|
409
|
+
// Channels: every version is on the general channel until the channel table
|
|
410
|
+
// (task 1003815) and "install honours the channel" (task 1003805) land; those
|
|
411
|
+
// add the channel check to step 1 and step 2, beside the entitlement check.
|
|
412
|
+
router.post('/store/modules/:key/acquire', auth.requireBuilder,
|
|
413
|
+
asyncHandler('POST /store/modules/:key/acquire', async (req, res) => {
|
|
414
|
+
const { key } = req.params;
|
|
415
|
+
if (!KEY_RE.test(key)) {
|
|
416
|
+
return res.fail('bad_module_key', { status: 400, message: 'A module key is lowercase kebab-case.' });
|
|
417
|
+
}
|
|
418
|
+
if (validateOrRespond(req, res, { version: { type: 'string', maxLength: 32 } })) return;
|
|
419
|
+
const wanted = req.body && req.body.version;
|
|
420
|
+
if (wanted != null && !parseVersion(String(wanted))) {
|
|
421
|
+
return res.fail('bad_version', { status: 400, message: 'A version is exact X.Y.Z.' });
|
|
422
|
+
}
|
|
423
|
+
const r = await resolveAcquirable(key, { version: wanted || null });
|
|
424
|
+
if (!r.ok) return res.fail(r.code, { status: r.status, message: r.message });
|
|
425
|
+
|
|
426
|
+
const holder = { moduleKey: key, holderKind: 'builder', holderRef: String(req.builder.id) };
|
|
427
|
+
let held = await checkEntitlement(holder);
|
|
428
|
+
if (!held.entitled) {
|
|
429
|
+
if (!r.free) {
|
|
430
|
+
return res.fail('payment_required', {
|
|
431
|
+
status: 402,
|
|
432
|
+
message: `"${key}" is priced, and buying a module is not available yet (it arrives with the credit economy, area 8).`,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
const g = await grantEntitlement({ ...holder, source: 'free', grantedBy: req.builder.id });
|
|
436
|
+
held = { entitled: true, entitlement: g.entitlement };
|
|
437
|
+
}
|
|
438
|
+
const v = r.version;
|
|
439
|
+
res.set('Cache-Control', 'no-store');
|
|
440
|
+
res.json({
|
|
441
|
+
ok: true,
|
|
442
|
+
module_key: key,
|
|
443
|
+
version: v.version,
|
|
444
|
+
core_version: v.core_version,
|
|
445
|
+
tarball_sha256: v.tarball_sha256,
|
|
446
|
+
tree_sha256: v.tree_sha256,
|
|
447
|
+
tarball_bytes: Number(v.tarball_bytes),
|
|
448
|
+
channel: 'general',
|
|
449
|
+
entitlement: held.entitlement,
|
|
450
|
+
});
|
|
451
|
+
}));
|
|
452
|
+
|
|
453
|
+
router.get('/store/modules/:key/versions/:version/tarball', auth.requireBuilder,
|
|
454
|
+
asyncHandler('GET /store/modules/:key/versions/:version/tarball', async (req, res) => {
|
|
455
|
+
const { key, version } = req.params;
|
|
456
|
+
if (!KEY_RE.test(key) || !parseVersion(version)) {
|
|
457
|
+
return res.fail('bad_request', { status: 400, message: 'A module key is lowercase kebab-case and a version is exact X.Y.Z.' });
|
|
458
|
+
}
|
|
459
|
+
const r = await resolveAcquirable(key, { version });
|
|
460
|
+
if (!r.ok) return res.fail(r.code, { status: r.status, message: r.message });
|
|
461
|
+
const held = await checkEntitlement({ moduleKey: key, holderKind: 'builder', holderRef: String(req.builder.id) });
|
|
462
|
+
if (!held.entitled) {
|
|
463
|
+
return res.fail('not_entitled', { status: 403, message: `You do not hold "${key}". Acquire it first.` });
|
|
464
|
+
}
|
|
465
|
+
let buf;
|
|
466
|
+
try { buf = await fs.promises.readFile(versionArtifactFile(r.version)); }
|
|
467
|
+
catch (e) {
|
|
468
|
+
log.error({ key, version, err: e.message }, 'store tarball missing');
|
|
469
|
+
return res.fail('artifact_missing', { status: 500, message: `The store has no file for ${key} ${version}.` });
|
|
470
|
+
}
|
|
471
|
+
res.set('Cache-Control', 'no-store');
|
|
472
|
+
res.set('Content-Type', 'application/gzip');
|
|
473
|
+
res.set('X-Tarball-Sha256', r.version.tarball_sha256);
|
|
474
|
+
res.send(buf);
|
|
475
|
+
}));
|
|
476
|
+
|
|
477
|
+
router.post('/store/modules/:key/acquired', auth.requireBuilder,
|
|
478
|
+
asyncHandler('POST /store/modules/:key/acquired', async (req, res) => {
|
|
479
|
+
const { key } = req.params;
|
|
480
|
+
if (!KEY_RE.test(key)) {
|
|
481
|
+
return res.fail('bad_module_key', { status: 400, message: 'A module key is lowercase kebab-case.' });
|
|
482
|
+
}
|
|
483
|
+
if (validateOrRespond(req, res, { version: { type: 'string', required: true, maxLength: 32 } })) return;
|
|
484
|
+
const { version } = req.body;
|
|
485
|
+
if (!parseVersion(version)) return res.fail('bad_version', { status: 400, message: 'A version is exact X.Y.Z.' });
|
|
486
|
+
const r = await resolveAcquirable(key, { version });
|
|
487
|
+
if (!r.ok) return res.fail(r.code, { status: r.status, message: r.message });
|
|
488
|
+
const out = await recordAcquired({ moduleKey: key, holderKind: 'builder', holderRef: String(req.builder.id), version });
|
|
489
|
+
if (!out.ok) {
|
|
490
|
+
const status = out.code === 'not_entitled' ? 403 : 409;
|
|
491
|
+
return res.fail(out.code, { status, message: out.code === 'not_entitled' ? `You do not hold "${key}".` : `You already hold a newer version of "${key}".` });
|
|
492
|
+
}
|
|
493
|
+
res.json({ ok: true, entitlement: out.entitlement });
|
|
494
|
+
}));
|
|
495
|
+
|
|
350
496
|
return router;
|
|
351
497
|
};
|
|
352
498
|
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.19.
|
|
78
|
+
const CORE_VERSION = '1.19.1079'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|