@bongos/core 1.19.374 → 1.19.376

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 (56) hide show
  1. package/.bongos-core.json +99 -54
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +9 -1
  6. package/clients/bongos-client/index.mjs +4 -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 +158 -5
  10. package/docs/api-reference.md +6 -4
  11. package/docs/copy-inventory.md +373 -356
  12. package/docs/copy-registry.json +646 -452
  13. package/migrations/core_227_task_created_by.sql +83 -0
  14. package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
  15. package/modules/government/catalog.js +1 -1
  16. package/modules/hall-ui/public/builders.js +5 -3
  17. package/modules/hall-ui/public/goals-lib.js +5 -5
  18. package/modules/hall-ui/public/goals-page.js +54 -23
  19. package/modules/hall-ui/public/goals-render.js +15 -0
  20. package/modules/hall-ui/public/hall-render.js +63 -22
  21. package/modules/hall-ui/public/my-projects.js +6 -1
  22. package/modules/hall-ui/public/task-lib.js +29 -0
  23. package/modules/hall-ui/public/watch.css +8 -0
  24. package/modules/hall-ui/public/watch.js +56 -3
  25. package/modules/hall-ui/public/work.js +117 -5
  26. package/modules/ideas/inbox.js +4 -0
  27. package/modules/lifecycle/db-tasks.js +61 -5
  28. package/modules/lifecycle/goal-rollup.js +118 -0
  29. package/modules/lifecycle/routes/goals.js +26 -0
  30. package/modules/lifecycle/routes/task-write-routes.js +3 -0
  31. package/modules/lifecycle/routes/tasks.js +47 -1
  32. package/modules/onboarding/module.json +1 -1
  33. package/modules/onboarding/routes/access-requests.js +87 -1
  34. package/modules/platform-identity/routes/my-projects.js +35 -9
  35. package/package-lock.json +2 -2
  36. package/package.json +1 -1
  37. package/public-landing/projects.html +169 -3
  38. package/scripts/gds/login.js +24 -3
  39. package/src/bongos/auth-admission.js +50 -5
  40. package/src/bongos/auth.js +2 -1
  41. package/src/bongos/routes/auth.js +15 -0
  42. package/src/branding.js +3 -2
  43. package/src/module-api.js +22 -1
  44. package/tests/access_requests_invite.mjs +165 -0
  45. package/tests/auth_admission_gate.mjs +42 -0
  46. package/tests/goal_sort_staleness.mjs +286 -0
  47. package/tests/goal_subtitle.mjs +6 -1
  48. package/tests/goals_lib.mjs +16 -4
  49. package/tests/join_relay_outcome.mjs +61 -0
  50. package/tests/joinability_apply.mjs +72 -0
  51. package/tests/joinability_invite_only.mjs +192 -0
  52. package/tests/joinability_open.mjs +89 -0
  53. package/tests/module_api.mjs +1 -0
  54. package/tests/projects_hub.mjs +76 -2
  55. package/tests/projects_hub_pre_uat.mjs +4 -2
  56. package/tests/public_landing_projects.mjs +10 -3
@@ -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.376",
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.376",
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.376",
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",
@@ -678,11 +678,19 @@ summary{min-height:24px;padding:3px 0;}
678
678
  </details>
679
679
  </div>
680
680
 
681
- <!-- the setting the sibling task (1003044) fills in — placed BELOW the
682
- sections that work, so a founder meets controls before promises -->
681
+ <!-- live since task 1003044: how people join — the membership door. The
682
+ visibility section's two reads, key for key: the choice shown is
683
+ the platform's REQUEST (its settings row); what the project actually
684
+ RUNS is read back from its own public manifest (brand.project.
685
+ joinability) — never from the save's reply, which is a receipt. The
686
+ fold is a SIBLING of the volatile slot so no re-render can drop it. -->
683
687
  <div class="card" id="mJoinability">
684
688
  <h2 style="font-family:var(--font-display);font-size:19px;margin-bottom:12px;">How people join</h2>
