@bongos/core 1.21.4 → 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.
Files changed (126) hide show
  1. package/.bongos-core.json +233 -108
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +3 -1
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +1 -0
  8. package/docs/adr/0362-the-governor-docket-is-collected-from-contributed-sources.md +34 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +37 -2
  11. package/docs/api-reference.md +4 -3
  12. package/docs/copy-inventory.md +541 -658
  13. package/docs/copy-registry.json +1205 -2340
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/onboarding/diagrams/03-drachmae-karma.mmd +1 -1
  16. package/docs/onboarding/diagrams/assertions.json +1 -1
  17. package/docs/page-inventory.json +47 -28
  18. package/docs/page-readings.json +1128 -1339
  19. package/modules/autonomy/config-idle.js +192 -0
  20. package/modules/autonomy/governor-docket.js +101 -0
  21. package/modules/autonomy/routes/autonomy.js +27 -0
  22. package/modules/autonomy/runner-health.js +22 -1
  23. package/modules/discord/board-broadcast.js +3 -1
  24. package/modules/discord/craft-broadcast.js +107 -0
  25. package/modules/discord/routes/discord.js +10 -0
  26. package/modules/discord/ship-broadcast.js +30 -2
  27. package/modules/economy/reward.js +3 -0
  28. package/modules/government/board.js +20 -5
  29. package/modules/government/docket-vocab.js +82 -0
  30. package/modules/government/docket.js +458 -0
  31. package/modules/government/routes/government.js +34 -0
  32. package/modules/hall-ui/public/city-draw.js +438 -0
  33. package/modules/hall-ui/public/city.css +110 -0
  34. package/modules/hall-ui/public/city.html +71 -0
  35. package/modules/hall-ui/public/city.js +177 -0
  36. package/modules/hall-ui/public/city.states.json +30 -0
  37. package/modules/hall-ui/public/gate.js +11 -4
  38. package/modules/hall-ui/public/genesis-home.js +9 -10
  39. package/modules/hall-ui/public/profile.css +4 -0
  40. package/modules/hall-ui/public/profile.js +25 -1
  41. package/modules/hall-ui/public/settings-autobongos.js +19 -1
  42. package/modules/hall-ui/public/settings.css +26 -0
  43. package/modules/hall-ui/public/settings.html +8 -0
  44. package/modules/hall-ui/public/settings.js +43 -3
  45. package/modules/ideas/governor-docket.js +93 -0
  46. package/modules/ideas/routes/blockers.js +5 -0
  47. package/modules/lifecycle/governor-docket.js +192 -0
  48. package/modules/lifecycle/routes/gate-approvals.js +78 -33
  49. package/modules/lifecycle/routes/lifecycle.js +3 -0
  50. package/modules/onboarding/routes/access-requests.js +68 -27
  51. package/modules/platform-identity/craft-rollup.js +72 -0
  52. package/modules/platform-identity/migrations/platform_identity_029_activity_crafts.sql +23 -0
  53. package/modules/platform-identity/platform-identity.js +12 -6
  54. package/modules/platform-identity/routes/sso.js +4 -0
  55. package/modules/platform-identity/tests/platform-identity.mjs +1 -1
  56. package/modules/provisioning/core-upgrade.js +34 -7
  57. package/modules/provisioning/module.json +2 -1
  58. package/modules/provisioning/seams.js +2 -0
  59. package/modules/public-landing/public/assets/cosmos.css +6 -50
  60. package/modules/public-landing/public/projects.html +1170 -1659
  61. package/modules/public-landing/public/projects.probes.json +18 -18
  62. package/modules/public-landing/public/projects.states.json +24 -31
  63. package/modules/security/routes/reports.js +39 -0
  64. package/package-lock.json +2 -2
  65. package/package.json +1 -1
  66. package/release-notes.json +72 -0
  67. package/scripts/gds/autobongos-grade-cap.js +187 -0
  68. package/scripts/gds/autobongos-loop.js +4 -0
  69. package/scripts/gds/autobongos-run.js +154 -13
  70. package/scripts/gds/autobongos-verify.js +211 -14
  71. package/scripts/gds/provision-core-upgrade.js +14 -2
  72. package/scripts/gds/provision-repo.js +77 -13
  73. package/scripts/gds/ship-flow.js +5 -0
  74. package/scripts/gds/ship-preflight-steps.js +2 -1
  75. package/scripts/gds/ship.js +10 -0
  76. package/scripts/gds/update-sweep.js +24 -8
  77. package/scripts/gds/upgrade-outcome.js +1 -0
  78. package/src/bongos/auth-admission.js +95 -13
  79. package/src/bongos/db-kernel.js +1 -1
  80. package/src/bongos/module-rechecks.js +77 -0
  81. package/src/bongos/route-rank-check.js +3 -0
  82. package/src/bongos/routes/core-update.js +68 -2
  83. package/src/bongos/routes/modules.js +17 -0
  84. package/src/bongos/serve-internal.js +14 -0
  85. package/src/bongos/software-update.js +3 -2
  86. package/src/module-api.js +1 -1
  87. package/tests/activity_rollup_order_db.mjs +3 -1
  88. package/tests/activity_snapshots_db.mjs +2 -0
  89. package/tests/autobongos_grade_cap.mjs +125 -0
  90. package/tests/autobongos_loop.mjs +230 -1
  91. package/tests/autobongos_verify.mjs +239 -3
  92. package/tests/autonomy_config_idle.mjs +272 -0
  93. package/tests/core_update_banner.mjs +29 -0
  94. package/tests/core_upgrade_door.mjs +31 -1
  95. package/tests/core_upgrade_runner.mjs +20 -0
  96. package/tests/discord_craft_broadcast.mjs +162 -0
  97. package/tests/genesis_home.mjs +1 -1
  98. package/tests/government_routes.mjs +3 -1
  99. package/tests/governor_docket.mjs +552 -0
  100. package/tests/hall_audit.mjs +10 -1
  101. package/tests/hall_city.mjs +369 -0
  102. package/tests/hall_page_gate_map.mjs +5 -0
  103. package/tests/hub_craft_rollup.mjs +350 -0
  104. package/tests/onboard.mjs +10 -4
  105. package/tests/platform_boot.mjs +1 -1
  106. package/tests/profile_rollup_consent.mjs +2 -0
  107. package/tests/profile_route.mjs +16 -2
  108. package/tests/projects_hub.mjs +196 -89
  109. package/tests/projects_hub_app_status.mjs +3 -1
  110. package/tests/projects_hub_app_step.mjs +72 -48
  111. package/tests/projects_hub_dns_ready.mjs +10 -2
  112. package/tests/projects_hub_look.mjs +32 -22
  113. package/tests/projects_hub_module_picker.mjs +112 -62
  114. package/tests/projects_hub_pre_uat.mjs +48 -41
  115. package/tests/provision.mjs +78 -0
  116. package/tests/public_landing_projects.mjs +12 -6
  117. package/tests/settings_main_role.mjs +190 -0
  118. package/tests/software_update.mjs +56 -1
  119. package/tests/update_subscription_engine.mjs +55 -3
  120. package/tests/wizard_demo.mjs +37 -11
  121. package/tests/wizard_draft_resume.mjs +34 -22
  122. package/tests/wizard_front_door.mjs +173 -103
  123. package/tests/wizard_intent_resume.mjs +28 -25
  124. package/tests/wizard_physics.mjs +94 -76
  125. package/tests/wizard_physics_more.mjs +428 -277
  126. package/tests/wizard_six_screens.mjs +126 -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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);
