@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
@@ -998,11 +998,13 @@ summary{min-height:24px;padding:3px 0;}
998
998
  only if there is a way back. The SAME widget on the SAME route
999
999
  (POST /projects/invite), admitted here by the project's own ownership
1000
1000
  (ADR 0248); like its neighbour above, nothing is pushed to the project and
1001
- nothing restarts, so there is no "what your project runs" read-back line. -->
1001
+ nothing restarts, so there is no "what your project runs" read-back line.
1002
+ Since task 1002818 (ADR 0353) the route only says WHERE to invite — the
1003
+ project's own hall — and each name becomes a link that finishes it there. -->
1002
1004
  <div class="card mSec" id="mInvite">
1003
1005
  <h2>Invite a builder</h2>
1004
1006
  <div id="mInv">
1005
- <p class="finePrint">By GitHub username. They get an invitation to accept from their own projects page, and they need to have signed in here at least once.</p>
1007
+ <p class="finePrint">By GitHub username. The invite is sent from your project’s own hall — that is the invite that lets them in — and they see it on their projects page here too.</p>
1006
1008
  <div class="attach">
1007
1009
  <label class="vh" for="mInviteWho">GitHub username</label>
1008
1010
  <input type="text" id="mInviteWho" placeholder="GitHub username" autocomplete="off" autocapitalize="off" spellcheck="false" maxlength="200">
@@ -1490,7 +1492,7 @@ summary{min-height:24px;padding:3px 0;}
1490
1492
  shows it. -->
1491
1493
  <div class="inviteBox" id="wizInvite" hidden>
1492
1494
  <h2 class="stepsHead">Invite builders</h2>
1493
- <p class="finePrint">Optional — invite people by GitHub username. Each one gets an invitation to accept from their own projects page, and they need to have signed in here at least once. Skip this and invite anyone later; nothing about your project waits on it.</p>
1495
+ <p class="finePrint">Optional — invite people by GitHub username. Each invite is sent from your project’s own hall, which is what lets them in, and they see it on their projects page here too. Skip this and invite anyone later; nothing about your project waits on it.</p>
1494
1496
  <div class="attach">
1495
1497
  <label class="vh" for="wizInviteWho">GitHub username</label>
1496
1498
  <input type="text" id="wizInviteWho" placeholder="GitHub username" autocomplete="off" autocapitalize="off" spellcheck="false" maxlength="200">
@@ -3184,8 +3186,10 @@ summary{min-height:24px;padding:3px 0;}
3184
3186
 
3185
3187
  /* Each invitation renders as a way IN, never an Accept button: the hub's own
3186
3188
  accept flip never admits anyone to the project's own instance (ADR 0335)
3187
- — signing in there is what actually accepts it (task 1002969). Decline
3188
- still works; it only clears the hub's own pending row. */
3189
+ — signing in there is what actually accepts it (task 1002969). Since task
3190
+ 1002818 (ADR 0353) the row is the project's own notice of its invite and the
3191
+ hub has no accept route at all. Decline still works; it only clears the
3192
+ hub's own pending row. */
3189
3193
  function renderInvitesList(invites) {
3190
3194
  var section = $('mineInvites');
3191
3195
  var ul = $('invitesList');
@@ -8550,9 +8554,17 @@ summary{min-height:24px;padding:3px 0;}
8550
8554
  IT NEVER BLOCKS CREATION. The project is filed before the step is shown at all;
8551
8555
  a refusal leaves the box exactly as it was, ready to try again.
8552
8556
 
8553
- WHAT IT PUBLISHES OF AN INVITEE is the 201 row's own membership_kind and the
8554
- login the inviter typed — never the github_id the row carries, which belongs to
8555
- someone who has accepted nothing yet.
8557
+ THE INVITE ITSELF IS SENT FROM THE PROJECT'S OWN HALL (task 1002818, ADR 0353).
8558
+ Only the project's own invite admits anyone; a hub row never did, so on any door
8559
+ but `open` an invitee who signed in met a locked door. The route now writes
8560
+ nothing and answers `{ invited: false, invite_url }` — the hall's invite box
8561
+ with the login filled in — and each name becomes a line whose
8562
+ link finishes the invite there. It is a link the owner clicks, never a window
8563
+ opened for them: that open would come from a network answer, not a click, and
8564
+ browsers block it. The hall's invite also reaches the invitee's projects page
8565
+ here, because the project notifies the hub (POST /sso/invites/notify).
8566
+
8567
+ WHAT IT PUBLISHES OF AN INVITEE is the login the inviter typed and nothing else.
8556
8568
  Proof: ../../../tests/project_invite_ui.mjs ══ */
8557
8569
 
8558
8570
  var INVITE_MOUNTS = {
@@ -8645,16 +8657,16 @@ summary{min-height:24px;padding:3px 0;}
8645
8657
  }).catch(function () { setInviteSuggestions(id, null); });
