@bongos/core 1.21.57 → 1.21.59

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 (52) hide show
  1. package/.bongos-core.json +91 -51
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +13 -2
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/adr/0336-a-guilds-record-is-the-sum-of-what-its-members-already-show.md +2 -0
  8. package/docs/adr/0365-a-guild-engages-a-project-through-each-members-own-door.md +58 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +233 -6
  11. package/docs/api-reference.md +8 -5
  12. package/docs/copy-inventory.md +384 -376
  13. package/docs/copy-registry.json +514 -432
  14. package/docs/file-map.md +3 -0
  15. package/docs/module-api-changelog.md +4 -0
  16. package/docs/page-inventory.json +7 -1
  17. package/docs/page-readings.json +930 -919
  18. package/modules/hall-ui/public/goals-page.js +20 -1
  19. package/modules/hall-ui/public/task-lib.js +13 -0
  20. package/modules/hall-ui/public/work.js +22 -1
  21. package/modules/platform-identity/application-echo.js +11 -5
  22. package/modules/platform-identity/guild-engagements.js +255 -0
  23. package/modules/platform-identity/guilds.js +3 -1
  24. package/modules/platform-identity/migrations/platform_identity_030_guild_engagements.sql +64 -0
  25. package/modules/platform-identity/routes/guilds.js +35 -1
  26. package/modules/platform-identity/routes/my-projects.js +27 -3
  27. package/modules/platform-identity/routes/projects.js +44 -0
  28. package/modules/platform-identity/routes/sso.js +21 -3
  29. package/modules/public-landing/public/assets/cosmos.css +7 -0
  30. package/modules/public-landing/public/guild.html +64 -2
  31. package/modules/public-landing/public/guild.probes.json +13 -3
  32. package/modules/public-landing/public/guild.states.json +10 -5
  33. package/modules/public-landing/public/projects.html +252 -16
  34. package/modules/public-landing/public/projects.probes.json +26 -1
  35. package/modules/public-landing/public/projects.states.json +4 -0
  36. package/modules/ui-design/kit/fixtures/me__guild-engagements.json +7 -0
  37. package/modules/ui-design/kit/serve.js +54 -5
  38. package/package-lock.json +2 -2
  39. package/package.json +1 -1
  40. package/release-notes.json +12 -0
  41. package/scripts/gds/run-unit-tests.js +5 -0
  42. package/src/module-api.js +1 -1
  43. package/tests/applicant_profile_boundary.mjs +104 -1
  44. package/tests/application_echo.mjs +91 -5
  45. package/tests/focus_version_filter_default.mjs +47 -0
  46. package/tests/guild_engagement.mjs +881 -0
  47. package/tests/guild_engagement_db.mjs +240 -0
  48. package/tests/hall_mine_lens.mjs +1 -0
  49. package/tests/hub_engagement_cards.mjs +307 -0
  50. package/tests/hub_guild_page.mjs +94 -7
  51. package/tests/privacy_matrix.mjs +99 -9
  52. package/tests/project_invite_ui.mjs +187 -3
@@ -237,6 +237,21 @@
237
237
  }
238
238
  }
239
239
 
240
+ // task 1004552 — the list opens on the project's focus version (the Roadmap
241
+ // star); no focus = '— any —'. This page keeps no URL filter state, so only a
242
+ // choice already made can win. A focus with no goal yet has no option here and
243
+ // is left alone rather than selecting a blank. Runs before the first render.
244
+ function applyFocusVersion(focusResp) {
245
+ const sel = $('#f-goal-version');
246
+ if (!sel) return;
247
+ const want = window.OTBTaskLib.focusVersionDefault({
248
+ focusId: focusResp && focusResp.focus_version_id,
249
+ optionValues: Array.from(sel.options || [], (o) => o.value),
250
+ current: sel.value,
251
+ });
252
+ if (want) sel.value = want;
253
+ }
254
+
240
255
  // task 1003135 — the Owner dropdown. 'Goal owner' is goals.created_by (the
241
256
  // fixed authority, migration 160), returned by GET /goals as a raw builder id;
242
257
  // labels come from the directory the page already lazily caches (loadDirectory,
@@ -836,9 +851,13 @@
836
851
  // task 1828 — "My goals" needs an identity; disable it when signed out.
837
852
  const mineSel = $('#f-goal-mine');
838
853
  if (mineSel && !state.me) { mineSel.disabled = true; mineSel.title = 'Sign in to filter to your goals'; }
839
- const gres = await getJSON(`${API}/goals`);
854
+ const [gres, focusResp] = await Promise.all([
855
+ getJSON(`${API}/goals`),
856
+ getJSON(`${API}/versions/focus`).catch(() => null),
857
+ ]);
840
858
  state.goals = gres.goals || [];
841
859
  populateVersionFilter();
860
+ applyFocusVersion(focusResp);
842
861
  // task 1003135 — fire-and-forget: the Owner options fill when the (cached)
843
862
  // directory resolves; boot never waits on a label lookup.
844
863
  populateOwnerFilter().catch(() => {});