685
- <div class="empty"><p>Choosing how new people join — walk right in, apply first, or invite only — isn’t wired up here yet. For now, people apply to join and you invite them.</p></div>
689
+ <div id="mJoin"><p class="finePrint">Reading the project’s settings…</p></div>
690
+ <details class="finePrint" id="mJoinWhat">
691
+ <summary>What each choice changes</summary>
692
+ <p><b>Anyone can join</b> — whoever signs in with GitHub becomes a member at once, at the newcomer rank. <b>Apply to join</b> — people ask from the project’s hall, you approve each one from the hall’s Watch page, and then they sign in. <b>Invite only</b> — no one can apply: the project’s hall and its sign-in say so, and you invite people by GitHub username from the hall’s Watch page; only an invited person’s sign-in gets in — and people who asked before you switched stay in your queue, to approve or dismiss. Whichever you pick, people who are already members stay members. Changing it restarts the project to pick it up — the line under the choice says what the project is running right now.</p>
693
+ </details>
686
694
  </div>
687
695
 
688
696
  <!-- live since task 1003042: the teardown control, for a project in ANY
@@ -4379,6 +4387,7 @@ summary{min-height:24px;padding:3px 0;}
4379
4387
  /* same contract: the visibility section's in-flight line reads open_intent
4380
4388
  only, so a queued settings push shows up on the tick without a reload */
4381
4389
  renderVisState(inst);
4390
+ renderJoinState(inst);
4382
4391
  /* the page names its project — in the heading and in the tab */
4383
4392
  $('mTitle').textContent = inst.slug || 'Manage';
4384
4393
  document.title = (inst.slug ? inst.slug + ' — ' : '') + BASE_TITLE;
@@ -4484,6 +4493,7 @@ summary{min-height:24px;padding:3px 0;}
4484
4493
  /* the settings row + the project's own manifest — the same cadence as
4485
4494
  the GitHub read: entry + tab return, never the in-flight tick */
4486
4495
  if (!keepRepo) loadVisibility(inst);
4496
+ if (!keepRepo) loadJoinability(inst);
4487
4497
  })
4488
4498
  .catch(function (err) {
4489
4499
  var m = String(err && err.message);
@@ -4805,6 +4815,162 @@ summary{min-height:24px;padding:3px 0;}
4805
4815
  if (t && t.hasAttribute('data-vis-save')) doVisSave();
4806
4816
  });
4807
4817
 
