@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.
@@ -0,0 +1,705 @@
1
+ // modules/platform-identity/guilds.js — the guild domain: create, membership,
2
+ // invites and join requests, and the public/unlisted read (task 1002300, R24;
3
+ // ADR 0336 D1–D4 + D6, amended by ADR 0337 D4.4).
4
+ //
5
+ // WHAT LIVES HERE, next to the data so no route can forget a rule:
6
+ //
7
+ // * TWO CONSENTS TO JOIN, NO SELF-JOIN (D3). An owner invites and the invitee
8
+ // accepts, or a builder asks a PUBLIC guild and the owner accepts. An
9
+ // unlisted guild takes invites only, because a stranger cannot address it.
10
+ // * THE CONNECTIONS LIFECYCLE, VERBATIM (D3, ADR 0202 D2) — its exported
11
+ // constants, so the two features cannot drift: a decline is silent and a
12
+ // re-ask of the same kind inside the cooldown is SWALLOWED with the answer a
13
+ // real one gets; a pending row expires read-time and the write path resolves
14
+ // it in the same statement that inserts the next one; accepting resolves the
15
+ // row and inserts the membership in ONE statement.
16
+ // * CONSENT IS TO WHAT THE CARD SHOWED (D4 §3). shown_publicly is set by the
17
+ // guild's visibility at the moment of joining; an invite accept carries the
18
+ // visibility its card displayed and is refused if the guild changed since.
19
+ // A visibility change flips only the owner's own flag.
20
+ // * ONE OWNER (D2), held by a partial unique index. Transfer is two statements
21
+ // in one transaction — demote, then promote — because that index is checked
22
+ // row by row and cannot be deferred. The last member leaving dissolves.
23
+ // * THE PUBLIC ROSTER IS shown_publicly AND accountVisibleSql (D4): a private
24
+ // account is never on it, whatever its flag. Members read each other through
25
+ // the own-scope roster, which floors on consent alone (joining is consent to
26
+ // be seen by the guild, never by the world). Every count is the length of a
27
+ // list the reader may see.
28
+ // * counted_in_totals (ADR 0337 D4.4) is stored and switchable here; the
29
+ // totals that read it are task 1002302's.
30
+ //
31
+ // NOTHING HERE IS AUTHORITY (ADR 0016 / ADR 0141). A guild grants no rank,
32
+ // credits, admission or access to any project.
33
+ //
34
+ // Reaches core only through the doorway (ADR 0083).
35
+ 'use strict';
36
+
37
+ const api = require('../../src/module-api');
38
+ const {
39
+ accountVisibleSql, accountActiveSql, isAccountVisible, isAccountActive,
40
+ } = require('./account-visibility');
41
+ const conn = require('./connections');
42
+ const { normalizeHandle } = require('./platform-identity');
43
+
44
+ const { pool: defaultPool } = api;
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // The constants ADR 0336 named. Exported so a route, a test and a doc read the
48
+ // same number.
49
+ // ---------------------------------------------------------------------------
50
+
51
+ // Mirrors the migration's CHECK. No default anywhere: the creator chooses.
52
+ const GUILD_VISIBILITIES = ['public', 'unlisted'];
53
+
54
+ // Words the /g namespace uses (D6). A slug may never shadow them.
55
+ const GUILD_RESERVED_SLUGS = ['me', 'new', 'edit', 'settings', 'admin', 'api'];
56
+
57
+ // D6: the handle charset with a two-character floor. The ceiling (39) is the
58
+ // charset's own, and the migration's CHECK holds both.
59
+ const SLUG_MIN_CHARS = 2;
60
+
61
+ // ADR 0336 Deferred: guild names are user-written public text, so R24 caps them.
62
+ // Mirrored by the migration's CHECKs.
63
+ const NAME_MAX_CHARS = 80;
64
+ const DESCRIPTION_MAX_CHARS = 500;
65
+
66
+ // D3: a batch invite is a loop of ordinary invites, never a new kind of
67
+ // membership, and it is bounded.
68
+ const INVITE_BATCH_MAX = 25;
69
+
70
+ // The largest roster or queue one read returns.
71
+ const LIST_MAX = 500;
72
+
73
+ // The connections lifecycle's own numbers (D3), imported rather than restated.
74
+ const { REQUEST_EXPIRY_DAYS, DECLINE_COOLDOWN_DAYS } = conn;
75
+
76
+ // What a person in a guild list carries (ADR 0336 R25 row): handle, display
77
+ // name, avatar. github_id and github_login never leave this module.
78
+ const PERSON_COLUMNS = 'pa.handle, pa.display_name, pa.avatar_url';
79
+
80
+ // The owner test every owner-only statement re-asserts in its own WHERE, so a
81
+ // route's pre-check is a nicer error and never the only gate. $g/$a are the
82
+ // placeholders holding the guild id and the acting github_id.
83
+ function ownerExistsSql(g, a) {
84
+ return `EXISTS (SELECT 1 FROM platform_identity_guild_members o
85
+ WHERE o.guild_id = ${g} AND o.github_id = ${a}::bigint
86
+ AND o.membership_kind = 'owner')`;
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Slugs and addressing
91
+ // ---------------------------------------------------------------------------
92
+
93
+ // A slug offered at creation → { slug } or { error }. Canonical form only.
94
+ function normalizeGuildSlug(raw) {
95
+ const slug = normalizeHandle(raw);
96
+ if (!slug || slug.length < SLUG_MIN_CHARS) return { error: 'slug_invalid' };
97
+ if (GUILD_RESERVED_SLUGS.includes(slug)) return { error: 'slug_reserved' };
98
+ return { slug };
99
+ }
100
+
101
+ // A slug from a URL → its canonical form, or null. A reserved word simply never
102
+ // matches a row, so lookups need no separate refusal.
103
+ function lookupSlug(raw) {
104
+ return normalizeHandle(raw);
105
+ }
106
+
107
+ // Resolve a handle to an account THIS viewer may invite: publicly visible, or a
108
+ // private account the viewer is already connected to (ADR 0202 D4 §2 — the
109
+ // rule routes/connections.js visibleTarget applies, composed from the same
110
+ // shared predicates). Unknown, provisional and private-stranger are one null.
111
+ async function addressableAccount(viewerGithubId, handle, { pool = defaultPool } = {}) {
112
+ const acct = await conn.findAccountByHandle(handle, { pool });
113
+ if (!acct) return null;
114
+ if (isAccountVisible(acct)) return acct;
115
+ if (!isAccountActive(acct)) return null;
116
+ return (await conn.isConnectedTo(viewerGithubId, acct.github_id, { pool })) ? acct : null;
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Guild CRUD
121
+ // ---------------------------------------------------------------------------
122
+
123
+ // Create a guild and its owner row in ONE statement, so a guild never exists
124
+ // without its owner. The owner's shown_publicly follows the visibility they
125
+ // chose. Returns { guild } or { taken: true } when the slug is in use.
126
+ async function createGuild({ githubId, slug, name, description = null, visibility }, { pool = defaultPool } = {}) {
127
+ try {
128
+ const { rows } = await pool.query(
129
+ `WITH g AS (
130
+ INSERT INTO platform_identity_guilds (slug, name, description, visibility)
131
+ VALUES ($1, $2, $3, $4)
132
+ RETURNING id, slug, name, description, visibility, created_at
133
+ ),
134
+ owner_row AS (
135
+ INSERT INTO platform_identity_guild_members (guild_id, github_id, membership_kind, shown_publicly)
136
+ SELECT g.id, $5::bigint, 'owner', g.visibility = 'public' FROM g
137
+ RETURNING guild_id
138
+ )
139
+ SELECT g.slug, g.name, g.description, g.visibility, g.created_at
140
+ FROM g JOIN owner_row o ON o.guild_id = g.id`,
141
+ [slug, name, description, visibility, githubId],
142
+ );
143
+ return { guild: rows[0] };
144
+ } catch (err) {
145
+ // 23505 here can only be the case-insensitive slug index.
146
+ if (err && err.code === '23505') return { taken: true };
147
+ throw err;
148
+ }
149
+ }
150
+
151
+ // The caller's own membership of one guild, with the guild's details, or null.
152
+ // The own-scope door to an UNLISTED guild: a non-member gets null, the same
153
+ // answer as a guild that does not exist.
154
+ async function getMembership(slug, githubId, { pool = defaultPool } = {}) {
155
+ const { rows } = await pool.query(
156
+ `SELECT g.id, g.slug, g.name, g.description, g.visibility, g.created_at,
157
+ m.membership_kind, m.shown_publicly, m.counted_in_totals, m.joined_at
158
+ FROM platform_identity_guilds g
159
+ JOIN platform_identity_guild_members m ON m.guild_id = g.id AND m.github_id = $2::bigint
160
+ WHERE lower(g.slug) = $1`,
161
+ [slug, githubId],
162
+ );
163
+ return rows[0] || null;
164
+ }
165
+
166
+ // Edit name, description or visibility — the owner only, re-asserted here. A
167
+ // visibility CHANGE flips the owner's own shown_publicly to match and nobody
168
+ // else's (D4 §3: consent given to a small audience is not consent to a large
169
+ // one). `description` undefined leaves it; null or '' clears it.
170
+ async function updateGuild({ guildId, githubId, name, description, visibility }, { pool = defaultPool } = {}) {
171
+ const { rows } = await pool.query(
172
+ `WITH own AS (
173
+ SELECT g.id, g.visibility AS old_visibility
174
+ FROM platform_identity_guilds g
175
+ WHERE g.id = $1 AND ${ownerExistsSql('g.id', '$2')}
176
+ ),
177
+ upd AS (
178
+ UPDATE platform_identity_guilds g
179
+ SET name = COALESCE($3::text, g.name),
180
+ description = CASE WHEN $4::boolean THEN $5::text ELSE g.description END,
181
+ visibility = COALESCE($6::text, g.visibility),
182
+ updated_at = now()
183
+ FROM own
184
+ WHERE g.id = own.id
185
+ RETURNING g.id, g.slug, g.name, g.description, g.visibility, own.old_visibility
186
+ ),
187
+ flip AS (
188
+ UPDATE platform_identity_guild_members m
189
+ SET shown_publicly = (upd.visibility = 'public')
190
+ FROM upd
191
+ WHERE m.guild_id = upd.id AND m.github_id = $2::bigint
192
+ AND upd.visibility <> upd.old_visibility
193
+ RETURNING m.guild_id
194
+ )
195
+ SELECT slug, name, description, visibility FROM upd`,
196
+ [guildId, githubId, name ?? null, description !== undefined, description || null, visibility ?? null],
197
+ );
198
+ return rows[0] || null;
199
+ }
200
+
201
+ // Dissolve — the owner only. Hard delete; the FKs cascade to every membership
202
+ // and open request (D2).
203
+ async function dissolveGuild({ guildId, githubId }, { pool = defaultPool } = {}) {
204
+ const { rowCount } = await pool.query(
205
+ `DELETE FROM platform_identity_guilds g
206
+ WHERE g.id = $1 AND ${ownerExistsSql('g.id', '$2')}
207
+ RETURNING g.id`,
208
+ [guildId, githubId],
209
+ );
210
+ return rowCount > 0;
211
+ }
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Membership
215
+ // ---------------------------------------------------------------------------
216
+
217
+ // The caller's guilds, newest first. Own data, so no account join.
218
+ async function listMyGuilds({ githubId, limit = LIST_MAX, offset = 0 }, { pool = defaultPool } = {}) {
219
+ const { rows } = await pool.query(
220
+ `SELECT g.slug, g.name, g.visibility,
221
+ m.membership_kind, m.shown_publicly, m.counted_in_totals, m.joined_at
222
+ FROM platform_identity_guild_members m
223
+ JOIN platform_identity_guilds g ON g.id = m.guild_id
224
+ WHERE m.github_id = $1::bigint
225
+ ORDER BY m.joined_at DESC, g.id DESC
226
+ LIMIT $2 OFFSET $3`,
227
+ [githubId, Math.min(Number(limit) || LIST_MAX, LIST_MAX), Math.max(0, Number(offset) || 0)],
228
+ );
229
+ return rows;
230
+ }
231
+
232
+ // The members' roster — the OWN-SCOPE read a member reaches their guild through
233
+ // (D4). Floors on consent alone: a private member is visible to fellow members,
234
+ // because joining is consent to be seen by the guild. Neither shown_publicly nor
235
+ // counted_in_totals of anyone else is returned — both are that member's own call.
236
+ async function listMemberRoster({ guildId, limit = LIST_MAX, offset = 0 }, { pool = defaultPool } = {}) {
237
+ const { rows } = await pool.query(
238
+ `SELECT ${PERSON_COLUMNS}, m.membership_kind
239
+ FROM platform_identity_guild_members m
240
+ JOIN platform_identity_accounts pa ON pa.github_id = m.github_id
241
+ WHERE m.guild_id = $1 AND ${accountActiveSql('pa')}
242
+ ORDER BY (m.membership_kind = 'owner') DESC, lower(pa.handle), pa.github_id
243
+ LIMIT $2 OFFSET $3`,
244
+ [guildId, Math.min(Number(limit) || LIST_MAX, LIST_MAX), Math.max(0, Number(offset) || 0)],
245
+ );
246
+ return rows;
247
+ }
248
+
249
+ // A member's own switches: shown_publicly (D4) and counted_in_totals (ADR 0337
250
+ // D4.4). Each is optional; null leaves it. Keyed on the session's github_id, so
251
+ // nobody can flip anybody else's. Returns the new flags, or null if not a member.
252
+ async function setMembershipFlags({ slug, githubId, shownPublicly = null, countedInTotals = null }, { pool = defaultPool } = {}) {
253
+ const { rows } = await pool.query(
254
+ `UPDATE platform_identity_guild_members m
255
+ SET shown_publicly = COALESCE($3::boolean, m.shown_publicly),
256
+ counted_in_totals = COALESCE($4::boolean, m.counted_in_totals)
257
+ FROM platform_identity_guilds g
258
+ WHERE g.id = m.guild_id AND lower(g.slug) = $1 AND m.github_id = $2::bigint
259
+ RETURNING m.shown_publicly, m.counted_in_totals`,
260
+ [slug, githubId, shownPublicly, countedInTotals],
261
+ );
262
+ return rows[0] || null;
263
+ }
264
+
265
+ // Leave. A member leaves; the owner must transfer first, unless they are the
266
+ // last member, in which case leaving DISSOLVES the guild (D2). One statement.
267
+ // `mine` locks the caller's row, so a leave racing a transfer that is promoting
268
+ // them waits and re-reads it as 'owner' (must_transfer) instead of deleting the
269
+ // new owner from a stale snapshot; left_row re-checks the kind for the same
270
+ // reason. The accepted request row that admitted them goes too: kept, it would
271
+ // be a record that they were ever here (the D2 no-tombstone reason).
272
+ // Returns 'left' | 'dissolved' | 'must_transfer' | null (not a member).
273
+ async function leaveGuild({ guildId, githubId }, { pool = defaultPool } = {}) {
274
+ const { rows } = await pool.query(
275
+ `WITH mine AS (
276
+ SELECT membership_kind FROM platform_identity_guild_members
277
+ WHERE guild_id = $1 AND github_id = $2::bigint
278
+ FOR UPDATE
279
+ ),
280
+ others AS (
281
+ SELECT EXISTS (SELECT 1 FROM platform_identity_guild_members
282
+ WHERE guild_id = $1 AND github_id <> $2::bigint) AS has_others
283
+ ),
284
+ left_row AS (
285
+ DELETE FROM platform_identity_guild_members m
286
+ USING mine
287
+ WHERE m.guild_id = $1 AND m.github_id = $2::bigint
288
+ AND m.membership_kind = 'member' AND mine.membership_kind = 'member'
289
+ RETURNING 'left'::text AS outcome
290
+ ),
291
+ purged AS (
292
+ DELETE FROM platform_identity_guild_requests r
293
+ WHERE r.guild_id = $1 AND r.github_id = $2::bigint AND r.status = 'accepted'
294
+ AND EXISTS (SELECT 1 FROM left_row)
295
+ RETURNING r.id
296
+ ),
297
+ dissolved AS (
298
+ DELETE FROM platform_identity_guilds g
299
+ USING mine, others
300
+ WHERE g.id = $1 AND mine.membership_kind = 'owner' AND NOT others.has_others
301
+ RETURNING 'dissolved'::text AS outcome
302
+ )
303
+ SELECT outcome FROM left_row
304
+ UNION ALL SELECT outcome FROM dissolved
305
+ UNION ALL SELECT 'must_transfer'::text FROM mine, others
306
+ WHERE mine.membership_kind = 'owner' AND others.has_others`,
307
+ [guildId, githubId],
308
+ );
309
+ return rows[0] ? rows[0].outcome : null;
310
+ }
311
+
312
+ // Remove a member — the owner only, and never the owner themselves (they leave
313
+ // or dissolve). Hard delete (D2), taking the accepted request row with it, as
314
+ // leaveGuild does. The member is named by current handle, resolved on the same
315
+ // consent floor as the members' roster the owner picked them from.
316
+ async function removeMember({ guildId, ownerGithubId, handle }, { pool = defaultPool } = {}) {
317
+ const { rows } = await pool.query(
318
+ `WITH gone AS (
319
+ DELETE FROM platform_identity_guild_members m
320
+ USING platform_identity_accounts pa
321
+ WHERE m.guild_id = $1 AND m.membership_kind = 'member'
322
+ AND pa.github_id = m.github_id AND lower(pa.handle) = $3
323
+ AND ${accountActiveSql('pa')}
324
+ AND ${ownerExistsSql('$1', '$2')}
325
+ RETURNING m.guild_id, m.github_id
326
+ ),
327
+ purged AS (
328
+ DELETE FROM platform_identity_guild_requests r
329
+ USING gone
330
+ WHERE r.guild_id = gone.guild_id AND r.github_id = gone.github_id AND r.status = 'accepted'
331
+ RETURNING r.id
332
+ )
333
+ SELECT count(*)::int AS removed FROM gone`,
334
+ [guildId, ownerGithubId, handle],
335
+ );
336
+ return !!rows[0] && rows[0].removed > 0;
337
+ }
338
+
339
+ class TransferAborted extends Error {}
340
+
341
+ // Transfer ownership to an existing member: TWO statements in ONE transaction,
342
+ // demote then promote (D2). A single UPDATE swapping both rows would pass or fail
343
+ // on the order Postgres visits them, because the one-owner index is checked row
344
+ // by row and a partial index cannot be deferred. The target row is locked first
345
+ // so it cannot leave between the two. Returns 'transferred' | 'not_member' | 'not_owner'.
346
+ async function transferOwnership({ guildId, ownerGithubId, handle }, { pool = defaultPool } = {}) {
347
+ try {
348
+ return await api.withTx(async (client) => {
349
+ const target = await client.query(
350
+ `SELECT m.github_id FROM platform_identity_guild_members m
351
+ JOIN platform_identity_accounts pa ON pa.github_id = m.github_id
352
+ WHERE m.guild_id = $1 AND m.membership_kind = 'member'
353
+ AND lower(pa.handle) = $2 AND ${accountActiveSql('pa')}
354
+ FOR UPDATE OF m`,
355
+ [guildId, handle],
356
+ );
357
+ if (!target.rows[0]) return 'not_member';
358
+ const demoted = await client.query(
359
+ `UPDATE platform_identity_guild_members SET membership_kind = 'member'
360
+ WHERE guild_id = $1 AND github_id = $2::bigint AND membership_kind = 'owner'
361
+ RETURNING github_id`,
362
+ [guildId, ownerGithubId],
363
+ );
364
+ if (!demoted.rowCount) return 'not_owner';
365
+ const promoted = await client.query(
366
+ `UPDATE platform_identity_guild_members SET membership_kind = 'owner'
367
+ WHERE guild_id = $1 AND github_id = $2::bigint AND membership_kind = 'member'
368
+ RETURNING github_id`,
369
+ [guildId, target.rows[0].github_id],
370
+ );
371
+ // Unreachable while the row lock holds, but a guild must never be left
372
+ // ownerless: throwing rolls the demotion back.
373
+ if (!promoted.rowCount) throw new TransferAborted();
374
+ return 'transferred';
375
+ }, { pool });
376
+ } catch (err) {
377
+ if (err instanceof TransferAborted) return 'not_member';
378
+ throw err;
379
+ }
380
+ }
381
+
382
+ // ---------------------------------------------------------------------------
383
+ // Invites and join requests
384
+ // ---------------------------------------------------------------------------
385
+
386
+ // ONE statement for both directions, the connections requestConnection shape:
387
+ // `expired` resolves a timed-out pending row of this pair and is referenced
388
+ // through `guard`, so it is drained BEFORE the INSERT (an unreferenced
389
+ // data-modifying CTE runs after it and would hit the open-row index); the
390
+ // cooldown NOT EXISTS inserts nothing inside the window, which is how a
391
+ // swallowed re-ask writes nothing while answering yes. `gate` is a static
392
+ // fragment naming who may open this kind of row.
393
+ function openRequestSql(gate) {
394
+ return `
395
+ WITH expired AS (
396
+ UPDATE platform_identity_guild_requests r
397
+ SET status = 'expired', resolved_at = now()
398
+ WHERE r.guild_id = $1 AND r.github_id = $2::bigint AND r.status = 'pending'
399
+ AND r.created_at <= now() - make_interval(days => $4::int)
400
+ RETURNING r.id
401
+ ),
402
+ guard AS (SELECT count(*)::int AS expired_count FROM expired)
403
+ INSERT INTO platform_identity_guild_requests (guild_id, github_id, kind, counted_in_totals)
404
+ SELECT $1::bigint, $2::bigint, $3::text, $6::boolean
405
+ FROM guard
406
+ WHERE ${gate}
407
+ AND NOT EXISTS (SELECT 1 FROM platform_identity_guild_members m
408
+ WHERE m.guild_id = $1 AND m.github_id = $2::bigint)
409
+ AND NOT EXISTS (SELECT 1 FROM platform_identity_guild_requests r
410
+ WHERE r.guild_id = $1 AND r.github_id = $2::bigint AND r.kind = $3::text
411
+ AND r.status = 'rejected'
412
+ AND r.resolved_at > now() - make_interval(days => $5::int))
413
+ RETURNING id`;
414
+ }
415
+
416
+ // Run an open-request insert → 'created' | 'swallowed' | 'already_open'. The
417
+ // route must answer 'created' and 'swallowed' identically.
418
+ async function runOpenRequest(sql, params, pool) {
419
+ try {
420
+ const { rowCount } = await pool.query(sql, params);
421
+ return rowCount > 0 ? 'created' : 'swallowed';
422
+ } catch (err) {
423
+ // The open-row index: an invite or a request is already pending for this
424
+ // (guild, person). Safe to name — it is in the caller's own queue already.
425
+ if (err && err.code === '23505') return 'already_open';
426
+ throw err;
427
+ }
428
+ }
429
+
430
+ async function isMember(guildId, githubId, pool) {
431
+ const { rowCount } = await pool.query(
432
+ `SELECT 1 FROM platform_identity_guild_members WHERE guild_id = $1 AND github_id = $2::bigint`,
433
+ [guildId, githubId],
434
+ );
435
+ return rowCount > 0;
436
+ }
437
+
438
+ // A builder asks to join. Only a PUBLIC guild takes requests (D3/D4), so an
439
+ // unlisted slug and an unknown one are the same 'not_found'. Joining a public
440
+ // guild is consent to be shown there, so the request needs no card check; the
441
+ // requester's counted_in_totals choice rides the row until the owner accepts.
442
+ // Returns { outcome: 'created' | 'swallowed' | 'already_open' | 'already_member' | 'not_found' }.
443
+ async function requestToJoin({ slug, githubId, countedInTotals = true }, { pool = defaultPool } = {}) {
444
+ const { rows } = await pool.query(
445
+ `SELECT id FROM platform_identity_guilds WHERE lower(slug) = $1 AND visibility = 'public'`,
446
+ [slug],
447
+ );
448
+ const guild = rows[0];
449
+ if (!guild) return { outcome: 'not_found' };
450
+ if (await isMember(guild.id, githubId, pool)) return { outcome: 'already_member' };
451
+ const gate = `EXISTS (SELECT 1 FROM platform_identity_guilds g WHERE g.id = $1 AND g.visibility = 'public')`;
452
+ const outcome = await runOpenRequest(openRequestSql(gate), [
453
+ guild.id, githubId, 'request', REQUEST_EXPIRY_DAYS, DECLINE_COOLDOWN_DAYS, countedInTotals !== false,
454
+ ], pool);
455
+ return { outcome };
456
+ }
457
+
458
+ // The owner invites up to INVITE_BATCH_MAX handles, each an ordinary individual
459
+ // invite (D3). Per handle: 'invited' (written OR swallowed by a decline
460
+ // cooldown — deliberately one answer), 'already_open', 'already_member', or
461
+ // 'not_found' (unknown, provisional, or a private account the owner is not
462
+ // connected to — one answer for all three).
463
+ async function inviteHandles({ guildId, ownerGithubId, handles }, { pool = defaultPool } = {}) {
464
+ const seen = new Set();
465
+ const results = [];
466
+ const sql = openRequestSql(ownerExistsSql('$1', '$7'));
467
+ for (const raw of handles.slice(0, INVITE_BATCH_MAX)) {
468
+ const handle = String(raw || '').trim().toLowerCase();
469
+ if (!handle || seen.has(handle)) continue;
470
+ seen.add(handle);
471
+ const target = await addressableAccount(ownerGithubId, handle, { pool });
472
+ if (!target) { results.push({ handle, outcome: 'not_found' }); continue; }
473
+ if (String(target.github_id) === String(ownerGithubId) || await isMember(guildId, target.github_id, pool)) {
474
+ results.push({ handle, outcome: 'already_member' });
475
+ continue;
476
+ }
477
+ const out = await runOpenRequest(sql, [
478
+ guildId, target.github_id, 'invite', REQUEST_EXPIRY_DAYS, DECLINE_COOLDOWN_DAYS, true, ownerGithubId,
479
+ ], pool);
480
+ results.push({ handle, outcome: out === 'already_open' ? 'already_open' : 'invited' });
481
+ }
482
+ return results;
483
+ }
484
+
485
+ // Accept an invite (the invitee) or a join request (the guild's owner).
486
+ //
487
+ // The invitee's accept carries the visibility their card showed; if the guild's
488
+ // visibility has changed since, it is refused ('visibility_changed') so the
489
+ // card can re-render for a fresh decision (D4 §3). They may also set their
490
+ // counted_in_totals there (ADR 0337 D4.4). An owner accepting a request cannot
491
+ // set the requester's switch: that comes from the request row.
492
+ //
493
+ // The accept itself is ONE statement: resolve the row and insert the membership,
494
+ // every condition re-asserted in its WHERE. shown_publicly is the guild's
495
+ // visibility at this moment. Returns { outcome: 'accepted' | 'not_found' |
496
+ // 'card_required' | 'visibility_changed', visibility? }.
497
+ async function acceptRequest({ id, githubId, cardVisibility = null, countedInTotals = null }, { pool = defaultPool } = {}) {
498
+ const pre = await pool.query(
499
+ `SELECT r.kind, r.github_id::text AS github_id, g.visibility,
500
+ ${ownerExistsSql('r.guild_id', '$2')} AS actor_is_owner
501
+ FROM platform_identity_guild_requests r
502
+ JOIN platform_identity_guilds g ON g.id = r.guild_id
503
+ WHERE r.id = $1 AND r.status = 'pending'
504
+ AND r.created_at > now() - make_interval(days => $3::int)`,
505
+ [id, githubId, REQUEST_EXPIRY_DAYS],
506
+ );
507
+ const row = pre.rows[0];
508
+ const asInvitee = !!row && row.kind === 'invite' && row.github_id === String(githubId);
509
+ const asOwner = !!row && row.kind === 'request' && row.actor_is_owner === true;
510
+ if (!asInvitee && !asOwner) return { outcome: 'not_found' };
511
+ if (asInvitee) {
512
+ if (!cardVisibility) return { outcome: 'card_required' };
513
+ if (cardVisibility !== row.visibility) return { outcome: 'visibility_changed', visibility: row.visibility };
514
+ }
515
+ const { rows } = await pool.query(
516
+ `WITH acc AS (
517
+ UPDATE platform_identity_guild_requests r
518
+ SET status = 'accepted', resolved_at = now()
519
+ FROM platform_identity_guilds g
520
+ WHERE r.id = $1 AND g.id = r.guild_id AND r.status = 'pending'
521
+ AND r.created_at > now() - make_interval(days => $3::int)
522
+ AND ((r.kind = 'invite' AND r.github_id = $2::bigint AND g.visibility = $4::text)
523
+ OR (r.kind = 'request' AND ${ownerExistsSql('r.guild_id', '$2')}
524
+ AND EXISTS (SELECT 1 FROM platform_identity_accounts pa
525
+ WHERE pa.github_id = r.github_id AND ${accountActiveSql('pa')})))
526
+ RETURNING r.guild_id, r.github_id, r.kind, r.counted_in_totals, g.visibility
527
+ ),
528
+ joined AS (
529
+ INSERT INTO platform_identity_guild_members
530
+ (guild_id, github_id, membership_kind, shown_publicly, counted_in_totals)
531
+ SELECT guild_id, github_id, 'member', visibility = 'public',
532
+ CASE WHEN kind = 'invite' THEN COALESCE($5::boolean, counted_in_totals)
533
+ ELSE counted_in_totals END
534
+ FROM acc
535
+ ON CONFLICT (guild_id, github_id) DO NOTHING
536
+ RETURNING guild_id
537
+ )
538
+ SELECT (SELECT count(*)::int FROM acc) AS accepted`,
539
+ [id, githubId, REQUEST_EXPIRY_DAYS, asInvitee ? cardVisibility : null, asInvitee ? countedInTotals : null],
540
+ );
541
+ return { outcome: rows[0] && rows[0].accepted > 0 ? 'accepted' : 'not_found' };
542
+ }
543
+
544
+ // Decline — the invitee an invite, the owner a request. SILENT: the other side
545
+ // is never told, and the kept 'rejected' row IS the cooldown.
546
+ async function declineRequest({ id, githubId }, { pool = defaultPool } = {}) {
547
+ const { rowCount } = await pool.query(
548
+ `UPDATE platform_identity_guild_requests r
549
+ SET status = 'rejected', resolved_at = now()
550
+ WHERE r.id = $1 AND r.status = 'pending'
551
+ AND r.created_at > now() - make_interval(days => $3::int)
552
+ AND ((r.kind = 'invite' AND r.github_id = $2::bigint)
553
+ OR (r.kind = 'request' AND ${ownerExistsSql('r.guild_id', '$2')}))
554
+ RETURNING r.id`,
555
+ [id, githubId, REQUEST_EXPIRY_DAYS],
556
+ );
557
+ return rowCount > 0;
558
+ }
559
+
560
+ // Cancel — whoever opened the row: the requester their request, the owner an
561
+ // invite. A cancel never arms a cooldown, so the other side cannot use it to
562
+ // dodge a decline.
563
+ async function cancelRequest({ id, githubId }, { pool = defaultPool } = {}) {
564
+ const { rowCount } = await pool.query(
565
+ `UPDATE platform_identity_guild_requests r
566
+ SET status = 'cancelled', resolved_at = now()
567
+ WHERE r.id = $1 AND r.status = 'pending'
568
+ AND ((r.kind = 'request' AND r.github_id = $2::bigint)
569
+ OR (r.kind = 'invite' AND ${ownerExistsSql('r.guild_id', '$2')}))
570
+ RETURNING r.id`,
571
+ [id, githubId],
572
+ );
573
+ return rowCount > 0;
574
+ }
575
+
576
+ // Split a two-direction queue: the incoming side keeps seen_at (and gets it
577
+ // stamped); the outgoing side never sees it — a decline is silent, and so is a
578
+ // read (the connections listRequests rule).
579
+ function splitQueue(rows, incomingKind) {
580
+ const incoming = rows.filter((r) => r.kind === incomingKind).map(({ kind, ...r }) => r);
581
+ const outgoing = rows.filter((r) => r.kind !== incomingKind).map(({ kind, seen_at, ...r }) => r);
582
+ return { incoming, outgoing, unseen: incoming.filter((r) => !r.seen_at).map((r) => r.id) };
583
+ }
584
+
585
+ // The caller's own queue: invites addressed to them (each carrying the card —
586
+ // the guild's name, description and CURRENT visibility, which the accept must
587
+ // echo back) and the join requests they sent. Each direction has its own cap.
588
+ async function listMyRequests({ githubId }, { pool = defaultPool } = {}) {
589
+ const one = (kind) => `
590
+ SELECT r.id::text AS id, r.kind, r.created_at, r.seen_at,
591
+ g.slug, g.name, g.description, g.visibility
592
+ FROM platform_identity_guild_requests r
593
+ JOIN platform_identity_guilds g ON g.id = r.guild_id
594
+ WHERE r.github_id = $1::bigint AND r.kind = '${kind}' AND r.status = 'pending'
595
+ AND r.created_at > now() - make_interval(days => $2::int)
596
+ ORDER BY r.created_at DESC, r.id DESC
597
+ LIMIT $3`;
598
+ const { rows } = await pool.query(
599
+ `(${one('invite')}) UNION ALL (${one('request')})`,
600
+ [githubId, REQUEST_EXPIRY_DAYS, LIST_MAX],
601
+ );
602
+ const { incoming, outgoing, unseen } = splitQueue(rows, 'invite');
603
+ if (unseen.length) {
604
+ await pool.query(
605
+ `UPDATE platform_identity_guild_requests SET seen_at = now()
606
+ WHERE id = ANY($1::bigint[]) AND github_id = $2::bigint AND kind = 'invite' AND seen_at IS NULL`,
607
+ [unseen, githubId],
608
+ );
609
+ }
610
+ return { incoming, outgoing };
611
+ }
612
+
613
+ // The owner's queue for one guild: join requests to decide, and invites still
614
+ // out. A requester floors on consent alone — they addressed the guild, and a
615
+ // request must name who is asking or it cannot be decided. An INVITEE consented
616
+ // to nothing yet, so they are listed only if THIS owner could address them
617
+ // (public, or connected — the addressableAccount rule): after a transfer, a
618
+ // private account the previous owner invited is not revealed to the new one.
619
+ // Owner re-asserted in the statement.
620
+ async function listGuildRequests({ guildId, ownerGithubId }, { pool = defaultPool } = {}) {
621
+ const one = (kind, extra = '') => `
622
+ SELECT r.id::text AS id, r.kind, r.created_at, r.seen_at, ${PERSON_COLUMNS}
623
+ FROM platform_identity_guild_requests r
624
+ JOIN platform_identity_accounts pa ON pa.github_id = r.github_id
625
+ WHERE r.guild_id = $1 AND r.kind = '${kind}' AND r.status = 'pending'
626
+ AND r.created_at > now() - make_interval(days => $3::int)
627
+ AND ${ownerExistsSql('$1', '$2')}
628
+ AND ${accountActiveSql('pa')}${extra}
629
+ ORDER BY r.created_at DESC, r.id DESC
630
+ LIMIT $4`;
631
+ const addressable = `
632
+ AND (${accountVisibleSql('pa')} OR pa.github_id IN (${conn.neighboursSql('$2')}))`;
633
+ const { rows } = await pool.query(
634
+ `(${one('request')}) UNION ALL (${one('invite', addressable)})`,
635
+ [guildId, ownerGithubId, REQUEST_EXPIRY_DAYS, LIST_MAX],
636
+ );
637
+ const { incoming, outgoing, unseen } = splitQueue(rows, 'request');
638
+ if (unseen.length) {
639
+ await pool.query(
640
+ `UPDATE platform_identity_guild_requests SET seen_at = now()
641
+ WHERE id = ANY($1::bigint[]) AND guild_id = $2 AND kind = 'request' AND seen_at IS NULL`,
642
+ [unseen, guildId],
643
+ );
644
+ }
645
+ return { incoming, outgoing };
646
+ }
647
+
648
+ // ---------------------------------------------------------------------------
649
+ // The public read (D4)
650
+ // ---------------------------------------------------------------------------
651
+
652
+ // A PUBLIC guild's page data, or null. An unlisted guild answers null to
653
+ // everyone, its own members included — the read takes no viewer, so it cannot
654
+ // vary by one. The roster is shown_publicly AND accountVisibleSql, ordered only
655
+ // by what it publishes (ADR 0255), capped at LIST_MAX; `size` is the length of
656
+ // the list returned, never a total beside it (D4: there is no "+N more").
657
+ async function getPublicGuild(slug, { pool = defaultPool } = {}) {
658
+ const g = await pool.query(
659
+ `SELECT id, slug, name, description, created_at
660
+ FROM platform_identity_guilds
661
+ WHERE lower(slug) = $1 AND visibility = 'public'`,
662
+ [slug],
663
+ );
664
+ const guild = g.rows[0];
665
+ if (!guild) return null;
666
+ const { rows } = await pool.query(
667
+ `SELECT ${PERSON_COLUMNS}, m.membership_kind
668
+ FROM platform_identity_guild_members m
669
+ JOIN platform_identity_accounts pa ON pa.github_id = m.github_id
670
+ WHERE m.guild_id = $1 AND m.shown_publicly AND ${accountVisibleSql('pa')}
671
+ ORDER BY (m.membership_kind = 'owner') DESC, lower(pa.handle)
672
+ LIMIT $2`,
673
+ [guild.id, LIST_MAX],
674
+ );
675
+ const { id, ...shown } = guild;
676
+ return { guild: shown, members: rows, size: rows.length };
677
+ }
678
+
679
+ module.exports = {
680
+ GUILD_VISIBILITIES,
681
+ NAME_MAX_CHARS,
682
+ DESCRIPTION_MAX_CHARS,
683
+ INVITE_BATCH_MAX,
684
+ LIST_MAX,
685
+ normalizeGuildSlug,
686
+ lookupSlug,
687
+ createGuild,
688
+ getMembership,
689
+ updateGuild,
690
+ dissolveGuild,
691
+ listMyGuilds,
692
+ listMemberRoster,
693
+ setMembershipFlags,
694
+ leaveGuild,
695
+ removeMember,
696
+ transferOwnership,
697
+ requestToJoin,
698
+ inviteHandles,
699
+ acceptRequest,
700
+ declineRequest,
701
+ cancelRequest,
702
+ listMyRequests,
703
+ listGuildRequests,
704
+ getPublicGuild,
705
+ };