@bongos/core 1.19.374 → 1.19.375
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 +72 -42
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +4 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0194-joinability-one-door-decision.md +42 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +85 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +249 -233
- package/docs/copy-registry.json +475 -300
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/government/catalog.js +1 -1
- package/modules/hall-ui/public/builders.js +5 -3
- package/modules/hall-ui/public/hall-render.js +63 -22
- package/modules/hall-ui/public/my-projects.js +6 -1
- package/modules/hall-ui/public/watch.css +8 -0
- package/modules/hall-ui/public/watch.js +56 -3
- package/modules/onboarding/module.json +1 -1
- package/modules/onboarding/routes/access-requests.js +87 -1
- package/modules/platform-identity/routes/my-projects.js +35 -9
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/projects.html +169 -3
- package/scripts/gds/login.js +24 -3
- package/src/bongos/auth-admission.js +50 -5
- package/src/bongos/auth.js +2 -1
- package/src/bongos/routes/auth.js +15 -0
- package/src/branding.js +3 -2
- package/src/module-api.js +20 -1
- package/tests/access_requests_invite.mjs +165 -0
- package/tests/auth_admission_gate.mjs +42 -0
- package/tests/join_relay_outcome.mjs +61 -0
- package/tests/joinability_apply.mjs +72 -0
- package/tests/joinability_invite_only.mjs +192 -0
- package/tests/joinability_open.mjs +89 -0
- package/tests/module_api.mjs +1 -0
- package/tests/projects_hub.mjs +76 -2
- package/tests/projects_hub_pre_uat.mjs +4 -2
- package/tests/public_landing_projects.mjs +10 -3
|
@@ -90,6 +90,8 @@ function createClient(opts = {}) {
|
|
|
90
90
|
postAccessRequests: (args) => request("POST", "/access-requests", { hasBody: true }, args),
|
|
91
91
|
// PATCH /access-requests/{id} — rank: archon — PATCH /access-requests/:id
|
|
92
92
|
patchAccessRequestsId: (args) => request("PATCH", "/access-requests/{id}", { hasBody: true }, args),
|
|
93
|
+
// POST /access-requests/invite — rank: archon — POST /access-requests/invite
|
|
94
|
+
postAccessRequestsInvite: (args) => request("POST", "/access-requests/invite", { hasBody: true }, args),
|
|
93
95
|
// GET /access-requests/status — rank: public — GET /access-requests/status
|
|
94
96
|
getAccessRequestsStatus: (args) => request("GET", "/access-requests/status", { hasBody: false }, args),
|
|
95
97
|
},
|
|
@@ -127,7 +127,7 @@ const PERMISSIONS = [
|
|
|
127
127
|
// one of them would make this delegation self-reversible.
|
|
128
128
|
{ key: 'builder.status.set', system: false, floor: 'archon', guards: 'PATCH /builders/:id/status — offboard / (de)activate' },
|
|
129
129
|
{ key: 'auth.session.revoke', system: false, floor: 'archon', guards: 'POST /auth/revoke, /auth/revoke-all' },
|
|
130
|
-
{ key: 'access_request.review', system: false, floor: 'archon', guards: 'GET/PATCH /access-requests/:id — admit a builder' },
|
|
130
|
+
{ key: 'access_request.review', system: false, floor: 'archon', guards: 'GET/PATCH /access-requests/:id, POST /access-requests/invite — admit a builder' },
|
|
131
131
|
|
|
132
132
|
// ── operational — floor metic (the working rank's review/triage/authoring) ────
|
|
133
133
|
{ key: 'idea.triage', system: false, floor: 'metic', guards: 'PATCH /inbox/:id' },
|
|
@@ -79,9 +79,11 @@
|
|
|
79
79
|
let code = null;
|
|
80
80
|
try {
|
|
81
81
|
const j = res.data;
|
|
82
|
-
// Prefer the human-facing message; fall back to the machine code.
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
// Prefer the human-facing message; fall back to the machine code. The
|
|
83
|
+
// envelope (ADR 0116) nests both under `error`; older handlers answer flat.
|
|
84
|
+
const env = j.error && typeof j.error === 'object' ? j.error : null;
|
|
85
|
+
detail = (env && env.message) || j.message || j.detail || (typeof j.error === 'string' ? j.error : detail);
|
|
86
|
+
code = (env && env.code) || (typeof j.error === 'string' ? j.error : null);
|
|
85
87
|
} catch (_) { /* keep default */ }
|
|
86
88
|
const err = new Error(detail);
|
|
87
89
|
err.status = res.status;
|
|
@@ -238,23 +238,43 @@
|
|
|
238
238
|
else standingRail = window.OTBKit.makeSectionRail({ mount: rail, items, mode: 'scrollspy' });
|
|
239
239
|
}
|
|
240
240
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
|
|
241
|
+
// The project's door, as the platform pushed it into this instance's pack
|
|
242
|
+
// (window.__BRANDING__.project.joinability, task 1003044): what a stranger is
|
|
243
|
+
// told to do next depends on it. An older pack reports none → 'apply', today's
|
|
244
|
+
// behaviour. Cosmetic — the server refuses on its own terms either way.
|
|
245
|
+
function joinabilityMode() {
|
|
246
|
+
const p = window.__BRANDING__ && window.__BRANDING__.project;
|
|
247
|
+
const v = p && p.joinability;
|
|
248
|
+
return v === 'open' || v === 'invite_only' ? v : 'apply';
|
|
249
|
+
}
|
|
248
250
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
251
|
+
// One landing per door: open → sign in and you're in; invite_only → nothing to
|
|
252
|
+
// apply for, sign in once invited; apply → the request form.
|
|
253
|
+
function landingDoorHtml(mode) {
|
|
254
|
+
const hub = `This is the builders' hub for ${brandWorld()} — where builders claim work, earn ${curLabel()}, and track their standing.`;
|
|
255
|
+
const admitter = escapeHtml(rankLabel('archon'));
|
|
256
|
+
const signIn = `<p class="landing__cta"><a class="landing__btn" href="${API}/auth/web/start">Sign in with GitHub</a></p>`;
|
|
257
|
+
if (mode === 'open') {
|
|
258
|
+
return `
|
|
259
|
+
<p class="landing__lede">${hub} Anyone with a GitHub account can join: sign in and you're in, starting at the newcomer rank.</p>
|
|
260
|
+
<div class="landing__request" id="landing-request">
|
|
261
|
+
<h3 class="landing__request-h">New here — sign in to join</h3>
|
|
262
|
+
<p class="landing__request-lede">No request and no invite needed. Sign in with GitHub, and a step-by-step guide takes you the rest of the way.</p>
|
|
263
|
+
${signIn}
|
|
264
|
+
</div>`;
|
|
265
|
+
}
|
|
266
|
+
if (mode === 'invite_only') {
|
|
267
|
+
return `
|
|
268
|
+
<p class="landing__lede">${hub} This project is invite-only: the ${admitter} invites each builder, and there is nothing to apply for here.</p>
|
|
269
|
+
<div class="landing__request" id="landing-request">
|
|
270
|
+
<h3 class="landing__request-h">Invited? Sign in</h3>
|
|
271
|
+
<p class="landing__request-lede">If the ${admitter} has invited you, sign in with GitHub and you're in. If not, ask them for an invite — this project doesn't take requests.</p>
|
|
272
|
+
${signIn}
|
|
273
|
+
</div>`;
|
|
274
|
+
}
|
|
275
|
+
return `
|
|
255
276
|
<p class="landing__lede">
|
|
256
|
-
|
|
257
|
-
work, earn ${curLabel()}, and track their standing. Access is
|
|
277
|
+
${hub} Access is
|
|
258
278
|
invite-based: an Archon approves each new builder. To begin, request
|
|
259
279
|
access below.
|
|
260
280
|
</p>
|
|
@@ -290,7 +310,24 @@
|
|
|
290
310
|
<button type="submit" class="landing__btn landing__btn--request" id="ar-submit">Request access</button>
|
|
291
311
|
<p class="landing__request-status" id="ar-status" aria-live="polite"></p>
|
|
292
312
|
</form>
|
|
293
|
-
</div
|
|
313
|
+
</div>`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function renderLanding() {
|
|
317
|
+
// task 1723: collapse the shell nav to the signed-out minimum (Home + a
|
|
318
|
+
// Sign-in button). Cosmetic — the server gates every page regardless.
|
|
319
|
+
window.OTBShell?.applyAccess({ authed: false });
|
|
320
|
+
|
|
321
|
+
const welcome = $('#welcome-line');
|
|
322
|
+
if (welcome) welcome.textContent = '';
|
|
323
|
+
|
|
324
|
+
const landing = $('#landing-root');
|
|
325
|
+
if (landing) {
|
|
326
|
+
landing.hidden = false;
|
|
327
|
+
landing.innerHTML = `
|
|
328
|
+
<div class="scroll landing">
|
|
329
|
+
<h2 class="scroll__h">Welcome</h2>
|
|
330
|
+
${landingDoorHtml(joinabilityMode())}
|
|
294
331
|
|
|
295
332
|
<p class="landing__signin-note">
|
|
296
333
|
Already a member? <a href="${API}/auth/web/start">Sign in with GitHub</a>.
|
|
@@ -346,15 +383,19 @@
|
|
|
346
383
|
</p>`;
|
|
347
384
|
}
|
|
348
385
|
} catch (err) {
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
// collapse the form into
|
|
352
|
-
|
|
386
|
+
// The door's own answers — already a builder, already approved, or a
|
|
387
|
+
// project that takes no requests (invite-only) / needs none (open) —
|
|
388
|
+
// collapse the form into what to do instead: sign in. Never a red error.
|
|
389
|
+
const code = err && err.code;
|
|
390
|
+
if (code === 'already_member' || code === 'already_approved' || code === 'invite_only' || code === 'open_enrollment') {
|
|
353
391
|
const req = $('#landing-request');
|
|
354
392
|
if (req) {
|
|
393
|
+
const heading = code === 'invite_only' ? 'This project is invite-only'
|
|
394
|
+
: code === 'open_enrollment' ? 'No request needed'
|
|
395
|
+
: 'You already have access ✓';
|
|
355
396
|
req.innerHTML = `
|
|
356
|
-
<h3 class="landing__request-h"
|
|
357
|
-
<p class="landing__request-lede">${escapeHtml(err.message || '
|
|
397
|
+
<h3 class="landing__request-h">${heading}</h3>
|
|
398
|
+
<p class="landing__request-lede">${escapeHtml(err.message || 'Sign in with GitHub to continue.')}</p>
|
|
358
399
|
<p class="landing__cta">
|
|
359
400
|
<a class="landing__btn" href="${API}/auth/web/start">Sign in with GitHub</a>
|
|
360
401
|
</p>`;
|
|
@@ -130,7 +130,12 @@
|
|
|
130
130
|
.then(function (r) { return r.json().then(function (b) { return { ok: r.ok, body: b }; }); })
|
|
131
131
|
.then(function (res) {
|
|
132
132
|
joinBtn.disabled = false;
|
|
133
|
-
if (res.ok && res.body && res.body.message) {
|
|
133
|
+
if (res.ok && res.body && res.body.message) {
|
|
134
|
+
joinNote.textContent = res.body.message;
|
|
135
|
+
/* a door refusal rides a 200 (task 1003044): keep the address, and don't repaint as if a request went out */
|
|
136
|
+
var refused = res.body.status === 'invite_only' || res.body.status === 'open_enrollment' || res.body.status === 'members_only';
|
|
137
|
+
if (!refused) { joinInput.value = ''; load(); }
|
|
138
|
+
}
|
|
134
139
|
else if (res.body && res.body.error && res.body.error.code === 'unknown_project') { joinNote.textContent = 'That project isn’t on the platform.'; }
|
|
135
140
|
else { joinNote.textContent = 'Could not request access right now.'; }
|
|
136
141
|
})
|
|
@@ -182,3 +182,11 @@ table.watch tr:hover td { background: var(--surface-2); }
|
|
|
182
182
|
font-size: 13px; font-weight: 500;
|
|
183
183
|
color: var(--accent-text);
|
|
184
184
|
}
|
|
185
|
+
|
|
186
|
+
/* Invite by GitHub username (task 1003044) — the Access requests section's own
|
|
187
|
+
form: the way in on an invite-only project, a shortcut on an apply one. */
|
|
188
|
+
.watch-invite { margin: 0 0 14px; padding: 12px 14px; border: 1px solid var(--line, #e5e0d6); border-radius: 10px; }
|
|
189
|
+
.watch-invite__label { display: block; font-weight: 600; margin-bottom: 6px; }
|
|
190
|
+
.watch-invite__row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
191
|
+
.watch-invite__row input[type="text"] { flex: 1 1 200px; min-width: 0; }
|
|
192
|
+
.watch-invite__why { margin: 8px 0 0; font-size: 13px; opacity: .8; }
|
|
@@ -88,7 +88,11 @@
|
|
|
88
88
|
const res = await api.request('POST', `${API}${path}`, { body: body || {} });
|
|
89
89
|
if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); throw { kind: '401' }; }
|
|
90
90
|
const data = res.data || {};
|
|
91
|
-
if (!res.ok)
|
|
91
|
+
if (!res.ok) {
|
|
92
|
+
// the envelope (ADR 0116) nests the sentence under `error`; older handlers answer flat
|
|
93
|
+
const env = data.error && typeof data.error === 'object' ? data.error : null;
|
|
94
|
+
throw new Error((env && env.message) || data.message || (typeof data.error === 'string' ? data.error : '') || `${path} → ${res.status}`);
|
|
95
|
+
}
|
|
92
96
|
return data;
|
|
93
97
|
}
|
|
94
98
|
|
|
@@ -183,14 +187,62 @@
|
|
|
183
187
|
// 1. GATE — access requests (pending)
|
|
184
188
|
// =========================================================================
|
|
185
189
|
|
|
190
|
+
// Invite by GitHub username (task 1003044): admits someone BEFORE any request
|
|
191
|
+
// exists — the only way in on an invite-only project, where nobody can apply,
|
|
192
|
+
// and a shortcut on an apply one. POST /access-requests/invite writes the same
|
|
193
|
+
// 'invited' row the sign-in gate reads. The door the project runs comes from
|
|
194
|
+
// the pack the server injected (window.__BRANDING__.project.joinability).
|
|
195
|
+
function inviteFormHtml() {
|
|
196
|
+
const p = window.__BRANDING__ && window.__BRANDING__.project;
|
|
197
|
+
const mode = (p && p.joinability) || 'apply';
|
|
198
|
+
const why = mode === 'invite_only'
|
|
199
|
+
? 'This project is invite-only — nobody can apply, so this is how people get in.'
|
|
200
|
+
: mode === 'open'
|
|
201
|
+
? 'This project is open — anyone who signs in is admitted, so an invite is rarely needed.'
|
|
202
|
+
: 'Admit someone before they ask — once invited, their sign-in gets in.';
|
|
203
|
+
return (
|
|
204
|
+
'<form class="watch-invite" id="gate-invite" novalidate>' +
|
|
205
|
+
'<label class="watch-invite__label" for="gate-invite-login">Invite by GitHub username</label>' +
|
|
206
|
+
'<div class="watch-invite__row">' +
|
|
207
|
+
'<input type="text" id="gate-invite-login" name="github_login" autocomplete="off" placeholder="e.g. octocat" maxlength="40">' +
|
|
208
|
+
'<button type="submit" class="watch-btn">Invite</button>' +
|
|
209
|
+
'</div>' +
|
|
210
|
+
`<p class="watch-invite__why">${escapeHtml(why)}</p>` +
|
|
211
|
+
'</form>'
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function wireInviteForm(target) {
|
|
216
|
+
const form = target.querySelector('#gate-invite');
|
|
217
|
+
if (!form) return;
|
|
218
|
+
form.addEventListener('submit', async (e) => {
|
|
219
|
+
e.preventDefault();
|
|
220
|
+
const input = form.querySelector('#gate-invite-login');
|
|
221
|
+
const login = ((input && input.value) || '').trim().replace(/^@/, '');
|
|
222
|
+
if (!login) { toast('A GitHub username is needed.'); return; }
|
|
223
|
+
const btn = form.querySelector('button[type="submit"]');
|
|
224
|
+
if (btn) btn.disabled = true;
|
|
225
|
+
try {
|
|
226
|
+
const r = await postJson('/access-requests/invite', { github_login: login });
|
|
227
|
+
toast(r.already_invited ? `@${login} is already invited.` : `@${login} invited — their sign-in gets in.`);
|
|
228
|
+
await loadGate();
|
|
229
|
+
} catch (err) {
|
|
230
|
+
if (err && err.kind === '401') return;
|
|
231
|
+
if (btn) btn.disabled = false;
|
|
232
|
+
toast(`Could not invite: ${err.message || err}`);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
186
237
|
function renderGate(data, target) {
|
|
187
238
|
const requests = Array.isArray(data.requests) ? data.requests : [];
|
|
188
239
|
setAttention('att-access', requests.length); // tile count = table content
|
|
189
240
|
if (!requests.length) {
|
|
190
|
-
target.innerHTML = emptyStateHtml('No pending access requests.');
|
|
241
|
+
target.innerHTML = inviteFormHtml() + emptyStateHtml('No pending access requests.');
|
|
242
|
+
wireInviteForm(target);
|
|
191
243
|
return;
|
|
192
244
|
}
|
|
193
|
-
target.innerHTML = requests
|
|
245
|
+
target.innerHTML = inviteFormHtml() + requests
|
|
194
246
|
.map((r) => {
|
|
195
247
|
const who = r.display_name
|
|
196
248
|
? `${escapeHtml(r.display_name)} <span class="watch-mono">@${escapeHtml(r.github_login)}</span>`
|
|
@@ -221,6 +273,7 @@
|
|
|
221
273
|
toast(status === 'invited' ? 'Builder invited.' : 'Request dismissed.');
|
|
222
274
|
await loadGate();
|
|
223
275
|
}, 'Could not resolve');
|
|
276
|
+
wireInviteForm(target);
|
|
224
277
|
}
|
|
225
278
|
|
|
226
279
|
function loadGate() {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"key": "onboarding",
|
|
3
3
|
"title": "Onboarding (admission · setup · newcomer floor)",
|
|
4
4
|
"description": "The newcomer ADMISSION + ORIENTATION domain — the public access-request → Archon-approval gate, the server-ticked onboarding state machine (the 'Path into the City' checklist that graduates a Xenos to Thetes), the friction analyzer (feedback-without-asking on consented onboarding transcripts), and the evergreen newcomer-chore restock (a fresh first-builder task is always waiting). A core-domain module carved out of the GDS core in BV1.R81 (ADR 0093 §1); default-on because every instance onboards its own builders. The access-requests admission routes mount here; the core lifecycle (db.js claim/ship), auth callbacks, and the me/builders profile readers resolve the `onboarding` kernel PORT for the state-machine + restock surface they need — never importing the module.",
|
|
5
|
-
"coreVersion": "^1.
|
|
5
|
+
"coreVersion": "^1.19.374",
|
|
6
6
|
"default": true,
|
|
7
7
|
"maintenance": { "status": "core-maintained" },
|
|
8
8
|
"contributes": { "routes": ["onboarding"] },
|
|
@@ -10,11 +10,21 @@
|
|
|
10
10
|
// POST /api/bongos/access-requests PUBLIC (the one unauthenticated GDS
|
|
11
11
|
// write) — a stranger submits their
|
|
12
12
|
// GitHub username; a pending row lands
|
|
13
|
-
// in the queue.
|
|
13
|
+
// in the queue. Refused outright on an
|
|
14
|
+
// invite-only project, and on an open
|
|
15
|
+
// one (sign in — you're in already).
|
|
14
16
|
// GET /api/bongos/access-requests Archon-only — the pending queue (and
|
|
15
17
|
// ?status=invited|dismissed history).
|
|
16
18
|
// PATCH /api/bongos/access-requests/:id Archon-only — resolve a request:
|
|
17
19
|
// { status: 'invited' | 'dismissed' }.
|
|
20
|
+
// POST /api/bongos/access-requests/invite Archon-only — invite a GitHub
|
|
21
|
+
// username before any request exists:
|
|
22
|
+
// the way in on an invite-only project.
|
|
23
|
+
//
|
|
24
|
+
// Which door a project runs — open / apply / invite_only — is the owner's
|
|
25
|
+
// joinability setting, read through the doorway's joinabilityMode() (task
|
|
26
|
+
// 1003044, ADR 0194): the same reader the sign-in gate uses, so this route and
|
|
27
|
+
// the gate never disagree about whether a stranger may ask.
|
|
18
28
|
//
|
|
19
29
|
// The global IP rate-limiter (mounted in routes.js before every router) guards
|
|
20
30
|
// the public POST against spam. A partial unique index on lower(github_login)
|
|
@@ -30,6 +40,14 @@ const auth = { requireBuilder: api.requireBuilder, requirePermission: api.requir
|
|
|
30
40
|
const { pool } = api;
|
|
31
41
|
const { validateOrRespond, parseId } = api;
|
|
32
42
|
const approvalBroadcast = require('../approval-broadcast');
|
|
43
|
+
const log = api.logger('onboarding');
|
|
44
|
+
|
|
45
|
+
// The public refusal each door gives a would-be applicant — plain sentences,
|
|
46
|
+
// shown as written by the hall, the CLI, and the hub's join relay.
|
|
47
|
+
const DOOR_REFUSAL = {
|
|
48
|
+
invite_only: { code: 'invite_only', status: 403, message: 'This project is invite-only — it doesn’t take applications. If you’ve been invited, or already have an account, sign in with GitHub to enter.' },
|
|
49
|
+
open: { code: 'open_enrollment', status: 409, message: 'This project is open — no request needed. Sign in with GitHub and you’re in.' },
|
|
50
|
+
};
|
|
33
51
|
|
|
34
52
|
// GitHub usernames: 1–39 chars, alphanumeric or single hyphens, no leading/
|
|
35
53
|
// trailing hyphen. We accept a leading '@' and strip it (people paste "@name").
|
|
@@ -46,6 +64,12 @@ module.exports = function buildAccessRequestsRouter() {
|
|
|
46
64
|
// rank: public — the requester is by definition outside the system (no
|
|
47
65
|
// builder row yet); this is the one public write on the GDS.
|
|
48
66
|
router.post('/access-requests', async (req, res) => {
|
|
67
|
+
// The door answers first — before validation and before any lookup — so a
|
|
68
|
+
// refusal costs no query and reveals nothing about who already exists.
|
|
69
|
+
const refusal = DOOR_REFUSAL[api.joinabilityMode()];
|
|
70
|
+
if (refusal) {
|
|
71
|
+
return res.fail(refusal.code, { status: refusal.status, message: refusal.message });
|
|
72
|
+
}
|
|
49
73
|
if (validateOrRespond(req, res, {
|
|
50
74
|
github_login: { required: true, type: 'string', minLength: 1, maxLength: 60 },
|
|
51
75
|
display_name: { type: 'string', maxLength: 100 },
|
|
@@ -204,5 +228,67 @@ module.exports = function buildAccessRequestsRouter() {
|
|
|
204
228
|
}
|
|
205
229
|
});
|
|
206
230
|
|
|
231
|
+
// POST /access-requests/invite — an Archon admits a GitHub username BEFORE any
|
|
232
|
+
// request exists (task 1003044): the only way in on an invite-only project,
|
|
233
|
+
// where the public POST refuses, and a shortcut on an apply one. It writes the
|
|
234
|
+
// same access_request row the sign-in gate reads (status 'invited' —
|
|
235
|
+
// hasInvitedAccessRequest), so nothing new is consulted at sign-in; a pending
|
|
236
|
+
// request for that login is resolved in place rather than duplicated. Two
|
|
237
|
+
// Archons inviting the same login at once can each insert a row (the unique
|
|
238
|
+
// index covers pending only): harmless — the gate reads "any invited row" —
|
|
239
|
+
// and it shows as two lines in the invited history, so no lock is taken.
|
|
240
|
+
// body: { github_login }. 201 with a new row; 200 with the row when a pending
|
|
241
|
+
// request was resolved; 200 already_invited when an invite already stands;
|
|
242
|
+
// 409 already_member when they have an account.
|
|
243
|
+
// rank: archon — the same gate as resolving a request.
|
|
244
|
+
router.post('/access-requests/invite', auth.requireBuilder, auth.requirePermission('access_request.review'), async (req, res) => {
|
|
245
|
+
if (validateOrRespond(req, res, {
|
|
246
|
+
github_login: { required: true, type: 'string', minLength: 1, maxLength: 60 },
|
|
247
|
+
})) return;
|
|
248
|
+
const login = normalizeLogin(req.body.github_login);
|
|
249
|
+
if (!GITHUB_LOGIN_RE.test(login)) {
|
|
250
|
+
return res.fail('bad_github_login', { status: 400, message: 'That does not look like a GitHub username.' });
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
const member = await pool.query(
|
|
254
|
+
`SELECT 1 FROM builders WHERE lower(github_login) = lower($1) LIMIT 1`,
|
|
255
|
+
[login]
|
|
256
|
+
);
|
|
257
|
+
if (member.rows.length > 0) {
|
|
258
|
+
return res.fail('already_member', { status: 409, message: 'They already have an account here — no invite needed.' });
|
|
259
|
+
}
|
|
260
|
+
const standing = await pool.query(
|
|
261
|
+
`SELECT 1 FROM access_requests
|
|
262
|
+
WHERE lower(github_login) = lower($1) AND status = 'invited' LIMIT 1`,
|
|
263
|
+
[login]
|
|
264
|
+
);
|
|
265
|
+
if (standing.rows.length > 0) {
|
|
266
|
+
return res.json({ ok: true, already_invited: true, github_login: login });
|
|
267
|
+
}
|
|
268
|
+
const resolved = await pool.query(
|
|
269
|
+
`UPDATE access_requests
|
|
270
|
+
SET status = 'invited',
|
|
271
|
+
resolved_by_builder_id = $1,
|
|
272
|
+
resolved_at = now()
|
|
273
|
+
WHERE lower(github_login) = lower($2) AND status = 'pending'
|
|
274
|
+
RETURNING id, github_login, status, resolved_at`,
|
|
275
|
+
[req.builder.id, login]
|
|
276
|
+
);
|
|
277
|
+
if (resolved.rows.length > 0) {
|
|
278
|
+
return res.json({ ok: true, request: resolved.rows[0] });
|
|
279
|
+
}
|
|
280
|
+
const { rows } = await pool.query(
|
|
281
|
+
`INSERT INTO access_requests (github_login, status, resolved_by_builder_id, resolved_at)
|
|
282
|
+
VALUES ($1, 'invited', $2, now())
|
|
283
|
+
RETURNING id, github_login, status, resolved_at`,
|
|
284
|
+
[login, req.builder.id]
|
|
285
|
+
);
|
|
286
|
+
return res.status(201).json({ ok: true, request: rows[0] });
|
|
287
|
+
} catch (err) {
|
|
288
|
+
log.error('[gds] POST /access-requests/invite', err);
|
|
289
|
+
return res.fail('invite_failed', { status: 500, message: 'internal error' });
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
207
293
|
return router;
|
|
208
294
|
};
|
|
@@ -16,6 +16,34 @@ const pi = require('../platform-identity');
|
|
|
16
16
|
|
|
17
17
|
const { pool, requireBuilder, validateOrRespond, userAgent } = api;
|
|
18
18
|
|
|
19
|
+
// joinRelayOutcome — what the project's own admission surface answered, said in
|
|
20
|
+
// the HUB's words (task 1003044). The project decides; the hub relays and must
|
|
21
|
+
// never paint a refusal as "requested" — nor as "unreachable". A 2xx is a filed
|
|
22
|
+
// request; 409 already_member a standing membership; the door refusals (403
|
|
23
|
+
// invite_only, 409 open_enrollment) name the door; a 401 is the project's
|
|
24
|
+
// member-only visibility door (ADR 0192) refusing the ask itself. The project's
|
|
25
|
+
// own sentence is deliberately NOT relayed: a registered project is trusted to
|
|
26
|
+
// build, not to write prose into the platform's UI. Anything else is "not
|
|
27
|
+
// relayed" (null) → the caller's 502.
|
|
28
|
+
const RELAY_SENTENCE = {
|
|
29
|
+
requested: 'Requested access. The project’s owner admits new builders; once approved, sign in to enter.',
|
|
30
|
+
already_member: 'You already build on this project — sign in to it to see it here.',
|
|
31
|
+
invite_only: 'This project is invite-only — it doesn’t take requests. Ask its owner for an invite; once invited, sign in to it.',
|
|
32
|
+
open_enrollment: 'This project is open — no request needed. Sign in to it and you’re in.',
|
|
33
|
+
members_only: 'This project is only visible to its members right now, so it can’t take a request from here — ask its owner.',
|
|
34
|
+
};
|
|
35
|
+
function joinRelayOutcome(status, body) {
|
|
36
|
+
const err = (body && body.error) || null;
|
|
37
|
+
const code = err && (err.code || err);
|
|
38
|
+
let outcome;
|
|
39
|
+
if (status >= 200 && status < 300) outcome = 'requested';
|
|
40
|
+
else if (code === 'invite_only' || code === 'open_enrollment') outcome = code;
|
|
41
|
+
else if (status === 409) outcome = 'already_member';
|
|
42
|
+
else if (status === 401) outcome = 'members_only';
|
|
43
|
+
else return null;
|
|
44
|
+
return { status: outcome, message: RELAY_SENTENCE[outcome] };
|
|
45
|
+
}
|
|
46
|
+
|
|
19
47
|
module.exports = function myProjectsRoutes() {
|
|
20
48
|
const router = express.Router();
|
|
21
49
|
|
|
@@ -56,27 +84,24 @@ module.exports = function myProjectsRoutes() {
|
|
|
56
84
|
// Relay an access request to the project's OWN (public) admission endpoint —
|
|
57
85
|
// the project decides (open-join, or request/invite per ADR 0112). We post to
|
|
58
86
|
// the registry-trusted origin, never the caller's raw input.
|
|
59
|
-
let
|
|
60
|
-
let alreadyMember = false;
|
|
87
|
+
let outcome = null;
|
|
61
88
|
try {
|
|
62
89
|
const r = await fetch(`${client.origin}/api/gds/access-requests`, {
|
|
63
90
|
method: 'POST',
|
|
64
91
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': userAgent() },
|
|
65
92
|
body: JSON.stringify({ github_login: req.builder.github_login, note: 'via platform My Projects' }),
|
|
66
93
|
});
|
|
67
|
-
|
|
68
|
-
|
|
94
|
+
const body = await r.json().catch(() => null);
|
|
95
|
+
outcome = joinRelayOutcome(r.status, body);
|
|
69
96
|
} catch (e) {
|
|
70
97
|
console.log('[platform-identity] join relay failed (non-fatal):', e.message);
|
|
71
98
|
}
|
|
72
|
-
if (!
|
|
99
|
+
if (!outcome) return res.fail('join_relay_failed', { status: 502, message: 'the project could not be reached to request access' });
|
|
73
100
|
res.json({
|
|
74
101
|
ok: true,
|
|
75
|
-
status:
|
|
102
|
+
status: outcome.status,
|
|
76
103
|
project: { name: client.name, origin: client.origin },
|
|
77
|
-
message:
|
|
78
|
-
? 'You already build on this project — sign in to it to see it here.'
|
|
79
|
-
: 'Requested access. The project’s owner admits new builders; once approved, sign in to enter.',
|
|
104
|
+
message: outcome.message,
|
|
80
105
|
});
|
|
81
106
|
} catch (err) {
|
|
82
107
|
console.error('[platform-identity] POST /my-projects/join', err);
|
|
@@ -138,3 +163,4 @@ module.exports = function myProjectsRoutes() {
|
|
|
138
163
|
|
|
139
164
|
return router;
|
|
140
165
|
};
|
|
166
|
+
module.exports.joinRelayOutcome = joinRelayOutcome;
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.375",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.375",
|
|
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.19.
|
|
3
|
+
"version": "1.19.375",
|
|
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",
|