@bongos/core 1.21.168 → 1.21.169

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 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.168",
6
- "core_contract": "1.21.168",
7
- "source_commit": "81ee30904d575050d008bd2cc16c8ecb8ef9df18",
5
+ "core_version": "1.21.169",
6
+ "core_contract": "1.21.169",
7
+ "source_commit": "8b02f41534160e6504eabfe51100a2495d211974",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-10-23T16:53:45.959Z",
9
+ "built_at": "2026-10-23T17:05:22.541Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 609,
13
13
  "agent_docs_stubbed": 28,
14
- "functional_verbatim": 3192,
14
+ "functional_verbatim": 3193,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 3830,
20
- "tree_sha256": "249eab6a2df12b3cd7c9f4a9f0268db4ba0aa3947e9e29064ee37bd8ea110968",
19
+ "file_count": 3831,
20
+ "tree_sha256": "bac0beb6098d17b2b1f50303ea3e9fdee6fe962ca9b7670f43b1770dac79c7de",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2387,12 +2387,12 @@
2387
2387
  {
2388
2388
  "path": "docs/api/openapi.json",
2389
2389
  "mode": "0000644",
2390
- "sha256": "9505031bf22d91c4700dc34091784bfdce71eedec8f6ad4b5d88e860a4773f19"
2390
+ "sha256": "01aa4a0660af5c27d90556aa30b440fb1236caeb75ea3ce2f0020437bbe11542"
2391
2391
  },
2392
2392
  {
2393
2393
  "path": "docs/architecture.md",
2394
2394
  "mode": "0000644",
2395
- "sha256": "3850775c198edeece6ac5497a9f5788421a8082e21edb496a4b00a3fc7a6eb3d"
2395
+ "sha256": "37dc2e5faa441c7300026b5d70bbfc05ff19639c12eae38fa2e4f3c4dcf7e899"
2396
2396
  },
