@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.
- package/.bongos-core.json +233 -108
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +1 -0
- package/docs/adr/0362-the-governor-docket-is-collected-from-contributed-sources.md +34 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +37 -2
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +541 -658
- package/docs/copy-registry.json +1205 -2340
- package/docs/module-api-changelog.md +4 -0
- package/docs/onboarding/diagrams/03-drachmae-karma.mmd +1 -1
- package/docs/onboarding/diagrams/assertions.json +1 -1
- package/docs/page-inventory.json +47 -28
- package/docs/page-readings.json +1128 -1339
- package/modules/autonomy/config-idle.js +192 -0
- package/modules/autonomy/governor-docket.js +101 -0
- package/modules/autonomy/routes/autonomy.js +27 -0
- package/modules/autonomy/runner-health.js +22 -1
- package/modules/discord/board-broadcast.js +3 -1
- package/modules/discord/craft-broadcast.js +107 -0
- package/modules/discord/routes/discord.js +10 -0
- package/modules/discord/ship-broadcast.js +30 -2
- package/modules/economy/reward.js +3 -0
- package/modules/government/board.js +20 -5
- package/modules/government/docket-vocab.js +82 -0
- package/modules/government/docket.js +458 -0
- package/modules/government/routes/government.js +34 -0
- 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/genesis-home.js +9 -10
- 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/ideas/governor-docket.js +93 -0
- package/modules/ideas/routes/blockers.js +5 -0
- package/modules/lifecycle/governor-docket.js +192 -0
- package/modules/lifecycle/routes/gate-approvals.js +78 -33
- package/modules/lifecycle/routes/lifecycle.js +3 -0
- package/modules/onboarding/routes/access-requests.js +68 -27
- 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/assets/cosmos.css +6 -50
- package/modules/public-landing/public/projects.html +1170 -1659
- package/modules/public-landing/public/projects.probes.json +18 -18
- package/modules/public-landing/public/projects.states.json +24 -31
- package/modules/security/routes/reports.js +39 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +72 -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/module-rechecks.js +77 -0
- package/src/bongos/route-rank-check.js +3 -0
- package/src/bongos/routes/core-update.js +68 -2
- package/src/bongos/routes/modules.js +17 -0
- 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/discord_craft_broadcast.mjs +162 -0
- package/tests/genesis_home.mjs +1 -1
- package/tests/government_routes.mjs +3 -1
- package/tests/governor_docket.mjs +552 -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/onboard.mjs +10 -4
- package/tests/platform_boot.mjs +1 -1
- package/tests/profile_rollup_consent.mjs +2 -0
- package/tests/profile_route.mjs +16 -2
- package/tests/projects_hub.mjs +196 -89
- package/tests/projects_hub_app_status.mjs +3 -1
- package/tests/projects_hub_app_step.mjs +72 -48
- package/tests/projects_hub_dns_ready.mjs +10 -2
- package/tests/projects_hub_look.mjs +32 -22
- package/tests/projects_hub_module_picker.mjs +112 -62
- package/tests/projects_hub_pre_uat.mjs +48 -41
- package/tests/provision.mjs +78 -0
- package/tests/public_landing_projects.mjs +12 -6
- 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_demo.mjs +37 -11
- package/tests/wizard_draft_resume.mjs +34 -22
- package/tests/wizard_front_door.mjs +173 -103
- package/tests/wizard_intent_resume.mjs +28 -25
- package/tests/wizard_physics.mjs +94 -76
- package/tests/wizard_physics_more.mjs +428 -277
- 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, '&').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);
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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');
|
package/tests/platform_boot.mjs
CHANGED
|
@@ -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,
|
|
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
|
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'),
|