@bongos/core 1.19.1025 → 1.19.1027

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/.bongos-core.json +88 -33
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +40 -0
  4. package/clients/bongos-client/index.cjs +40 -0
  5. package/clients/bongos-client/index.d.ts +63 -0
  6. package/clients/bongos-client/index.mjs +40 -0
  7. package/docs/adr/0213-platform-terms-acceptance-is-the-hubs-own-witness.md +4 -2
  8. package/docs/api/openapi.json +1361 -151
  9. package/docs/api-reference.md +32 -4
  10. package/docs/copy-inventory.md +6 -6
  11. package/docs/copy-registry.json +10 -20
  12. package/docs/module-api-changelog.md +4 -0
  13. package/modules/hall-ui/public/goal-map.html +1 -1
  14. package/modules/platform-identity/community-leaderboard.js +235 -0
  15. package/modules/platform-identity/connections.js +1 -0
  16. package/modules/platform-identity/guilds.js +705 -0
  17. package/modules/platform-identity/migrations/platform_identity_025_guilds.sql +100 -0
  18. package/modules/platform-identity/module.json +1 -1
  19. package/modules/platform-identity/read-rate-limit.js +62 -0
  20. package/modules/platform-identity/routes/community-leaderboard.js +69 -0
  21. package/modules/platform-identity/routes/community-search.js +4 -31
  22. package/modules/platform-identity/routes/guilds-public.js +57 -0
  23. package/modules/platform-identity/routes/guilds.js +393 -0
  24. package/modules/platform-identity/routes/projects.js +1 -1
  25. package/modules/platform-identity/terms-acceptance.js +14 -5
  26. package/modules/public-landing/public/terms.html +3 -3
  27. package/package-lock.json +2 -2
  28. package/package.json +1 -1
  29. package/scripts/gds/run-unit-tests.js +10 -0
  30. package/src/module-api.js +1 -1
  31. package/tests/community_leaderboard_db.mjs +202 -0
  32. package/tests/community_leaderboard_route.mjs +414 -0
  33. package/tests/goal_map_page.mjs +8 -0
  34. package/tests/guilds_api.mjs +521 -0
  35. package/tests/guilds_db.mjs +343 -0
  36. package/tests/own_scope_guard.mjs +5 -0
  37. package/tests/terms_acceptance.mjs +74 -28
  38. package/tests/terms_grandfather_db.mjs +2 -2
