@bongos/core 1.21.5 → 1.21.6
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 +138 -68
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +1 -0
- package/docs/api/openapi.json +2 -1
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +156 -144
- package/docs/copy-registry.json +286 -158
- package/docs/module-api-changelog.md +2 -0
- package/docs/onboarding/diagrams/03-drachmae-karma.mmd +1 -1
- package/docs/onboarding/diagrams/assertions.json +1 -1
- package/docs/page-inventory.json +31 -4
- package/docs/page-readings.json +121 -94
- package/modules/autonomy/config-idle.js +192 -0
- package/modules/autonomy/routes/autonomy.js +22 -0
- package/modules/autonomy/runner-health.js +22 -1
- package/modules/hall-ui/public/city-draw.js +438 -0
- package/modules/hall-ui/public/city.css +110 -0
- package/modules/hall-ui/public/city.html +71 -0
- package/modules/hall-ui/public/city.js +177 -0
- package/modules/hall-ui/public/city.states.json +30 -0
- package/modules/hall-ui/public/gate.js +11 -4
- package/modules/hall-ui/public/profile.css +4 -0
- package/modules/hall-ui/public/profile.js +25 -1
- package/modules/hall-ui/public/settings-autobongos.js +19 -1
- package/modules/hall-ui/public/settings.css +26 -0
- package/modules/hall-ui/public/settings.html +8 -0
- package/modules/hall-ui/public/settings.js +43 -3
- package/modules/platform-identity/craft-rollup.js +72 -0
- package/modules/platform-identity/migrations/platform_identity_029_activity_crafts.sql +23 -0
- package/modules/platform-identity/platform-identity.js +12 -6
- package/modules/platform-identity/routes/sso.js +4 -0
- package/modules/platform-identity/tests/platform-identity.mjs +1 -1
- package/modules/provisioning/core-upgrade.js +34 -7
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/seams.js +2 -0
- package/modules/public-landing/public/projects.states.json +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +58 -0
- package/scripts/gds/autobongos-grade-cap.js +187 -0
- package/scripts/gds/autobongos-loop.js +4 -0
- package/scripts/gds/autobongos-run.js +154 -13
- package/scripts/gds/autobongos-verify.js +211 -14
- package/scripts/gds/provision-core-upgrade.js +14 -2
- package/scripts/gds/provision-repo.js +77 -13
- package/scripts/gds/ship-flow.js +5 -0
- package/scripts/gds/ship-preflight-steps.js +2 -1
- package/scripts/gds/ship.js +10 -0
- package/scripts/gds/update-sweep.js +24 -8
- package/scripts/gds/upgrade-outcome.js +1 -0
- package/src/bongos/auth-admission.js +95 -13
- package/src/bongos/db-kernel.js +1 -1
- package/src/bongos/routes/core-update.js +27 -2
- package/src/bongos/serve-internal.js +14 -0
- package/src/bongos/software-update.js +3 -2
- package/src/module-api.js +1 -1
- package/tests/activity_rollup_order_db.mjs +3 -1
- package/tests/activity_snapshots_db.mjs +2 -0
- package/tests/autobongos_grade_cap.mjs +125 -0
- package/tests/autobongos_loop.mjs +230 -1
- package/tests/autobongos_verify.mjs +239 -3
- package/tests/autonomy_config_idle.mjs +272 -0
- package/tests/core_update_banner.mjs +29 -0
- package/tests/core_upgrade_door.mjs +31 -1
- package/tests/core_upgrade_runner.mjs +20 -0
- package/tests/hall_audit.mjs +10 -1
- package/tests/hall_city.mjs +369 -0
- package/tests/hall_page_gate_map.mjs +5 -0
- package/tests/hub_craft_rollup.mjs +350 -0
- package/tests/profile_rollup_consent.mjs +2 -0
- package/tests/profile_route.mjs +16 -2
- package/tests/provision.mjs +78 -0
- package/tests/settings_main_role.mjs +190 -0
- package/tests/software_update.mjs +56 -1
- package/tests/update_subscription_engine.mjs +55 -3
- package/tests/wizard_physics_more.mjs +19 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// tests/hub_craft_rollup.mjs — task 1004440 (WA6.RP10, goal 1000095, criterion
|
|
2
|
+
// wa6-roles-read-true-in-public): the hub profile shows each craft's own work.
|
|
3
|
+
//
|
|
4
|
+
// The cross-project rollup used to carry one ship count (with no status filter)
|
|
5
|
+
// and "Roles" from the declared preference alone, so on the hub an artist with
|
|
6
|
+
// forty approved pages, or an ideator the board ratified, read as an engineer
|
|
7
|
+
// with nothing shipped. Three halves, each driven through its real code:
|
|
8
|
+
//
|
|
9
|
+
// 1. THE SENDER (core auth-admission.js reportActivityRollup) counts SHIPPED
|
|
10
|
+
// works only, sends each EARNED craft's counts (the ledger split decides,
|
|
11
|
+
// as on the local profile), derives `disciplines` from that evidence with
|
|
12
|
+
// the declared preference as the fallback, and — because the hub validates
|
|
13
|
+
// the body strictly — re-sends once without `crafts` when an older hub
|
|
14
|
+
// refuses the field, so the totals are never lost.
|
|
15
|
+
// 2. THE HUB ROUTE + upsertActivity accept `crafts` and bind only the
|
|
16
|
+
// sanitised shape: three crafts, named counts, integers or null. Nothing a
|
|
17
|
+
// sender writes can carry text through it.
|
|
18
|
+
// 3. getRollupForBuilder sums each craft over EVERY project row — named or
|
|
19
|
+
// not, exactly as the totals are (the aggregate is not the leak, the name
|
|
20
|
+
// is) — and the profile page draws a tile per craft from that sum.
|
|
21
|
+
//
|
|
22
|
+
// DB-free: the hub_recent_ships.mjs / activity_rollup_as_of.mjs idioms (pinned
|
|
23
|
+
// require.cache, stubbed fetch, a recording fake pool).
|
|
24
|
+
//
|
|
25
|
+
// Run: node tests/hub_craft_rollup.mjs
|
|
26
|
+
|
|
27
|
+
import { strict as assert } from 'node:assert';
|
|
28
|
+
import { createRequire } from 'node:module';
|
|
29
|
+
import fs from 'node:fs';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
import vm from 'node:vm';
|
|
32
|
+
import { fileURLToPath } from 'node:url';
|
|
33
|
+
|
|
34
|
+
const require = createRequire(import.meta.url);
|
|
35
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
36
|
+
|
|
37
|
+
let passed = 0, failed = 0;
|
|
38
|
+
async function t(name, fn) {
|
|
39
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
40
|
+
catch (e) { failed++; console.error(` FAIL ${name}\n ${e.message}`); }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function mockPool(answer = () => ({ rows: [] })) {
|
|
44
|
+
const calls = [];
|
|
45
|
+
const query = async (text, params = []) => {
|
|
46
|
+
calls.push({ text: String(text), params });
|
|
47
|
+
return answer(String(text), params) || { rows: [] };
|
|
48
|
+
};
|
|
49
|
+
return {
|
|
50
|
+
calls, query,
|
|
51
|
+
connect: async () => ({ query, release: () => {} }),
|
|
52
|
+
find: (re) => calls.find((c) => re.test(c.text)),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const pin = (rel, exports) => {
|
|
56
|
+
const p = require.resolve(rel);
|
|
57
|
+
require.cache[p] = { id: p, filename: p, loaded: true, exports };
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// ---- 1. the sender ----------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
console.log('\n1. the instance sends each earned craft, and shipped means shipped:');
|
|
63
|
+
|
|
64
|
+
const seams = require('../src/module-seams.js');
|
|
65
|
+
|
|
66
|
+
// One rollup through the real sender. `byCraft` is economy's ledger split for
|
|
67
|
+
// builder 7 (null = no economy module); `ports` the craft stats; `statuses` the
|
|
68
|
+
// HTTP status each successive send answers.
|
|
69
|
+
async function sendRollup({ byCraft = null, preferred = ['engineer'], ports = {}, statuses = [200] } = {}) {
|
|
70
|
+
seams._reset();
|
|
71
|
+
if (byCraft) {
|
|
72
|
+
seams.registerProvider('reward', {
|
|
73
|
+
creditsByCraft: async (ids) => new Map(ids.map((id) => [String(id), byCraft])),
|
|
74
|
+
craftForPreference: (d) => (d === 'ui' ? 'artist' : ['engineer', 'artist', 'ideator'].includes(d) ? d : null),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
for (const [k, fn] of Object.entries(ports)) seams.registerProvider(k, fn);
|
|
78
|
+
const senderPool = mockPool((sql) => {
|
|
79
|
+
if (/FROM builders WHERE id/.test(sql)) {
|
|
80
|
+
return { rows: [{ github_id: 42, total_credits: 900, karma: 2, rank: 'thetes', preferred_disciplines: preferred }] };
|
|
81
|
+
}
|
|
82
|
+
if (/count\(\*\)/.test(sql)) return { rows: [{ n: 5 }] };
|
|
83
|
+
return { rows: [] };
|
|
84
|
+
});
|
|
85
|
+
pin('../src/bongos/pool.js', { pool: senderPool });
|
|
86
|
+
pin('../src/bongos/auth-config.js', {
|
|
87
|
+
loadIdpConfig: () => ({ origin: 'https://hub.example', clientId: 'c1', clientSecret: 's' }),
|
|
88
|
+
});
|
|
89
|
+
delete require.cache[require.resolve('../src/bongos/auth-admission.js')];
|
|
90
|
+
const { reportActivityRollup } = require('../src/bongos/auth-admission.js');
|
|
91
|
+
const sent = [];
|
|
92
|
+
const realFetch = globalThis.fetch;
|
|
93
|
+
globalThis.fetch = async (url, opts) => {
|
|
94
|
+
sent.push(JSON.parse(opts.body));
|
|
95
|
+
const status = statuses[Math.min(sent.length - 1, statuses.length - 1)];
|
|
96
|
+
return { ok: status < 300, status, json: async () => ({}) };
|
|
97
|
+
};
|
|
98
|
+
try { await reportActivityRollup({ builderId: 7 }); } finally { globalThis.fetch = realFetch; seams._reset(); }
|
|
99
|
+
return { sent, senderPool };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const ARTIST_STATS = async () => ({ pages_approved: 42, pages: 30, surfaces_touched: 3, credits: 1100, this_week: 12, surfaces: [{ label: 'Landing', line: 'secret wording' }] });
|
|
103
|
+
const IDEATOR_STATS = async () => ({ filed: 12, ignited: 4, full_ideas: 2, ratified: 1, size: { total: 9 }, alive: { share: 0.5 } });
|
|
104
|
+
|
|
105
|
+
await t('the ship count asks for SHIPPED works only (the missing status filter)', async () => {
|
|
106
|
+
const { senderPool } = await sendRollup();
|
|
107
|
+
const q = senderPool.find(/count\(\*\)/);
|
|
108
|
+
assert.ok(q, 'the ship count ran');
|
|
109
|
+
assert.match(q.text, /shipped_by = \$1/);
|
|
110
|
+
assert.match(q.text, /status = 'shipped'/, 'a count without the status filter also counts work handed back after shipping');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await t('an artist who is also an ideator sends both crafts\' counts, and only counts', async () => {
|
|
114
|
+
const { sent } = await sendRollup({
|
|
115
|
+
byCraft: { engineer: 0, artist: 1100, ideator: 160, other: 0, total: 1260 },
|
|
116
|
+
preferred: ['ui'],
|
|
117
|
+
ports: { 'copy-desk.artistStats': ARTIST_STATS, 'ideas.ideatorStats': IDEATOR_STATS },
|
|
118
|
+
});
|
|
119
|
+
assert.equal(sent.length, 1);
|
|
120
|
+
const body = sent[0];
|
|
121
|
+
assert.deepEqual(body.crafts, {
|
|
122
|
+
artist: { pages_approved: 42, credits: 1100 },
|
|
123
|
+
ideator: { filed: 12, full_ideas: 2, ratified: 1, credits: 160 },
|
|
124
|
+
}, 'exactly the named counts — no surface line, no size, no wording');
|
|
125
|
+
assert.ok(!JSON.stringify(body).includes('secret wording'), 'no page wording leaves the instance');
|
|
126
|
+
assert.deepEqual(body.disciplines, ['artist', 'ideator'], 'crafts from evidence of work, in preferred order (ui → artist)');
|
|
127
|
+
assert.equal(body.tasks_shipped, 5, 'CONTROL: the totals still ride');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await t('an unearned craft sends nothing, and its stats port is never asked', async () => {
|
|
131
|
+
let asked = 0;
|
|
132
|
+
const { sent } = await sendRollup({
|
|
133
|
+
byCraft: { engineer: 900, artist: 0, ideator: 0, other: 0, total: 900 },
|
|
134
|
+
preferred: ['artist', 'engineer'],
|
|
135
|
+
ports: { 'copy-desk.artistStats': async () => { asked++; return {}; } },
|
|
136
|
+
});
|
|
137
|
+
assert.deepEqual(sent[0].crafts, { engineer: { shipped: 5, credits: 900 } });
|
|
138
|
+
assert.deepEqual(sent[0].disciplines, ['engineer'], 'a declared but unearned role is not shown once work exists elsewhere');
|
|
139
|
+
assert.equal(asked, 0, 'a craft that will not be sent is not read');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await t('nothing earned, or no economy to ask → the declared preference, and no crafts field', async () => {
|
|
143
|
+
const none = await sendRollup({ byCraft: { engineer: 0, artist: 0, ideator: 0, other: 0, total: 0 }, preferred: ['ideator'] });
|
|
144
|
+
assert.deepEqual(none.sent[0].disciplines, ['ideator'], 'the fallback is the preference');
|
|
145
|
+
assert.ok(!('crafts' in none.sent[0]), 'no crafts field, so an older hub never sees one');
|
|
146
|
+
const vanilla = await sendRollup({ byCraft: null, preferred: ['engineer', 'ui'] });
|
|
147
|
+
assert.deepEqual(vanilla.sent[0].disciplines, ['engineer', 'ui']);
|
|
148
|
+
assert.ok(!('crafts' in vanilla.sent[0]));
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await t('a craft whose port cannot be asked sends null counts, never 0', async () => {
|
|
152
|
+
const { sent } = await sendRollup({ byCraft: { engineer: 0, artist: 50, ideator: 0, other: 0, total: 50 }, preferred: [] });
|
|
153
|
+
assert.deepEqual(sent[0].crafts, { artist: { pages_approved: null, credits: 50 } });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await t('an older hub refusing `crafts` (400) gets the report again without it — once', async () => {
|
|
157
|
+
const { sent } = await sendRollup({
|
|
158
|
+
byCraft: { engineer: 0, artist: 1100, ideator: 0, other: 0, total: 1100 },
|
|
159
|
+
ports: { 'copy-desk.artistStats': ARTIST_STATS },
|
|
160
|
+
statuses: [400, 200],
|
|
161
|
+
});
|
|
162
|
+
assert.equal(sent.length, 2, 'one retry');
|
|
163
|
+
assert.ok('crafts' in sent[0]);
|
|
164
|
+
assert.ok(!('crafts' in sent[1]), 'the retry drops only the new field');
|
|
165
|
+
assert.equal(sent[1].tasks_shipped, 5, 'the totals still land');
|
|
166
|
+
assert.deepEqual(sent[1].disciplines, ['artist'], 'the evidence-derived crafts ride the retry too');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
await t('a 400 on a report WITHOUT crafts is not retried (nothing to drop)', async () => {
|
|
170
|
+
const { sent } = await sendRollup({ statuses: [400] });
|
|
171
|
+
assert.equal(sent.length, 1);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ---- 2. the hub route + upsert ---------------------------------------------
|
|
175
|
+
|
|
176
|
+
console.log('\n2. the hub accepts crafts and binds only the sanitised shape:');
|
|
177
|
+
|
|
178
|
+
const SECRET = 'craft-secret';
|
|
179
|
+
function hubRoute() {
|
|
180
|
+
const pi = require('../modules/platform-identity/platform-identity.js');
|
|
181
|
+
const rollupPool = mockPool((sql) => {
|
|
182
|
+
if (/FROM platform_identity_sso_clients/.test(sql)) {
|
|
183
|
+
return { rows: [{ client_id: 'c1', secret_hash: pi.sha256hex(SECRET), status: 'active', origin: 'https://p.example' }] };
|
|
184
|
+
}
|
|
185
|
+
if (/FROM platform_identity_project_memberships/.test(sql)) {
|
|
186
|
+
return { rows: [{ id: 1, client_id: 'c1', github_id: 42, membership_kind: 'member', signin_verified_at: '2026-08-29T00:00:00.000Z' }] };
|
|
187
|
+
}
|
|
188
|
+
return { rows: [{ id: 1 }] };
|
|
189
|
+
});
|
|
190
|
+
const helpers = require('../src/bongos/routes/_helpers.js');
|
|
191
|
+
const realApi = require('../src/module-api.js');
|
|
192
|
+
pin('../src/module-api.js', {
|
|
193
|
+
pool: rollupPool,
|
|
194
|
+
validateOrRespond: helpers.validateOrRespond,
|
|
195
|
+
resolveSession: async () => null,
|
|
196
|
+
logger: () => ({ info() {}, warn() {}, error() {} }),
|
|
197
|
+
LIVE_RANK_LADDER: realApi.LIVE_RANK_LADDER,
|
|
198
|
+
withTx: realApi.withTx,
|
|
199
|
+
corsPublicGet: () => (_q, _s, next) => next(),
|
|
200
|
+
on: () => () => {},
|
|
201
|
+
hasProvider: () => false,
|
|
202
|
+
registerProvider: () => {},
|
|
203
|
+
});
|
|
204
|
+
delete require.cache[require.resolve('../modules/platform-identity/routes/sso.js')];
|
|
205
|
+
const router = require('../modules/platform-identity/routes/sso.js')();
|
|
206
|
+
const layer = router.stack.find((l) => l.route && l.route.path === '/sso/activity/rollup');
|
|
207
|
+
return { handle: layer.route.stack[0].handle, rollupPool };
|
|
208
|
+
}
|
|
209
|
+
async function postRollup(extra) {
|
|
210
|
+
const { handle, rollupPool } = hubRoute();
|
|
211
|
+
const res = { body: null, failure: null };
|
|
212
|
+
res.json = (b) => { res.body = b; return res; };
|
|
213
|
+
res.fail = (code, ...rest) => { res.failure = { code, rest }; return res; };
|
|
214
|
+
const req = {
|
|
215
|
+
get: () => undefined,
|
|
216
|
+
body: { client_id: 'c1', client_secret: SECRET, github_id: 42, credits: 10, tasks_shipped: 2, karma: 1, ...extra },
|
|
217
|
+
};
|
|
218
|
+
await handle(req, res, () => {});
|
|
219
|
+
const insert = rollupPool.find(/INSERT INTO platform_identity_builder_project_activity/);
|
|
220
|
+
return { res, insert };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
await t('a report carrying crafts is accepted and binds the craft counts', async () => {
|
|
224
|
+
const { res, insert } = await postRollup({ crafts: { artist: { pages_approved: 42, credits: 1100 } } });
|
|
225
|
+
assert.equal(res.failure, null, JSON.stringify(res.failure));
|
|
226
|
+
assert.match(insert.text, /reported_as_of, crafts\)/, 'the upsert writes the crafts column');
|
|
227
|
+
assert.match(insert.text, /crafts = EXCLUDED\.crafts/, 'and a later report replaces it');
|
|
228
|
+
assert.deepEqual(JSON.parse(insert.params[9]), { artist: { pages_approved: 42, credits: 1100 } });
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
await t('the hub keeps only the three crafts and their named integer counts', async () => {
|
|
232
|
+
const { insert } = await postRollup({
|
|
233
|
+
crafts: {
|
|
234
|
+
artist: { pages_approved: 'forty', credits: -3, title: '<script>' },
|
|
235
|
+
ideator: { filed: 1.5, full_ideas: 99_000_000, ratified: 2, credits: 7, idea_title: 'A secret plan' },
|
|
236
|
+
governor: { laws: 4 },
|
|
237
|
+
engineer: 'lots',
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
const bound = JSON.parse(insert.params[9]);
|
|
241
|
+
assert.deepEqual(bound, {
|
|
242
|
+
artist: { pages_approved: null, credits: null },
|
|
243
|
+
ideator: { filed: null, full_ideas: null, ratified: 2, credits: 7 },
|
|
244
|
+
});
|
|
245
|
+
assert.ok(!insert.params[9].includes('secret') && !insert.params[9].includes('script'), 'no text survives');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
await t('a report with no crafts (an older instance) binds the empty object', async () => {
|
|
249
|
+
const { res, insert } = await postRollup({});
|
|
250
|
+
assert.deepEqual(res.body, { ok: true });
|
|
251
|
+
assert.equal(insert.params[9], '{}');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
await t('CONTROL: the body is still strict — an unknown field is refused', async () => {
|
|
255
|
+
const { res } = await postRollup({ rolez: {} });
|
|
256
|
+
assert.ok(res.failure, 'a strict body is what makes the sender\'s retry necessary');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// ---- 3. the aggregate + the page ----------------------------------------------
|
|
260
|
+
|
|
261
|
+
console.log('\n3. the profile sums each craft over every project, and draws it:');
|
|
262
|
+
|
|
263
|
+
await t('getRollupForBuilder sums each craft over EVERY project row, named or not', async () => {
|
|
264
|
+
// pin the doorway back to something with a pool the domain can take.
|
|
265
|
+
const pi = require('../modules/platform-identity/platform-identity.js');
|
|
266
|
+
const pool = mockPool((sql) => {
|
|
267
|
+
if (/WITH agg AS/.test(sql)) {
|
|
268
|
+
return { rows: [{ projects: 2, credits: 1300, works_shipped: 44, karma: 3, last_active: null, disciplines: ['artist'] }] };
|
|
269
|
+
}
|
|
270
|
+
if (/recent_ships/.test(sql)) {
|
|
271
|
+
return { rows: [
|
|
272
|
+
{ name: 'Pub', origin: 'https://pub.example', rank_held: 'metic', recent_ships: [], is_public: true,
|
|
273
|
+
crafts: { artist: { pages_approved: 40, credits: 1000 } } },
|
|
274
|
+
{ name: 'Hidden', origin: 'https://priv.example', rank_held: 'xenos', recent_ships: [], is_public: false,
|
|
275
|
+
crafts: { artist: { pages_approved: 2, credits: null }, ideator: { filed: 5, full_ideas: 1, ratified: null, credits: 60 } } },
|
|
276
|
+
{ name: 'Old', origin: 'https://old.example', rank_held: null, recent_ships: [], is_public: true, crafts: {} },
|
|
277
|
+
] };
|
|
278
|
+
}
|
|
279
|
+
return { rows: [] };
|
|
280
|
+
});
|
|
281
|
+
const r = await pi.getRollupForBuilder(42, { pool });
|
|
282
|
+
assert.match(pool.find(/recent_ships/).text, /a\.crafts/, 'the per-project read carries crafts');
|
|
283
|
+
assert.deepEqual(r.crafts, {
|
|
284
|
+
artist: { pages_approved: 42, credits: 1000 },
|
|
285
|
+
ideator: { filed: 5, full_ideas: 1, ratified: null, credits: 60 },
|
|
286
|
+
}, 'a private project is counted, as its totals are; a count nobody reported stays null');
|
|
287
|
+
assert.deepEqual(r.projects.map((p) => p.name), ['Pub', 'Old'], 'CONTROL: the private project is still not NAMED');
|
|
288
|
+
assert.ok(!JSON.stringify(r.projects).includes('pages_approved'), 'per-project numbers never cross, only the sum');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// The page: the real profile.js in the tests/profile_ui_cross_project.mjs stub.
|
|
292
|
+
const SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/profile.js'), 'utf8');
|
|
293
|
+
const ROLES_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/profile-roles.js'), 'utf8');
|
|
294
|
+
async function renderXp(crossProject) {
|
|
295
|
+
const els = new Map();
|
|
296
|
+
const makeEl = () => ({ hidden: false, textContent: '', _html: '', set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; } });
|
|
297
|
+
const $ = (sel) => { if (!els.has(sel)) els.set(sel, makeEl()); return els.get(sel); };
|
|
298
|
+
const escapeHtml = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
299
|
+
const profile = {
|
|
300
|
+
builder: { id: 7, github_login: 'b7', display_name: 'Builder 7', rank: 'thetes', total_credits: 1, karma: 0, created_at: '2026-01-01T00:00:00.000Z', preferred_disciplines: [] },
|
|
301
|
+
shipped_count: 0, recent_ships: [], achievements: [], cross_project: crossProject,
|
|
302
|
+
};
|
|
303
|
+
const api = { async request(_m, url) {
|
|
304
|
+
if (url === '/api/bongos/me') return { status: 200, ok: true, data: { builder: { id: 99 } } };
|
|
305
|
+
if (/\/builders\/[^/]+\/profile$/.test(url)) return { status: 200, ok: true, data: profile };
|
|
306
|
+
return { status: 404, ok: false, data: null };
|
|
307
|
+
} };
|
|
308
|
+
const sandbox = { location: { search: '?id=7' }, URLSearchParams, console, window: {
|
|
309
|
+
__BRANDING__: {}, BongosClient: { createClient: () => api },
|
|
310
|
+
OTB: { roleAxisLabel: () => 'Role', roleLabel: (d) => d.charAt(0).toUpperCase() + d.slice(1), roleOptions: () => [], roleIsUnset: (d) => !d,
|
|
311
|
+
ROLE_UNSET: 'unclassified', $, escapeHtml, fmtNum: (n) => String(Number(n) || 0), fmtDate: (d) => String(d).slice(0, 10), rankLabel: (r) => r, toast() {} },
|
|
312
|
+
} };
|
|
313
|
+
sandbox.globalThis = sandbox;
|
|
314
|
+
vm.createContext(sandbox);
|
|
315
|
+
vm.runInContext(ROLES_SRC, sandbox, { filename: 'profile-roles.js' });
|
|
316
|
+
vm.runInContext(SRC, sandbox, { filename: 'profile.js' });
|
|
317
|
+
for (let i = 0; i < 4; i++) await new Promise((r) => setImmediate(r));
|
|
318
|
+
return $('#xp-body').innerHTML;
|
|
319
|
+
}
|
|
320
|
+
const CP = (crafts) => ({ totals: { projects: 1, credits: 10, works_shipped: 0, karma: 0 }, disciplines: [], crafts, last_active: null, projects: [], more_projects: 0 });
|
|
321
|
+
const tileOf = (html, label) => {
|
|
322
|
+
const m = html.match(new RegExp(`${label}</span>\\s*<span class="stat-tile__num">([^<]*)</span>`));
|
|
323
|
+
return m ? m[1] : null;
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
await t('an artist\'s and an ideator\'s tiles show their own craft\'s numbers', async () => {
|
|
327
|
+
const html = await renderXp(CP({ artist: { pages_approved: 42, credits: 1100 }, ideator: { filed: 12, full_ideas: 2, ratified: 1, credits: 160 } }));
|
|
328
|
+
assert.equal(tileOf(html, 'Pages approved'), '42');
|
|
329
|
+
assert.equal(tileOf(html, 'Thoughts filed'), '12');
|
|
330
|
+
assert.equal(tileOf(html, 'Full Ideas'), '2');
|
|
331
|
+
assert.match(html, /1 ratified by the board/);
|
|
332
|
+
assert.match(html, /stat-row--craft/);
|
|
333
|
+
assert.equal(tileOf(html, 'Works shipped'), '0', 'CONTROL: the totals band is unchanged');
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
await t('a count no project could report is a dash, never 0', async () => {
|
|
337
|
+
const html = await renderXp(CP({ artist: { pages_approved: null, credits: 50 } }));
|
|
338
|
+
assert.equal(tileOf(html, 'Pages approved'), '—');
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
await t('no crafts (an engineer, or an older hub) → no craft band at all', async () => {
|
|
342
|
+
for (const crafts of [undefined, {}, { engineer: { shipped: 3, credits: 9 } }]) {
|
|
343
|
+
const html = await renderXp(CP(crafts));
|
|
344
|
+
assert.ok(!html.includes('stat-row--craft'), `no craft band for ${JSON.stringify(crafts)}`);
|
|
345
|
+
assert.equal((html.match(/stat-tile__num/g) || []).length, 4);
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
350
|
+
if (failed) process.exit(1);
|
|
@@ -102,6 +102,8 @@ try {
|
|
|
102
102
|
await q(migrationSql('platform_identity_022_activity_snapshots.sql'));
|
|
103
103
|
// upsertActivity also writes reported_as_of (026, task 1004268): 42703 without it.
|
|
104
104
|
await q(migrationSql('platform_identity_026_activity_reported_as_of.sql'));
|
|
105
|
+
// ...and crafts (029, task 1004440), which getRollupForBuilder also reads.
|
|
106
|
+
await q(migrationSql('platform_identity_029_activity_crafts.sql'));
|
|
105
107
|
await cleanup();
|
|
106
108
|
|
|
107
109
|
// CONSENT IS TWO COLUMNS (ADR 0213, task 1002913): profile_state='active' AND
|
package/tests/profile_route.mjs
CHANGED
|
@@ -108,13 +108,22 @@ function stubPublicProfileExtras() {
|
|
|
108
108
|
return {
|
|
109
109
|
totals: { projects: 2, credits: 150, works_shipped: 7, karma: 4 },
|
|
110
110
|
disciplines: ['engineer', 'ui'],
|
|
111
|
+
crafts: { artist: { pages_approved: 3, credits: 40 } },
|
|
111
112
|
last_active: '2026-08-01T00:00:00.000Z',
|
|
112
113
|
projects: named,
|
|
113
114
|
more_projects: CROSS_PROJECT_ROWS.length - named.length,
|
|
114
115
|
};
|
|
115
116
|
}
|
|
116
117
|
// The key contract for the cross_project object + its project rows.
|
|
117
|
-
|
|
118
|
+
// crafts (task 1004440) is each craft's summed counts — always present, {} when
|
|
119
|
+
// no project reported any — so it belongs in the exact list too.
|
|
120
|
+
const CP_PUBLIC_KEYS = ['totals', 'disciplines', 'crafts', 'last_active', 'projects', 'more_projects'];
|
|
121
|
+
// The only shape crafts may take: the three crafts, each a block of counts.
|
|
122
|
+
const CP_ROLE_FIELDS = {
|
|
123
|
+
engineer: ['credits', 'shipped'],
|
|
124
|
+
artist: ['credits', 'pages_approved'],
|
|
125
|
+
ideator: ['credits', 'filed', 'full_ideas', 'ratified'],
|
|
126
|
+
};
|
|
118
127
|
// recent_ships rides the project row (task 1002803) and is ALWAYS present —
|
|
119
128
|
// getRollupForBuilder defaults it to [] — so it belongs in the exact key list.
|
|
120
129
|
const CP_PROJECT_ROW_KEYS = ['name', 'origin', 'rank_held', 'recent_ships'];
|
|
@@ -129,6 +138,11 @@ const CP_SENTINELS = [SECRET_CLIENT_ID, SECRET_PROJECT_NAME, SECRET_EMAIL, SECRE
|
|
|
129
138
|
function assertCrossProjectContract(cp, text) {
|
|
130
139
|
assert.deepEqual(Object.keys(cp).sort(), [...CP_PUBLIC_KEYS].sort(), 'cross_project keys are exactly the contract');
|
|
131
140
|
assert.deepEqual(Object.keys(cp.totals).sort(), ['credits', 'karma', 'projects', 'works_shipped']);
|
|
141
|
+
for (const [craft, block] of Object.entries(cp.crafts)) {
|
|
142
|
+
assert.ok(CP_ROLE_FIELDS[craft], `crafts carries only the three crafts — found "${craft}"`);
|
|
143
|
+
assert.deepEqual(Object.keys(block).sort(), CP_ROLE_FIELDS[craft], `${craft} carries only its named counts`);
|
|
144
|
+
for (const v of Object.values(block)) assert.ok(v === null || Number.isInteger(v), 'a craft count is a number or null, never text');
|
|
145
|
+
}
|
|
132
146
|
for (const row of cp.projects) {
|
|
133
147
|
assert.deepEqual(Object.keys(row).sort(), [...CP_PROJECT_ROW_KEYS].sort(), 'project rows carry only the whitelisted keys');
|
|
134
148
|
}
|
|
@@ -619,7 +633,7 @@ await t('R06.5 no PARTIAL numbers survive the hide (a count beside a hidden tota
|
|
|
619
633
|
await pi.setAccountHideStats({ githubId: HUB_GITHUB_ID, githubLogin: 'jaxri', hide: true }, { pool: engine });
|
|
620
634
|
const r = await get(port, '/api/bongos/builders/95/profile');
|
|
621
635
|
assert.equal(r.json.cross_project, null);
|
|
622
|
-
for (const key of ['totals', 'works_shipped', 'more_projects', 'last_active', 'rank_held']) {
|
|
636
|
+
for (const key of ['totals', 'works_shipped', 'more_projects', 'last_active', 'rank_held', 'pages_approved']) {
|
|
623
637
|
assert.ok(!r.text.includes(key), `nothing derived from the rollup may survive — found "${key}"`);
|
|
624
638
|
}
|
|
625
639
|
const digits = [sumOf('credits'), sumOf('tasks_shipped'), sumOf('karma'),
|
package/tests/provision.mjs
CHANGED
|
@@ -1074,6 +1074,84 @@ await ta('scaffoldStandaloneRepo: a REGISTRY-pinned consumer control plane PACKS
|
|
|
1074
1074
|
}
|
|
1075
1075
|
});
|
|
1076
1076
|
|
|
1077
|
+
// ── new projects start on the RELEASED core, not the hall's candidate (task 1004299) ──
|
|
1078
|
+
// ADR 0361: merges publish as candidates and the platform hall runs them, so the core
|
|
1079
|
+
// this hall runs is routinely AHEAD of the registry's `latest` label. A new project —
|
|
1080
|
+
// and the pin refresh that runs in the same provisioning pass — must get the release.
|
|
1081
|
+
t('projectCoreDecision: the hall ahead of the release → the release; level or unreadable → the running core', () => {
|
|
1082
|
+
assert.deepEqual(
|
|
1083
|
+
(({ version, source }) => ({ version, source }))(RP.projectCoreDecision({ running: '1.19.60', released: '1.19.50' })),
|
|
1084
|
+
{ version: '1.19.50', source: 'released' });
|
|
1085
|
+
assert.equal(RP.projectCoreDecision({ running: '1.19.50', released: '1.19.50' }).source, 'running', 'level → byte-exact running core, no registry pack');
|
|
1086
|
+
assert.equal(RP.projectCoreDecision({ running: '1.19.40', released: '1.19.50' }).version, '1.19.40', 'never moves a project PAST what the hall has run');
|
|
1087
|
+
assert.equal(RP.projectCoreDecision({ running: '1.19.60', released: null }).version, '1.19.60', 'no label → fail-open to the running core');
|
|
1088
|
+
assert.equal(RP.projectCoreDecision({ running: '1.19.60', released: '1.19.50-rc.1' }).source, 'running', 'a prerelease label is not a release');
|
|
1089
|
+
assert.equal(RP.projectCoreDecision({ running: '1.19.60', released: '1.19.50;touch /tmp/x' }).source, 'running', 'a non-semver label never reaches a shell');
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
// A registry-installed control plane at 1.19.60 whose registry says `latest` = 1.19.50.
|
|
1093
|
+
function candidateHall({ latest }) {
|
|
1094
|
+
const cp = mkdtempSync(join(tmpdir(), 'cpdir-cand-'));
|
|
1095
|
+
mkdirSync(join(cp, 'node_modules', '@bongos', 'core'), { recursive: true });
|
|
1096
|
+
writeFileSync(join(cp, 'node_modules', '@bongos', 'core', 'package.json'), JSON.stringify({ name: '@bongos/core', version: '1.19.60' }));
|
|
1097
|
+
writeFileSync(join(cp, 'package.json'), JSON.stringify({ dependencies: { '@bongos/core': '1.19.60' } }));
|
|
1098
|
+
const { deps, cmds } = recorder({ apply: true });
|
|
1099
|
+
const exec = (cmd, o) => { cmds.push(cmd); return /^npm view @bongos\/core dist-tags\.latest$/.test(cmd) ? { ok: true, stdout: `${latest}\n` } : { ok: true, stdout: '' }; };
|
|
1100
|
+
return { cp, cmds, deps: { ...deps, exec, instanceRoot: cp } };
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
await ta('scaffoldStandaloneRepo: a hall running an UNRELEASED candidate births the project on the released core (task 1004299)', async () => {
|
|
1104
|
+
const { cp, cmds, deps } = candidateHall({ latest: '1.19.50' });
|
|
1105
|
+
const base = mkdtempSync(join(tmpdir(), 'cpdir-standalone-'));
|
|
1106
|
+
const origBase = P.CONFIG.standaloneBase;
|
|
1107
|
+
P.CONFIG.standaloneBase = base;
|
|
1108
|
+
try {
|
|
1109
|
+
await P.scaffoldStandaloneRepo({ slug: 'demo', hosting_shape: 'cloud-host', target_ref: 'example-owner/demo' }, deps);
|
|
1110
|
+
const joined = cmds.join('\n');
|
|
1111
|
+
assert.match(joined, /npm pack @bongos\/core@1\.19\.50 --pack-destination /, 'packs the RELEASED version from the registry');
|
|
1112
|
+
assert.match(joined, /--vendor-core \S*bongos-core-1\.19\.50\.tgz/, 'the child is pinned to the release');
|
|
1113
|
+
assert.ok(!/1\.19\.60\.tgz|bongos-core-pack-1\.19\.60/.test(joined), 'the candidate the hall runs is never vended');
|
|
1114
|
+
assert.match(joined, /on a pinned @bongos\/core 1\.19\.50 /, 'the scaffold commit names the version it really pinned');
|
|
1115
|
+
assert.ok(joined.indexOf('rm -rf') > joined.indexOf('--vendor-core'), 'the temp pack dir is cleaned after init');
|
|
1116
|
+
} finally {
|
|
1117
|
+
P.CONFIG.standaloneBase = origBase;
|
|
1118
|
+
[cp, base].forEach((d) => rmSync(d, { recursive: true, force: true }));
|
|
1119
|
+
}
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
await ta('scaffoldStandaloneRepo: a hall running the release itself keeps the byte-exact running core (task 1004299)', async () => {
|
|
1123
|
+
const { cp, cmds, deps } = candidateHall({ latest: '1.19.60' });
|
|
1124
|
+
const base = mkdtempSync(join(tmpdir(), 'cpdir-standalone-'));
|
|
1125
|
+
const origBase = P.CONFIG.standaloneBase;
|
|
1126
|
+
P.CONFIG.standaloneBase = base;
|
|
1127
|
+
try {
|
|
1128
|
+
await P.scaffoldStandaloneRepo({ slug: 'demo', hosting_shape: 'cloud-host', target_ref: 'example-owner/demo' }, deps);
|
|
1129
|
+
const joined = cmds.join('\n');
|
|
1130
|
+
assert.ok(!/npm pack @bongos\/core@/.test(joined), 'no registry pack when nothing is unreleased');
|
|
1131
|
+
assert.match(joined, /--vendor-core \S*bongos-core-1\.19\.60\.tgz/);
|
|
1132
|
+
} finally {
|
|
1133
|
+
P.CONFIG.standaloneBase = origBase;
|
|
1134
|
+
[cp, base].forEach((d) => rmSync(d, { recursive: true, force: true }));
|
|
1135
|
+
}
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
await ta('refreshStandaloneCorePin: a project pinned to the release is NOT bumped to the hall\'s candidate in the same run (task 1004299)', async () => {
|
|
1139
|
+
const { cp, cmds, deps } = candidateHall({ latest: '1.19.50' });
|
|
1140
|
+
const base = mkdtempSync(join(tmpdir(), 'cpdir-standalone-'));
|
|
1141
|
+
const origBase = P.CONFIG.standaloneBase;
|
|
1142
|
+
P.CONFIG.standaloneBase = base;
|
|
1143
|
+
try {
|
|
1144
|
+
mkdirSync(join(base, 'demo'));
|
|
1145
|
+
writeFileSync(join(base, 'demo', 'package.json'), JSON.stringify({ dependencies: { '@bongos/core': 'file:vendor/bongos-core-1.19.50.tgz' } }));
|
|
1146
|
+
const res = await P.refreshStandaloneCorePin({ slug: 'demo', hosting_shape: 'cloud-host', target_ref: 'example-owner/demo' }, deps);
|
|
1147
|
+
assert.equal(res.action, 'current', 'the released pin is already the core this project is given');
|
|
1148
|
+
assert.ok(!cmds.some((c) => /npm pack/.test(c)), 'nothing is packed or re-vendored');
|
|
1149
|
+
} finally {
|
|
1150
|
+
P.CONFIG.standaloneBase = origBase;
|
|
1151
|
+
[cp, base].forEach((d) => rmSync(d, { recursive: true, force: true }));
|
|
1152
|
+
}
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1077
1155
|
// ── scaffold push via the owner OAuth token (task 1002317) ────────────────────────
|
|
1078
1156
|
// Deploy keys are per-repo; a shared key can't write to a NEW project repo. Push with the
|
|
1079
1157
|
// owner's token (which created the repo) instead — WITHOUT leaking it into a logged command.
|