@@ -179,7 +179,20 @@
179
179
  }
180
180
  }
181
181
 
182
+ // task 1004552 — the value a Version filter should OPEN on. The project's focus
183
+ // version (the Roadmap star, task 1004551) is the default; nothing is guessed when
184
+ // there is no focus (no "pick the version that is building" fallback). A URL filter
185
+ // (?f-version=) or a choice the builder already made wins, and a focus the select
186
+ // has no option for (e.g. no goal in that version yet) is left alone — selecting a
187
+ // value that is not on the list would show a blank control. Returns the value to
188
+ // set, or null for "leave the control as it is".
189
+ function focusVersionDefault({ focusId, optionValues, current, urlValue } = {}) {
190
+ if (!focusId || urlValue || current) return null;
191
+ return (optionValues || []).includes(focusId) ? focusId : null;
192
+ }
193
+
182
194
  const api = {
195
+ focusVersionDefault,
183
196
  msOf, taskActivityMs, STALE_CHOICES, isStale,
184
197
  priorityRank, touchesConflict, conflictPeers, conflictChipHtml, rankTagHtml, deHashRefs,
185
198
  creditsPerMinute, buildPromptForTask, friendlyBatchReason,
@@ -514,6 +514,25 @@
514
514
  return true;
515
515
  }
516
516
 
