@bongos/core 1.20.21 → 1.20.23
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 +78 -48
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -2
- package/clients/bongos-client/index.cjs +4 -2
- package/clients/bongos-client/index.d.ts +8 -4
- package/clients/bongos-client/index.mjs +4 -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 +137 -72
- package/docs/api-reference.md +6 -5
- package/docs/architecture.md +14 -0
- 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/migrations/core_265_module_assessments.sql +97 -0
- package/modules/hall-ui/public/watch.js +23 -0
- package/modules/onboarding/routes/access-requests.js +36 -6
- 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/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/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/hub_invite_notices.mjs +261 -0
- package/tests/module_api.mjs +1 -0
- package/tests/module_assessments_migration.mjs +96 -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
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
-- core_265_module_assessments.sql — what the module assessment produces: each
|
|
2
|
+
-- part's result, the composed overall score, and the history of both.
|
|
3
|
+
--
|
|
4
|
+
-- task 1003814 (goal 1000091, working area 5). ADR 0343 settled the score: a
|
|
5
|
+
-- Security gate, then the plain average of the other parts that have data;
|
|
6
|
+
-- ADR 0347 D5 adds a Docs part that is shown but never averaged in. These two
|
|
7
|
+
-- tables are the storage only — the signals that fill them (tasks 1003791-3,
|
|
8
|
+
-- 1003806, 1004367), the composition (1003794) and the Metic+ override route
|
|
9
|
+
-- (1003796) are their own tasks and write here.
|
|
10
|
+
--
|
|
11
|
+
-- Everything is scoped per module VERSION (store_module_versions.id), not per
|
|
12
|
+
-- module: a new version is a new thing to judge, which is what lets assessment
|
|
13
|
+
-- re-run on publish.
|
|
14
|
+
--
|
|
15
|
+
-- module_assessment_signals one row per measurement of one part of one
|
|
16
|
+
-- version. APPEND-ONLY: a re-run adds a row, never
|
|
17
|
+
-- UPDATEs one, so a part's score can be seen to move.
|
|
18
|
+
-- The CURRENT result of a part is the newest row per
|
|
19
|
+
-- (version, part). `outcome` keeps "no data yet"
|
|
20
|
+
-- distinct from a low score (ADR 0343 D3/D5 — a missing
|
|
21
|
+
-- part is never a zero):
|
|
22
|
+
-- scored a 0-100 score (Tests, Install,
|
|
23
|
+
-- Reliability, Tester feedback, Docs)
|
|
24
|
+
-- passed / the Security gate (D2) — pass/fail,
|
|
25
|
+
-- failed never a number, never averaged
|
|
26
|
+
-- no_data nothing to measure yet (no installs, no
|
|
27
|
+
-- testers, a module that declares no tests
|
|
28
|
+
-- — task 1003791 says that is not a fail)
|
|
29
|
+
-- pending measurement under way ("Docs: pending")
|
|
30
|
+
-- not_scored the measurement could not run
|
|
31
|
+
-- `sample_size` is how many installs / testers / test
|
|
32
|
+
-- cases the number rests on, so two friendly testers
|
|
33
|
+
-- cannot outweigh two hundred real ones (task 1003806).
|
|
34
|
+
-- module_assessment_scores one row per composition of a version's overall
|
|
35
|
+
-- score. APPEND-ONLY history; the CURRENT score is the
|
|
36
|
+
-- newest row per version. `overall` is NULL when no
|
|
37
|
+
-- averaged part has data. `security_passed` NULL means
|
|
38
|
+
-- the gate has not run. `is_new` is ADR 0343 D5's label
|
|
39
|
+
-- (no Install or Reliability data yet). `signal_ids`
|
|
40
|
+
-- names the exact signal rows the number was composed
|
|
41
|
+
-- from, so "why did I score 71?" is answerable from the
|
|
42
|
+
-- row. `kind = 'override'` is a Metic+ correction
|
|
43
|
+
-- (ADR 0343 D6, task 1003796): it must say who and why,
|
|
44
|
+
-- and it is a new row on top of the computed history,
|
|
45
|
+
-- never an edit of it.
|
|
46
|
+
--
|
|
47
|
+
-- Nothing here reads price: free and paid are assessed identically (D6).
|
|
48
|
+
-- Additive only (new tables), so forward-safe. Idempotent: safe to re-run.
|
|
49
|
+
|
|
50
|
+
CREATE TABLE IF NOT EXISTS module_assessment_signals (
|
|
51
|
+
id bigserial PRIMARY KEY,
|
|
52
|
+
version_id bigint NOT NULL REFERENCES store_module_versions(id),
|
|
53
|
+
part text NOT NULL
|
|
54
|
+
CHECK (part IN ('security', 'tests', 'install', 'reliability',
|
|
55
|
+
'tester_feedback', 'docs')),
|
|
56
|
+
outcome text NOT NULL
|
|
57
|
+
CHECK (outcome IN ('scored', 'passed', 'failed', 'no_data',
|
|
58
|
+
'pending', 'not_scored')),
|
|
59
|
+
score smallint CHECK (score BETWEEN 0 AND 100),
|
|
60
|
+
sample_size integer CHECK (sample_size >= 0),
|
|
61
|
+
reason text,
|
|
62
|
+
detail jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
63
|
+
measured_at timestamptz NOT NULL DEFAULT now(),
|
|
64
|
+
-- a number exactly when the outcome is 'scored'
|
|
65
|
+
CHECK ((outcome = 'scored') = (score IS NOT NULL)),
|
|
66
|
+
-- Security is a gate (ADR 0343 D2): pass/fail only; no other part passes or fails
|
|
67
|
+
CHECK ((part = 'security') = (outcome IN ('passed', 'failed'))
|
|
68
|
+
OR outcome IN ('no_data', 'pending', 'not_scored'))
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
CREATE INDEX IF NOT EXISTS idx_module_assessment_signals_current
|
|
72
|
+
ON module_assessment_signals (version_id, part, measured_at DESC, id DESC);
|
|
73
|
+
|
|
74
|
+
CREATE TABLE IF NOT EXISTS module_assessment_scores (
|
|
75
|
+
id bigserial PRIMARY KEY,
|
|
76
|
+
version_id bigint NOT NULL REFERENCES store_module_versions(id),
|
|
77
|
+
kind text NOT NULL DEFAULT 'computed'
|
|
78
|
+
CHECK (kind IN ('computed', 'override')),
|
|
79
|
+
overall smallint CHECK (overall BETWEEN 0 AND 100),
|
|
80
|
+
security_passed boolean,
|
|
81
|
+
is_new boolean NOT NULL,
|
|
82
|
+
formula text NOT NULL,
|
|
83
|
+
signal_ids bigint[] NOT NULL DEFAULT '{}',
|
|
84
|
+
override_by bigint REFERENCES builders(id),
|
|
85
|
+
override_reason text,
|
|
86
|
+
computed_at timestamptz NOT NULL DEFAULT now(),
|
|
87
|
+
-- an override names who made it and why; a computed row names neither
|
|
88
|
+
CHECK ((kind = 'override') = (override_by IS NOT NULL)),
|
|
89
|
+
CHECK ((kind = 'computed' AND override_reason IS NULL)
|
|
90
|
+
OR (kind = 'override' AND length(btrim(coalesce(override_reason, ''))) > 0))
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
CREATE INDEX IF NOT EXISTS idx_module_assessment_scores_current
|
|
94
|
+
ON module_assessment_scores (version_id, computed_at DESC, id DESC);
|
|
95
|
+
|
|
96
|
+
INSERT INTO schema_migrations (version) VALUES ('core_265_module_assessments')
|
|
97
|
+
ON CONFLICT (version) DO NOTHING;
|
|
@@ -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
|
-
//
|
|
531
|
-
//
|
|
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
|
-
//
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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.
|
|
312
|
-
if (!out.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
// a
|
|
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
|
|
154
|
-
if (!
|
|
155
|
-
|
|
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 —
|
|
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('
|
|
551
|
-
|
|
552
|
-
|
|
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('
|
|
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.
|
|
562
|
-
assert.deepEqual(out, {
|
|
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
|