@bongos/core 1.21.172 → 1.21.174

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.
Files changed (35) hide show
  1. package/.bongos-core.json +53 -38
  2. package/docs/adr/0343-a-module-score-is-a-security-gate-then-an-average-of-visible-parts.md +10 -0
  3. package/docs/api/openapi.json +1 -1
  4. package/docs/copy-inventory.md +75 -72
  5. package/docs/copy-registry.json +117 -90
  6. package/docs/module-api-changelog.md +4 -0
  7. package/docs/modules-contract.md +2 -1
  8. package/docs/page-readings.json +122 -120
  9. package/modules/copy-desk/routes/copy-desk.js +4 -4
  10. package/modules/hall-ui/public/city-decide.js +36 -2
  11. package/modules/hall-ui/public/city.css +8 -2
  12. package/modules/hall-ui/public/city.html +5 -5
  13. package/modules/lifecycle/artist-review-goal.js +103 -0
  14. package/modules/lifecycle/db-tasks.js +17 -1
  15. package/modules/lifecycle/db-versions.js +5 -1
  16. package/modules/lifecycle/page-tweak-claim.js +3 -3
  17. package/modules/lifecycle/page-tweak-write.js +12 -4
  18. package/modules/public-landing/public/index.html +9 -1
  19. package/package-lock.json +2 -2
  20. package/package.json +1 -1
  21. package/release-notes.json +24 -0
  22. package/src/bongos/auth-github.js +6 -1
  23. package/src/bongos/module-listing.js +76 -11
  24. package/src/bongos/routes/auth.js +6 -2
  25. package/src/bongos/routes/modules.js +15 -1
  26. package/src/module-api.js +1 -1
  27. package/tests/artist_review_goal.mjs +119 -0
  28. package/tests/auth_switch_account.mjs +38 -0
  29. package/tests/copy_desk_page_claim.mjs +6 -1
  30. package/tests/copy_desk_page_submit.mjs +1 -1
  31. package/tests/fixtures/page-ledger.mjs +13 -0
  32. package/tests/hall_city_rewording.mjs +11 -0
  33. package/tests/module_store_listing.mjs +105 -5
  34. package/tests/speciality_injection.mjs +20 -0
  35. package/tests/task_goal_required.mjs +12 -2
