@bongos/core 1.19.168 → 1.19.170
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 +45 -30
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +8 -1
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +128 -4
- package/docs/api-reference.md +4 -2
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
- package/modules/hall-ui/public/settings.html +14 -0
- package/modules/hall-ui/public/settings.js +84 -0
- package/modules/platform-identity/migrations/platform_identity_007_profile_consent.sql +32 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/platform-identity.js +173 -9
- package/modules/platform-identity/routes/profile.js +77 -0
- package/modules/platform-identity/routes/scouting.js +7 -6
- package/modules/platform-identity/routes/sso.js +38 -0
- package/modules/platform-identity/tests/platform-identity.mjs +159 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/run-unit-tests.js +1 -1
- package/src/bongos/auth.js +18 -6
- package/src/bongos/routes/builders.js +25 -5
- package/src/module-api.js +5 -1
- package/tests/profile_rollup_consent.mjs +187 -0
- package/tests/profile_route.mjs +86 -1
- package/tests/scouting_rollup_fanout.mjs +6 -2
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.170",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.170",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.170",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -75,7 +75,7 @@ const INTEGRATION = new Set([
|
|
|
75
75
|
'consumer_layout_boot',
|
|
76
76
|
'claim_gate_open_blocker', 'claim_gate_rebase', 'clawback_confirmed_backlog', 'discord_autojoin', 'discord_oauth',
|
|
77
77
|
'governance_fold_bench', 'governance_live_proof', 'linkify_refs', 'memory_isolation',
|
|
78
|
-
'memory_pull', 'needs_rebase', 'parallel-load', 'reward_mode_cost_plus_only',
|
|
78
|
+
'memory_pull', 'needs_rebase', 'parallel-load', 'profile_rollup_consent', 'reward_mode_cost_plus_only',
|
|
79
79
|
'scouting_rollup_fanout', 'session_records', 'setup_smoke',
|
|
80
80
|
'status-probe',
|
|
81
81
|
]);
|
package/src/bongos/auth.js
CHANGED
|
@@ -764,9 +764,17 @@ async function completeIdpFlow({ code, redirectUri, userAgent: ua }) {
|
|
|
764
764
|
expiresAt,
|
|
765
765
|
});
|
|
766
766
|
await seams.resolveOptional('onboarding')?.safeMarkStage(pool, builder.id, 'auth_complete', builder);
|
|
767
|
-
// Fire-and-forget hub membership check-in + activity rollup (ADR 0141 §3/§4) —
|
|
768
|
-
|
|
769
|
-
|
|
767
|
+
// Fire-and-forget hub membership check-in + activity rollup (ADR 0141 §3/§4) —
|
|
768
|
+
// federated web sign-in. The rollup is CHAINED after the check-in (ADR 0171 D7):
|
|
769
|
+
// the hub now requires a membership row before accepting activity, so a first
|
|
770
|
+
// sign-in's rollup must not race its own check-in. Both swallow all errors, so
|
|
771
|
+
// the chain never rejects and the sign-in never waits on it.
|
|
772
|
+
// Both helpers are async (always return a promise) and swallow their own
|
|
773
|
+
// errors; the .catch is belt-and-braces so this floating chain can never
|
|
774
|
+
// surface an unhandledRejection even if a future edit lets one throw.
|
|
775
|
+
reportMembershipCheckIn({ githubId: ghId, githubLogin: ghLogin, displayName: identity.display_name, avatarUrl: identity.avatar_url })
|
|
776
|
+
.then(() => reportActivityRollup({ builderId: builder.id, githubId: ghId }))
|
|
777
|
+
.catch(() => {});
|
|
770
778
|
return { token, builder, expiresAt };
|
|
771
779
|
}
|
|
772
780
|
|
|
@@ -935,9 +943,13 @@ async function completeDeviceExchange({ assertion, userAgent: ua }) {
|
|
|
935
943
|
expiresAt: null,
|
|
936
944
|
});
|
|
937
945
|
await seams.resolveOptional('onboarding')?.safeMarkStage(pool, builder.id, 'auth_complete', builder);
|
|
938
|
-
// Fire-and-forget hub membership check-in + activity rollup (ADR 0141 §3/§4) —
|
|
939
|
-
|
|
940
|
-
|
|
946
|
+
// Fire-and-forget hub membership check-in + activity rollup (ADR 0141 §3/§4) —
|
|
947
|
+
// federated CLI sign-in. Rollup chained after check-in (ADR 0171 D7): the hub's
|
|
948
|
+
// membership requirement must not race a first sign-in; both swallow errors.
|
|
949
|
+
// Async + self-swallowing helpers; .catch is belt-and-braces (see web flow).
|
|
950
|
+
reportMembershipCheckIn({ githubId: ghId, githubLogin: ghLogin, displayName: payload.display_name, avatarUrl: payload.avatar_url })
|
|
951
|
+
.then(() => reportActivityRollup({ builderId: builder.id, githubId: ghId }))
|
|
952
|
+
.catch(() => {});
|
|
941
953
|
return { token, builder };
|
|
942
954
|
}
|
|
943
955
|
|
|
@@ -460,13 +460,17 @@ module.exports = function buildBuildersRouter() {
|
|
|
460
460
|
const b = await db.getBuilderById(targetId);
|
|
461
461
|
if (!b) return res.fail('builder_not_found', 404);
|
|
462
462
|
// Recent ships + shipped-count read the lifecycle port; the achievement
|
|
463
|
-
// shelf reads the reward port
|
|
464
|
-
//
|
|
463
|
+
// shelf reads the reward port; the cross-project record reads the hub port
|
|
464
|
+
// (ADR 0171 D1 — registered only where the platform-identity module is
|
|
465
|
+
// enabled, i.e. the hub). All resolveOptional — on a vanilla instance the
|
|
466
|
+
// shelf degrades to empty and the cross_project section is absent, not a 500.
|
|
465
467
|
const reward = seams.resolveOptional('reward');
|
|
466
|
-
const
|
|
468
|
+
const hub = seams.resolveOptional('platform-identity.hub');
|
|
469
|
+
const [shippedCount, recentShips, achievements, crossProject] = await Promise.all([
|
|
467
470
|
lc().countShippedTasksForBuilder(targetId),
|
|
468
471
|
lc().recentShippedTasksByBuilder(targetId, 5),
|
|
469
472
|
reward ? reward.getBuilderAchievements(targetId) : [],
|
|
473
|
+
hub ? hub.getPublicProfileExtras(b.github_id) : null,
|
|
470
474
|
]);
|
|
471
475
|
// WHITELIST — only the leaderboard-equivalent public field set. Never spread
|
|
472
476
|
// the raw row: it carries anthropic_email / anthropic_user_id /
|
|
@@ -484,8 +488,24 @@ module.exports = function buildBuildersRouter() {
|
|
|
484
488
|
preferred_disciplines: b.preferred_disciplines,
|
|
485
489
|
};
|
|
486
490
|
// Identity + counters change rarely; a short public cache matches
|
|
487
|
-
// /public/leaderboard's posture.
|
|
488
|
-
|
|
491
|
+
// /public/leaderboard's posture. On the hub the response carries the
|
|
492
|
+
// cross_project section, whose hide flag must take effect IMMEDIATELY
|
|
493
|
+
// (ADR 0171 D5) — a 60s cached copy would keep serving hidden stats — so
|
|
494
|
+
// when the hub port resolves the response is no-store.
|
|
495
|
+
res.set('Cache-Control', hub ? 'no-store' : 'public, max-age=60');
|
|
496
|
+
// cross_project is attached only where the hub port exists (two literal
|
|
497
|
+
// branches so gen-api-docs proves the shape — the key merges as optional);
|
|
498
|
+
// null there means "no public cross-project record" (hidden / provisional /
|
|
499
|
+
// none — deliberately indistinguishable). Viewer-independent (ADR 0171 D4).
|
|
500
|
+
if (hub) {
|
|
501
|
+
return res.json({
|
|
502
|
+
builder,
|
|
503
|
+
shipped_count: shippedCount,
|
|
504
|
+
recent_ships: recentShips,
|
|
505
|
+
achievements,
|
|
506
|
+
cross_project: crossProject,
|
|
507
|
+
});
|
|
508
|
+
}
|
|
489
509
|
res.json({
|
|
490
510
|
builder,
|
|
491
511
|
shipped_count: shippedCount,
|
package/src/module-api.js
CHANGED
|
@@ -763,8 +763,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
763
763
|
// landed since 1.19.166 with no explicit bump. run 31961815831. (task 1002620)
|
|
764
764
|
// 1.19.168 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
765
765
|
// landed since 1.19.167 with no explicit bump. run 31962256201. (task 1002620)
|
|
766
|
+
// 1.19.169 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
767
|
+
// landed since 1.19.168 with no explicit bump. run 31964702865. (task 1002620)
|
|
768
|
+
// 1.19.170 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
769
|
+
// landed since 1.19.169 with no explicit bump. run 31965483417. (task 1002620)
|
|
766
770
|
// ---------------------------------------------------------------------------
|
|
767
|
-
const CORE_VERSION = '1.19.
|
|
771
|
+
const CORE_VERSION = '1.19.170'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
768
772
|
|
|
769
773
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
770
774
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// tests/profile_rollup_consent.mjs — task 1002278 (R02, ADR 0171; real DB).
|
|
2
|
+
//
|
|
3
|
+
// Proves the consent + hide semantics against real SQL — pg-mem cannot execute
|
|
4
|
+
// unnest(), so the rollup queries need a live Postgres (the task-1002911
|
|
5
|
+
// precedent). Replays the module's actual migrations, then asserts:
|
|
6
|
+
// 1. listScoutingRollup lists ONLY active + not-hidden builders (D2/D3) —
|
|
7
|
+
// provisional and hidden accounts drop out of /people + /scouting by
|
|
8
|
+
// construction.
|
|
9
|
+
// 2. getRollupForBuilder aggregates un-fanned and names ONLY projects whose
|
|
10
|
+
// catalog row is publicly listed (featured + active); everything else is
|
|
11
|
+
// counted in more_projects, never named. client_id never crosses.
|
|
12
|
+
// 3. getPublicProfileExtras is fail-closed (missing/provisional/hidden/
|
|
13
|
+
// non-numeric → null); getOwnProfileExtras serves the self view anyway.
|
|
14
|
+
// 4. upsertAccount's consent upgrade is one-way (activate flips provisional →
|
|
15
|
+
// active; a later plain upsert never demotes).
|
|
16
|
+
// 5. upsertActivity stores a validated rank (off-ladder → NULL) + capped
|
|
17
|
+
// disciplines against the real columns (D7).
|
|
18
|
+
//
|
|
19
|
+
// Real-DB integration test: self-skips without a reachable Postgres; in the
|
|
20
|
+
// INTEGRATION set so the default DB-free lane never runs it.
|
|
21
|
+
//
|
|
22
|
+
// Run: DATABASE_URL=... node tests/profile_rollup_consent.mjs
|
|
23
|
+
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
import { readFileSync } from 'node:fs';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
import { dirname, join } from 'node:path';
|
|
28
|
+
import { strict as assert } from 'node:assert';
|
|
29
|
+
const require = createRequire(import.meta.url);
|
|
30
|
+
const { Pool } = require('pg');
|
|
31
|
+
const pi = require('../modules/platform-identity/platform-identity.js');
|
|
32
|
+
|
|
33
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
34
|
+
const MIG_DIR = join(here, '..', 'modules', 'platform-identity', 'migrations');
|
|
35
|
+
const migrationSql = (f) => readFileSync(join(MIG_DIR, f), 'utf8');
|
|
36
|
+
|
|
37
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
38
|
+
try {
|
|
39
|
+
await pool.query('SELECT 1');
|
|
40
|
+
} catch (e) {
|
|
41
|
+
console.log(`profile_rollup_consent.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
42
|
+
await pool.end().catch(() => {});
|
|
43
|
+
process.exit(0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// High-numbered fixture ids so a shared DB can never collide with real data.
|
|
47
|
+
const ACTIVE = 90002278, HIDDEN = 90002279, PROV = 90002280, LAZY = 90002281;
|
|
48
|
+
const ALL_IDS = [ACTIVE, HIDDEN, PROV, LAZY];
|
|
49
|
+
const PUB_CLIENT = 't1002278-pub', PRIV_CLIENT = 't1002278-priv', LOST_CLIENT = 't1002278-lost';
|
|
50
|
+
const ALL_CLIENTS = [PUB_CLIENT, PRIV_CLIENT, LOST_CLIENT];
|
|
51
|
+
const PUB_ORIGIN = 'https://t1002278-pub.example', PRIV_ORIGIN = 'https://t1002278-priv.example';
|
|
52
|
+
const q = (sql, params) => pool.query(sql, params);
|
|
53
|
+
|
|
54
|
+
async function cleanup() {
|
|
55
|
+
await q(`DELETE FROM platform_identity_builder_project_activity WHERE github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
|
|
56
|
+
await q(`DELETE FROM platform_identity_project_memberships WHERE github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
|
|
57
|
+
await q(`DELETE FROM platform_identity_accounts WHERE github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
|
|
58
|
+
await q(`DELETE FROM platform_identity_projects WHERE origin = ANY($1::text[])`, [[PUB_ORIGIN, PRIV_ORIGIN]]).catch(() => {});
|
|
59
|
+
await q(`DELETE FROM platform_identity_sso_clients WHERE client_id = ANY($1::text[])`, [ALL_CLIENTS]).catch(() => {});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let pass = 0;
|
|
63
|
+
const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
// The module's actual migrations (idempotent).
|
|
67
|
+
await q(migrationSql('platform_identity_001_tables.sql'));
|
|
68
|
+
await q(migrationSql('platform_identity_004_projects.sql'));
|
|
69
|
+
await q(migrationSql('platform_identity_005_builder_activity.sql'));
|
|
70
|
+
await q(migrationSql('platform_identity_007_profile_consent.sql'));
|
|
71
|
+
await cleanup();
|
|
72
|
+
|
|
73
|
+
await q(
|
|
74
|
+
`INSERT INTO platform_identity_accounts (github_id, github_login, display_name, profile_state, hide_stats)
|
|
75
|
+
VALUES ($1, 'active-b', 'Active', 'active', false),
|
|
76
|
+
($2, 'hidden-b', 'Hidden', 'active', true),
|
|
77
|
+
($3, 'prov-b', 'Prov', 'provisional', false)`,
|
|
78
|
+
[ACTIVE, HIDDEN, PROV],
|
|
79
|
+
);
|
|
80
|
+
// Three reporting projects: one publicly catalogued (featured+active), one
|
|
81
|
+
// catalogued but NOT featured (→ never named), one with no catalog row at all.
|
|
82
|
+
await q(
|
|
83
|
+
`INSERT INTO platform_identity_sso_clients (client_id, secret_hash, name, origin)
|
|
84
|
+
VALUES ($1, 'x', 'Pub Proj', $4), ($2, 'x', 'Priv Proj', $5), ($3, 'x', 'Lost Proj', NULL)`,
|
|
85
|
+
[...ALL_CLIENTS, PUB_ORIGIN, PRIV_ORIGIN],
|
|
86
|
+
);
|
|
87
|
+
await q(
|
|
88
|
+
`INSERT INTO platform_identity_projects (origin, name, featured, status)
|
|
89
|
+
VALUES ($1, 'Pub Proj', true, 'active'), ($2, 'Priv Proj', false, 'active')`,
|
|
90
|
+
[PUB_ORIGIN, PRIV_ORIGIN],
|
|
91
|
+
);
|
|
92
|
+
await q(
|
|
93
|
+
`INSERT INTO platform_identity_builder_project_activity
|
|
94
|
+
(client_id, github_id, credits, tasks_shipped, karma, rank_held, disciplines, last_active)
|
|
95
|
+
VALUES ($1, $4, 100, 5, 3, 'metic', '{engineer,ui}', '2026-01-01T00:00:00Z'),
|
|
96
|
+
($2, $4, 50, 2, 1, 'archon', '{artist}', '2026-02-01T00:00:00Z'),
|
|
97
|
+
($3, $4, 25, 1, 0, NULL, '{}', '2026-03-01T00:00:00Z'),
|
|
98
|
+
($1, $5, 60, 4, 2, 'metic', '{engineer}', '2026-01-01T00:00:00Z'),
|
|
99
|
+
($1, $6, 10, 1, 0, 'xenos', '{engineer}', '2026-01-01T00:00:00Z')`,
|
|
100
|
+
[...ALL_CLIENTS, ACTIVE, HIDDEN, PROV],
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// 1 — the scouting/people predicate (D2/D3).
|
|
104
|
+
const listed = (await pi.listScoutingRollup({ limit: 500, pool })).map((r) => Number(r.github_id));
|
|
105
|
+
ok(listed.includes(ACTIVE), 'active + visible builder is listed');
|
|
106
|
+
ok(!listed.includes(HIDDEN), 'hide_stats builder drops out of the scouting rollup');
|
|
107
|
+
ok(!listed.includes(PROV), 'provisional (no hub-session consent) builder drops out');
|
|
108
|
+
|
|
109
|
+
// 2 — getRollupForBuilder semantics.
|
|
110
|
+
const r = await pi.getRollupForBuilder(ACTIVE, { pool });
|
|
111
|
+
assert.deepEqual(r.totals, { projects: 3, credits: 175, works_shipped: 8, karma: 4 });
|
|
112
|
+
pass++; console.log(' ok - totals aggregate un-fanned across the builder\'s projects');
|
|
113
|
+
assert.deepEqual([...r.disciplines].sort(), ['artist', 'engineer', 'ui']);
|
|
114
|
+
pass++; console.log(' ok - disciplines are the deduped union');
|
|
115
|
+
ok(new Date(r.last_active).toISOString() === '2026-03-01T00:00:00.000Z', 'last_active is the max');
|
|
116
|
+
ok(r.projects.length === 1 && r.projects[0].name === 'Pub Proj', 'only the publicly-catalogued project is named');
|
|
117
|
+
assert.deepEqual(Object.keys(r.projects[0]).sort(), ['name', 'origin', 'rank_held']);
|
|
118
|
+
pass++; console.log(' ok - named rows carry exactly name/origin/rank_held');
|
|
119
|
+
ok(r.projects[0].rank_held === 'metic', 'rank_held is that project\'s reported standing');
|
|
120
|
+
ok(r.more_projects === 2, 'unfeatured + uncatalogued projects are counted, never named');
|
|
121
|
+
ok(!JSON.stringify(r).includes('client_id'), 'client_id never crosses the shape');
|
|
122
|
+
ok(!JSON.stringify(r).includes('Priv Proj'), 'a non-public project name never crosses');
|
|
123
|
+
|
|
124
|
+
// 3 — the port reads.
|
|
125
|
+
ok(await pi.getPublicProfileExtras(HIDDEN, { pool }) === null, 'public read → null for a hidden builder');
|
|
126
|
+
ok(await pi.getPublicProfileExtras(PROV, { pool }) === null, 'public read → null for a provisional builder');
|
|
127
|
+
ok(await pi.getPublicProfileExtras(99999999999, { pool }) === null, 'public read → null with no account');
|
|
128
|
+
ok(await pi.getPublicProfileExtras('system:bfg', { pool }) === null, 'non-numeric principal → null, no bigint cast error');
|
|
129
|
+
const pub = await pi.getPublicProfileExtras(ACTIVE, { pool });
|
|
130
|
+
ok(pub && pub.totals.credits === 175, 'public read returns the rollup for an active, visible builder');
|
|
131
|
+
const own = await pi.getOwnProfileExtras(HIDDEN, { pool });
|
|
132
|
+
ok(own && own.totals.credits === 60, 'self view serves a hidden builder their own numbers');
|
|
133
|
+
|
|
134
|
+
// 4 — one-way consent upgrade on the real upsert.
|
|
135
|
+
await pi.upsertAccount({ githubId: LAZY, githubLogin: 'lazy-b' }, { pool });
|
|
136
|
+
let acct = await pi.getAccount(LAZY, { pool });
|
|
137
|
+
ok(acct.profile_state === 'provisional', 'a plain upsert creates a provisional account');
|
|
138
|
+
await pi.upsertAccount({ githubId: LAZY, githubLogin: 'lazy-b', activate: true }, { pool });
|
|
139
|
+
acct = await pi.getAccount(LAZY, { pool });
|
|
140
|
+
ok(acct.profile_state === 'active', 'an authorize-path upsert (activate) flips it active');
|
|
141
|
+
await pi.upsertAccount({ githubId: LAZY, githubLogin: 'lazy-b' }, { pool });
|
|
142
|
+
acct = await pi.getAccount(LAZY, { pool });
|
|
143
|
+
ok(acct.profile_state === 'active', 'a later plain upsert never demotes an active account');
|
|
144
|
+
|
|
145
|
+
// 4b — the live hide toggle (R03, task 1002279): flipping hide_stats takes
|
|
146
|
+
// effect IMMEDIATELY on every public read — the profile port and the
|
|
147
|
+
// /people + /scouting rollup — while the self view keeps serving.
|
|
148
|
+
await pi.setAccountHideStats({ githubId: ACTIVE, githubLogin: 'active-b', hide: true }, { pool });
|
|
149
|
+
ok(await pi.getPublicProfileExtras(ACTIVE, { pool }) === null, 'hide ON → public read drops to null immediately');
|
|
150
|
+
const hiddenListed = (await pi.listScoutingRollup({ limit: 500, pool })).map((r) => Number(r.github_id));
|
|
151
|
+
ok(!hiddenListed.includes(ACTIVE), 'hide ON → the builder drops out of /people + /scouting immediately');
|
|
152
|
+
const stillOwn = await pi.getOwnProfileExtras(ACTIVE, { pool });
|
|
153
|
+
ok(stillOwn && stillOwn.totals.credits === 175, 'hide ON → the self view still serves their own numbers');
|
|
154
|
+
let hiddenAcct = await pi.getAccount(ACTIVE, { pool });
|
|
155
|
+
ok(hiddenAcct.profile_state === 'active', 'the toggle never touches profile_state');
|
|
156
|
+
ok(hiddenAcct.github_login === 'active-b', 'the toggle never clobbers identity fields');
|
|
157
|
+
await pi.setAccountHideStats({ githubId: ACTIVE, githubLogin: 'active-b', hide: false }, { pool });
|
|
158
|
+
const backPub = await pi.getPublicProfileExtras(ACTIVE, { pool });
|
|
159
|
+
ok(backPub && backPub.totals.credits === 175, 'hide OFF → the public read returns immediately');
|
|
160
|
+
|
|
161
|
+
// 4c — hide-before-federating: a builder with NO account row can still hide;
|
|
162
|
+
// the insert creates a fail-closed provisional account carrying the flag.
|
|
163
|
+
const EARLY = 90002282;
|
|
164
|
+
await q(`DELETE FROM platform_identity_accounts WHERE github_id = $1`, [EARLY]).catch(() => {});
|
|
165
|
+
const early = await pi.setAccountHideStats({ githubId: EARLY, githubLogin: 'early-b', hide: true }, { pool });
|
|
166
|
+
ok(early.hide_stats === true && early.profile_state === 'provisional',
|
|
167
|
+
'hide with no account row creates a provisional, hidden account');
|
|
168
|
+
await q(`DELETE FROM platform_identity_accounts WHERE github_id = $1`, [EARLY]).catch(() => {});
|
|
169
|
+
|
|
170
|
+
// 5 — rollup payload sanitization against the real columns (D7).
|
|
171
|
+
await pi.upsertActivity({
|
|
172
|
+
clientId: PUB_CLIENT, githubId: LAZY, credits: 1,
|
|
173
|
+
rank: 'Supreme Overlord',
|
|
174
|
+
disciplines: ['engineer', 42, 'x'.repeat(33), 'a', 'b', 'c', 'd', 'e', 'f', 'g'],
|
|
175
|
+
}, { pool });
|
|
176
|
+
const { rows: [act] } = await q(
|
|
177
|
+
`SELECT rank_held, disciplines FROM platform_identity_builder_project_activity
|
|
178
|
+
WHERE client_id = $1 AND github_id = $2`, [PUB_CLIENT, LAZY]);
|
|
179
|
+
ok(act.rank_held === null, 'an off-ladder rank is stored as NULL');
|
|
180
|
+
ok(act.disciplines.length <= 8 && act.disciplines.every((d) => d.length <= 32),
|
|
181
|
+
'disciplines land validated + capped');
|
|
182
|
+
|
|
183
|
+
console.log(`\nprofile_rollup_consent.mjs: ${pass} passed`);
|
|
184
|
+
} finally {
|
|
185
|
+
await cleanup();
|
|
186
|
+
await pool.end().catch(() => {});
|
|
187
|
+
}
|
package/tests/profile_route.mjs
CHANGED
|
@@ -73,6 +73,36 @@ const PUBLIC_KEYS = [
|
|
|
73
73
|
'total_credits', 'karma', 'created_at', 'preferred_disciplines',
|
|
74
74
|
];
|
|
75
75
|
|
|
76
|
+
// ---- the cross_project scope (ADR 0171, task 1002278) ---------------------
|
|
77
|
+
//
|
|
78
|
+
// On the hub the route resolves the 'platform-identity.hub' port and attaches
|
|
79
|
+
// its whitelist-shaped return as `cross_project`. The stub below mirrors the
|
|
80
|
+
// module's contract over an in-memory fixture that — like FULL_ROW above —
|
|
81
|
+
// deliberately carries the module-internal values (a client_id, a non-public
|
|
82
|
+
// project's name) so the raw-text scan proves the whitelist held end-to-end.
|
|
83
|
+
const SECRET_CLIENT_ID = 'cli_DO_NOT_LEAK_9f3a';
|
|
84
|
+
const SECRET_PROJECT_NAME = 'DO_NOT_LEAK_PRIVATE_PROJECT';
|
|
85
|
+
const CROSS_PROJECT_ROWS = [
|
|
86
|
+
{ client_id: SECRET_CLIENT_ID, name: 'Public Proj', origin: 'https://pub.example', rank_held: 'metic', is_public: true },
|
|
87
|
+
{ client_id: SECRET_CLIENT_ID, name: SECRET_PROJECT_NAME, origin: 'https://priv.example', rank_held: 'archon', is_public: false },
|
|
88
|
+
];
|
|
89
|
+
// What the port returns for an active, visible account: the module's whitelist
|
|
90
|
+
// shape — named projects only where the catalog row is public; the rest counted.
|
|
91
|
+
function stubPublicProfileExtras() {
|
|
92
|
+
const named = CROSS_PROJECT_ROWS.filter((r) => r.is_public)
|
|
93
|
+
.map((r) => ({ name: r.name, origin: r.origin, rank_held: r.rank_held }));
|
|
94
|
+
return {
|
|
95
|
+
totals: { projects: 2, credits: 150, works_shipped: 7, karma: 4 },
|
|
96
|
+
disciplines: ['engineer', 'ui'],
|
|
97
|
+
last_active: '2026-08-01T00:00:00.000Z',
|
|
98
|
+
projects: named,
|
|
99
|
+
more_projects: CROSS_PROJECT_ROWS.length - named.length,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
// The key contract for the cross_project object + its project rows.
|
|
103
|
+
const CP_PUBLIC_KEYS = ['totals', 'disciplines', 'last_active', 'projects', 'more_projects'];
|
|
104
|
+
const CP_PROJECT_ROW_KEYS = ['name', 'origin', 'rank_held'];
|
|
105
|
+
|
|
76
106
|
// ---- stubs ----------------------------------------------------------------
|
|
77
107
|
|
|
78
108
|
// auth: the profile route is public, but the router factory references
|
|
@@ -139,7 +169,7 @@ function get(port, path) {
|
|
|
139
169
|
res.on('end', () => {
|
|
140
170
|
const text = Buffer.concat(chunks).toString('utf8');
|
|
141
171
|
let json = null; try { json = JSON.parse(text); } catch (_) {}
|
|
142
|
-
resolve({ status: res.statusCode, json, text });
|
|
172
|
+
resolve({ status: res.statusCode, json, text, headers: res.headers });
|
|
143
173
|
});
|
|
144
174
|
});
|
|
145
175
|
req.on('error', reject);
|
|
@@ -192,6 +222,61 @@ await t('non-numeric id → 400 bad_builder_id', async () => {
|
|
|
192
222
|
assert.equal(r.json && r.json.error && r.json.error.code, 'bad_builder_id');
|
|
193
223
|
});
|
|
194
224
|
|
|
225
|
+
// ---- cross_project (ADR 0171, task 1002278) -------------------------------
|
|
226
|
+
// The hub port resolves at REQUEST time, so the port-absent tests above ran
|
|
227
|
+
// against the same server; registering the stub here flips the route into its
|
|
228
|
+
// hub posture for the tests below.
|
|
229
|
+
|
|
230
|
+
await t('hub port ABSENT → no cross_project key + the cacheable posture stays', async () => {
|
|
231
|
+
const r = await get(port, '/api/gds/builders/95/profile');
|
|
232
|
+
assert.equal(r.status, 200);
|
|
233
|
+
assert.ok(!('cross_project' in r.json), 'cross_project must be absent on a non-hub instance');
|
|
234
|
+
assert.equal(r.headers['cache-control'], 'public, max-age=60');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
let hubExtras = stubPublicProfileExtras();
|
|
238
|
+
seams.registerProvider('platform-identity.hub', {
|
|
239
|
+
getPublicProfileExtras: async () => hubExtras,
|
|
240
|
+
getOwnProfileExtras: async () => stubPublicProfileExtras(),
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
await t('hub port present → cross_project carries exactly the whitelist shape', async () => {
|
|
244
|
+
const r = await get(port, '/api/gds/builders/95/profile');
|
|
245
|
+
assert.equal(r.status, 200);
|
|
246
|
+
const cp = r.json.cross_project;
|
|
247
|
+
assert.ok(cp, 'cross_project attached when the port resolves');
|
|
248
|
+
assert.deepEqual(Object.keys(cp).sort(), [...CP_PUBLIC_KEYS].sort(), 'cross_project keys are exactly the contract');
|
|
249
|
+
assert.deepEqual(Object.keys(cp.totals).sort(), ['credits', 'karma', 'projects', 'works_shipped']);
|
|
250
|
+
for (const row of cp.projects) {
|
|
251
|
+
assert.deepEqual(Object.keys(row).sort(), [...CP_PROJECT_ROW_KEYS].sort(), 'project rows carry only name/origin/rank_held');
|
|
252
|
+
}
|
|
253
|
+
assert.equal(cp.projects.length, 1, 'only the public catalog row is named');
|
|
254
|
+
assert.equal(cp.more_projects, 1, 'the non-public project is counted, never named');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
await t('hub port present → module-internal sentinels leak NOWHERE in the response', async () => {
|
|
258
|
+
const r = await get(port, '/api/gds/builders/95/profile');
|
|
259
|
+
assert.equal(r.status, 200);
|
|
260
|
+
for (const secret of [SECRET_CLIENT_ID, SECRET_PROJECT_NAME, SECRET_EMAIL, SECRET_USER, SECRET_REASON]) {
|
|
261
|
+
assert.ok(!r.text.includes(secret), `response body must not contain "${secret}"`);
|
|
262
|
+
}
|
|
263
|
+
assert.ok(!r.text.includes('client_id'), 'no client_id key may cross the port');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
await t('hub port present → hide must beat the cache: Cache-Control is no-store', async () => {
|
|
267
|
+
const r = await get(port, '/api/gds/builders/95/profile');
|
|
268
|
+
assert.equal(r.headers['cache-control'], 'no-store');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
await t('hub port returns null (hidden/provisional/none) → cross_project: null, still no-store', async () => {
|
|
272
|
+
hubExtras = null;
|
|
273
|
+
const r = await get(port, '/api/gds/builders/95/profile');
|
|
274
|
+
assert.equal(r.status, 200);
|
|
275
|
+
assert.ok('cross_project' in r.json, 'the key is present on the hub');
|
|
276
|
+
assert.equal(r.json.cross_project, null, 'null — no partial numbers, indistinguishable from no-account');
|
|
277
|
+
assert.equal(r.headers['cache-control'], 'no-store');
|
|
278
|
+
});
|
|
279
|
+
|
|
195
280
|
server.close();
|
|
196
281
|
|
|
197
282
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
@@ -53,11 +53,15 @@ try {
|
|
|
53
53
|
// The module's actual migrations (idempotent CREATE TABLE IF NOT EXISTS).
|
|
54
54
|
await q(migrationSql('platform_identity_001_tables.sql'));
|
|
55
55
|
await q(migrationSql('platform_identity_005_builder_activity.sql'));
|
|
56
|
+
await q(migrationSql('platform_identity_007_profile_consent.sql'));
|
|
56
57
|
await cleanup();
|
|
57
58
|
|
|
59
|
+
// profile_state='active' — since ADR 0171 the rollup only lists consented,
|
|
60
|
+
// visible builders; this test is about AGGREGATION, so fixtures opt in.
|
|
61
|
+
// (The predicate itself is proven by tests/profile_rollup_consent.mjs.)
|
|
58
62
|
await q(
|
|
59
|
-
`INSERT INTO platform_identity_accounts (github_id, github_login, display_name, avatar_url)
|
|
60
|
-
VALUES ($1, 'multi', 'Multi', 'a'), ($2, 'solo', 'Solo', 'b'), ($3, 'ghost', 'Ghost', 'c')`,
|
|
63
|
+
`INSERT INTO platform_identity_accounts (github_id, github_login, display_name, avatar_url, profile_state)
|
|
64
|
+
VALUES ($1, 'multi', 'Multi', 'a', 'active'), ($2, 'solo', 'Solo', 'b', 'active'), ($3, 'ghost', 'Ghost', 'c', 'active')`,
|
|
61
65
|
[MULTI, SOLO, GHOST],
|
|
62
66
|
);
|
|
63
67
|
// MULTI: two projects, 2 and 3 disciplines — the exact fan-out shape the old
|