4818
+ /* ── how people join (task 1003044): the joinability toggle — the visibility
4819
+ section's shape, key for key: the CHOICE from the platform's settings row,
4820
+ what the project RUNS from its own manifest (brand.project.joinability),
4821
+ the save's push.message shown verbatim and never painted as the live
4822
+ state. visPushOpen() is shared: one settings push carries every key. ── */
4823
+
4824
+ var JOIN_OPTIONS = {
4825
+ open: { label: 'Anyone can join', blurb: 'Whoever signs in with GitHub becomes a member right away, at the newcomer rank.' },
4826
+ apply: { label: 'Apply to join', blurb: 'People ask to join from the project’s hall; you approve each one, then they sign in.' },
4827
+ invite_only: { label: 'Invite only', blurb: 'No one can apply. You invite people by GitHub username from the hall, and only an invited person can sign in.' }
4828
+ };
4829
+ var mJoinSettings = null; /* the platform row's last answer: { value, options } */
4830
+ var mJoinRunsHtml = ''; /* what the project's manifest last said it runs */
4831
+ var mJoinPushSeen = false; /* a settings push was open — re-read the manifest once it settles */
4832
+
4833
+ function joinMsg(html) { $('mJoin').innerHTML = html; }
4834
+
4835
+ function renderJoin() {
4836
+ var s = mJoinSettings;
4837
+ if (!s) return;
4838
+ var opts = (s.options || []).filter(function (v) { return !!JOIN_OPTIONS[v]; });
4839
+ joinMsg('<div role="radiogroup" aria-label="How people join">' + opts.map(function (v) {
4840
+ var o = JOIN_OPTIONS[v];
4841
+ return '<label class="visOpt"><input type="radio" name="mJoinPick" value="' + esc(v) + '"' +
4842
+ (v === s.value ? ' checked' : '') + '><span><b>' + esc(o.label) + '</b>' + esc(o.blurb) + '</span></label>';
4843
+ }).join('') + '</div>' +
4844
+ '<div class="actions"><button type="button" class="btn small" data-join-save>Save</button></div>' +
4845
+ '<div class="projMsg" id="mJoinMsg" role="status" aria-live="polite"></div>' +
4846
+ '<p class="finePrint" id="mJoinRuns">' + mJoinRunsHtml + '</p>');
4847
+ renderJoinState(manageInst);
4848
+ }
4849
+
4850
+ function joinRuns(html) {
4851
+ mJoinRunsHtml = html;
4852
+ var el = $('mJoinRuns');
4853
+ if (el) el.innerHTML = html;
4854
+ }
4855
+
4856
+ /* fetch-free, from open_intent only (renderManage calls it on every repaint) */
4857
+ function renderJoinState(inst) {
4858
+ if (!inst || !$('mJoinRuns')) return;
4859
+ if (visPushOpen(inst)) {
4860
+ mJoinPushSeen = true;
4861
+ joinRuns(inst.open_intent.state === 'running'
4862
+ ? 'Restarting the project to apply a settings change…'
4863
+ : 'A settings change is queued — the project restarts to pick it up. Until then it keeps its current setting.');
4864
+ return;
4865
+ }
4866
+ if (mJoinPushSeen) { mJoinPushSeen = false; loadJoinRuns(inst); }
4867
+ }
4868
+
4869
+ /* what the project RUNS: its own public manifest, read cross-origin */
4870
+ function loadJoinRuns(inst) {
4871
+ if (!inst || inst.status !== 'active' || !inst.domain) { joinRuns(''); return; }
4872
+ var asked = manageId;
4873
+ joinRuns('Checking what the project is running…');
4874
+ var ctl = typeof AbortController === 'function' ? new AbortController() : null;
4875
+ var timer = ctl ? setTimeout(function () { ctl.abort(); }, 8000) : null;
4876
+ fetch('https://' + inst.domain + '/api/gds/instance', { mode: 'cors', signal: ctl ? ctl.signal : undefined })
4877
+ .then(function (res) {
4878
+ if (!res.ok) throw new Error('http:' + res.status);
4879
+ return res.json();
4880
+ })
4881
+ .then(function (m) {
4882
+ if (asked !== manageId) return;
4883
+ var p = m && m.brand && m.brand.project;
4884
+ var v = p && p.joinability;
4885
+ if (!p || typeof p !== 'object') {
4886
+ joinRuns('This project’s server doesn’t report this setting yet — it runs an older platform version, so the setting can’t take effect until the project is updated.');
4887
+ return;
4888
+ }
4889
+ var o = JOIN_OPTIONS[v];
4890
+ if (!o) { joinRuns('This project’s server reports a setting this page doesn’t recognise (' + esc(String(v)) + ').'); return; }
4891
+ var want = mJoinSettings && mJoinSettings.value;
4892
+ joinRuns('Right now the project runs as <b>' + esc(o.label) + '</b>' +
4893
+ (want && want !== v && JOIN_OPTIONS[want] ? ' — the saved choice (' + esc(JOIN_OPTIONS[want].label) + ') hasn’t taken effect there yet.' : '.'));
4894
+ })
4895
+ .catch(function (err) {
4896
+ if (asked !== manageId) return;
4897
+ var http = /^http:(\d+)$/.exec(String(err && err.message));
4898
+ joinRuns(http
4899
+ ? 'The project answered but didn’t share what it’s running (it replied ' + esc(http[1]) + ').'
4900
+ : 'Couldn’t reach the project to check what it’s running right now.');
4901
+ })
4902
+ .then(function () { if (timer) clearTimeout(timer); });
4903
+ }
4904
+
4905
+ function loadJoinability(inst) {
4906
+ mJoinSettings = null; mJoinRunsHtml = ''; mJoinPushSeen = false;
4907
+ joinMsg('<p class="finePrint">Reading the project’s settings…</p>');
4908
+ var asked = manageId; /* a stale answer must never describe a newer project */
4909
+ fetch(API + '/provisioning/instances/' + encodeURIComponent(manageId) + '/settings', { credentials: 'same-origin' })
4910
+ .then(function (res) {
4911
+ if (asked !== manageId) throw new Error('stale');
4912
+ return jsonOrEmpty(res).then(function (data) {
4913
+ if (res.ok) {
4914
+ mJoinSettings = {
4915
+ value: (data.settings || {}).joinability,
4916
+ options: (data.options || {}).joinability || Object.keys(JOIN_OPTIONS)
4917
+ };
4918
+ renderJoin();
4919
+ if (!visPushOpen(inst)) loadJoinRuns(inst);
4920
+ return;
4921
+ }
4922
+ joinMsg('<p class="finePrint">' + (res.status === 401 ? signInAgain() : 'Couldn’t read this project’s settings right now — refresh in a moment.') + '</p>');
4923
+ });
4924
+ })
4925
+ .catch(function (err) {
4926
+ if (String(err && err.message) === 'stale') return;
4927
+ joinMsg('<p class="finePrint">Couldn’t read this project’s settings right now — refresh in a moment.</p>');
4928
+ });
4929
+ }
4930
+
4931
+ /* the save sends the CHOICE and shows the platform's receipt verbatim */
4932
+ function doJoinSave() {
4933
+ if (!manageInst || !mJoinSettings) return;
4934
+ var picked = document.querySelector('#mJoin input[name="mJoinPick"]:checked');
4935
+ if (!picked) return;
4936
+ var value = picked.value;
4937
+ var box = $('mJoinMsg');
4938
+ var btn = document.querySelector('#mJoin [data-join-save]');
4939
+ if (btn) btn.disabled = true;
4940
+ box.className = 'projMsg'; box.textContent = 'Saving…';
4941
+ fetch(API + '/provisioning/instances/' + encodeURIComponent(manageId) + '/settings', {
4942
+ method: 'PATCH', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
4943
+ body: JSON.stringify({ joinability: value })
4944
+ }).then(function (res) {
4945
+ return jsonOrEmpty(res).then(function (data) {
4946
+ if (res.ok) {
4947
+ /* the row now holds the choice — the radio follows it; the "runs"
4948
+ line does NOT, until the project's own manifest says so */
4949
+ mJoinSettings.value = (data.saved || {}).joinability || value;
4950
+ if (data.push && data.push.queued) mJoinPushSeen = true;
4951
+ box.className = 'projMsg ok';
4952
+ box.textContent = (data.push && data.push.message) || 'Saved.';
4953
+ return;
4954
+ }
4955
+ var e = apiError(data, res.status);
4956
+ if (res.status === 401) { box.className = 'projMsg err'; box.innerHTML = signInAgain(); return; }
4957
+ box.className = e.code === 'intent_conflict' ? 'projMsg' : 'projMsg err';
4958
+ box.textContent = e.message || 'The change could not be saved — try again in a moment.';
4959
+ });
4960
+ }).catch(function () {
4961
+ box.className = 'projMsg err';
4962
+ box.textContent = 'Couldn’t reach the platform — check your connection and try again.';
4963
+ }).then(function () {
4964
+ if (btn) btn.disabled = false;
4965
+ loadManage(true, true);
4966
+ });
4967
+ }
4968
+
4969
+ $('mJoin').addEventListener('click', function (ev) {
4970
+ var t = ev.target.closest && ev.target.closest('button');
4971
+ if (t && t.hasAttribute('data-join-save')) doJoinSave();
4972
+ });
4973
+
4808
4974
  /* ── delete the project (task 1003042): the manage page's teardown control.
4809
4975
  It renders from status + open_intent only — no second request — so the
4810
4976
  minute tick can repaint it and a queued teardown appears without a reload.
@@ -189,11 +189,20 @@ async function main() {
189
189
  }
190
190
  const instanceName = man.data.name || base;
191
191
  const admission = (man.data.admission && man.data.admission.policy) || 'invite-only';
192
+ // The project's door (task 1003044): brand.project.joinability names which of
193
+ // open / apply / invite_only it runs; an older core reports none, and then the
194
+ // binary admission.policy above is all there is to say.
195
+ // admission.policy 'open' wins over a pushed 'apply': a self-hosted instance on
196
+ // the legacy open-enrolment flag reports the pack's default while its door is open.
197
+ const joinability = admission === 'open' ? 'open'
198
+ : (man.data.brand && man.data.brand.project && man.data.brand.project.joinability) || null;
199
+ const DOOR_WORD = { open: '', apply: ' (apply to join)', invite_only: ' (invite-only)' };
200
+ const doorWord = joinability in DOOR_WORD ? DOOR_WORD[joinability] : (admission === 'invite-only' ? ' (invite-only)' : '');
192
201
  // Federation (ADR 0141 §1): a hosted instance advertises auth.idp = { origin,
193
202
  // client_id } — sign-in then runs through the Cloud Bongos hub, not a local
194
203
  // GitHub app. null → the direct-GitHub path, unchanged.
195
204
  const idp = (man.data.auth && man.data.auth.idp) || null;
196
- log(` Signing in to ${instanceName}${admission === 'invite-only' ? ' (invite-only)' : ''}${idp ? ' — via Cloud Bongos platform sign-in' : ''}`);
205
+ log(` Signing in to ${instanceName}${doorWord}${idp ? ' — via Cloud Bongos platform sign-in' : ''}`);
197
206
 
198
207
  // 2. Device flow.
199
208
  let flow = await runDeviceFlow(base, { log, idp });
@@ -202,6 +211,12 @@ async function main() {
202
211
  // then finish sign-in with a fresh device flow (the first code is spent).
203
212
  if (flow.state === 'not_approved') {
204
213
  log('');
214
+ // An invite-only project takes no applications — say so instead of
215
+ // collecting a username for a request the project would refuse.
216
+ if (joinability === 'invite_only') {
217
+ console.error(`✖ ${instanceName} is invite-only — it doesn’t take applications. Ask its owner for an invite, then re-run \`bongos login ${base}\`.`);
218
+ process.exit(1);
219
+ }
205
220
  log(` You're not admitted to ${instanceName} yet.`);
206
221
  const ghLogin = (await prompt(' Your GitHub username: ')).replace(/^@/, '');
207
222
  if (!ghLogin) {
@@ -212,12 +227,18 @@ async function main() {
212
227
  method: 'POST',
213
228
  body: JSON.stringify({ github_login: ghLogin, note: 'via bongos login' }),
214
229
  });
215
- if (reqRes.status === 409 && reqRes.data && reqRes.data.error === 'already_member') {
230
+ // The refusal envelope is { error: { code, message } } (ADR 0116); an older
231
+ // instance answered a flat { error: 'code' } — read both.
232
+ const reqErr = (reqRes.data && reqRes.data.error) || null;
233
+ const reqCode = reqErr && (reqErr.code || reqErr);
234
+ const reqMessage = reqErr && reqErr.message;
235
+ if (reqRes.status === 409 && reqCode === 'already_member') {
216
236
  log(' You already have an account — retrying sign-in…');
217
237
  } else if (reqRes.ok || reqRes.status === 200 || reqRes.status === 201) {
218
238
  log(` ✓ Requested access to ${instanceName}. An Archon approves you with a 👍 in Discord (#member-approvals).`);
219
239
  } else {
220
- console.error(`✖ could not file an access request (HTTP ${reqRes.status}).`);
240
+ // the project's own sentence when it gave one (an invite-only or open door)
241
+ console.error(reqMessage ? `✖ ${reqMessage}` : `✖ could not file an access request (HTTP ${reqRes.status}).`);
221
242
  process.exit(1);
222
243
  }
223
244
  log(' Waiting for approval (safe to leave running)…');
@@ -121,15 +121,59 @@ async function reportActivityRollup({ builderId, githubId }) {
121
121
  // already exists for their github_id) ALWAYS passes — the gate never locks out
122
122
  // someone already enrolled.
123
123
  //
124
- // OTB_OPEN_XENOS_ENROLLMENT is the escape-hatch: set it truthy ('1' or 'true')
125
- // to restore fully-open self-enrollment (anyone with a GitHub account becomes a
126
- // Xenos) WITHOUT a code change, for an owner who deliberately wants open
127
- // enrollment. Default (unset/anything-else) = invite required. See ADR 0050.
128
- function openEnrollmentEnabled() {
124
+ // The project's JOINABILITY decides which of those a first-timer gets (task
125
+ // 1003044, goal 1000058; the owner's 2026-08-16 decision). The owner picks it on
126
+ // the hub's manage page; the platform pushes it into this instance's branding
127
+ // pack as project.joinability (ADR 0190) and it is read through branding(),
128
+ // like platformVisibility — memoized for the process, applied by a restart:
129
+ // open a first-time GitHub sign-in self-enrols at xenos — no request,
130
+ // no invite (what OTB_OPEN_XENOS_ENROLLMENT used to switch on)
131
+ // apply (the default — today's behaviour) a stranger files an access
132
+ // request and an Archon marks it 'invited'; then sign-in admits
133
+ // invite_only the public POST /access-requests refuses; only an access
134
+ // request an Archon CREATED at 'invited' admits a first-timer
135
+ // The legacy OPEN_XENOS_ENROLLMENT flag (ADR 0050) still opens an *apply*
136
+ // project, so a self-hosted instance that set it keeps its open door across the
137
+ // upgrade — but it does not override an explicit invite_only: the stricter,
138
+ // newer choice wins. An unreadable pack fails CLOSED (invite_only): when the
139
+ // door's policy is unknown the widest door is never the fallback.
140
+ function legacyOpenEnrollmentFlag() {
129
141
  const v = ic.resolveEnv('OPEN_XENOS_ENROLLMENT');
130
142
  return v === '1' || v === 'true';
131
143
  }
132
144
 
145
+ // joinabilityMode — the project's effective membership door policy: 'open' |
146
+ // 'apply' | 'invite_only'. The one reader every admission surface uses (both
147
+ // sign-in flows, the public access-request route via the doorway, the manifest,
148
+ // the refusal pages), so none of them can disagree about which door this is.
149
+ // `read` is injectable for the unit tests only (the pack is memoized).
150
+ let warnedUnknownJoinability = false;
151
+ function joinabilityMode(read = branding) {
152
+ let v;
153
+ try { v = read().project?.joinability; } catch (err) {
154
+ log.error('[auth] joinability: branding pack unreadable — treating the project as invite-only:', err.message);
155
+ return 'invite_only';
156
+ }
157
+ if (v === 'open' || v === 'invite_only') return v;
158
+ if (v === undefined || v === null || v === '' || v === 'apply') {
159
+ return legacyOpenEnrollmentFlag() ? 'open' : 'apply';
160
+ }
161
+ // A value this core cannot read ('invite-only' with a hyphen, a truncated
162
+ // write) is a policy it does not know — never the wider door.
163
+ if (!warnedUnknownJoinability) {
164
+ warnedUnknownJoinability = true;
165
+ log.error(`[auth] joinability: unrecognised value ${JSON.stringify(v)} — treating the project as invite-only`);
166
+ }
167
+ return 'invite_only';
168
+ }
169
+
170
+ // openEnrollmentEnabled — true when a first-time GitHub account self-enrols
171
+ // (joinability 'open'). Kept as the name the four sign-in call sites and the
172
+ // manifest read; the decision itself is joinabilityMode().
173
+ function openEnrollmentEnabled() {
174
+ return joinabilityMode() === 'open';
175
+ }
176
+
133
177
  // PURE admission decision shared by both flows so they CANNOT drift (SR-19).
134
178
  // Returns true when a first-time account must be blocked. `existing` is the
135
179
  // builder row (or null) from getBuilderByGithubId; `approved` is the result of
@@ -257,6 +301,7 @@ async function safeSyncRankRole(builder) {
257
301
  module.exports = {
258
302
  firstAdminLogin,
259
303
  firstTimeAdmissionBlocked,
304
+ joinabilityMode,
260
305
  maybeSeatFirstAdmin,
261
306
  membershipKind,
262
307
  openEnrollmentEnabled,
@@ -55,7 +55,7 @@ const ic = require('../instance-config');
55
55
  const { branding, userAgent } = require('../branding');
56
56
 
57
57
  const { SCOPE, authConfigured, grantedScopeCoversRepos, idpConfigured, idpEnabled, loadIdpConfig, scopeForWebFlow } = require('./auth-config.js');
58
- const { firstAdminLogin, firstTimeAdmissionBlocked, maybeSeatFirstAdmin, membershipKind, openEnrollmentEnabled, webAdmissionStatus } = require('./auth-admission.js');
58
+ const { firstAdminLogin, firstTimeAdmissionBlocked, joinabilityMode, maybeSeatFirstAdmin, membershipKind, openEnrollmentEnabled, webAdmissionStatus } = require('./auth-admission.js');
59
59
  const { buildAuthorizeUrl, completeWebFlow, pollDeviceFlow, startDeviceFlow } = require('./auth-github.js');
60
60
  const { buildIdpAuthorizeUrl, completeDeviceExchange, completeIdpFlow, idpAuthorizeUrl, loadHubPublicKey, logoutTokenJtiSeenAndRecord, verifyHubAssertion, verifyHubLogoutToken } = require('./auth-idp.js');
61
61
  const { addDiscordGuildMember, buildDiscordAuthorizeUrl, discordAuthorizeUrl, discordBotConfigured, discordConfigured, exchangeDiscordCode, fetchDiscordUser, isValidDiscordUserId, loadDiscordBotToken, loadDiscordCreds, removeDiscordGuildMember } = require('./auth-discord.js');
@@ -730,6 +730,7 @@ module.exports = {
730
730
  loadIdpConfig,
731
731
  idpConfigured,
732
732
  openEnrollmentEnabled,
733
+ joinabilityMode,
733
734
  firstTimeAdmissionBlocked,
734
735
  webAdmissionStatus,
735
736
  firstAdminLogin,
@@ -677,6 +677,21 @@ module.exports = function buildAuthRouter() {
677
677
  // approval brings them back to it — the return cookie says where.
678
678
  const founderReturn = safeReturnTo(parseCookie(req, RETURN_COOKIE)) || '';
679
679
  const onWayToProject = founderReturn.startsWith('/projects');
680
+ // An invite-only project (task 1003044) takes no applications: say so,
681
+ // and name the one way in — an invite from the admitter — instead of
682
+ // sending a stranger to a request form the project refuses.
683
+ if (auth.joinabilityMode() === 'invite_only') {
684
+ return res.status(403).type('text/html').send(renderAuthErrorPage(
685
+ 'This project is invite-only',
686
+ `Signing in with GitHub is only for builders the ${admitterLabel()} has invited. This project doesn’t take applications, so there is nothing to request here. If you know the ${admitterLabel()}, ask them for an invite — once you’re invited, this sign-in lets you in.`,
687
+ {
688
+ subtitle: 'Sign-in is for invited builders. This project is invite-only.',
689
+ cta: { href: '/builders', label: 'Go to the hall' },
690
+ note: (onWayToProject ? 'You were on your way to creating a project — once invited, sign in again and you land back in it. ' : '') +
691
+ `Already invited and still blocked? Ask the ${admitterLabel()} to confirm your invite was recorded.`,
692
+ },
693
+ ));
694
+ }
680
695
  return res.status(403).type('text/html').send(renderAuthErrorPage(
681
696
  'This account is not approved yet',
682
697
  `Signing in with GitHub is only for builders the ${admitterLabel()} has already approved. If you are new, request access at the builders’ hall — once the ${admitterLabel()} approves you, this sign-in will let you in. If you were just approved, it may take a moment to be marked invited; try again shortly.`,
package/src/branding.js CHANGED
@@ -209,8 +209,9 @@ function clientBranding(b = branding()) {
209
209
  // says so — and the READ-BACK a manage page needs: a settings PATCH on the
210
210
  // platform is only a request until this instance restarts with the value, so
211
211
  // the honest place to learn what it RESOLVED from its environment is its own
212
- // GET /instance manifest. Resolved, not enforced: enforcement lands with
213
- // tasks 1003043 (visibility) and 1003044 (joinability).
212
+ // GET /instance manifest. Enforced on the instance by the platform-visibility
213
+ // gate (task 1003043, ADR 0192) and the admission machinery (task 1003044,
214
+ // ADR 0194 — joinabilityMode() in auth-admission.js).
214
215
  project: {
215
216
  platformVisibility: b.project?.platformVisibility || 'public',
216
217
  joinability: b.project?.joinability || 'apply',