517
+ // task 1004552 — the board opens on the project's focus version (the Roadmap
518
+ // star). A ?f-version= link or a choice already made wins; no focus = '— any —'.
519
+ // Sets the control directly (no change event), so the default never rides the
520
+ // URL and a copied link stays exactly what the builder chose. The claimable feed
521
+ // was fetched unfiltered, which is fine: matchesBoardFilters narrows it here.
522
+ function applyFocusVersion(focusResp) {
523
+ const sel = $('#f-version');
524
+ if (!sel) return false;
525
+ const want = window.OTBTaskLib.focusVersionDefault({
526
+ focusId: focusResp && focusResp.focus_version_id,
527
+ optionValues: Array.from(sel.options || [], (o) => o.value),
528
+ current: sel.value,
529
+ urlValue: new URLSearchParams(window.location.search).get('f-version'),
530
+ });
531
+ if (!want) return false;
532
+ sel.value = want;
533
+ return true;
534
+ }
535
+
517
536
  function populateVersionFilter(versions) {
518
537
  const sel = $('#f-version');
519
538
  if (!sel) return;
@@ -972,13 +991,15 @@
972
991
  // the friendly notice and hide the claim queue, but keep the in-flight list
973
992
  // empty-but-honest (the gate also blocks active_claims, so say so).
974
993
  try {
975
- const [claimResp, versionsResp] = await Promise.all([
994
+ const [claimResp, versionsResp, focusResp] = await Promise.all([
976
995
  getJSON(`${API}/tasks/claimable${serverFilterQuery()}`),
977
996
  getJSON(`${API}/versions`).catch(() => ({ versions: [] })),
997
+ getJSON(`${API}/versions/focus`).catch(() => null),
978
998
  ]);
979
999
  taskState.versions = versionsResp.versions || [];
980
1000
  populateVersionFilter(taskState.versions);
981
1001
  restoreUrlFilter('f-version');
1002
+ if (applyFocusVersion(focusResp)) renderBacklog();
982
1003
  renderInFlight(claimResp.active_claims || []);
983
1004
  taskState.all = claimResp.claimable || [];
984
1005
  renderClaimable();
@@ -47,19 +47,25 @@ const APPLICATION_ECHO_WINDOW_DAYS = 30;
47
47
  // is measured from the most recent ask — otherwise an applicant who re-asks
48
48
  // watches their own card go quiet while their request is still open.
49
49
  //
50
+ // `guildId` is the guild this application was made with (task 1002303, ADR 0365
51
+ // D1) — resolved by the relay through guild-engagements.js engagementFor, never
52
+ // taken from the request — or null for a solo application. It is written on
53
+ // EVERY upsert, null included, so the most recent application decides the label
54
+ // the reviewer sees: applying again on your own clears it.
55
+ //
50
56
  // BEST-EFFORT BY CONTRACT: returns false instead of throwing when the account is
51
57
  // a non-numeric principal (ADR 0171 D2). The caller must also swallow a DB error
52
58
  // — the echo is a convenience on the applicant's own view, and losing it must
53
59
  // never turn a filed application into a reported failure.
54
- async function recordApplication({ githubId, clientId }, { pool = defaultPool } = {}) {
60
+ async function recordApplication({ githubId, clientId, guildId = null }, { pool = defaultPool } = {}) {
55
61
  if (!isNumericGithubId(githubId)) return false;
56
62
  const client = String(clientId || '');
57
63
  if (!client) return false;
58
64
  await pool.query(
59
- `INSERT INTO platform_identity_application_echoes (github_id, client_id, applied_at)
60
- VALUES ($1::bigint, $2, now())
61
- ON CONFLICT (github_id, client_id) DO UPDATE SET applied_at = now()`,
62
- [String(githubId), client],
65
+ `INSERT INTO platform_identity_application_echoes (github_id, client_id, applied_at, guild_id)
66
+ VALUES ($1::bigint, $2, now(), $3::bigint)
67
+ ON CONFLICT (github_id, client_id) DO UPDATE SET applied_at = now(), guild_id = EXCLUDED.guild_id`,
68
+ [String(githubId), client, guildId == null ? null : String(guildId)],
63
69
  );
64
70
  return true;
65
71
  }
@@ -0,0 +1,255 @@
1
+ // modules/platform-identity/guild-engagements.js — a guild engaging a project:
2
+ // "G is applying to P" and "P invited G", plus the guild label an application
3
+ // carries to the project's reviewer (task 1002303, R27; ADR 0336 D7, amended by
4
+ // ADR 0365).
5
+ //
6
+ // WHAT LIVES HERE, next to the data so no route can forget a rule:
7
+ //
8
+ // * A PROJECT STILL ADMITS PEOPLE, ONE AT A TIME (D7). An engagement admits
9
+ // nobody. A member who acts on one applies AS THEMSELVES through the
10
+ // existing relay (routes/my-projects.js), vouched and echoed; the echo then
11
+ // carries the guild as a label. Filing does not apply on the owner's behalf.
12
+ // * A PROJECT INVITING A GUILD WRITES NO INVITES (ADR 0365 D3, after ADR
13
+ // 0353). It records the `invited` engagement and hands back one hall link
14
+ // per member on the guild's PUBLIC roster at that moment — the people the
15
+ // inviter could see — so each invite is the project's own row, sent from
16
+ // its own hall. An unlisted guild cannot be invited: it answers like an
17
+ // unknown one.
18
+ // * THE NAMING RULE (ADR 0365 D4): a project is named only when it is on the
19
+ // map AND Public (namedProjectSql — the getRollupForBuilder rule, composed
20
+ // from the feed's own onMapSql). It gates both kinds, at filing AND at
21
+ // read. A private or stealth project's engagement is not shown at all,
22
+ // never shown anonymised.
23
+ // * ONE FRAGMENT DECIDES WHICH ENGAGEMENTS A MEMBER MAY SEE
24
+ // (memberEngagementsSql): their own guild's, inside the window, naming a
25
+ // project by the rule above, and an `invited` row only if they were already
26
+ // a member when it was filed. The inbox AND the join's guild check both use
27
+ // it, so the join can never accept a guild the inbox would not have shown —
28
+ // that difference would let a member probe whether a private project had
29
+ // invited their guild.
30
+ // * STATELESS AND STATUSLESS (ADR 0201 D1.3). No status, no filer, no
31
+ // withdraw: a row ages out read-time at ENGAGEMENT_WINDOW_DAYS, and
32
+ // re-filing restarts it.
33
+ //
34
+ // NOTHING HERE IS AUTHORITY (ADR 0016 / ADR 0141). Every read that touches
35
+ // platform_identity_accounts joins account-visibility.js
36
+ // (tests/effective_visibility_predicate.mjs). client_id never leaves this file.
37
+ //
38
+ // Its own file because platform-identity.js is at the size ratchet. Reaches
39
+ // core only through the doorway (ADR 0083).
40
+ 'use strict';
41
+
42
+ const api = require('../../src/module-api');
43
+ const { accountVisibleSql } = require('./account-visibility');
44
+ const { onMapSql } = require('./projects-feed');
45
+ const { isNumericGithubId } = require('./platform-identity');
46
+ const { APPLICATION_ECHO_WINDOW_DAYS } = require('./application-echo');
47
+ const { hallInviteUrl } = require('./invite-notices');
48
+ const { ownerExistsSql, LIST_MAX } = require('./guilds');
49
+
50
+ const { pool: defaultPool } = api;
51
+
52
+ // How long an engagement stays visible. READ-TIME, like the application echo's
53
+ // window and for the same reason: no sweep to fall behind, no status to expire
54
+ // into. Exported so the routes, the tests and the docs quote one number.
55
+ const ENGAGEMENT_WINDOW_DAYS = 30;
56
+
57
+ // A project may be NAMED to a guild's members only when it is on the map and
58
+ // Public (ADR 0365 D4, the ADR 0171 D2 amendment). onMapSql validates the alias.
59
+ function namedProjectSql(alias) {
60
+ return `(${onMapSql(alias)} AND ${alias}.visibility = 'public')`;
61
+ }
62
+
63
+ // The FROM + WHERE every member-facing read shares (see the header). `member`
64
+ // and `windowDays` are placeholders, e.g. '$1' and '$2'. The client must still
65
+ // be an active registration: a dead client is not a project anyone can apply to.
66
+ function memberEngagementsSql(member, windowDays) {
67
+ return `
68
+ FROM platform_identity_guild_members m
69
+ JOIN platform_identity_guilds g ON g.id = m.guild_id
70
+ JOIN platform_identity_guild_engagements e ON e.guild_id = m.guild_id
71
+ JOIN platform_identity_sso_clients c ON c.client_id = e.client_id
72
+ JOIN platform_identity_projects p ON p.origin = c.origin
73
+ WHERE m.github_id = ${member}::bigint
74
+ AND e.created_at > now() - make_interval(days => ${windowDays}::int)
75
+ AND c.status = 'active' AND ${namedProjectSql('p')}
76
+ AND (e.kind = 'apply' OR m.joined_at <= e.created_at)`;
77
+ }
78
+
79
+ // The wire shape of one engagement. A literal whitelist: client_id, the guild's
80
+ // row id and the guild's visibility never cross.
81
+ function engagementView(r) {
82
+ return {
83
+ guild: { slug: r.slug, name: r.guild_name },
84
+ kind: r.kind,
85
+ project: { name: r.project_name, origin: r.project_origin },
86
+ created_at: r.created_at,
87
+ };
88
+ }
89
+
90
+ // fileEngagement — the guild's OWNER files "this guild is applying to P"
91
+ // (kind 'apply'). ONE statement: P is resolved from the origin the way the join
92
+ // relay resolves it (the oldest active registration at that exact origin), must
93
+ // pass the naming rule, and the owner test is re-asserted inside the INSERT, so
94
+ // the route's pre-check is a clearer error and never the only gate. Re-filing
95
+ // restarts the window. Returns the engagement view, or null when P is unknown,
96
+ // inactive or not named — one null, so the route's 404 cannot be an oracle.
97
+ async function fileEngagement({ guildId, githubId, origin }, { pool = defaultPool } = {}) {
98
+ const norm = String(origin || '').replace(/\/+$/, '');
99
+ if (!norm || !isNumericGithubId(githubId)) return null;
100
+ const { rows } = await pool.query(
101
+ `WITH target AS (
102
+ SELECT c.client_id, p.name, p.origin
103
+ FROM platform_identity_sso_clients c
104
+ JOIN platform_identity_projects p ON p.origin = c.origin
105
+ WHERE c.client_id = (SELECT c1.client_id FROM platform_identity_sso_clients c1
106
+ WHERE c1.origin = $3 AND c1.status = 'active'
107
+ ORDER BY c1.id ASC LIMIT 1)
108
+ AND ${namedProjectSql('p')}
109
+ ),
110
+ filed AS (
111
+ INSERT INTO platform_identity_guild_engagements AS e (guild_id, client_id, kind)
112
+ SELECT g.id, target.client_id, 'apply'
113
+ FROM platform_identity_guilds g CROSS JOIN target
114
+ WHERE g.id = $1 AND ${ownerExistsSql('g.id', '$2')}
115
+ ON CONFLICT (guild_id, client_id, kind) DO UPDATE SET created_at = now()
116
+ RETURNING e.guild_id, e.kind, e.created_at
117
+ )
118
+ SELECT g.slug, g.name AS guild_name, filed.kind, filed.created_at,
119
+ target.name AS project_name, target.origin AS project_origin
120
+ FROM filed
121
+ JOIN platform_identity_guilds g ON g.id = filed.guild_id
122
+ CROSS JOIN target`,
123
+ [guildId, String(githubId), norm],
124
+ );
125
+ return rows[0] ? engagementView(rows[0]) : null;
126
+ }
127
+
128
+ // inviteGuild — a project invites a PUBLIC guild (ADR 0365 D3). The caller has
129
+ // already passed the project's inviter gate and holds the active client row.
130
+ // Returns { guild, invites } — one hall hand-off link per member on the guild's
131
+ // public roster at this moment — or null when the slug is unknown OR unlisted
132
+ // (one answer: an unlisted guild cannot be addressed).
133
+ //
134
+ // The `invited` row is written only while P passes the naming rule, re-checked
135
+ // in the INSERT: a private or stealth project's invite reaches the people it
136
+ // chose and nobody else, so it leaves no notice for the members it could not
137
+ // see — not even one that would surface if P went public later.
138
+ async function inviteGuild({ clientId, origin, slug }, { pool = defaultPool } = {}) {
139
+ const g = await pool.query(
140
+ `SELECT id, slug, name FROM platform_identity_guilds
141
+ WHERE lower(slug) = $1 AND visibility = 'public'`,
142
+ [slug],
143
+ );
144
+ const guild = g.rows[0];
145
+ if (!guild) return null;
146
+ await pool.query(
147
+ `INSERT INTO platform_identity_guild_engagements AS e (guild_id, client_id, kind)
148
+ SELECT $1::bigint, c.client_id, 'invited'
149
+ FROM platform_identity_sso_clients c
150
+ JOIN platform_identity_projects p ON p.origin = c.origin
151
+ WHERE c.client_id = $2 AND c.status = 'active' AND ${namedProjectSql('p')}
152
+ AND EXISTS (SELECT 1 FROM platform_identity_guilds pg
153
+ WHERE pg.id = $1 AND pg.visibility = 'public')
154
+ ON CONFLICT (guild_id, client_id, kind) DO UPDATE SET created_at = now()`,
155
+ [guild.id, clientId],
156
+ );
157
+ const roster = await publicRosterForInvite(guild.id, { pool });
158
+ const invites = roster
159
+ .map((m) => ({ handle: m.handle, display_name: m.display_name, invite_url: hallInviteUrl(origin, m.github_login) }))
160
+ .filter((m) => m.invite_url);
161
+ return { guild: { slug: guild.slug, name: guild.name }, invites };
162
+ }
163
+
164
+ // The guild's PUBLIC roster, exactly as getPublicGuild reads it (shown_publicly
165
+ // AND accountVisibleSql, owner first, then by handle, capped at LIST_MAX) — the
166
+ // people an inviter can already see on the guild's page. github_login is read
167
+ // only to build each hall link (the hall's ?invite= prefill takes a login, which
168
+ // a public profile already shows); it is never returned on its own.
169
+ async function publicRosterForInvite(guildId, { pool }) {
170
+ const { rows } = await pool.query(
171
+ `SELECT pa.handle, pa.display_name, pa.github_login
172
+ FROM platform_identity_guild_members m
173
+ JOIN platform_identity_accounts pa ON pa.github_id = m.github_id
174
+ WHERE m.guild_id = $1 AND m.shown_publicly AND ${accountVisibleSql('pa')}
175
+ ORDER BY (m.membership_kind = 'owner') DESC, lower(pa.handle)
176
+ LIMIT $2`,
177
+ [guildId, LIST_MAX],
178
+ );
179
+ return rows;
180
+ }
181
+
182
+ // listMyEngagements — the caller's OWN inbox: engagements of guilds they belong
183
+ // to that they may act on, newest first, capped at LIST_MAX. On top of the shared
184
+ // rule (memberEngagementsSql) it drops any project the caller already builds on,
185
+ // already holds a live invite notice for (the inviteExpiry window), or already
186
+ // has a live application echo for — the card would ask them to do what they
187
+ // have done. Own-scope only: the github_id is always the session's.
188
+ async function listMyEngagements({ githubId }, { pool = defaultPool } = {}) {
189
+ if (!isNumericGithubId(githubId)) return [];
190
+ const { rows } = await pool.query(
191
+ `SELECT g.slug, g.name AS guild_name, e.kind, e.created_at,
192
+ p.name AS project_name, p.origin AS project_origin
193
+ ${memberEngagementsSql('$1', '$2')}
194
+ AND NOT EXISTS (SELECT 1 FROM platform_identity_project_memberships pm
195
+ WHERE pm.client_id = e.client_id AND pm.github_id = $1::bigint
196
+ AND (pm.membership_kind <> 'pending'
197
+ OR pm.joined_at > now() - make_interval(days => $3::int)))
198
+ AND NOT EXISTS (SELECT 1 FROM platform_identity_application_echoes ae
199
+ WHERE ae.github_id = $1::bigint AND ae.client_id = e.client_id
200
+ AND ae.applied_at > now() - make_interval(days => $4::int))
201
+ ORDER BY e.created_at DESC, g.slug, e.kind
202
+ LIMIT $5`,
203
+ [String(githubId), ENGAGEMENT_WINDOW_DAYS, api.inviteExpiry.INVITE_EXPIRY_DAYS,
204
+ APPLICATION_ECHO_WINDOW_DAYS, LIST_MAX],
205
+ );
206
+ return rows.map(engagementView);
207
+ }
208
+
209
+ // engagementFor — "may THIS member apply to THIS project WITH this guild?" The
210
+ // join relay's check before it relays anything (ADR 0365 D2): the guild must be
211
+ // one the caller belongs to, with an engagement of either kind for the project
212
+ // that the shared rule lets them see. Returns { id, slug, name } (the id is for
213
+ // the echo, never the wire) or null — left the guild, aged out, or never visible.
214
+ async function engagementFor({ githubId, slug, clientId }, { pool = defaultPool } = {}) {
215
+ if (!isNumericGithubId(githubId) || !slug || !clientId) return null;
216
+ const { rows } = await pool.query(
217
+ `SELECT g.id, g.slug, g.name
218
+ ${memberEngagementsSql('$1', '$4')}
219
+ AND lower(g.slug) = $2 AND e.client_id = $3
220
+ LIMIT 1`,
221
+ [String(githubId), slug, String(clientId), ENGAGEMENT_WINDOW_DAYS],
222
+ );
223
+ return rows[0] || null;
224
+ }
225
+
226
+ // echoGuildLabel — the guild an applicant's LIVE echo for this project carries,
227
+ // as the reviewer may see it: { slug, name } while the applicant is still a
228
+ // member (slug null for an unlisted guild, which has no public page to open),
229
+ // else null. Read by POST /sso/applicant-profile only AFTER its echo gate, so
230
+ // it says nothing about anyone who did not apply to the calling project. The
231
+ // most recent application decides: a later solo one wrote guild_id NULL.
232
+ async function echoGuildLabel({ githubId, clientId }, { pool = defaultPool } = {}) {
233
+ if (!isNumericGithubId(githubId) || !clientId) return null;
234
+ const { rows } = await pool.query(
235
+ `SELECT g.slug, g.name, g.visibility
236
+ FROM platform_identity_application_echoes ae
237
+ JOIN platform_identity_guilds g ON g.id = ae.guild_id
238
+ JOIN platform_identity_guild_members m ON m.guild_id = ae.guild_id AND m.github_id = ae.github_id
239
+ WHERE ae.github_id = $1::bigint AND ae.client_id = $2
240
+ AND ae.applied_at > now() - make_interval(days => $3::int)
241
+ LIMIT 1`,
242
+ [String(githubId), String(clientId), APPLICATION_ECHO_WINDOW_DAYS],
243
+ );
244
+ const r = rows[0];
245
+ return r ? { slug: r.visibility === 'public' ? r.slug : null, name: r.name } : null;
246
+ }
247
+
248
+ module.exports = {
249
+ ENGAGEMENT_WINDOW_DAYS,
250
+ fileEngagement,
251
+ inviteGuild,
252
+ listMyEngagements,
253
+ engagementFor,
254
+ echoGuildLabel,
255
+ };
@@ -80,7 +80,8 @@ const PERSON_COLUMNS = 'pa.handle, pa.display_name, pa.avatar_url';
80
80
 
81
81
  // The owner test every owner-only statement re-asserts in its own WHERE, so a
82
82
  // route's pre-check is a nicer error and never the only gate. $g/$a are the
83
- // placeholders holding the guild id and the acting github_id.
83
+ // placeholders holding the guild id and the acting github_id. Exported for
84
+ // guild-engagements.js, whose owner-filed engagement asserts the same test.
84
85
  function ownerExistsSql(g, a) {
85
86
  return `EXISTS (SELECT 1 FROM platform_identity_guild_members o
86
87
  WHERE o.guild_id = ${g} AND o.github_id = ${a}::bigint
@@ -686,6 +687,7 @@ module.exports = {
686
687
  DESCRIPTION_MAX_CHARS,
687
688
  INVITE_BATCH_MAX,
688
689
  LIST_MAX,
690
+ ownerExistsSql,
689
691
  normalizeGuildSlug,
690
692
  lookupSlug,
691
693
  createGuild,
@@ -0,0 +1,64 @@
1
+ -- platform_identity_030_guild_engagements.sql — a guild's engagement with a
2
+ -- project, and the guild an application was made with (task 1002303, R27;
3
+ -- ADR 0336 D7, amended by ADR 0365).
4
+ --
5
+ -- WHAT THIS IS. One row says "this guild is applying to this project" (kind
6
+ -- 'apply', filed by the guild's owner) or "this project invited this guild"
7
+ -- (kind 'invited', written when a project's inviter names a public guild).
8
+ -- Members read it in their own inbox and may apply AS THEMSELVES through the
9
+ -- existing relay; nobody reaches a project without their own act. And the
10
+ -- application echo gains the guild the member applied with, so the project's
11
+ -- reviewer can see the label on the live applicant-profile read.
12
+ --
13
+ -- THE SHAPE CARRIES THE RULES, so no route has to remember them:
14
+ -- * NO FILER COLUMN — for the reason the guild row keeps no creator: the
15
+ -- owner row is the authority, and a permanent "filed by" would tie a person
16
+ -- to an act after they have left the guild.
17
+ -- * NO STATUS COLUMN, AND ADDING ONE WOULD BE THE BUG (ADR 0201 D1.3). The
18
+ -- hub may not hold an admission fact about someone else's project; whether
19
+ -- anyone got in is the project's own access_requests row, and it becomes
20
+ -- visible here only as membership does.
21
+ -- * THE KEY IS (guild, project, kind), so re-filing cannot pile up rows: the
22
+ -- writer's upsert restarts created_at in place.
23
+ -- * THE 30-DAY WINDOW IS READ-TIME, not a column and not a sweep
24
+ -- (ENGAGEMENT_WINDOW_DAYS in ../guild-engagements.js). A stale row is
25
+ -- invisible rather than wrong.
26
+ -- * THE GUILD FK CASCADES, so a dissolved guild takes its engagements with
27
+ -- it (the ADR 0336 D2 no-tombstone reason). The echo's guild_id is SET NULL
28
+ -- instead: the application stands without its label.
29
+ -- * client_id is the registered sso_client, never a caller-supplied origin —
30
+ -- the writer resolves the origin against the registry, as the join relay does.
31
+ --
32
+ -- NOTHING HERE IS AUTHORITY (ADR 0016 / ADR 0141). No rank, credit or admission
33
+ -- path reads these rows; an engagement admits nobody anywhere.
34
+ --
35
+ -- Additive + platform_identity_-namespaced (ADR 0083 §5). CREATE and ADD COLUMN
36
+ -- only, so the previous release still runs against this schema. Idempotent.
37
+
38
+ BEGIN;
39
+
40
+ CREATE TABLE IF NOT EXISTS platform_identity_guild_engagements (
41
+ guild_id bigint NOT NULL REFERENCES platform_identity_guilds(id) ON DELETE CASCADE,
42
+ client_id text NOT NULL,
43
+ kind text NOT NULL CHECK (kind IN ('apply', 'invited')),
44
+ created_at timestamptz NOT NULL DEFAULT now(),
45
+ PRIMARY KEY (guild_id, client_id, kind)
46
+ );
47
+
48
+ -- The project side: the member's join check and the per-project reads.
49
+ CREATE INDEX IF NOT EXISTS platform_identity_guild_engagements_client
50
+ ON platform_identity_guild_engagements (client_id);
51
+
52
+ COMMENT ON TABLE platform_identity_guild_engagements IS
53
+ 'A guild''s engagement with a project (ADR 0336 D7, ADR 0365): apply (the guild owner filed "we are applying") or invited (a project''s inviter named this public guild). No filer column, as the guild row keeps no creator. No status column: the hub holds no admission fact about someone else''s project (ADR 0201 D1.3). The 30-day window is applied when read (ENGAGEMENT_WINDOW_DAYS); nothing expires the rows. Descriptive only, never authority (ADR 0016).';
54
+
55
+ COMMENT ON COLUMN platform_identity_guild_engagements.created_at IS
56
+ 'When this engagement was most recently filed. Re-filing restarts it in place, so the 30-day read window is measured from the latest act.';
57
+
58
+ ALTER TABLE platform_identity_application_echoes
59
+ ADD COLUMN IF NOT EXISTS guild_id bigint REFERENCES platform_identity_guilds(id) ON DELETE SET NULL;
60
+
61
+ COMMENT ON COLUMN platform_identity_application_echoes.guild_id IS
62
+ 'The guild this application was made with (ADR 0365 D1), or NULL for a solo application. Written by the join relay on every application, so the most recent one decides the label: applying again on your own clears it. SET NULL when the guild is dissolved. A label only, never a status.';
63
+
64
+ COMMIT;
@@ -25,6 +25,8 @@
25
25
  // POST /guild-requests/:id/accept invitee {visibility (the card's), counted_in_totals?} or owner
26
26
  // POST /guild-requests/:id/decline invitee or owner; SILENT 204
27
27
  // DELETE /guild-requests/:id whoever opened it: cancel
28
+ // POST /guilds/:slug/engagements owner: "this guild is applying to P" {origin} (R27)
29
+ // GET /me/guild-engagements my inbox: engagements I may act on (R27)
28
30
  //
29
31
  // A GUILD IS NEVER AUTHORITY (ADR 0016 / ADR 0141). no-store everywhere: these
30
32
  // payloads are privacy-filtered per viewer (ADR 0171 D5).
@@ -36,6 +38,7 @@ const express = require('express');
36
38
  const api = require('../../../src/module-api');
37
39
  const pi = require('../platform-identity');
38
40
  const guilds = require('../guilds');
41
+ const engagements = require('../guild-engagements');
39
42
  const { isAccountActive } = require('../account-visibility');
40
43
 
41
44
  const { pool, requireBuilder, validateOrRespond, parsePagination } = api;
@@ -110,7 +113,7 @@ const COUNTED = { type: 'boolean' };
110
113
  module.exports = function guildRoutes() {
111
114
  const router = express.Router();
112
115
 
113
- router.use(['/guilds', '/me/guilds', '/me/guild-requests', '/guild-requests'], (req, res, next) => {
116
+ router.use(['/guilds', '/me/guilds', '/me/guild-requests', '/me/guild-engagements', '/guild-requests'], (req, res, next) => {
114
117
  res.set('Cache-Control', 'no-store');
115
118
  next();
116
119
  });
@@ -389,5 +392,36 @@ module.exports = function guildRoutes() {
389
392
  res.json({ outcome: 'cancelled' });
390
393
  }));
391
394
 
395
+ // rank: any-builder — owner: file "this guild is applying to P" (ADR 0365 D2,
396
+ // task 1002303). Each member then sees it in their own inbox and applies as
397
+ // themselves; filing applies for nobody, the owner included. P unknown,
398
+ // inactive, or not on the map and Public is ONE 404 — no oracle on a private
399
+ // project. The owner test is re-asserted inside the INSERT. Re-filing restarts
400
+ // the 30-day window.
401
+ router.post('/guilds/:slug/engagements', requireBuilder, handle('POST /guilds/:slug/engagements', 'guild_engagement_failed', async (req, res) => {
402
+ if (validateOrRespond(req, res, { origin: { required: true, type: 'string', maxLength: 512 } })) return;
403
+ const me = await actingAccount(req.builder);
404
+ if (me.fail) return res.fail(...me.fail);
405
+ const o = await ownerOf(me, req.params.slug);
406
+ if (o.fail) return res.fail(...o.fail);
407
+ const engagement = await engagements.fileEngagement({
408
+ guildId: o.guild.id, githubId: me.githubId, origin: req.body.origin,
409
+ }, { pool });
410
+ if (!engagement) return res.fail('unknown_project', 404);
411
+ res.json({ engagement });
412
+ }));
413
+
414
+ // rank: any-builder — MY inbox of guild engagements (ADR 0365): "G is applying
415
+ // to P" and "P invited G", for guilds I belong to, inside 30 days, naming only
416
+ // projects on the map and Public, an invite only if I was a member when it was
417
+ // sent, and never a project I already build on, hold an invite to, or applied
418
+ // to. count is the array's own length; client_id never crosses.
419
+ router.get('/me/guild-engagements', requireBuilder, handle('GET /me/guild-engagements', 'guild_engagements_read_failed', async (req, res) => {
420
+ const me = await actingAccount(req.builder);
421
+ if (me.fail) return res.fail(...me.fail);
422
+ const list = await engagements.listMyEngagements({ githubId: me.githubId }, { pool });
423
+ res.json({ engagements: list, count: list.length });
424
+ }));
425
+
392
426
  return router;
393
427
  };
@@ -5,7 +5,9 @@
5
5
  // apart, plus `applied`: the projects it has
6
6
  // asked to join and when (ADR 0201 D1.3)
7
7
  // POST /my-projects/join — apply to a project: one click plus an optional
8
- // note in the applicant's own words (task 1002330).
8
+ // note in the applicant's own words (task 1002330),
9
+ // and optionally WITH a guild that is engaging the
10
+ // project (`guild`, a slug; task 1002303, ADR 0365).
9
11
  // Relayed; the project executes and decides —
10
12
  // unless the platform's record says it is unpublished.
11
13
  // POST /my-projects/:clientId/leave — self-service offboard (reflected to the hub)
@@ -25,6 +27,8 @@ const api = require('../../../src/module-api');
25
27
  const pi = require('../platform-identity');
26
28
  const projectStats = require('../project-stats');
27
29
  const applicationEcho = require('../application-echo');
30
+ const guilds = require('../guilds');
31
+ const guildEngagements = require('../guild-engagements');
28
32
  const hubKeys = require('../hub-keys');
29
33
  const { isAccountActive } = require('../account-visibility');
30
34
  const { projectPublishability } = require('../publish-gate');
@@ -219,12 +223,30 @@ module.exports = function myProjectsRoutes() {
219
223
  if (validateOrRespond(req, res, {
220
224
  origin: { required: true, type: 'string', maxLength: 512 },
221
225
  note: { type: 'string', maxLength: APPLICANT_NOTE_MAX },
226
+ guild: { type: 'string', maxLength: 39 },
222
227
  })) return;
223
228
  try {
224
229
  const client = await pi.getClientByOrigin(req.body.origin, { pool });
225
230
  if (!client || client.status !== 'active' || !client.origin) {
226
231
  return res.fail('unknown_project', 404);
227
232
  }
233
+ // Applying WITH a guild (ADR 0365 D2). The guild must still resolve FOR THE
234
+ // CALLER — a guild they belong to, engaging this project inside the window,
235
+ // by the same rule their inbox shows it — BEFORE anything is relayed. A guild
236
+ // that no longer does (they left, it aged out, the project stopped being
237
+ // named) is a 409 with nothing relayed and nothing echoed: the applicant
238
+ // chose to apply with that guild, so the hub never files it without one.
239
+ let guildId = null;
240
+ if (req.body.guild != null) {
241
+ const slug = guilds.lookupSlug(req.body.guild);
242
+ const engaged = slug ? await guildEngagements.engagementFor({
243
+ githubId: req.builder.github_id, slug, clientId: client.client_id,
244
+ }, { pool }) : null;
245
+ if (!engaged) {
246
+ return res.fail('guild_engagement_gone', { status: 409, message: 'that guild is no longer engaging this project — apply on your own instead' });
247
+ }
248
+ guildId = engaged.id;
249
+ }
228
250
  if (await isDarkMatter(client)) {
229
251
  return res.json({ ok: true, status: 'not_published', project: { name: client.name, origin: client.origin },
230
252
  message: RELAY_SENTENCE.not_published });
@@ -256,11 +278,13 @@ module.exports = function myProjectsRoutes() {
256
278
  // covers the project's own already_pending 200, D1.4), and refreshes the date.
257
279
  // Best-effort in both directions: a lost echo must never turn a filed
258
280
  // application into a reported failure, which is the only reason this is
259
- // allowed to swallow its error.
281
+ // allowed to swallow its error. The echo carries the guild this application
282
+ // was made with — NULL when none, so a later solo application clears the
283
+ // label the reviewer sees (ADR 0365 D1).
260
284
  if (outcome.status === 'requested') {
261
285
  try {
262
286
  await applicationEcho.recordApplication(
263
- { githubId: req.builder.github_id, clientId: client.client_id }, { pool },
287
+ { githubId: req.builder.github_id, clientId: client.client_id, guildId }, { pool },
264
288
  );
265
289
  } catch (e) {
266
290
  api.logger('platform-identity').error(`join echo write failed (non-fatal): ${e && e.message}`);