@bongos/core 1.19.181 → 1.19.183
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 +39 -29
- 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 +5 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +81 -4
- package/docs/api-reference.md +3 -2
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/governance/protected-surfaces.json +2 -1
- package/modules/hall-ui/public/profile.js +1 -1
- package/modules/platform-identity/migrations/platform_identity_009_profile_fields.sql +38 -0
- package/modules/platform-identity/platform-identity.js +201 -3
- package/modules/platform-identity/routes/profile.js +63 -1
- package/modules/platform-identity/routes/public-profile.js +41 -2
- package/modules/platform-identity/tests/platform-identity.mjs +90 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/publish-manifest.js +2 -1
- package/src/bongos/module-scope-map.js +22 -1
- package/src/bongos/route-rank-check.js +37 -18
- package/src/module-api.js +5 -1
- package/tests/governance_protected_surfaces.mjs +60 -4
- package/tests/hub_profile_leak.mjs +11 -6
- package/tests/profile_fields.mjs +133 -0
package/src/module-api.js
CHANGED
|
@@ -789,8 +789,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
789
789
|
// landed since 1.19.179 with no explicit bump. run 31972568511. (task 1002620)
|
|
790
790
|
// 1.19.181 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
791
791
|
// landed since 1.19.180 with no explicit bump. run 31972686846. (task 1002620)
|
|
792
|
+
// 1.19.182 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
793
|
+
// landed since 1.19.181 with no explicit bump. run 31972846686. (task 1002620)
|
|
794
|
+
// 1.19.183 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
795
|
+
// landed since 1.19.182 with no explicit bump. run 31973728179. (task 1002620)
|
|
792
796
|
// ---------------------------------------------------------------------------
|
|
793
|
-
const CORE_VERSION = '1.19.
|
|
797
|
+
const CORE_VERSION = '1.19.183'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
794
798
|
|
|
795
799
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
796
800
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -83,14 +83,70 @@ const EXPECTED_GLOBS_BEFORE_R101 = [
|
|
|
83
83
|
'.claude/settings.json',
|
|
84
84
|
];
|
|
85
85
|
|
|
86
|
+
// Globs added to the registry SINCE R101, each with the change that added it.
|
|
87
|
+
// Kept separate from the verbatim pre-R101 array above so that array stays what
|
|
88
|
+
// it claims to be — the literal pre-change constant. Overwriting it to make a
|
|
89
|
+
// later addition pass would destroy the only evidence that R101 moved WHERE the
|
|
90
|
+
// truth lives without changing WHAT is protected.
|
|
91
|
+
//
|
|
92
|
+
// Each entry is { after, glob, why }: `after` is the glob it is inserted
|
|
93
|
+
// directly below, so the expected list is reconstructed in order rather than
|
|
94
|
+
// re-pasted (a re-paste is how the two lists drift apart).
|
|
95
|
+
const ADDED_SINCE_R101 = [
|
|
96
|
+
{
|
|
97
|
+
after: 'modules/governance/',
|
|
98
|
+
glob: 'modules/government/',
|
|
99
|
+
why: 'goal 1000068 / task 1003003 (ADR 0174) — the authz core is renamed '
|
|
100
|
+
+ 'governance→government. The new glob is registered BEFORE the folder '
|
|
101
|
+
+ 'moves because `protected` DERIVES from this registry: without it the '
|
|
102
|
+
+ 'new module key computes protected:false and both the Archon gate on '
|
|
103
|
+
+ 'goal creation and the pre-push block on these files would silently lapse.',
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
// The list the registry must produce TODAY: the pre-R101 set with each
|
|
108
|
+
// documented addition spliced in at its declared position.
|
|
109
|
+
const EXPECTED_GLOBS = (() => {
|
|
110
|
+
const out = [...EXPECTED_GLOBS_BEFORE_R101];
|
|
111
|
+
for (const a of ADDED_SINCE_R101) {
|
|
112
|
+
const at = out.indexOf(a.after);
|
|
113
|
+
assert.notEqual(at, -1, `ADDED_SINCE_R101 anchors on '${a.after}', which is not in the list`);
|
|
114
|
+
out.splice(at + 1, 0, a.glob);
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
})();
|
|
118
|
+
|
|
86
119
|
// ---------- behavior identity: the same globs, at the same floor ----------
|
|
87
120
|
|
|
88
|
-
test('
|
|
89
|
-
|
|
90
|
-
|
|
121
|
+
test('nothing protected before R101 has been unprotected since', () => {
|
|
122
|
+
// The load-bearing half. Additions are ordinary policy; a REMOVAL silently
|
|
123
|
+
// unprotects a trust surface, so it is asserted separately and must never be
|
|
124
|
+
// satisfied by editing the pre-R101 array.
|
|
125
|
+
for (const glob of EXPECTED_GLOBS_BEFORE_R101) {
|
|
126
|
+
assert.ok(
|
|
127
|
+
registry.PROTECTED_GLOBS.includes(glob),
|
|
128
|
+
`'${glob}' was protected before R101 and is no longer in the registry — `
|
|
129
|
+
+ 'a protected surface cannot be dropped without an ADR saying why.',
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('the registry reproduces the expected glob list exactly, in order', () => {
|
|
135
|
+
assert.deepEqual(registry.PROTECTED_GLOBS, EXPECTED_GLOBS);
|
|
136
|
+
assert.deepEqual(ppc.PROTECTED_GLOBS, EXPECTED_GLOBS,
|
|
91
137
|
'the kernel matcher must expose the same derived list the registry declares');
|
|
92
138
|
});
|
|
93
139
|
|
|
140
|
+
test('every glob added since R101 carries a documented reason', () => {
|
|
141
|
+
for (const a of ADDED_SINCE_R101) {
|
|
142
|
+
assert.ok(a.why && a.why.length > 40,
|
|
143
|
+
`${a.glob} must record WHY it became protected — an undocumented addition `
|
|
144
|
+
+ 'is indistinguishable from a typo.');
|
|
145
|
+
assert.ok(registry.PROTECTED_GLOBS.includes(a.glob),
|
|
146
|
+
`${a.glob} is declared as added but is not in the registry`);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
94
150
|
test('every surface floors at metic — the ADR 0043 floor, unchanged by the move', () => {
|
|
95
151
|
for (const s of registry.SURFACES) {
|
|
96
152
|
assert.equal(s.floor, 'metic', `${s.glob} must keep the Metic floor`);
|
|
@@ -128,7 +184,7 @@ test('the registry loads synchronously from the repo with no DB and no server',
|
|
|
128
184
|
"const p=require('./src/bongos/permission-path-check');" +
|
|
129
185
|
"process.stdout.write(String(p.PROTECTED_GLOBS.length)+':'+String(p.floorFor('migrations/001.sql')));",
|
|
130
186
|
], { cwd: ROOT, encoding: 'utf8', env: { ...process.env, DATABASE_URL: '', PGHOST: '' } });
|
|
131
|
-
assert.equal(out, `${
|
|
187
|
+
assert.equal(out, `${EXPECTED_GLOBS.length}:metic`);
|
|
132
188
|
});
|
|
133
189
|
|
|
134
190
|
test('the registry data file carries no DB/network dependency', () => {
|
|
@@ -69,7 +69,7 @@ const SECRET_PROJECT = 'P05-PRIVATE-DO-NOT-LEAK';
|
|
|
69
69
|
const SECRET_PREV = 'p05-prev-sentinel';
|
|
70
70
|
const PUB_ORIGIN = 'https://p05-pub.example', PRIV_ORIGIN = 'https://p05-priv.example';
|
|
71
71
|
const CP_KEYS = ['totals', 'disciplines', 'last_active', 'projects', 'more_projects'];
|
|
72
|
-
const ACCOUNT_KEYS = ['handle', 'github_login', 'display_name', 'avatar_url', 'member_since'];
|
|
72
|
+
const ACCOUNT_KEYS = ['handle', 'github_login', 'display_name', 'avatar_url', 'member_since', 'bio', 'links'];
|
|
73
73
|
|
|
74
74
|
const q = (sql, params) => pool.query(sql, params);
|
|
75
75
|
async function cleanup() {
|
|
@@ -103,7 +103,7 @@ function get(port, path) {
|
|
|
103
103
|
|
|
104
104
|
const NEVER_IN_A_BODY = [
|
|
105
105
|
SECRET_CLIENT_PUB, SECRET_CLIENT_PRIV, SECRET_HASH, SECRET_PROJECT, SECRET_PREV,
|
|
106
|
-
'profile_state', 'hide_stats', 'github_id',
|
|
106
|
+
'profile_state', 'hide_stats', 'github_id', 'display_name_source',
|
|
107
107
|
];
|
|
108
108
|
function assertNoSentinels(text, where) {
|
|
109
109
|
for (const s of NEVER_IN_A_BODY) {
|
|
@@ -118,14 +118,17 @@ try {
|
|
|
118
118
|
await q(migrationSql('platform_identity_005_builder_activity.sql'));
|
|
119
119
|
await q(migrationSql('platform_identity_007_profile_consent.sql'));
|
|
120
120
|
await q(migrationSql('platform_identity_008_handle.sql'));
|
|
121
|
+
await q(migrationSql('platform_identity_009_profile_fields.sql'));
|
|
121
122
|
await cleanup();
|
|
122
123
|
|
|
124
|
+
// The active account carries the P02 fields + a user-set name source, so the
|
|
125
|
+
// 200 proves bio/links serialize while display_name_source never does.
|
|
123
126
|
await q(
|
|
124
127
|
`INSERT INTO platform_identity_accounts
|
|
125
|
-
(github_id, github_login, display_name, avatar_url, profile_state, hide_stats, handle, previous_handle)
|
|
126
|
-
VALUES ($1, 'p05-active-login', 'P05 Active', 'https://a.example/a.png', 'active', false, 'p05-active', $4),
|
|
127
|
-
($2, 'p05-hidden-login', 'P05 Hidden', NULL, 'active', true, 'p05-hidden', NULL),
|
|
128
|
-
($3, 'p05-prov-login', 'P05 Prov', NULL, 'provisional', false, 'p05-prov', NULL)`,
|
|
128
|
+
(github_id, github_login, display_name, avatar_url, profile_state, hide_stats, handle, previous_handle, bio, links, display_name_source)
|
|
129
|
+
VALUES ($1, 'p05-active-login', 'P05 Active', 'https://a.example/a.png', 'active', false, 'p05-active', $4, 'Builds things.', '{"website":"https://p05.example/"}', 'user'),
|
|
130
|
+
($2, 'p05-hidden-login', 'P05 Hidden', NULL, 'active', true, 'p05-hidden', NULL, NULL, NULL, 'github'),
|
|
131
|
+
($3, 'p05-prov-login', 'P05 Prov', NULL, 'provisional', false, 'p05-prov', NULL, NULL, NULL, 'github')`,
|
|
129
132
|
[ACTIVE, HIDDEN, PROV, SECRET_PREV],
|
|
130
133
|
);
|
|
131
134
|
await q(
|
|
@@ -165,6 +168,8 @@ try {
|
|
|
165
168
|
assert.equal(r.json.cross_project.projects[0].name, 'P05 Pub Proj');
|
|
166
169
|
assert.deepEqual(Object.keys(r.json.cross_project.projects[0]).sort(), ['name', 'origin', 'rank_held'].sort());
|
|
167
170
|
assert.equal(r.json.cross_project.more_projects, 1, 'the non-public project is counted, never named');
|
|
171
|
+
assert.equal(r.json.account.bio, 'Builds things.', 'bio serializes (P02)');
|
|
172
|
+
assert.deepEqual(r.json.account.links, { website: 'https://p05.example/' }, 'links serialize (P02)');
|
|
168
173
|
assertNoSentinels(r.text, 'the 200 body');
|
|
169
174
|
});
|
|
170
175
|
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// tests/profile_fields.mjs — P02, task 1002798 (goal 1000045; real DB).
|
|
2
|
+
//
|
|
3
|
+
// Proves migration 009 + the profile-fields machinery against real SQL (the
|
|
4
|
+
// handle_machinery.mjs precedent):
|
|
5
|
+
// 1. setAccountProfileFields on an UNSEEN builder creates a fail-closed
|
|
6
|
+
// provisional account, seeds the handle, stores bio + links
|
|
7
|
+
// 2. the ADR 0156 mirror: setting display_name stamps source='user', and a
|
|
8
|
+
// later upsertAccount re-sync (the check-in shape) keeps its hands off the
|
|
9
|
+
// name while github_login/avatar_url still re-sync; a never-renamed
|
|
10
|
+
// account still follows GitHub renames
|
|
11
|
+
// 3. the UPDATE branch touches ONLY the fields sent — bio alone leaves
|
|
12
|
+
// links/display_name alone, and never demotes profile_state or flips
|
|
13
|
+
// hide_stats
|
|
14
|
+
// 4. links REPLACE wholesale; null clears; a rejected link writes nothing
|
|
15
|
+
// 5. the public read (getPublicProfileByHandle) carries bio + links
|
|
16
|
+
//
|
|
17
|
+
// Real-DB integration test: self-skips without a reachable Postgres; in the
|
|
18
|
+
// INTEGRATION set so the default DB-free lane never runs it.
|
|
19
|
+
//
|
|
20
|
+
// Run: DATABASE_URL=... node tests/profile_fields.mjs
|
|
21
|
+
|
|
22
|
+
import { createRequire } from 'node:module';
|
|
23
|
+
import { readFileSync } from 'node:fs';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import { dirname, join } from 'node:path';
|
|
26
|
+
import { strict as assert } from 'node:assert';
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
const { Pool } = require('pg');
|
|
29
|
+
const pi = require('../modules/platform-identity/platform-identity.js');
|
|
30
|
+
|
|
31
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const MIG_DIR = join(here, '..', 'modules', 'platform-identity', 'migrations');
|
|
33
|
+
const migrationSql = (f) => readFileSync(join(MIG_DIR, f), 'utf8');
|
|
34
|
+
|
|
35
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
36
|
+
try {
|
|
37
|
+
await pool.query('SELECT 1');
|
|
38
|
+
} catch (e) {
|
|
39
|
+
console.log(`profile_fields.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
40
|
+
await pool.end().catch(() => {});
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// High-numbered fixture ids so a shared DB can never collide with real data.
|
|
45
|
+
const FRESH = 9002798001, SYNCED = 9002798002;
|
|
46
|
+
const ALL_IDS = [FRESH, SYNCED];
|
|
47
|
+
|
|
48
|
+
const q = (sql, params) => pool.query(sql, params);
|
|
49
|
+
async function cleanup() {
|
|
50
|
+
await q(`DELETE FROM platform_identity_accounts WHERE github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let pass = 0;
|
|
54
|
+
const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
|
|
55
|
+
const acctOf = (id) => pi.getAccount(id, { pool });
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
await q(migrationSql('platform_identity_001_tables.sql'));
|
|
59
|
+
await q(migrationSql('platform_identity_007_profile_consent.sql'));
|
|
60
|
+
await q(migrationSql('platform_identity_008_handle.sql'));
|
|
61
|
+
await q(migrationSql('platform_identity_009_profile_fields.sql'));
|
|
62
|
+
// Idempotence is part of what this proves.
|
|
63
|
+
await q(migrationSql('platform_identity_009_profile_fields.sql'));
|
|
64
|
+
await cleanup();
|
|
65
|
+
|
|
66
|
+
// --- 1. INSERT branch: unseen builder fills their profile ahead of time ---
|
|
67
|
+
const first = await pi.setAccountProfileFields({
|
|
68
|
+
githubId: FRESH, githubLogin: 'P02Fresh', displayName: 'GH Fresh', avatarUrl: 'https://a.example/f.png',
|
|
69
|
+
fields: { bio: ' Builds bongos in the cloud. ', links: { website: 'https://fresh.example', x: 'https://x.com/fresh' } },
|
|
70
|
+
}, { pool });
|
|
71
|
+
ok(first.bio === 'Builds bongos in the cloud.', 'INSERT branch stores the trimmed bio');
|
|
72
|
+
ok(first.links.website === 'https://fresh.example/' && first.links.x === 'https://x.com/fresh', 'INSERT branch stores normalized links');
|
|
73
|
+
ok(first.handle === 'p02fresh', 'the create path seeds the handle, like every other account-creating upsert');
|
|
74
|
+
let a = await acctOf(FRESH);
|
|
75
|
+
ok(a.profile_state === 'provisional', 'a profile-fields write never grants consent — fail-closed provisional');
|
|
76
|
+
ok(a.display_name === 'GH Fresh' && a.display_name_source === 'github', 'no display_name sent → GitHub-derived name, source stays github');
|
|
77
|
+
|
|
78
|
+
// --- 2. the ADR 0156 mirror ----------------------------------------------
|
|
79
|
+
const named = await pi.setAccountProfileFields({ githubId: FRESH, githubLogin: 'P02Fresh', fields: { display_name: 'Fresh, Renamed' } }, { pool });
|
|
80
|
+
ok(named.display_name === 'Fresh, Renamed', 'setting display_name writes it');
|
|
81
|
+
a = await acctOf(FRESH);
|
|
82
|
+
ok(a.display_name_source === 'user', 'and stamps display_name_source=user');
|
|
83
|
+
|
|
84
|
+
await pi.upsertAccount({ githubId: FRESH, githubLogin: 'p02fresh-renamed', displayName: 'GH Clobber', avatarUrl: 'https://a.example/new.png' }, { pool });
|
|
85
|
+
a = await acctOf(FRESH);
|
|
86
|
+
ok(a.display_name === 'Fresh, Renamed', 'a check-in re-sync does NOT clobber a user-set name');
|
|
87
|
+
ok(a.github_login === 'p02fresh-renamed' && a.avatar_url === 'https://a.example/new.png', 'github_login + avatar_url still re-sync unconditionally');
|
|
88
|
+
|
|
89
|
+
await pi.upsertAccount({ githubId: SYNCED, githubLogin: 'p02synced', displayName: 'GH Old' }, { pool });
|
|
90
|
+
await pi.upsertAccount({ githubId: SYNCED, githubLogin: 'p02synced', displayName: 'GH New' }, { pool });
|
|
91
|
+
a = await acctOf(SYNCED);
|
|
92
|
+
ok(a.display_name === 'GH New', 'a never-renamed account still follows GitHub renames');
|
|
93
|
+
|
|
94
|
+
// --- 3. the UPDATE branch touches only what was sent ----------------------
|
|
95
|
+
await q(`UPDATE platform_identity_accounts SET profile_state = 'active', hide_stats = true WHERE github_id = $1`, [FRESH]);
|
|
96
|
+
await pi.setAccountProfileFields({ githubId: FRESH, githubLogin: 'p02fresh-renamed', fields: { bio: 'Just the bio.' } }, { pool });
|
|
97
|
+
a = await acctOf(FRESH);
|
|
98
|
+
ok(a.bio === 'Just the bio.', 'bio alone updates');
|
|
99
|
+
ok(a.links.website === 'https://fresh.example/', 'links untouched by a bio-only write');
|
|
100
|
+
ok(a.display_name === 'Fresh, Renamed', 'display_name untouched by a bio-only write');
|
|
101
|
+
ok(a.profile_state === 'active' && a.hide_stats === true, 'profile_state + hide_stats never move on a profile-fields write');
|
|
102
|
+
|
|
103
|
+
// --- 4. links replace wholesale; null clears; rejects write nothing -------
|
|
104
|
+
await pi.setAccountProfileFields({ githubId: FRESH, githubLogin: 'p02fresh-renamed', fields: { links: { discord: 'https://discord.gg/bongos' } } }, { pool });
|
|
105
|
+
a = await acctOf(FRESH);
|
|
106
|
+
ok(a.links.discord === 'https://discord.gg/bongos' && !a.links.website && !a.links.x, 'a links write replaces the whole set');
|
|
107
|
+
|
|
108
|
+
const bad = await pi.setAccountProfileFields({ githubId: FRESH, githubLogin: 'p02fresh-renamed', fields: { bio: 'never lands', links: { linkedin: 'https://evil.example/in' } } }, { pool });
|
|
109
|
+
ok(bad.error === 'invalid_link' && bad.reason === 'wrong_host', 'a host-pinned type refuses a foreign host');
|
|
110
|
+
a = await acctOf(FRESH);
|
|
111
|
+
ok(a.bio === 'Just the bio.' && a.links.discord === 'https://discord.gg/bongos', 'a rejected write writes NOTHING — not even the valid fields beside it');
|
|
112
|
+
|
|
113
|
+
const none = await pi.setAccountProfileFields({ githubId: FRESH, githubLogin: 'p02fresh-renamed', fields: {} }, { pool });
|
|
114
|
+
ok(none.error === 'no_fields', 'an empty fields object is refused, not a silent no-op');
|
|
115
|
+
|
|
116
|
+
await pi.setAccountProfileFields({ githubId: FRESH, githubLogin: 'p02fresh-renamed', fields: { bio: null, links: null } }, { pool });
|
|
117
|
+
a = await acctOf(FRESH);
|
|
118
|
+
ok(a.bio === null && a.links === null, 'null clears bio and links');
|
|
119
|
+
|
|
120
|
+
// --- 5. the public read carries the fields --------------------------------
|
|
121
|
+
await pi.setAccountProfileFields({ githubId: FRESH, githubLogin: 'p02fresh-renamed', fields: { bio: 'Public bio.', links: { website: 'https://fresh.example' } } }, { pool });
|
|
122
|
+
const page = await pi.getPublicProfileByHandle('p02fresh', { pool });
|
|
123
|
+
ok(page.account.bio === 'Public bio.' && page.account.links.website === 'https://fresh.example/', 'getPublicProfileByHandle serves bio + links');
|
|
124
|
+
ok(!('display_name_source' in page.account), 'display_name_source never crosses the public whitelist');
|
|
125
|
+
|
|
126
|
+
console.log(`\nprofile_fields: ${pass} checks passed`);
|
|
127
|
+
} catch (e) {
|
|
128
|
+
console.error(`\nFAIL after ${pass} checks: ${e.message}`);
|
|
129
|
+
process.exitCode = 1;
|
|
130
|
+
} finally {
|
|
131
|
+
await cleanup();
|
|
132
|
+
await pool.end().catch(() => {});
|
|
133
|
+
}
|