@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,393 @@
1
+ // modules/platform-identity/routes/guilds.js — the guild API, own-scope half
2
+ // (task 1002300, R24; ADR 0336 D2–D4 + D6, ADR 0337 D4.4). The one PUBLIC read,
3
+ // GET /guilds/:slug, lives in routes/guilds-public.js, because every route in
4
+ // this file sits behind requireBuilder and tests/own_scope_guard.mjs holds it.
5
+ //
6
+ // EVERY ROUTE HERE IS OWN-SCOPED. The acting account is always
7
+ // req.builder.github_id. A guild is named by slug, a person by public handle,
8
+ // an invite or request by its row id — none of them is a way to act AS someone
9
+ // else. Owner-only acts are re-asserted inside each statement, so the route's
10
+ // pre-check is a clearer error, never the only gate.
11
+ //
12
+ // POST /guilds create {slug, name, description?, visibility}
13
+ // GET /me/guilds my guilds, with my own two switches
14
+ // GET /me/guild-requests invites to me (the card) + requests I sent
15
+ // GET /guilds/:slug/members the members' roster (members only; 404 else)
16
+ // PATCH /guilds/:slug/membership my switches {shown_publicly?, counted_in_totals?}
17
+ // DELETE /guilds/:slug/membership leave (the owner transfers first, or is last and dissolves)
18
+ // POST /guilds/:slug/requests ask to join a PUBLIC guild {counted_in_totals?}
19
+ // PATCH /guilds/:slug owner: {name?, description?, visibility?}
20
+ // DELETE /guilds/:slug owner: dissolve
21
+ // POST /guilds/:slug/invites owner: invite {handles: [≤25]}
22
+ // GET /guilds/:slug/requests owner: join requests + invites still out
23
+ // DELETE /guilds/:slug/members/:handle owner: remove a member
24
+ // POST /guilds/:slug/transfer owner: hand ownership to a member {handle}
25
+ // POST /guild-requests/:id/accept invitee {visibility (the card's), counted_in_totals?} or owner
26
+ // POST /guild-requests/:id/decline invitee or owner; SILENT 204
27
+ // DELETE /guild-requests/:id whoever opened it: cancel
28
+ //
29
+ // A GUILD IS NEVER AUTHORITY (ADR 0016 / ADR 0141). no-store everywhere: these
30
+ // payloads are privacy-filtered per viewer (ADR 0171 D5).
31
+ //
32
+ // Reaches core only through the doorway (ADR 0083).
33
+ 'use strict';
34
+
35
+ const express = require('express');
36
+ const api = require('../../../src/module-api');
37
+ const pi = require('../platform-identity');
38
+ const guilds = require('../guilds');
39
+ const { isAccountActive } = require('../account-visibility');
40
+
41
+ const { pool, requireBuilder, validateOrRespond, parsePagination } = api;
42
+ const log = api.logger('platform-identity');
43
+
44
+ // A request row id is a bigserial. Anything else is 404, not 400: an id past the
45
+ // bigint range would reach Postgres as a 22003 and answer 500, and a different
46
+ // answer for a different id is a probe (the routes/connections.js rule, with the
47
+ // range checked exactly rather than by digit count).
48
+ const BIGINT_MAX = 9223372036854775807n;
49
+ function requestId(req) {
50
+ const raw = String(req.params.id || '');
51
+ return /^\d{1,19}$/.test(raw) && BigInt(raw) <= BIGINT_MAX ? raw : null;
52
+ }
53
+
54
+ // The caller's platform account, or a { fail }. The consent axis floors the
55
+ // whole feature (ADR 0336 D3): a provisional or terms-less account cannot
56
+ // create, ask, accept or appear anywhere.
57
+ async function actingAccount(builder) {
58
+ const githubId = builder && builder.github_id;
59
+ if (!pi.isNumericGithubId(githubId)) {
60
+ return { fail: ['no_platform_identity', { status: 400, message: 'this session has no platform account' }] };
61
+ }
62
+ const acct = await pi.getAccount(githubId, { pool });
63
+ if (!isAccountActive(acct)) {
64
+ return { fail: ['profile_provisional', { status: 403, message: 'sign in to the platform once to activate your profile before using guilds' }] };
65
+ }
66
+ return { githubId };
67
+ }
68
+
69
+ // The caller's membership of the guild in the path, or a { fail }. A non-member
70
+ // gets the same 404 as a guild that does not exist, so no guild route confirms
71
+ // an unlisted guild to a stranger. (One honest limit, inherent to a global slug:
72
+ // POST /guilds with a taken slug must answer "taken", unlisted or not.)
73
+ async function memberOf(me, rawSlug) {
74
+ const slug = guilds.lookupSlug(rawSlug);
75
+ const guild = slug ? await guilds.getMembership(slug, me.githubId, { pool }) : null;
76
+ if (!guild) return { fail: ['guild_not_found', 404] };
77
+ return { guild, slug };
78
+ }
79
+
80
+ // memberOf, narrowed to the owner. A member already knows the guild exists, so
81
+ // they may be told plainly that the act is the owner's.
82
+ async function ownerOf(me, rawSlug) {
83
+ const out = await memberOf(me, rawSlug);
84
+ if (out.fail) return out;
85
+ if (out.guild.membership_kind !== 'owner') {
86
+ return { fail: ['guild_owner_only', { status: 403, message: 'only the guild owner can do that' }] };
87
+ }
88
+ return out;
89
+ }
90
+
91
+ // The guild fields a member-facing response carries. The row id stays internal.
92
+ function guildView(g) {
93
+ return { slug: g.slug, name: g.name, description: g.description, visibility: g.visibility };
94
+ }
95
+
96
+ // One try/catch shape for every handler: the act, or a logged 500.
97
+ function handle(label, code, fn) {
98
+ return async (req, res) => {
99
+ try {
100
+ await fn(req, res);
101
+ } catch (err) {
102
+ log.error({ err: err && err.message }, `${label} failed`);
103
+ res.fail(code, { status: 500, message: 'internal error' });
104
+ }
105
+ };
106
+ }
107
+
108
+ const COUNTED = { type: 'boolean' };
109
+
110
+ module.exports = function guildRoutes() {
111
+ const router = express.Router();
112
+
113
+ router.use(['/guilds', '/me/guilds', '/me/guild-requests', '/guild-requests'], (req, res, next) => {
114
+ res.set('Cache-Control', 'no-store');
115
+ next();
116
+ });
117
+
118
+ // rank: any-builder — create a guild. The creator becomes its one owner, and
119
+ // chooses its visibility: there is no default (ADR 0336 D1).
120
+ router.post('/guilds', requireBuilder, handle('POST /guilds', 'guild_create_failed', async (req, res) => {
121
+ if (validateOrRespond(req, res, {
122
+ slug: { required: true, type: 'string', minLength: 1, maxLength: 39 },
123
+ name: { required: true, type: 'string', minLength: 1, maxLength: guilds.NAME_MAX_CHARS },
124
+ description: { type: 'string', maxLength: guilds.DESCRIPTION_MAX_CHARS },
125
+ visibility: { required: true, type: 'string', enum: guilds.GUILD_VISIBILITIES },
126
+ })) return;
127
+ const me = await actingAccount(req.builder);
128
+ if (me.fail) return res.fail(...me.fail);
129
+ const s = guilds.normalizeGuildSlug(req.body.slug);
130
+ if (s.error === 'slug_reserved') return res.fail('guild_slug_reserved', { status: 400, message: 'that slug is reserved' });
131
+ if (s.error) return res.fail('guild_slug_invalid', { status: 400, message: 'a slug is 2-39 lowercase letters, digits or inner hyphens' });
132
+ const name = req.body.name.trim();
133
+ if (!name) return res.fail('guild_name_required', { status: 400, message: 'a guild needs a name' });
134
+ const out = await guilds.createGuild({
135
+ githubId: me.githubId, slug: s.slug, name,
136
+ description: (req.body.description || '').trim() || null,
137
+ visibility: req.body.visibility,
138
+ }, { pool });
139
+ if (out.taken) return res.fail('guild_slug_taken', { status: 409, message: 'that slug is taken' });
140
+ res.status(201).json({ guild: out.guild });
141
+ }));
142
+
143
+ // rank: any-builder — MY guilds, each with my own two switches. count is the
144
+ // array's own length.
145
+ router.get('/me/guilds', requireBuilder, handle('GET /me/guilds', 'guilds_read_failed', async (req, res) => {
146
+ const me = await actingAccount(req.builder);
147
+ if (me.fail) return res.fail(...me.fail);
148
+ const { limit, offset } = parsePagination(req, { defaultLimit: guilds.LIST_MAX, maxLimit: guilds.LIST_MAX });
149
+ const mine = await guilds.listMyGuilds({ githubId: me.githubId, limit, offset }, { pool });
150
+ res.json({ guilds: mine, count: mine.length });
151
+ }));
152
+
153
+ // rank: any-builder — MY queue: invites addressed to me, each carrying the
154
+ // card (the guild's current visibility, which an accept must echo back), and
155
+ // the join requests I sent. Stamps seen_at on the invites.
156
+ router.get('/me/guild-requests', requireBuilder, handle('GET /me/guild-requests', 'guild_requests_read_failed', async (req, res) => {
157
+ const me = await actingAccount(req.builder);
158
+ if (me.fail) return res.fail(...me.fail);
159
+ const { incoming, outgoing } = await guilds.listMyRequests({ githubId: me.githubId }, { pool });
160
+ res.json({ incoming, outgoing, incoming_count: incoming.length, outgoing_count: outgoing.length });
161
+ }));
162
+
163
+ // rank: any-builder — the members' roster, for members only (the own-scope
164
+ // door to an unlisted guild, ADR 0336 D4). A private member is listed here,
165
+ // to fellow members, and on no public page.
166
+ router.get('/guilds/:slug/members', requireBuilder, handle('GET /guilds/:slug/members', 'guild_members_read_failed', async (req, res) => {
167
+ const me = await actingAccount(req.builder);
168
+ if (me.fail) return res.fail(...me.fail);
169
+ const m = await memberOf(me, req.params.slug);
170
+ if (m.fail) return res.fail(...m.fail);
171
+ const { limit, offset } = parsePagination(req, { defaultLimit: guilds.LIST_MAX, maxLimit: guilds.LIST_MAX });
172
+ const members = await guilds.listMemberRoster({ guildId: m.guild.id, limit, offset }, { pool });
173
+ res.json({
174
+ guild: guildView(m.guild),
175
+ you: {
176
+ membership_kind: m.guild.membership_kind,
177
+ shown_publicly: m.guild.shown_publicly,
178
+ counted_in_totals: m.guild.counted_in_totals,
179
+ },
180
+ members,
181
+ count: members.length,
182
+ });
183
+ }));
184
+
185
+ // rank: any-builder — MY switches in one guild: shown on its public page
186
+ // (ADR 0336 D4) and counted in its totals (ADR 0337 D4.4). Each optional, at
187
+ // least one required, both keyed on the session.
188
+ router.patch('/guilds/:slug/membership', requireBuilder, handle('PATCH /guilds/:slug/membership', 'guild_membership_update_failed', async (req, res) => {
189
+ if (validateOrRespond(req, res, { shown_publicly: { type: 'boolean' }, counted_in_totals: COUNTED })) return;
190
+ const { shown_publicly: shown, counted_in_totals: counted } = req.body || {};
191
+ if (shown == null && counted == null) {
192
+ return res.fail('nothing_to_update', { status: 400, message: 'send shown_publicly and/or counted_in_totals' });
193
+ }
194
+ const me = await actingAccount(req.builder);
195
+ if (me.fail) return res.fail(...me.fail);
196
+ const slug = guilds.lookupSlug(req.params.slug);
197
+ const flags = slug ? await guilds.setMembershipFlags({
198
+ slug, githubId: me.githubId, shownPublicly: shown ?? null, countedInTotals: counted ?? null,
199
+ }, { pool }) : null;
200
+ if (!flags) return res.fail('guild_not_found', 404);
201
+ res.json(flags);
202
+ }));
203
+
204
+ // rank: any-builder — leave. The owner must transfer first unless they are the
205
+ // last member, when leaving dissolves the guild (ADR 0336 D2).
206
+ router.delete('/guilds/:slug/membership', requireBuilder, handle('DELETE /guilds/:slug/membership', 'guild_leave_failed', async (req, res) => {
207
+ const me = await actingAccount(req.builder);
208
+ if (me.fail) return res.fail(...me.fail);
209
+ const m = await memberOf(me, req.params.slug);
210
+ if (m.fail) return res.fail(...m.fail);
211
+ const outcome = await guilds.leaveGuild({ guildId: m.guild.id, githubId: me.githubId }, { pool });
212
+ if (outcome === 'must_transfer') {
213
+ return res.fail('guild_owner_must_transfer', { status: 409, message: 'hand the guild to another member before you leave' });
214
+ }
215
+ if (!outcome) return res.fail('guild_not_found', 404);
216
+ res.json({ outcome });
217
+ }));
218
+
219
+ // rank: any-builder — ask to join. Only a PUBLIC guild takes requests, so an
220
+ // unlisted guild and an unknown slug are one 404 (ADR 0336 D3/D4). A request
221
+ // written and one swallowed by a decline cooldown answer identically.
222
+ router.post('/guilds/:slug/requests', requireBuilder, handle('POST /guilds/:slug/requests', 'guild_request_failed', async (req, res) => {
223
+ if (validateOrRespond(req, res, { counted_in_totals: COUNTED })) return;
224
+ const me = await actingAccount(req.builder);
225
+ if (me.fail) return res.fail(...me.fail);
226
+ const slug = guilds.lookupSlug(req.params.slug);
227
+ const { outcome } = slug
228
+ ? await guilds.requestToJoin({ slug, githubId: me.githubId, countedInTotals: req.body?.counted_in_totals }, { pool })
229
+ : { outcome: 'not_found' };
230
+ if (outcome === 'not_found') return res.fail('guild_not_found', 404);
231
+ if (outcome === 'already_member') return res.fail('guild_already_member', { status: 409, message: 'you are already in this guild' });
232
+ if (outcome === 'already_open') return res.fail('guild_request_already_open', { status: 409, message: 'there is already an open invite or request between you and this guild' });
233
+ res.status(201).json({ status: 'pending' });
234
+ }));
235
+
236
+ // rank: any-builder — owner: edit name, description or visibility. The slug is
237
+ // fixed at creation (ADR 0336 D6), so it is not a field here.
238
+ router.patch('/guilds/:slug', requireBuilder, handle('PATCH /guilds/:slug', 'guild_update_failed', async (req, res) => {
239
+ if (validateOrRespond(req, res, {
240
+ name: { type: 'string', minLength: 1, maxLength: guilds.NAME_MAX_CHARS },
241
+ description: { type: 'string', maxLength: guilds.DESCRIPTION_MAX_CHARS },
242
+ visibility: { type: 'string', enum: guilds.GUILD_VISIBILITIES },
243
+ })) return;
244
+ // The validator reads null as absent, so null is handled here: a name cannot
245
+ // be cleared, a description can (null or ''), and a null visibility is no
246
+ // change. Absent means "leave it".
247
+ const body = req.body || {};
248
+ if (body.name === undefined && body.description === undefined && body.visibility == null) {
249
+ return res.fail('nothing_to_update', { status: 400, message: 'send name, description and/or visibility' });
250
+ }
251
+ const name = body.name === undefined ? undefined : String(body.name ?? '').trim();
252
+ if (name === '') return res.fail('guild_name_required', { status: 400, message: 'a guild needs a name' });
253
+ const me = await actingAccount(req.builder);
254
+ if (me.fail) return res.fail(...me.fail);
255
+ const o = await ownerOf(me, req.params.slug);
256
+ if (o.fail) return res.fail(...o.fail);
257
+ const guild = await guilds.updateGuild({
258
+ guildId: o.guild.id, githubId: me.githubId, name,
259
+ description: body.description === undefined ? undefined : String(body.description ?? '').trim(),
260
+ visibility: body.visibility ?? undefined,
261
+ }, { pool });
262
+ if (!guild) return res.fail('guild_not_found', 404);
263
+ res.json({ guild });
264
+ }));
265
+
266
+ // rank: any-builder — owner: dissolve. Hard delete, cascading to every
267
+ // membership and open request (ADR 0336 D2).
268
+ router.delete('/guilds/:slug', requireBuilder, handle('DELETE /guilds/:slug', 'guild_dissolve_failed', async (req, res) => {
269
+ const me = await actingAccount(req.builder);
270
+ if (me.fail) return res.fail(...me.fail);
271
+ const o = await ownerOf(me, req.params.slug);
272
+ if (o.fail) return res.fail(...o.fail);
273
+ const ok = await guilds.dissolveGuild({ guildId: o.guild.id, githubId: me.githubId }, { pool });
274
+ if (!ok) return res.fail('guild_not_found', 404);
275
+ res.json({ outcome: 'dissolved' });
276
+ }));
277
+
278
+ // rank: any-builder — owner: invite up to INVITE_BATCH_MAX builders by handle,
279
+ // each an ordinary individual invite (ADR 0336 D3). Unlisted guilds take
280
+ // invites. count is the array's own length.
281
+ router.post('/guilds/:slug/invites', requireBuilder, handle('POST /guilds/:slug/invites', 'guild_invite_failed', async (req, res) => {
282
+ if (validateOrRespond(req, res, {
283
+ handles: { required: true, type: 'array', itemsType: 'string', maxItems: guilds.INVITE_BATCH_MAX },
284
+ })) return;
285
+ if (!req.body.handles.length) return res.fail('handles_required', { status: 400, message: 'name at least one handle' });
286
+ const me = await actingAccount(req.builder);
287
+ if (me.fail) return res.fail(...me.fail);
288
+ const o = await ownerOf(me, req.params.slug);
289
+ if (o.fail) return res.fail(...o.fail);
290
+ const results = await guilds.inviteHandles({
291
+ guildId: o.guild.id, ownerGithubId: me.githubId, handles: req.body.handles,
292
+ }, { pool });
293
+ res.json({ results, count: results.length });
294
+ }));
295
+
296
+ // rank: any-builder — owner: the join requests to decide and the invites
297
+ // still out. Stamps seen_at on the requests only.
298
+ router.get('/guilds/:slug/requests', requireBuilder, handle('GET /guilds/:slug/requests', 'guild_requests_read_failed', async (req, res) => {
299
+ const me = await actingAccount(req.builder);
300
+ if (me.fail) return res.fail(...me.fail);
301
+ const o = await ownerOf(me, req.params.slug);
302
+ if (o.fail) return res.fail(...o.fail);
303
+ const { incoming, outgoing } = await guilds.listGuildRequests({
304
+ guildId: o.guild.id, ownerGithubId: me.githubId,
305
+ }, { pool });
306
+ res.json({ incoming, outgoing, incoming_count: incoming.length, outgoing_count: outgoing.length });
307
+ }));
308
+
309
+ // rank: any-builder — owner: remove a member (never the owner). Hard delete.
310
+ router.delete('/guilds/:slug/members/:handle', requireBuilder, handle('DELETE /guilds/:slug/members/:handle', 'guild_member_remove_failed', async (req, res) => {
311
+ const me = await actingAccount(req.builder);
312
+ if (me.fail) return res.fail(...me.fail);
313
+ const o = await ownerOf(me, req.params.slug);
314
+ if (o.fail) return res.fail(...o.fail);
315
+ const target = guilds.lookupSlug(req.params.handle);
316
+ const ok = target ? await guilds.removeMember({
317
+ guildId: o.guild.id, ownerGithubId: me.githubId, handle: target,
318
+ }, { pool }) : false;
319
+ if (!ok) return res.fail('guild_member_not_found', 404);
320
+ res.json({ outcome: 'removed' });
321
+ }));
322
+
323
+ // rank: any-builder — owner: hand ownership to an existing member. Two
324
+ // statements in one transaction (ADR 0336 D2).
325
+ router.post('/guilds/:slug/transfer', requireBuilder, handle('POST /guilds/:slug/transfer', 'guild_transfer_failed', async (req, res) => {
326
+ if (validateOrRespond(req, res, { handle: { required: true, type: 'string', minLength: 1, maxLength: 39 } })) return;
327
+ const me = await actingAccount(req.builder);
328
+ if (me.fail) return res.fail(...me.fail);
329
+ const o = await ownerOf(me, req.params.slug);
330
+ if (o.fail) return res.fail(...o.fail);
331
+ const target = guilds.lookupSlug(req.body.handle);
332
+ const outcome = target ? await guilds.transferOwnership({
333
+ guildId: o.guild.id, ownerGithubId: me.githubId, handle: target,
334
+ }, { pool }) : 'not_member';
335
+ if (outcome === 'not_member') return res.fail('guild_member_not_found', 404);
336
+ if (outcome !== 'transferred') return res.fail('guild_owner_only', { status: 403, message: 'only the guild owner can do that' });
337
+ res.json({ outcome });
338
+ }));
339
+
340
+ // rank: any-builder — accept an invite addressed to ME, or (as the guild's
341
+ // owner) a request to join it. An invite accept must echo the visibility its
342
+ // card showed; if the guild changed since, 409 and the card re-renders
343
+ // (ADR 0336 D4 §3). Any row the caller may not accept is 404.
344
+ router.post('/guild-requests/:id/accept', requireBuilder, handle('POST /guild-requests/:id/accept', 'guild_accept_failed', async (req, res) => {
345
+ if (validateOrRespond(req, res, {
346
+ visibility: { type: 'string', enum: guilds.GUILD_VISIBILITIES },
347
+ counted_in_totals: COUNTED,
348
+ })) return;
349
+ const id = requestId(req);
350
+ if (!id) return res.fail('guild_request_not_found', 404);
351
+ const me = await actingAccount(req.builder);
352
+ if (me.fail) return res.fail(...me.fail);
353
+ const out = await guilds.acceptRequest({
354
+ id, githubId: me.githubId,
355
+ cardVisibility: req.body?.visibility ?? null,
356
+ countedInTotals: req.body?.counted_in_totals ?? null,
357
+ }, { pool });
358
+ if (out.outcome === 'card_required') {
359
+ return res.fail('guild_card_visibility_required', { status: 400, message: 'send the visibility your invite card showed' });
360
+ }
361
+ if (out.outcome === 'visibility_changed') {
362
+ return res.fail('guild_visibility_changed', 409, { visibility: out.visibility });
363
+ }
364
+ if (out.outcome !== 'accepted') return res.fail('guild_request_not_found', 404);
365
+ res.json({ status: 'accepted' });
366
+ }));
367
+
368
+ // rank: any-builder — decline an invite to ME, or (as owner) a request to my
369
+ // guild. SILENT: 204, and the other side is never told.
370
+ router.post('/guild-requests/:id/decline', requireBuilder, handle('POST /guild-requests/:id/decline', 'guild_decline_failed', async (req, res) => {
371
+ const id = requestId(req);
372
+ if (!id) return res.fail('guild_request_not_found', 404);
373
+ const me = await actingAccount(req.builder);
374
+ if (me.fail) return res.fail(...me.fail);
375
+ const ok = await guilds.declineRequest({ id, githubId: me.githubId }, { pool });
376
+ if (!ok) return res.fail('guild_request_not_found', 404);
377
+ res.status(204).end();
378
+ }));
379
+
380
+ // rank: any-builder — cancel a row I opened: my request, or (as owner) an
381
+ // invite my guild sent.
382
+ router.delete('/guild-requests/:id', requireBuilder, handle('DELETE /guild-requests/:id', 'guild_cancel_failed', async (req, res) => {
383
+ const id = requestId(req);
384
+ if (!id) return res.fail('guild_request_not_found', 404);
385
+ const me = await actingAccount(req.builder);
386
+ if (me.fail) return res.fail(...me.fail);
387
+ const ok = await guilds.cancelRequest({ id, githubId: me.githubId }, { pool });
388
+ if (!ok) return res.fail('guild_request_not_found', 404);
389
+ res.json({ outcome: 'cancelled' });
390
+ }));
391
+
392
+ return router;
393
+ };
@@ -120,7 +120,7 @@ module.exports = function projectsRoutes() {
120
120
  // as any-builder — wrong in the dangerous direction (the ADR 0210 rule).
121
121
 
122
122
  // rank: metic+archon — own-or-metic in effect: invite a builder into a project
123
- // (ADR 0141 §5, task #1002115), called by a `project.curate` holder OR the project's OWN
123
+ // (ADR 0141 §5, task 1002115), called by a `project.curate` holder OR the project's OWN
124
124
  // OWNER (task 1003580; the ownership proof is `provisioning_instances.owner_builder_id`,
125
125
  // reached through a port — see invite-authz.js). The wizard's audience is any signed-in
126
126
  // GitHub user, typically a xenos who used to get 403 on their own project. A caller who is
@@ -24,7 +24,10 @@
24
24
  //
25
25
  // WHY THE CALLER CANNOT FORGE ONE. These fragments have exactly one call site:
26
26
  // upsertAccount's `activate` branch, whose only caller is /sso/authorize
27
- // (routes/sso.js). There `activate: true` is a SOURCE LITERAL and every other
27
+ // (routes/sso.js) — the leg a builder passes through when they use their platform
28
+ // account to sign in to a PROJECT. Signing in to the hub itself does not reach it
29
+ // and records nothing (blocker 1000124; task 1003361 adds that capture point),
30
+ // which is why /terms names a project sign-in. There `activate: true` is a SOURCE LITERAL and every other
28
31
  // field comes from the hub's own resolved session — the ADR 0205 D3 property
29
32
  // established for recordFederatedSignin by task 1003340. No request-body field
30
33
  // reaches these statements, which is the whole point: an acceptance record a
@@ -40,7 +43,7 @@
40
43
  // accounts that already existed were accepted on their owner's behalf, and the
41
44
  // record says so rather than passing that off as a sign-in. A grandfather is not
42
45
  // an acceptance its owner gave, so it is the one acceptance that does not
43
- // "stand": the owner's next sign-in replaces it with their own. This file only
46
+ // "stand": the owner's next project sign-in replaces it with their own. This file only
44
47
  // ever READS the marker. tests/terms_acceptance.mjs holds both rules, and fails
45
48
  // any other server file that writes these columns or names the marker.
46
49
  //
@@ -57,6 +60,10 @@
57
60
  //
58
61
  // Date-stamped rather than numbered: the page says "Version <this>", and a
59
62
  // reader comparing page to record should not have to translate.
63
+ //
64
+ // The terms_version column it fills is WRITE-ONLY today, on purpose: nothing
65
+ // reads it until a revision needs to know who accepted which text (ADR 0213 D5).
66
+ // It is not dead — dropping it would erase exactly that answer.
60
67
  const TERMS_VERSION = '2026-08-29';
61
68
 
62
69
  // A Postgres placeholder, e.g. '$5'. Interpolated into SQL, so it is validated
@@ -73,9 +80,11 @@ function acceptanceInsertColumns() {
73
80
  return ', terms_accepted_at, terms_version, terms_accepted_source';
74
81
  }
75
82
 
76
- // The matching VALUES fragment. A row created by a NON-activating caller
77
- // (ensureAccount's check-in / device-poll path) gets NULL — fail-closed: no hub
78
- // session witnessed that human, so there is no acceptance to record.
83
+ // The matching VALUES fragment. A row created by a NON-activating upsert — the
84
+ // device poll (POST /sso/device/poll) or a handle change (PATCH /me/handle) —
85
+ // gets NULL: fail-closed, because no sign-in the hub witnessed recorded an
86
+ // acceptance. The federated check-in never reaches this at all; it writes
87
+ // through ensureAccount, whose insert-only statement names none of these columns.
79
88
  function acceptanceInsertValues(activateParam, versionParam) {
80
89
  assertPlaceholder('acceptanceInsertValues', activateParam);
81
90
  assertPlaceholder('acceptanceInsertValues', versionParam);
@@ -65,10 +65,10 @@
65
65
  <h2>Your work across projects</h2>
66
66
  <p>Each {{productName}} project you build in reports a summary of your work there to the platform: the {{currency}} you earned, the works you shipped, your karma, the rank that project gave you, the roles you worked in, and when you were last active. The platform adds these up across every project into one cross-project record. Projects report this themselves — the platform does not read their databases.</p>
67
67
  <p><b>These numbers describe; they do not govern.</b> Your cross-project record is descriptive analytics and never authority. What you may do inside any project is decided by that project alone, from its own records, at the moment you ask. A large record grants nothing and a hidden one takes nothing away; no project is required to read it, and none may treat it as permission. A rank shown here is that project&rsquo;s own standing for you, as that project reported it &mdash; the platform holds no rank of its own.</p>
68
- <p><b>What is public.</b> Once you have signed in here and accepted these terms, your profile and that record are public by default: at your handle&rsquo;s page, in the people directory, to project owners looking for people to work with, and to search engines. An account that has never signed in here stays provisional and is published nowhere.</p>
68
+ <p><b>What is public.</b> Once you have signed in to a project with your platform account and accepted these terms, your profile and that record are public by default: at your handle&rsquo;s page, in the people directory, to project owners looking for people to work with, and to search engines. An account that has never signed in to a project that way stays provisional and is published nowhere.</p>
69
69
  <p><b>What you control.</b> From your settings you can hide your statistics, drop out of the recruiting directory, or make your account private. Each takes effect immediately, everywhere, including on a page someone already has open. Hiding removes the platform&rsquo;s combined view; it does not retract the per-project numbers each project publishes itself, so a determined reader could still add them up the hard way.</p>
70
- <p><b>Your acceptance is recorded.</b> The platform stores the date you accepted these terms, and which version, on your account. It is written by signing in here, and only once &mdash; signing in again does not change the original date. This is version <b>2026-08-29</b>.</p>
71
- <p><b>Accounts from before these terms.</b> If your account already existed when this version took effect, the platform accepted it on your behalf, rather than waiting for you to sign in, and dated it the day that was done. The record marks that acceptance as grandfathered, so it is never mistaken for one you gave, and your own acceptance replaces it the next time you sign in.</p>
70
+ <p><b>Your acceptance is recorded.</b> The platform stores the date you accepted these terms, and which version, on your account. It is written when you sign in to a project with your platform account, and only once &mdash; signing in again does not change the date you accepted. This is version <b>2026-08-29</b>.</p>
71
+ <p><b>Accounts from before these terms.</b> If your account already existed when this version took effect, the platform accepted it on your behalf, rather than waiting for you to sign in, and dated it the day that was done. The record marks that acceptance as grandfathered, so it is never mistaken for one you gave, and your own acceptance replaces it the next time you sign in to a project.</p>
72
72
  <!--apex-only:end-->
73
73
  <h2>Fair use</h2>
74
74
  <p>Don't use {{productName}} to break the law, to attack this site or other people's projects, to mine, spam or otherwise abuse hosted resources, or to harass other builders. The owner may deactivate a builder or take a project offline for breaking these, and may set further house rules inside the hall.</p>
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1025",
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.1025",
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.1025",
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",
@@ -87,6 +87,11 @@ const INTEGRATION = new Set([
87
87
  // WHERE NOT EXISTS statement) and the upsertActivity wiring against real SQL —
88
88
  // a fake pool can prove the query shape but not which reports it inserts.
89
89
  'activity_snapshots_db',
90
+ // task 1002978: the windowed leaderboard against real SQL. Which snapshot
91
+ // becomes a window baseline (a LATERAL at-or-before probe), a missing baseline
92
+ // counting as zero, and the privacy matrix filtering are all planner facts the
93
+ // DB-free route test can only assert the shape of.
94
+ 'community_leaderboard_db',
90
95
  'asset_version', 'auth_flow_slice1', 'backfill_sessions', 'bfg', 'bfg_write',
91
96
  'box_connect_e2e', 'box_onboard', 'boxes', 'cf_tunnel', 'cli_token_reissue',
92
97
  // consumer_layout_boot builds the core .tgz, `npm install`s it, and boots a live
@@ -147,6 +152,11 @@ const INTEGRATION = new Set([
147
152
  // the --integration lane never saw them either, because it runs only this set. A
148
153
  // test that cannot fail in either lane is a file that looks like a gate.
149
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',
150
160
  // task 1003935: 'linkify_refs' was swept in with the three above without the
151
161
  // no-DB check they were being fixed for. It touches no Postgres at all — pure
152
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.1025'; // 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');