@bongos/core 1.19.1026 → 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.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1026",
3
+ "version": "1.19.1027",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.1026",
9
+ "version": "1.19.1027",
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.1026",
3
+ "version": "1.19.1027",
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",
@@ -152,6 +152,11 @@ const INTEGRATION = new Set([
152
152
  // the --integration lane never saw them either, because it runs only this set. A
153
153
  // test that cannot fail in either lane is a file that looks like a gate.
154
154
  'handle_machinery', 'hub_profile_leak', 'profile_fields',
155
+ // task 1002300 (R24): guilds against real SQL. The DB-free sibling
156
+ // (guilds_api) pins the route layer and statement shapes; only a real engine
157
+ // proves the one-owner partial index, the expiry CTE drained before its insert,
158
+ // the cascade on dissolve and the two-statement transfer.
159
+ 'guilds_db',
155
160
  // task 1003935: 'linkify_refs' was swept in with the three above without the
156
161
  // no-DB check they were being fixed for. It touches no Postgres at all — pure
157
162
  // string work plus a filesystem walk — so the DB-free lane skipped it and the
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.1026'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1027'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,521 @@
1
+ // tests/guilds_api.mjs — the guild API's route layer and statement shapes,
2
+ // DB-free (task 1002300, R24; ADR 0336 D1–D4 + D6, ADR 0337 D4.4).
3
+ //
4
+ // WHAT THIS PINS, each a rule rather than a behaviour:
5
+ //
6
+ // 1. THE STORED SHAPE — no visibility default, no `role`, shown_publicly with
7
+ // no default, counted_in_totals DEFAULT true, one owner held by the database.
8
+ // 2. OWN SCOPE — the acting github_id bound into every statement is the
9
+ // SESSION's; a body naming another account is refused by the strict schema.
10
+ // 3. THE PUBLIC READ — 404 for an unlisted guild is byte-identical to an
11
+ // unknown slug, needs no session, is no-store, and its roster statement
12
+ // carries shown_publicly AND the shared existence predicate.
13
+ // 4. SILENCE — a request written and one swallowed by a decline cooldown
14
+ // answer identically; a decline is an empty 204.
15
+ // 5. THE CARD — an invite accept must echo the visibility its card showed,
16
+ // and a mismatch is a 409 naming the current one (D4 §3).
17
+ // 6. TRANSFER — two statements, demote then promote, on one client inside
18
+ // BEGIN/COMMIT, and a failed promote rolls the demotion back (D2).
19
+ // 7. NOTHING HERE IS AUTHORITY, and every count is the length of its array.
20
+ //
21
+ // Real-SQL semantics (the indexes, cascades, expiry CTE) are proved against
22
+ // Postgres by tests/guilds_db.mjs in the integration lane.
23
+ //
24
+ // Run: node tests/guilds_api.mjs
25
+
26
+ import { strict as assert } from 'node:assert';
27
+ import { test, after } from 'node:test';
28
+ import { createRequire } from 'node:module';
29
+ import http from 'node:http';
30
+ import { readFileSync } from 'node:fs';
31
+ import { fileURLToPath } from 'node:url';
32
+ import { dirname, join } from 'node:path';
33
+
34
+ const require = createRequire(import.meta.url);
35
+ const express = require('express');
36
+
37
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
38
+ const read = (p) => readFileSync(join(ROOT, p), 'utf8').replace(/\r\n/g, '\n');
39
+ const MIGRATION = read('modules/platform-identity/migrations/platform_identity_025_guilds.sql');
40
+ const DOMAIN_SRC = read('modules/platform-identity/guilds.js');
41
+ const ROUTE_SRC = read('modules/platform-identity/routes/guilds.js');
42
+ const PUBLIC_SRC = read('modules/platform-identity/routes/guilds-public.js');
43
+
44
+ // ---- a fake pool that answers by statement ---------------------------------
45
+ //
46
+ // `answer(text, params)` is set per test and returns a { rows, rowCount } (or
47
+ // throws). Every call is recorded, which is the evidence the bound-parameter
48
+ // assertions read. Transactions go through connect(), same recording.
49
+ let calls = [];
50
+ let answer = () => result();
51
+ const result = (rows = [], rowCount = null) => ({ rows, rowCount: rowCount ?? rows.length });
52
+ async function run(text, params) {
53
+ calls.push({ text: String(text), params: params || [] });
54
+ return answer(String(text), params || []);
55
+ }
56
+ const pool = {
57
+ query: run,
58
+ connect: async () => ({ query: run, release() {} }),
59
+ };
60
+ function on(fn) { calls = []; answer = fn; }
61
+
62
+ // ---- the routers over the real domain, with core pinned --------------------
63
+
64
+ const ME = 4242;
65
+ let session = { github_id: ME, github_login: 'octo' };
66
+
67
+ const moduleApiPath = require.resolve('../src/module-api.js');
68
+ const helpers = require('../src/bongos/routes/_helpers.js');
69
+ require.cache[moduleApiPath] = {
70
+ id: moduleApiPath, filename: moduleApiPath, loaded: true,
71
+ exports: {
72
+ pool,
73
+ validateOrRespond: helpers.validateOrRespond,
74
+ parsePagination: helpers.parsePagination,
75
+ logger: () => ({ error() {}, warn() {}, info() {} }),
76
+ requireBuilder: (req, _res, next) => { req.builder = { ...session }; next(); },
77
+ // The kernel withTx contract: BEGIN, fn(client), COMMIT — ROLLBACK + rethrow.
78
+ withTx: async (fn, { pool: p }) => {
79
+ const client = await p.connect();
80
+ try {
81
+ await client.query('BEGIN');
82
+ const out = await fn(client);
83
+ await client.query('COMMIT');
84
+ return out;
85
+ } catch (err) {
86
+ await client.query('ROLLBACK');
87
+ throw err;
88
+ } finally { client.release(); }
89
+ },
90
+ },
91
+ };
92
+ const guilds = require('../modules/platform-identity/guilds.js');
93
+ const buildGuildRoutes = require('../modules/platform-identity/routes/guilds.js');
94
+ const buildPublicRoutes = require('../modules/platform-identity/routes/guilds-public.js');
95
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
96
+
97
+ const app = express();
98
+ app.use(express.json());
99
+ app.use(attachFail);
100
+ app.use('/api/bongos', buildGuildRoutes());
101
+ app.use('/api/bongos', buildPublicRoutes());
102
+ const server = app.listen(0, '127.0.0.1');
103
+ await new Promise((r) => server.once('listening', r));
104
+ const { port } = server.address();
105
+ after(() => server.close());
106
+
107
+ function call(method, path, body) {
108
+ return new Promise((resolve, reject) => {
109
+ const payload = body === undefined ? null : Buffer.from(JSON.stringify(body));
110
+ const req = http.request({
111
+ host: '127.0.0.1', port, method, path,
112
+ headers: payload ? { 'content-type': 'application/json', 'content-length': payload.length } : {},
113
+ }, (res) => {
114
+ const chunks = [];
115
+ res.on('data', (c) => chunks.push(c));
116
+ res.on('end', () => {
117
+ const text = Buffer.concat(chunks).toString('utf8');
118
+ let json = null; try { json = JSON.parse(text); } catch { /* empty or non-JSON */ }
119
+ resolve({ status: res.statusCode, json, text, headers: res.headers });
120
+ });
121
+ });
122
+ req.on('error', reject);
123
+ req.end(payload);
124
+ });
125
+ }
126
+
127
+ // Statement matchers, so each test answers exactly the statements it expects.
128
+ const IS = {
129
+ myAccount: (t) => /FROM platform_identity_accounts WHERE github_id = \$1/.test(t),
130
+ membership: (t) => /JOIN platform_identity_guild_members m ON m\.guild_id = g\.id AND m\.github_id = \$2::bigint/.test(t),
131
+ publicGuild: (t) => /FROM platform_identity_guilds\s+WHERE lower\(slug\) = \$1 AND visibility = 'public'/.test(t) && /created_at/.test(t),
132
+ joinableGuild: (t) => /^SELECT id FROM platform_identity_guilds WHERE lower\(slug\) = \$1 AND visibility = 'public'$/.test(t.trim()),
133
+ isMember: (t) => /^SELECT 1 FROM platform_identity_guild_members WHERE guild_id = \$1/.test(t.trim()),
134
+ openRequest: (t) => /INSERT INTO platform_identity_guild_requests/.test(t),
135
+ };
136
+ const ACTIVE = { github_id: String(ME), profile_state: 'active', terms_accepted_at: '2026-08-29T00:00:00Z', account_visibility: 'public', handle: 'octo' };
137
+ const MEMBERSHIP = (kind = 'owner', visibility = 'unlisted') => ({
138
+ id: '7', slug: 'guild-a', name: 'Guild A', description: null, visibility,
139
+ membership_kind: kind, shown_publicly: false, counted_in_totals: true,
140
+ });
141
+ // A base answerer: my own account is active; anything else is empty.
142
+ const base = (extra = () => null) => (t, p) => {
143
+ if (IS.myAccount(t)) return result([ACTIVE]);
144
+ return extra(t, p) || result();
145
+ };
146
+
147
+ // ===========================================================================
148
+ // 1. The stored shape
149
+ // ===========================================================================
150
+
151
+ test('migration 025: visibility has no default, and membership_kind is never role', () => {
152
+ const guildsTable = MIGRATION.match(/CREATE TABLE IF NOT EXISTS platform_identity_guilds \(([\s\S]*?)\n\);/)[1];
153
+ assert.match(guildsTable, /visibility\s+text NOT NULL CHECK \(visibility IN \('public', 'unlisted'\)\),/,
154
+ 'the creator chooses — a silent default would decide a privacy question for them (ADR 0336 D1)');
155
+ assert.doesNotMatch(guildsTable, /created_by|creator/, 'no creator column: the owner row is the authority');
156
+ assert.doesNotMatch(MIGRATION.replace(/^--.*$/gm, ''), /\brole\b/, 'ADR 0174 reserves `role` for the crafts');
157
+ const inSql = guildsTable.match(/CHECK \(visibility IN \(([^)]*)\)\)/)[1].split(',').map((s) => s.trim().replace(/'/g, ''));
158
+ assert.deepEqual(inSql, guilds.GUILD_VISIBILITIES, 'the CHECK and the exported vocabulary cannot drift');
159
+ });
160
+
161
+ test('migration 025: shown_publicly has NO default, counted_in_totals DEFAULTS true (ADR 0337 D4.4)', () => {
162
+ assert.match(MIGRATION, /shown_publicly\s+boolean NOT NULL,/, 'every join must say what its card showed');
163
+ assert.match(MIGRATION, /counted_in_totals boolean NOT NULL DEFAULT true,\n\s+joined_at/,
164
+ 'guild_members carries the per-guild switch, on by default');
165
+ });
166
+
167
+ test('migration 025: one owner per guild and one open row per pair are DATABASE rules', () => {
168
+ assert.match(MIGRATION, /CREATE UNIQUE INDEX IF NOT EXISTS uniq_platform_identity_guild_owner\s+ON platform_identity_guild_members \(guild_id\) WHERE membership_kind = 'owner';/);
169
+ assert.match(MIGRATION, /CREATE UNIQUE INDEX IF NOT EXISTS uniq_platform_identity_guild_requests_open\s+ON platform_identity_guild_requests \(guild_id, github_id\) WHERE status = 'pending';/);
170
+ assert.match(MIGRATION, /CREATE UNIQUE INDEX IF NOT EXISTS uniq_platform_identity_guilds_slug\s+ON platform_identity_guilds \(lower\(slug\)\);/);
171
+ assert.equal((MIGRATION.match(/ON DELETE CASCADE/g) || []).length, 2, 'dissolving cascades to members and requests');
172
+ assert.doesNotMatch(MIGRATION, /\bDROP\b|ALTER TABLE/i, 'additive only (ADR 0083 §5)');
173
+ });
174
+
175
+ test('the caps in the migration and the domain are one number', () => {
176
+ assert.match(MIGRATION, new RegExp(`length\\(name\\) BETWEEN 1 AND ${guilds.NAME_MAX_CHARS}\\)`));
177
+ assert.match(MIGRATION, new RegExp(`length\\(description\\) <= ${guilds.DESCRIPTION_MAX_CHARS}\\)`));
178
+ assert.equal(guilds.INVITE_BATCH_MAX, 25, 'ADR 0336 D3: a batch is at most 25');
179
+ });
180
+
181
+ // ===========================================================================
182
+ // 2. Create — own scope and the slug rules (D6)
183
+ // ===========================================================================
184
+
185
+ test('create: the owner row is keyed on the SESSION, and a body naming an account is refused', async () => {
186
+ on(base((t) => (/INSERT INTO platform_identity_guilds/.test(t)
187
+ ? result([{ slug: 'guild-a', name: 'Guild A', description: null, visibility: 'public', created_at: 'x' }]) : null)));
188
+ const res = await call('POST', '/api/bongos/guilds', { slug: 'Guild-A', name: ' Guild A ', visibility: 'public' });
189
+ assert.equal(res.status, 201);
190
+ assert.equal(res.json.guild.slug, 'guild-a');
191
+ const write = calls.find((c) => /INSERT INTO platform_identity_guilds/.test(c.text));
192
+ assert.deepEqual(write.params, ['guild-a', 'Guild A', null, 'public', ME], 'slug canonical, name trimmed, owner = session');
193
+ assert.match(write.text, /'owner', g\.visibility = 'public'/, 'the owner is shown exactly when they chose public');
194
+
195
+ on(base());
196
+ const smuggled = await call('POST', '/api/bongos/guilds', { slug: 'guild-b', name: 'B', visibility: 'public', github_id: 9001 });
197
+ assert.equal(smuggled.status, 400, 'the strict schema refuses an undeclared field — there is no way to create for someone else');
198
+ assert.equal(calls.length, 0, 'and nothing reached the database');
199
+ });
200
+
201
+ test('create: visibility is required, the slug is canonical, reserved words and taken slugs are refused', async () => {
202
+ on(base());
203
+ assert.equal((await call('POST', '/api/bongos/guilds', { slug: 'guild-a', name: 'A' })).status, 400, 'no visibility, no guild');
204
+ assert.equal((await call('POST', '/api/bongos/guilds', { slug: 'guild-a', name: 'A', visibility: 'private' })).status, 400);
205
+ const short = await call('POST', '/api/bongos/guilds', { slug: 'a', name: 'A', visibility: 'public' });
206
+ assert.equal(short.json.error.code, 'guild_slug_invalid', 'the two-character floor');
207
+ const bad = await call('POST', '/api/bongos/guilds', { slug: '-bad-', name: 'A', visibility: 'public' });
208
+ assert.equal(bad.json.error.code, 'guild_slug_invalid', 'no leading or trailing hyphen');
209
+ for (const word of ['me', 'new', 'edit', 'settings', 'admin', 'api']) {
210
+ const r = await call('POST', '/api/bongos/guilds', { slug: word, name: 'A', visibility: 'public' });
211
+ assert.equal(r.json.error.code, 'guild_slug_reserved', `"${word}" is a /g namespace word`);
212
+ }
213
+ const blank = await call('POST', '/api/bongos/guilds', { slug: 'guild-a', name: ' ', visibility: 'public' });
214
+ assert.equal(blank.json.error.code, 'guild_name_required');
215
+
216
+ on(base((t) => { if (/INSERT INTO platform_identity_guilds/.test(t)) throw Object.assign(new Error('dup'), { code: '23505' }); }));
217
+ const taken = await call('POST', '/api/bongos/guilds', { slug: 'guild-a', name: 'A', visibility: 'public' });
218
+ assert.equal(taken.status, 409);
219
+ assert.equal(taken.json.error.code, 'guild_slug_taken');
220
+ });
221
+
222
+ test('a provisional account can take part in nothing, and a non-numeric principal is refused cleanly', async () => {
223
+ on((t) => (IS.myAccount(t) ? result([{ ...ACTIVE, profile_state: 'provisional', terms_accepted_at: null }]) : result()));
224
+ const prov = await call('POST', '/api/bongos/guilds', { slug: 'guild-a', name: 'A', visibility: 'public' });
225
+ assert.equal(prov.status, 403);
226
+ assert.ok(!calls.some((c) => /platform_identity_guild/.test(c.text)), 'no guild statement ran');
227
+
228
+ session = { github_id: 'system:bfg' };
229
+ try {
230
+ on(base());
231
+ assert.equal((await call('GET', '/api/bongos/me/guilds')).status, 400);
232
+ } finally { session = { github_id: ME, github_login: 'octo' }; }
233
+ });
234
+
235
+ // ===========================================================================
236
+ // 3. The public read (D4)
237
+ // ===========================================================================
238
+
239
+ test('public read: an unlisted guild and an unknown slug are the SAME 404, with no session and no-store', async () => {
240
+ on(() => result()); // nothing public matches either slug
241
+ session = null; // the public read must not need one
242
+ try {
243
+ const unlisted = await call('GET', '/api/bongos/guilds/quiet-guild');
244
+ const unknown = await call('GET', '/api/bongos/guilds/no-such-guild');
245
+ assert.equal(unlisted.status, 404);
246
+ assert.equal(unlisted.text, unknown.text, 'byte-identical — an unlisted guild has no public existence');
247
+ assert.equal(unlisted.headers['cache-control'], 'no-store');
248
+ assert.ok(calls.every((c) => !IS.myAccount(c.text)), 'the public read never reads the viewer');
249
+ assert.match(calls[0].text, /visibility = 'public'/, 'the guild must be public to be read at all');
250
+ } finally { session = { github_id: ME, github_login: 'octo' }; }
251
+ });
252
+
253
+ test('public read: the roster is shown_publicly AND the shared existence predicate, ordered by what it publishes', async () => {
254
+ on((t) => {
255
+ if (IS.publicGuild(t)) return result([{ id: '7', slug: 'guild-a', name: 'Guild A', description: 'd', created_at: 'x' }]);
256
+ if (/FROM platform_identity_guild_members m\s+JOIN platform_identity_accounts pa/.test(t)) {
257
+ return result([
258
+ { handle: 'octo', display_name: 'Octo', avatar_url: null, membership_kind: 'owner' },
259
+ { handle: 'ada', display_name: 'Ada', avatar_url: null, membership_kind: 'member' },
260
+ ]);
261
+ }
262
+ return result();
263
+ });
264
+ const res = await call('GET', '/api/bongos/guilds/Guild-A');
265
+ assert.equal(res.status, 200);
266
+ assert.deepEqual(Object.keys(res.json).sort(), ['guild', 'members', 'size']);
267
+ assert.equal(res.json.size, 2);
268
+ assert.equal(res.json.members.length, 2);
269
+ assert.ok(!('id' in res.json.guild), 'the row id stays internal');
270
+ assert.ok(!/github_id|github_login/.test(res.text), 'no account id or login crosses');
271
+ const roster = calls.find((c) => /JOIN platform_identity_accounts pa/.test(c.text)).text;
272
+ assert.match(roster, /m\.shown_publicly AND \(\(pa\.profile_state = 'active' AND pa\.terms_accepted_at IS NOT NULL\) AND pa\.account_visibility = 'public'\)/,
273
+ 'shown_publicly AND accountVisibleSql(pa) — a private account is never on a public roster');
274
+ assert.match(roster, /ORDER BY \(m\.membership_kind = 'owner'\) DESC, lower\(pa\.handle\)/,
275
+ 'ordered only by published fields (ADR 0255) — never joined_at, which the roster does not carry');
276
+ assert.equal(calls[0].params[0], 'guild-a', 'the slug is looked up in canonical form');
277
+ });
278
+
279
+ // ===========================================================================
280
+ // 4. Join requests and silence (D3)
281
+ // ===========================================================================
282
+
283
+ test('join request: written and swallowed answer IDENTICALLY; unlisted is the unknown-slug 404', async () => {
284
+ const joinable = (insertRows) => base((t) => {
285
+ if (IS.joinableGuild(t)) return result([{ id: '7' }]);
286
+ if (IS.openRequest(t)) return result(insertRows);
287
+ return null;
288
+ });
289
+ on(joinable([{ id: '99' }]));
290
+ const created = await call('POST', '/api/bongos/guilds/guild-a/requests', {});
291
+ const write = calls.find((c) => IS.openRequest(c.text));
292
+ assert.deepEqual(write.params, ['7', ME, 'request', 30, 90, true],
293
+ 'the requester is the SESSION; expiry and cooldown are the connections constants, bound');
294
+ on(joinable([]));
295
+ const swallowed = await call('POST', '/api/bongos/guilds/guild-a/requests', {});
296
+ assert.equal(created.status, 201);
297
+ assert.equal(created.text, swallowed.text, 'a swallowed re-ask is byte-identical to a real one');
298
+
299
+ on(base());
300
+ const unlisted = await call('POST', '/api/bongos/guilds/quiet-guild/requests', {});
301
+ const unknown = await call('POST', '/api/bongos/guilds/nope-guild/requests', {});
302
+ assert.equal(unlisted.status, 404);
303
+ assert.equal(unlisted.text, unknown.text);
304
+
305
+ on(base((t) => (IS.joinableGuild(t) ? result([{ id: '7' }]) : IS.isMember(t) ? result([{}]) : null)));
306
+ assert.equal((await call('POST', '/api/bongos/guilds/guild-a/requests', {})).json.error.code, 'guild_already_member');
307
+ assert.ok(!calls.some((c) => IS.openRequest(c.text)), 'a member never opens a request');
308
+ });
309
+
310
+ test('join request: the counted_in_totals choice rides the row; the request never inserts a membership', async () => {
311
+ on(base((t) => (IS.joinableGuild(t) ? result([{ id: '7' }]) : IS.openRequest(t) ? result([{ id: '1' }]) : null)));
312
+ await call('POST', '/api/bongos/guilds/guild-a/requests', { counted_in_totals: false });
313
+ const write = calls.find((c) => IS.openRequest(c.text));
314
+ assert.equal(write.params[5], false);
315
+ assert.ok(!calls.some((c) => /INSERT INTO platform_identity_guild_members/.test(c.text)), 'no self-join');
316
+ assert.match(write.text, /WITH expired AS \([\s\S]*?guard AS \(SELECT count\(\*\)::int AS expired_count FROM expired\)[\s\S]*?FROM guard/,
317
+ 'the expiry CTE is referenced through guard, so it is drained BEFORE the insert');
318
+ });
319
+
320
+ // ===========================================================================
321
+ // 5. Owner-only acts, invites and the card
322
+ // ===========================================================================
323
+
324
+ test('owner-only acts: a member is told plainly, a stranger gets the unlisted-safe 404', async () => {
325
+ on(base((t) => (IS.membership(t) ? result([MEMBERSHIP('member')]) : null)));
326
+ const member = await call('POST', '/api/bongos/guilds/guild-a/invites', { handles: ['ada'] });
327
+ assert.equal(member.status, 403);
328
+ assert.equal(member.json.error.code, 'guild_owner_only');
329
+
330
+ on(base());
331
+ const stranger = await call('POST', '/api/bongos/guilds/guild-a/invites', { handles: ['ada'] });
332
+ const unknown = await call('POST', '/api/bongos/guilds/nope-guild/invites', { handles: ['ada'] });
333
+ assert.equal(stranger.status, 404);
334
+ assert.equal(stranger.text, unknown.text, 'a non-member cannot learn an unlisted guild exists');
335
+ const membershipRead = calls.find((c) => IS.membership(c.text));
336
+ assert.deepEqual(membershipRead.params, ['guild-a', ME], 'membership is always the SESSION\'s');
337
+ });
338
+
339
+ test('invites: the batch is capped at 25, and each entry is an ordinary invite gated on ownership', async () => {
340
+ on(base());
341
+ const tooMany = Array.from({ length: 26 }, (_, i) => `b${i}`);
342
+ assert.equal((await call('POST', '/api/bongos/guilds/guild-a/invites', { handles: tooMany })).status, 400);
343
+ assert.equal((await call('POST', '/api/bongos/guilds/guild-a/invites', { handles: [] })).status, 400);
344
+
345
+ on(base((t) => {
346
+ if (IS.membership(t)) return result([MEMBERSHIP('owner')]);
347
+ if (/WHERE lower\(handle\) = \$1 OR lower\(previous_handle\) = \$1/.test(t)) {
348
+ return result([{ github_id: '9001', handle: 'ada', profile_state: 'active', terms_accepted_at: 'x', account_visibility: 'public' }]);
349
+ }
350
+ if (IS.openRequest(t)) return result([{ id: '5' }]);
351
+ return null;
352
+ }));
353
+ const res = await call('POST', '/api/bongos/guilds/guild-a/invites', { handles: ['Ada'] });
354
+ assert.equal(res.status, 200);
355
+ assert.deepEqual(res.json, { results: [{ handle: 'ada', outcome: 'invited' }], count: 1 }, 'count is the array\'s length');
356
+ const write = calls.find((c) => IS.openRequest(c.text));
357
+ assert.deepEqual(write.params, ['7', '9001', 'invite', 30, 90, true, ME]);
358
+ assert.match(write.text, /o\.guild_id = \$1 AND o\.github_id = \$7::bigint\s+AND o\.membership_kind = 'owner'/,
359
+ 'the owner gate is re-asserted in the insert itself, keyed on the session');
360
+ });
361
+
362
+ test('accept: an invite accept must echo its card; a changed guild is a 409 naming the current visibility', async () => {
363
+ const pending = (kind, who) => base((t) => (/SELECT r\.kind, r\.github_id::text AS github_id/.test(t)
364
+ ? result([{ kind, github_id: String(who), visibility: 'public', actor_is_owner: false }]) : null));
365
+ on(pending('invite', ME));
366
+ const missing = await call('POST', '/api/bongos/guild-requests/12/accept', {});
367
+ assert.equal(missing.status, 400);
368
+ assert.equal(missing.json.error.code, 'guild_card_visibility_required');
369
+
370
+ on(pending('invite', ME));
371
+ const stale = await call('POST', '/api/bongos/guild-requests/12/accept', { visibility: 'unlisted' });
372
+ assert.equal(stale.status, 409);
373
+ assert.equal(stale.json.error.code, 'guild_visibility_changed');
374
+ assert.equal(stale.json.error.details.visibility, 'public', 'the card re-renders from this');
375
+ assert.ok(!calls.some((c) => /INSERT INTO platform_identity_guild_members/.test(c.text)), 'nothing joined');
376
+
377
+ on(pending('invite', 9001));
378
+ assert.equal((await call('POST', '/api/bongos/guild-requests/12/accept', { visibility: 'public' })).status, 404,
379
+ 'someone else\'s invite is 404');
380
+ });
381
+
382
+ test('accept: resolving the row and inserting the membership is ONE statement; the owner cannot set a requester\'s switch', async () => {
383
+ on(base((t) => {
384
+ if (/SELECT r\.kind, r\.github_id::text AS github_id/.test(t)) return result([{ kind: 'request', github_id: '9001', visibility: 'public', actor_is_owner: true }]);
385
+ if (/WITH acc AS/.test(t)) return result([{ accepted: 1 }]);
386
+ return null;
387
+ }));
388
+ const res = await call('POST', '/api/bongos/guild-requests/12/accept', { counted_in_totals: false, visibility: 'public' });
389
+ assert.equal(res.status, 200);
390
+ const acc = calls.find((c) => /WITH acc AS/.test(c.text));
391
+ assert.match(acc.text, /UPDATE platform_identity_guild_requests r[\s\S]*?INSERT INTO platform_identity_guild_members/,
392
+ 'accept resolves and joins in the same statement (ADR 0336 D3)');
393
+ assert.match(acc.text, /visibility = 'public',/, 'shown_publicly is the guild\'s visibility at the moment of joining');
394
+ assert.deepEqual(acc.params, ['12', ME, 30, null, null],
395
+ 'on the owner path the card and the switch are NOT the owner\'s to send — the request row carries the requester\'s');
396
+ });
397
+
398
+ test('decline is an empty 204; an id that could not be a row is 404 before any query', async () => {
399
+ on(base((t) => (/SET status = 'rejected'/.test(t) ? result([{ id: '12' }]) : null)));
400
+ const res = await call('POST', '/api/bongos/guild-requests/12/decline');
401
+ assert.equal(res.status, 204);
402
+ assert.equal(res.text, '', 'silent — nothing the other side could ever observe');
403
+
404
+ on(base());
405
+ const probe = await call('POST', '/api/bongos/guild-requests/1234567890123456789012345/decline');
406
+ assert.equal(probe.status, 404);
407
+ assert.equal(calls.length, 0, 'a 25-digit id never reaches Postgres as a 22003');
408
+ });
409
+
410
+ // ===========================================================================
411
+ // 6. Membership switches, leave, transfer (D2)
412
+ // ===========================================================================
413
+
414
+ test('membership switches: at least one field, keyed on the session', async () => {
415
+ on(base());
416
+ assert.equal((await call('PATCH', '/api/bongos/guilds/guild-a/membership', {})).json.error.code, 'nothing_to_update');
417
+ on(base((t) => (/UPDATE platform_identity_guild_members m\s+SET shown_publicly/.test(t)
418
+ ? result([{ shown_publicly: false, counted_in_totals: false }]) : null)));
419
+ const res = await call('PATCH', '/api/bongos/guilds/guild-a/membership', { counted_in_totals: false });
420
+ assert.deepEqual(res.json, { shown_publicly: false, counted_in_totals: false });
421
+ const write = calls.find((c) => /SET shown_publicly = COALESCE/.test(c.text));
422
+ assert.deepEqual(write.params, ['guild-a', ME, null, false], 'only my own row, only the field I sent');
423
+ });
424
+
425
+ test('leave: an owner with members must transfer first (409)', async () => {
426
+ on(base((t) => {
427
+ if (IS.membership(t)) return result([MEMBERSHIP('owner')]);
428
+ if (/WITH mine AS/.test(t)) return result([{ outcome: 'must_transfer' }]);
429
+ return null;
430
+ }));
431
+ const res = await call('DELETE', '/api/bongos/guilds/guild-a/membership');
432
+ assert.equal(res.status, 409);
433
+ assert.equal(res.json.error.code, 'guild_owner_must_transfer');
434
+ });
435
+
436
+ test('transfer: demote then promote, two statements on ONE client inside BEGIN/COMMIT', async () => {
437
+ on(base((t) => {
438
+ if (IS.membership(t)) return result([MEMBERSHIP('owner')]);
439
+ if (/FOR UPDATE OF m/.test(t)) return result([{ github_id: '9001' }]);
440
+ if (/SET membership_kind = 'member'/.test(t) || /SET membership_kind = 'owner'/.test(t)) return result([{ github_id: 'x' }]);
441
+ return null;
442
+ }));
443
+ const res = await call('POST', '/api/bongos/guilds/guild-a/transfer', { handle: 'Ada' });
444
+ assert.equal(res.status, 200);
445
+ const seq = calls.map((c) => c.text.trim()).filter((t) => /^(BEGIN|COMMIT|ROLLBACK)$|membership_kind = '(member|owner)'\s+WHERE/.test(t))
446
+ .map((t) => (/^[A-Z]+$/.test(t) ? t : /SET membership_kind = 'member'/.test(t) ? 'demote' : 'promote'));
447
+ assert.deepEqual(seq, ['BEGIN', 'demote', 'promote', 'COMMIT'],
448
+ 'the one-owner index is checked row by row, so the order is load-bearing (ADR 0336 D2)');
449
+ const demote = calls.find((c) => /SET membership_kind = 'member'/.test(c.text));
450
+ assert.deepEqual(demote.params, ['7', ME], 'the demoted owner is the session');
451
+ });
452
+
453
+ test('transfer: a promote that fails rolls the demotion back — a guild is never left ownerless', async () => {
454
+ on(base((t) => {
455
+ if (IS.membership(t)) return result([MEMBERSHIP('owner')]);
456
+ if (/FOR UPDATE OF m/.test(t)) return result([{ github_id: '9001' }]);
457
+ if (/SET membership_kind = 'member'/.test(t)) return result([{ github_id: 'x' }]);
458
+ return null; // the promote matches nothing
459
+ }));
460
+ const res = await call('POST', '/api/bongos/guilds/guild-a/transfer', { handle: 'ada' });
461
+ assert.equal(res.status, 404);
462
+ assert.ok(calls.some((c) => c.text === 'ROLLBACK') && !calls.some((c) => c.text === 'COMMIT'));
463
+ });
464
+
465
+ test('edit: null never 500s — a name cannot be cleared, a description can, a null visibility is no change', async () => {
466
+ on(base());
467
+ const nullName = await call('PATCH', '/api/bongos/guilds/guild-a', { name: null });
468
+ assert.equal(nullName.status, 400);
469
+ assert.equal(nullName.json.error.code, 'guild_name_required');
470
+ assert.equal((await call('PATCH', '/api/bongos/guilds/guild-a', { visibility: null })).json.error.code, 'nothing_to_update');
471
+
472
+ on(base((t) => {
473
+ if (IS.membership(t)) return result([MEMBERSHIP('owner')]);
474
+ if (/WITH own AS/.test(t)) return result([{ slug: 'guild-a', name: 'Guild A', description: null, visibility: 'unlisted' }]);
475
+ return null;
476
+ }));
477
+ const cleared = await call('PATCH', '/api/bongos/guilds/guild-a', { description: null });
478
+ assert.equal(cleared.status, 200);
479
+ const upd = calls.find((c) => /WITH own AS/.test(c.text));
480
+ assert.deepEqual(upd.params, ['7', ME, null, true, null, null], 'description cleared; name and visibility untouched');
481
+ assert.match(upd.text, /WHERE m\.guild_id = upd\.id AND m\.github_id = \$2::bigint\s+AND upd\.visibility <> upd\.old_visibility/,
482
+ 'a visibility CHANGE flips only the acting owner\'s shown_publicly (ADR 0336 D4 §3)');
483
+
484
+ on(base((t) => (IS.membership(t) ? result([MEMBERSHIP('member')]) : null)));
485
+ assert.equal((await call('PATCH', '/api/bongos/guilds/guild-a', { name: 'Hijack' })).status, 403);
486
+ });
487
+
488
+ test('a request id past the bigint range is 404 before any query, not a 22003 500', async () => {
489
+ on(base());
490
+ const res = await call('POST', '/api/bongos/guild-requests/9999999999999999999/accept', { visibility: 'public' });
491
+ assert.equal(res.status, 404);
492
+ assert.equal(calls.length, 0);
493
+ });
494
+
495
+ test('the owner\'s invite list names only invitees THIS owner could address (a transfer reveals no private invitee)', () => {
496
+ const src = guilds.listGuildRequests.toString();
497
+ assert.match(src, /one\('invite', addressable\)/, 'the addressability floor applies to the invite leg');
498
+ assert.match(src, /accountVisibleSql\('pa'\)\} OR pa\.github_id IN \(\$\{conn\.neighboursSql\('\$2'\)\}\)/,
499
+ 'public, or connected to the viewing owner — the addressableAccount rule, reusing the one symmetric-edge fragment');
500
+ });
501
+
502
+ // ===========================================================================
503
+ // 7. Nothing here is authority; counts are lengths
504
+ // ===========================================================================
505
+
506
+ test('no rank, permission or credit symbol appears anywhere in the feature (ADR 0016)', () => {
507
+ const code = (s) => s.replace(/\/\/.*$/gm, '');
508
+ for (const [name, src] of [['guilds.js', DOMAIN_SRC], ['routes/guilds.js', ROUTE_SRC], ['routes/guilds-public.js', PUBLIC_SRC]]) {
509
+ assert.doesNotMatch(code(src), /requireRank|requirePermission|total_credits|\bkarma\b|builders\.rank|access_requests/,
510
+ `${name} must stay identity-only`);
511
+ }
512
+ });
513
+
514
+ test('every list response reports a count equal to the length of its array', async () => {
515
+ on(base((t) => (/FROM platform_identity_guild_members m\s+JOIN platform_identity_guilds g/.test(t)
516
+ ? result([{ slug: 'a' }, { slug: 'b' }]) : null)));
517
+ const mine = await call('GET', '/api/bongos/me/guilds');
518
+ assert.equal(mine.json.count, mine.json.guilds.length);
519
+ assert.equal(mine.headers['cache-control'], 'no-store');
520
+ assert.equal(calls.find((c) => /JOIN platform_identity_guilds g/.test(c.text)).params[0], ME);
521
+ });