@bongos/core 1.21.85 → 1.21.86
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 +20 -15
- package/docs/architecture.md +1 -1
- package/docs/module-api-changelog.md +2 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/bongos/module-store.js +101 -5
- package/src/bongos/routes/modules.js +7 -1
- package/src/module-api.js +1 -1
- package/tests/module_store_instance_root.mjs +163 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.21.
|
|
6
|
-
"core_contract": "1.21.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.21.86",
|
|
6
|
+
"core_contract": "1.21.86",
|
|
7
|
+
"source_commit": "7183f6e501a4de2ea256ffa4c2d19ccc62d7fdc1",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-10-09T04:
|
|
9
|
+
"built_at": "2026-10-09T04:48:08.126Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 590,
|
|
13
13
|
"agent_docs_stubbed": 28,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2998,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 3617,
|
|
20
|
+
"tree_sha256": "391c304f1f86515a2c812e6764e83ba98b5fe6a67757909baf15d8e88009694b",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2327,7 +2327,7 @@
|
|
|
2327
2327
|
{
|
|
2328
2328
|
"path": "docs/architecture.md",
|
|
2329
2329
|
"mode": "0000644",
|
|
2330
|
-
"sha256": "
|
|
2330
|
+
"sha256": "0dac6b0ee55d7836cf6352c695f592000bfd61d0c3ec06d9c17d37b476b1134a"
|
|
2331
2331
|
},
|
|
2332
2332
|
{
|
|
2333
2333
|
"path": "docs/branding-contract.md",
|
|
@@ -2862,7 +2862,7 @@
|
|
|
2862
2862
|
{
|
|
2863
2863
|
"path": "docs/module-api-changelog.md",
|
|
2864
2864
|
"mode": "0000644",
|
|
2865
|
-
"sha256": "
|
|
2865
|
+
"sha256": "007f5683bb299be37f78f220958ae14bb8a8069fc59b81cc6fb94a12c5b3712f"
|
|
2866
2866
|
},
|
|
2867
2867
|
{
|
|
2868
2868
|
"path": "docs/modules-contract.md",
|
|
@@ -10097,12 +10097,12 @@
|
|
|
10097
10097
|
{
|
|
10098
10098
|
"path": "package-lock.json",
|
|
10099
10099
|
"mode": "0000644",
|
|
10100
|
-
"sha256": "
|
|
10100
|
+
"sha256": "9d3ecc5f558e51dc8e4e66a6f3f0a6e9773e517ce9571ae4ec130b33128536ff"
|
|
10101
10101
|
},
|
|
10102
10102
|
{
|
|
10103
10103
|
"path": "package.json",
|
|
10104
10104
|
"mode": "0000644",
|
|
10105
|
-
"sha256": "
|
|
10105
|
+
"sha256": "ce90ad007650b7bcf0705bf0743acf66507496e0a043d7d6c2d9b84795b94f9b"
|
|
10106
10106
|
},
|
|
10107
10107
|
{
|
|
10108
10108
|
"path": "public-docs/index.html",
|
|
@@ -10122,7 +10122,7 @@
|
|
|
10122
10122
|
{
|
|
10123
10123
|
"path": "release-notes.json",
|
|
10124
10124
|
"mode": "0000644",
|
|
10125
|
-
"sha256": "
|
|
10125
|
+
"sha256": "99cc3e08c1384339c868c499061c081eade1bcb0a996c47d3e644066d22ca536"
|
|
10126
10126
|
},
|
|
10127
10127
|
{
|
|
10128
10128
|
"path": "scripts/cron/style_guide_audit.sh",
|
|
@@ -12137,7 +12137,7 @@
|
|
|
12137
12137
|
{
|
|
12138
12138
|
"path": "src/bongos/module-store.js",
|
|
12139
12139
|
"mode": "0000644",
|
|
12140
|
-
"sha256": "
|
|
12140
|
+
"sha256": "c45fffd2d1320cfadd2177421e8b58643676a71fa1bac7a01e4219c60bfe20b1"
|
|
12141
12141
|
},
|
|
12142
12142
|
{
|
|
12143
12143
|
"path": "src/bongos/module-submissions.js",
|
|
@@ -12292,7 +12292,7 @@
|
|
|
12292
12292
|
{
|
|
12293
12293
|
"path": "src/bongos/routes/modules.js",
|
|
12294
12294
|
"mode": "0000644",
|
|
12295
|
-
"sha256": "
|
|
12295
|
+
"sha256": "aea0a0f28621500bee51e4fec8e794139cf90c3360fdc55caecbbd5a5fc82b69"
|
|
12296
12296
|
},
|
|
12297
12297
|
{
|
|
12298
12298
|
"path": "src/bongos/routes/my-sessions.js",
|
|
@@ -12372,7 +12372,7 @@
|
|
|
12372
12372
|
{
|
|
12373
12373
|
"path": "src/module-api.js",
|
|
12374
12374
|
"mode": "0000644",
|
|
12375
|
-
"sha256": "
|
|
12375
|
+
"sha256": "2fd52ee0afe6fc9ade992a558ebb0fc3ad5904f876f7c5093dd60ea5d201b448"
|
|
12376
12376
|
},
|
|
12377
12377
|
{
|
|
12378
12378
|
"path": "src/module-loader/catalog.js",
|
|
@@ -15754,6 +15754,11 @@
|
|
|
15754
15754
|
"mode": "0000644",
|
|
15755
15755
|
"sha256": "43828c1a95652180d558e7676cc2053d0599a61c0edf4cd1ab100820fc7de493"
|
|
15756
15756
|
},
|
|
15757
|
+
{
|
|
15758
|
+
"path": "tests/module_store_instance_root.mjs",
|
|
15759
|
+
"mode": "0000644",
|
|
15760
|
+
"sha256": "aa66cdb054eeaef8c3c8babf5238f029d13b668a45a97230efe5f8cfa8908d48"
|
|
15761
|
+
},
|
|
15757
15762
|
{
|
|
15758
15763
|
"path": "tests/module_store_listing.mjs",
|
|
15759
15764
|
"mode": "0000644",
|
package/docs/architecture.md
CHANGED
|
@@ -291,7 +291,7 @@ Bongos views: `claimable_tasks` (rebuilt in 006 to use SELECT t.*; rebuilt again
|
|
|
291
291
|
- Own-scoped session self-service ([ADR 0206](adr/0206-own-scoped-session-revocation-no-absolute-ceiling.md), task 1003341), in [`src/bongos/routes/my-sessions.js`](../src/bongos/routes/my-sessions.js): `GET /me/sessions` (every live session on the caller's own account — source, user agent, created/last-used/expires, a `current` flag; the token value is never selected), `POST /me/sessions/revoke` (`{session_id}` — one of your own; a miss answers `revoked: 0` so a stranger's id cannot be probed) and `POST /me/sessions/revoke-all` (sign out everywhere, the requesting session included). All three gate on `requireBuilder` alone and key every statement on `req.builder.id` — an account is never named in the query, body or path. They exist because a bearer refreshes to `now()+24h` on every call, so a leaked one never expires, and the cross-builder pair (`POST /auth/revoke`, `/auth/revoke-all`, both still gated on the admin `auth.session.revoke` atom) needed an Archon the victim might not have. Surfaced in the hall at Settings → Active sessions.
|
|
292
292
|
- Authenticated CLI/web: `GET /me` (returns `rank`), `GET /versions[/progress]`, `GET /tasks[?version=&status=&kind=&discipline=]`, `GET /tasks/claimable[?version=&discipline=]`, `GET /tasks/:id`, `POST /tasks` (accepts `discipline` + `criterion_ids`; **metic+** — [ADR 0090](adr/0090-metic-task-authoring.md)), `PATCH /tasks/:id` (supports `parent_task_id`, `kind`, `discipline`, `goal_id` — (re)assign the task's goal; must be a goal in the task's own version, task 1763; **metic+**), `POST /tasks/:id/promote` (**metic+**), `POST /claims`, `POST /claims/:id/resolve`, `POST /cost`.
|
|
293
293
|
- Criterion rollup + task↔criterion links ([ADR 0025](adr/0025-structured-criterion-task-link.md), [#435](https://example.com/builders#/task/435)/[#438](https://example.com/builders#/task/438)): `GET /versions/:id/progress` rolls up each done-when criterion → its gating tasks (via `task_criteria`) → live status counts + the not-yet-shipped `remaining[]` + `unattributed_tasks` (the read behind the `/status` skill, in `src/bongos/done-when.js criterionProgress`). The link CRUD mirrors the dependency endpoints: `GET /tasks/:id/criteria`, `POST /tasks/:id/criteria` (**metic+** — [ADR 0090](adr/0090-metic-task-authoring.md)) and `DELETE /tasks/:id/criteria/:criterionId` (**metic+**). A criterion ref is a numeric `done_when_criteria.id`, a positional `"Cn"` token, or a `criterion_id` slug, resolved against the task's version (`POST /tasks` links at create time so `/status` counts the task with no backfill).
|
|
294
|
-
- Module store publish (ADR 0338 D1, task 1004271): `POST /store/modules/:key/versions` (`requireBuilder` + `requirePermission('module.submit')`, Metic floor). The body is the raw gzip tarball `bongos module publish` builds (own `express.raw` parser, 5 MB cap; 16 MB / 2000 files unpacked). `scripts/gds/module-artifact.js` re-verifies every hash and the publish gate, then `src/bongos/module-store.js` keeps the file under `var/module-store/` and inserts the `store_module_versions` row in one transaction. The first publish makes the caller the author; later versions are author-only, newer than the last and never overwritten; a delisted key takes none.
|
|
294
|
+
- Module store publish (ADR 0338 D1, task 1004271): `POST /store/modules/:key/versions` (`requireBuilder` + `requirePermission('module.submit')`, Metic floor). The body is the raw gzip tarball `bongos module publish` builds (own `express.raw` parser, 5 MB cap; 16 MB / 2000 files unpacked). `scripts/gds/module-artifact.js` re-verifies every hash and the publish gate, then `src/bongos/module-store.js` keeps the file under the INSTANCE root's `var/module-store/` (`MODULE_STORE_DIR` overrides; never inside the installed core package, which every upgrade replaces, and a boot move carries files from that old path, task 1004399) and inserts the `store_module_versions` row in one transaction. The first publish makes the caller the author; later versions are author-only, newer than the last and never overwritten; a delisted key takes none.
|
|
295
295
|
- Module store install (ADR 0338 D1, task 1003785), all `requireBuilder` and own-scoped (the holder is the caller): `POST /store/modules/:key/acquire` (`{version?}`; grants a free entitlement — no price row, or a zero price, is free — or `402 payment_required` for a priced module; refuses a delisted one), `GET /store/modules/:key/versions/:version/tarball` (served only to a live entitlement holder; `X-Tarball-Sha256`), `POST /store/modules/:key/acquired` (`{version}` → `recordAcquired`). Read side in `src/bongos/module-store.js` (`resolveAcquirable`, `isFreePrice`, `versionArtifactFile`). No channel check yet (tasks 1003815, 1003805). `bongos module update` (task 1003786) reuses the same three calls; the acquire route reads price only for a caller with no entitlement, so a holder is never charged again.
|
|
296
296
|
- Module store delist (ADR 0338 D2, task 1003797): `POST /store/modules/:key/delist` (`requireBuilder` + `requirePermission('module.delist')`, Metic floor, granted by `government_022`; strict body `{reason}`, reason required, recorded by the audit middleware). Sets `store_modules.status='delisted'` and `delisted_at` — one-way, no relist route. Every store read already refuses a delisted key (acquire, tarball, acquired, publish), while `GET /store/modules/latest` still answers with the status so `bongos upgrade` warns the holder. `GET /modules` sets `store_delisted` on an instance-tree module the store delisted (hall pill "delisted · no more updates"), and `bongos module list` marks it DELISTED (fail-soft store check, instance modules only). Never revokes an entitlement, never reaches an installed copy, never writes a score (ADR 0359). 404 unknown key, 409 `already_delisted`.
|
|
297
297
|
- Server-mediated branch publish (ADR 0055 / [#1025](https://example.com/builders#/task/1025) — lets a checkout with no GitHub push credential ship). Owner-gated like ship: `POST /tasks/:id/publish-branch` (`requireBuilder` + `gateTaskOwnership`; own 32 MB json parser for the base64 thin-bundle body, 20 MB decoded cap; the server pushes the branch + opens the PR + auto-merges with a server-side push credential — a **GitHub App** installation token (short-lived, repo-scoped; preferred, [ADR 0055](adr/0055-server-mediated-branch-publish.md) update / task 1028) or the `GITHUB_PUSH_TOKEN` PAT fallback; `503 push_unconfigured` when neither is set; `400 tip_mismatch` if the bundle tip ≠ the claimed `head_sha`) and `GET /tasks/:id/publish-status?branch=…` (polls PR + deploy-prod state derived live from GitHub). `branch` is optional: without it the server finds the task's own PR by its `task <id>: …` title, and the answer names the `branch` it used and its `branch_source` (`query` / `task_pr`, or `null` with a `branch_hint` when there is no such PR) — never a `400` (task 1003764). When the PR is still open it also carries `merge_driver`, whose `reason` says why it has not merged; on a red PR `failing_tests` names the failing unit tests (read from the `unit-report` commit status the `unit` workflow posts) and `checks_unreadable: true` says the check list is blind because the App lacks `Checks: read` — so an empty `checks[]` is never read as "nothing failed" (task 1003988, ADR 0301). Because the server's credential is the PR's AUTHOR, publish also **assigns the PR to the task's claim holder** and names them in the body (`modules/lifecycle/pr-assign.js`, task 1003991) — best-effort: the 201 carries `pr_assignee` (the login, or `null` when GitHub would not assign it) and a failed assignment never fails the publish. `ship.js` uses these in `ci` mode only when `pushVia()` resolves to server (no authenticated `gh` on the machine, or `<PREFIX>_PUSH_VIA_SERVER=1`); a machine with `gh` keeps the local push path unchanged. Implementation in `src/bongos/github-push.js`.
|
|
@@ -3002,5 +3002,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
3002
3002
|
landed since 1.21.83 with no explicit bump. run 37876887587. (task 1002620)
|
|
3003
3003
|
1.21.85 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
3004
3004
|
landed since 1.21.84 with no explicit bump. run 37884200093. (task 1002620)
|
|
3005
|
+
1.21.86 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
3006
|
+
landed since 1.21.85 with no explicit bump. run 37885516955. (task 1002620)
|
|
3005
3007
|
---------------------------------------------------------------------------
|
|
3006
3008
|
```
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.86",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.21.
|
|
9
|
+
"version": "1.21.86",
|
|
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.21.
|
|
3
|
+
"version": "1.21.86",
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -9336,5 +9336,11 @@
|
|
|
9336
9336
|
"id": "1004379",
|
|
9337
9337
|
"text": "A page tweak that passed its grade and is waiting for its artist no longer reports itself as a failed grade: the closing card, the PR description and the credits line now all say it passed and is waiting for the artist's appro"
|
|
9338
9338
|
}
|
|
9339
|
+
],
|
|
9340
|
+
"1.21.86": [
|
|
9341
|
+
{
|
|
9342
|
+
"id": "1004399",
|
|
9343
|
+
"text": "Published module packages are now kept in the project's own data folder instead of inside the installed core, so a core upgrade no longer deletes them; any already stored in the old place are moved over when the server starts."
|
|
9344
|
+
}
|
|
9339
9345
|
]
|
|
9340
9346
|
}
|
|
@@ -8,9 +8,8 @@
|
|
|
8
8
|
// 1. stageArtifact — write the tarball under the store dir (a temp name first;
|
|
9
9
|
// commitArtifact moves it into place inside step 2's transaction). ADR 0338 lets the
|
|
10
10
|
// store keep tarballs on the control plane's disk while volume is small, so it
|
|
11
|
-
// does: <MODULE_STORE_DIR or var/module-store>/<key>/<key>-<version>.tgz
|
|
12
|
-
//
|
|
13
|
-
// wipes it.
|
|
11
|
+
// does: <MODULE_STORE_DIR or <instance root>/var/module-store>/<key>/<key>-<version>.tgz
|
|
12
|
+
// (see storeDirFor for why the INSTANCE root, task 1004399).
|
|
14
13
|
// 2. publishVersion — one transaction over the registry tables (core_257): create
|
|
15
14
|
// the store_modules row on a key's first publish (the publisher becomes its
|
|
16
15
|
// author), refuse anyone else's key, refuse a delisted key (ADR 0338 D2: delisting
|
|
@@ -26,9 +25,105 @@ const crypto = require('node:crypto');
|
|
|
26
25
|
const { parseVersion, cmp } = require('../module-loader/semver');
|
|
27
26
|
const { KEY_RE } = require('../module-loader/manifest-schema');
|
|
28
27
|
|
|
28
|
+
// Where the store lives: <INSTANCE ROOT>/var/module-store (task 1004399).
|
|
29
|
+
//
|
|
30
|
+
// THE BUG THIS FIXES. This used to be path.join(__dirname, '..', '..', 'var',
|
|
31
|
+
// 'module-store') — the CORE root. Right in a single checkout, where core root and
|
|
32
|
+
// instance root are one directory; wrong in the consumer layout, where __dirname is
|
|
33
|
+
// node_modules/@bongos/core/src/bongos, so every published tarball was written INSIDE
|
|
34
|
+
// the installed package and every core upgrade (npm ci replaces it) erased them all,
|
|
35
|
+
// leaving registry rows pointing at nothing. Task 1004381 found and fixed the same
|
|
36
|
+
// failure for task visuals (modules/lifecycle/task-visuals.js); this follows it.
|
|
37
|
+
//
|
|
38
|
+
// MODULE_STORE_DIR stays the override (tests, or an operator who wants the tarballs on
|
|
39
|
+
// another disk). The default store carries a `*` .gitignore, because the instance root
|
|
40
|
+
// is a git checkout whose tree `bongos upgrade` requires to be clean.
|
|
41
|
+
let instanceRootCache;
|
|
42
|
+
function instanceRoot() {
|
|
43
|
+
if (instanceRootCache === undefined) {
|
|
44
|
+
try {
|
|
45
|
+
instanceRootCache = require('../instance-config').resolveInstanceRoot();
|
|
46
|
+
} catch (e) {
|
|
47
|
+
// A storage-path lookup must never take the server down: fall back to the
|
|
48
|
+
// historical location, which is right in a single checkout.
|
|
49
|
+
instanceRootCache = path.join(__dirname, '..', '..');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return instanceRootCache;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// PURE given its inputs, so the consumer-layout test can prove where it lands.
|
|
56
|
+
function storeDirFor({ env = process.env, root } = {}) {
|
|
57
|
+
if (env.MODULE_STORE_DIR) return env.MODULE_STORE_DIR;
|
|
58
|
+
return path.join(root || instanceRoot(), 'var', 'module-store');
|
|
59
|
+
}
|
|
29
60
|
|
|
30
61
|
function storeDir() {
|
|
31
|
-
return
|
|
62
|
+
return storeDirFor();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The old, in-package location. Read only by migrateLegacyStore.
|
|
66
|
+
function legacyStoreDir() {
|
|
67
|
+
return path.join(__dirname, '..', '..', 'var', 'module-store');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// mkdir -p the store and, for the DEFAULT store, drop a `*` .gitignore in it so the
|
|
71
|
+
// tarballs never show as untracked in the instance's git tree (an untracked path
|
|
72
|
+
// there halts the next `bongos upgrade` pre-flight). An override is the operator's
|
|
73
|
+
// own directory and is left bare. Idempotent; the .gitignore write is best-effort.
|
|
74
|
+
const DIR_GITIGNORE = '# Module store tarballs (task 1004399): runtime data, never committed.\n*\n';
|
|
75
|
+
async function ensureStoreDir(dir, { env = process.env } = {}) {
|
|
76
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
77
|
+
if (env.MODULE_STORE_DIR || path.resolve(dir) !== path.resolve(storeDirFor({ env }))) return;
|
|
78
|
+
try {
|
|
79
|
+
await fs.promises.writeFile(path.join(dir, '.gitignore'), DIR_GITIGNORE, { flag: 'wx' });
|
|
80
|
+
} catch { /* EEXIST is the steady state; anything else is non-fatal */ }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// One-time boot move: every tarball still at the old in-package path moves into the
|
|
84
|
+
// instance store, keeping its <key>/<key>-<version>.tgz place — the registry stores
|
|
85
|
+
// artifact_path RELATIVE to the store dir, so the rows stay right with no data
|
|
86
|
+
// migration. Only well-formed artifacts move (a key directory matching KEY_RE holding
|
|
87
|
+
// "<key>-<version>.tgz"); a leftover .partial is not one. A no-op when both paths are
|
|
88
|
+
// the same directory (a single checkout), the old one is absent, or MODULE_STORE_DIR
|
|
89
|
+
// is set (the old code honoured it too, so nothing was ever written to the legacy
|
|
90
|
+
// path). Never overwrites, never throws: returns { moved, skipped, failed }.
|
|
91
|
+
async function migrateLegacyStore({ from, to = storeDir(), env = process.env } = {}) {
|
|
92
|
+
const out = { moved: 0, skipped: 0, failed: 0 };
|
|
93
|
+
if (from === undefined) {
|
|
94
|
+
if (env.MODULE_STORE_DIR) return out;
|
|
95
|
+
from = legacyStoreDir();
|
|
96
|
+
}
|
|
97
|
+
if (path.resolve(from) === path.resolve(to)) return out;
|
|
98
|
+
let keys;
|
|
99
|
+
try { keys = await fs.promises.readdir(from); } catch { return out; }
|
|
100
|
+
for (const key of keys.filter((k) => KEY_RE.test(k))) {
|
|
101
|
+
let names;
|
|
102
|
+
try { names = await fs.promises.readdir(path.join(from, key)); } catch { continue; }
|
|
103
|
+
for (const name of names) {
|
|
104
|
+
const m = name.startsWith(`${key}-`) && name.endsWith('.tgz') ? name.slice(key.length + 1, -4) : null;
|
|
105
|
+
if (!m || !parseVersion(m)) continue;
|
|
106
|
+
const src = path.join(from, key, name);
|
|
107
|
+
const dest = artifactPath(key, m, { dir: to });
|
|
108
|
+
try {
|
|
109
|
+
try { await fs.promises.access(dest); out.skipped += 1; continue; } catch { /* absent: move it */ }
|
|
110
|
+
await ensureStoreDir(to, { env });
|
|
111
|
+
await fs.promises.mkdir(path.dirname(dest), { recursive: true });
|
|
112
|
+
try {
|
|
113
|
+
await fs.promises.rename(src, dest);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if (!err || err.code !== 'EXDEV') throw err;
|
|
116
|
+
// Another filesystem: copy, then remove the original.
|
|
117
|
+
await fs.promises.copyFile(src, dest, fs.constants.COPYFILE_EXCL);
|
|
118
|
+
await fs.promises.unlink(src);
|
|
119
|
+
}
|
|
120
|
+
out.moved += 1;
|
|
121
|
+
} catch {
|
|
122
|
+
out.failed += 1;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
32
127
|
}
|
|
33
128
|
|
|
34
129
|
// Where one version's tarball lives. Key and version are re-validated here so this
|
|
@@ -48,6 +143,7 @@ function artifactPath(key, version, { dir = storeDir() } = {}) {
|
|
|
48
143
|
// Async throughout so a multi-MB write never holds the web process's event loop.
|
|
49
144
|
async function stageArtifact(buf, { key, version, dir = storeDir() } = {}) {
|
|
50
145
|
const final = artifactPath(key, version, { dir });
|
|
146
|
+
await ensureStoreDir(dir);
|
|
51
147
|
await fs.promises.mkdir(path.dirname(final), { recursive: true });
|
|
52
148
|
const tmp = `${final}.${process.pid}.${Date.now()}.${crypto.randomBytes(4).toString('hex')}.partial`;
|
|
53
149
|
await fs.promises.writeFile(tmp, buf, { flag: 'wx', mode: 0o640 });
|
|
@@ -276,5 +372,5 @@ function versionArtifactFile(row, { dir = storeDir() } = {}) {
|
|
|
276
372
|
module.exports = {
|
|
277
373
|
stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion,
|
|
278
374
|
isFreePrice, resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
|
|
279
|
-
delistModule, delistedKeySet,
|
|
375
|
+
delistModule, delistedKeySet, storeDir, storeDirFor, migrateLegacyStore,
|
|
280
376
|
};
|
|
@@ -95,7 +95,7 @@ const { checkEntitlement, listEntitlements, grantEntitlement, recordAcquired } =
|
|
|
95
95
|
const {
|
|
96
96
|
stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion,
|
|
97
97
|
resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
|
|
98
|
-
delistModule, delistedKeySet,
|
|
98
|
+
delistModule, delistedKeySet, migrateLegacyStore,
|
|
99
99
|
} = require('../module-store');
|
|
100
100
|
const {
|
|
101
101
|
isOptedOut, setReporting, installReportError, errorCountError, recordInstallReport, recordErrorCount,
|
|
@@ -210,6 +210,12 @@ function contributeModuleDesk() {
|
|
|
210
210
|
module.exports = function buildModulesRouter() {
|
|
211
211
|
contributeModuleRechecksToDocket();
|
|
212
212
|
contributeModuleDesk();
|
|
213
|
+
// Task 1004399: carry tarballs written inside the installed core package (the old
|
|
214
|
+
// store path) into the instance store before the next upgrade erases them. Async and
|
|
215
|
+
// never fatal; a no-op in a single checkout, where the two paths are one directory.
|
|
216
|
+
migrateLegacyStore()
|
|
217
|
+
.then((r) => { if (r.moved || r.failed) log.info(r, 'module store: moved from the old in-package store'); })
|
|
218
|
+
.catch((err) => log.error({ err: err && err.message }, 'module store: boot move failed'));
|
|
213
219
|
const router = express.Router();
|
|
214
220
|
// Checked how-tos by "key@version:tarball_sha256". Per router, so a test's fresh
|
|
215
221
|
// router starts empty.
|
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.21.
|
|
78
|
+
const CORE_VERSION = '1.21.86'; // 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');
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// tests/module_store_instance_root.mjs
|
|
2
|
+
//
|
|
3
|
+
// Task 1004399: published module tarballs must be stored under the INSTANCE root,
|
|
4
|
+
// never inside the installed core package, so they survive a core upgrade. The
|
|
5
|
+
// sibling of tests/task_visuals_instance_root.mjs (task 1004381), which found the
|
|
6
|
+
// same bug in the task-visuals store.
|
|
7
|
+
//
|
|
8
|
+
// This builds a real consumer tree in a temp dir: a fake installed core package
|
|
9
|
+
// carrying the REAL src/bongos/module-store.js, with its three core requires
|
|
10
|
+
// re-exporting the real files, then proves, through that installed copy:
|
|
11
|
+
// 1. the store is the instance's var/module-store, not inside the package;
|
|
12
|
+
// 2. a published tarball survives the package being deleted and reinstalled,
|
|
13
|
+
// and the registry row's artifact_path still resolves to it;
|
|
14
|
+
// 3. the boot move carries tarballs from the old in-package path, keeping their
|
|
15
|
+
// <key>/ place, once, without overwriting, skipping anything not an artifact,
|
|
16
|
+
// and is a no-op in a single checkout or under MODULE_STORE_DIR;
|
|
17
|
+
// 4. the store keeps the instance's git tree clean (upgrade pre-flight).
|
|
18
|
+
//
|
|
19
|
+
// Run: node tests/module_store_instance_root.mjs
|
|
20
|
+
|
|
21
|
+
import { strict as assert } from 'node:assert';
|
|
22
|
+
import { createRequire } from 'node:module';
|
|
23
|
+
import { spawnSync } from 'node:child_process';
|
|
24
|
+
import os from 'node:os';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
|
|
29
|
+
const require = createRequire(import.meta.url);
|
|
30
|
+
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
31
|
+
const REAL = (...p) => path.join(REPO, 'src', ...p);
|
|
32
|
+
|
|
33
|
+
let passed = 0, failed = 0;
|
|
34
|
+
async function t(name, fn) {
|
|
35
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
36
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e && e.stack ? e.stack.split('\n').slice(0, 3).join('\n ') : e}`); }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const TGZ = Buffer.from('1f8b0800000000000003', 'hex');
|
|
40
|
+
|
|
41
|
+
const WORK = fs.mkdtempSync(path.join(os.tmpdir(), 'ms-instance-root-'));
|
|
42
|
+
const CONSUMER = path.join(WORK, 'consumer');
|
|
43
|
+
const CORE = path.join(CONSUMER, 'node_modules', '@bongos', 'core');
|
|
44
|
+
const reexport = (real) => `module.exports = require(${JSON.stringify(real)});\n`;
|
|
45
|
+
|
|
46
|
+
// "npm ci": (re)install the fake core package — the real store, its requires stubbed through.
|
|
47
|
+
function installCore() {
|
|
48
|
+
fs.rmSync(CORE, { recursive: true, force: true });
|
|
49
|
+
fs.mkdirSync(path.join(CORE, 'src', 'bongos'), { recursive: true });
|
|
50
|
+
fs.mkdirSync(path.join(CORE, 'src', 'module-loader'), { recursive: true });
|
|
51
|
+
fs.copyFileSync(REAL('bongos', 'module-store.js'), path.join(CORE, 'src', 'bongos', 'module-store.js'));
|
|
52
|
+
fs.writeFileSync(path.join(CORE, 'src', 'module-loader', 'semver.js'), reexport(REAL('module-loader', 'semver.js')));
|
|
53
|
+
fs.writeFileSync(path.join(CORE, 'src', 'module-loader', 'manifest-schema.js'), reexport(REAL('module-loader', 'manifest-schema.js')));
|
|
54
|
+
fs.writeFileSync(path.join(CORE, 'src', 'instance-config.js'), reexport(REAL('instance-config.js')));
|
|
55
|
+
}
|
|
56
|
+
// A fresh require of the INSTALLED copy (a restarted server).
|
|
57
|
+
function loadInstalled() {
|
|
58
|
+
const p = path.join(CORE, 'src', 'bongos', 'module-store.js');
|
|
59
|
+
delete require.cache[p];
|
|
60
|
+
return require(p);
|
|
61
|
+
}
|
|
62
|
+
const inside = (child, parent) => {
|
|
63
|
+
const rel = path.relative(path.resolve(parent), path.resolve(child));
|
|
64
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
fs.mkdirSync(CONSUMER, { recursive: true });
|
|
68
|
+
installCore();
|
|
69
|
+
delete process.env.MODULE_STORE_DIR;
|
|
70
|
+
process.env.BONGOS_INSTANCE_ROOT = CONSUMER;
|
|
71
|
+
|
|
72
|
+
await t('consumer layout: the store is <instance>/var/module-store, NOT inside the core package', () => {
|
|
73
|
+
const ms = loadInstalled();
|
|
74
|
+
const dir = ms.storeDir();
|
|
75
|
+
assert.equal(path.resolve(dir), path.join(CONSUMER, 'var', 'module-store'));
|
|
76
|
+
assert.ok(!inside(dir, CORE), `store ${dir} is inside the core package ${CORE}`);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await t('MODULE_STORE_DIR still overrides', () => {
|
|
80
|
+
const ms = loadInstalled();
|
|
81
|
+
assert.equal(ms.storeDirFor({ env: { MODULE_STORE_DIR: '/elsewhere/x' } }), '/elsewhere/x');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
await t('the checkout copy resolves the same way: an instance root, or the override', () => {
|
|
85
|
+
const { storeDir, storeDirFor } = require('../src/bongos/module-store.js');
|
|
86
|
+
assert.equal(storeDirFor({ env: {}, root: '/inst' }), path.join('/inst', 'var', 'module-store'));
|
|
87
|
+
assert.equal(path.resolve(storeDir()), path.join(CONSUMER, 'var', 'module-store'));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
let row;
|
|
91
|
+
await t('a published tarball survives a core upgrade (package deleted and reinstalled)', async () => {
|
|
92
|
+
const ms = loadInstalled();
|
|
93
|
+
const staged = await ms.stageArtifact(TGZ, { key: 'weather', version: '1.2.0' });
|
|
94
|
+
await ms.commitArtifact(staged);
|
|
95
|
+
row = { module_key: 'weather', version: '1.2.0', artifact_path: ms.relativeArtifactPath(staged.final) };
|
|
96
|
+
assert.equal(row.artifact_path, 'weather/weather-1.2.0.tgz');
|
|
97
|
+
installCore(); // the upgrade
|
|
98
|
+
const after = loadInstalled();
|
|
99
|
+
const file = after.versionArtifactFile(row);
|
|
100
|
+
assert.ok(fs.existsSync(file), 'the tarball is gone after the upgrade');
|
|
101
|
+
assert.deepEqual(fs.readFileSync(file), TGZ);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await t('the default store carries a * .gitignore, so the instance git tree stays clean', () => {
|
|
105
|
+
const gi = fs.readFileSync(path.join(CONSUMER, 'var', 'module-store', '.gitignore'), 'utf8');
|
|
106
|
+
assert.match(gi, /^\*$/m);
|
|
107
|
+
const init = spawnSync('git', ['init', '-q'], { cwd: CONSUMER, encoding: 'utf8' });
|
|
108
|
+
if (init.status !== 0) { console.log(' (git unavailable: .gitignore content checked only)'); return; }
|
|
109
|
+
fs.writeFileSync(path.join(CONSUMER, '.gitignore'), 'node_modules/\n');
|
|
110
|
+
const st = spawnSync('git', ['status', '--porcelain', '--untracked-files=all', '--', 'var'], { cwd: CONSUMER, encoding: 'utf8' });
|
|
111
|
+
assert.equal(st.status, 0);
|
|
112
|
+
assert.equal(st.stdout.trim(), '', `var/ shows in git status:\n${st.stdout}`);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await t('boot move: tarballs at the old in-package path move into the instance store, once', async () => {
|
|
116
|
+
const ms = loadInstalled();
|
|
117
|
+
const legacy = path.join(CORE, 'var', 'module-store');
|
|
118
|
+
fs.mkdirSync(path.join(legacy, 'notes'), { recursive: true });
|
|
119
|
+
fs.mkdirSync(path.join(legacy, 'weather'), { recursive: true });
|
|
120
|
+
fs.mkdirSync(path.join(legacy, 'Bad Key'), { recursive: true });
|
|
121
|
+
fs.writeFileSync(path.join(legacy, 'notes', 'notes-0.1.0.tgz'), TGZ);
|
|
122
|
+
fs.writeFileSync(path.join(legacy, 'notes', 'notes-0.1.0.tgz.123.456.ab.partial'), 'half');
|
|
123
|
+
fs.writeFileSync(path.join(legacy, 'notes', 'other-0.1.0.tgz'), 'not this key');
|
|
124
|
+
fs.writeFileSync(path.join(legacy, 'Bad Key', 'Bad Key-1.0.0.tgz'), 'x');
|
|
125
|
+
// weather 1.2.0 already exists in the new store: kept, never overwritten.
|
|
126
|
+
fs.writeFileSync(path.join(legacy, 'weather', 'weather-1.2.0.tgz'), 'legacy copy');
|
|
127
|
+
|
|
128
|
+
const r = await ms.migrateLegacyStore();
|
|
129
|
+
assert.deepEqual(r, { moved: 1, skipped: 1, failed: 0 });
|
|
130
|
+
const store = ms.storeDir();
|
|
131
|
+
assert.deepEqual(fs.readFileSync(path.join(store, 'notes', 'notes-0.1.0.tgz')), TGZ);
|
|
132
|
+
assert.ok(!fs.existsSync(path.join(legacy, 'notes', 'notes-0.1.0.tgz')), 'moved, not copied');
|
|
133
|
+
assert.deepEqual(fs.readFileSync(path.join(store, 'weather', 'weather-1.2.0.tgz')), TGZ, 'the existing file is not overwritten');
|
|
134
|
+
assert.ok(!fs.existsSync(path.join(store, 'notes', 'notes-0.1.0.tgz.123.456.ab.partial')), 'a .partial is not an artifact');
|
|
135
|
+
assert.ok(!fs.existsSync(path.join(store, 'Bad Key')), 'a directory that is not a module key is left alone');
|
|
136
|
+
|
|
137
|
+
// A moved tarball is served through its unchanged registry row.
|
|
138
|
+
const moved = ms.versionArtifactFile({ module_key: 'notes', version: '0.1.0', artifact_path: 'notes/notes-0.1.0.tgz' });
|
|
139
|
+
assert.ok(fs.existsSync(moved));
|
|
140
|
+
|
|
141
|
+
const again = await ms.migrateLegacyStore();
|
|
142
|
+
assert.equal(again.moved, 0, 'the second boot moves nothing');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await t('boot move is a no-op in a single checkout and under MODULE_STORE_DIR', async () => {
|
|
146
|
+
const ms = loadInstalled();
|
|
147
|
+
const dir = fs.mkdtempSync(path.join(WORK, 'same-'));
|
|
148
|
+
fs.mkdirSync(path.join(dir, 'notes'));
|
|
149
|
+
fs.writeFileSync(path.join(dir, 'notes', 'notes-0.2.0.tgz'), TGZ);
|
|
150
|
+
assert.deepEqual(await ms.migrateLegacyStore({ from: dir, to: dir }), { moved: 0, skipped: 0, failed: 0 });
|
|
151
|
+
assert.deepEqual(await ms.migrateLegacyStore({ env: { MODULE_STORE_DIR: dir } }), { moved: 0, skipped: 0, failed: 0 });
|
|
152
|
+
assert.ok(fs.existsSync(path.join(dir, 'notes', 'notes-0.2.0.tgz')));
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await t('the modules router runs the boot move when it is built', () => {
|
|
156
|
+
const src = fs.readFileSync(path.join(REPO, 'src', 'bongos', 'routes', 'modules.js'), 'utf8');
|
|
157
|
+
const body = src.slice(src.indexOf('module.exports = function buildModulesRouter('));
|
|
158
|
+
assert.ok(body.indexOf('migrateLegacyStore()') > 0 && body.indexOf('migrateLegacyStore()') < body.indexOf('express.Router()'));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
fs.rmSync(WORK, { recursive: true, force: true });
|
|
162
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
163
|
+
if (failed) process.exitCode = 1;
|