@@ -0,0 +1,103 @@
1
+ // modules/lifecycle/artist-review-goal.js — every version's ARTIST REVIEW goal
2
+ // (task 1004819). The home for copywriting and Artist Studio work: page tweak
3
+ // rounds, the engineer tasks a round leaves behind, and copy proposals.
4
+ //
5
+ // Owner direction (2026-10-23): all of that work sits under one ongoing goal, and
6
+ // every version carries one, with two standing done-when criteria (below). Before
7
+ // this the studio's create paths passed allowCatchAll, and a version with no
8
+ // "<version> — general" goal (BONGOS-V2) filed every round with no goal at all.
9
+ //
10
+ // ensureMaintenanceGoal's shape (db-goals.js), with two differences worth knowing:
11
+ // - it is matched by TITLE, not a flag. Nothing decides anything from this goal
12
+ // being the artist one except where new studio work lands, so a column (and a
13
+ // migration) would buy nothing a hand-titled goal could abuse;
14
+ // - it is NOT exempt from the version-close count. Its criteria are meant to
15
+ // hold the version open until every page has been reviewed.
16
+ // A 'planning' version takes one too, so createVersion can give a new version
17
+ // its goal at birth; a closed version never does.
18
+ //
19
+ // Runs on the caller's client (deps.client) when given, so the goal, its criteria
20
+ // and the task that needed them commit together or not at all; otherwise in a
21
+ // transaction of its own.
22
+
23
+ const api = require('../../src/module-api');
24
+
25
+ const { pool, withTx } = api;
26
+
27
+ const OPEN_TO_WORK = new Set(['planning', 'building']);
28
+
29
+ const ARTIST_REVIEW_CRITERIA = Object.freeze([
30
+ Object.freeze({
31
+ criterion_id: 'artist-every-page-reviewed',
32
+ criterion_md: 'Every page in the page inventory has had at least one artist review (a shipped tweak round) of its words before the version closes.',
33
+ }),
34
+ Object.freeze({
35
+ criterion_id: 'artist-pages-simple-to-use',
36
+ criterion_md: 'Every page is simple for a user to read and use: plain words, one clear next step, nothing a newcomer has to decode. An artist signs this off per page.',
37
+ }),
38
+ ]);
39
+
40
+ function artistReviewGoalTitle(versionId) {
41
+ return `${versionId} — artist review`;
42
+ }
43
+
44
+ async function ensureArtistReviewGoal(versionId, deps = {}) {
45
+ if (versionId == null) return null;
46
+ if (deps.client) return findOrCreate(versionId, deps.client);
47
+ // No caller transaction (a copy proposal): open one, or the advisory lock below
48
+ // would release the moment it is taken and two writers could both insert.
49
+ const txPool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
50
+ return withTx((client) => findOrCreate(versionId, client), { pool: txPool });
51
+ }
52
+
53
+ async function findOrCreate(versionId, exec) {
54
+ const title = artistReviewGoalTitle(versionId);
55
+
56
+ const { rows: vRows } = await exec.query(`SELECT status FROM versions WHERE id = $1`, [versionId]);
57
+ if (!vRows[0] || !OPEN_TO_WORK.has(vRows[0].status)) return null;
58
+
59
+ // No unique constraint on (version_id, title), so two studio writes in the same
60
+ // second would both insert without this. Released at COMMIT/ROLLBACK.
61
+ await exec.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [title]);
62
+
63
+ const { rows: found } = await exec.query(
64
+ `SELECT id, version_id, title FROM goals WHERE version_id = $1 AND title = $2 ORDER BY id LIMIT 1`,
65
+ [versionId, title]
66
+ );
67
+ if (found[0]) {
68
+ // A goal of this title made by hand (POST /goals) before the first studio
69
+ // write still gets the two standing criteria; ones already there are kept.
70
+ await seedCriteria(exec, versionId, found[0].id);
71
+ return found[0];
72
+ }
73
+
74
+ const { rows: made } = await exec.query(
75
+ `INSERT INTO goals (version_id, title, description, scope_modules, status, created_by, sort_order)
76
+ SELECT $1, $2, $3, $4, 'open', NULL, COALESCE(MAX(sort_order) + 1, 0)
77
+ FROM goals WHERE version_id = $1
78
+ RETURNING id, version_id, title`,
79
+ [versionId, title,
80
+ 'The ongoing home for copywriting and Artist Studio work in this version: page tweak rounds, ' +
81
+ 'the engineer tasks they leave, and copy proposals. Every version carries one (task 1004819).',
82
+ []]
83
+ );
84
+ const goal = made[0] || null;
85
+ if (!goal) return null;
86
+ await seedCriteria(exec, versionId, goal.id);
87
+ return goal;
88
+ }
89
+
90
+ async function seedCriteria(exec, versionId, goalId) {
91
+ let order = 0;
92
+ for (const c of ARTIST_REVIEW_CRITERIA) {
93
+ order += 1;
94
+ await exec.query(
95
+ `INSERT INTO done_when_criteria (version_id, criterion_id, criterion_md, goal_id, sort_order)
96
+ SELECT $1, $2, $3, $4, COALESCE(MAX(sort_order), 0) + $5 FROM done_when_criteria WHERE version_id = $1
97
+ ON CONFLICT (version_id, criterion_id) DO NOTHING`,
98
+ [versionId, c.criterion_id, c.criterion_md, goalId, order]
99
+ );
100
+ }
101
+ }
102
+
103
+ module.exports = { ensureArtistReviewGoal, artistReviewGoalTitle, ARTIST_REVIEW_CRITERIA };
@@ -21,6 +21,7 @@ const { executorFor } = require('./db-executor.js');
21
21
  const { isReservedCriterionSlug } = require('./criterion-ref.js');
22
22
  const { resolveWriteDiscipline, WRITABLE_DISCIPLINES } = require('./task-classifier.js');
23
23
  const { gateOnSpecTask } = require('./goal-spec-task.js');
24
+ const { ensureArtistReviewGoal } = require('./artist-review-goal.js');
24
25
 
25
26
  // -------------------------------------------------------------------------
26
27
  // Tasks
