@bongos/core 1.19.1078 → 1.19.1080
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 +35 -25
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +10 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/api/openapi.json +246 -3
- package/docs/api-reference.md +5 -2
- package/docs/architecture.md +2 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +16 -0
- package/package-lock.json +2 -2
- package/package.json +2 -2
- package/release-notes.json +12 -0
- package/scripts/gds/module-artifact.js +9 -2
- package/scripts/gds/module.js +272 -1
- package/src/bongos/module-store.js +55 -0
- package/src/bongos/routes/modules.js +119 -2
- package/src/module-api.js +1 -1
- package/tests/module_install.mjs +224 -0
- package/tests/module_update.mjs +232 -0
package/docs/modules-contract.md
CHANGED
|
@@ -357,6 +357,22 @@ Run `bongos upgrade` to confirm coreVersion compatibility, then `node scripts/gd
|
|
|
357
357
|
- The publish denylist applies (ADR 0098), plus a refusal of credential-named files (`.env*`, `*.pem`, `*.key`, `id_rsa`, …) anywhere in the module. The store re-checks every hash and the denylist itself.
|
|
358
358
|
- Gate: Metic+ (`module.submit`) for now. This is not `bongos module submit`, which proposes a module *into* core.
|
|
359
359
|
|
|
360
|
+
### 7. Install from the store
|
|
361
|
+
|
|
362
|
+
`bongos module install <key> [--version X.Y.Z]` acquires a module from the store and places it under the instance's `modules/`. Three calls: `POST /store/modules/:key/acquire` takes your entitlement and names the version (the newest unless you pick one), `GET /store/modules/:key/versions/:version/tarball` downloads it, and once the files are placed, `POST /store/modules/:key/acquired` records the exact version.
|
|
363
|
+
|
|
364
|
+
- The download is checked with `verifyModuleArtifact`, the same checker the store runs on publish, and must match the version and hashes the store described. A failed check places nothing.
|
|
365
|
+
- A module with no price, or a zero price, is free and granted on the spot. A priced one answers 402 until buying exists (area 8). An entitlement is kept for good, even if a price is added later (ADR 0338 D2).
|
|
366
|
+
- Install never enables. Switch the module on from the hall's Modules tab.
|
|
367
|
+
- A module already on disk, in the instance's `modules/` or bundled with the core, is refused; moving one to a newer version is `update`. A module this core cannot mount is refused before download.
|
|
368
|
+
- A delisted module cannot be acquired or downloaded. Channels are not checked yet: every version is general until the channel tasks land.
|
|
369
|
+
|
|
370
|
+
`bongos module update <key> [--version X.Y.Z]` moves an installed store module forward through the same three calls, so a paid module and a free one take one identical path, and a module you already hold is never charged again (`tests/module_update.mjs` fails if a price-conditional branch appears).
|
|
371
|
+
|
|
372
|
+
- With no `--version` it takes the newest, and says "up to date" without downloading when that isn't newer. An older `--version` is refused: update only moves forward.
|
|
373
|
+
- The new version is written beside the old one and swapped in; any failure leaves the installed version in place. Files the new version dropped are gone afterwards.
|
|
374
|
+
- The on/off switch is untouched. A module bundled with the core is refused; it updates with the core (`bongos upgrade`).
|
|
375
|
+
|
|
360
376
|
## Carving an existing core domain (the `db.js` per-domain pattern)
|
|
361
377
|
|
|
362
378
|
Most BONGOS-V1 module work is not scaffolding a *new* module — it's **carving an existing core domain out of the monolith** (ADR 0091, criterion C7). The reference carve is the kernel slice `src/bongos/db-kernel.js` (BV1.R69); the feature precedents are `modules/discord/db.js` + `modules/game/db.js`. Every later carve (memory R72, grading R73, …) copies this recipe. The invariant is **OTB byte-identical** at every step — you are *moving* code, not changing behavior.
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1080",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.1080",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1080",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -29,6 +29,6 @@
|
|
|
29
29
|
"pino": "^9.14.0"
|
|
30
30
|
},
|
|
31
31
|
"overrides": {
|
|
32
|
-
"undici": "^6.28.
|
|
32
|
+
"undici": "^6.28.1"
|
|
33
33
|
}
|
|
34
34
|
}
|
package/release-notes.json
CHANGED
|
@@ -7861,5 +7861,17 @@
|
|
|
7861
7861
|
"id": "1003813",
|
|
7862
7862
|
"text": "The store now has a durable record of who owns which module and at which version, so install and update can check it. Free and bundled modules use the same record; nothing charges money yet."
|
|
7863
7863
|
}
|
|
7864
|
+
],
|
|
7865
|
+
"1.19.1079": [
|
|
7866
|
+
{
|
|
7867
|
+
"id": "1003785",
|
|
7868
|
+
"text": "You can now install a module from the store with one command. It checks the download is exactly what was published and never switches the module on by itself. Modules without a price are free."
|
|
7869
|
+
}
|
|
7870
|
+
],
|
|
7871
|
+
"1.19.1080": [
|
|
7872
|
+
{
|
|
7873
|
+
"id": "1003786",
|
|
7874
|
+
"text": "Installed modules can now be updated with one command. Paid and free modules update the exact same way, you are never charged twice, and a failed update leaves your working version in place."
|
|
7875
|
+
}
|
|
7864
7876
|
]
|
|
7865
7877
|
}
|
|
@@ -141,7 +141,10 @@ function packModule(key, { modulesDir, now = () => new Date(), modeOf } = {}) {
|
|
|
141
141
|
// denylist runs again. Resolves { ok: true, ... } or { ok: false, code, message }.
|
|
142
142
|
// Async so the inflate runs off the event loop (readTarAsync); the rest is one pass
|
|
143
143
|
// over at most READ_LIMITS.maxUnpackedBytes.
|
|
144
|
-
|
|
144
|
+
// `includeFiles: true` (install, task 1003785) also returns `files`: the module's own
|
|
145
|
+
// files — [{ path, mode, buf }], the inner manifest left out — from the SAME parse the
|
|
146
|
+
// hashes were checked against, so a caller that places them never re-reads the bytes.
|
|
147
|
+
async function verifyModuleArtifact(tgz, { key, maxBytes = MAX_TARBALL_BYTES, includeFiles = false } = {}) {
|
|
145
148
|
const fail = (code, message) => ({ ok: false, code, message });
|
|
146
149
|
if (!Buffer.isBuffer(tgz) || tgz.length === 0) return fail('empty_artifact', 'the upload is empty');
|
|
147
150
|
if (tgz.length > maxBytes) return fail('artifact_too_large', `the tarball is over ${maxBytes} bytes`);
|
|
@@ -202,11 +205,15 @@ async function verifyModuleArtifact(tgz, { key, maxBytes = MAX_TARBALL_BYTES } =
|
|
|
202
205
|
return fail('bad_manifest', 'the manifest version does not match module.json');
|
|
203
206
|
}
|
|
204
207
|
|
|
205
|
-
|
|
208
|
+
const out = {
|
|
206
209
|
ok: true, manifest, moduleJson,
|
|
207
210
|
version: moduleJson.version, coreVersion: moduleJson.coreVersion,
|
|
208
211
|
treeSha256: treeSha, tarballSha256: sha256hex(tgz), bytes: tgz.length,
|
|
209
212
|
};
|
|
213
|
+
if (includeFiles) {
|
|
214
|
+
out.files = [...byName.entries()].map(([p, e]) => ({ path: p, mode: normalizeMode(e.mode.toString(8)), buf: e.buf }));
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
210
217
|
}
|
|
211
218
|
|
|
212
219
|
module.exports = { MAX_TARBALL_BYTES, packModule, verifyModuleArtifact };
|
package/scripts/gds/module.js
CHANGED
|
@@ -60,6 +60,8 @@
|
|
|
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)
|
|
64
|
+
// bongos module update <key> [--version X.Y.Z] move an installed store module forward
|
|
63
65
|
|
|
64
66
|
const fs = require('node:fs');
|
|
65
67
|
const path = require('node:path');
|
|
@@ -862,6 +864,269 @@ async function cmdPublish(args, {
|
|
|
862
864
|
return 0;
|
|
863
865
|
}
|
|
864
866
|
|
|
867
|
+
// ---------------------------------------------------------------------------
|
|
868
|
+
// bongos module install <key> — acquire a module from the store (ADR 0338 D1)
|
|
869
|
+
// ---------------------------------------------------------------------------
|
|
870
|
+
|
|
871
|
+
// The store half of the CLI, over three calls (src/bongos/routes/modules.js):
|
|
872
|
+
// acquire (take the entitlement, learn the version), download the tarball, and
|
|
873
|
+
// once the files are placed, record the version acquired. Injected in tests.
|
|
874
|
+
function storeRequest() {
|
|
875
|
+
// Lazy require — cli-lib's session/fetch plumbing must never load in the server
|
|
876
|
+
// process, which requires this file too (the same reason cmdPublish gives).
|
|
877
|
+
const { readSessionToken, dispatchedFetch, noKeepAliveDispatcher, CLI_USER_AGENT } = require('./cli-lib');
|
|
878
|
+
const session = readSessionToken();
|
|
879
|
+
if (!session) throw new Error('no Bongos session — run `bongos setup` first');
|
|
880
|
+
return async (method, urlPath, { json, binary = false } = {}) => {
|
|
881
|
+
const res = await dispatchedFetch(`${session.apiBase}/api/bongos${urlPath}`, {
|
|
882
|
+
method,
|
|
883
|
+
headers: {
|
|
884
|
+
Authorization: `Bearer ${session.token}`, 'User-Agent': CLI_USER_AGENT,
|
|
885
|
+
...(json ? { 'Content-Type': 'application/json' } : {}),
|
|
886
|
+
},
|
|
887
|
+
body: json ? JSON.stringify(json) : undefined,
|
|
888
|
+
dispatcher: noKeepAliveDispatcher(),
|
|
889
|
+
});
|
|
890
|
+
if (binary && res.ok) return { ok: true, status: res.status, buf: Buffer.from(await res.arrayBuffer()) };
|
|
891
|
+
const text = await res.text();
|
|
892
|
+
let data;
|
|
893
|
+
try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; }
|
|
894
|
+
return { ok: res.ok, status: res.status, data };
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function storeError(res) {
|
|
899
|
+
const err = res.data && (res.data.error || res.data);
|
|
900
|
+
return (err && (err.message || err.code)) || `HTTP ${res.status}`;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// Where install places a module: the INSTANCE's own modules/ root (ADR 0108 §1),
|
|
904
|
+
// which the loader discovers. In a single checkout that is this repo's modules/.
|
|
905
|
+
function instanceModulesDir() {
|
|
906
|
+
return path.join(require('../../src/instance-config').resolveInstanceRoot(), 'modules');
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// Write the verified files into a private staging dir beside the target, then
|
|
910
|
+
// rename it into place, so a crash leaves a staging dir to delete, never a
|
|
911
|
+
// half-written module the loader would try to mount.
|
|
912
|
+
function placeModule(files, target) {
|
|
913
|
+
const staging = `${target}.installing-${process.pid}-${Date.now()}`;
|
|
914
|
+
fs.mkdirSync(staging, { recursive: true });
|
|
915
|
+
try {
|
|
916
|
+
for (const f of files) {
|
|
917
|
+
const dest = path.join(staging, ...f.path.split('/'));
|
|
918
|
+
if (!dest.startsWith(staging + path.sep)) throw new Error(`"${f.path}" would land outside the module`);
|
|
919
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
920
|
+
fs.writeFileSync(dest, f.buf, { mode: parseInt(f.mode, 8) || 0o644 });
|
|
921
|
+
}
|
|
922
|
+
fs.renameSync(staging, target);
|
|
923
|
+
} catch (e) {
|
|
924
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
925
|
+
throw e;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// Every discovery root the loader scans (core + instance, ADR 0108 §1). A key on
|
|
930
|
+
// disk in ANY of them is already taken: the loader treats a repeated key as a hard
|
|
931
|
+
// error, so placing a second copy in the instance root would break boot.
|
|
932
|
+
function discoveryRoots() {
|
|
933
|
+
return require('../../src/module-loader/loader').DEFAULT_ROOTS;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// Parse `<key> [--version X.Y.Z]` for install and update. Returns { key, wanted }
|
|
937
|
+
// or { code } (2 = usage) after printing why.
|
|
938
|
+
function parseStoreArgs(args, verb, errlog) {
|
|
939
|
+
const usage = `usage: bongos module ${verb} <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 { code: 2 }; }
|
|
944
|
+
if (!KEY_RE.test(key)) { errlog(`REFUSING: "${key}" is not a module key (lowercase kebab-case).`); return { code: 2 }; }
|
|
945
|
+
const { parseVersion } = require('../../src/module-loader/semver');
|
|
946
|
+
if (wanted && !parseVersion(wanted)) { errlog(`REFUSING: --version must be exact X.Y.Z, not "${wanted}".`); return { code: 2 }; }
|
|
947
|
+
return { key, wanted };
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// The one acquisition path install AND update share (task 1003786): acquire, check
|
|
951
|
+
// the version can mount on this core, download, verify with verifyModuleArtifact and
|
|
952
|
+
// hold the bytes to what the store described. Nothing here reads a price or branches
|
|
953
|
+
// on how the module was obtained — the store decides entitlement, and a holder it has
|
|
954
|
+
// already granted (for any reason) is never asked again. tests/module_update.mjs
|
|
955
|
+
// fails if a price-conditional branch appears in this path.
|
|
956
|
+
//
|
|
957
|
+
// `shouldDownload(described)` lets update stop before the download when the store's
|
|
958
|
+
// version is not newer than what is installed. Resolves { ok, described, verified }
|
|
959
|
+
// or { ok: false, code, upToDate? } after printing why.
|
|
960
|
+
async function fetchVerified(key, { wanted, call, core, log, errlog, verb, shouldDownload = () => true }) {
|
|
961
|
+
const fail = (code, msg) => { if (msg) errlog(msg); return { ok: false, code }; };
|
|
962
|
+
const enc = encodeURIComponent(key);
|
|
963
|
+
let acq;
|
|
964
|
+
try { acq = await call('POST', `/store/modules/${enc}/acquire`, { json: wanted ? { version: wanted } : {} }); }
|
|
965
|
+
catch (e) { return fail(1, `bongos module ${verb}: the store could not be reached — ${e.message}`); }
|
|
966
|
+
if (!acq.ok) return fail(1, `REFUSING: the store would not hand over "${key}" (HTTP ${acq.status}): ${storeError(acq)}`);
|
|
967
|
+
const described = acq.data;
|
|
968
|
+
log(`bongos module ${verb} — "${key}" ${described.version} (needs core ${described.core_version})`);
|
|
969
|
+
|
|
970
|
+
const stop = shouldDownload(described);
|
|
971
|
+
if (stop !== true) return { ok: false, code: stop.code, upToDate: stop.upToDate };
|
|
972
|
+
|
|
973
|
+
const { satisfies } = require('../../src/module-loader/semver');
|
|
974
|
+
const coreV = core || require('../../src/module-api').CORE_VERSION;
|
|
975
|
+
if (!satisfies(coreV, described.core_version)) {
|
|
976
|
+
return fail(1, `REFUSING: "${key}" ${described.version} needs core ${described.core_version}; this core is ${coreV}, so it could not mount.`);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
let dl;
|
|
980
|
+
try { dl = await call('GET', `/store/modules/${enc}/versions/${encodeURIComponent(described.version)}/tarball`, { binary: true }); }
|
|
981
|
+
catch (e) { return fail(1, `bongos module ${verb}: download failed — ${e.message}`); }
|
|
982
|
+
if (!dl.ok) return fail(1, `REFUSING: the download was refused (HTTP ${dl.status}): ${storeError(dl)}`);
|
|
983
|
+
|
|
984
|
+
const artifact = require('./module-artifact');
|
|
985
|
+
const verified = await artifact.verifyModuleArtifact(dl.buf, { key, includeFiles: true });
|
|
986
|
+
if (!verified.ok) return fail(1, `REFUSING: the downloaded tarball failed verification (${verified.code}): ${verified.message}`);
|
|
987
|
+
if (verified.version !== described.version || verified.tarballSha256 !== described.tarball_sha256 || verified.treeSha256 !== described.tree_sha256) {
|
|
988
|
+
return fail(1, `REFUSING: the download is not the ${described.version} the store described (version or hash differs).`);
|
|
989
|
+
}
|
|
990
|
+
log(` ✓ verified ${verified.files.length} file(s), ${verified.bytes} bytes — tree ${verified.treeSha256.slice(0, 12)}…`);
|
|
991
|
+
return { ok: true, described, verified };
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// Tell the store which exact version was placed. A failure here never undoes the
|
|
995
|
+
// placement: the files are good, and the next install or update records it.
|
|
996
|
+
async function recordPlaced(key, version, { call, errlog, verb }) {
|
|
997
|
+
let rec;
|
|
998
|
+
try { rec = await call('POST', `/store/modules/${encodeURIComponent(key)}/acquired`, { json: { version } }); }
|
|
999
|
+
catch (e) { rec = { ok: false, status: 0, data: { error: { message: e.message } } }; }
|
|
1000
|
+
if (!rec.ok) {
|
|
1001
|
+
errlog(`WARNING: "${key}" is ${verb === 'update' ? 'updated' : 'installed'}, but the store did not record the version (${storeError(rec)}). Nothing is lost; the next install or update records it.`);
|
|
1002
|
+
return false;
|
|
1003
|
+
}
|
|
1004
|
+
return true;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// `bongos module install <key> [--version X.Y.Z]` — task 1003785. Acquires the
|
|
1008
|
+
// module (a free one is granted on the spot; a priced one waits on the buy action,
|
|
1009
|
+
// area 8), downloads its tarball, verifies it with verifyModuleArtifact — the one
|
|
1010
|
+
// checker the store itself uses on publish — and places it under the instance's
|
|
1011
|
+
// modules/. It never enables: the loader discovers what is on disk and the hall's
|
|
1012
|
+
// Modules tab decides what mounts. A key already on disk in ANY discovery root is
|
|
1013
|
+
// refused; moving an installed module to a newer version is `update` (task 1003786).
|
|
1014
|
+
async function cmdInstall(args, {
|
|
1015
|
+
log = console.log, errlog = console.error,
|
|
1016
|
+
modulesDir, roots, request, core,
|
|
1017
|
+
} = {}) {
|
|
1018
|
+
const parsed = parseStoreArgs(args, 'install', errlog);
|
|
1019
|
+
if (parsed.code) return parsed.code;
|
|
1020
|
+
const { key, wanted } = parsed;
|
|
1021
|
+
|
|
1022
|
+
const dir = modulesDir || instanceModulesDir();
|
|
1023
|
+
const target = path.join(dir, key);
|
|
1024
|
+
const taken = [dir, ...(roots || discoveryRoots())].map((r) => path.join(r, key)).find((p) => fs.existsSync(p));
|
|
1025
|
+
if (taken) {
|
|
1026
|
+
errlog(`REFUSING: "${key}" is already on disk at ${taken}. Install only adds a new module; to move an installed one forward, run \`bongos module update ${key}\`.`);
|
|
1027
|
+
return 1;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
let call;
|
|
1031
|
+
try { call = request || storeRequest(); }
|
|
1032
|
+
catch (e) { errlog(`bongos module install: ${e.message}`); return 1; }
|
|
1033
|
+
|
|
1034
|
+
const got = await fetchVerified(key, { wanted, call, core, log, errlog, verb: 'install' });
|
|
1035
|
+
if (!got.ok) return got.code;
|
|
1036
|
+
|
|
1037
|
+
try { placeModule(got.verified.files, target); }
|
|
1038
|
+
catch (e) { errlog(`bongos module install: could not place the module — ${e.message}`); return 1; }
|
|
1039
|
+
log(` ✓ placed at ${target}`);
|
|
1040
|
+
|
|
1041
|
+
if (!(await recordPlaced(key, got.described.version, { call, errlog, verb: 'install' }))) return 1;
|
|
1042
|
+
log(`\n✓ Installed "${key}" ${got.described.version}. It is not switched on: enable it from the hall's Modules tab when you want it.`);
|
|
1043
|
+
return 0;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// Replace an installed module's directory with a verified new one. The new files
|
|
1047
|
+
// are written to a staging dir first; the old dir is moved aside, the new one moved
|
|
1048
|
+
// in, and only then is the old one removed — so a failure at any step leaves the
|
|
1049
|
+
// previous version in place, never a half-written or missing module.
|
|
1050
|
+
function replaceModule(files, target) {
|
|
1051
|
+
const aside = `${target}.previous-${process.pid}-${Date.now()}`;
|
|
1052
|
+
const staging = `${target}.installing-${process.pid}-${Date.now()}`;
|
|
1053
|
+
placeModule(files, staging);
|
|
1054
|
+
try {
|
|
1055
|
+
fs.renameSync(target, aside);
|
|
1056
|
+
} catch (e) {
|
|
1057
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
1058
|
+
throw e;
|
|
1059
|
+
}
|
|
1060
|
+
try {
|
|
1061
|
+
fs.renameSync(staging, target);
|
|
1062
|
+
} catch (e) {
|
|
1063
|
+
fs.renameSync(aside, target);
|
|
1064
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
1065
|
+
throw e;
|
|
1066
|
+
}
|
|
1067
|
+
fs.rmSync(aside, { recursive: true, force: true });
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// `bongos module update <key> [--version X.Y.Z]` — task 1003786. Moves an installed
|
|
1071
|
+
// store module forward, through exactly the acquisition path install uses
|
|
1072
|
+
// (fetchVerified): the same calls whether the module was free or paid, and a module
|
|
1073
|
+
// the caller already holds is never charged again (the store's acquire route only
|
|
1074
|
+
// looks at price for a caller with no entitlement). Only forward: with no --version
|
|
1075
|
+
// it takes the newest, and says so without downloading when that is not newer; an
|
|
1076
|
+
// older --version is refused, since rolling back is a different act. Like install it
|
|
1077
|
+
// never enables or disables — the module keeps whatever switch it had.
|
|
1078
|
+
async function cmdUpdate(args, {
|
|
1079
|
+
log = console.log, errlog = console.error,
|
|
1080
|
+
modulesDir, roots, request, core,
|
|
1081
|
+
} = {}) {
|
|
1082
|
+
const parsed = parseStoreArgs(args, 'update', errlog);
|
|
1083
|
+
if (parsed.code) return parsed.code;
|
|
1084
|
+
const { key, wanted } = parsed;
|
|
1085
|
+
|
|
1086
|
+
const dir = modulesDir || instanceModulesDir();
|
|
1087
|
+
const target = path.join(dir, key);
|
|
1088
|
+
if (!fs.existsSync(path.join(target, 'module.json'))) {
|
|
1089
|
+
const bundled = (roots || discoveryRoots()).map((r) => path.join(r, key)).find((p) => p !== target && fs.existsSync(p));
|
|
1090
|
+
errlog(bundled
|
|
1091
|
+
? `REFUSING: "${key}" at ${bundled} comes with the core, not the store. It updates when the core does (\`bongos upgrade\`).`
|
|
1092
|
+
: `REFUSING: "${key}" is not installed here. Run \`bongos module install ${key}\` first.`);
|
|
1093
|
+
return 1;
|
|
1094
|
+
}
|
|
1095
|
+
const { parseVersion, cmp } = require('../../src/module-loader/semver');
|
|
1096
|
+
let installed;
|
|
1097
|
+
try { installed = JSON.parse(fs.readFileSync(path.join(target, 'module.json'), 'utf8')).version; }
|
|
1098
|
+
catch (e) { errlog(`REFUSING: could not read the installed ${key}/module.json — ${e.message}`); return 1; }
|
|
1099
|
+
const have = parseVersion(String(installed));
|
|
1100
|
+
if (!have) { errlog(`REFUSING: the installed "${key}" has no exact version in module.json, so there is nothing to compare against.`); return 1; }
|
|
1101
|
+
if (wanted && cmp(parseVersion(wanted), have) < 0) {
|
|
1102
|
+
errlog(`REFUSING: ${wanted} is older than the installed ${installed}. Update only moves forward.`);
|
|
1103
|
+
return 1;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
let call;
|
|
1107
|
+
try { call = request || storeRequest(); }
|
|
1108
|
+
catch (e) { errlog(`bongos module update: ${e.message}`); return 1; }
|
|
1109
|
+
|
|
1110
|
+
const got = await fetchVerified(key, {
|
|
1111
|
+
wanted, call, core, log, errlog, verb: 'update',
|
|
1112
|
+
shouldDownload: (d) => {
|
|
1113
|
+
const next = parseVersion(String(d.version));
|
|
1114
|
+
if (next && cmp(next, have) > 0) return true;
|
|
1115
|
+
log(`\n✓ "${key}" is up to date at ${installed}.`);
|
|
1116
|
+
return { code: 0, upToDate: true };
|
|
1117
|
+
},
|
|
1118
|
+
});
|
|
1119
|
+
if (!got.ok) return got.code;
|
|
1120
|
+
|
|
1121
|
+
try { replaceModule(got.verified.files, target); }
|
|
1122
|
+
catch (e) { errlog(`bongos module update: could not replace the module, ${installed} is still in place — ${e.message}`); return 1; }
|
|
1123
|
+
log(` ✓ replaced ${installed} with ${got.described.version} at ${target}`);
|
|
1124
|
+
|
|
1125
|
+
if (!(await recordPlaced(key, got.described.version, { call, errlog, verb: 'update' }))) return 1;
|
|
1126
|
+
log(`\n✓ Updated "${key}" ${installed} → ${got.described.version}. Its on/off switch is unchanged.`);
|
|
1127
|
+
return 0;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
865
1130
|
function printHelp(out = console.log) {
|
|
866
1131
|
out('bongos module — scaffold + manage Cloud Bongos modules\n');
|
|
867
1132
|
out('Usage:');
|
|
@@ -875,6 +1140,10 @@ function printHelp(out = console.log) {
|
|
|
875
1140
|
out(' Pre-check + sign-off, then file into the rank-gated upstreaming review queue');
|
|
876
1141
|
out(' bongos module publish <key> [--dry-run] [--out <dir>]');
|
|
877
1142
|
out(' Pack a module version (tarball + hash manifest) and upload it to the module store');
|
|
1143
|
+
out(' bongos module install <key> [--version X.Y.Z]');
|
|
1144
|
+
out(' Acquire a module from the store, verify it and place it under modules/ (not enabled)');
|
|
1145
|
+
out(' bongos module update <key> [--version X.Y.Z]');
|
|
1146
|
+
out(' Move an installed store module to a newer version (same path, paid or free)');
|
|
878
1147
|
}
|
|
879
1148
|
|
|
880
1149
|
async function main(argv) {
|
|
@@ -886,6 +1155,8 @@ async function main(argv) {
|
|
|
886
1155
|
if (sub === 'check') return cmdCheck(args.slice(1));
|
|
887
1156
|
if (sub === 'submit') return cmdSubmit(args.slice(1));
|
|
888
1157
|
if (sub === 'publish') return cmdPublish(args.slice(1));
|
|
1158
|
+
if (sub === 'install') return cmdInstall(args.slice(1));
|
|
1159
|
+
if (sub === 'update') return cmdUpdate(args.slice(1));
|
|
889
1160
|
if (!sub || sub === 'help' || sub === '--help' || sub === '-h') {
|
|
890
1161
|
printHelp();
|
|
891
1162
|
return sub ? 0 : 2;
|
|
@@ -896,7 +1167,7 @@ async function main(argv) {
|
|
|
896
1167
|
}
|
|
897
1168
|
|
|
898
1169
|
module.exports = {
|
|
899
|
-
main, cmdNew, cmdList, cmdUpgrade, cmdCheck, cmdSubmit, cmdPublish, printHelp,
|
|
1170
|
+
main, cmdNew, cmdList, cmdUpgrade, cmdCheck, cmdSubmit, cmdPublish, cmdInstall, cmdUpdate, printHelp,
|
|
900
1171
|
gatherCatalog, formatCatalog, ownProjectName,
|
|
901
1172
|
scaffoldModule, gatherUpgrade, evaluateUpgrade, buildManifest,
|
|
902
1173
|
formatMaintenanceHint, isPastRemoveAfter,
|
|
@@ -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
|
};
|
|
@@ -26,6 +26,12 @@
|
|
|
26
26
|
// GET /store/entitlements the modules the caller holds, and at which version
|
|
27
27
|
// GET /store/modules/:key/entitlement
|
|
28
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.
|
|
29
35
|
//
|
|
30
36
|
// Read is open to any signed-in builder (same gate as the atlas/primer pages);
|
|
31
37
|
// every write (enable/disable, submit) is metic+archon, same rank as
|
|
@@ -53,6 +59,7 @@
|
|
|
53
59
|
// cross-repo call to the extracted core repo): see
|
|
54
60
|
// docs/adr/0135-module-upstream-submission-interim-queue.md.
|
|
55
61
|
|
|
62
|
+
const fs = require('node:fs');
|
|
56
63
|
const express = require('express');
|
|
57
64
|
const auth = require('../auth');
|
|
58
65
|
const { asyncHandler, validateOrRespond, LIMITS } = require('./_helpers');
|
|
@@ -68,9 +75,14 @@ const modulesLib = require('../../modules');
|
|
|
68
75
|
const moduleCli = require('../../../scripts/gds/module');
|
|
69
76
|
const moduleSubmissions = require('../module-submissions');
|
|
70
77
|
const moduleArtifact = require('../../../scripts/gds/module-artifact');
|
|
71
|
-
const { checkEntitlement, listEntitlements } = require('../module-entitlements');
|
|
72
|
-
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');
|
|
73
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' });
|
|
74
86
|
// Named imports, not a namespace object (task 1004052 strand walk): knip cannot follow
|
|
75
87
|
// property access on a CommonJS namespace, so reaching these through one made them read
|
|
76
88
|
// as unused exports and tripped the dead-code ratchet that held this branch out of main.
|
|
@@ -376,6 +388,111 @@ module.exports = function buildModulesRouter() {
|
|
|
376
388
|
res.json(held);
|
|
377
389
|
}));
|
|
378
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
|
+
|
|
379
496
|
return router;
|
|
380
497
|
};
|
|
381
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.1080'; // 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');
|