@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
|
@@ -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,105 @@
|
|
|
1
|
+
// modules/platform-identity/guild-map.js — the guild map's data: public guilds,
|
|
2
|
+
// ranked (R30, task 1002306; ADR 0336 D8, amended by ADR 0337 D4.4).
|
|
3
|
+
//
|
|
4
|
+
// WHO. `visibility = 'public'` AND a non-empty public roster. An unlisted guild
|
|
5
|
+
// has no public existence (ADR 0336 D4), so it is never listed. Neither is a
|
|
6
|
+
// public guild whose every member is private or unshown: it would be a name on
|
|
7
|
+
// the map with nobody behind it.
|
|
8
|
+
//
|
|
9
|
+
// TWO ORDERINGS, NO STORED SCORE (D8):
|
|
10
|
+
// * size — the public roster's length, computed exactly as getPublicGuild
|
|
11
|
+
// computes it (shown_publicly AND accountVisibleSql, capped at
|
|
12
|
+
// guilds.LIST_MAX), so the map and the guild page never disagree.
|
|
13
|
+
// hide_stats hides numbers, never membership (ADR 0336 D4 §2), so a
|
|
14
|
+
// member who hides their numbers is still in it; a private one never.
|
|
15
|
+
// * shipped — the guild's STORED works_shipped band floor (guild-totals.js),
|
|
16
|
+
// never an exact sum (D4.4). A guild not yet refreshed publishes
|
|
17
|
+
// null and sorts last.
|
|
18
|
+
// Ties break on slug. Every sort key is a field the row publishes (ADR 0255: a
|
|
19
|
+
// public list's order is a covert column otherwise).
|
|
20
|
+
//
|
|
21
|
+
// COST, AND WHY THERE IS NO CACHE. One aggregate per read, over PUBLIC guilds'
|
|
22
|
+
// memberships only (the roster CTE filters on visibility before it groups, so an
|
|
23
|
+
// unlisted guild's members are never counted, let alone scanned for the map) —
|
|
24
|
+
// at the hub's scale a few index scans, bounded per caller by guild-map-read.
|
|
25
|
+
// D8 decides "computed at read time" and names the revisit trigger: a measured
|
|
26
|
+
// p95. It also rules out the cheap cache: any cache must be INVALIDATED by a hide,
|
|
27
|
+
// a privacy flip, a shown_publicly change, a membership change and a visibility
|
|
28
|
+
// change, because a TTL alone keeps publishing a withdrawn member in a guild's
|
|
29
|
+
// size (the ADR 0202 D3 reason). community-leaderboard.js's 30s memo does not
|
|
30
|
+
// transfer: it caches only NUMBERS and re-reads WHO on every request, and here
|
|
31
|
+
// the expensive half IS who — the roster under the privacy predicates. A cache
|
|
32
|
+
// is therefore a write-path change across guilds.js, the privacy setters and
|
|
33
|
+
// consent, taken when the p95 says so, not a TTL bolted on here.
|
|
34
|
+
//
|
|
35
|
+
// NOTHING HERE IS AUTHORITY (ADR 0016 / ADR 0141). Descriptive only.
|
|
36
|
+
//
|
|
37
|
+
// Reaches core only through the doorway (ADR 0083).
|
|
38
|
+
'use strict';
|
|
39
|
+
|
|
40
|
+
const api = require('../../src/module-api');
|
|
41
|
+
const { accountVisibleSql } = require('./account-visibility');
|
|
42
|
+
const { LIST_MAX } = require('./guilds');
|
|
43
|
+
const { clampOffset, MAX_OFFSET } = require('./builder-search');
|
|
44
|
+
|
|
45
|
+
const { pool: defaultPool } = api;
|
|
46
|
+
|
|
47
|
+
const DEFAULT_LIMIT = 25;
|
|
48
|
+
const MAX_LIMIT = 100;
|
|
49
|
+
|
|
50
|
+
// The orderings, as fixed SQL. A request names a key; it never supplies SQL.
|
|
51
|
+
const ORDERS = {
|
|
52
|
+
size: 'size DESC, g.slug',
|
|
53
|
+
shipped: 'shipped DESC NULLS LAST, g.slug',
|
|
54
|
+
};
|
|
55
|
+
const ORDER_KEYS = Object.keys(ORDERS);
|
|
56
|
+
const DEFAULT_ORDER = 'size';
|
|
57
|
+
|
|
58
|
+
function isOrder(order) {
|
|
59
|
+
return typeof order === 'string' && Object.prototype.hasOwnProperty.call(ORDERS, order);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function clampLimit(limit) {
|
|
63
|
+
const n = parseInt(limit, 10);
|
|
64
|
+
if (!Number.isFinite(n) || n <= 0) return DEFAULT_LIMIT;
|
|
65
|
+
return Math.min(n, MAX_LIMIT);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// One page of the map. `order` must already be valid (the route 400s first; an
|
|
69
|
+
// invalid one here falls back to the default rather than reaching SQL). Returns
|
|
70
|
+
// { order, results: [{ slug, name, size, shipped }], limit, offset } with the
|
|
71
|
+
// CLAMPED paging, so the route echoes what was actually applied.
|
|
72
|
+
async function listGuildMap({ order = DEFAULT_ORDER, limit, offset } = {}, { pool = defaultPool } = {}) {
|
|
73
|
+
const key = isOrder(order) ? order : DEFAULT_ORDER;
|
|
74
|
+
const lim = clampLimit(limit);
|
|
75
|
+
const off = clampOffset(offset);
|
|
76
|
+
const { rows } = await pool.query(
|
|
77
|
+
`WITH roster AS (
|
|
78
|
+
SELECT m.guild_id, LEAST(count(*), $3)::int AS size
|
|
79
|
+
FROM platform_identity_guilds pg
|
|
80
|
+
JOIN platform_identity_guild_members m ON m.guild_id = pg.id
|
|
81
|
+
JOIN platform_identity_accounts pa ON pa.github_id = m.github_id
|
|
82
|
+
WHERE pg.visibility = 'public' AND m.shown_publicly AND ${accountVisibleSql('pa')}
|
|
83
|
+
GROUP BY m.guild_id
|
|
84
|
+
)
|
|
85
|
+
SELECT g.slug, g.name, r.size, t.works_shipped AS shipped
|
|
86
|
+
FROM platform_identity_guilds g
|
|
87
|
+
JOIN roster r ON r.guild_id = g.id
|
|
88
|
+
LEFT JOIN platform_identity_guild_totals t ON t.guild_id = g.id
|
|
89
|
+
WHERE g.visibility = 'public'
|
|
90
|
+
ORDER BY ${ORDERS[key]}
|
|
91
|
+
LIMIT $1 OFFSET $2`,
|
|
92
|
+
[lim, off, LIST_MAX],
|
|
93
|
+
);
|
|
94
|
+
return { order: key, results: rows, limit: lim, offset: off };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = {
|
|
98
|
+
ORDER_KEYS,
|
|
99
|
+
DEFAULT_ORDER,
|
|
100
|
+
DEFAULT_LIMIT,
|
|
101
|
+
MAX_LIMIT,
|
|
102
|
+
MAX_OFFSET,
|
|
103
|
+
isOrder,
|
|
104
|
+
listGuildMap,
|
|
105
|
+
};
|
|
@@ -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,
|
|
@@ -11,12 +11,17 @@
|
|
|
11
11
|
// GET /guilds/:slug/members. The page at /g/<slug> is task 1002301's; this is
|
|
12
12
|
// its read.
|
|
13
13
|
//
|
|
14
|
+
// GET /guilds — the guild MAP (task 1002306, R30; ADR 0336 D8): public guilds
|
|
15
|
+
// with a non-empty public roster, ranked by size or by the stored shipped band
|
|
16
|
+
// floor, slug tiebreak, paged. Who and how live in ../guild-map.js.
|
|
17
|
+
//
|
|
14
18
|
// Reaches core only through the doorway (ADR 0083).
|
|
15
19
|
'use strict';
|
|
16
20
|
|
|
17
21
|
const express = require('express');
|
|
18
22
|
const api = require('../../../src/module-api');
|
|
19
23
|
const guilds = require('../guilds');
|
|
24
|
+
const guildMap = require('../guild-map');
|
|
20
25
|
const { createReadRateLimit } = require('../read-rate-limit');
|
|
21
26
|
|
|
22
27
|
const { pool } = api;
|
|
@@ -34,9 +39,40 @@ function logError(msg) {
|
|
|
34
39
|
// uncapped scraper could walk the slug space at line rate.
|
|
35
40
|
const guildReadRateLimit = createReadRateLimit({ scope: 'guild-public-read', limit: 120 });
|
|
36
41
|
|
|
42
|
+
// The map's own budget: one read aggregates every public guild's roster, so it
|
|
43
|
+
// gets the community reads' tighter cap rather than the single-guild one.
|
|
44
|
+
const guildMapRateLimit = createReadRateLimit({ scope: 'guild-map-read', limit: 60 });
|
|
45
|
+
|
|
37
46
|
module.exports = function guildPublicRoutes() {
|
|
38
47
|
const router = express.Router();
|
|
39
48
|
|
|
49
|
+
// rank: public — the guild map (?order=size|shipped, default size;
|
|
50
|
+
// ?limit=&offset=). Viewer-independent: it reads no session. size is the
|
|
51
|
+
// public roster's length; shipped is R26's stored band floor (ADR 0337 D4.4),
|
|
52
|
+
// never an exact sum, null before a guild's first refresh. no-store: a hide or
|
|
53
|
+
// a visibility flip moves size on the very next read (ADR 0171 D5).
|
|
54
|
+
router.get('/guilds', guildMapRateLimit, async (req, res) => {
|
|
55
|
+
const order = req.query.order === undefined || req.query.order === ''
|
|
56
|
+
? guildMap.DEFAULT_ORDER
|
|
57
|
+
: req.query.order;
|
|
58
|
+
if (!guildMap.isOrder(order)) {
|
|
59
|
+
return res.fail('guild_map_bad_order', { status: 400, message: `order must be one of: ${guildMap.ORDER_KEYS.join(', ')}` });
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const map = await guildMap.listGuildMap({ order, limit: req.query.limit, offset: req.query.offset }, { pool });
|
|
63
|
+
res.set('Cache-Control', 'no-store');
|
|
64
|
+
res.json({
|
|
65
|
+
order: map.order,
|
|
66
|
+
count: map.results.length,
|
|
67
|
+
results: map.results,
|
|
68
|
+
page: { limit: map.limit, offset: map.offset },
|
|
69
|
+
});
|
|
70
|
+
} catch (err) {
|
|
71
|
+
logError(`GET /guilds failed: ${err && err.message}`);
|
|
72
|
+
res.fail('guild_map_failed', { status: 500, message: 'internal error' });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
40
76
|
// rank: public — a public guild's page data. The roster is the members who
|
|
41
77
|
// agreed to be shown AND whose own accounts are publicly visible (ADR 0336 D4);
|
|
42
78
|
// size is that roster's length; totals are the weekly band floors (ADR 0337
|
|
@@ -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 () => {
|