@bongos/core 1.21.56 → 1.21.58

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 (51) hide show
  1. package/.bongos-core.json +106 -46
  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 +361 -315
  13. package/docs/copy-registry.json +868 -419
  14. package/docs/file-map.md +4 -0
  15. package/docs/module-api-changelog.md +4 -0
  16. package/docs/page-inventory.json +38 -4
  17. package/docs/page-readings.json +856 -771
  18. package/modules/platform-identity/application-echo.js +11 -5
  19. package/modules/platform-identity/guild-engagements.js +255 -0
  20. package/modules/platform-identity/guilds.js +3 -1
  21. package/modules/platform-identity/migrations/platform_identity_030_guild_engagements.sql +64 -0
  22. package/modules/platform-identity/routes/guilds.js +35 -1
  23. package/modules/platform-identity/routes/my-projects.js +27 -3
  24. package/modules/platform-identity/routes/projects.js +44 -0
  25. package/modules/platform-identity/routes/sso.js +21 -3
  26. package/modules/public-landing/public/assets/cosmos.css +7 -0
  27. package/modules/public-landing/public/guild.html +633 -0
  28. package/modules/public-landing/public/guild.probes.json +63 -0
  29. package/modules/public-landing/public/guild.states.json +43 -0
  30. package/modules/public-landing/public/projects.html +252 -16
  31. package/modules/public-landing/public/projects.probes.json +26 -1
  32. package/modules/public-landing/public/projects.states.json +4 -0
  33. package/modules/ui-design/kit/fixtures/guilds.json +73 -0
  34. package/modules/ui-design/kit/fixtures/me__guild-engagements.json +7 -0
  35. package/modules/ui-design/kit/serve.js +99 -5
  36. package/package-lock.json +2 -2
  37. package/package.json +1 -1
  38. package/release-notes.json +12 -0
  39. package/scripts/gds/run-unit-tests.js +5 -0
  40. package/src/module-api.js +1 -1
  41. package/src/platform-server.js +13 -0
  42. package/tests/applicant_profile_boundary.mjs +104 -1
  43. package/tests/application_echo.mjs +91 -5
  44. package/tests/guild_engagement.mjs +881 -0
  45. package/tests/guild_engagement_db.mjs +240 -0
  46. package/tests/hub_engagement_cards.mjs +307 -0
  47. package/tests/hub_guild_page.mjs +624 -0
  48. package/tests/privacy_matrix.mjs +216 -14
  49. package/tests/project_invite_ui.mjs +187 -3
  50. package/tests/render_check.mjs +2 -2
  51. package/tests/ui_design_kit.mjs +3 -3
