@bongos/core 1.20.20 → 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.
- package/.bongos-core.json +80 -50
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -2
- package/clients/bongos-client/index.cjs +6 -2
- package/clients/bongos-client/index.d.ts +11 -4
- package/clients/bongos-client/index.mjs +6 -2
- package/docs/adr/0335-an-invite-is-a-pre-approved-row-on-the-projects-own-instance.md +1 -0
- package/docs/adr/0353-a-hub-invite-is-a-notice-of-the-projects-own-invite.md +70 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +179 -73
- package/docs/api-reference.md +8 -6
- package/docs/copy-inventory.md +196 -196
- package/docs/copy-registry.json +250 -250
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +2 -1
- package/docs/page-readings.json +310 -306
- package/modules/hall-ui/public/watch.js +23 -0
- package/modules/onboarding/routes/access-requests.js +36 -6
- package/modules/platform-identity/guild-map.js +105 -0
- package/modules/platform-identity/invite-notices.js +61 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/platform-identity.js +12 -18
- package/modules/platform-identity/routes/guilds-public.js +36 -0
- package/modules/platform-identity/routes/my-projects.js +6 -15
- package/modules/platform-identity/routes/projects.js +22 -15
- package/modules/platform-identity/routes/sso-invites.js +82 -0
- package/modules/platform-identity/tests/platform-identity.mjs +14 -11
- package/modules/public-landing/public/assets/cosmos.css +5 -0
- package/modules/public-landing/public/projects.html +35 -29
- package/modules/public-landing/public/projects.states.json +1 -0
- package/modules/ui-design/kit/serve.js +9 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/run-unit-tests.js +4 -0
- package/src/bongos/auth-admission.js +40 -0
- package/src/bongos/auth.js +2 -1
- package/src/module-api.js +8 -1
- package/tests/access_requests_invite.mjs +39 -1
- package/tests/application_lifecycle.mjs +41 -1
- package/tests/catalog_only_client.mjs +5 -3
- package/tests/guild_map_db.mjs +157 -0
- package/tests/guilds_api.mjs +74 -0
- package/tests/hub_invite_notices.mjs +261 -0
- package/tests/module_api.mjs +1 -0
- package/tests/my_projects_surface.mjs +11 -1
- package/tests/project_invite_ownership.mjs +12 -7
- package/tests/project_invite_ui.mjs +42 -45
- package/tests/watch_applications_queue.mjs +33 -2
|
@@ -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
|
|
@@ -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.
|
|
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
|
|
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).
|
|
3188
|
-
|
|
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
|
-
|
|
8554
|
-
|
|
8555
|
-
|
|
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
|
|
8649
|
-
|
|
8650
|
-
|
|
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
|
-
|
|
8657
|
-
|
|
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, '
|
|
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.
|
|
8704
|
-
|
|
8705
|
-
|
|
8706
|
-
|
|
8707
|
-
|
|
8708
|
-
|
|
8709
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -8176,5 +8176,17 @@
|
|
|
8176
8176
|
"id": "1002302",
|
|
8177
8177
|
"text": "Guilds now show a combined track record: rounded totals of their members' credits, shipped work and projects, refreshed once a week. Members who keep their numbers private still count, but only when at least three such members"
|
|
8178
8178
|
}
|
|
8179
|
+
],
|
|
8180
|
+
"1.20.21": [
|
|
8181
|
+
{
|
|
8182
|
+
"id": "1002306",
|
|
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
|
+
}
|
|
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
|
+
}
|
|
8179
8191
|
]
|
|
8180
8192
|
}
|
|
@@ -183,6 +183,10 @@ const INTEGRATION = new Set([
|
|
|
183
183
|
// re-evaluated against a racing writer's committed row, are planner facts; the
|
|
184
184
|
// DB-free sibling (guild_totals) pins the band scale and statement shapes.
|
|
185
185
|
'guild_totals_db',
|
|
186
|
+
// task 1002306 (R30): the guild map against real SQL — the INNER join that drops
|
|
187
|
+
// an empty public roster, the capped roster count, and NULLS LAST on a guild
|
|
188
|
+
// with no weekly record yet. guilds_api §3b pins the route and statement shape.
|
|
189
|
+
'guild_map_db',
|
|
186
190
|
// task 1003935: 'linkify_refs' was swept in with the three above without the
|
|
187
191
|
// no-DB check they were being fixed for. It touches no Postgres at all — pure
|
|
188
192
|
// string work plus a filesystem walk — so the DB-free lane skipped it and the
|
|
@@ -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,
|
package/src/bongos/auth.js
CHANGED
|
@@ -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.
|
|
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,
|
|
208
|
-
assert.equal(res.body.invited,
|
|
209
|
-
assert.equal(res.body.
|
|
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 () => {
|