2397
2397
  {
2398
2398
  "path": "docs/branding-contract.md",
@@ -2927,12 +2927,12 @@
2927
2927
  {
2928
2928
  "path": "docs/module-api-changelog.md",
2929
2929
  "mode": "0000644",
2930
- "sha256": "182a1cf009b33b4a55c20b9a33fb0067c655d68b2bed6666a34b61777706d802"
2930
+ "sha256": "2167306eca652743c7e6f345a0837ff342db271856e3ce08a6cd87f216cf4b23"
2931
2931
  },
2932
2932
  {
2933
2933
  "path": "docs/modules-contract.md",
2934
2934
  "mode": "0000644",
2935
- "sha256": "dfb54888224c2d4735bd6411873fa7b1f4ebe3e95b2ce308ca83036289c7bb80"
2935
+ "sha256": "9e3a88916b780e0ae8c842bebfe892f513a61e2a234ab32dd7480873bd7de94f"
2936
2936
  },
2937
2937
  {
2938
2938
  "path": "docs/onboarding/diagrams/01-task-lifecycle.mmd",
@@ -10602,12 +10602,12 @@
10602
10602
  {
10603
10603
  "path": "package-lock.json",
10604
10604
  "mode": "0000644",
10605
- "sha256": "49f0cefbacf1b7d854acc23f63b04804bbf3858ebec63aab8beb5a11e6ec42a1"
10605
+ "sha256": "34102111462f50e10260246a90a52182e37c80d0ccd3bec9e803ebff646eb732"
10606
10606
  },
10607
10607
  {
10608
10608
  "path": "package.json",
10609
10609
  "mode": "0000644",
10610
- "sha256": "6f48dbfc47a83cbcaf089d56c4dede80cf6f965d9b15279341cf1a270672da08"
10610
+ "sha256": "12da6d57d97df3fa29bde1ad54891bbd8adb1a36cfcd1841a793a6b37ea24edb"
10611
10611
  },
10612
10612
  {
10613
10613
  "path": "public-docs/index.html",
@@ -10627,7 +10627,7 @@
10627
10627
  {
10628
10628
  "path": "release-notes.json",
10629
10629
  "mode": "0000644",
10630
- "sha256": "204380206f47e8b2456888adf61061ce51458d8183cfdfc6d5efd1bc54e33661"
10630
+ "sha256": "fab9655657d0c99fa95529dde44fcdfac9fc830520286ba1b6f9682477e45992"
10631
10631
  },
10632
10632
  {
10633
10633
  "path": "scripts/cron/style_guide_audit.sh",
@@ -12762,7 +12762,7 @@
12762
12762
  {
12763
12763
  "path": "src/bongos/module-store.js",
12764
12764
  "mode": "0000644",
12765
- "sha256": "fb6fdff53de296e7b8d981082013d57f65f1e89ae234a7d417e6e455578b94a3"
12765
+ "sha256": "695c8f111d0cc1a3410128881c9624746df991c8e6017ae8df4ea1480cbf8239"
12766
12766
  },
12767
12767
  {
12768
12768
  "path": "src/bongos/module-submissions.js",
@@ -12927,7 +12927,7 @@
12927
12927
  {
12928
12928
  "path": "src/bongos/routes/modules.js",
12929
12929
  "mode": "0000644",
12930
- "sha256": "b76ae847ba4929ef1390e7600e425d89069a34e8e389d1a6a374bb18346db198"
12930
+ "sha256": "7ac9ce1a3d82549db000176970670d551a3c9b61994dc68d1f122b346dfb0878"
12931
12931
  },
12932
12932
  {
12933
12933
  "path": "src/bongos/routes/my-passkeys.js",
@@ -13017,7 +13017,7 @@
13017
13017
  {
13018
13018
  "path": "src/module-api.js",
13019
13019
  "mode": "0000644",
13020
- "sha256": "e76395a143e487f9d4e7d0c6e0127be41c489d0d9b85b468cd37325dbe6c7ef1"
13020
+ "sha256": "af3d3d00ecdd47a62cd383271cf2f606d0c695cd8ba189269764fd4108c0485d"
13021
13021
  },
13022
13022
  {
13023
13023
  "path": "src/module-loader/catalog.js",
@@ -16644,6 +16644,11 @@
16644
16644
  "mode": "0000644",
16645
16645
  "sha256": "ba408278116825c7acc4889aca4dd03cab08a6c5c1c0bddfe361997e72fca38c"
16646
16646
  },
16647
+ {
16648
+ "path": "tests/module_channel_install.mjs",
16649
+ "mode": "0000644",
16650
+ "sha256": "b1e6c6f0481fad0dc1f760f33c393b31884f37e9beee357b52c231a4c65a8b7b"
16651
+ },
16647
16652
  {
16648
16653
  "path": "tests/module_cli.mjs",
16649
16654
  "mode": "0000644",
@@ -16782,7 +16787,7 @@
16782
16787
  {
16783
16788
  "path": "tests/module_upgrade_updates.mjs",
16784
16789
  "mode": "0000644",
16785
- "sha256": "7260dabcf71a69d4ce41609e8a88c94c112fdda354f774dadf0a2648f4feacaf"
16790
+ "sha256": "580f672c411c83c4ddc60308409f208ba3f00894c9112add59b94c65b95d6feb"
16786
16791
  },
16787
16792
  {
16788
16793
  "path": "tests/modules.mjs",
@@ -23275,7 +23275,7 @@
23275
23275
  "store"
23276
23276
  ],
23277
23277
  "summary": "GET /store/modules/latest",
23278
- "description": "The newest published version of each named module — what `bongos upgrade` compares an instance's installed store modules against to report the updates available (task 1003787). `?keys=a,b,c`, at most STORE_LATEST_MAX_KEYS. rank: any-builder — a read of public store metadata (version numbers and the core range each needs, the same facts the how-to page shows). It takes no entitlement and changes nothing, unlike acquire; a key the store does not know is simply absent from the answer.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
23278
+ "description": "The newest published version of each named module — what `bongos upgrade` compares an instance's installed store modules against to report the updates available (task 1003787). `?keys=a,b,c`, at most STORE_LATEST_MAX_KEYS. Only versions on a channel this project takes count (task 1003805), so it reports exactly what `bongos module update` would hand over. rank: any-builder — a read of public store metadata (version numbers and the core range each needs, the same facts the how-to page shows). It takes no entitlement and changes nothing, unlike acquire; a key the store does not know is simply absent from the answer.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
23279
23279
  "x-rank": "any-builder",
23280
23280
  "x-source": "src/bongos/routes/modules.js",
23281
23281
  "responses": {
@@ -23313,7 +23313,7 @@
23313
23313
  "store"
23314
23314
  ],
23315
23315
  "summary": "POST /store/modules/:key/acquire",
23316
- "description": "---- install: acquire → download → record (task 1003785, ADR 0338 D1) ---- The server end of `bongos module install`. Three steps, so the store never records a version as acquired that the caller did not verify and place: 1. POST acquire — resolve the version, and take the entitlement: a free module (no price, or a zero price) is granted here with source 'free'; a priced one needs the buy action, which is area 8's (tasks 1003788/1003789) and answers 402 until then. 2. GET tarball — served only to a LIVE entitlement holder, never for a delisted module (ADR 0338 D2: no further versions served). 3. POST acquired — the CLI has verified the bytes with verifyModuleArtifact and placed them; recordAcquired stamps the exact version. Own-scoped like the entitlement reads: the holder is always the caller (holder_kind 'builder'), never named in the request, so requireBuilder is the gate — any signed-in builder may take a free module for themselves, and nothing here changes what runs on this instance (install never enables; the CLI places files on the CALLER's checkout). Channels: every version is on the general channel until the channel table (task 1003815) and \"install honours the channel\" (task 1003805) land; those add the channel check to step 1 and step 2, beside the entitlement check.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
23316
+ "description": "---- install: acquire → download → record (task 1003785, ADR 0338 D1) ---- The server end of `bongos module install`. Three steps, so the store never records a version as acquired that the caller did not verify and place: 1. POST acquire — resolve the version, and take the entitlement: a free module (no price, or a zero price) is granted here with source 'free'; a priced one needs the buy action, which is area 8's (tasks 1003788/1003789) and answers 402 until then. 2. GET tarball — served only to a LIVE entitlement holder, never for a delisted module (ADR 0338 D2: no further versions served). 3. POST acquired — the CLI has verified the bytes with verifyModuleArtifact and placed them; recordAcquired stamps the exact version. Own-scoped like the entitlement reads: the holder is always the caller (holder_kind 'builder'), never named in the request, so requireBuilder is the gate — any signed-in builder may take a free module for themselves, and nothing here changes what runs on this instance (install never enables; the CLI places files on the CALLER's checkout). Channels (task 1003805, ADR 0344): steps 1 and 2 hand out only versions on a channel this project takes (projectChannels) — with no version asked for, the newest there. Install and update share step 1, so both honour it. A project that stops testing keeps the tester version it has; its next update is the newest general one, and update only moves forward, so nothing rolls back. Step 3 records what was already placed, so it does not re-check the channel.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
23317
23317
  "x-rank": "any-builder",
23318
23318
  "x-source": "src/bongos/routes/modules.js",
23319
23319
  "parameters": [
@@ -271,6 +271,8 @@ store_module_version_channels(id, version_id FK store_module_versions, channel F
271
271
  reason short code, set_by builder | NULL (system), set_at)
272
272
  -- history; newest row per version is current. Pre-existing versions were
273
273
  -- backfilled to general ('predates_staged_rollout').
274
+ -- idx_store_module_version_channels_current (version_id, set_at DESC, id DESC):
275
+ -- "current channel" is one index probe per version, never a table scan.
274
276
  ```
275
277
 
276
278
  Module entitlements (migration `core_259`, task 1003813 — who holds which module, at which version; the record install, update and the telemetry signals consult; it records ownership, never payment — area 8 owns money):
@@ -3173,5 +3173,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
3173
3173
  landed since 1.21.166 with no explicit bump. run 39986992124. (task 1002620)
3174
3174
  1.21.168 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3175
3175
  landed since 1.21.167 with no explicit bump. run 39990468332. (task 1002620)
3176
+ 1.21.169 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3177
+ landed since 1.21.168 with no explicit bump. run 39992064615. (task 1002620)
3176
3178
  ---------------------------------------------------------------------------
3177
3179
  ```
@@ -407,7 +407,7 @@ Run `bongos upgrade` to confirm coreVersion compatibility, then `node scripts/gd
407
407
  - Install never enables. Switch the module on from the hall's Modules tab.
408
408
  - **Every version's how-to is a page** (task 1004366). `GET /store/modules/:key/versions/:version/howto` reads the `HOWTO.md` from that version's own stored tarball, after the same hash check install runs, and returns it as text; the hall shows it at `/builders/modules?howto=<key>&version=<X.Y.Z>`, rendered through the hall's escaping markdown reader (`md-reader.js`) because the file is author-written. Any signed-in builder can read it without holding the module; a delisted module's how-to reads only for people who hold it. "Save as PDF" is the browser's own print, with a print sheet that keeps only the document. The optional `howto.artifactUrl` shows as "Also available as a Claude page". Install prints the link, and a Modules-tab row links it when the module on disk is a published store version. A version published before the gate has none and answers 404 `howto_missing`.
409
409
  - 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.
410
- - A delisted module cannot be acquired or downloaded. Channels are not checked yet: install and update hand out a version whatever its channel until task 1003805 lands.
410
+ - A delisted module cannot be acquired or downloaded. **Install and update honour the channel** (task 1003805, [ADR 0344](adr/0344-a-tester-is-a-project-that-opts-in-once.md) D1/D2): a project that tests a module (`testsVersionsOf`) is handed the newest version on `tester` or `general`, every other project the newest on `general`, and nobody a `withdrawn` one (`servedChannels` in `src/bongos/module-store.js`). The acquire answer names the version's `channel`. Asking for an exact version off the project's channels, or downloading it, is refused with `version_not_on_channel` (409); a module with nothing on them yet answers `no_versions_on_channel` (404). `GET /store/modules/latest`, which `bongos upgrade` reads, counts only versions on those channels, so it reports what `bongos module update` would hand over. A project that stops testing keeps the tester version it has; its next update is the newest general one, and update only moves forward, so nothing is rolled back. Recording a version already placed (`POST …/acquired`) does not re-check the channel.
411
411
 
412
412
  `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).
413
413
 
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.168",
3
+ "version": "1.21.169",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.168",
9
+ "version": "1.21.169",
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.168",
3
+ "version": "1.21.169",
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",
@@ -10078,5 +10078,11 @@
10078
10078
  "id": "1004777",
10079
10079
  "text": "A page tweak that sits too long now tells someone. If a round waits too long for its artist's review, that artist gets a line in Needs attention on Home. If it waits to be applied or to go live, builders who can apply it get t"
10080
10080
  }
10081
+ ],
10082
+ "1.21.169": [
10083
+ {
10084
+ "id": "1003805",
10085
+ "text": "Projects that test new module versions now actually get them first, and everyone else only gets versions that have passed testing. Turning testing off keeps what you have and moves you to the tested versions from your next upd"
10086
+ }
10081
10087
  ]
10082
10088
  }
@@ -256,12 +256,34 @@ function isFreePrice(priceRow) {
256
256
  return priceRow.price_cents === 0 || priceRow.price_credits === 0;
257
257
  }
258
258
 
259
+ // The release channels a project is handed versions from (task 1003805, ADR 0344
260
+ // D1/D2): a project that tests this module gets tester AND general versions, every
261
+ // other project general only. 'withdrawn' is never in either list, so a withdrawn
262
+ // version is never handed out (a copy already installed keeps working, ADR 0338 D2).
263
+ function servedChannels(tester) {
264
+ return tester ? ['tester', 'general'] : ['general'];
265
+ }
266
+
267
+ // A version's current channel is its newest store_module_version_channels row
268
+ // (core_276): a move is a new row, never an update. One probe per version into
269
+ // idx_store_module_version_channels_current. `idCol` is the outer query's version
270
+ // id column, and only these two literals are accepted, so no text reaches the SQL.
271
+ const VERSION_ID_COLS = new Set(['store_module_versions.id', 'v.id']);
272
+ function currentChannelSql(idCol) {
273
+ if (!VERSION_ID_COLS.has(idCol)) throw new Error(`currentChannelSql: unexpected column ${idCol}`);
274
+ return `(SELECT c.channel FROM store_module_version_channels c
275
+ WHERE c.version_id = ${idCol} ORDER BY c.set_at DESC, c.id DESC LIMIT 1)`;
276
+ }
277
+
259
278
  // Resolve what an acquire of `key` (optionally at an exact `version`) would hand
260
279
  // over. Refuses an unknown or delisted module (ADR 0338 D2: a delisted module takes
261
280
  // no new acquisitions and serves no further versions) and an unknown version. With
262
281
  // no version asked for, the newest published version (semver order, not text).
282
+ // `channels` (from servedChannels) limits it to versions currently on those
283
+ // channels — the newest one there, or a refusal for an exact version that is not;
284
+ // null skips the channel check (recording a version already placed).
263
285
  // Returns { ok: true, module, version, price, free } or { ok: false, status, code, message }.
264
- async function resolveAcquirable(key, { version = null, db } = {}) {
286
+ async function resolveAcquirable(key, { version = null, channels = null, db } = {}) {
265
287
  const pool = db || require('./pool').pool;
266
288
  const refuse = (status, code, message) => ({ ok: false, status, code, message });
267
289
  const { rows: [mod] } = await pool.query(
@@ -270,16 +292,28 @@ async function resolveAcquirable(key, { version = null, db } = {}) {
270
292
  if (mod.status === 'delisted') return refuse(409, 'module_delisted', `"${key}" is delisted: it can no longer be acquired or downloaded (ADR 0338 D2).`);
271
293
  const { rows: versions } = await pool.query(
272
294
  `SELECT id, module_key, version, core_version, tree_sha256, tarball_sha256, tarball_bytes,
273
- artifact_path, published_at
295
+ artifact_path, published_at, ${currentChannelSql('store_module_versions.id')} AS channel
274
296
  FROM store_module_versions WHERE module_key = $1`, [key]);
297
+ const served = (r) => !channels || channels.includes(r.channel);
275
298
  let row;
276
299
  if (version) {
277
300
  row = versions.find((r) => r.version === version);
278
301
  if (!row) return refuse(404, 'version_not_found', `"${key}" has no published version ${version}.`);
302
+ if (!served(row)) {
303
+ return refuse(409, 'version_not_on_channel', row.channel === 'withdrawn'
304
+ ? `"${key}" ${version} was withdrawn: it is no longer handed out.`
305
+ : `"${key}" ${version} is still with testers, and this project takes general versions of it.`);
306
+ }
279
307
  } else {
280
- row = versions.filter((r) => parseVersion(r.version))
308
+ row = versions.filter((r) => parseVersion(r.version) && served(r))
281
309
  .sort((a, b) => cmp(parseVersion(a.version), parseVersion(b.version))).pop();
282
- if (!row) return refuse(404, 'no_versions', `"${key}" has no published version yet.`);
310
+ if (!row) {
311
+ if (!versions.length) return refuse(404, 'no_versions', `"${key}" has no published version yet.`);
312
+ const testing = versions.some((r) => r.channel === 'tester');
313
+ return refuse(404, 'no_versions_on_channel', testing
314
+ ? `"${key}" has no version on the channel this project takes yet: its newest is still with testers.`
315
+ : `"${key}" has no version this project can be handed: every version was withdrawn.`);
316
+ }
283
317
  }
284
318
  const { rows: [price] } = await pool.query(
285
319
  `SELECT price_cents, price_credits, set_at FROM store_module_prices
@@ -321,21 +355,25 @@ async function publishedVersionSet(keys, { db } = {}) {
321
355
  // takes no entitlement and refuses nothing, so a delisted module still answers,
322
356
  // with its status, and the caller says it gets no further versions (ADR 0338 D2).
323
357
  // A key the store does not know is simply absent. One query for the whole list.
324
- // Returns { [key]: { version, core_version, status } }.
325
- async function latestVersions(keys, { db } = {}) {
358
+ // `channelsFor(key)` (servedChannels per module) counts only versions on the
359
+ // channels this project takes, so upgrade reports what update would hand over
360
+ // (task 1003805); a key with nothing on them is absent too.
361
+ // Returns { [key]: { version, core_version, status, channel } }.
362
+ async function latestVersions(keys, { db, channelsFor = null } = {}) {
326
363
  if (!keys.length) return {};
327
364
  const pool = db || require('./pool').pool;
328
365
  const { rows } = await pool.query(
329
- `SELECT v.module_key, v.version, v.core_version, m.status
366
+ `SELECT v.module_key, v.version, v.core_version, m.status, ${currentChannelSql('v.id')} AS channel
330
367
  FROM store_module_versions v JOIN store_modules m ON m.module_key = v.module_key
331
368
  WHERE v.module_key = ANY($1)`, [keys]);
332
369
  const out = {};
333
370
  for (const r of rows) {
334
371
  const next = parseVersion(r.version);
335
372
  if (!next) continue;
373
+ if (channelsFor && !channelsFor(r.module_key).includes(r.channel)) continue;
336
374
  const have = out[r.module_key];
337
375
  if (have && cmp(parseVersion(have.version), next) >= 0) continue;
338
- out[r.module_key] = { version: r.version, core_version: r.core_version, status: r.status };
376
+ out[r.module_key] = { version: r.version, core_version: r.core_version, status: r.status, channel: r.channel };
339
377
  }
340
378
  return out;
341
379
  }
@@ -383,6 +421,6 @@ function versionArtifactFile(row, { dir = storeDir() } = {}) {
383
421
 
384
422
  module.exports = {
385
423
  stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion,
386
- isFreePrice, resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
424
+ isFreePrice, servedChannels, resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
387
425
  delistModule, delistedKeySet, storeDir, storeDirFor, migrateLegacyStore,
388
426
  };
@@ -96,7 +96,7 @@ const moduleAssessVersion = require('../../../scripts/gds/module-assess-version'
96
96
  const { checkEntitlement, listEntitlements, grantEntitlement, recordAcquired } = require('../module-entitlements');
97
97
  const {
98
98
  stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion,
99
- resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
99
+ servedChannels, resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
100
100
  delistModule, delistedKeySet, migrateLegacyStore,
101
101
  } = require('../module-store');
102
102
  const {
@@ -111,9 +111,20 @@ const log = require('../logger').logger.child({ src: 'modules' });
111
111
  // as unused exports and tripped the dead-code ratchet that held this branch out of main.
112
112
  const { listOverrides, setOverride } = require('../module-overrides');
113
113
  const { listCatalog, SORT_PARTS } = require('../module-listing');
114
- const { readTesting, setTester, setGeneralOnly } = require('../module-testing');
114
+ const { readTesting, setTester, setGeneralOnly, testsVersionsOf } = require('../module-testing');
115
115
  const { readStanding, releaseHeld } = require('../module-promotion');
116
116
 
117
+ // The channels this project takes, per module (task 1003805, ADR 0344 D1/D2): a
118
+ // tester project gets tester and general versions, except of a module it marked
119
+ // "general only"; everyone else gets general. A choice that cannot be read reads as
120
+ // NOT a tester (module-testing.js), so it hands out general, never tester.
121
+ async function projectChannels() {
122
+ let testing = null;
123
+ try { testing = await readTesting(); }
124
+ catch (e) { log.warn({ err: e.message }, 'tester choice unreadable; serving general versions'); }
125
+ return (key) => servedChannels(testsVersionsOf(testing, key));
126
+ }
127
+
117
128
  // The sentence for a toggle that waits on a restart (task 1004463). 'routes' are the
118
129
  // module's pages, 'pollers' its background jobs. PURE.
119
130
  function restartNote(waits, enabled) {
@@ -667,9 +678,12 @@ module.exports = function buildModulesRouter() {
667
678
  // here changes what runs on this instance (install never enables; the CLI places
668
679
  // files on the CALLER's checkout).
669
680
  //
670
- // Channels: every version is on the general channel until the channel table
671
- // (task 1003815) and "install honours the channel" (task 1003805) land; those
672
- // add the channel check to step 1 and step 2, beside the entitlement check.
681
+ // Channels (task 1003805, ADR 0344): steps 1 and 2 hand out only versions on a
682
+ // channel this project takes (projectChannels) — with no version asked for, the
683
+ // newest there. Install and update share step 1, so both honour it. A project
684
+ // that stops testing keeps the tester version it has; its next update is the
685
+ // newest general one, and update only moves forward, so nothing rolls back.
686
+ // Step 3 records what was already placed, so it does not re-check the channel.
673
687
  router.post('/store/modules/:key/acquire', auth.requireBuilder,
674
688
  asyncHandler('POST /store/modules/:key/acquire', async (req, res) => {
675
689
  const { key } = req.params;
@@ -681,7 +695,7 @@ module.exports = function buildModulesRouter() {
681
695
  if (wanted != null && !parseVersion(String(wanted))) {
682
696
  return res.fail('bad_version', { status: 400, message: 'A version is exact X.Y.Z.' });
683
697
  }
684
- const r = await resolveAcquirable(key, { version: wanted || null });
698
+ const r = await resolveAcquirable(key, { version: wanted || null, channels: (await projectChannels())(key) });
685
699
  if (!r.ok) return res.fail(r.code, { status: r.status, message: r.message });
686
700
 
687
701
  const holder = { moduleKey: key, holderKind: 'builder', holderRef: String(req.builder.id) };
@@ -706,7 +720,7 @@ module.exports = function buildModulesRouter() {
706
720
  tarball_sha256: v.tarball_sha256,
707
721
  tree_sha256: v.tree_sha256,
708
722
  tarball_bytes: Number(v.tarball_bytes),
709
- channel: 'general',
723
+ channel: v.channel,
710
724
  entitlement: held.entitlement,
711
725
  });
712
726
  }));
@@ -717,7 +731,7 @@ module.exports = function buildModulesRouter() {
717
731
  if (!KEY_RE.test(key) || !parseVersion(version)) {
718
732
  return res.fail('bad_request', { status: 400, message: 'A module key is lowercase kebab-case and a version is exact X.Y.Z.' });
719
733
  }
720
- const r = await resolveAcquirable(key, { version });
734
+ const r = await resolveAcquirable(key, { version, channels: (await projectChannels())(key) });
721
735
  if (!r.ok) return res.fail(r.code, { status: r.status, message: r.message });
722
736
  const held = await checkEntitlement({ moduleKey: key, holderKind: 'builder', holderRef: String(req.builder.id) });
723
737
  if (!held.entitled) {
@@ -801,7 +815,9 @@ module.exports = function buildModulesRouter() {
801
815
 
802
816
  // The newest published version of each named module — what `bongos upgrade`
803
817
  // compares an instance's installed store modules against to report the updates
804
- // available (task 1003787). `?keys=a,b,c`, at most STORE_LATEST_MAX_KEYS.
818
+ // available (task 1003787). `?keys=a,b,c`, at most STORE_LATEST_MAX_KEYS. Only
819
+ // versions on a channel this project takes count (task 1003805), so it reports
820
+ // exactly what `bongos module update` would hand over.
805
821
  //
806
822
  // rank: any-builder — a read of public store metadata (version numbers and the
807
823
  // core range each needs, the same facts the how-to page shows). It takes no
@@ -814,7 +830,7 @@ module.exports = function buildModulesRouter() {
814
830
  if (!keys.length || keys.length > STORE_LATEST_MAX_KEYS || !keys.every((k) => KEY_RE.test(k))) {
815
831
  return res.fail('bad_request', { status: 400, message: `keys is a comma-separated list of 1 to ${STORE_LATEST_MAX_KEYS} module keys (lowercase kebab-case).` });
816
832
  }
817
- const latest = await latestVersions(keys);
833
+ const latest = await latestVersions(keys, { channelsFor: await projectChannels() });
818
834
  res.set('Cache-Control', 'no-store');
819
835
  res.json({ latest });
820
836
  }));
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.168'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.169'; // 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,112 @@
1
+ // tests/module_channel_install.mjs — install and update honour the channel a
2
+ // project is on (task 1003805, ADR 0344 D1/D2, ADR 0338 D2).
3
+ //
4
+ // WHY. The channels exist (core_276) and new versions land on tester (task
5
+ // 1003803), but until acquire reads them, every project got the newest version
6
+ // whatever its channel. These pin the rule: a tester project gets the newest on
7
+ // tester or general, everyone else the newest on general, nobody a withdrawn one,
8
+ // and `bongos upgrade` reports the same version update would hand over. Run
9
+ // against a fake pg client; the routes are pinned in their source.
10
+
11
+ import { test } from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { createRequire } from 'node:module';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+ const { servedChannels, resolveAcquirable, latestVersions } = require('../src/bongos/module-store.js');
21
+ const { testsVersionsOf } = require('../src/bongos/module-testing.js');
22
+
23
+ const row = (version, channel) => ({ id: 1, module_key: 'weather', version, channel, core_version: '^1.0.0', tarball_sha256: 'a'.repeat(64), tree_sha256: 'b'.repeat(64), tarball_bytes: 10, artifact_path: `weather/weather-${version}.tgz` });
24
+
25
+ function fakeDb(versions) {
26
+ return {
27
+ async query(sql) {
28
+ if (sql.includes('FROM store_modules ')) return { rows: [{ module_key: 'weather', status: 'listed' }] };
29
+ if (sql.includes('FROM store_module_versions')) return { rows: versions };
30
+ if (sql.includes('FROM store_module_prices')) return { rows: [] };
31
+ throw new Error(`unhandled: ${sql}`);
32
+ },
33
+ };
34
+ }
35
+ const SHELF = [row('1.0.0', 'general'), row('1.1.0', 'general'), row('1.2.0', 'tester'), row('2.0.0', 'withdrawn')];
36
+ const GENERAL = servedChannels(false);
37
+ const TESTER = servedChannels(true);
38
+
39
+ test('a tester project takes tester and general versions, everyone else general, nobody withdrawn', () => {
40
+ assert.deepEqual(TESTER, ['tester', 'general']);
41
+ assert.deepEqual(GENERAL, ['general']);
42
+ for (const c of [TESTER, GENERAL]) assert.ok(!c.includes('withdrawn'));
43
+ });
44
+
45
+ test('"general only" keeps a tester project on general for that one module', () => {
46
+ const testing = { tester: true, general_only: ['weather'] };
47
+ assert.deepEqual(servedChannels(testsVersionsOf(testing, 'weather')), GENERAL);
48
+ assert.deepEqual(servedChannels(testsVersionsOf(testing, 'clock')), TESTER);
49
+ assert.deepEqual(servedChannels(testsVersionsOf(null, 'clock')), GENERAL, 'an unreadable choice is not a tester');
50
+ });
51
+
52
+ test('with no version asked for, each project gets the newest on its own channels', async () => {
53
+ const general = await resolveAcquirable('weather', { channels: GENERAL, db: fakeDb(SHELF) });
54
+ assert.equal(general.version.version, '1.1.0');
55
+ assert.equal(general.version.channel, 'general');
56
+ const tester = await resolveAcquirable('weather', { channels: TESTER, db: fakeDb(SHELF) });
57
+ assert.equal(tester.version.version, '1.2.0', 'the newer withdrawn 2.0.0 is never handed out');
58
+ assert.equal(tester.version.channel, 'tester');
59
+ });
60
+
61
+ test('an exact version off the project\'s channels is refused, never handed over', async () => {
62
+ const early = await resolveAcquirable('weather', { version: '1.2.0', channels: GENERAL, db: fakeDb(SHELF) });
63
+ assert.equal(early.ok, false);
64
+ assert.equal(early.code, 'version_not_on_channel');
65
+ assert.match(early.message, /testers/);
66
+ const gone = await resolveAcquirable('weather', { version: '2.0.0', channels: TESTER, db: fakeDb(SHELF) });
67
+ assert.equal(gone.code, 'version_not_on_channel');
68
+ assert.match(gone.message, /withdrawn/);
69
+ assert.equal((await resolveAcquirable('weather', { version: '1.0.0', channels: GENERAL, db: fakeDb(SHELF) })).ok, true);
70
+ });
71
+
72
+ test('a module whose only version is still with testers has nothing for a general project yet', async () => {
73
+ const r = await resolveAcquirable('weather', { channels: GENERAL, db: fakeDb([row('1.0.0', 'tester')]) });
74
+ assert.equal(r.code, 'no_versions_on_channel');
75
+ assert.equal(r.status, 404);
76
+ assert.match(r.message, /testers/);
77
+ const gone = await resolveAcquirable('weather', { channels: TESTER, db: fakeDb([row('1.0.0', 'withdrawn')]) });
78
+ assert.equal(gone.code, 'no_versions_on_channel');
79
+ assert.match(gone.message, /withdrawn/);
80
+ });
81
+
82
+ test('no channels means no channel check — recording a version already placed', async () => {
83
+ const r = await resolveAcquirable('weather', { version: '2.0.0', db: fakeDb(SHELF) });
84
+ assert.equal(r.ok, true);
85
+ });
86
+
87
+ test('upgrade reports the newest version on the channels this project takes', async () => {
88
+ const db = { async query() { return { rows: SHELF.map((r) => ({ ...r, status: 'listed' })) }; } };
89
+ const general = await latestVersions(['weather'], { db, channelsFor: () => GENERAL });
90
+ assert.equal(general.weather.version, '1.1.0');
91
+ const tester = await latestVersions(['weather'], { db, channelsFor: () => TESTER });
92
+ assert.equal(tester.weather.version, '1.2.0');
93
+ assert.equal(tester.weather.channel, 'tester');
94
+ const none = await latestVersions(['weather'], { db, channelsFor: () => ['nothing'] });
95
+ assert.deepEqual(none, {});
96
+ });
97
+
98
+ test('the acquire, download and latest routes ask the project\'s channels; recording does not', () => {
99
+ const src = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/modules.js'), 'utf8');
100
+ const route = (decl) => {
101
+ const start = src.indexOf(decl);
102
+ assert.ok(start !== -1, decl);
103
+ return src.slice(start, src.indexOf('}));', start));
104
+ };
105
+ const acquire = route("router.post('/store/modules/:key/acquire'");
106
+ assert.match(acquire, /resolveAcquirable\(key, \{ version: wanted \|\| null, channels: \(await projectChannels\(\)\)\(key\) \}\)/);
107
+ assert.match(acquire, /channel: v\.channel,/);
108
+ assert.doesNotMatch(acquire, /channel: 'general'/);
109
+ assert.match(route("router.get('/store/modules/:key/versions/:version/tarball'"), /channels: \(await projectChannels\(\)\)\(key\)/);
110
+ assert.match(route("router.get('/store/modules/latest'"), /latestVersions\(keys, \{ channelsFor: await projectChannels\(\) \}\)/);
111
+ assert.doesNotMatch(route("router.post('/store/modules/:key/acquired'"), /channels/);
112
+ });
@@ -24,7 +24,7 @@ const pool = {
24
24
  return {
25
25
  rows: state.versions
26
26
  .filter((v) => params[0].includes(v.module_key) && state.modules[v.module_key])
27
- .map((v) => ({ ...v, status: state.modules[v.module_key].status })),
27
+ .map((v) => ({ channel: 'general', ...v, status: state.modules[v.module_key].status })),
28
28
  };
29
29
  }
30
30
  return { rows: [] };
@@ -236,7 +236,7 @@ test('the route is open to any signed-in builder: requireBuilder and no permissi
236
236
  test('the route answers the newest version per key, in semver order', async () => {
237
237
  const r = await ask('weather');
238
238
  assert.equal(r.statusCode, 200, JSON.stringify(r.body));
239
- assert.deepEqual(r.body.latest.weather, { version: '1.10.0', core_version: '^1.2.0', status: 'listed' });
239
+ assert.deepEqual(r.body.latest.weather, { version: '1.10.0', core_version: '^1.2.0', status: 'listed', channel: 'general' });
240
240
  assert.equal(r.headers['Cache-Control'], 'no-store');
241
241
  });
242
242