@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.
- package/.bongos-core.json +53 -38
- package/docs/adr/0343-a-module-score-is-a-security-gate-then-an-average-of-visible-parts.md +10 -0
- package/docs/api/openapi.json +1 -1
- package/docs/copy-inventory.md +75 -72
- package/docs/copy-registry.json +117 -90
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +2 -1
- package/docs/page-readings.json +122 -120
- package/modules/copy-desk/routes/copy-desk.js +4 -4
- package/modules/hall-ui/public/city-decide.js +36 -2
- package/modules/hall-ui/public/city.css +8 -2
- package/modules/hall-ui/public/city.html +5 -5
- package/modules/lifecycle/artist-review-goal.js +103 -0
- package/modules/lifecycle/db-tasks.js +17 -1
- package/modules/lifecycle/db-versions.js +5 -1
- package/modules/lifecycle/page-tweak-claim.js +3 -3
- package/modules/lifecycle/page-tweak-write.js +12 -4
- package/modules/public-landing/public/index.html +9 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +24 -0
- package/src/bongos/auth-github.js +6 -1
- package/src/bongos/module-listing.js +76 -11
- package/src/bongos/routes/auth.js +6 -2
- package/src/bongos/routes/modules.js +15 -1
- package/src/module-api.js +1 -1
- package/tests/artist_review_goal.mjs +119 -0
- package/tests/auth_switch_account.mjs +38 -0
- package/tests/copy_desk_page_claim.mjs +6 -1
- package/tests/copy_desk_page_submit.mjs +1 -1
- package/tests/fixtures/page-ledger.mjs +13 -0
- package/tests/hall_city_rewording.mjs +11 -0
- package/tests/module_store_listing.mjs +105 -5
- package/tests/speciality_injection.mjs +20 -0
- 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
|
-
|
|
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
|
|
160
|
-
// exactly as a copy proposal does.
|
|
161
|
-
|
|
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:
|
|
147
|
-
// The
|
|
148
|
-
//
|
|
149
|
-
|
|
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
|
|
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=/&repos=1&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=/&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.
|
|
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.
|
|
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.
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -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: [...] },
|
|
144
|
-
// { key, title, version, published_at, overall, is_new, parts,
|
|
145
|
-
// unmeasured } (the last four from readParts). Excluded
|
|
146
|
-
// out entirely: a module below the bar is not listed at all
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
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
|
|
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
|
-
|
|
253
|
+
out.listed = sort ? sortByPart(out.listed, sort) : rankListed(out.listed);
|
|
192
254
|
return out;
|
|
193
255
|
}
|
|
194
256
|
|
|
195
|
-
module.exports = {
|
|
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
|
-
|
|
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.
|
|
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();
|