@bongos/core 1.20.21 → 1.20.22

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 (44) hide show
  1. package/.bongos-core.json +67 -47
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -2
  4. package/clients/bongos-client/index.cjs +4 -2
  5. package/clients/bongos-client/index.d.ts +8 -4
  6. package/clients/bongos-client/index.mjs +4 -2
  7. package/docs/adr/0335-an-invite-is-a-pre-approved-row-on-the-projects-own-instance.md +1 -0
  8. package/docs/adr/0353-a-hub-invite-is-a-notice-of-the-projects-own-invite.md +70 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +137 -72
  11. package/docs/api-reference.md +6 -5
  12. package/docs/copy-inventory.md +196 -196
  13. package/docs/copy-registry.json +250 -250
  14. package/docs/module-api-changelog.md +2 -0
  15. package/docs/page-inventory.json +2 -1
  16. package/docs/page-readings.json +310 -306
  17. package/modules/hall-ui/public/watch.js +23 -0
  18. package/modules/onboarding/routes/access-requests.js +36 -6
  19. package/modules/platform-identity/invite-notices.js +61 -0
  20. package/modules/platform-identity/module.json +1 -1
  21. package/modules/platform-identity/platform-identity.js +12 -18
  22. package/modules/platform-identity/routes/my-projects.js +6 -15
  23. package/modules/platform-identity/routes/projects.js +22 -15
  24. package/modules/platform-identity/routes/sso-invites.js +82 -0
  25. package/modules/platform-identity/tests/platform-identity.mjs +14 -11
  26. package/modules/public-landing/public/assets/cosmos.css +5 -0
  27. package/modules/public-landing/public/projects.html +35 -29
  28. package/modules/public-landing/public/projects.states.json +1 -0
  29. package/modules/ui-design/kit/serve.js +9 -0
  30. package/package-lock.json +2 -2
  31. package/package.json +1 -1
  32. package/release-notes.json +6 -0
  33. package/src/bongos/auth-admission.js +40 -0
  34. package/src/bongos/auth.js +2 -1
  35. package/src/module-api.js +8 -1
  36. package/tests/access_requests_invite.mjs +39 -1
  37. package/tests/application_lifecycle.mjs +41 -1
  38. package/tests/catalog_only_client.mjs +5 -3
  39. package/tests/hub_invite_notices.mjs +261 -0
  40. package/tests/module_api.mjs +1 -0
  41. package/tests/my_projects_surface.mjs +11 -1
  42. package/tests/project_invite_ownership.mjs +12 -7
  43. package/tests/project_invite_ui.mjs +42 -45
  44. package/tests/watch_applications_queue.mjs +33 -2
@@ -301,9 +301,32 @@
301
301
  );
302
302
  }
303
303
 
304
+ // The name another page asked this box to hold: the platform hub's invite widget
305
+ // links here as /watch?invite=<login> (task 1002818, ADR 0353), because only this
306
+ // project's own invite lets anyone in. It FILLS the box and nothing more — sending
307
+ // stays the owner's own click, since a link anyone can craft must never be the act
308
+ // that admits. Read once and spent on first use, so the re-render after an invite
309
+ // does not put the name back; anything that is not a GitHub username is ignored.
310
+ let invitePrefill = (() => {
311
+ try {
312
+ const raw = new URLSearchParams(window.location.search).get('invite') || '';
313
+ const login = raw.trim().replace(/^@/, '');
314
+ return /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/.test(login) ? login : '';
315
+ } catch { return ''; }
316
+ })();
317
+
304
318
  function wireInviteForm(target) {
305
319
  const form = target.querySelector('#gate-invite');
306
320
  if (!form) return;
321
+ if (invitePrefill) {
322
+ const box = form.querySelector('#gate-invite-login');
323
+ if (box) {
324
+ box.value = invitePrefill;
325
+ if (box.focus) box.focus();
326
+ if (form.scrollIntoView) form.scrollIntoView({ block: 'center' });
327
+ }
328
+ invitePrefill = '';
329
+ }
307
330
  form.addEventListener('submit', async (e) => {
308
331
  e.preventDefault();
309
332
  const input = form.querySelector('#gate-invite-login');
@@ -154,6 +154,34 @@ async function attachApplicantProfiles(rows) {
154
154
  })));