@@ -158,6 +159,11 @@ async function createTask({
158
159
  // copy-desk. Grep for it before adding a third: the answer is almost always
159
160
  // to name a goal instead.
160
161
  allowCatchAll = false,
162
+ // task 1004819: copywriting / Artist Studio work. With no goalId it lands in
163
+ // the version's artist review goal (created on first use, artist-review-goal.js)
164
+ // rather than the catch-all; the catch-all is only the fallback for a version
165
+ // that cannot take one.
166
+ artistReview = false,
161
167
  }, deps = {}) { // deps.client enlists this INSERT in the CALLER'S transaction (task 1003065, BV1.R01); the argument for it is at modules/ideas/inbox.js routeIdeaToGoal
162
168
  // task 1677 / ADR 0096 (2026-06-28): creation is now the PRIMARY reward-assignment
163
169
  // point — every task is born with a fair drachmae value, not just at promote/claim.
@@ -196,8 +202,18 @@ async function createTask({
196
202
  // opt-ins can name a real goal. Owner's call, recorded here so the remaining
197
203
  // gap is not mistaken for an oversight.
198
204
  let goalIdEffective = goalId;
205
+ if (goalIdEffective == null && artistReview === true && !deps.client) {
206
+ // No caller transaction (a copy proposal): run the whole create in one, so the
207
+ // goal it may create and the task commit together or not at all.
208
+ return api.withTx((client) => createTask(arguments[0], { ...deps, client }),
209
+ { pool: (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool });
210
+ }
211
+ if (goalIdEffective == null && artistReview === true) {
212
+ const artistGoal = await ensureArtistReviewGoal(versionId, { client: deps.client });
213
+ if (artistGoal) goalIdEffective = artistGoal.id;
214
+ }
199
215
  if (goalIdEffective == null) {
200
- if (allowCatchAll !== true) {
216
+ if (allowCatchAll !== true && artistReview !== true) {
201
217
  throw new Error(
202
218
  'createTask: goalId is required — every task belongs to a goal (BV1.R11). ' +
203
219
  'A vector that genuinely cannot name one yet must pass allowCatchAll: true, ' +
@@ -20,6 +20,7 @@ const { executorFor, inTransaction } = require('./db-executor.js');
20
20
  // (fitness pins the count). createVersion needs it to seed a version's FIRST goal
21
21
  // in the same transaction — task 1003701.
22
22
  const { createGoal } = require('./db-goals.js');
23
+ const { ensureArtistReviewGoal } = require('./artist-review-goal.js');
23
24
  const { cancelPendingMembershipRequests } = require('./goal-close-sweep.js');
24
25
 
25
26
  async function listVersions() {
@@ -214,7 +215,10 @@ async function createVersion({ id, name, status = 'planning', scopeDocPath = nul
214
215
  if (cr[0]) createdCriteria.push(cr[0]);
215
216
  i++;
216
217
  }
217
- return { version, goal: goal || null, criteria: createdCriteria };
218
+ // task 1004819: every version is born with its artist review goal and its two
219
+ // standing criteria, on the same client, so they roll back with the version.
220
+ const artistReviewGoal = await ensureArtistReviewGoal(id, { client });
221
+ return { version, goal: goal || null, criteria: createdCriteria, artistReviewGoal };
218
222
  }, { pool: txPool });
219
223
  }
220
224
 
@@ -156,9 +156,9 @@ async function webClaimPageTweak({ pageId, builderId, versionId = null, round },
156
156
  const n = Math.max(rounds.length, ...rounds.map((t) => roundNumber(t.source_ref))) + 1;
157
157
  const task = await createTask({
158
158
  versionId,
159
- // ADR 0341 D2: a round lands in the building version's catch-all goal,
160
- // exactly as a copy proposal does. Declared out loud (BV1.R11).
161
- allowCatchAll: true,
159
+ // ADR 0341 D2, task 1004819: a round lands in the building version's
160
+ // artist review goal, exactly as a copy proposal does.
161
+ artistReview: true,
162
162
  title: round.title(n),
163
163
  description: round.description(n),
164
164
  status: 'ready',
@@ -35,6 +35,7 @@
35
35
  'use strict';
36
36
 
37
37
  const api = require('../../src/module-api');
38
+ const { ensureArtistReviewGoal } = require('./artist-review-goal.js');
38
39
  const db = require('./db');
39
40
 
40
41
  function activePoolFor(deps) {
@@ -139,14 +140,21 @@ async function submitPageTweak({ taskId, builderId, versionId = null, freeze },
139
140
  );
140
141
  for (const f of found) if (!existing.has(f.source_ref)) existing.set(f.source_ref, String(f.id));
141
142
  }
143
+ // The version's artist review goal, resolved ONCE for the whole loop (task
144
+ // 1004819) rather than once per line: the version cannot change mid-submit.
145
+ const roundVersionId = versionId != null ? versionId : held.task.version_id;
146
+ const artistGoal = wanted.some((u) => !existing.has(u.sourceRef))
147
+ ? await ensureArtistReviewGoal(roundVersionId, { client }) : null;
142
148
  const filed = [];
143
149
  for (const u of wanted) {
144
150
  if (existing.has(u.sourceRef)) { filed.push({ id: existing.get(u.sourceRef), key: u.key, created: false }); continue; }
145
151
  const t = await createTask({
146
- versionId: versionId != null ? versionId : held.task.version_id,
147
- // The building version's catch-all goal, declared out loud (BV1.R11),
148
- // exactly as the round itself and a copy proposal land.
149
- allowCatchAll: true,
152
+ versionId: roundVersionId,
153
+ // The version's artist review goal (task 1004819), exactly as the
154
+ // round itself and a copy proposal land; artistReview is the fallback
155
+ // should the goal above not resolve.
156
+ goalId: artistGoal ? artistGoal.id : null,
157
+ artistReview: true,
150
158
  title: u.title,
151
159
  description: u.description,
152
160
  // BACKLOG (ADR 0341 D4): an engineer's triage decides when, and the
@@ -173,6 +173,10 @@ main:focus{outline:none;box-shadow:none}
173
173
  .signin{background:color-mix(in srgb,var(--ink) 92%,var(--bg));color:var(--bg);border:0;
174
174
  text-transform:lowercase;font-weight:500;padding:0 26px}
175
175
  .signin:hover{background:var(--ink);color:var(--bg)}
176
+ /* the second-account door: an outline beside the filled one, so sign-in stays the first door */
177
+ .newacct{background:transparent;border:1px solid color-mix(in srgb,var(--ink) 40%,transparent);
178
+ text-transform:lowercase;font-weight:500;margin-right:8px}
179
+ .newacct:hover{border-color:var(--ink)}
176
180
 
177
181
  /* the menu bands (.menuBand — .band is this page's content sections): not floating cards. Full width on the card ground, closed by one hairline, a LAYER
178
182
  over the page under the bar — the page does not reflow (owner, 2026-08-28; task 1003332). Cells in a
@@ -495,7 +499,8 @@ body{
495
499
  .trig{font-size:13.5px}
496
500
  .who-name{display:none}
497
501
  .pill{min-height:42px;font-size:13.5px}
498
- .signin{padding:0 16px}
502
+ .signin,.newacct{padding:0 14px}
503
+ .newacct{margin-right:6px}
499
504
  .bandIn{padding:0 12px 16px}
500
505
  .bandRow{grid-template-columns:1fr;gap:0}
501
506
  .bandT{font-size:20px}
@@ -686,6 +691,9 @@ composition A; landing composition v3, picked by the owner 2026-08-27. No roll,
686
691
  <button class="trig" type="button" id="projTrig" data-menu="projects" aria-expanded="false" aria-controls="menuProjects">Projects<svg class="cv" viewBox="0 0 20 20" aria-hidden="true"><path d="m5.6 8 4.4 4.4L14.4 8"/></svg></button>
687
692
  <!-- the identity slot: the sign-in door signed out; the chip (the profile trigger) once
688
693
  the session resolves — swapped client-side, never a wrong-state flash -->
694
+ <!-- a second account (task 1004789): switch=1 makes GitHub show its account picker
695
+ instead of silently re-using whoever is logged in there; stays when signed in -->
696
+ <a class="pill newacct" href="/api/bongos/auth/web/start?return=/&amp;repos=1&amp;switch=1" title="Sign in with a different GitHub account">New account</a>
689
697
  <span class="whoSlot" id="who">
690
698
  <a class="pill signin" href="/api/bongos/auth/web/start?return=/&amp;repos=1">Sign in</a>
691
699
  </span>
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.172",
3
+ "version": "1.21.174",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.172",
9
+ "version": "1.21.174",
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.172",
3
+ "version": "1.21.174",
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",
@@ -10106,5 +10106,29 @@
10106
10106
  "id": "1004670",
10107
10107
  "text": "The Studio now shows which pages have the most words to write: a short list of the five pages with the most text, each with how many lines it has and whether it has been tweaked, linked straight to the editor. The suggested ne"
10108
10108
  }
10109
+ ],
10110
+ "1.21.173": [
10111
+ {
10112
+ "id": "1004789",
10113
+ "text": "The home page now has a \"New account\" button next to Sign in. It opens GitHub's account picker, so you can sign in with a different GitHub account and get a second Cloud Bongos account. The password screen stays as it is."
10114
+ },
10115
+ {
10116
+ "id": "1004817",
10117
+ "text": "When one builder works two tasks at once in two separate copies of the project, each working session now gets only the expert guidance for its own task. Before, an artist task and an engineering task running side by side both"
10118
+ },
10119
+ {
10120
+ "id": "1003808",
10121
+ "text": "The module store now ranks the best-scoring modules first, uses price only to break ties (free first), and lets buyers hide anything above a price they set."
10122
+ }
10123
+ ],
10124
+ "1.21.174": [
10125
+ {
10126
+ "id": "1004791",
10127
+ "text": "The city's cards no longer cut off their words. Long questions show in full, answer buttons say their whole answer, and a \"Full text\" link on each card opens everything the item says when there is more to read. Long goal names"
10128
+ },
10129
+ {
10130
+ "id": "1004819",
10131
+ "text": "Every version now has an artist review goal, holding all copywriting and Artist Studio work, with two standing checks: every page reviewed by an artist before the version closes, and every page simple to read and use. New page"
10132
+ }
10109
10133
  ]
10110
10134
  }
@@ -234,7 +234,7 @@ async function pollDeviceFlow(deviceCode, userAgent) {
234
234
  // Web Flow (browser)
235
235
  // -------------------------------------------------------------------------
236
236
 
237
- function buildAuthorizeUrl(state, redirectUri, { repos = false } = {}) {
237
+ function buildAuthorizeUrl(state, redirectUri, { repos = false, switchAccount = false } = {}) {
238
238
  const creds = loadGithubCreds();
239
239
  if (!creds) throw new Error('GitHub OAuth credentials not configured');
240
240
  const u = new URL('https://github.com/login/oauth/authorize');
@@ -244,6 +244,11 @@ function buildAuthorizeUrl(state, redirectUri, { repos = false } = {}) {
244
244
  // other caller gets identity-only. scopeForWebFlow keeps that decision in one place.
245
245
  u.searchParams.set('scope', scopeForWebFlow({ repos }));
246
246
  u.searchParams.set('state', state);
247
+ // `switchAccount:true` (the home page's "New account" button, task 1004789):
248
+ // GitHub otherwise silently re-uses whoever is logged in to github.com in this
249
+ // browser, so a second account could never be reached. select_account makes
250
+ // GitHub show its account picker, with "Use a different account".
251
+ if (switchAccount) u.searchParams.set('prompt', 'select_account');
247
252
  return u.toString();
248
253
  }
249
254
 
@@ -126,6 +126,54 @@ function sortByPart(entries, part) {
126
126
  });
127
127
  }
128
128
 
129
+ // ---------------------------------------------------------------------------
130
+ // The ranking: quality first, price breaks ties (task 1003808, owner 2026-10-23)
131
+ // ---------------------------------------------------------------------------
132
+ //
133
+ // Above the floor, the default order of `listed` is the rule an author and a buyer
134
+ // can both check in one sentence:
135
+ //
136
+ // highest overall score first; between equal scores the cheaper module first
137
+ // (free first, then the lowest cash price, then the lowest credit price); then
138
+ // the key.
139
+ //
140
+ // Price never buys a better place: it only orders modules the score could not tell
141
+ // apart (price is never an input to the score, ADR 0343 D6). A buyer may also set a
142
+ // maximum price, which leaves out what costs more — a filter, not a ranking.
143
+ const RANKING = 'Highest overall score first; between equal scores, free modules and then the cheapest come first.';
144
+
145
+ // A module's current price row ({ price_cents, price_credits } or none) as the
146
+ // catalog shows it. No row is free (isFreePrice, module-store.js); 0 in either
147
+ // currency is free; null means not offered in that currency (core_257). Pure.
148
+ function priceOf(row) {
149
+ const cents = row && row.price_cents != null ? Number(row.price_cents) : null;
150
+ const credits = row && row.price_credits != null ? Number(row.price_credits) : null;
151
+ return { cents, credits, free: !row || cents === 0 || credits === 0 };
152
+ }
153
+
154
+ // Within a buyer's maximum ({ cents, credits }, either may be null): a free module
155
+ // always is; a priced one is when it is offered at or under a cap the buyer set. Pure.
156
+ function withinMaxPrice(price, max) {
157
+ if (!max || (max.cents == null && max.credits == null)) return true;
158
+ if (price.free) return true;
159
+ return (max.cents != null && price.cents != null && price.cents <= max.cents)
160
+ || (max.credits != null && price.credits != null && price.credits <= max.credits);
161
+ }
162
+
163
+ // The default order of `listed`, per RANKING. Returns a new array. Pure.
164
+ function rankListed(entries) {
165
+ const cheap = (n) => (n == null ? Infinity : n);
166
+ return [...entries].sort((a, b) => {
167
+ const pa = a.price || priceOf(null);
168
+ const pb = b.price || priceOf(null);
169
+ return ((b.overall ?? -1) - (a.overall ?? -1))
170
+ || (Number(pb.free) - Number(pa.free))
171
+ || (cheap(pa.cents) - cheap(pb.cents))
172
+ || (cheap(pa.credits) - cheap(pb.credits))
173
+ || a.key.localeCompare(b.key);
174
+ });
175
+ }
176
+
129
177
  // The newest version row per module key, in semver order (a version that does not
130
178
  // parse is skipped, as latestVersions does in module-store.js).
131
179
  function newestPerModule(rows) {
@@ -140,13 +188,14 @@ function newestPerModule(rows) {
140
188
  return [...out.values()];
141
189
  }
142
190
 
143
- // The catalog: { floor, sort, sort_parts, listed: [...], new: [...] }, each entry
144
- // { key, title, version, published_at, overall, is_new, parts, strengths, weaknesses,
145
- // unmeasured } (the last four from readParts). Excluded versions are left
146
- // out entirely: a module below the bar is not listed at all. Both lists are in key
147
- // order unless `sort` names a part, which orders `listed` by sortByPart; ranking
148
- // is a separate task (1003808) and does not happen here.
149
- async function listCatalog({ db, floor = QUALITY_FLOOR, sort = null } = {}) {
191
+ // The catalog: { floor, ranking, sort, sort_parts, max_price, listed: [...], new: [...] },
192
+ // each entry { key, title, version, published_at, price, overall, is_new, parts,
193
+ // strengths, weaknesses, unmeasured } (the last four from readParts). Excluded
194
+ // versions are left out entirely: a module below the bar is not listed at all, and
195
+ // neither is one over the buyer's `maxPrice`. `listed` is in rankListed order unless
196
+ // `sort` names a part, which orders it by sortByPart; `new` is never ranked and
197
+ // stays in key order.
198
+ async function listCatalog({ db, floor = QUALITY_FLOOR, sort = null, maxPrice = null } = {}) {
150
199
  const pool = db || require('./pool').pool;
151
200
  const { rows: versions } = await pool.query(
152
201
  `SELECT m.module_key, m.title, v.id AS version_id, v.version, v.published_at
@@ -162,6 +211,15 @@ async function listCatalog({ db, floor = QUALITY_FLOOR, sort = null } = {}) {
162
211
  ORDER BY version_id, computed_at DESC, id DESC`, [newest.map((r) => r.version_id)]);
163
212
  for (const s of rows) scores.set(String(s.version_id), s);
164
213
  }
214
+ const prices = new Map();
215
+ if (newest.length) {
216
+ const { rows } = await pool.query(
217
+ `SELECT DISTINCT ON (module_key) module_key, price_cents, price_credits
218
+ FROM store_module_prices
219
+ WHERE module_key = ANY($1)
220
+ ORDER BY module_key, set_at DESC, id DESC`, [newest.map((r) => r.module_key)]);
221
+ for (const p of rows) prices.set(p.module_key, p);
222
+ }
165
223
  // Each version's current result per part: the newest row per (version, part),
166
224
  // the read idx_module_assessment_signals_current exists for (core_265).
167
225
  const signals = new Map();
@@ -177,19 +235,26 @@ async function listCatalog({ db, floor = QUALITY_FLOOR, sort = null } = {}) {
177
235
  signals.get(k).push(s);
178
236
  }
179
237
  }
180
- const out = { floor, sort: sort || null, sort_parts: SORT_PARTS, listed: [], new: [] };
238
+ const max = maxPrice && (maxPrice.cents != null || maxPrice.credits != null)
239
+ ? { cents: maxPrice.cents ?? null, credits: maxPrice.credits ?? null } : null;
240
+ const out = { floor, ranking: RANKING, sort: sort || null, sort_parts: SORT_PARTS, max_price: max, listed: [], new: [] };
181
241
  for (const r of newest.sort((a, b) => a.module_key.localeCompare(b.module_key))) {
182
242
  const score = scores.get(String(r.version_id)) || null;
183
243
  const { placement } = placeVersion(score, floor);
184
244
  if (placement === 'excluded') continue;
245
+ const price = priceOf(prices.get(r.module_key));
246
+ if (!withinMaxPrice(price, max)) continue;
185
247
  out[placement].push({
186
- key: r.module_key, title: r.title, version: r.version, published_at: r.published_at,
248
+ key: r.module_key, title: r.title, version: r.version, published_at: r.published_at, price,
187
249
  overall: placement === 'new' ? null : score.overall, is_new: placement === 'new',
188
250
  ...readParts(signals.get(String(r.version_id)) || [], { floor }),
189
251
  });
190
252
  }
191
- if (sort) out.listed = sortByPart(out.listed, sort);
253
+ out.listed = sort ? sortByPart(out.listed, sort) : rankListed(out.listed);
192
254
  return out;
193
255
  }
194
256
 
195
- module.exports = { QUALITY_FLOOR, STRONG_AT, SORT_PARTS, placeVersion, readParts, sortByPart, listCatalog };
257
+ module.exports = {
258
+ QUALITY_FLOOR, STRONG_AT, SORT_PARTS, RANKING, placeVersion, readParts, sortByPart,
259
+ priceOf, withinMaxPrice, rankListed, listCatalog,
260
+ };
@@ -697,6 +697,9 @@ module.exports = function buildAuthRouter() {
697
697
  return sendAuthNotConfigured(res);
698
698
  }
699
699
  const wantsRepos = req.query.repos === '1' || req.query.repos === 'true';
700
+ // ?switch=1 — sign in as a DIFFERENT GitHub account (task 1004789): show
701
+ // GitHub's account picker instead of re-using the one logged in there.
702
+ const wantsSwitch = req.query.switch === '1' || req.query.switch === 'true';
700
703
  // A sign-in begun on a satellite hub host (builders./status.) restarts on
701
704
  // the canonical host (task 1004357). The handshake cookies are host-only now,
702
705
  // and the callback always lands on the canonical host, so a state cookie set
@@ -708,13 +711,14 @@ module.exports = function buildAuthRouter() {
708
711
  const back = raw.startsWith('/') ? `https://${host}${raw}` : raw;
709
712
  const params = new URLSearchParams({ return: back });
710
713
  if (wantsRepos) params.set('repos', '1');
714
+ if (wantsSwitch) params.set('switch', '1');
711
715
  return res.redirect(`${WEB_REDIRECT_ORIGIN.replace(/\/+$/, '')}${apiBase(req)}/auth/web/start?${params}`);
712
716
  }
713
717
  // Already signed in on the canonical host and headed for a satellite hub
714
718
  // host (a builder who signed in on the apex, then opened builders.): no
715
719
  // GitHub round-trip, just hand that host its copy. A repo-scope sign-in
716
720
  // still goes to GitHub — it exists to ask for the broader grant.
717
- if (!wantsRepos) {
721
+ if (!wantsRepos && !wantsSwitch) {
718
722
  const target = safeReturnTo(req.query.return);
719
723
  if (target && !target.startsWith('/')) {
720
724
  try {
@@ -774,7 +778,7 @@ module.exports = function buildAuthRouter() {
774
778
  // way — the callback validates it identically, so the hub leg is CSRF-protected too.
775
779
  res.redirect(auth.idpConfigured()
776
780
  ? auth.buildIdpAuthorizeUrl(state, redirectUri)
777
- : auth.buildAuthorizeUrl(state, redirectUri, { repos: wantsRepos }));
781
+ : auth.buildAuthorizeUrl(state, redirectUri, { repos: wantsRepos, switchAccount: wantsSwitch }));
778
782
  });
779
783
 
780
784
  // GET /auth/web/handoff?code=&return= — the far side of the one-time session
@@ -841,6 +841,9 @@ module.exports = function buildModulesRouter() {
841
841
  // is not listed at all. The floor is fixed in module-listing.js and returned here.
842
842
  // `?sort=<part>` orders the listed modules by the part a buyer cares about
843
843
  // (task 1003800, sortByPart); the parts it accepts come back as sort_parts.
844
+ // Without it the listed modules are ranked quality first, price breaking ties
845
+ // (task 1003808, rankListed); `?max_cents=` / `?max_credits=` leave out modules
846
+ // that cost more.
844
847
  //
845
848
  // rank: any-builder — a read of public store metadata, like /store/modules/latest:
846
849
  // no entitlement taken, nothing changed.
@@ -852,7 +855,18 @@ module.exports = function buildModulesRouter() {
852
855
  const names = SORT_PARTS.map((p) => `${p.title} (${p.part})`).join(', ');
853
856
  return res.fail('bad_request', { status: 400, message: `The store can be sorted by one part of the score: ${names}.` });
854
857
  }
855
- const listing = await listCatalog({ sort });
858
+ // An optional maximum price (task 1003808): ?max_cents= and/or ?max_credits=,
859
+ // each a whole number of at least 0. Free modules always pass.
860
+ const maxPrice = {};
861
+ for (const [param, field] of [['max_cents', 'cents'], ['max_credits', 'credits']]) {
862
+ const v = req.query && req.query[param];
863
+ if (v == null || v === '') continue;
864
+ if (typeof v !== 'string' || !/^\d{1,9}$/.test(v)) {
865
+ return res.fail('bad_request', { status: 400, message: `${param} is a maximum price: a whole number, 0 or more.` });
866
+ }
867
+ maxPrice[field] = Number(v);
868
+ }
869
+ const listing = await listCatalog({ sort, maxPrice });
856
870
  res.set('Cache-Control', 'no-store');
857
871
  res.json(listing);
858
872
  }));
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.172'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.174'; // 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,119 @@
1
+ // tests/artist_review_goal.mjs
2
+ //
3
+ // Every version's ARTIST REVIEW goal (task 1004819): the find-or-create behind
4
+ // createTask's `artistReview` switch and createVersion. DB-free: a recording fake
5
+ // client stands in for Postgres, and the statement order is the contract — the
6
+ // version gate before anything else, the advisory lock before the read, and the
7
+ // two standing criteria written only when the goal is new.
8
+ //
9
+ // Run: node tests/artist_review_goal.mjs
10
+
11
+ import { strict as assert } from 'node:assert';
12
+ import { createRequire } from 'node:module';
13
+ import { makeRunner } from './helpers.mjs';
14
+
15
+ process.env.NODE_ENV = 'test';
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const { ensureArtistReviewGoal, artistReviewGoalTitle, ARTIST_REVIEW_CRITERIA } = require('../modules/lifecycle/artist-review-goal.js');
19
+ const { createTask } = require('../modules/lifecycle/db.js');
20
+
21
+ const { test, summary } = makeRunner();
22
+ const norm = (s) => String(s).replace(/\s+/g, ' ').trim();
23
+
24
+ // One version, at most one goal: the fake answers like the tables would.
25
+ function fakeClient({ status = 'building', goal = null } = {}) {
26
+ const queries = [];
27
+ const state = { goal, criteria: [], tasks: [] };
28
+ const client = {
29
+ queries, state,
30
+ async query(sql, params = []) {
31
+ const q = norm(sql);
32
+ queries.push({ sql: q, params });
33
+ if (/^(BEGIN|COMMIT|ROLLBACK)$/.test(q)) return { rows: [] };
34
+ if (/^SELECT status FROM versions WHERE id = \$1/.test(q)) return { rows: status ? [{ status }] : [] };
35
+ if (/pg_advisory_xact_lock/.test(q)) return { rows: [] };
36
+ if (/^SELECT id, version_id, title FROM goals WHERE version_id = \$1 AND title = \$2/.test(q)) {
37
+ return { rows: state.goal && state.goal.title === params[1] ? [state.goal] : [] };
38
+ }
39
+ if (/^INSERT INTO goals/.test(q)) { state.goal = { id: 91, version_id: params[0], title: params[1] }; return { rows: [state.goal] }; }
40
+ if (/^INSERT INTO done_when_criteria/.test(q)) { state.criteria.push({ id: params[1], goal: params[3] }); return { rows: [] }; }
41
+ if (/^SELECT scope_modules FROM goals WHERE id = \$1/.test(q)) return { rows: [{ scope_modules: [] }] };
42
+ if (/^INSERT INTO tasks/.test(q)) { const t = { id: 1, goal_id: params[22] }; state.tasks.push(t); return { rows: [t] }; }
43
+ return { rows: [] };
44
+ },
45
+ release() {},
46
+ };
47
+ return client;
48
+ }
49
+
50
+ await test('the title is the version plus " — artist review" (em dash)', () => {
51
+ assert.equal(artistReviewGoalTitle('BONGOS-V3'), 'BONGOS-V3 — artist review');
52
+ assert.deepEqual(ARTIST_REVIEW_CRITERIA.map((c) => c.criterion_id), ['artist-every-page-reviewed', 'artist-pages-simple-to-use']);
53
+ });
54
+
55
+ await test('a new goal is made with its two criteria, after the version gate and the lock', async () => {
56
+ const c = fakeClient();
57
+ const g = await ensureArtistReviewGoal('BONGOS-V3', { client: c });
58
+ assert.equal(g.title, 'BONGOS-V3 — artist review');
59
+ const order = c.queries.map((q) => q.sql.split(' ').slice(0, 3).join(' '));
60
+ const at = (re) => c.queries.findIndex((q) => re.test(q.sql));
61
+ assert.ok(at(/FROM versions/) < at(/pg_advisory_xact_lock/), 'the version is checked first');
62
+ assert.ok(at(/pg_advisory_xact_lock/) < at(/FROM goals WHERE version_id/), 'the lock is taken before the read');
63
+ assert.equal(c.queries.find((q) => /pg_advisory_xact_lock/.test(q.sql)).params[0], 'BONGOS-V3 — artist review', 'locked on the title');
64
+ assert.deepEqual(c.state.criteria, [{ id: 'artist-every-page-reviewed', goal: 91 }, { id: 'artist-pages-simple-to-use', goal: 91 }]);
65
+ assert.ok(/ON CONFLICT \(version_id, criterion_id\) DO NOTHING/.test(c.queries.find((q) => /INSERT INTO done_when_criteria/.test(q.sql)).sql),
66
+ 'a criterion already on the version is left alone', order);
67
+ });
68
+
69
+ await test('an existing goal is reused, never duplicated, and still carries the two criteria', async () => {
70
+ // A goal of this title made by hand before the first studio write is found,
71
+ // and its criteria are seeded (ON CONFLICT DO NOTHING keeps any already there).
72
+ const c = fakeClient({ goal: { id: 1000134, version_id: 'BONGOS-V2', title: 'BONGOS-V2 — artist review' } });
73
+ const g = await ensureArtistReviewGoal('BONGOS-V2', { client: c });
74
+ assert.equal(g.id, 1000134);
75
+ assert.equal(c.queries.some((q) => /^INSERT INTO goals/.test(q.sql)), false, 'no second goal');
76
+ assert.deepEqual(c.state.criteria, [{ id: 'artist-every-page-reviewed', goal: 1000134 }, { id: 'artist-pages-simple-to-use', goal: 1000134 }]);
77
+ });
78
+
79
+ await test('a closed or missing version gets no goal, and nothing is locked or written', async () => {
80
+ for (const status of ['shipped', null]) {
81
+ const c = fakeClient({ status });
82
+ assert.equal(await ensureArtistReviewGoal('V0', { client: c }), null);
83
+ assert.equal(c.queries.length, 1, 'only the version read ran');
84
+ }
85
+ const planning = fakeClient({ status: 'planning' });
86
+ assert.ok(await ensureArtistReviewGoal('V4', { client: planning }), 'a planning version takes one, so a new version is born with it');
87
+ });
88
+
89
+ await test('with no caller transaction it opens its own, so the lock means something', async () => {
90
+ const c = fakeClient();
91
+ const pool = { connect: async () => c, query: (...a) => c.query(...a) };
92
+ await ensureArtistReviewGoal('BONGOS-V3', { pool });
93
+ const sqls = c.queries.map((q) => q.sql);
94
+ assert.equal(sqls[0], 'BEGIN');
95
+ assert.equal(sqls.at(-1), 'COMMIT');
96
+ });
97
+
98
+ await test('createTask with artistReview lands in the goal; without one the catch-all still answers', async () => {
99
+ const c = fakeClient();
100
+ await createTask({ versionId: 'BONGOS-V3', title: 'Tweak: Home (builders:home)', discipline: 'artist', artistReview: true }, { client: c });
101
+ assert.equal(c.state.tasks[0].goal_id, 91, 'the studio task names the artist review goal');
102
+ await assert.rejects(
103
+ () => createTask({ versionId: 'BONGOS-V3', title: 'plain', discipline: 'engineer' }, { client: fakeClient() }),
104
+ /goalId is required/, 'an ordinary goal-less create still throws');
105
+ });
106
+
107
+ await test('a create with no caller transaction (a copy proposal) puts goal and task in ONE transaction', async () => {
108
+ const c = fakeClient();
109
+ const pool = { connect: async () => c, query: () => { throw new Error('nothing may run outside the transaction'); } };
110
+ await createTask({ versionId: 'BONGOS-V3', title: 'Reword the Home heading', discipline: 'artist', artistReview: true }, { pool });
111
+ const sqls = c.queries.map((q) => q.sql);
112
+ const begins = sqls.filter((q) => q === 'BEGIN').length;
113
+ assert.equal(begins, 1, 'exactly one transaction');
114
+ assert.ok(sqls.indexOf('BEGIN') < sqls.findIndex((q) => /^INSERT INTO goals/.test(q)), 'the goal is made inside it');
115
+ assert.ok(sqls.findIndex((q) => /^INSERT INTO tasks/.test(q)) < sqls.lastIndexOf('COMMIT'), 'and the task too, before the one COMMIT');
116
+ assert.equal(c.state.tasks[0].goal_id, 91);
117
+ });
118
+
119
+ summary();