@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.
Files changed (41) hide show
  1. package/.bongos-core.json +72 -42
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +4 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0194-joinability-one-door-decision.md +42 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +85 -3
  10. package/docs/api-reference.md +3 -2
  11. package/docs/copy-inventory.md +249 -233
  12. package/docs/copy-registry.json +475 -300
  13. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  14. package/modules/government/catalog.js +1 -1
  15. package/modules/hall-ui/public/builders.js +5 -3
  16. package/modules/hall-ui/public/hall-render.js +63 -22
  17. package/modules/hall-ui/public/my-projects.js +6 -1
  18. package/modules/hall-ui/public/watch.css +8 -0
  19. package/modules/hall-ui/public/watch.js +56 -3
  20. package/modules/onboarding/module.json +1 -1
  21. package/modules/onboarding/routes/access-requests.js +87 -1
  22. package/modules/platform-identity/routes/my-projects.js +35 -9
  23. package/package-lock.json +2 -2
  24. package/package.json +1 -1
  25. package/public-landing/projects.html +169 -3
  26. package/scripts/gds/login.js +24 -3
  27. package/src/bongos/auth-admission.js +50 -5
  28. package/src/bongos/auth.js +2 -1
  29. package/src/bongos/routes/auth.js +15 -0
  30. package/src/branding.js +3 -2
  31. package/src/module-api.js +20 -1
  32. package/tests/access_requests_invite.mjs +165 -0
  33. package/tests/auth_admission_gate.mjs +42 -0
  34. package/tests/join_relay_outcome.mjs +61 -0
  35. package/tests/joinability_apply.mjs +72 -0
  36. package/tests/joinability_invite_only.mjs +192 -0
  37. package/tests/joinability_open.mjs +89 -0
  38. package/tests/module_api.mjs +1 -0
  39. package/tests/projects_hub.mjs +76 -2
  40. package/tests/projects_hub_pre_uat.mjs +4 -2
  41. 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
- detail = j.message || j.detail || j.error || detail;
84
- code = j.error || null;
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
- function renderLanding() {
242
- // task 1723: collapse the shell nav to the signed-out minimum (Home + a
243
- // Sign-in button). Cosmetic — the server gates every page regardless.
244
- window.OTBShell?.applyAccess({ authed: false });
245
-
246
- const welcome = $('#welcome-line');
247
- if (welcome) welcome.textContent = '';
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
- const landing = $('#landing-root');
250
- if (landing) {
251
- landing.hidden = false;
252
- landing.innerHTML = `
253
- <div class="scroll landing">
254
- <h2 class="scroll__h">Welcome</h2>
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
- This is the builders' hub for ${brandWorld()} — where builders claim
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
- // Already known to the city — they don't need a request, they need to
350
- // sign in. Both the "already a builder" and "already approved" cases
351
- // collapse the form into a sign-in prompt rather than a red error.
352
- if (err && (err.code === 'already_member' || err.code === 'already_approved')) {
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">You already have access ✓</h3>
357
- <p class="landing__request-lede">${escapeHtml(err.message || 'You already have access.')}</p>
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) { joinNote.textContent = res.body.message; joinInput.value = ''; load(); }
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) throw new Error(data.message || data.error || `${path} → ${res.status}`);
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.9.0",
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 relayed = false;
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
- alreadyMember = r.status === 409;
68
- relayed = r.ok || r.status === 200 || r.status === 201 || alreadyMember;
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 (!relayed) return res.fail('join_relay_failed', { status: 502, message: 'the project could not be reached to request access' });
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: alreadyMember ? 'already_member' : 'requested',
102
+ status: outcome.status,
76
103
  project: { name: client.name, origin: client.origin },
77
- message: alreadyMember
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.374",
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.374",
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.374",
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",