@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/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.181'; // CI auto-patch carrier (ADR 0161); see changelog above
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('the registry reproduces the pre-R101 PROTECTED_GLOBS exactly, in order', () => {
89
- assert.deepEqual(registry.PROTECTED_GLOBS, EXPECTED_GLOBS_BEFORE_R101);
90
- assert.deepEqual(ppc.PROTECTED_GLOBS, EXPECTED_GLOBS_BEFORE_R101,
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, `${EXPECTED_GLOBS_BEFORE_R101.length}:metic`);
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
+ }