@bongos/core 1.23.1 → 1.23.2

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.
@@ -3219,5 +3219,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
3219
3219
  so MINOR. The browser twin is window.OTB.sleep.
3220
3220
  1.23.1 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3221
3221
  landed since 1.23.0 with no explicit bump. run 40270556091. (task 1002620)
3222
+ 1.23.2 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3223
+ landed since 1.23.1 with no explicit bump. run 40399986777. (task 1002620)
3222
3224
  ---------------------------------------------------------------------------
3223
3225
  ```
@@ -399,6 +399,7 @@ Run `bongos upgrade` to confirm coreVersion compatibility, then `node scripts/bo
399
399
  - **Sorting by a part** (task 1003800). `GET /store/modules?sort=<part>` orders the listed modules by one part a buyer cares about: `tests`, `install`, `reliability`, `tester_feedback` or `docs` (the response lists them as `sort_parts`, and says which one it applied as `sort`). Modules with a score for that part come first, highest first; a tie goes to the higher overall score, then the module key. A module with no score for that part goes last, so missing data never wins or loses a place on its own. New modules are not sorted: they are never ranked. Any other `sort` value is refused with a 400. Without `sort`, the listed modules are in the ranking below; New stays in key order. The rule is `sortByPart` in `src/bongos/module-listing.js`.
400
400
  - **The ranking, and a maximum price** (task 1003808, owner decision 2026-10-23). Without `sort`, listed modules are ranked **quality first**: highest overall score first; between equal scores, free modules first, then the lowest cash price, then the lowest credit price, then the module key. Price never buys a better place, it only orders modules the score could not tell apart (and it is never an input to the score, ADR 0343 D6). The response states the rule as `ranking`, and each entry carries its current `price` (`cents`, `credits`, `free`). A buyer may add `?max_cents=` and/or `?max_credits=` (whole numbers, 0 or more): modules that cost more are left out of both lists, free modules always stay, and a priced module stays when it is offered at or under a cap the buyer set. The applied caps come back as `max_price`. The rules are `rankListed` and `withinMaxPrice` in `src/bongos/module-listing.js`.
401
401
  - **Why a module is not listed** (task 1003810). `GET /store/modules/:key/listing` says where one module stands, judging the same newest version the catalog does: `listed` (true or false), a `reason` (`not_assessed`, `security_not_passed`, `no_score`, `below_floor`, `delisted`, `no_version`, `new` or `at_or_above_floor`), `why` in a sentence and `next_step` saying what would change it. A failed Security gate is told apart from one that has not finished, and names what it found to the module's author only (to anyone else it would point at a weakness not fixed yet); below the floor names the score, the floor and the weakest parts. It carries the same `parts`, `strengths`, `weaknesses` and `unmeasured` as the catalog, plus `security`. Any signed-in builder can read it. Being past the cap of ten per category will be a reason of its own once the cap exists (task 1003809). The rule is `explainPlacement` in `src/bongos/module-listing.js`.
402
+ - **Sub-categories are approved before they count** (task 1004355, ADR 0342 D3). A module names `category: { parent, sub }` in `module.json`; `GET /store/subcategories` lists the approved ones under each of the eight parents. A `sub` the store has not seen under that parent is **proposed** at publish (labelled from its key) and the publish answer's `category` says so (`status: "proposed"`, `live: false`): until a Metic+ person decides, the module sits under its parent only and counts toward no cap. The review queue (`GET /store/subcategories/queue`, atom `module.subcategory.review`, metic + archon via `government_033`) shows each proposal with the modules that named it and its near-duplicate names; `POST /store/subcategories/:id/approve { label?, sub_key? }` makes it live, renamed first if asked (a rename onto an existing key is refused — merge instead), and `POST /store/subcategories/:id/merge { into }` folds it into an approved one and moves its modules there. A later publish naming a merged key lands on its target. Where a module sits is `store_module_categories` (core_278), apart from the manifest; `readModuleCategories` in `src/bongos/module-subcategories.js` is the read the cap uses.
402
403
  - **Testing new versions first** (task 1003802, [ADR 0344](adr/0344-a-tester-is-a-project-that-opts-in-once.md) D1). A project can choose to get each new version of its modules before everyone else. It is the project that opts in, never one builder for someone else's project, and never by default. `GET /modules/testing` returns `{ tester, general_only, updated_at, updated_by }` to anyone signed in. `POST /modules/testing { tester: true|false }` switches it, and `POST /modules/:key/general-only { general_only: true|false }` keeps one module on the versions everyone gets while the rest are tested. Both writes take the `module.enable` permission, the same one that turns modules on and off. Both are reversible: switching testing off puts the project back on general versions from its next update, and a module's "general only" mark is kept if testing is switched off and on again. The choice is stored in the project's own settings (`modules.tester`, `modules.general_only`); anything that cannot be read counts as not testing. `testsVersionsOf` in `src/bongos/module-testing.js` is the one answer to "does this project get the tester version of this module", which installs and updates use (task 1003805). The hall's Modules tab has the switch in its "Testing new versions" group, and the general-only control in each module's details.