package/tests/onboard.mjs CHANGED
@@ -81,10 +81,16 @@ test('every form OFFERS the optional step and names what skipping costs — skil
81
81
  assert.match(cli, /Press Enter to skip/, 'the terminal form offers the skip in the prompt itself');
82
82
  assert.match(cli, /off the public map/, 'and says what a missing description means');
83
83
 
84
- // the web form
85
- const hub = read('modules/public-landing/public/projects.html');
86
- assert.match(hub, /id="panel-detail"/, 'the wizard ships the panel');
87
- assert.match(hub, /id="skipDetail"/, 'with a skip control of its own');
84
+ // the web form — since task 1004504 the optional step is no panel of its own: the
85
+ // sentence is an optional field on "What are you making?" whose label names what
86
+ // leaving it empty costs, and the team shape is the review's Team size row
87
+ const hub = read('modules/public-landing/public/projects.html').replace(/\r\n/g, '\n');
88
+ const what = (hub.match(/<section class="panel" id="panel-what"[\s\S]*?\n {10}<\/section>/) || [''])[0];
89
+ assert.ok(what, 'the wizard ships the screen that carries the sentence');
90
+ assert.match(what, /<label class="wizField"><span>In a sentence <small>\(optional — puts it on the public map\)<\/small><\/span>\s*<input type="text" id="f-desc"/,
91
+ 'the sentence is offered as optional, and its label says what skipping costs');
92
+ assert.match(hub, /<div class="setRow" data-row="team">[\s\S]*?id="teamCards"/, 'the team shape rides the review\'s Team size row');
93
+ assert.doesNotMatch(hub, /id="panel-detail"|id="skipDetail"/, 'the old About panel and its skip control are gone');
88
94
 
89
95
  // the route that receives it
90
96
  const route = read('modules/provisioning/routes/provisioning.js');
@@ -147,7 +147,7 @@ test('/create redirects into the hub wizard, seed intact (task 1002763)', async
147
147
  assert.equal(hub.status, 200);
148
148
  assert.match(hub.headers['content-type'] || '', /text\/html/);
149
149
  assert.match(hub.body, /window\.__BRANDING__=/);
150
- assert.match(hub.body, /Create a project/);
150
+ assert.match(hub.body, /<h1 id="gate-h">Make something\.<\/h1>/); // the wizard's sign-in screen (task 1004504)
151
151
  // the wizard files the request against the provisioning API, nothing else
152
152
  assert.match(hub.body, /provisioning\/instances/);
153
153
  });
@@ -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
@@ -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
- const CP_PUBLIC_KEYS = ['totals', 'disciplines', 'last_active', 'projects', 'more_projects'];
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'),