@@ -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}`);
@@ -17,6 +17,9 @@
17
17
  // POST /projects/invite — a `project.curate` holder OR the project's OWN owner:
18
18
  // where to invite — the project's hall (invite-authz.js,
19
19
  // invite-notices.js); writes nothing (ADR 0353)
20
+ // POST /projects/invite-guild — the same gate: invite a PUBLIC guild — records the
21
+ // `invited` engagement and answers one hall link per
22
+ // member on its public roster (ADR 0365 D3)
20
23
  // PATCH /projects/:id/featured — Archon: the feature / unfeature control
21
24
  // PATCH /projects/:id/status — `project.curate`: the TAKEDOWN lever (R08, ADR 0252
22
25
  // §5.1). Setting 'hidden' pins the row so the ADR 0246
@@ -33,6 +36,8 @@ const projectCatalog = require('../project-catalog');
33
36
  const projectStats = require('../project-stats');
34
37
  const inviteAuthz = require('../invite-authz');
35
38
  const inviteNotices = require('../invite-notices');
39
+ const guilds = require('../guilds');
40
+ const guildEngagements = require('../guild-engagements');
36
41
 
37
42
  const { pool, requireBuilder, requirePermission, validateOrRespond, parseId, corsPublicGet,
38
43
  parsePagination, pageMeta } = api;
@@ -168,6 +173,45 @@ module.exports = function projectsRoutes() {
168
173
  },
169
174
  );
170
175
 
176
+ // rank: metic+archon — own-or-metic in effect, the SAME gate as POST /projects/invite
177
+ // above and for the same reasons (a non-inviter gets that route's unchanged refusal,
178
+ // identical whether or not the project exists): a project invites a PUBLIC guild
179
+ // (task 1002303, ADR 0365 D3, amending ADR 0336 D7).
180
+ //
181
+ // IT WRITES NO INVITES (ADR 0353). It records the `invited` engagement — so members
182
+ // the inviter could not see get a "P invited this guild" notice and may apply
183
+ // themselves, and only while P is on the map and Public (ADR 0365 D4) — and answers
184
+ // one hall hand-off link per member on the guild's PUBLIC roster at this moment.
185
+ // The inviter sends each from the project's hall, so every invite is the project's
186
+ // own row. Members who join later inherit nothing. An unlisted guild cannot be
187
+ // addressed, so it answers the unknown-guild 404 byte for byte.
188
+ router.post(
189
+ '/projects/invite-guild',
190
+ requireBuilder,
191
+ inviteAuthz.requireProjectInviter({ curateGate: api.requirePermission('project.curate'), pool, log }),
192
+ async (req, res) => {
193
+ if (validateOrRespond(req, res, {
194
+ client_id: { required: true, type: 'string', maxLength: 200 },
195
+ guild: { required: true, type: 'string', maxLength: 39 },
196
+ })) return;
197
+ try {
198
+ const client = req.inviteTargetClient || await pi.getClient(req.body.client_id, { pool });
199
+ if (!client || client.status !== 'active' || !inviteNotices.hallInviteUrl(client.origin)) {
200
+ return res.fail('unknown_project', 404);
201
+ }
202
+ const slug = guilds.lookupSlug(req.body.guild);
203
+ const out = slug ? await guildEngagements.inviteGuild({
204
+ clientId: client.client_id, origin: client.origin, slug,
205
+ }, { pool }) : null;
206
+ if (!out) return res.fail('guild_not_found', 404);
207
+ return res.json({ invited: false, guild: out.guild, invites: out.invites, count: out.invites.length });
208
+ } catch (err) {
209
+ log.error({ err: err && err.message }, 'POST /projects/invite-guild failed');
210
+ res.fail('invite_guild_failed', { status: 500, message: 'internal error' });
211
+ }
212
+ },
213
+ );
214
+
171
215
  // rank: archon — the feature / unfeature control (the ADR 0141 §5 curation gate),
172
216
  // BOUNDED BY THE PUBLISH GATE (ADR 0182 D3, task 1002335). Featuring ranks among
173
217
  // projects already on the map; it is not a way ONTO it, so featuring a dark-matter
@@ -27,6 +27,8 @@ const hubDevice = require('../hub-device');
27
27
  const applicantProfile = require('../applicant-profile');
28
28
  const terms = require('../terms-acceptance');
29
29
  const applicationEcho = require('../application-echo');
30
+ // The guild label the echo carries (task 1002303, ADR 0365 D5).
31
+ const guildEngagements = require('../guild-engagements');
30
32
  const instanceFeedback = require('../instance-feedback');
31
33
 
32
34
  // One limiter per hub process for POST /sso/feedback (task 1004462).
@@ -638,8 +640,11 @@ module.exports = function ssoRoutes() {
638
640
  // profile view for its own reviewer queue, LIVE at render (privacy spec D7,
639
641
  // task 1002972). Server-to-server, client_id+client_secret auth, like the two
640
642
  // routes above. Answers { profile: null | { view:'public', … } | { view:
641
- // 'sliver', … } } — the D7 rule, decided entirely inside
642
- // ../applicant-profile.js.
643
+ // 'sliver', … }, guild } — the D7 rule, decided entirely inside
644
+ // ../applicant-profile.js, plus the guild the applicant applied with
645
+ // (ADR 0365 D5): { slug, name } while they are still a member of it (slug null
646
+ // for an unlisted guild), else null. A project on an older core reads only
647
+ // `profile` and ignores the rest (the ADR 0201 D2.2 compatibility rule).
643
648
  //
644
649
  // WHY A POST FOR A READ. The same reason /sso/activity/rollup is one: the
645
650
  // credentials are in the body, and a client_secret in a query string lands in
@@ -693,12 +698,25 @@ module.exports = function ssoRoutes() {
693
698
  );
694
699
  if (!applied) return res.fail('no_application', 404);
695
700
  const profile = await applicantProfile.getApplicantProfileView(b.github_id, { pool });
701
+ // The guild label rides only beside a profile: an applicant who discloses
702
+ // nothing discloses no guild either. BEST-EFFORT — a label is a convenience
703
+ // on the reviewer's queue, so a failed read answers null and never costs
704
+ // the profile. Read only here, after the echo gate above.
705
+ let guild = null;
706
+ if (profile) {
707
+ try {
708
+ guild = await guildEngagements.echoGuildLabel({ githubId: b.github_id, clientId: client.client_id }, { pool });
709
+ } catch (e) {
710
+ log.error('[platform-identity] /sso/applicant-profile guild label read failed (non-fatal)', e && e.message);
711
+ guild = null;
712
+ }
713
+ }
696
714
  // The rollup half is live-derived and hide must beat any cache (ADR 0171
697
715
  // D5) — and D7's "live at review time" says the same thing louder: a
698
716
  // cached applicant view is a snapshot, which is the one thing the rule
699
717
  // forbids.
700
718
  res.set('Cache-Control', 'no-store');
701
- res.json({ profile });
719
+ res.json({ profile, guild });
702
720
  } catch (err) {
703
721
  log.error('[platform-identity] /sso/applicant-profile', err);
704
722
  res.fail('applicant_profile_failed', { status: 500, message: 'internal error' });
@@ -1325,6 +1325,13 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
1325
1325
  .mineJoinRow input::placeholder{color:var(--ink-faint);}
1326
1326
  .mineJoinNote{display:block;width:100%;box-sizing:border-box;margin-top:10px;min-height:64px;padding:12px 16px;font:inherit;font-size:14px;line-height:1.5;color:var(--ink);background:var(--bg-card);border:1px solid var(--ctl-line);border-radius:14px;resize:vertical;}
1327
1327
  .mineJoinNote::placeholder{color:var(--ink-faint);}
1328
+ /* the guild cards (task 1002303, R27): a second list under the invitations, one
1329
+ hairline between the two when both have rows; each card's note waits behind
1330
+ "Add a note", and its status line takes no room until it has something to say */
1331
+ .mineRows:not(:empty)+.mineRows .mineRow:first-child{border-top:1px solid var(--rule-soft);}
1332
+ .engageRow .mineJoinNote{max-width:62ch;}
1333
+ .engageRow .projMsg{margin-top:6px;min-height:0;}
1334
+ .engageRow .projMsg:empty{margin:0;}
1328
1335
 
1329
1336
  /* ── the ledger row: planet · name and chip · the facts · the rail · the
1330
1337
  doors. The planet and the doors are fixed widths; the three middle tracks