403
404
 
404
405
  ### 7. Install from the store
@@ -0,0 +1,58 @@
1
+ -- core_280_store_subcategories.sql — the store's sub-categories and the queue that
2
+ -- approves them (task 1004355, ADR 0342 D3).
3
+ --
4
+ -- WHAT IT IS. A module sits under one of eight fixed parents (ADR 0342 D1) and, once
5
+ -- approved, one narrow sub-category: the comparable set the cap of ten and the
6
+ -- ranking count within (D2). An author proposes a sub-category when publishing; it
7
+ -- is NOT live until a Metic+ person approves it (optionally renaming it) or merges
8
+ -- it into an existing one. Until then the module is listed under its parent only.
9
+ --
10
+ -- store_subcategories one row per (parent, sub_key). status proposed |
11
+ -- approved | merged. A merged row points at the
12
+ -- sub-category it was merged into, so a later publish
13
+ -- naming the old key lands on the right one.
14
+ -- store_module_categories where each store module sits now: its parent and the
15
+ -- sub-category it named (proposed or approved). A merge
16
+ -- moves every module of the merged row here. Kept apart
17
+ -- from the published manifest, which never changes.
18
+ --
19
+ -- The parent list matches CATEGORY_PARENTS in src/module-loader/manifest-schema.js;
20
+ -- changing it needs a new ADR (D1).
21
+ --
22
+ -- Additive only (two new tables), so forward-safe. Idempotent: safe to re-run.
23
+ -- Approved by the owner on 2026-10-15.
24
+
25
+ BEGIN;
26
+
27
+ CREATE TABLE IF NOT EXISTS store_subcategories (
28
+ id bigserial PRIMARY KEY,
29
+ parent text NOT NULL
30
+ CHECK (parent IN ('work', 'people', 'quality', 'money', 'communication', 'pages', 'hosting', 'knowledge')),
31
+ sub_key text NOT NULL
32
+ CHECK (sub_key ~ '^[a-z][a-z0-9-]*$'),
33
+ label text NOT NULL CHECK (length(label) BETWEEN 1 AND 80),
34
+ status text NOT NULL DEFAULT 'proposed'
35
+ CHECK (status IN ('proposed', 'approved', 'merged')),
36
+ proposed_by bigint REFERENCES builders(id),
37
+ decided_by bigint REFERENCES builders(id),
38
+ decided_at timestamptz,
39
+ merged_into bigint REFERENCES store_subcategories(id),
40
+ created_at timestamptz NOT NULL DEFAULT now(),
41
+ UNIQUE (parent, sub_key),
42
+ CHECK ((status = 'merged') = (merged_into IS NOT NULL)),
43
+ CHECK ((status = 'proposed') = (decided_at IS NULL))
44
+ );
45
+
46
+ CREATE INDEX IF NOT EXISTS idx_store_subcategories_status ON store_subcategories (status);
47
+
48
+ CREATE TABLE IF NOT EXISTS store_module_categories (
49
+ module_key text PRIMARY KEY REFERENCES store_modules(module_key),
50
+ parent text NOT NULL
51
+ CHECK (parent IN ('work', 'people', 'quality', 'money', 'communication', 'pages', 'hosting', 'knowledge')),
52
+ subcategory_id bigint REFERENCES store_subcategories(id),
53
+ updated_at timestamptz NOT NULL DEFAULT now()
54
+ );
55
+
56
+ CREATE INDEX IF NOT EXISTS idx_store_module_categories_sub ON store_module_categories (subcategory_id);
57
+
58
+ COMMIT;
@@ -235,6 +235,9 @@ const PERMISSIONS = [
235
235
  // Re-run a store version's automatic assessment (task 1003796, ADR 0359). Never a
236
236
  // hand-set score: the owner ruled a person may ask for a re-check, not change the number.
237
237
  { key: 'module.assessment.rerun', system: false, floor: 'metic', guards: 'POST /store/modules/:key/versions/:version/reassess' },
238
+ // Review the store's proposed sub-categories (task 1004355, ADR 0342 D3): approve,
239
+ // rename then approve, or merge into an existing one (a merge moves its modules).
240
+ { key: 'module.subcategory.review', system: false, floor: 'metic', guards: 'GET /store/subcategories/queue, POST /store/subcategories/:id/approve, POST /store/subcategories/:id/merge' },
238
241
  // Take a module out of the store (task 1003797, ADR 0338 D2). Stops new installs,
239
242
  // versions and downloads; never reaches an instance that already holds it.
240
243
  { key: 'module.delist', system: false, floor: 'metic', guards: 'POST /store/modules/:key/delist, POST /store/modules/:key/versions/:version/release (a version the promotion gate held on tester — task 1003804)' },
@@ -0,0 +1,35 @@
1
+ -- government_034_module_subcategory_review.sql — grant the new
2
+ -- `module.subcategory.review` atom to Metic and Archon (task 1004355, ADR 0342 D3).
3
+ --
4
+ -- WHAT IT IS. An author proposes a store sub-category when publishing; a Metic+
5
+ -- person approves it (optionally renaming it) or merges it into an existing one.
6
+ -- Merging moves every module that named it. Approving is what makes a set of
7
+ -- modules "comparable" for the cap of ten, so it is Metic+, not every builder.
8
+ --
9
+ -- WHY THIS FILE EXISTS. RANK_SEED is derived from the catalog's floors, and the
10
+ -- drift guard (tests/government_seed.mjs) compares it against the union of the
11
+ -- run-once seed plus every later grant migration, per rank. A metic-floor key is
12
+ -- held by metic AND archon, so both are granted here.
13
+ --
14
+ -- Additive only: one new permission key granted to two ranks; nothing is revoked.
15
+ -- Approved by the owner on 2026-10-15 (with the sub-category table, core_280).
16
+ -- Idempotent (ON CONFLICT DO NOTHING); forward-safe (INSERT only).
17
+ --
18
+ -- The `SELECT '<rank>', unnest(ARRAY[…])` shape is load-bearing: the drift guard
19
+ -- scans for it literally.
20
+
21
+ BEGIN;
22
+
23
+ INSERT INTO government_rank_permissions (rank_key, permission_key)
24
+ SELECT 'metic', unnest(ARRAY[
25
+ 'module.subcategory.review'
26
+ ])
27
+ ON CONFLICT (rank_key, permission_key) DO NOTHING;
28
+
29
+ INSERT INTO government_rank_permissions (rank_key, permission_key)
30
+ SELECT 'archon', unnest(ARRAY[
31
+ 'module.subcategory.review'
32
+ ])
33
+ ON CONFLICT (rank_key, permission_key) DO NOTHING;
34
+
35
+ COMMIT;
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.23.1",
3
+ "version": "1.23.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.23.1",
9
+ "version": "1.23.2",
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.23.1",
3
+ "version": "1.23.2",
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",
@@ -10389,5 +10389,11 @@
10389
10389
  "id": "1004893",
10390
10390
  "text": "The ~30 copy-pasted \"wait a moment\" helpers across the codebase now all use one shared helper (one for the browser, one for the server), and a fitness check stops new copies from creeping back in. Nothing users see changes."
10391
10391
  }