@@ -0,0 +1,343 @@
1
+ // tests/guilds_db.mjs — guild CRUD, membership and visibility against REAL SQL
2
+ // (task 1002300, R24; ADR 0336 D1–D4 + D6, ADR 0337 D4.4).
3
+ //
4
+ // Why a real database: the rules that matter here live in the schema and in
5
+ // single statements a fake pool cannot execute — the one-owner PARTIAL unique
6
+ // index, the open-request partial index, the expiry CTE that must be drained
7
+ // before its INSERT, the FK cascade on dissolve, the two-statement transfer, and
8
+ // an accept that resolves a row and inserts a membership in one statement. The
9
+ // DB-free sibling, tests/guilds_api.mjs, covers the route layer.
10
+ //
11
+ // Real-DB integration test: self-skips without a reachable Postgres; in the
12
+ // INTEGRATION set so the default DB-free lane never runs it. Fixture ids are
13
+ // high-numbered and every guild slug starts r24-, so a shared DB is safe; all
14
+ // fixtures are deleted before and after.
15
+ //
16
+ // Run: DATABASE_URL=... node tests/guilds_db.mjs
17
+
18
+ import { createRequire } from 'node:module';
19
+ import { readFileSync } from 'node:fs';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { dirname, join } from 'node:path';
22
+ import { strict as assert } from 'node:assert';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const { Pool } = require('pg');
26
+
27
+ const here = dirname(fileURLToPath(import.meta.url));
28
+ const MIG_DIR = join(here, '..', 'modules', 'platform-identity', 'migrations');
29
+ const migrationSql = (f) => readFileSync(join(MIG_DIR, f), 'utf8');
30
+
31
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
32
+ try {
33
+ await pool.query('SELECT 1');
34
+ } catch (e) {
35
+ console.log(`guilds_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
36
+ await pool.end().catch(() => {});
37
+ process.exit(0);
38
+ }
39
+
40
+ const guilds = require('../modules/platform-identity/guilds.js');
41
+
42
+ const OWNER = 9002300001; // public, active — creates the guilds
43
+ const ALICE = 9002300002; // public, active
44
+ const PRIV = 9002300003; // private, active, NOT connected to OWNER
45
+ const PRIVC = 9002300004; // private, active, connected to OWNER
46
+ const PROV = 9002300005; // provisional — can take part in nothing
47
+ const BOB = 9002300006; // public, active
48
+ const CARL = 9002300007; // public, active
49
+ const PRIV2 = 9002300008; // private, active, connected to OWNER only
50
+ const ALL_IDS = [OWNER, ALICE, PRIV, PRIVC, PROV, BOB, CARL, PRIV2];
51
+
52
+ const q = (sql, params) => pool.query(sql, params);
53
+ async function cleanup() {
54
+ await q(`DELETE FROM platform_identity_guilds WHERE slug LIKE 'r24-%'`).catch(() => {});
55
+ await q(`DELETE FROM platform_identity_connections
56
+ WHERE requester_github_id = ANY($1::bigint[]) OR addressee_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
+ }
59
+
60
+ let pass = 0;
61
+ const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
62
+ async function rejects(promise, code, msg) {
63
+ let caught = null;
64
+ try { await promise; } catch (e) { caught = e; }
65
+ ok(caught && caught.code === code, `${msg} (expected ${code}, got ${caught ? caught.code : 'no error'})`);
66
+ }
67
+ const memberRow = async (slug, gid) => (await q(
68
+ `SELECT m.* FROM platform_identity_guild_members m JOIN platform_identity_guilds g ON g.id = m.guild_id
69
+ WHERE g.slug = $1 AND m.github_id = $2`, [slug, gid])).rows[0] || null;
70
+ const guildId = async (slug) => (await q(`SELECT id FROM platform_identity_guilds WHERE slug = $1`, [slug])).rows[0]?.id;
71
+ const openRows = async (slug, gid) => (await q(
72
+ `SELECT r.* FROM platform_identity_guild_requests r JOIN platform_identity_guilds g ON g.id = r.guild_id
73
+ WHERE g.slug = $1 AND r.github_id = $2 ORDER BY r.id`, [slug, gid])).rows;
74
+ const pendingId = async (slug, gid) => (await openRows(slug, gid)).find((r) => r.status === 'pending')?.id;
75
+
76
+ try {
77
+ // The migrations this domain reads, all idempotent: accounts + consent,
78
+ // handles, privacy, terms, connections (addressing), and guilds.
79
+ for (const f of [
80
+ 'platform_identity_001_tables.sql', 'platform_identity_007_profile_consent.sql',
81
+ 'platform_identity_008_handle.sql', 'platform_identity_012_account_privacy.sql',
82
+ 'platform_identity_015_connections.sql', 'platform_identity_019_terms_acceptance.sql',
83
+ 'platform_identity_025_guilds.sql',
84
+ ]) await q(migrationSql(f));
85
+ await q(migrationSql('platform_identity_025_guilds.sql')); // re-runnable
86
+ await cleanup();
87
+
88
+ await q(
89
+ `INSERT INTO platform_identity_accounts
90
+ (github_id, github_login, handle, display_name, profile_state, terms_accepted_at, account_visibility)
91
+ VALUES ($1, 'r24own', 'r24own', 'Owner', 'active', now(), 'public'),
92
+ ($2, 'r24alice', 'r24alice', 'Alice', 'active', now(), 'public'),
93
+ ($3, 'r24priv', 'r24priv', 'Priv', 'active', now(), 'private'),
94
+ ($4, 'r24privc', 'r24privc', 'PrivC', 'active', now(), 'private'),
95
+ ($5, 'r24prov', 'r24prov', 'Prov', 'provisional', NULL, 'public'),
96
+ ($6, 'r24bob', 'r24bob', 'Bob', 'active', now(), 'public'),
97
+ ($7, 'r24carl', 'r24carl', 'Carl', 'active', now(), 'public'),
98
+ ($8, 'r24priv2', 'r24priv2', 'Priv2', 'active', now(), 'private')`,
99
+ ALL_IDS,
100
+ );
101
+ await q(`INSERT INTO platform_identity_connections (requester_github_id, addressee_github_id, status, resolved_at)
102
+ VALUES ($1, $2, 'accepted', now()), ($1, $3, 'accepted', now())`, [OWNER, PRIVC, PRIV2]);
103
+
104
+ // --- 1. the schema holds its own rules (ADR 0336 D1) ---------------------
105
+ await rejects(q(`INSERT INTO platform_identity_guilds (slug, name) VALUES ('r24-nodefault', 'x')`),
106
+ '23502', 'visibility has NO default — the creator must choose');
107
+ await rejects(q(`INSERT INTO platform_identity_guilds (slug, name, visibility) VALUES ('r24-bad', 'x', 'private')`),
108
+ '23514', 'visibility is public or unlisted, nothing else');
109
+ await rejects(q(`INSERT INTO platform_identity_guilds (slug, name, visibility) VALUES ('R24-Upper', 'x', 'public')`),
110
+ '23514', 'the slug is stored in canonical lowercase form only');
111
+ const cols = (await q(
112
+ `SELECT column_name, column_default, is_nullable FROM information_schema.columns
113
+ WHERE table_name = 'platform_identity_guild_members'`)).rows;
114
+ const col = (n) => cols.find((c) => c.column_name === n);
115
+ ok(col('shown_publicly').is_nullable === 'NO' && col('shown_publicly').column_default === null,
116
+ 'shown_publicly is NOT NULL with no default — every join must say what its card showed');
117
+ ok(col('counted_in_totals').is_nullable === 'NO' && col('counted_in_totals').column_default === 'true',
118
+ 'counted_in_totals is NOT NULL DEFAULT true (ADR 0337 D4.4)');
119
+ ok(!col('role') && !!col('membership_kind'), 'membership_kind, never role (ADR 0174)');
120
+
121
+ // --- 2. create ------------------------------------------------------------
122
+ const pub = await guilds.createGuild({ githubId: OWNER, slug: 'r24-pub', name: 'Public Guild', visibility: 'public' }, { pool });
123
+ ok(pub.guild && pub.guild.slug === 'r24-pub' && pub.guild.id === undefined, 'createGuild returns the guild without its row id');
124
+ const ownerPub = await memberRow('r24-pub', OWNER);
125
+ ok(ownerPub.membership_kind === 'owner' && ownerPub.shown_publicly === true && ownerPub.counted_in_totals === true,
126
+ 'the creator is the one owner, shown on a public guild, counted by default');
127
+ const unl = await guilds.createGuild({ githubId: OWNER, slug: 'r24-unl', name: 'Quiet Guild', visibility: 'unlisted' }, { pool });
128
+ ok(unl.guild.visibility === 'unlisted' && (await memberRow('r24-unl', OWNER)).shown_publicly === false,
129
+ 'creating an unlisted guild leaves its owner unshown');
130
+ ok((await guilds.createGuild({ githubId: ALICE, slug: 'r24-pub', name: 'Dup', visibility: 'public' }, { pool })).taken === true,
131
+ 'a taken slug is refused');
132
+ await rejects(q(`INSERT INTO platform_identity_guild_members (guild_id, github_id, membership_kind, shown_publicly)
133
+ VALUES ($1, $2, 'owner', true)`, [await guildId('r24-pub'), ALICE]),
134
+ '23505', 'the database refuses a second owner');
135
+
136
+ // --- 3. the public read (D4) ------------------------------------------------
137
+ ok((await guilds.getPublicGuild('r24-unl', { pool })) === null, 'an unlisted guild has no public existence');
138
+ ok((await guilds.getPublicGuild('r24-nope', { pool })) === null, '…the same null as a guild that does not exist');
139
+
140
+ // --- 4. join requests: public only, two consents, silent decline -----------
141
+ ok((await guilds.requestToJoin({ slug: 'r24-unl', githubId: ALICE }, { pool })).outcome === 'not_found',
142
+ 'an unlisted guild takes no requests, and says so as not_found');
143
+ ok((await guilds.requestToJoin({ slug: 'r24-pub', githubId: ALICE, countedInTotals: false }, { pool })).outcome === 'created',
144
+ 'a builder may ask a public guild');
145
+ ok(!(await memberRow('r24-pub', ALICE)), 'asking is not joining — no self-join');
146
+ ok((await guilds.requestToJoin({ slug: 'r24-pub', githubId: ALICE }, { pool })).outcome === 'already_open',
147
+ 'a second open request for the pair is refused by the open-row index');
148
+ ok((await guilds.requestToJoin({ slug: 'r24-pub', githubId: OWNER }, { pool })).outcome === 'already_member',
149
+ 'a member cannot ask to join');
150
+
151
+ const aliceReq = await pendingId('r24-pub', ALICE);
152
+ ok((await guilds.acceptRequest({ id: aliceReq, githubId: BOB }, { pool })).outcome === 'not_found',
153
+ 'only the owner may accept a request — anyone else is not_found');
154
+ ok((await guilds.acceptRequest({ id: aliceReq, githubId: OWNER, countedInTotals: true }, { pool })).outcome === 'accepted',
155
+ 'the owner accepts the request');
156
+ const alicePub = await memberRow('r24-pub', ALICE);
157
+ ok(alicePub && alicePub.membership_kind === 'member' && alicePub.shown_publicly === true,
158
+ 'a request to a public guild joins shown');
159
+ ok(alicePub.counted_in_totals === false,
160
+ 'the requester\'s own counted_in_totals choice rides the request — the owner cannot override it');
161
+
162
+ ok((await guilds.requestToJoin({ slug: 'r24-pub', githubId: BOB }, { pool })).outcome === 'created', 'Bob asks');
163
+ ok(await guilds.declineRequest({ id: await pendingId('r24-pub', BOB), githubId: OWNER }, { pool }), 'the owner declines');
164
+ const before = (await openRows('r24-pub', BOB)).length;
165
+ ok((await guilds.requestToJoin({ slug: 'r24-pub', githubId: BOB }, { pool })).outcome === 'swallowed',
166
+ 'a re-ask inside the cooldown is swallowed…');
167
+ ok((await openRows('r24-pub', BOB)).length === before, '…and writes nothing');
168
+
169
+ // --- 5. invites: addressing, batch, the card (D3/D4) ------------------------
170
+ const unlId = await guildId('r24-unl');
171
+ const res = await guilds.inviteHandles({
172
+ guildId: unlId, ownerGithubId: OWNER,
173
+ handles: ['r24alice', 'R24PRIV', 'r24privc', 'r24prov', 'r24own', 'r24-ghost', 'r24alice'],
174
+ }, { pool });
175
+ const outcome = (h) => res.find((r) => r.handle === h)?.outcome;
176
+ ok(outcome('r24alice') === 'invited', 'a public account is invitable into an unlisted guild');
177
+ ok(outcome('r24priv') === 'not_found', 'a private stranger is not_found');
178
+ ok(outcome('r24privc') === 'invited', 'a private account the owner is connected to is invitable (ADR 0202 D4 §2)');
179
+ ok(outcome('r24prov') === 'not_found', 'a provisional account cannot be invited');
180
+ ok(outcome('r24own') === 'already_member', 'the owner is already a member');
181
+ ok(outcome('r24-ghost') === 'not_found', 'an unknown handle is not_found — one answer with private and provisional');
182
+ ok(res.filter((r) => r.handle === 'r24alice').length === 1, 'a repeated handle in one batch is invited once');
183
+ await guilds.inviteHandles({ guildId: unlId, ownerGithubId: ALICE, handles: ['r24bob'] }, { pool });
184
+ ok((await openRows('r24-unl', BOB)).length === 0,
185
+ 'a non-owner invite writes nothing (the owner gate is in the statement, not only the route)');
186
+ ok((await guilds.inviteHandles({ guildId: unlId, ownerGithubId: OWNER, handles: ['r24priv2'] }, { pool }))[0].outcome === 'invited',
187
+ 'the owner invites a second private account they are connected to');
188
+
189
+ const aliceInv = await pendingId('r24-unl', ALICE);
190
+ ok((await guilds.acceptRequest({ id: aliceInv, githubId: ALICE }, { pool })).outcome === 'card_required',
191
+ 'an invite accept must carry the visibility its card showed');
192
+ const changed = await guilds.acceptRequest({ id: aliceInv, githubId: ALICE, cardVisibility: 'public' }, { pool });
193
+ ok(changed.outcome === 'visibility_changed' && changed.visibility === 'unlisted',
194
+ 'a card that does not match the guild is refused, naming the current visibility');
195
+ ok((await guilds.acceptRequest({ id: aliceInv, githubId: OWNER, cardVisibility: 'unlisted' }, { pool })).outcome === 'not_found',
196
+ 'only the invitee may accept an invite');
197
+ ok((await guilds.acceptRequest({ id: aliceInv, githubId: ALICE, cardVisibility: 'unlisted', countedInTotals: false }, { pool })).outcome === 'accepted',
198
+ 'the invitee accepts with the card they saw');
199
+ const aliceUnl = await memberRow('r24-unl', ALICE);
200
+ ok(aliceUnl.shown_publicly === false && aliceUnl.counted_in_totals === false,
201
+ 'joining an unlisted guild leaves the member unshown; the card switch is honoured');
202
+
203
+ const privcInv = await pendingId('r24-unl', PRIVC);
204
+ ok((await guilds.acceptRequest({ id: privcInv, githubId: PRIVC, cardVisibility: 'unlisted' }, { pool })).outcome === 'accepted',
205
+ 'a private account can join');
206
+ ok((await guilds.acceptRequest({ id: privcInv, githubId: PRIVC, cardVisibility: 'unlisted' }, { pool })).outcome === 'not_found',
207
+ 'an accepted row cannot be accepted twice');
208
+
209
+ // Decline cooldown on invites, and the owner's queue.
210
+ await guilds.inviteHandles({ guildId: unlId, ownerGithubId: OWNER, handles: ['r24bob', 'r24carl'] }, { pool });
211
+ const q1 = await guilds.listGuildRequests({ guildId: unlId, ownerGithubId: OWNER }, { pool });
212
+ ok(q1.outgoing.length === 3 && q1.outgoing.some((r) => r.handle === 'r24priv2') && q1.outgoing.every((r) => !('seen_at' in r) && r.handle && !('github_id' in r)),
213
+ 'the owner sees invites still out — named by handle, never github_id, never the invitee\'s seen_at');
214
+ ok((await guilds.listGuildRequests({ guildId: unlId, ownerGithubId: ALICE }, { pool })).outgoing.length === 0,
215
+ 'a non-owner reads an empty queue (the owner gate is in the statement)');
216
+ const bobQ = await guilds.listMyRequests({ githubId: BOB }, { pool });
217
+ ok(bobQ.incoming.length === 1 && bobQ.incoming[0].visibility === 'unlisted' && bobQ.incoming[0].slug === 'r24-unl',
218
+ 'the invitee\'s queue carries the card: slug and current visibility');
219
+ ok((await openRows('r24-unl', BOB))[0].seen_at !== null, 'reading the queue stamps seen_at on the invite');
220
+ ok(await guilds.declineRequest({ id: await pendingId('r24-unl', BOB), githubId: BOB }, { pool }), 'Bob declines — silently');
221
+ await guilds.inviteHandles({ guildId: unlId, ownerGithubId: OWNER, handles: ['r24bob'] }, { pool });
222
+ ok((await openRows('r24-unl', BOB)).length === 1, 'a re-invite inside the cooldown is swallowed: no new row');
223
+
224
+ // Cancel: the opener only.
225
+ const carlInv = await pendingId('r24-unl', CARL);
226
+ ok(!(await guilds.cancelRequest({ id: carlInv, githubId: CARL }, { pool })), 'an invitee cannot cancel an invite (that is a decline)');
227
+ ok(await guilds.cancelRequest({ id: carlInv, githubId: OWNER }, { pool }), 'the owner cancels an invite');
228
+
229
+ // Expiry is read-time, and the next write resolves the stale row in the same statement.
230
+ await guilds.inviteHandles({ guildId: unlId, ownerGithubId: OWNER, handles: ['r24carl'] }, { pool });
231
+ await q(`UPDATE platform_identity_guild_requests SET created_at = now() - interval '31 days'
232
+ WHERE github_id = $1 AND status = 'pending'`, [CARL]);
233
+ const stale = await pendingId('r24-unl', CARL);
234
+ ok((await guilds.acceptRequest({ id: stale, githubId: CARL, cardVisibility: 'unlisted' }, { pool })).outcome === 'not_found',
235
+ 'an invite older than the expiry cannot be accepted');
236
+ const again = await guilds.inviteHandles({ guildId: unlId, ownerGithubId: OWNER, handles: ['r24carl'] }, { pool });
237
+ ok(again[0].outcome === 'invited' && (await openRows('r24-unl', CARL)).some((r) => r.status === 'expired'),
238
+ 'a fresh invite resolves the stale row as expired in the same statement, instead of hitting the open-row index');
239
+
240
+ // --- 6. visibility changes flip only the owner (D4 §3) ---------------------
241
+ const flipped = await guilds.updateGuild({ guildId: unlId, githubId: OWNER, visibility: 'public' }, { pool });
242
+ ok(flipped && flipped.visibility === 'public', 'the owner makes the guild public');
243
+ ok((await memberRow('r24-unl', OWNER)).shown_publicly === true, 'the owner who flipped it is now shown');
244
+ ok((await memberRow('r24-unl', ALICE)).shown_publicly === false, 'a member who joined it unlisted is NOT shown until they agree');
245
+ ok(!(await guilds.updateGuild({ guildId: unlId, githubId: ALICE, name: 'Hijack' }, { pool })), 'a non-owner cannot edit the guild');
246
+ const carlCard = await guilds.acceptRequest({ id: await pendingId('r24-unl', CARL), githubId: CARL, cardVisibility: 'unlisted' }, { pool });
247
+ ok(carlCard.outcome === 'visibility_changed', 'an invite accepted from a card that predates the flip is refused');
248
+
249
+ let page = await guilds.getPublicGuild('r24-unl', { pool });
250
+ ok(page && page.members.map((m) => m.handle).join(',') === 'r24own' && page.size === 1,
251
+ 'the public roster shows only members who agreed to be shown; size is its length');
252
+ ok(page.members.every((m) => !('github_id' in m)), 'the public roster never carries github_id');
253
+
254
+ ok((await guilds.setMembershipFlags({ slug: 'r24-unl', githubId: ALICE, shownPublicly: true }, { pool })).shown_publicly === true,
255
+ 'a member agrees to be shown');
256
+ await guilds.setMembershipFlags({ slug: 'r24-unl', githubId: PRIVC, shownPublicly: true }, { pool });
257
+ page = await guilds.getPublicGuild('r24-unl', { pool });
258
+ ok(page.members.map((m) => m.handle).sort().join(',') === 'r24alice,r24own' && page.size === 2,
259
+ 'a private account is never on a public roster, whatever its flag');
260
+ ok(page.members[0].handle === 'r24own', 'the owner leads; the rest are ordered by handle, a published field');
261
+ const roster = await guilds.listMemberRoster({ guildId: unlId }, { pool });
262
+ ok(roster.some((m) => m.handle === 'r24privc'), 'fellow members still see the private member in the own-scope roster');
263
+ ok((await guilds.setMembershipFlags({ slug: 'r24-unl', githubId: ALICE, countedInTotals: true }, { pool })).counted_in_totals === true,
264
+ 'a member flips their own counted_in_totals');
265
+ ok((await guilds.setMembershipFlags({ slug: 'r24-unl', githubId: BOB, countedInTotals: false }, { pool })) === null,
266
+ 'a non-member has no switch to flip');
267
+ ok((await guilds.listMyGuilds({ githubId: ALICE }, { pool })).length === 2, 'my guilds lists both memberships');
268
+
269
+ // --- 7. transfer, remove, leave, dissolve (D2) -----------------------------
270
+ ok(await guilds.transferOwnership({ guildId: unlId, ownerGithubId: ALICE, handle: 'r24privc' }, { pool }) === 'not_owner',
271
+ 'a member cannot transfer ownership');
272
+ ok((await memberRow('r24-unl', ALICE)).membership_kind === 'member', '…and the refused attempt changed nothing');
273
+ ok(await guilds.transferOwnership({ guildId: unlId, ownerGithubId: OWNER, handle: 'r24bob' }, { pool }) === 'not_member',
274
+ 'ownership goes only to an existing member');
275
+ ok(await guilds.transferOwnership({ guildId: unlId, ownerGithubId: OWNER, handle: 'r24alice' }, { pool }) === 'transferred',
276
+ 'the owner transfers to a member');
277
+ ok((await memberRow('r24-unl', ALICE)).membership_kind === 'owner' && (await memberRow('r24-unl', OWNER)).membership_kind === 'member',
278
+ 'demote then promote: exactly one owner, and it is the new one');
279
+ const newOwnerQ = await guilds.listGuildRequests({ guildId: unlId, ownerGithubId: ALICE }, { pool });
280
+ ok(newOwnerQ.outgoing.some((r) => r.handle === 'r24carl') && !newOwnerQ.outgoing.some((r) => r.handle === 'r24priv2'),
281
+ 'after a transfer, the new owner sees public invitees but not a private one the previous owner invited');
282
+ ok((await guilds.listGuildRequests({ guildId: unlId, ownerGithubId: OWNER }, { pool })).outgoing.length === 0,
283
+ 'and the previous owner no longer reads the queue at all');
284
+
285
+ ok(!(await guilds.removeMember({ guildId: unlId, ownerGithubId: OWNER, handle: 'r24privc' }, { pool })),
286
+ 'the former owner can no longer remove members');
287
+ ok(await guilds.removeMember({ guildId: unlId, ownerGithubId: ALICE, handle: 'r24privc' }, { pool }),
288
+ 'the owner removes a member');
289
+ ok(!(await memberRow('r24-unl', PRIVC)), 'removal is a hard delete');
290
+ ok(!(await openRows('r24-unl', PRIVC)).some((r) => r.status === 'accepted'),
291
+ 'removal also deletes the accepted invite that admitted them — no record they were ever here');
292
+ ok(!(await guilds.removeMember({ guildId: unlId, ownerGithubId: ALICE, handle: 'r24alice' }, { pool })),
293
+ 'the owner cannot remove themselves');
294
+
295
+ ok(await guilds.leaveGuild({ guildId: unlId, githubId: ALICE }, { pool }) === 'must_transfer',
296
+ 'an owner with members must transfer before leaving');
297
+ ok(await guilds.leaveGuild({ guildId: unlId, githubId: OWNER }, { pool }) === 'left', 'a member leaves');
298
+ ok(await guilds.leaveGuild({ guildId: unlId, githubId: OWNER }, { pool }) === null, 'a non-member cannot leave');
299
+ ok(await guilds.leaveGuild({ guildId: unlId, githubId: ALICE }, { pool }) === 'dissolved',
300
+ 'the last member leaving dissolves the guild');
301
+ ok(!(await guildId('r24-unl')) && (await openRows('r24-unl', CARL)).length === 0,
302
+ 'dissolving hard-deletes the guild and cascades to its open requests');
303
+
304
+ const pubId = await guildId('r24-pub');
305
+
306
+ // A leave racing a transfer that promotes the leaver. The transfer holds the
307
+ // leaver's row locked; the leave must wait, then see 'owner', and refuse —
308
+ // never delete the new owner from a stale snapshot and orphan the guild.
309
+ const tx = await pool.connect();
310
+ let raced;
311
+ try {
312
+ await tx.query('BEGIN');
313
+ await tx.query(`SELECT 1 FROM platform_identity_guild_members WHERE guild_id = $1 AND github_id = $2 FOR UPDATE`, [pubId, ALICE]);
314
+ await tx.query(`UPDATE platform_identity_guild_members SET membership_kind = 'member' WHERE guild_id = $1 AND github_id = $2`, [pubId, OWNER]);
315
+ await tx.query(`UPDATE platform_identity_guild_members SET membership_kind = 'owner' WHERE guild_id = $1 AND github_id = $2`, [pubId, ALICE]);
316
+ raced = guilds.leaveGuild({ guildId: pubId, githubId: ALICE }, { pool });
317
+ let waiting = false;
318
+ for (let i = 0; i < 50 && !waiting; i++) {
319
+ await new Promise((r) => setTimeout(r, 50));
320
+ const { rows } = await q(`SELECT 1 FROM pg_stat_activity WHERE wait_event_type = 'Lock' AND query LIKE '%WITH mine AS%'`);
321
+ waiting = rows.length > 0;
322
+ }
323
+ ok(waiting, 'the racing leave is blocked on the transfer\'s row lock');
324
+ await tx.query('COMMIT');
325
+ } finally { tx.release(); }
326
+ ok(await raced === 'must_transfer', 'once the transfer commits, the leave re-reads the row as owner and refuses');
327
+ ok((await memberRow('r24-pub', ALICE)).membership_kind === 'owner', 'the guild still has its (new) owner');
328
+
329
+ ok(await guilds.transferOwnership({ guildId: pubId, ownerGithubId: ALICE, handle: 'r24own' }, { pool }) === 'transferred',
330
+ 'ownership is handed back');
331
+ ok(await guilds.leaveGuild({ guildId: pubId, githubId: ALICE }, { pool }) === 'left', 'the member leaves');
332
+ ok(!(await openRows('r24-pub', ALICE)).some((r) => r.status === 'accepted'),
333
+ 'leaving deletes the accepted request that admitted them');
334
+ ok(!(await guilds.dissolveGuild({ guildId: pubId, githubId: ALICE }, { pool })), 'a non-owner cannot dissolve');
335
+ ok(await guilds.dissolveGuild({ guildId: pubId, githubId: OWNER }, { pool }), 'the owner dissolves');
336
+ ok((await q(`SELECT count(*)::int AS n FROM platform_identity_guild_members WHERE guild_id = $1`, [pubId])).rows[0].n === 0,
337
+ 'dissolving cascades to every membership');
338
+
339
+ console.log(`\nguilds_db.mjs: ${pass} passed`);
340
+ } finally {
341
+ await cleanup();
342
+ await pool.end();
343
+ }
@@ -123,6 +123,11 @@ const OWN_SCOPED_SURFACES = [
123
123
  why: 'the connection graph (ADR 0202, task 1002295). The ACTING account is always the session; a counterparty is named by public handle or by the connection row id, never by an account identifier — and the degree label is a viewer-relative own-scope read (D4 §4).',
124
124
  crossOwner: [],
125
125
  },
126
+ {
127
+ file: 'modules/platform-identity/routes/guilds.js',
128
+ why: 'guilds (ADR 0336, task 1002300). The ACTING account is always the session: a guild is named by slug, a person by public handle, an invite or request by its row id, and owner-only acts are re-asserted against the session inside each statement. The one public read, GET /guilds/:slug, is deliberately in routes/guilds-public.js so this whole file can be held to the rule.',
129
+ crossOwner: [],
130
+ },
126
131
  {
127
132
  file: 'modules/platform-identity/routes/my-projects.js',
128
133
  why: "the caller's OWN cross-project memberships, invitations, applications and leave (ADR 0141 §3; the application echo, ADR 0201 D1.3, task 1002284) — every route keyed on req.builder.github_id / github_login. The only path param is :clientId, which names a PROJECT and never a builder: a project is the resource being acted on, and the actor is always the session, which is why join can relay an application for nobody but the caller.",
@@ -1,10 +1,12 @@
1
1
  // tests/terms_acceptance.mjs
2
2
  //
3
- // THE PLATFORM T&C ACCEPTANCE RECORD — "the hub's own sign-in is the only thing
4
- // that can say a human accepted the platform terms, it says so once, and nothing
5
- // publishes without it" (ADR 0213, task 1002913; the artifact ADR 0141 §4
6
- // deferred cross-project-aggregation consent to and ADR 0171 D3 reserved a place
7
- // for).
3
+ // THE PLATFORM T&C ACCEPTANCE RECORD — "a sign-in the hub itself witnessed is the
4
+ // only thing that can say a human accepted the platform terms, it says so once,
5
+ // and nothing publishes without it" (ADR 0213, task 1002913; the artifact ADR
6
+ // 0141 §4 deferred cross-project-aggregation consent to and ADR 0171 D3 reserved
7
+ // a place for). That sign-in is GET /sso/authorize — a builder using their
8
+ // platform account to sign in to a PROJECT. Signing in to the hub itself stamps
9
+ // nothing yet (blocker 1000124); task 1003361 adds it as a second capture point.
8
10
  //
9
11
  // WHY THIS EXISTS. An acceptance record is only worth the evidence behind it. If
10
12
  // a caller can assert one — a body field, a query param, a header — then the
@@ -111,24 +113,44 @@ t('the terms page states what crosses between projects', () => {
111
113
  t('the acceptance the page promises is the one the code actually keeps', () => {
112
114
  assert.match(termsHtml, /stores the date you accepted these terms/i,
113
115
  'the page must tell the reader an acceptance record is kept');
114
- assert.match(termsHtml, /signing in again does not change the original date/i,
116
+ assert.match(termsHtml, /signing in again does not change the date you accepted/i,
115
117
  'and that it is written once — the property the COALESCE below actually provides');
116
118
  });
117
119
 
120
+ // The capture point is GET /sso/authorize — reached when a builder uses their
121
+ // platform account to sign in to a PROJECT. Signing in to the hub itself stamps
122
+ // nothing (blocker 1000124, task 1003353), so the page must not say "here".
123
+ // Task 1003361 adds the hub's own sign-in as a second capture point; when it
124
+ // lands, this wording and §3 below change together.
125
+ t('the page names the sign-in the code actually watches — a project sign-in, not "here" (blocker 1000124)', () => {
126
+ const section = termsHtml.slice(termsHtml.indexOf('Your work across projects'), termsHtml.indexOf('Fair use'));
127
+ assert.doesNotMatch(section, /sign(?:ed|ing)? in here/i,
128
+ 'the page promises that signing in to this site records acceptance — the code never stamps that sign-in');
129
+ assert.match(section, /Once you have signed in to a project with your platform account and accepted these terms/,
130
+ 'what makes a profile public must name the moment the code captures');
131
+ assert.match(section, /never signed in to a project that way stays provisional/,
132
+ 'and the provisional case must be the same moment, negated');
133
+ assert.match(section, /written when you sign in to a project with your platform account, and only once/,
134
+ 'the acceptance paragraph must name the same capture point');
135
+ });
136
+
118
137
  t('the page says which acceptances were given on a builder’s behalf, and that the record marks them (task 1003360)', () => {
119
138
  assert.match(termsHtml, /accepted it on your behalf/i,
120
139
  'the owner chose to grandfather AND to say so on the page — the page must tell a pre-existing builder what was recorded for them');
121
140
  assert.match(termsHtml, /marks that acceptance as grandfathered/i,
122
141
  'and that the record keeps it distinguishable from one they gave');
123
- assert.match(termsHtml, /your own acceptance replaces it the next time you sign in/i,
142
+ assert.match(termsHtml, /your own acceptance replaces it the next time you sign in to a project/i,
124
143
  'and what happens when they do sign in — the conflict branch below really does replace it');
125
144
  assert.doesNotMatch(termsHtml, /written only by signing in/i,
126
145
  'the page still says acceptance is written ONLY by signing in, which is false for every grandfathered account');
127
- assert.doesNotMatch(stripApexOnly(termsHtml), /grandfathered/i, 'a claim about the platform record survived into a child project page');
146
+ assert.doesNotMatch(stripApexOnly(termsHtml, false), /grandfathered/i, 'a claim about the platform record survived into a child project page');
128
147
  });
129
148
 
130
149
  t('the cross-project claim is apex-only — it never ships in a child project page', () => {
131
- const stripped = stripApexOnly(termsHtml);
150
+ // The hub flag is passed explicitly: its default reads config/modules.json, so
151
+ // an implicit call passes or fails with whatever this checkout has enabled.
152
+ assert.equal(stripApexOnly(termsHtml, true), termsHtml, 'the hub keeps every apex-only passage');
153
+ const stripped = stripApexOnly(termsHtml, false);
132
154
  assert.doesNotMatch(stripped, /Your work across projects/,
133
155
  'a claim about THE PLATFORM survived into a child project page (task 1002748)');
134
156
  // ...and the stripper did not simply eat the page.
@@ -165,7 +187,9 @@ t('the conflict fragment keeps the first acceptance, and replaces only a grandfa
165
187
  t('the insert fragment writes NULL for a caller the hub did not witness, and marks a witnessed one as a sign-in', () => {
166
188
  const values = acceptanceInsertValues('$5', '$6');
167
189
  assert.match(values, /CASE WHEN \$5::boolean THEN now\(\) ELSE NULL END/,
168
- 'a check-in / device-poll account has no acceptance — fail-closed');
190
+ 'an account no sign-in witnessed has no acceptance — fail-closed');
191
+ assert.match(values, /CASE WHEN \$5::boolean THEN \$6::text ELSE NULL END/,
192
+ 'and no version either: the pair is written together or not at all');
169
193
  assert.match(values, /CASE WHEN \$5::boolean THEN 'signin' ELSE NULL END/,
170
194
  'an acceptance written at sign-in must say so (task 1003360) — an unmarked one is indistinguishable from a grandfather');
171
195
  assert.equal(acceptanceInsertColumns(), ', terms_accepted_at, terms_version, terms_accepted_source');
@@ -199,8 +223,12 @@ function splitTopLevel(s) {
199
223
  // permissive `.*?` match: every shape this understands is spelled out, and an
200
224
  // unrecognised one THROWS. A SET list matched loosely is how a deleted audit
201
225
  // trail passes a test that looks like it is checking one.
202
- function evalAcceptance(expr, { col, activate, existing, oldRow = {}, params, now }) {
226
+ function evalAcceptance(expr, { col, existing, oldRow = {}, params, now }) {
203
227
  const self = `platform_identity_accounts.${col}`;
228
+ // A boolean placeholder resolves by its OWN number, exactly as `$N::text` does
229
+ // below — so a fragment gated on the wrong parameter reads as never activating,
230
+ // rather than scoring as correct because the interpreter assumed which one it was.
231
+ const on = (n) => params[Number(n) - 1] === true;
204
232
  const src = (tok) => {
205
233
  if (/^now\(\)$/i.test(tok)) return now;
206
234
  if (tok === "'signin'") return 'signin';
@@ -216,24 +244,24 @@ function evalAcceptance(expr, { col, activate, existing, oldRow = {}, params, no
216
244
  // right-hand side against the row before the update.
217
245
  m = new RegExp(`^CASE WHEN \\$(\\d+)::boolean AND platform_identity_accounts\\.terms_accepted_source = 'grandfathered' THEN (.+?) WHEN \\$(\\d+)::boolean THEN COALESCE\\(${esc}, (.+?)\\) ELSE ${esc} END$`, 'i').exec(expr);
218
246
  if (m) {
219
- if (!activate) return existing;
220
- if (oldRow.terms_accepted_source === 'grandfathered') return src(m[2]);
221
- return existing != null ? existing : src(m[4]);
247
+ if (on(m[1]) && oldRow.terms_accepted_source === 'grandfathered') return src(m[2]);
248
+ if (on(m[3])) return existing != null ? existing : src(m[4]);
249
+ return existing;
222
250
  }
223
251
 
224
252
  // The ADR 0213 shape: activate ? (existing ?? src) : existing. Recognised so a
225
253
  // regression to it is caught by behaviour (a grandfather that never gives way).
226
254
  m = new RegExp(`^CASE WHEN \\$(\\d+)::boolean THEN COALESCE\\(${esc}, (.+?)\\) ELSE ${esc} END$`, 'i').exec(expr);
227
- if (m) return activate ? (existing != null ? existing : src(m[2])) : existing;
255
+ if (m) return on(m[1]) ? (existing != null ? existing : src(m[2])) : existing;
228
256
 
229
257
  // The overwriting shape — a real possibility, and the one the idempotence
230
258
  // assertion below must be able to tell apart from the shipped one.
231
259
  m = new RegExp(`^CASE WHEN \\$(\\d+)::boolean THEN (.+?) ELSE ${esc} END$`, 'i').exec(expr);
232
- if (m) return activate ? src(m[2]) : existing;
260
+ if (m) return on(m[1]) ? src(m[2]) : existing;
233
261
 
234
262
  // The INSERT-branch shape: activate ? src : NULL
235
263
  m = /^CASE WHEN \$(\d+)::boolean THEN (.+?) ELSE NULL END$/i.exec(expr);
236
- if (m) return activate ? src(m[2]) : null;
264
+ if (m) return on(m[1]) ? src(m[2]) : null;
237
265
 
238
266
  // Unconditional — a caller-independent write. Recognised so the "non-activating
239
267
  // paths never stamp" assertion can catch it rather than erroring out.
@@ -272,16 +300,28 @@ function makePool() {
272
300
  const id = String(params[0]);
273
301
  const existing = accounts.get(id);
274
302
 
275
- if (/ON CONFLICT \(github_id\) DO NOTHING/i.test(sql)) {
276
- if (!existing) accounts.set(id, { github_id: id, github_login: params[1], profile_state: 'provisional', terms_accepted_at: null, terms_version: null, terms_accepted_source: null, account_visibility: 'public', hide_stats: false, handle: 'h' });
277
- return { rows: [], rowCount: existing ? 0 : 1 };
278
- }
279
-
280
303
  const valuesRaw = /VALUES \((.+?)\) ON CONFLICT/i.exec(sql);
281
304
  assert.ok(valuesRaw, `the upsert changed shape — update the interpreter, do not bypass it: ${sql.slice(0, 200)}`);
282
305
  const values = splitTopLevel(valuesRaw[1]);
283
306
  assert.equal(values.length, cols.length, 'the INSERT column list and VALUES list must line up');
284
307
 
308
+ // ensureAccount's insert-only write. Built from the statement's OWN column
309
+ // list, not a hardcoded row: were an acceptance column added to it, the
310
+ // row below would really carry it and the assertions would really fail.
311
+ if (/ON CONFLICT \(github_id\) DO NOTHING/i.test(sql)) {
312
+ if (existing) return { rows: [], rowCount: 0 };
313
+ const row = { github_id: id, github_login: params[1], account_visibility: 'public', hide_stats: false, handle: 'h' };
314
+ for (const col of ACCEPTANCE_COLUMNS) {
315
+ const i = cols.indexOf(col);
316
+ row[col] = i === -1 ? null : evalAcceptance(values[i], { col, existing: null, params, now: clock });
317
+ }
318
+ const ps = /^'(\w+)'$/.exec(values[cols.indexOf('profile_state')] || '');
319
+ assert.ok(ps, 'the insert-only write stopped stating its profile_state literally — update the interpreter');
320
+ row.profile_state = ps[1];
321
+ accounts.set(id, row);
322
+ return { rows: [], rowCount: 1 };
323
+ }
324
+
285
325
  const activate = params[4] === true;
286
326
  let row;
287
327
  if (!existing) {
@@ -289,7 +329,7 @@ function makePool() {
289
329
  for (const col of ACCEPTANCE_COLUMNS) {
290
330
  const i = cols.indexOf(col);
291
331
  assert.notEqual(i, -1, `the upsert stopped writing ${col} on insert`);
292
- row[col] = evalAcceptance(values[i], { col, activate, existing: null, params, now: clock });
332
+ row[col] = evalAcceptance(values[i], { col, existing: null, params, now: clock });
293
333
  }
294
334
  row.profile_state = activate ? 'active' : 'provisional';
295
335
  accounts.set(id, row);
@@ -305,7 +345,7 @@ function makePool() {
305
345
  const oldRow = { ...row };
306
346
  for (const col of ACCEPTANCE_COLUMNS) {
307
347
  assert.ok(assigns.has(col), `the conflict branch stopped writing ${col} — an account that signs in again would never record its acceptance`);
308
- row[col] = evalAcceptance(assigns.get(col), { col, activate, existing: oldRow[col], oldRow, params, now: clock });
348
+ row[col] = evalAcceptance(assigns.get(col), { col, existing: oldRow[col], oldRow, params, now: clock });
309
349
  }
310
350
  if (activate) row.profile_state = 'active';
311
351
  }
@@ -392,7 +432,7 @@ const authorize = (qs = '') => request(
392
432
  );
393
433
 
394
434
  // ===========================================================================
395
- // 3. The capture point — the hub's own sign-in, and nothing else
435
+ // 3. The capture point — the project sign-in leg (/sso/authorize), and nothing else
396
436
  // ===========================================================================
397
437
 
398
438
  console.log('\nthe capture point (only the hub can say a human accepted):');
@@ -405,7 +445,7 @@ await ta('CONTROL: a hub-session authorize records the acceptance', async () =>
405
445
  assert.equal(res.status, 302, 'the authorize leg should hand back a code');
406
446
  const row = pool.accounts.get('4242');
407
447
  assert.ok(row, 'no account was written at all — the rest of this section would pass vacuously');
408
- assert.equal(row.terms_accepted_at, NOW, 'the acceptance was not stamped by the hub sign-in');
448
+ assert.equal(row.terms_accepted_at, NOW, 'the acceptance was not stamped by the platform-account sign-in to a project');
409
449
  assert.equal(row.terms_version, TERMS_VERSION, 'the record must name WHICH text was accepted');
410
450
  assert.equal(row.terms_accepted_source, 'signin',
411
451
  'and HOW it was given — a sign-in, queryably distinct from a grandfather (task 1003360)');
@@ -494,13 +534,19 @@ await ta('a NON-activating write never touches an existing acceptance', async ()
494
534
  pool.setClock(NOW);
495
535
  await authorize();
496
536
 
497
- // ensureAccount is the federated path (check-in / device-poll): it must not
498
- // create, restamp, or clear an acceptance.
537
+ // Neither non-activating writer may create, restamp, or clear an acceptance:
538
+ // ensureAccount (the federated membership check-in, insert-only) and the
539
+ // non-activating upsertAccount (POST /sso/device/poll and PATCH /me/handle),
540
+ // whose conflict branch really runs here — its "leave it alone" ELSE half.
499
541
  const pi = require('../modules/platform-identity/platform-identity.js');
500
542
  pool.setClock(LATER);
501
543
  await pi.ensureAccount({ githubId: '4242', githubLogin: 'octo', displayName: 'x', avatarUrl: null }, { pool });
502
544
  assert.equal(pool.accounts.get('4242').terms_accepted_at, NOW,
503
545
  'a federated check-in changed the acceptance record');
546
+ await pi.upsertAccount({ githubId: '4242', githubLogin: 'octo', displayName: 'x', avatarUrl: null }, { pool });
547
+ const row = pool.accounts.get('4242');
548
+ assert.deepEqual([row.terms_accepted_at, row.terms_version, row.terms_accepted_source], [NOW, TERMS_VERSION, 'signin'],
549
+ 'a non-activating upsert (device poll, handle change) changed the acceptance record');
504
550
  });
505
551
 
506
552
  await ta('a federated check-in creates an account with NO acceptance at all', async () => {
@@ -4,7 +4,7 @@
4
4
  // Proves migration platform_identity_024 against real SQL: an account the hub
5
5
  // had already witnessed is published immediately after the migration, without
6
6
  // signing in again; its acceptance is queryably distinct from a real sign-in;
7
- // the grandfather runs once; and the owner's next hub sign-in replaces it.
7
+ // the grandfather runs once; and the owner's next project sign-in replaces it.
8
8
  // tests/terms_acceptance.mjs pins the same rules DB-free (the statement shapes
9
9
  // and the executed conflict branch); only a transaction engine can run the
10
10
  // migration's DO block and its UPDATEs.
@@ -106,7 +106,7 @@ try {
106
106
  await q(migrationSql('platform_identity_024_terms_grandfathered.sql'));
107
107
  ok((await row(LATE)).terms_accepted_at === null, 'replaying 024 grandfathers nobody new — the grandfather was an event, not a rule');
108
108
 
109
- // The owner's next hub sign-in replaces the grandfather with their own acceptance.
109
+ // The owner's next project sign-in replaces the grandfather with their own acceptance.
110
110
  await pi.upsertAccount({ githubId: LEGACY, githubLogin: 'legacy-b', activate: true }, { pool: client });
111
111
  const after = await row(LEGACY);
112
112
  ok(after.terms_accepted_source === 'signin', 'the next sign-in replaces the grandfather with a signin');