8646
8658
  }
8647
8659
 
8648
- /* One line per invite this session filed: the login the inviter typed, and the
8649
- WORD THE ROW ITSELF CARRIES — so a membership_kind other than 'pending' shows
8650
- up here rather than being papered over as one. */
8660
+ /* One line per name handed to the hall this session: the login the inviter
8661
+ typed, and the link that finishes its invite there. The line says the invite is
8662
+ NOT sent yet, because it is not — the hall's Invite button is what sends it. */
8651
8663
  function renderInviteRows(id) {
8652
8664
  var m = INVITE_MOUNTS[id];
8653
8665
  var list = m && $(m.list);
8654
8666
  if (!list) return;
8655
8667
  list.innerHTML = (inviteRows[id] || []).map(function (r) {
8656
- var said = r.kind === 'pending' ? 'invited — waiting for them to accept.' : r.kind;
8657
- return '<li><b>' + esc(r.login) + '</b><small>' + esc(said) + '</small></li>';
8668
+ return '<li><b>' + esc(r.login) + '</b><small>not sent yet — finish it on your project’s hall.</small>'
8669
+ + '<a class="btn small" href="' + esc(r.url) + '" target="_blank" rel="noopener">Send the invite →</a></li>';
8658
8670
  }).join('');
8659
8671
  /* the two lists are one answer: a name that just became a pending row is no
8660
8672
  longer a suggestion, and they are always redrawn together. */
@@ -8673,14 +8685,13 @@ summary{min-height:24px;padding:3px 0;}
8673
8685
  each. PURE, so the harness reads it directly. A code this page does not know
8674
8686
  falls through to the SERVER'S own words rather than an invented reason. */
8675
8687
  function inviteRefusal(code, status, message) {
8676
- if (status === 404 && code === 'account_not_found') return 'That builder hasn’t signed in here yet — ask them to sign in once, then invite them.';
8677
8688
  if (status === 404 && code === 'unknown_project') return 'This project isn’t on the map yet — give it a moment and try again.';
8678
8689
  /* deliberately says nothing about the project: see the header */
8679
8690
  if (status === 403) return 'This account can’t invite people here.';
8680
8691
  return message || 'The invitation couldn’t be sent — try again in a moment.';
8681
8692
  }
8682
8693
 
8683
- /* doInvite(id) — one builder, one request. */
8694
+ /* doInvite(id) — one builder, one request: where to send their invite. */
8684
8695
  function doInvite(id) {
8685
8696
  var m = INVITE_MOUNTS[id];
8686
8697
  var slug = inviteSlug[id] || '';
@@ -8693,28 +8704,23 @@ summary{min-height:24px;padding:3px 0;}
8693
8704
  return;
8694
8705
  }
8695
8706
  inviteBusy[id] = true;
8696
- inviteSay(id, 'Sending the invitation…', '');
8707
+ inviteSay(id, 'Finding your project’s invite page…', '');
8697
8708
  fetch(API + '/projects/invite', {
8698
8709
  method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
8699
8710
  body: JSON.stringify({ client_id: slug, github_login: login })
8700
8711
  }).then(function (res) {
8701
8712
  return jsonOrEmpty(res).then(function (data) {
8702
8713
  inviteBusy[id] = false;
8703
- if (res.ok) {
8704
- if (data && data.invited && data.row) {
8705
- inviteRows[id] = (inviteRows[id] || []).concat([{ login: login, kind: String(data.row.membership_kind || 'pending') }]);
8706
- renderInviteRows(id);
8707
- if (field) { field.value = ''; if (field.focus) field.focus(); }
8708
- inviteSay(id, login + ' is invited — they accept it from their own projects page.', 'ok');
8709
- return;
8710
- }
8711
- /* 200 { invited: false, alreadyMember: true } — not a failure, and not a
8712
- pending invite either: they are already on the project */
8713
- if (field) field.value = '';
8714
- inviteSay(id, login + ' is already on this project.', 'ok');
8714
+ if (res.status === 401) { inviteSay(id, signInAgain(), 'err', true); return; }
8715
+ /* the answer an owner is meant to get: the hall's own invite box for this name */
8716
+ var url = res.ok && data ? safeHttpUrl(data.invite_url) : '';
8717
+ if (url) {
8718
+ inviteRows[id] = (inviteRows[id] || []).concat([{ login: login, url: url }]);
8719
+ renderInviteRows(id);
8720
+ if (field) { field.value = ''; if (field.focus) field.focus(); }
8721
+ inviteSay(id, 'One more step: send ' + login + '’s invite from your project’s hall — its page opens with the name filled in. They’ll see it here on their projects page too.', 'ok');
8715
8722
  return;
8716
8723
  }
8717
- if (res.status === 401) { inviteSay(id, signInAgain(), 'err', true); return; }
8718
8724
  var e = apiError(data, res.status);
8719
8725
  inviteSay(id, inviteRefusal(e.code, res.status, e.message), 'err');
8720
8726
  });
@@ -24,6 +24,7 @@
24
24
  "in-manage-card": { "auth": true, "url": "/projects?view=manage&id=101", "expect": { "visible": ["#mCard", "#mCardPick .pick .pickPreview .cardBand", "#mCardPick [data-card-save]", "#mCardWhat summary"], "count": [["#mCardPick .pickCells[role=\"radiogroup\"]", 1], ["#mCardPick .pickCell", 5], ["#mCardPick .pickCell.on", 1]], "checked": "#mCardPick input[name=\"mCardBackdrop\"][value=\"\"]", "attr": [["#mCardPick .cardBand", "data-own", ""]] } },
25
25
  "in-manage-kind": { "auth": true, "url": "/projects?view=manage&id=101", "expect": {"visible":["#mKind","#mKindPick .modBand","#mKindWhat summary"],"count":[["#mKindPick .modOpt",5],["#mKindPick .modOpt[aria-pressed=\"true\"]",0]],"text":[["#mKindPick .modOpt b","Game"],["#mKindPick .finePrint","This project has never said what kind it is. Saying so puts the word beside its name wherever it is listed; it changes nothing else."]]} },
26
26
  "in-manage-invite": { "auth": true, "url": "/projects?view=manage&id=101", "expect": { "visible": ["#mInvite", "#mInvite h2", "#mInviteWho", "#mInvite [data-invite-send]"] } },
27
+ "in-manage-invite-handoff": { "auth": true, "url": "/projects?view=manage&id=101", "actions": [["wait",900],["fill","#mInviteWho","octo-builder"],["click","#mInvite [data-invite-send]"],["wait",400]], "expect": {"visible":["#mInviteList a","#mInviteMsg"],"hidden":[]} },
27
28
  "in-manage-app-live": { "auth": true, "url": "/projects?view=manage&id=101", "actions": [["wait",900],["focus","#mApp a"]], "expect": {"visible":["#mAppCard","#mApp .appStat","#mApp .appVars"],"hidden":[]} },
28
29
  "in-manage-app-building": { "auth": true, "url": "/projects?view=manage&id=102", "actions": [["wait",900],["focus","#mApp a"]], "expect": {"visible":["#mAppCard","#mApp .appStages"],"hidden":[]} },
29
30
  "in-manage-app-failed": { "auth": true, "url": "/projects?view=manage&id=105", "actions": [["wait",900],["focus","#mApp .appRetryBtn"]], "expect": {"visible":["#mAppCard","#mApp p.err","#mApp .appRetryBtn"],"hidden":["#mApp .appRetry"]} },
@@ -402,6 +402,15 @@ const server = http.createServer((req, res) => {
402
402
  }
403
403
  if (/^\/my-projects\/[^/]+\/leave$/.test(rest) && req.method === 'POST') return AUTH ? json(res, 200, { ok: true, left: true }) : json(res, 401, UNAUTH);
404
404
  if (/^\/my-projects\/invites\/[^/]+\/decline$/.test(rest) && req.method === 'POST') return AUTH ? json(res, 200, { ok: true, accepted: false }) : json(res, 401, UNAUTH);
405
+ // the hub's invite (task 1002818, ADR 0353) writes nothing and hands the owner to the
406
+ // project's own hall — the stub answers with that hand-off, so the widget's line and
407
+ // link render rather than a refusal
408
+ if (rest === '/projects/invite' && req.method === 'POST') {
409
+ return AUTH ? readBody(req, (body) => json(res, 200, {
410
+ invited: false,
411
+ invite_url: 'https://mercury.example.test/builders/watch?invite=' + encodeURIComponent(body.github_login || ''),
412
+ })) : json(res, 401, UNAUTH);
413
+ }
405
414
  // the manage flows (task 1003345): every door on a row and on the manage page only ASKS
406
415
  // (ADR 0111) — the stub answers each ask with the route's receipt so the probes can drive
407
416
  // attach / restart / teardown / revive / join end to end; nothing changes in the fixture
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.21",
3
+ "version": "1.20.22",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.21",
9
+ "version": "1.20.22",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.21",
3
+ "version": "1.20.22",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -8182,5 +8182,11 @@
8182
8182
  "id": "1002306",
8183
8183
  "text": "The hub can now list guilds ranked by size or by how much their members have shipped, using the rounded weekly totals. Only public guilds with visible members appear, and private members are never counted."
8184
8184
  }
8185
+ ],
8186
+ "1.20.22": [
8187
+ {
8188
+ "id": "1002818",
8189
+ "text": "When a project invites someone, that person now sees the invitation on their Cloud Bongos projects page, and signing in to the project is what accepts it. Taking an invite back removes it there too. Inviting from the platform'"
8190
+ }
8185
8191
  ]
8186
8192
  }
@@ -475,6 +475,45 @@ async function readApplicantProfileFromHub(githubId) {
475
475
  }
476
476
  }
477
477
 
478
+ // notifyHubOfInvite(githubLogin, { invited }) — tell the hub about THIS project's own
479
+ // invite, so the invitee sees it in My Projects (task 1002818, ADR 0353). `invited:
480
+ // true` after the invite route leaves the login holding an invited row; `false` after
481
+ // a rescind leaves it none, so the hub drops its notice rather than keep pointing at
482
+ // a door that will not open. The hub keeps a notice only — the access_requests row
483
+ // here is what admits, and signing in is what accepts.
484
+ //
485
+ // In the kernel beside the other hub calls, for readApplicantProfileFromHub's reason:
486
+ // it holds this project's hub client secret, and a module reaches it only through
487
+ // the narrow doorway port (one login and one direction in, nothing out).
488
+ //
489
+ // FIRE-AND-FORGET, BOUNDED, SWALLOWED: an invite must never fail or wait because the
490
+ // hub is slow or down, and there is no retry — re-sending the same invite re-notifies
491
+ // (the hub's insert is idempotent). No-op without a hub (idp unset). An OLDER hub has
492
+ // no such route and answers 404; that is swallowed too, and loses only the notice
493
+ // (ADR 0352: the new-route half of the wire-skew rule).
494
+ const HUB_INVITE_NOTICE_TIMEOUT_MS = 5000;
495
+
496
+ async function notifyHubOfInvite(githubLogin, { invited = true } = {}) {
497
+ // Everything inside the try, config read included: callers never await this, so a
498
+ // throw here would surface as an unhandled rejection rather than a log line.
499
+ try {
500
+ const idp = loadIdpConfig();
501
+ if (!idp || !githubLogin) return;
502
+ await fetch(`${idp.origin}/api/bongos/sso/invites/${invited ? 'notify' : 'clear'}`, {
503
+ method: 'POST',
504
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': userAgent() },
505
+ signal: AbortSignal.timeout(HUB_INVITE_NOTICE_TIMEOUT_MS),
506
+ body: JSON.stringify({
507
+ client_id: idp.clientId,
508
+ client_secret: idp.clientSecret,
509
+ github_login: String(githubLogin),
510
+ }),
511
+ });
512
+ } catch (err) {
513
+ log.info('[auth] hub invite notice failed (non-blocking):', err.message);
514
+ }
515
+ }
516
+
478
517
  module.exports = {
479
518
  firstAdminLogin,
480
519
  firstTimeAdmissionBlocked,
@@ -488,6 +527,7 @@ module.exports = {
488
527
  maybeSeatFirstAdmin,
489
528
  seedKickoffChecklist,
490
529
  membershipKind,
530
+ notifyHubOfInvite,
491
531
  openEnrollmentEnabled,
492
532
  reportActivityRollup,
493
533
  reportActivityRollupForShippedTask,
@@ -55,7 +55,7 @@ const ic = require('../instance-config');
55
55
  const { branding, userAgent } = require('../branding');
56
56
 
57
57
  const { SCOPE, authConfigured, grantedScopeCoversRepos, idpConfigured, idpEnabled, loadIdpConfig, scopeForWebFlow } = require('./auth-config.js');
58
- const { firstAdminLogin, firstTimeAdmissionBlocked, instanceNeedsFirstAdmin, joinabilityMode, maybeSeatFirstAdmin, membershipKind, openEnrollmentEnabled, readApplicantProfileFromHub, webAdmissionStatus } = require('./auth-admission.js');
58
+ const { firstAdminLogin, firstTimeAdmissionBlocked, instanceNeedsFirstAdmin, joinabilityMode, maybeSeatFirstAdmin, membershipKind, notifyHubOfInvite, openEnrollmentEnabled, readApplicantProfileFromHub, webAdmissionStatus } = require('./auth-admission.js');
59
59
  const { buildAuthorizeUrl, completeWebFlow, pollDeviceFlow, startDeviceFlow } = require('./auth-github.js');
60
60
  const { buildIdpAuthorizeUrl, completeDeviceExchange, completeIdpFlow, idpAuthorizeUrl, loadHubPublicKey, logoutTokenJtiSeenAndRecord, readApplicationVouch, verifyHubAssertion, verifyHubLogoutToken } = require('./auth-idp.js');
61
61
  const { addDiscordGuildMember, buildDiscordAuthorizeUrl, discordAuthorizeUrl, discordBotConfigured, discordConfigured, exchangeDiscordCode, fetchDiscordUser, isValidDiscordUserId, loadDiscordBotToken, loadDiscordCreds, removeDiscordGuildMember } = require('./auth-discord.js');
@@ -828,6 +828,7 @@ module.exports = {
828
828
  openEnrollmentEnabled,
829
829
  joinabilityMode,
830
830
  readApplicantProfileFromHub,
831
+ notifyHubOfInvite,
831
832
  readApplicationVouch,
832
833
  firstTimeAdmissionBlocked,
833
834
  instanceNeedsFirstAdmin,
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.21'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.22'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -362,6 +362,13 @@ module.exports = {
362
362
  // re-address it. Fails soft to null on every error, so a hub outage costs
363
363
  // the profile block and never the queue.
364
364
  get readApplicantProfileFromHub() { return require('./bongos/auth').readApplicantProfileFromHub; },
365
+ // notifyHubOfInvite(githubLogin, { invited }) — tell the hub about one of THIS
366
+ // project's invites (true) or its rescind (false), so the invitee sees it in
367
+ // My Projects (task 1002818, ADR 0353). The same narrow-port reason as the
368
+ // read above: the call is authenticated with this instance's hub client secret,
369
+ // and a module passes one login and one direction — it can neither widen nor
370
+ // re-address it. Fire-and-forget; never throws, never gates the invite.
371
+ get notifyHubOfInvite() { return require('./bongos/auth').notifyHubOfInvite; },
365
372
  // readApplicationVouch(token, { githubLogin }) — the write-side twin (task
366
373
  // 1002285, ADR 0201 D2): verify the hub's application vouch on a public
367
374
  // access request → { githubId, handle } or null. Core supplies this
@@ -29,6 +29,8 @@ const realApi = require(apiPath);
29
29
  let calls = [];
30
30
  let script = [];
31
31
  let mode = 'apply';
32
+ // The hub notice port (task 1002818, ADR 0353), recorded instead of sent.
33
+ let notices = [];
32
34
  const pool = {
33
35
  async query(sql, params) {
34
36
  calls.push({ sql, params });
@@ -42,6 +44,7 @@ require.cache[apiPath].exports = {
42
44
  ...realApi,
43
45
  pool,
44
46
  projectJoinDoor: () => mode,
47
+ notifyHubOfInvite: (login, opts) => { notices.push({ login, invited: opts && opts.invited }); },
45
48
  requireBuilder: (req, _res, next) => { req.builder = { id: 42, github_login: 'archon' }; next(); },
46
49
  requirePermission: () => (_req, _res, next) => next(),
47
50
  };
@@ -87,7 +90,7 @@ function get(pathname) {
87
90
  }).on('error', reject);
88
91
  });
89
92
  }
90
- const reset = (m, s) => { mode = m; script = s; calls = []; };
93
+ const reset = (m, s) => { mode = m; script = s; calls = []; notices = []; };
91
94
  const none = { rows: [] };
92
95
  const one = (row = {}) => ({ rows: [row] });
93
96
 
@@ -163,6 +166,41 @@ test('invite: resolving a pending application in place leaves it an application
163
166
  assert.doesNotMatch(update, /\bnote\b/, 'the applicant’s note is theirs — an inviter’s note never overwrites it');
164
167
  });
165
168
 
169
+ // ── the hub hears about every invite that stands (task 1002818, ADR 0353) ────
170
+ // The invitee sees it in My Projects; the row above is still the only thing that admits.
171
+
172
+ test('invite: a fresh invite notifies the hub, after the row is written', async () => {
173
+ reset('invite_only', [none, none, none, one({ id: 8, github_login: 'octocat', status: 'invited', kind: 'invite' })]);
174
+ const r = await post('/api/gds/access-requests/invite', { github_login: '@octocat' });
175
+ assert.equal(r.status, 201);
176
+ assert.deepEqual(notices, [{ login: 'octocat', invited: true }], 'the normalised login, once');
177
+ });
178
+
179
+ test('invite: resolving a pending request in place notifies the hub too', async () => {
180
+ reset('invite_only', [none, none, one({ id: 7, github_login: 'octocat', status: 'invited', kind: 'application' })]);
181
+ await post('/api/gds/access-requests/invite', { github_login: 'octocat' });
182
+ assert.deepEqual(notices, [{ login: 'octocat', invited: true }]);
183
+ });
184
+
185
+ test('invite: re-inviting a standing invite re-notifies — that is how a missed notice heals', async () => {
186
+ reset('invite_only', [none, one()]);
187
+ await post('/api/gds/access-requests/invite', { github_login: 'octocat' });
188
+ assert.deepEqual(notices, [{ login: 'octocat', invited: true }]);
189
+ });
190
+
191
+ test('invite: nothing that leaves no invited row tells the hub anything', async () => {
192
+ reset('invite_only', [one()]);
193
+ await post('/api/gds/access-requests/invite', { github_login: 'octocat' });
194
+ assert.deepEqual(notices, [], 'already a member — there is no invite to announce');
195
+ reset('invite_only', [none, none, none, new Error('insert failed')]);
196
+ const failed = await post('/api/gds/access-requests/invite', { github_login: 'octocat' });
197
+ assert.equal(failed.status, 500);
198
+ assert.deepEqual(notices, [], 'a failed write announces nothing');
199
+ reset('invite_only', []);
200
+ await post('/api/gds/access-requests/invite', { github_login: 'not a login!' });
201
+ assert.deepEqual(notices, []);
202
+ });
203
+
166
204
  // ── GET /access-requests/invites — the sender's outstanding list ─────────────
167
205
 
168
206
  test('invites list: outstanding invites only, each naming who sent it', async () => {
@@ -336,6 +336,8 @@ let actingRank = 'archon';
336
336
  // Which builder is acting — switched only to prove an invite keeps its SENDER
337
337
  // when a different archon rescinds it.
338
338
  let actingId = 42;
339
+ // The hub notice port (task 1002818, ADR 0353), recorded instead of sent.
340
+ let notices = [];
339
341
 
340
342
  require.cache[apiPath].exports = {
341
343
  ...realApi,
@@ -348,6 +350,7 @@ require.cache[apiPath].exports = {
348
350
  // below is the production denial.
349
351
  readApplicationVouch: (token, { githubLogin } = {}) =>
350
352
  applicationVouchIdentity(token, { githubLogin, publicKey: hubPublicKey, clientId: SELF_CLIENT_ID }),
353
+ notifyHubOfInvite: (login, opts) => { notices.push({ login, invited: opts && opts.invited }); },
351
354
  };
352
355
 
353
356
  seams._reset();
@@ -392,7 +395,7 @@ function call(method, pathname, body, extraHeaders = {}) {
392
395
  req.end(payload);
393
396
  });
394
397
  }
395
- const reset = (rank = 'archon') => { seed(); actingRank = rank; actingId = 42; };
398
+ const reset = (rank = 'archon') => { seed(); actingRank = rank; actingId = 42; notices = []; };
396
399
 
397
400
  // ===========================================================================
398
401
  // The interpreter is the instrument, so it is calibrated first
@@ -699,6 +702,43 @@ test('decline: a PENDING request from someone who is already a member is still d
699
702
  assert.equal(row(1).status, 'dismissed');
700
703
  });
701
704
 
705
+ // ── the hub's notice follows the door (task 1002818, ADR 0353) ──────────────
706
+ // The hub lists an invite in the invitee's My Projects. Once this project holds no
707
+ // invited row for that login, the notice would lead to a locked door, so a dismissal
708
+ // takes it back — and only then.
709
+
710
+ test('rescind: the hub is told to drop its notice once the login holds no invite', async () => {
711
+ reset();
712
+ const r = await call('PATCH', '/api/gds/access-requests/3', { status: 'dismissed' });
713
+ assert.equal(r.status, 200);
714
+ assert.deepEqual(notices, [{ login: 'cy', invited: false }]);
715
+ });
716
+
717
+ test('rescind: a second invite still standing keeps the notice — the invitee can still get in', async () => {
718
+ reset();
719
+ // Two archons invited cy at once (the invite route takes no lock, by design).
720
+ rows.push({ ...row(3), id: 5, kind: 'invite', created_by: 42 });
721
+ const r = await call('PATCH', '/api/gds/access-requests/3', { status: 'dismissed' });
722
+ assert.equal(r.status, 200);
723
+ assert.equal(await dbKernel.hasInvitedAccessRequest('cy'), true, 'precondition of the claim: the door is still open');
724
+ assert.deepEqual(notices, [], 'the notice stays true, so it stays');
725
+ });
726
+
727
+ test('the hub hears nothing from a refused rescind, an approval, or an idempotent re-click', async () => {
728
+ reset();
729
+ builders.push({ github_login: 'cy' });
730
+ await call('PATCH', '/api/gds/access-requests/3', { status: 'dismissed' });
731
+ assert.deepEqual(notices, [], 'a member was not rescinded, so nothing is taken back');
732
+
733
+ reset();
734
+ await call('PATCH', '/api/gds/access-requests/1', { status: 'invited' });
735
+ assert.deepEqual(notices, [], 'an approval is not a rescind');
736
+
737
+ reset();
738
+ await call('PATCH', '/api/gds/access-requests/4', { status: 'dismissed' });
739
+ assert.deepEqual(notices, [], 'already dismissed — nothing changed');
740
+ });
741
+
702
742
  test('a declined request is NOT revived by a PATCH — that path is the invite route (D4.2)', async () => {
703
743
  reset();
704
744
  const before = { ...row(4) };
@@ -202,11 +202,13 @@ test('POST /projects/invite no longer 404s for a wizard-made project', async ()
202
202
  // now is. The route's `project.curate` gate is deliberately untouched — widening it to the
203
203
  // project's own owner is 1003580's job, and this test stubs the permission middleware so
204
204
  // the assertion is about the TARGET resolving, not about who may call it.
205
+ // Since task 1002818 (ADR 0353) a resolved target is answered with where the invite that
206
+ // admits is sent — the catalog row's origin is what the hand-off link is built from.
205
207
  const res = await call('POST', '/projects/invite', { client_id: 'spike-harbor', github_login: 'someone' });
206
208
  assert.notEqual(res.status, 404);
207
- assert.equal(res.status, 201);
208
- assert.equal(res.body.invited, true);
209
- assert.equal(res.body.clientId, 'spike-harbor');
209
+ assert.equal(res.status, 200);
210
+ assert.equal(res.body.invited, false, 'the hub writes nothing');
211
+ assert.equal(res.body.invite_url, 'https://spike-harbor.cloudbongos.test/builders/watch?invite=someone');
210
212
  });
211
213
 
212
214
  test('an unknown project still 404s — the catalog row is what resolves, not the route', async () => {