10392
+ ],
10393
+ "1.23.2": [
10394
+ {
10395
+ "id": "1004355",
10396
+ "text": "Module authors can now propose a new store sub-category when they publish, and Metic+ reviewers approve, rename or merge it, with look-alike names shown side by side so duplicates don't split the rankings."
10397
+ }
10392
10398
  ]
10393
10399
  }
@@ -25,6 +25,7 @@ const path = require('node:path');
25
25
  const crypto = require('node:crypto');
26
26
  const { parseVersion, cmp } = require('../module-loader/semver');
27
27
  const { KEY_RE } = require('../module-loader/manifest-schema');
28
+ const { recordPublishCategory } = require('./module-subcategories');
28
29
 
29
30
  // Where the store lives: <INSTANCE ROOT>/var/module-store (task 1004399).
30
31
  //
@@ -172,11 +173,11 @@ function relativeArtifactPath(file, { dir = storeDir() } = {}) {
172
173
  const PUBLISH_CHANNEL = 'tester';
173
174
 
174
175
  // One transaction. `db` is a pg Pool (anything with connect()). Returns
175
- // { ok: true, version: row, created: bool, channel } or { ok: false, code, message, status }.
176
+ // { ok: true, version: row, created: bool, channel, category } or { ok: false, code, message, status }.
176
177
  async function publishVersion(input, { db, beforeCommit } = {}) {
177
178
  const {
178
179
  key, title, description, version, coreVersion, manifest,
179
- treeSha256, tarballSha256, bytes, artifactPath: relPath, builderId,
180
+ treeSha256, tarballSha256, bytes, artifactPath: relPath, builderId, category,
180
181
  } = input;
181
182
  const pool = db || require('./pool').pool;
182
183
  const client = await pool.connect();
@@ -229,11 +230,15 @@ async function publishVersion(input, { db, beforeCommit } = {}) {
229
230
  `INSERT INTO store_module_version_channels (version_id, channel, reason, set_by)
230
231
  VALUES ($1, $2, $3, $4)`,
231
232
  [row.id, PUBLISH_CHANNEL, 'new_version', builderId]);
233
+ // Where the module sits, on the same transaction (task 1004355, ADR 0342 D3): a
234
+ // sub-category the store has not seen is proposed here and is not live until a
235
+ // Metic+ person approves it. A version naming no category leaves it where it was.
236
+ const placed = category ? await recordPublishCategory(client, { key, category, builderId }) : null;
232
237
  // Put the tarball in place while the row is still uncommitted: if this throws,
233
238
  // the catch below rolls the row back, so the two can never disagree.
234
239
  if (beforeCommit) await beforeCommit();
235
240
  await client.query('COMMIT');
236
- return { ok: true, created, version: row, channel: PUBLISH_CHANNEL };
241
+ return { ok: true, created, version: row, channel: PUBLISH_CHANNEL, category: placed };
237
242
  } catch (e) {
238
243
  try { await client.query('ROLLBACK'); } catch { /* the connection may be gone */ }
239
244
  // Two publishes of the same version racing past the check: the UNIQUE wins.
@@ -0,0 +1,316 @@
1
+ 'use strict';
2
+
3
+ // src/bongos/module-subcategories.js — the store's sub-categories and the queue that
4
+ // approves them (task 1004355, ADR 0342 D3; tables: core_280).
5
+ //
6
+ // A module sits under one of eight fixed parents and, once approved, one narrow
7
+ // sub-category: the comparable set the cap of ten and the ranking count within (D2).
8
+ // The author names a sub-category in module.json (`category: { parent, sub }`); a
9
+ // key the store has never seen under that parent is PROPOSED at publish and is not
10
+ // live until a Metic+ person approves it (optionally renaming it) or merges it into
11
+ // an existing one (`module.subcategory.review`). Until then the module is listed
12
+ // under its parent only and counts toward no cap.
13
+ //
14
+ // Where a module sits lives in store_module_categories, apart from the published
15
+ // manifest (which never changes), so a merge can move modules without touching a
16
+ // version. A merged row keeps a pointer to where it went, so a later publish naming
17
+ // the old key lands on the right sub-category instead of proposing it again.
18
+
19
+ const { CATEGORY_PARENTS } = require('../module-loader/manifest-schema');
20
+
21
+ // How many pending proposals one queue read returns, oldest first. Each is compared
22
+ // against every live sub-category for near-duplicates, so the read is bounded by
23
+ // this times the live count; the rest wait for the next page of decisions.
24
+ const QUEUE_LIMIT = 100;
25
+
26
+ // The label a proposal starts with: its key in words ("stripe-payments" → "Stripe
27
+ // payments"). The approver can rename it; the author never types a free-text label.
28
+ function labelFromKey(subKey) {
29
+ const words = String(subKey || '').split('-').filter(Boolean).join(' ');
30
+ return words ? words[0].toUpperCase() + words.slice(1) : '';
31
+ }
32
+
33
+ // A label as a sub key: lowercase kebab-case, the shape manifest-schema checks.
34
+ function subKeyFromLabel(label) {
35
+ return String(label || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').replace(/^[^a-z]+/, '');
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Near-duplicates — the point of the queue (ADR 0342 D3)
40
+ // ---------------------------------------------------------------------------
41
+ //
42
+ // "Stripe", "Stripe payments" and "Payments (Stripe)" must sit beside each other in
43
+ // front of the approver. Two names are near when their significant words overlap by
44
+ // at least half (Jaccard over word sets, a plural "s" dropped) or, as whole strings,
45
+ // are within two edits of each other (a typo: "stirpe"). Filler words never count.
46
+ const FILLER = new Set(['and', 'the', 'of', 'for', 'a', 'an', 'to', 'with', 'in', 'on']);
47
+
48
+ function nameWords(name) {
49
+ return new Set(String(name || '').toLowerCase().split(/[^a-z0-9]+/)
50
+ .filter((w) => w && !FILLER.has(w))
51
+ .map((w) => (w.length > 3 && w.endsWith('s') ? w.slice(0, -1) : w)));
52
+ }
53
+
54
+ function editDistance(a, b) {
55
+ if (Math.abs(a.length - b.length) > 2) return 3;
56
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
57
+ for (let i = 1; i <= a.length; i++) {
58
+ const cur = [i];
59
+ for (let j = 1; j <= b.length; j++) {
60
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
61
+ }
62
+ prev = cur;
63
+ }
64
+ return prev[b.length];
65
+ }
66
+
67
+ function isNear(a, b) {
68
+ return wordsNear(nameWords(a), nameWords(b));
69
+ }
70
+
71
+ // Two word sets are near when they share at least half their words (Jaccard), or,
72
+ // joined, are within two edits of each other.
73
+ function wordsNear(wa, wb) {
74
+ if (!wa.size || !wb.size) return false;
75
+ let shared = 0;
76
+ for (const w of wa) if (wb.has(w)) shared++;
77
+ if (shared / (wa.size + wb.size - shared) >= 0.5) return true;
78
+ const ja = [...wa].sort().join('');
79
+ const jb = [...wb].sort().join('');
80
+ return ja.length >= 4 && jb.length >= 4 && editDistance(ja, jb) <= 2;
81
+ }
82
+
83
+ // For each of `pending`, every other live (proposed or approved) sub-category whose
84
+ // key or label is near its own, across all parents (an author may have picked the
85
+ // wrong parent). Word sets are built once per row, not once per comparison. Pure.
86
+ // Rows carry id, parent, sub_key, label, status.
87
+ function nearDuplicatesFor(pending, rows) {
88
+ const live = (rows || []).filter((o) => o.status !== 'merged')
89
+ .map((o) => ({ o, names: [nameWords(o.sub_key), nameWords(o.label)] }));
90
+ const byId = new Map(live.map((x) => [String(x.o.id), x]));
91
+ return new Map(pending.map((row) => {
92
+ const mine = byId.get(String(row.id))?.names || [nameWords(row.sub_key), nameWords(row.label)];
93
+ const near = live
94
+ .filter((x) => String(x.o.id) !== String(row.id) && mine.some((m) => x.names.some((n) => wordsNear(m, n))))
95
+ .map(({ o }) => ({ id: o.id, parent: o.parent, sub_key: o.sub_key, label: o.label, status: o.status }));
96
+ return [String(row.id), near];
97
+ }));
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Publish: record where the module sits, proposing a new sub-category
102
+ // ---------------------------------------------------------------------------
103
+
104
+ // Runs on the publish transaction's client (module-store.js publishVersion), so a
105
+ // version never lands without its category or the other way round. `category` is
106
+ // module.json's block, already shape-checked; absent → nothing changes (a version
107
+ // that names no category leaves the module where it was). Returns what the author
108
+ // is told, or null.
109
+ async function recordPublishCategory(client, { key, category, builderId }) {
110
+ if (!category || !category.parent) return null;
111
+ const { parent, sub } = category;
112
+ if (!sub) {
113
+ await upsertModuleCategory(client, key, parent, null);
114
+ return { parent, sub: null, status: null, live: false };
115
+ }
116
+ await client.query(
117
+ `INSERT INTO store_subcategories (parent, sub_key, label, proposed_by)
118
+ VALUES ($1, $2, $3, $4)
119
+ ON CONFLICT (parent, sub_key) DO NOTHING`,
120
+ [parent, sub, labelFromKey(sub), builderId]);
121
+ let row = await subcategoryByKey(client, parent, sub);
122
+ // One step is enough: a merge re-points every earlier merge at its new target
123
+ // (mergeSubcategory), so merged_into always names a live row.
124
+ if (row && row.status === 'merged') row = await subcategoryById(client, row.merged_into);
125
+ if (!row || row.status === 'merged') {
126
+ await upsertModuleCategory(client, key, parent, null);
127
+ return { parent, sub: null, status: null, live: false };
128
+ }
129
+ await upsertModuleCategory(client, key, row.parent, row.id);
130
+ return {
131
+ parent: row.parent, sub: row.sub_key, label: row.label, status: row.status,
132
+ live: row.status === 'approved', named: sub,
133
+ };
134
+ }
135
+
136
+ async function upsertModuleCategory(client, key, parent, subcategoryId) {
137
+ await client.query(
138
+ `INSERT INTO store_module_categories (module_key, parent, subcategory_id)
139
+ VALUES ($1, $2, $3)
140
+ ON CONFLICT (module_key) DO UPDATE
141
+ SET parent = EXCLUDED.parent, subcategory_id = EXCLUDED.subcategory_id, updated_at = now()`,
142
+ [key, parent, subcategoryId]);
143
+ }
144
+
145
+ const SUB_COLUMNS = 'id, parent, sub_key, label, status, proposed_by, decided_by, decided_at, merged_into, created_at';
146
+
147
+ async function subcategoryByKey(exec, parent, subKey) {
148
+ const { rows: [row] } = await exec.query(
149
+ `SELECT ${SUB_COLUMNS} FROM store_subcategories WHERE parent = $1 AND sub_key = $2`, [parent, subKey]);
150
+ return row || null;
151
+ }
152
+
153
+ async function subcategoryById(exec, id, { lock = false } = {}) {
154
+ const { rows: [row] } = await exec.query(
155
+ `SELECT ${SUB_COLUMNS} FROM store_subcategories WHERE id = $1${lock ? ' FOR UPDATE' : ''}`, [id]);
156
+ return row || null;
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Reads
161
+ // ---------------------------------------------------------------------------
162
+
163
+ // The approved sub-categories, grouped under the eight parents: what an author picks
164
+ // from. Any builder may read it.
165
+ async function listSubcategories({ db } = {}) {
166
+ const pool = db || require('./pool').pool;
167
+ const { rows } = await pool.query(
168
+ `SELECT id, parent, sub_key, label FROM store_subcategories
169
+ WHERE status = 'approved' ORDER BY parent, label`);
170
+ return {
171
+ parents: Object.entries(CATEGORY_PARENTS).map(([parent, title]) => ({
172
+ parent, title,
173
+ subcategories: rows.filter((r) => r.parent === parent).map((r) => ({ id: r.id, sub_key: r.sub_key, label: r.label })),
174
+ })),
175
+ };
176
+ }
177
+
178
+ // The Metic+ queue: every pending proposal, oldest first, with the modules that
179
+ // named it and the near-duplicate names beside it.
180
+ async function reviewQueue({ db } = {}) {
181
+ const pool = db || require('./pool').pool;
182
+ const { rows } = await pool.query(
183
+ `SELECT ${SUB_COLUMNS} FROM store_subcategories WHERE status <> 'merged' ORDER BY created_at, id`);
184
+ const proposed = rows.filter((r) => r.status === 'proposed');
185
+ const pending = proposed.slice(0, QUEUE_LIMIT);
186
+ const modules = new Map();
187
+ if (pending.length) {
188
+ const { rows: mods } = await pool.query(
189
+ `SELECT module_key, subcategory_id FROM store_module_categories
190
+ WHERE subcategory_id = ANY($1) ORDER BY module_key`, [pending.map((r) => r.id)]);
191
+ for (const m of mods) {
192
+ const k = String(m.subcategory_id);
193
+ if (!modules.has(k)) modules.set(k, []);
194
+ modules.get(k).push(m.module_key);
195
+ }
196
+ }
197
+ const near = nearDuplicatesFor(pending, rows);
198
+ return {
199
+ total: proposed.length,
200
+ more: proposed.length > pending.length,
201
+ proposals: pending.map((r) => ({
202
+ id: r.id, parent: r.parent, parent_title: CATEGORY_PARENTS[r.parent] || r.parent,
203
+ sub_key: r.sub_key, label: r.label, proposed_by: r.proposed_by, proposed_at: r.created_at,
204
+ modules: modules.get(String(r.id)) || [],
205
+ near: near.get(String(r.id)) || [],
206
+ })),
207
+ };
208
+ }
209
+
210
+ // Where each of `keys` sits now: Map key → { parent, parent_title, sub, label,
211
+ // status, live }. `live` is true only under an approved sub-category, the only kind
212
+ // the cap and ranking count within (ADR 0342 D2, D3). A key with no row is absent.
213
+ async function readModuleCategories(keys, { db } = {}) {
214
+ const out = new Map();
215
+ if (!keys || !keys.length) return out;
216
+ const pool = db || require('./pool').pool;
217
+ const { rows } = await pool.query(
218
+ `SELECT c.module_key, c.parent, s.sub_key, s.label, s.status
219
+ FROM store_module_categories c
220
+ LEFT JOIN store_subcategories s ON s.id = c.subcategory_id
221
+ WHERE c.module_key = ANY($1)`, [keys]);
222
+ for (const r of rows) {
223
+ out.set(r.module_key, {
224
+ parent: r.parent, parent_title: CATEGORY_PARENTS[r.parent] || r.parent,
225
+ sub: r.sub_key || null, label: r.label || null, status: r.status || null,
226
+ live: r.status === 'approved',
227
+ });
228
+ }
229
+ return out;
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // Decisions — approve (optionally renamed) and merge
234
+ // ---------------------------------------------------------------------------
235
+
236
+ async function inTx(db, fn) {
237
+ const pool = db || require('./pool').pool;
238
+ const client = await pool.connect();
239
+ try {
240
+ await client.query('BEGIN');
241
+ const out = await fn(client);
242
+ await client.query(out && out.ok === false ? 'ROLLBACK' : 'COMMIT');
243
+ return out;
244
+ } catch (e) {
245
+ try { await client.query('ROLLBACK'); } catch { /* the connection may be gone */ }
246
+ throw e;
247
+ } finally {
248
+ client.release();
249
+ }
250
+ }
251
+
252
+ const refuse = (status, code, message) => ({ ok: false, status, code, message });
253
+
254
+ // Approve a proposal, optionally under a new label and/or key. A rename onto a key
255
+ // that already exists under the same parent is refused: that is a merge.
256
+ async function approveSubcategory(id, { label = null, subKey = null, builderId }, { db } = {}) {
257
+ return inTx(db, async (client) => {
258
+ const row = await subcategoryById(client, id, { lock: true });
259
+ if (!row) return refuse(404, 'subcategory_not_found', `No sub-category ${id}.`);
260
+ if (row.status !== 'proposed') return refuse(409, 'subcategory_decided', `"${row.label}" is already ${row.status}.`);
261
+ const newLabel = label != null ? String(label).trim() : row.label;
262
+ if (!newLabel || newLabel.length > 80) return refuse(400, 'bad_label', 'A label is 1 to 80 characters.');
263
+ const newKey = subKey != null ? String(subKey) : (label != null ? subKeyFromLabel(newLabel) : row.sub_key);
264
+ if (!/^[a-z][a-z0-9-]*$/.test(newKey)) return refuse(400, 'bad_sub_key', 'A sub-category key is lowercase kebab-case.');
265
+ if (newKey !== row.sub_key) {
266
+ const clash = await subcategoryByKey(client, row.parent, newKey);
267
+ if (clash) return refuse(409, 'subcategory_exists', `"${newKey}" already exists under ${row.parent} — merge into it instead (POST /store/subcategories/${row.id}/merge { into: ${clash.id} }).`);
268
+ }
269
+ const renamed = newKey !== row.sub_key || newLabel !== row.label;
270
+ const { rows: [done] } = await client.query(
271
+ `UPDATE store_subcategories
272
+ SET status = 'approved', label = $2, sub_key = $3, decided_by = $4, decided_at = now()
273
+ WHERE id = $1 RETURNING ${SUB_COLUMNS}`,
274
+ [row.id, newLabel, newKey, builderId]);
275
+ return { ok: true, subcategory: done, renamed };
276
+ });
277
+ }
278
+
279
+ // Merge a sub-category (proposed, or an approved duplicate) into an approved one.
280
+ // Every module that sat under it moves to the target, parent included, and any
281
+ // earlier merge into it is re-pointed so a chain stays one step.
282
+ async function mergeSubcategory(id, { into, builderId }, { db } = {}) {
283
+ return inTx(db, async (client) => {
284
+ if (String(id) === String(into)) return refuse(400, 'merge_into_self', 'A sub-category cannot be merged into itself.');
285
+ // Both rows locked in one statement, in id order, so two opposite merges at once
286
+ // wait on each other instead of deadlocking.
287
+ const { rows: locked } = await client.query(
288
+ `SELECT ${SUB_COLUMNS} FROM store_subcategories WHERE id = ANY($1) ORDER BY id FOR UPDATE`,
289
+ [[String(id), String(into)]]);
290
+ const row = locked.find((x) => String(x.id) === String(BigInt(id)));
291
+ const target = locked.find((x) => String(x.id) === String(BigInt(into)));
292
+ if (!row) return refuse(404, 'subcategory_not_found', `No sub-category ${id}.`);
293
+ if (row.status === 'merged') return refuse(409, 'subcategory_decided', `"${row.label}" is already merged.`);
294
+ if (!target) return refuse(404, 'subcategory_not_found', `No sub-category ${into} to merge into.`);
295
+ // Compared by the rows found, not the raw ids: "05" and "5" are the same row.
296
+ if (String(target.id) === String(row.id)) return refuse(400, 'merge_into_self', 'A sub-category cannot be merged into itself.');
297
+ if (target.status !== 'approved') return refuse(409, 'merge_target_not_approved', `Merge into an approved sub-category; "${target.label}" is ${target.status}.`);
298
+ await client.query(
299
+ `UPDATE store_subcategories
300
+ SET status = 'merged', merged_into = $2, decided_by = $3, decided_at = now()
301
+ WHERE id = $1`,
302
+ [row.id, target.id, builderId]);
303
+ await client.query('UPDATE store_subcategories SET merged_into = $2 WHERE merged_into = $1', [row.id, target.id]);
304
+ const { rows: moved } = await client.query(
305
+ `UPDATE store_module_categories SET subcategory_id = $2, parent = $3, updated_at = now()
306
+ WHERE subcategory_id = $1 RETURNING module_key`,
307
+ [row.id, target.id, target.parent]);
308
+ return { ok: true, merged: { id: row.id, sub_key: row.sub_key, label: row.label }, into: target, moved: moved.map((m) => m.module_key).sort() };
309
+ });
310
+ }
311
+
312
+ module.exports = {
313
+ isNear,
314
+ recordPublishCategory, listSubcategories, reviewQueue, readModuleCategories,
315
+ approveSubcategory, mergeSubcategory,
316
+ };
@@ -242,6 +242,11 @@ const EXPECTED_RANKS = {
242
242
  // Re-running a store version's assessment (task 1003796, ADR 0359) spends a Docs
243
243
  // grader call and re-opens the Security gate, so it is pinned to its own atom.
244
244
  'POST /store/modules/:key/versions/:version/reassess': 'perm:module.assessment.rerun',
245
+ // Approving, renaming or merging a store sub-category (task 1004355, ADR 0342 D3)
246
+ // decides which modules compete for the same ten slots, so it is pinned to its own atom.
247
+ 'GET /store/subcategories/queue': 'perm:module.subcategory.review',
248
+ 'POST /store/subcategories/:id/approve': 'perm:module.subcategory.review',
249
+ 'POST /store/subcategories/:id/merge': 'perm:module.subcategory.review',
245
250
  // Delisting a store module (task 1003797, ADR 0338 D2) stops every new install of
246
251
  // it for every instance, so it is pinned to its own atom.
247
252
  'POST /store/modules/:key/delist': 'perm:module.delist',
@@ -111,6 +111,7 @@ 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, readListing, SORT_PARTS } = require('../module-listing');
114
+ const { listSubcategories, reviewQueue, approveSubcategory, mergeSubcategory } = require('../module-subcategories');
114
115
  const { readTesting, setTester, setGeneralOnly, testsVersionsOf } = require('../module-testing');
115
116
  const { readStanding, releaseHeld } = require('../module-promotion');
116
117
 
@@ -522,6 +523,7 @@ module.exports = function buildModulesRouter() {
522
523
  bytes: v.bytes,
523
524
  artifactPath: relativeArtifactPath(staged.final),
524
525
  builderId: req.builder.id,
526
+ category: v.moduleJson.category,
525
527
  }, { beforeCommit: () => commitArtifact(staged) });
526
528
  } finally {
527
529
  await removeArtifact(staged.tmp);
@@ -529,7 +531,7 @@ module.exports = function buildModulesRouter() {
529
531
  if (!result.ok) return res.fail(result.code, { status: result.status, message: result.message });
530
532
 
531
533
  console.log(`[gds] module "${key}" ${v.version} published to the store by builder ${req.builder.id}`);
532
- res.status(201).json({ ok: true, created_module: result.created, version: result.version, channel: result.channel });
534
+ res.status(201).json({ ok: true, created_module: result.created, version: result.version, channel: result.channel, category: result.category || null });
533
535
  // A new version is a new thing to judge (task 1003794, ADR 0343): queue its
534
536
  // assessment after answering, since the Security gate asks npm about its
535
537
  // dependencies and can take a minute. The queue runs one at a time and is
@@ -891,6 +893,58 @@ module.exports = function buildModulesRouter() {
891
893
  res.json(listing);
892
894
  }));
893
895
 
896
+ // GET /api/bongos/store/subcategories — the approved sub-categories under each of
897
+ // the eight parents (task 1004355, ADR 0342 D3): what an author names in
898
+ // module.json's `category.sub`. A key not on this list is proposed at publish.
899
+ //
900
+ // rank: any-builder — public store metadata; nothing changed.
901
+ router.get('/store/subcategories', auth.requireBuilder,
902
+ asyncHandler('GET /store/subcategories', async (_req, res) => {
903
+ res.set('Cache-Control', 'private, max-age=60'); // changes only when a reviewer decides
904
+ res.json(await listSubcategories());
905
+ }));
906
+
907
+ // GET /api/bongos/store/subcategories/queue — the proposals waiting on a Metic+
908
+ // decision, oldest first, each with the modules that named it and the
909
+ // near-duplicate names beside it ("Stripe" next to "Stripe payments"), since
910
+ // stopping near-duplicates is the point of the queue (ADR 0342 D3).
911
+ router.get('/store/subcategories/queue', auth.requireBuilder, auth.requirePermission('module.subcategory.review'),
912
+ asyncHandler('GET /store/subcategories/queue', async (_req, res) => {
913
+ res.set('Cache-Control', 'no-store');
914
+ res.json(await reviewQueue());
915
+ }));
916
+
917
+ // POST /api/bongos/store/subcategories/:id/approve { label?, sub_key? } — make a
918
+ // proposal live, optionally renamed first. A rename onto a key that already
919
+ // exists under the parent is refused with the merge to run instead.
920
+ router.post('/store/subcategories/:id/approve', auth.requireBuilder, auth.requirePermission('module.subcategory.review'),
921
+ asyncHandler('POST /store/subcategories/:id/approve', async (req, res) => {
922
+ if (validateOrRespond(req, res, { label: { type: 'string', maxLength: 80 }, sub_key: { type: 'string', maxLength: 80 } })) return;
923
+ const id = String(req.params.id);
924
+ if (!/^\d{1,18}$/.test(id)) return res.fail('bad_request', { status: 400, message: 'A sub-category id is a whole number.' });
925
+ const r = await approveSubcategory(id, { label: req.body?.label ?? null, subKey: req.body?.sub_key ?? null, builderId: req.builder.id });
926
+ if (!r.ok) return res.fail(r.code, { status: r.status, message: r.message });
927
+ log.info({ id, builderId: req.builder.id }, `sub-category ${r.subcategory.parent}/${r.subcategory.sub_key} approved`);
928
+ res.json(r);
929
+ }));
930
+
931
+ // POST /api/bongos/store/subcategories/:id/merge { into } — fold a proposal (or an
932
+ // approved duplicate) into an approved sub-category. Every module under it moves
933
+ // to the target.
934
+ router.post('/store/subcategories/:id/merge', auth.requireBuilder, auth.requirePermission('module.subcategory.review'),
935
+ asyncHandler('POST /store/subcategories/:id/merge', async (req, res) => {
936
+ if (validateOrRespond(req, res, { into: {} })) return; // bigint as number or string; validated below
937
+ const id = String(req.params.id);
938
+ const into = req.body?.into == null ? '' : String(req.body.into);
939
+ if (!/^\d{1,18}$/.test(id) || !/^\d{1,18}$/.test(into)) {
940
+ return res.fail('bad_request', { status: 400, message: 'Name the sub-category to merge into by id, in into.' });
941
+ }
942
+ const r = await mergeSubcategory(id, { into, builderId: req.builder.id });
943
+ if (!r.ok) return res.fail(r.code, { status: r.status, message: r.message });
944
+ log.info({ id, into, moved: r.moved.length, builderId: req.builder.id }, `sub-category ${r.merged.sub_key} merged into ${r.into.sub_key}`);
945
+ res.json(r);
946
+ }));
947
+
894
948
  router.post('/store/modules/:key/acquired', auth.requireBuilder,
895
949
  asyncHandler('POST /store/modules/:key/acquired', async (req, res) => {
896
950
  const { key } = req.params;
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.23.1'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.23.2'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');