155
155
  }
156
156
 
157
+ // THE HUB'S NOTICE OF THIS PROJECT'S INVITES (task 1002818, ADR 0353). On a hosted
158
+ // project the hub lists an invite in the invitee's My Projects so they learn of it;
159
+ // the row HERE is still the only thing that admits, and signing in is what accepts.
160
+ // The notice goes out after the row is written and is never awaited: it rides a
161
+ // doorway port that swallows every failure, so the hub can neither slow nor fail an
162
+ // invite. No hub (self-hosted), or an older one without the route, costs the notice
163
+ // alone.
164
+ const STANDING_INVITE_SQL = `SELECT 1 FROM access_requests
165
+ WHERE lower(github_login) = lower($1) AND status = 'invited' LIMIT 1`;
166
+
167
+ function noticeHubInvited(login) {
168
+ api.notifyHubOfInvite(login, { invited: true });
169
+ }
170
+
171
+ // After a dismissal: once the login holds no invited row (a rescind, or the last of two
172
+ // racing invites), the hub's notice would point at a door that no longer opens, so it
173
+ // is dropped. Awaited only so its read is ordered; a failed read drops nothing and fails
174
+ // nothing — the dismissal is already committed, and a stale notice still leads only to
175
+ // this project's own gate.
176
+ async function clearHubNoticeIfUninvited(login) {
177
+ try {
178
+ const standing = await pool.query(STANDING_INVITE_SQL, [login]);
179
+ if (standing.rows.length === 0) api.notifyHubOfInvite(login, { invited: false });
180
+ } catch (err) {
181
+ log.warn({ err: err && err.message }, '[gds] hub invite notice check failed (non-blocking)');
182
+ }
183
+ }
184
+
157
185
  module.exports = function buildAccessRequestsRouter() {
158
186
  const router = express.Router();
159
187
 
@@ -421,6 +449,7 @@ module.exports = function buildAccessRequestsRouter() {
421
449
  [next, req.builder.id, id]
422
450
  );
423
451
  if (rows.length > 0) {
452
+ if (next === 'dismissed') await clearHubNoticeIfUninvited(rows[0].github_login);
424
453
  return res.json({ ok: true, request: rows[0] });
425
454
  }
426
455
  const current = await pool.query(
@@ -470,7 +499,8 @@ module.exports = function buildAccessRequestsRouter() {
470
499
  // A fresh row is marked kind='invite' with created_by = the sender (a rescind
471
500
  // overwrites resolved_by) and the optional note as the inviter's words. A pending
472
501
  // request resolved in place stays an application with its applicant's own note
473
- // (ADR 0335 D2.1–D2.3; task 1002814).
502
+ // (ADR 0335 D2.1–D2.3; task 1002814). Every outcome that leaves the login invited
503
+ // also notifies the hub, so the invitee sees it in My Projects (ADR 0353).
474
504
  // body: { github_login, note? }. 201 with a new row; 200 with the row when a
475
505
  // pending request was resolved; 200 already_invited when an invite already
476
506
  // stands; 409 already_member when they have an account.
@@ -493,12 +523,10 @@ module.exports = function buildAccessRequestsRouter() {
493
523
  if (member.rows.length > 0) {
494
524
  return res.fail('already_member', { status: 409, message: 'They already have an account here — no invite needed.' });
495
525
  }
496
- const standing = await pool.query(
497
- `SELECT 1 FROM access_requests
498
- WHERE lower(github_login) = lower($1) AND status = 'invited' LIMIT 1`,
499
- [login]
500
- );
526
+ const standing = await pool.query(STANDING_INVITE_SQL, [login]);
501
527
  if (standing.rows.length > 0) {
528
+ // Re-sent on purpose: inviting again is how a notice the hub missed is healed.
529
+ noticeHubInvited(login);
502
530
  return res.json({ ok: true, already_invited: true, github_login: login });
503
531
  }
504
532
  const resolved = await pool.query(
@@ -511,6 +539,7 @@ module.exports = function buildAccessRequestsRouter() {
511
539
  [req.builder.id, login]
512
540
  );
513
541
  if (resolved.rows.length > 0) {
542
+ noticeHubInvited(login);
514
543
  return res.json({ ok: true, request: resolved.rows[0] });
515
544
  }
516
545
  const { rows } = await pool.query(
@@ -519,6 +548,7 @@ module.exports = function buildAccessRequestsRouter() {
519
548
  RETURNING id, github_login, status, kind, resolved_at`,
520
549
  [login, note, req.builder.id]
521
550
  );
551
+ noticeHubInvited(login);
522
552
  return res.status(201).json({ ok: true, request: rows[0] });
523
553
  } catch (err) {
524
554
  log.error('[gds] POST /access-requests/invite', err);
@@ -0,0 +1,61 @@
1
+ // modules/platform-identity/invite-notices.js — the hub's copy of a project's OWN invite,
2
+ // kept as a NOTICE and never as a way in (task 1002818, R6 of goal 1000110; ADR 0353).
3
+ //
4
+ // A project admits people on its own instance: an invite is an `access_requests` row
5
+ // there, and signing in to the project is what accepts it (ADR 0335 D1/D2). The hub
6
+ // holds no verdict. What it adds is reach — the invitee sees the invitation in My
7
+ // Projects without having to know the project exists. So:
8
+ //
9
+ // noteProjectInvite — the project invited this login: seed the hub's `pending`
10
+ // membership row, which My Projects lists with an "Open
11
+ // project" link. Called by POST /sso/invites/notify.
12
+ // clearProjectInvite — the project rescinded it: drop that `pending` row, so a
13
+ // rescinded invite stops pointing at a door that will not
14
+ // open. Called by POST /sso/invites/clear.
15
+ // hallInviteUrl — where an invite that CAN let someone in is sent from: the
16
+ // project's own hall. POST /projects/invite answers with it
17
+ // instead of writing a hub row that admits nobody.
18
+ //
19
+ // Both writers touch ONLY a `pending` row, so a notice can never create, downgrade or
20
+ // remove a real membership (a member is left as-is; the row is not theirs to change).
21
+ // A login with no hub account is a no-op: there is nobody on the hub to notify, and
22
+ // the project's own row still admits them when they sign in there.
23
+ //
24
+ // Its own file because platform-identity.js is at the size ratchet; it reuses that
25
+ // file's account lookup and pending-row writers rather than restating them.
26
+ 'use strict';
27
+
28
+ const pi = require('./platform-identity');
29
+
30
+ // The project noticed an invite for `githubLogin` → a `pending` hub row, if the login
31
+ // has a hub account and no membership in this project yet. Returns { noted } for the
32
+ // route's own logging; the route never tells the caller which case happened (see it).
33
+ async function noteProjectInvite({ clientId, githubLogin }, { pool } = {}) {
34
+ const account = await pi.getAccountByLogin(githubLogin, { pool });
35
+ if (!account) return { noted: false };
36
+ const out = await pi.inviteToProject({ clientId, githubId: account.github_id }, { pool });
37
+ return { noted: !!out.invited };
38
+ }
39
+
40
+ // The project rescinded the invite for `githubLogin` → drop the `pending` hub row.
41
+ async function clearProjectInvite({ clientId, githubLogin }, { pool } = {}) {
42
+ const account = await pi.getAccountByLogin(githubLogin, { pool });
43
+ if (!account) return { cleared: false };
44
+ const out = await pi.removePendingInvite({ clientId, githubId: account.github_id }, { pool });
45
+ return { cleared: out.removed };
46
+ }
47
+
48
+ // The project's own invite box for `githubLogin`: its hall's watch page, with the login
49
+ // pre-filled. Built on /builders/watch because every instance canonicalises that path to
50
+ // its hall — a one-host instance serves /watch, a two-host one redirects to
51
+ // builders.<apex>/watch, both keeping the query — so the hub needs no hall address of
52
+ // its own. An origin that is not http(s) yields null: this value becomes an <a href>.
53
+ function hallInviteUrl(origin, githubLogin) {
54
+ let u;
55
+ try { u = new URL(String(origin || '')); } catch { return null; }
56
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
57
+ const login = String(githubLogin || '').trim().replace(/^@/, '');
58
+ return `${u.origin}/builders/watch${login ? `?invite=${encodeURIComponent(login)}` : ''}`;
59
+ }
60
+
61
+ module.exports = { noteProjectInvite, clearProjectInvite, hallInviteUrl };
@@ -11,7 +11,7 @@
11
11
  "maintainer": "Cloud Bongos maintainers",
12
12
  "maintenance": { "status": "core-maintained" },
13
13
  "contributes": {
14
- "routes": ["sso", "projects", "my-projects", "scouting", "profile", "public-profile", "connections", "community-search", "community-leaderboard", "guilds", "guilds-public"],
14
+ "routes": ["sso", "sso-invites", "projects", "my-projects", "scouting", "profile", "public-profile", "connections", "community-search", "community-leaderboard", "guilds", "guilds-public"],
15
15
  "migrations": true,
16
16
  "pollers": ["code-sweep", "visibility-pull", "guild-totals"]
17
17
  },
@@ -527,8 +527,9 @@ async function listMembershipsForAccount(githubId, { pool = defaultPool } = {})
527
527
  // Invitations (ADR 0141 §5, task #1002115) — pre-seed a 'pending' membership
528
528
  // -------------------------------------------------------------------------
529
529
 
530
- // Invite a builder into a project they don't yet build on: pre-seed a 'pending'
531
- // membership. Does NOT clobber an existing membership (ON CONFLICT DO NOTHING) — a
530
+ // Record that a project invited a builder: pre-seed a 'pending' membership, which is
531
+ // a NOTICE shown in My Projects — the project's own row is what admits (ADR 0353;
532
+ // written only from invite-notices.js now). Does NOT clobber an existing membership (ON CONFLICT DO NOTHING) — a
532
533
  // current member is left as-is. Returns { invited:true, row } when a pending row was
533
534
  // created, or { invited:false, alreadyMember:true } when a membership already existed.
534
535
  async function inviteToProject({ clientId, githubId }, { pool = defaultPool } = {}) {
@@ -557,26 +558,19 @@ async function listPendingInvitesForAccount(githubId, { pool = defaultPool } = {
557
558
  return rows;
558
559
  }
559
560
 
560
- // Resolve a pending invite for (client, builder): accept → flip to 'member' (the
561
- // membership becomes real; the builder then signs into the project, whose own gate
562
- // still applies + whose check-in keeps it fresh); decline → delete the pending row.
563
- // Only ever touches a 'pending' row, so it can't downgrade or remove a real member.
564
- async function resolveInvite({ clientId, githubId, accept }, { pool = defaultPool } = {}) {
565
- if (accept) {
566
- const { rows } = await pool.query(
567
- `UPDATE platform_identity_project_memberships SET membership_kind = 'member', last_active = now()
568
- WHERE client_id = $1 AND github_id = $2 AND membership_kind = 'pending'
569
- RETURNING id`,
570
- [clientId, githubId],
571
- );
572
- return { resolved: rows.length > 0, accepted: true };
573
- }
561
+ // Drop the pending invite for (client, builder): the invitee declined it, or the
562
+ // project rescinded it (invite-notices.js). There is no hub-side ACCEPT any more
563
+ // (task 1002818, ADR 0353): the hub holds no verdict, so flipping this row to
564
+ // 'member' admitted nobody. Signing in to the project accepts, and the hub's own
565
+ // sign-in witness (recordFederatedSignin) turns the row into a membership then.
566
+ // Only ever touches a 'pending' row, so it can't remove a real member.
567
+ async function removePendingInvite({ clientId, githubId }, { pool = defaultPool } = {}) {
574
568
  const { rowCount } = await pool.query(
575
569
  `DELETE FROM platform_identity_project_memberships
576
570
  WHERE client_id = $1 AND github_id = $2 AND membership_kind = 'pending'`,
577
571
  [clientId, githubId],
578
572
  );
579
- return { resolved: rowCount > 0, accepted: false };
573
+ return { removed: rowCount > 0 };
580
574
  }
581
575
 
582
576
  // Self-service LEAVE (ADR 0141 §3): remove a builder's membership in a project. The
@@ -1453,7 +1447,7 @@ module.exports = {
1453
1447
  // invitations
1454
1448
  inviteToProject,
1455
1449
  listPendingInvitesForAccount,
1456
- resolveInvite,
1450
+ removePendingInvite,
1457
1451
  // cross-project activity + scouting + profile rollup
1458
1452
  upsertActivity,
1459
1453
  listScoutingRollup,
@@ -289,27 +289,18 @@ module.exports = function myProjectsRoutes() {
289
289
  }
290
290
  });
291
291
 
292
- // rank: any-builder — accept an invite (pending → builder) for the caller's OWN account.
293
- router.post('/my-projects/invites/:clientId/accept', requireBuilder, async (req, res) => {
294
- const clientId = String(req.params.clientId || '');
295
- if (!clientId) return res.fail('bad_client_id', 400);
296
- try {
297
- const out = await pi.resolveInvite({ clientId, githubId: req.builder.github_id, accept: true }, { pool });
298
- if (!out.resolved) return res.fail('no_pending_invite', 404);
299
- res.json({ ok: true, accepted: true });
300
- } catch (err) {
301
- console.error('[platform-identity] accept invite', err);
302
- res.fail('accept_failed', { status: 500, message: 'internal error' });
303
- }
304
- });
292
+ // There is deliberately no ACCEPT route (task 1002818, ADR 0353). An invitation here
293
+ // is a notice of the project's own invite; signing in to the project accepts it, and
294
+ // that sign-in turns this row into a membership. The old accept flipped the row to
295
+ // 'member' and admitted nobody, so an accepted invite could still meet a locked door.
305
296
 
306
297
  // rank: any-builder — decline an invite (delete the pending row) for the caller's OWN account.
307
298
  router.post('/my-projects/invites/:clientId/decline', requireBuilder, async (req, res) => {
308
299
  const clientId = String(req.params.clientId || '');
309
300
  if (!clientId) return res.fail('bad_client_id', 400);
310
301
  try {
311
- const out = await pi.resolveInvite({ clientId, githubId: req.builder.github_id, accept: false }, { pool });
312
- if (!out.resolved) return res.fail('no_pending_invite', 404);
302
+ const out = await pi.removePendingInvite({ clientId, githubId: req.builder.github_id }, { pool });
303
+ if (!out.removed) return res.fail('no_pending_invite', 404);
313
304
  res.json({ ok: true, accepted: false });
314
305
  } catch (err) {
315
306
  console.error('[platform-identity] decline invite', err);
@@ -15,7 +15,8 @@
15
15
  // join door, and the DERIVED publish verdict — ADR 0182)
16
16
  // POST /projects — Archon: create / update a catalog entry
17
17
  // POST /projects/invite — a `project.curate` holder OR the project's OWN owner:
18
- // pre-seed a 'pending' membership (invite-authz.js)
18
+ // where to invite — the project's hall (invite-authz.js,
19
+ // invite-notices.js); writes nothing (ADR 0353)
19
20
  // PATCH /projects/:id/featured — Archon: the feature / unfeature control
20
21
  // PATCH /projects/:id/status — `project.curate`: the TAKEDOWN lever (R08, ADR 0252
21
22
  // §5.1). Setting 'hidden' pins the row so the ADR 0246
@@ -31,6 +32,7 @@ const pi = require('../platform-identity');
31
32
  const projectCatalog = require('../project-catalog');
32
33
  const projectStats = require('../project-stats');
33
34
  const inviteAuthz = require('../invite-authz');
35
+ const inviteNotices = require('../invite-notices');
34
36
 
35
37
  const { pool, requireBuilder, requirePermission, validateOrRespond, parseId, corsPublicGet,
36
38
  parsePagination, pageMeta } = api;
@@ -119,16 +121,22 @@ module.exports = function projectsRoutes() {
119
121
  // route from its real gate. A helper-hidden gate would record a privileged route
120
122
  // as any-builder — wrong in the dangerous direction (the ADR 0210 rule).
121
123
 
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
124
- // OWNER (task 1003580; the ownership proof is `provisioning_instances.owner_builder_id`,
125
- // reached through a port — see invite-authz.js). The wizard's audience is any signed-in
126
- // GitHub user, typically a xenos who used to get 403 on their own project. A caller who is
127
- // neither gets the unchanged permission refusal, identical whether or not the project
128
- // exists. Pre-seeds a 'pending' membership the invitee accepts from My Projects. V1 invites
129
- // by github_login and requires the invitee to already have a platform account (they build
130
- // on some project); inviting someone entirely new to the platform (GitHub-id resolution) is
131
- // a follow-up.
124
+ // rank: metic+archon — own-or-metic in effect: where to invite a builder into a project,
125
+ // asked by a `project.curate` holder OR the project's OWN OWNER (task 1003580; the
126
+ // ownership proof is `provisioning_instances.owner_builder_id`, reached through a port —
127
+ // see invite-authz.js). The wizard's audience is any signed-in GitHub user, typically a
128
+ // xenos who used to get 403 on their own project. A caller who is neither gets the
129
+ // unchanged permission refusal, identical whether or not the project exists.
130
+ //
131
+ // IT WRITES NOTHING (task 1002818, ADR 0353). It used to pre-seed a 'pending' hub
132
+ // membership, but the hub holds no verdict: that row let nobody in, and on any door but
133
+ // `open` (new projects default to `apply`) the invitee who signed in met a locked door.
134
+ // Only the project's own invite admits (ADR 0335 D1/D2), so the answer is 200
135
+ // `{ invited: false, invite_url }` — the project's hall invite box with the login pre-filled.
136
+ // A 200, not a refusal: it is the answer every admitted caller gets, and a 4xx on the normal
137
+ // path would log a failed request in the owner's browser on every invite. Sending it there
138
+ // also reaches this hub: the project notifies it (POST /sso/invites/notify), and the invitee
139
+ // sees it in My Projects.
132
140
  router.post(
133
141
  '/projects/invite',
134
142
  requireBuilder,
@@ -150,10 +158,9 @@ module.exports = function projectsRoutes() {
150
158
  // It is only ever absent when the gate found nothing, so the fallback still reads.
151
159
  const client = req.inviteTargetClient || await pi.getClient(req.body.client_id, { pool });
152
160
  if (!client || client.status !== 'active') return res.fail('unknown_project', 404);
153
- const account = await pi.getAccountByLogin(req.body.github_login, { pool });
154
- if (!account) return res.fail('account_not_found', { status: 404, message: 'That builder has no platform account yet — they must sign in somewhere once before they can be invited.' });
155
- const out = await pi.inviteToProject({ clientId: req.body.client_id, githubId: account.github_id }, { pool });
156
- res.status(out.invited ? 201 : 200).json(out);
161
+ const inviteUrl = inviteNotices.hallInviteUrl(client.origin, req.body.github_login);
162
+ if (!inviteUrl) return res.fail('unknown_project', 404);
163
+ return res.json({ invited: false, invite_url: inviteUrl });
157
164
  } catch (err) {
158
165
  console.error('[platform-identity] POST /projects/invite', err);
159
166
  res.fail('invite_failed', { status: 500, message: 'internal error' });
@@ -0,0 +1,82 @@
1
+ // modules/platform-identity/routes/sso-invites.js — a project tells the hub about its OWN
2
+ // invites, so the invitee sees them in My Projects (task 1002818, R6 of goal 1000110;
3
+ // ADR 0353). Server-to-server, authenticated by client_id + client_secret like the
4
+ // membership check-in; the sender is core's notifyHubOfInvite (src/bongos/auth-admission.js).
5
+ //
6
+ // POST /sso/invites/notify — the project invited this GitHub login → a `pending` hub row
7
+ // POST /sso/invites/clear — the project rescinded it → that `pending` row is dropped
8
+ //
9
+ // The row is a NOTICE, not a way in: the project's own `access_requests` row admits, and
10
+ // the invitee accepts by signing in to the project, which the hub's own sign-in witness
11
+ // turns into a membership (recordFederatedSignin). Both calls touch a `pending` row only
12
+ // (invite-notices.js), so client credentials still cannot create or change a membership —
13
+ // the ADR 0205 line.
14
+ //
15
+ // THE ANSWER IS ALWAYS { ok: true } once the client authenticates, whether or not the
16
+ // login has a hub account or a row changed. The sender never reads it, and a project must
17
+ // not be able to use this route to learn which GitHub logins have platform accounts.
18
+ //
19
+ // WIRE SKEW (ADR 0352): these routes are NEW, so an older hub answers 404 and the sender
20
+ // swallows it — the invite still admits on the project, only the notice is lost. The body
21
+ // is validated strictly, so a field added later must ride a header, or ship on the hub and
22
+ // be released before any instance sends it.
23
+ //
24
+ // Its own file rather than more of routes/sso.js, which carries the sign-in legs.
25
+ 'use strict';
26
+
27
+ const express = require('express');
28
+ const api = require('../../../src/module-api');
29
+ const pi = require('../platform-identity');
30
+ const inviteNotices = require('../invite-notices');
31
+
32
+ const log = api.logger('platform-identity');
33
+ const { pool, validateOrRespond } = api;
34
+
35
+ // After each route's own schema (spelled inline, so the API docs read it): authenticate
36
+ // the project, then apply the notice to ITS OWN client_id — never a project named in the
37
+ // body — and answer the same way whether or not anything was written.
38
+ function applyNotice(apply, label) {
39
+ return async (req, res) => {
40
+ const { client_id, client_secret, github_login } = req.body;
41
+ try {
42
+ const client = await pi.getClient(client_id, { pool });
43
+ if (!client || client.status !== 'active' || !pi.clientSecretValid(client, client_secret)) {
44
+ return res.fail('invalid_client', 401);
45
+ }
46
+ await apply({ clientId: client.client_id, githubLogin: github_login }, { pool });
47
+ res.json({ ok: true });
48
+ } catch (err) {
49
+ log.error(`[platform-identity] ${label}`, err);
50
+ res.fail('invite_notice_failed', { status: 500, message: 'internal error' });
51
+ }
52
+ };
53
+ }
54
+
55
+ module.exports = function ssoInviteRoutes() {
56
+ const router = express.Router();
57
+
58
+ const notify = applyNotice(inviteNotices.noteProjectInvite, '/sso/invites/notify');
59
+ const clear = applyNotice(inviteNotices.clearProjectInvite, '/sso/invites/clear');
60
+
61
+ // rank: public — authenticated by client_id + client_secret, not a builder rank.
62
+ router.post('/sso/invites/notify', (req, res) => {
63
+ if (validateOrRespond(req, res, {
64
+ client_id: { required: true, type: 'string', maxLength: 200 },
65
+ client_secret: { required: true, type: 'string', maxLength: 512 },
66
+ github_login: { required: true, type: 'string', minLength: 1, maxLength: 200 },
67
+ })) return undefined;
68
+ return notify(req, res);
69
+ });
70
+
71
+ // rank: public — authenticated by client_id + client_secret, not a builder rank.
72
+ router.post('/sso/invites/clear', (req, res) => {
73
+ if (validateOrRespond(req, res, {
74
+ client_id: { required: true, type: 'string', maxLength: 200 },
75
+ client_secret: { required: true, type: 'string', maxLength: 512 },
76
+ github_login: { required: true, type: 'string', minLength: 1, maxLength: 200 },
77
+ })) return undefined;
78
+ return clear(req, res);
79
+ });
80
+
81
+ return router;
82
+ };
@@ -529,7 +529,7 @@ await ta('listMembershipsForAccount joins projects + excludes pending', async ()
529
529
  assert.deepEqual(c.params, [42]);
530
530
  });
531
531
 
532
- console.log('\ninvitations (§5 — pre-seed pending membership):');
532
+ console.log('\ninvitations (§5 — a pending row is a NOTICE of the project\'s own invite, ADR 0353):');
533
533
 
534
534
  await ta('inviteToProject pre-seeds a pending row, never clobbering an existing member', async () => {
535
535
  const pool = mockPool([{ rows: [{ id: '1', membership_kind: 'pending' }] }]);
@@ -547,20 +547,23 @@ await ta('inviteToProject reports already-member when the row exists (DO NOTHING
547
547
  assert.deepEqual(out, { invited: false, alreadyMember: true });
548
548
  });
549
549
 
550
- await ta('resolveInvite accept flips ONLY a pending row to builder', async () => {
551
- const pool = mockPool([{ rows: [{ id: '1' }] }]);
552
- const out = await P.resolveInvite({ clientId: 'c1', githubId: 42, accept: true }, { pool });
553
- assert.deepEqual(out, { resolved: true, accepted: true });
554
- const c = pool.calls[0];
555
- assert.match(c.text, /UPDATE platform_identity_project_memberships SET membership_kind = 'member'/);
556
- assert.match(c.text, /AND membership_kind = 'pending'/, 'only touches pending — never a real member');
550
+ await ta('there is no hub-side accept any more — signing in to the project accepts (ADR 0353)', async () => {
551
+ assert.equal(P.resolveInvite, undefined, 'the accept flip admitted nobody and is gone');
552
+ assert.equal(typeof P.removePendingInvite, 'function');
557
553
  });
558
554
 
559
- await ta('resolveInvite decline deletes ONLY a pending row', async () => {
555
+ await ta('removePendingInvite deletes ONLY a pending row (decline, or the project rescinding)', async () => {
560
556
  const pool = mockPool([{ rowCount: 1 }]);
561
- const out = await P.resolveInvite({ clientId: 'c1', githubId: 42, accept: false }, { pool });
562
- assert.deepEqual(out, { resolved: true, accepted: false });
557
+ const out = await P.removePendingInvite({ clientId: 'c1', githubId: 42 }, { pool });
558
+ assert.deepEqual(out, { removed: true });
563
559
  assert.match(pool.calls[0].text, /DELETE FROM platform_identity_project_memberships[\s\S]*AND membership_kind = 'pending'/);
560
+ assert.deepEqual(pool.calls[0].params, ['c1', 42]);
561
+ });
562
+
563
+ await ta('removePendingInvite reports nothing removed when there was no pending row', async () => {
564
+ const pool = mockPool([{ rowCount: 0 }]);
565
+ const out = await P.removePendingInvite({ clientId: 'c1', githubId: 42 }, { pool });
566
+ assert.deepEqual(out, { removed: false });
564
567
  });
565
568
 
566
569
  await ta('getAccountByLogin resolves case-insensitively, newest first', async () => {
@@ -431,6 +431,7 @@ h1{font-family:var(--font-display);font-weight:700;font-size:clamp(24px,3.4vw,32
431
431
  .inviteList>li::before{content:"";grid-column:1;grid-row:1;width:7px;height:7px;margin-top:7px;border-radius:50%;border:1px solid var(--accent);background:var(--bg);}
432
432
  .inviteList b{grid-column:2;font-family:var(--font-body);font-weight:500;font-size:15px;color:var(--ink);}
433
433
  .inviteList small{grid-column:2;font-family:var(--font-body);font-size:13px;font-weight:400;line-height:1.45;color:var(--ink-faint);}
434
+ .inviteList a{grid-column:2;justify-self:start;margin-top:6px;}
434
435
  /* who is worth inviting (task 1002347, BV1.R28) — the recommendation's own
435
436
  sentence, in the .modWhy voice: the panel explaining itself, never a thing to
436
437
  act on, so it never competes with the names under it. */
@@ -1386,6 +1387,10 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
1386
1387
  #view-mine .addr a,#view-manage .ledger a{font-family:var(--font-mono);font-size:12.5px;color:var(--ink-soft);}
1387
1388
  #view-mine .addr a:hover,#view-manage .ledger a:hover{color:var(--ink);text-decoration:underline;}
1388
1389
  #view-mine .projMsg a,#view-manage .mSec a{color:var(--ink);text-decoration:underline;}
1390
+ /* a BUTTON that is a link inside a manage section (the invite hand-off, task 1002818) keeps
1391
+ the button's box: the rule above would make it an underlined inline-block with its label
1392
+ stuck to the top of the pill */
1393
+ #view-manage .mSec a.btn{display:inline-flex;text-decoration:none;}
1389
1394
 
1390
1395
  /* ── the lifecycle rail (DESIGN.md): stripMarkup's markup, drawn as three
1391
1396
  hairlines with a 7px dot and one lit step — no plate, no connector, no