@bongos/core 1.19.568 → 1.19.570

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.
@@ -165,6 +165,18 @@ module.exports = function registerTaskWriteRoutes(router) {
165
165
  // Validate an explicit goal_id: it must exist AND belong to THIS version (a task
166
166
  // can't be parented under another version's goal). Absent → the default-goal
167
167
  // trigger files it under the version's catch-all goal, exactly as before.
168
+ // BV1.R11 (task 1003598, goal 1000086, ADR 0250 D4): goal_id is REQUIRED on
169
+ // this vector. It used to be optional and a goal-less task was filed into the
170
+ // version catch-all, which is how 53 open tasks ended up in two buckets
171
+ // nobody reads. This is the human/agent door, and it always has a goal to
172
+ // name — /builder-start lists work by goal, and POST /goals/:id/tasks has
173
+ // required one since BV1.R60.
174
+ if (body.goal_id == null) {
175
+ return res.fail('goal_id_required', {
176
+ status: 400,
177
+ message: 'goal_id is required — every task belongs to a goal. List them at GET /goals?version=<id>, or author under one directly with POST /goals/:id/tasks.',
178
+ });
179
+ }
168
180
  if (body.goal_id != null) {
169
181
  const goal = await db.getGoal(body.goal_id);
170
182
  if (!goal || String(goal.version_id) !== String(body.version_id)) {
@@ -156,6 +156,10 @@ async function restockTemplate(discipline, template, floors) {
156
156
  const seq = maxSeq + 1 + i;
157
157
  const task = await db.createTask({
158
158
  versionId: template.version_id,
159
+ // BV1.R11 (task 1003598): no goal to name until the per-version maintenance
160
+ // goal exists (task 1003605, R18). Declared out loud rather than relying on
161
+ // a silent default — see createTask.
162
+ allowCatchAll: true,
159
163
  title: template.title,
160
164
  description: buildDescription(template),
161
165
  status: 'ready',
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.568",
3
+ "version": "1.19.570",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.568",
9
+ "version": "1.19.570",
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.568",
3
+ "version": "1.19.570",
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",
@@ -534,9 +534,11 @@ const KERNEL_FILES = [
534
534
  // route-rank-check.js and goal-scope-check.js above (a trust decision the doorway
535
535
  // hands to modules so no module re-derives it). The doorway re-exports it, so
536
536
  // leaving it off this roster reads as a kernel->non-kernel leak; it imports only
537
- // branding.js and auth-admission.js — both on this roster — so joining costs
538
- // nothing and buys the import-direction guarantee on the file that decides who
539
- // may knock.
537
+ // branding.js, instance-config.js and logger.js — all on this roster — so
538
+ // joining costs nothing and buys the import-direction guarantee on the file that
539
+ // decides who may knock. (Task 1003579 removed its require of auth-admission.js
540
+ // and moved joinabilityMode the other way: the gate now reads the composed door,
541
+ // so the reader had to sit with the composer or the two files would have cycled.)
540
542
  'src/bongos/project-door.js',
541
543
  ];
542
544
 
@@ -29,6 +29,10 @@ const seams = require('../module-seams');
29
29
  const ic = require('../instance-config');
30
30
  const { branding, userAgent } = require('../branding');
31
31
  const { loadIdpConfig } = require('./auth-config.js');
32
+ // task 1003579: the sign-in gate reads the project's COMPOSED join door, not the
33
+ // single `joinability` knob it used to. joinabilityMode moved into that file with
34
+ // the two knobs it composes against and is re-exported below, unchanged.
35
+ const { effectiveJoinDoor, joinabilityMode } = require('./project-door.js');
32
36
  // task 1003208: structured logging — was console.*. A core file logs through
33
37
  // the kernel logger directly; only MODULES go via api.logger (the doorway).
34
38
  const log = require('./logger').logger.child({ src: 'auth-admission' });
@@ -201,57 +205,41 @@ async function reportActivityRollupForShippedTask(task) {
201
205
  // already exists for their github_id) ALWAYS passes — the gate never locks out
202
206
  // someone already enrolled.
203
207
  //
204
- // The project's JOINABILITY decides which of those a first-timer gets (task
205
- // 1003044, goal 1000058; the owner's 2026-08-16 decision). The owner picks it on
206
- // the hub's manage page; the platform pushes it into this instance's branding
207
- // pack as project.joinability (ADR 0190) and it is read through branding(),
208
- // like platformVisibility memoized for the process, applied by a restart:
209
- // open a first-time GitHub sign-in self-enrols at xenosno request,
210
- // no invite (what OTB_OPEN_XENOS_ENROLLMENT used to switch on)
211
- // apply (the default — today's behaviour) a stranger files an access
212
- // request and an Archon marks it 'invited'; then sign-in admits
213
- // invite_only the public POST /access-requests refuses; only an access
214
- // request an Archon CREATED at 'invited' admits a first-timer
215
- // The legacy OPEN_XENOS_ENROLLMENT flag (ADR 0050) still opens an *apply*
216
- // project, so a self-hosted instance that set it keeps its open door across the
217
- // upgradebut it does not override an explicit invite_only: the stricter,
218
- // newer choice wins. An unreadable pack fails CLOSED (invite_only): when the
219
- // door's policy is unknown the widest door is never the fallback.
220
- function legacyOpenEnrollmentFlag() {
221
- const v = ic.resolveEnv('OPEN_XENOS_ENROLLMENT');
222
- return v === '1' || v === 'true';
223
- }
224
-
225
- // joinabilityMode — the project's effective membership door policy: 'open' |
226
- // 'apply' | 'invite_only'. The one reader every admission surface uses (both
227
- // sign-in flows, the public access-request route via the doorway, the manifest,
228
- // the refusal pages), so none of them can disagree about which door this is.
229
- // `read` is injectable for the unit tests only (the pack is memoized).
230
- let warnedUnknownJoinability = false;
231
- function joinabilityMode(read = branding) {
232
- let v;
233
- try { v = read().project?.joinability; } catch (err) {
234
- log.error('[auth] joinability: branding pack unreadable — treating the project as invite-only:', err.message);
235
- return 'invite_only';
236
- }
237
- if (v === 'open' || v === 'invite_only') return v;
238
- if (v === undefined || v === null || v === '' || v === 'apply') {
239
- return legacyOpenEnrollmentFlag() ? 'open' : 'apply';
240
- }
241
- // A value this core cannot read ('invite-only' with a hyphen, a truncated
242
- // write) is a policy it does not know — never the wider door.
243
- if (!warnedUnknownJoinability) {
244
- warnedUnknownJoinability = true;
245
- log.error(`[auth] joinability: unrecognised value ${JSON.stringify(v)} — treating the project as invite-only`);
246
- }
247
- return 'invite_only';
248
- }
208
+ // WHICH of those a first-timer gets is the project's JOIN DOOR — and the door is
209
+ // three owner-set knobs composed into one answer, not the `joinability` knob
210
+ // alone (visibility × joinGrant × joinability; ADR 0182 D6, ADR 0247, and
211
+ // src/bongos/project-door.js for the table). The owner picks them on the hub's
212
+ // manage page; the platform pushes them into this instance's branding pack (ADR
213
+ // 0190) and they are read through branding() memoized for the process, applied
214
+ // by a restart.
215
+ //
216
+ // THE COMPOSITION IS WHY THIS READS THE DOOR AND NOT THE KNOB (task 1003579).
217
+ // ADR 0247 shipped the composed door at the public application endpoint and left
218
+ // this gate reading `joinability` alone, so an owner who set their project
219
+ // `private` (or `stealth`, or joinGrant `view`) while leaving `joinability: open`
220
+ // got a door that queued applications and a sign-in that still self-enrolled
221
+ // strangershalf of D6's table unenforced. Both surfaces now ask the same
222
+ // function, which is the only arrangement in which they cannot answer one
223
+ // stranger differently.
249
224
 
250
- // openEnrollmentEnabled — true when a first-time GitHub account self-enrols
251
- // (joinability 'open'). Kept as the name the four sign-in call sites and the
252
- // manifest read; the decision itself is joinabilityMode().
253
- function openEnrollmentEnabled() {
254
- return joinabilityMode() === 'open';
225
+ // openEnrollmentEnabled — true when a first-time GitHub account self-enrols: the
226
+ // project's composed join door is `open`. Kept as the name the four sign-in call
227
+ // sites, webAdmissionStatus and the public manifest read, so composing the
228
+ // ceiling in changed one expression rather than six surfaces (the same SR-19 move
229
+ // ADR 0194 made when joinabilityMode replaced the raw env flag).
230
+ //
231
+ // A door that is `apply`, `invite_only` or `locked` all answer false here, and
232
+ // deliberately with the SAME false: the gate must not leak which knob closed it
233
+ // (ADR 0247 D3 — stealth's anonymity is a privacy primitive, so a refusal may
234
+ // never be read backwards into a state). It passes NO publish verdict, so the
235
+ // `locked` branch cannot fire from here — this plane holds no trustworthy
236
+ // dark-matter verdict yet (ADR 0247 D4).
237
+ //
238
+ // `deps` is effectiveJoinDoor's own injection seam ({ branding, joinabilityMode,
239
+ // publishable }), forwarded verbatim so a unit test can drive the whole matrix
240
+ // without booting a pack. Production calls it with none.
241
+ function openEnrollmentEnabled(deps = {}) {
242
+ return effectiveJoinDoor(deps) === 'open';
255
243
  }
256
244
 
257
245
  // PURE admission decision shared by both flows so they CANNOT drift (SR-19).
@@ -259,6 +247,12 @@ function openEnrollmentEnabled() {
259
247
  // builder row (or null) from getBuilderByGithubId; `approved` is the result of
260
248
  // hasInvitedAccessRequest. Blocks only when: not already a builder AND open
261
249
  // enrollment is off AND not invite-approved. Exported for direct unit testing.
250
+ //
251
+ // Note what the visibility ceiling does and does not close (task 1003579): it
252
+ // narrows SELF-enrolment only. An invited first-timer, an existing builder and
253
+ // the configured firstAdmin are all admitted to a `private` or `stealth` project
254
+ // exactly as before — an owner who takes their project off the map is closing the
255
+ // front door, not evicting the people they let in.
262
256
  function firstTimeAdmissionBlocked({ existing, approved, login }) {
263
257
  if (existing || openEnrollmentEnabled() || approved) return false;
264
258
  // The configured firstAdmin is always admitted on a fresh instance so the
@@ -403,6 +397,10 @@ async function safeSyncRankRole(builder) {
403
397
  module.exports = {
404
398
  firstAdminLogin,
405
399
  firstTimeAdmissionBlocked,
400
+ // Re-exported, not defined here (task 1003579 moved it to project-door.js with
401
+ // the two knobs it composes against). auth.js, the doorway's api.joinabilityMode
402
+ // and four test files import it by this path, and the read-back it answers —
403
+ // "what did the owner set?" — is still an admission-surface question.
406
404
  joinabilityMode,
407
405
  maybeSeatFirstAdmin,
408
406
  seedKickoffChecklist,
@@ -27,6 +27,15 @@
27
27
  // plus `publishable`, which is DERIVED and never stored (ADR 0182 D1/D4: a project
28
28
  // with no name or no description is "dark matter" — off the map and un-joinable).
29
29
  //
30
+ // THE KNOB READERS LIVE HERE TOO. `joinabilityMode()` moved in from
31
+ // src/bongos/auth-admission.js (task 1003579, ADR 0256). It was born in the
32
+ // admission gate (ADR 0194) because the gate was its only caller; once the gate
33
+ // started reading the COMPOSED door instead of that one knob, leaving the reader
34
+ // on the far side would have made this file require the whole admission gate back
35
+ // to answer one question — the two files pointing at each other. The composer owns
36
+ // its inputs and the gate reads the answer, so the dependency runs one way.
37
+ // auth-admission re-exports the name, so every existing import still resolves.
38
+ //
30
39
  // THE REFUSAL NEVER NAMES THE STATE. `stealth`, `public`+`view`, and an explicit
31
40
  // `invite_only` all answer with the SAME door, so a refusal can never be read
32
41
  // backwards to learn which state a project is in. That is not a nicety: ADR 0182
@@ -42,6 +51,12 @@
42
51
 
43
52
  'use strict';
44
53
 
54
+ // The ONE non-lazy require in this file. logger.js pulls in pino and node
55
+ // built-ins and nothing else — no branding pack, no env, no database — so it does
56
+ // not cost the pure half its "importable with nothing behind it" property, which
57
+ // is why every OTHER require here is still deferred to call time.
58
+ const log = require('./logger').logger.child({ src: 'project-door' });
59
+
45
60
  // The door vocabulary, ORDERED widest → strictest. The order IS the composition
46
61
  // rule: two knobs never negotiate, the higher index simply wins.
47
62
  const JOIN_DOORS = Object.freeze(['open', 'apply', 'invite_only', 'locked']);
@@ -102,13 +117,70 @@ function projectJoinDoor({ visibility, joinGrant, joinability, publishable = nul
102
117
  return strictestDoor(ceiling, JOINABILITY_DOOR[joinability] || 'invite_only');
103
118
  }
104
119
 
120
+ // The legacy OPEN_XENOS_ENROLLMENT escape hatch (ADR 0050). It still opens an
121
+ // *apply* project, so a self-hosted instance that set it keeps its open door
122
+ // across the upgrade — but it never reopens an explicit `invite_only`: the
123
+ // stricter, newer choice wins.
124
+ function legacyOpenEnrollmentFlag() {
125
+ const ic = require('../instance-config'); // eslint-disable-line global-require
126
+ const v = ic.resolveEnv('OPEN_XENOS_ENROLLMENT');
127
+ return v === '1' || v === 'true';
128
+ }
129
+
130
+ // joinabilityMode — the project's stored membership door policy, and ONLY that:
131
+ // 'open' | 'apply' | 'invite_only' (ADR 0194). The one reader for the knob, so
132
+ // the manifest's read-back, the hall's door copy and the refusal pages cannot
133
+ // disagree about what the owner set:
134
+ //
135
+ // open a first-time GitHub sign-in self-enrols at xenos — no request,
136
+ // no invite (what OTB_OPEN_XENOS_ENROLLMENT used to switch on)
137
+ // apply (the default — today's behaviour) a stranger files an access
138
+ // request and an Archon marks it 'invited'; then sign-in admits
139
+ // invite_only the public application endpoint refuses; only an access request
140
+ // an Archon CREATED at 'invited' admits a first-timer
141
+ //
142
+ // IT IS NOT THE DOOR. `projectJoinDoor` below may narrow this further, and since
143
+ // task 1003579 every surface that DECIDES whether someone gets in — the public
144
+ // application endpoint and the sign-in gate both — reads that composed answer
145
+ // instead. Read this one only where the question really is "what did the owner
146
+ // set?"; reading it to admit someone is the drift ADR 0247 was written against.
147
+ //
148
+ // An unreadable pack, or a value this core cannot read, fails CLOSED to
149
+ // `invite_only`: when the door's policy is unknown the widest door is never the
150
+ // fallback (ADR 0192). The `[auth]` log prefix is kept verbatim across the move —
151
+ // operators grep sign-in refusals by it, and the child logger's `src` already
152
+ // says which file emitted the line. `read` is injectable for the unit tests only
153
+ // (the pack is memoized for the process).
154
+ let warnedUnknownJoinability = false;
155
+ function joinabilityMode(read) {
156
+ const readPack = read || require('../branding').branding; // eslint-disable-line global-require
157
+ let v;
158
+ try { v = readPack().project?.joinability; } catch (err) {
159
+ log.error('[auth] joinability: branding pack unreadable — treating the project as invite-only:', err.message);
160
+ return 'invite_only';
161
+ }
162
+ if (v === 'open' || v === 'invite_only') return v;
163
+ if (v === undefined || v === null || v === '' || v === 'apply') {
164
+ return legacyOpenEnrollmentFlag() ? 'open' : 'apply';
165
+ }
166
+ // A value this core cannot read ('invite-only' with a hyphen, a truncated
167
+ // write) is a policy it does not know — never the wider door.
168
+ if (!warnedUnknownJoinability) {
169
+ warnedUnknownJoinability = true;
170
+ log.error(`[auth] joinability: unrecognised value ${JSON.stringify(v)} — treating the project as invite-only`);
171
+ }
172
+ return 'invite_only';
173
+ }
174
+
105
175
  // effectiveJoinDoor — this project's LIVE door: projectJoinDoor() over the policy
106
176
  // this instance actually resolved. The one call a door surface makes; published
107
177
  // through the module doorway as `api.projectJoinDoor()` so a module never
108
178
  // re-derives the composition (the drift `joinabilityMode` exists to prevent).
109
179
  //
110
180
  // The requires are deliberately lazy: this file's pure half must stay importable by
111
- // a test with no branding pack, no env and no database behind it.
181
+ // a test with no branding pack, no env and no database behind it. The joinability
182
+ // reader is now a local function rather than a require of the admission gate, which
183
+ // is what lets the gate read this door without the two files forming a cycle.
112
184
  //
113
185
  // A pack that will not load leaves BOTH visibility and the grant unknown, and
114
186
  // `visibilityCeiling` answers that with `invite_only` — the same fail-closed door
@@ -116,7 +188,7 @@ function projectJoinDoor({ visibility, joinGrant, joinability, publishable = nul
116
188
  // something that reader had already closed.
117
189
  function effectiveJoinDoor(deps = {}) {
118
190
  const readBranding = deps.branding || require('../branding').branding; // eslint-disable-line global-require
119
- const readJoinability = deps.joinabilityMode || require('./auth-admission').joinabilityMode; // eslint-disable-line global-require
191
+ const readJoinability = deps.joinabilityMode || joinabilityMode;
120
192
  let project = {};
121
193
  try { project = readBranding().project || {}; } catch { project = {}; }
122
194
  return projectJoinDoor({
@@ -133,6 +205,7 @@ module.exports = {
133
205
  PROJECT_JOIN_GRANTS,
134
206
  strictestDoor,
135
207
  visibilityCeiling,
208
+ joinabilityMode,
136
209
  projectJoinDoor,
137
210
  effectiveJoinDoor,
138
211
  };
@@ -31,6 +31,10 @@ const crypto = require('node:crypto');
31
31
 
32
32
  const auth = require('../auth');
33
33
  const { reportActivityRollupForShippedTask } = require('../auth-admission.js');
34
+ // task 1003579: the sign-in refusal page names the door the project actually
35
+ // runs, so it cannot offer an application form to a project whose application
36
+ // endpoint refuses. Same composed answer the gate itself reads.
37
+ const { effectiveJoinDoor } = require('../project-door.js');
34
38
  const db = require('../db');
35
39
  const { pairStore } = require('../app-pair');
36
40
  const { publicOrigin, parseCookie, validateOrRespond } = require('./_helpers');
@@ -705,10 +709,19 @@ module.exports = function buildAuthRouter() {
705
709
  // approval brings them back to it — the return cookie says where.
706
710
  const founderReturn = safeReturnTo(parseCookie(req, RETURN_COOKIE)) || '';
707
711
  const onWayToProject = founderReturn.startsWith('/projects');
708
- // An invite-only project (task 1003044) takes no applications: say so,
709
- // and name the one way in — an invite from the admitter — instead of
710
- // sending a stranger to a request form the project refuses.
711
- if (auth.joinabilityMode() === 'invite_only') {
712
+ // A project that takes no applications says so, and names the one way in
713
+ // — an invite from the admitter — instead of sending a stranger to a
714
+ // request form the project will refuse (task 1003044).
715
+ //
716
+ // The test is the COMPOSED door, not the `joinability` knob (task 1003579):
717
+ // `apply` is the only door that accepts an application, so `stealth` and a
718
+ // `view` grant must land on this page too, or the refusal hands a stranger
719
+ // a form the application endpoint 403s. Both closed doors show the SAME
720
+ // sentence, which is the point — a refusal that distinguished them would
721
+ // be the state read-out ADR 0247 D3 forbids. `open` cannot reach here (the
722
+ // gate would have admitted) and `locked` cannot fire at all (no publish
723
+ // verdict is passed, ADR 0247 D4), so this is `invite_only` in practice.
724
+ if (effectiveJoinDoor() !== 'apply') {
712
725
  return res.status(403).type('text/html').send(renderAuthErrorPage(
713
726
  'This project is invite-only',
714
727
  `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.`,
@@ -124,6 +124,13 @@ module.exports = function buildInstanceRouter() {
124
124
  rankTier: RANK_TIER,
125
125
  repo: loadRepoInfo(),
126
126
  authConfigured: authConfigured(),
127
+ // The COMPOSED join door, not the raw `joinability` knob (task 1003579):
128
+ // `admission.policy` says whether a stranger can actually sign themselves
129
+ // in, so a project that is off the map reports 'invite-only' even while its
130
+ // `joinability` reads back 'open' under brand.project. The two fields answer
131
+ // different questions on purpose — one is the owner's setting, the other is
132
+ // what the gate will do — and 'invite-only' already covers every non-open
133
+ // door, so narrowing it names no state (ADR 0247 D3).
127
134
  openEnrollment: openEnrollmentEnabled(),
128
135
  devBoxEnabled: isModuleEnabled('dev-box'),
129
136
  // Project OUT the client secret — the manifest is public; only origin +
package/src/branding.js CHANGED
@@ -220,7 +220,8 @@ function clientBranding(b = branding()) {
220
220
  // the honest place to learn what it RESOLVED from its environment is its own
221
221
  // GET /instance manifest. Enforced on the instance by the platform-visibility
222
222
  // gate (task 1003043, ADR 0192) and the admission machinery (task 1003044,
223
- // ADR 0194 — joinabilityMode() in auth-admission.js).
223
+ // ADR 0194 — joinabilityMode() in project-door.js, which composes it with
224
+ // visibility + joinGrant into the one door every admission surface reads).
224
225
  project: {
225
226
  platformVisibility: b.project?.platformVisibility || 'public',
226
227
  joinability: b.project?.joinability || 'apply',
package/src/module-api.js CHANGED
@@ -49,7 +49,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
49
49
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
50
50
  // the entry to that file. Look for a version's history there, not here.
51
51
  // ---------------------------------------------------------------------------
52
- const CORE_VERSION = '1.19.568'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
52
+ const CORE_VERSION = '1.19.570'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
53
53
 
54
54
  // A namespaced logger so a module's log lines are attributable + consistent.
55
55
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,203 @@
1
+ // tests/admission_ceiling_gate.mjs — the visibility ceiling reaches the SIGN-IN
2
+ // gate, not only the application door (task 1003579, goal 1000046; ADR 0256).
3
+ //
4
+ // THE BUG THIS PINS. ADR 0247 composed the project's join door — visibility ×
5
+ // joinGrant × joinability — and enforced it at the public application endpoint,
6
+ // but left `openEnrollmentEnabled()` reading the `joinability` knob alone. So an
7
+ // owner who took their project off the map (`visibility: private`, or `stealth`,
8
+ // or `joinGrant: view`) while leaving `joinability: open` got a door that queued
9
+ // applications for review and a sign-in that still handed any first-time GitHub
10
+ // account a xenos account. Half of ADR 0182 D6's table — its whole "Self-join"
11
+ // column — was unenforced wherever the owner had explicitly opened enrolment.
12
+ //
13
+ // The boot half sets the knobs the way production does: <PREFIX>_VISIBILITY and
14
+ // <PREFIX>_JOINABILITY in the environment, read once through the memoized
15
+ // branding pack, which is exactly what the provisioning runner writes into
16
+ // web.env before restarting the service (ADR 0190). `private` + `open` is the
17
+ // precise pair ADR 0247's "KNOWN LIMIT" paragraph named.
18
+ //
19
+ // The sweep half drives effectiveJoinDoor's injection seam across every knob
20
+ // combination and asserts the gate is the door EVERYWHERE, not just at the one
21
+ // booted point — the property that makes this a fix rather than a special case.
22
+ //
23
+ // DB-free: every decision under test answers from the pack and the pure
24
+ // composition, before any lookup. Run: node --test tests/admission_ceiling_gate.mjs
25
+
26
+ import assert from 'node:assert/strict';
27
+ import { test, before, after } from 'node:test';
28
+ import http from 'node:http';
29
+ import { createRequire } from 'node:module';
30
+
31
+ const require = createRequire(import.meta.url);
32
+
33
+ // Set BEFORE the first require that could read branding(): the pack is memoized
34
+ // and deep-frozen for the process lifetime, which is why a real instance applies
35
+ // one of these with a restart rather than a re-read.
36
+ process.env.CLOUDBONGOS_VISIBILITY = 'private';
37
+ process.env.CLOUDBONGOS_JOINABILITY = 'open';
38
+ // The bootstrap owner, so the test can prove the ceiling does not gate them out
39
+ // of their own instance. The neutral pack ships firstAdmin: "" (an unclaimed
40
+ // instance has no owner yet), so it is configured through the documented env
41
+ // override rather than a pack edit.
42
+ const FIRST_ADMIN = 'configured-owner';
43
+ process.env.CLOUDBONGOS_FIRST_ADMIN = FIRST_ADMIN;
44
+
45
+ const {
46
+ joinabilityMode, openEnrollmentEnabled, firstTimeAdmissionBlocked, firstAdminLogin,
47
+ } = require('../src/bongos/auth-admission.js');
48
+ const { effectiveJoinDoor, projectJoinDoor } = require('../src/bongos/project-door.js');
49
+ const platformMod = require('../src/platform-server.js');
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // The booted instance: private on the map, enrolment explicitly open.
53
+
54
+ test('the stored knob still reads back exactly what the owner set', () => {
55
+ // The composition may narrow what the door DOES; it may never rewrite what the
56
+ // owner chose, or the manage page's "did my save take effect?" read-back lies
57
+ // (ADR 0194 D1 — the reason the ceiling is composed on the instance instead of
58
+ // folded into the pushed value at the platform).
59
+ assert.equal(joinabilityMode(), 'open');
60
+ assert.equal(firstAdminLogin(), FIRST_ADMIN);
61
+ });
62
+
63
+ test('the application door still queues — private accepts applications', () => {
64
+ // private's ceiling is `apply`, and `open` cannot widen it back. The door and
65
+ // the gate are allowed to differ; what they may not do is disagree about the
66
+ // ceiling, which is the whole bug.
67
+ assert.equal(effectiveJoinDoor(), 'apply');
68
+ });
69
+
70
+ test('THE FIX: the sign-in gate obeys the ceiling — a stranger no longer self-enrols', () => {
71
+ assert.equal(openEnrollmentEnabled(), false, 'private + joinability:open must not self-enrol');
72
+ assert.equal(
73
+ firstTimeAdmissionBlocked({ existing: null, approved: false, login: 'stranger' }), true,
74
+ 'a first-time GitHub account is blocked at the gate, not admitted at xenos',
75
+ );
76
+ });
77
+
78
+ test('the ceiling narrows SELF-enrolment only — invited, existing and the owner still get in', () => {
79
+ // An owner taking their project off the map is closing the front door, not
80
+ // evicting the people they let in. If this ever flips, a `private` project
81
+ // locks out its own members and its own founder.
82
+ assert.equal(firstTimeAdmissionBlocked({ existing: null, approved: true, login: 'invited' }), false);
83
+ assert.equal(firstTimeAdmissionBlocked({ existing: { id: 42 }, approved: false, login: 'member' }), false);
84
+ assert.equal(firstTimeAdmissionBlocked({ existing: null, approved: false, login: FIRST_ADMIN }), false);
85
+ assert.equal(firstTimeAdmissionBlocked({ existing: null, approved: false, login: FIRST_ADMIN.toUpperCase() }), false);
86
+ });
87
+
88
+ test('the gate IS the door — one decision, not two that happen to agree', () => {
89
+ assert.equal(openEnrollmentEnabled(), effectiveJoinDoor() === 'open');
90
+ });
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // The manifest, from the real booted app.
94
+
95
+ let server;
96
+ let port;
97
+ before(async () => {
98
+ server = http.createServer(platformMod.buildPlatformApp());
99
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
100
+ port = server.address().port;
101
+ });
102
+ after(async () => {
103
+ await new Promise((r) => server.close(r));
104
+ });
105
+
106
+ function request(pathname) {
107
+ return new Promise((resolve, reject) => {
108
+ const req = http.request({ host: '127.0.0.1', port, path: pathname, method: 'GET' }, (res) => {
109
+ let data = '';
110
+ res.on('data', (c) => { data += c; });
111
+ res.on('end', () => resolve({ status: res.statusCode, body: data }));
112
+ });
113
+ req.on('error', reject);
114
+ req.end();
115
+ });
116
+ }
117
+
118
+ test('booted: the manifest reports the GATE, while the brand block reports the SETTING', async () => {
119
+ const res = await request('/api/bongos/instance');
120
+ assert.equal(res.status, 200);
121
+ const m = JSON.parse(res.body);
122
+ // The owner's settings, unchanged — a client can still say what the owner chose.
123
+ assert.equal(m.brand.project.visibility, 'private');
124
+ assert.equal(m.brand.project.joinability, 'open');
125
+ // ...and admission.policy now answers the different question it always claimed
126
+ // to: can a stranger sign themselves in? Before this task it said 'open' here
127
+ // and the CLI believed it.
128
+ assert.equal(m.admission.policy, 'invite-only');
129
+ });
130
+
131
+ test('booted: the refusal names no state — every closed door reports the same word', async () => {
132
+ // 'invite-only' is what an `apply` project has always reported, so narrowing a
133
+ // private project into it adds no way to tell private, stealth, a `view` grant
134
+ // and a plain invite-only project apart from the manifest (ADR 0247 D3 —
135
+ // stealth's anonymity is a privacy primitive, not a nicety).
136
+ const m = JSON.parse((await request('/api/bongos/instance')).body);
137
+ const closed = ['apply', 'invite_only'].map((j) => (effectiveJoinDoor({
138
+ branding: () => ({ project: { visibility: 'public', joinGrant: 'full' } }),
139
+ joinabilityMode: () => j,
140
+ }) === 'open' ? 'open' : 'invite-only'));
141
+ assert.deepEqual([...new Set([...closed, m.admission.policy])], ['invite-only']);
142
+ });
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // The sweep: the gate equals the door across the whole knob space.
146
+
147
+ const VISIBILITIES = ['public', 'private', 'stealth', 'unlisted', undefined];
148
+ const GRANTS = ['full', 'apply', 'view', 'FULL', undefined];
149
+ const JOINABILITIES = ['open', 'apply', 'invite_only'];
150
+
151
+ const packOf = (visibility, joinGrant) => () => ({ project: { visibility, joinGrant } });
152
+
153
+ test('every knob combination: openEnrollmentEnabled === (the composed door is open)', () => {
154
+ let checked = 0;
155
+ for (const visibility of VISIBILITIES) {
156
+ for (const joinGrant of GRANTS) {
157
+ for (const joinability of JOINABILITIES) {
158
+ const gate = openEnrollmentEnabled({
159
+ branding: packOf(visibility, joinGrant), joinabilityMode: () => joinability,
160
+ });
161
+ const door = projectJoinDoor({ visibility, joinGrant, joinability });
162
+ assert.equal(
163
+ gate, door === 'open',
164
+ `drift: visibility=${visibility} grant=${joinGrant} joinability=${joinability} → gate=${gate} door=${door}`,
165
+ );
166
+ checked++;
167
+ }
168
+ }
169
+ }
170
+ assert.equal(checked, VISIBILITIES.length * GRANTS.length * JOINABILITIES.length);
171
+ });
172
+
173
+ test('exactly ONE combination self-enrols: on the map, granting membership, open', () => {
174
+ // The inverse statement of the sweep, written out so a future widening of any
175
+ // one knob's table fails here loudly instead of quietly admitting strangers.
176
+ const opened = [];
177
+ for (const visibility of VISIBILITIES) {
178
+ for (const joinGrant of GRANTS) {
179
+ for (const joinability of JOINABILITIES) {
180
+ if (openEnrollmentEnabled({ branding: packOf(visibility, joinGrant), joinabilityMode: () => joinability })) {
181
+ opened.push([visibility, joinGrant, joinability].join('/'));
182
+ }
183
+ }
184
+ }
185
+ }
186
+ assert.deepEqual(opened, ['public/full/open']);
187
+ });
188
+
189
+ test('a pack that will not load fails CLOSED at the gate, exactly as it does at the door', () => {
190
+ const boom = () => { throw new Error('pack unreadable'); };
191
+ assert.equal(openEnrollmentEnabled({ branding: boom, joinabilityMode: () => 'open' }), false);
192
+ assert.equal(openEnrollmentEnabled({ branding: () => ({}), joinabilityMode: () => 'open' }), false,
193
+ 'a pack with no project block is not a public project by default');
194
+ });
195
+
196
+ test('the gate forwards a publish verdict when its caller holds one', () => {
197
+ // Nothing in the sign-in path passes one today — this plane holds no
198
+ // trustworthy dark-matter verdict yet (ADR 0247 D4) — but the seam is the
199
+ // door's own, so wiring the verdict later is one argument, not a redesign.
200
+ const pack = packOf('public', 'full');
201
+ assert.equal(openEnrollmentEnabled({ branding: pack, joinabilityMode: () => 'open', publishable: false }), false);
202
+ assert.equal(openEnrollmentEnabled({ branding: pack, joinabilityMode: () => 'open', publishable: true }), true);
203
+ });
@@ -158,7 +158,13 @@ const src = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
158
158
 
159
159
  test('the web sign-in refusal names the invite-only door instead of sending a stranger to a request form', () => {
160
160
  const auth = src('src/bongos/routes/auth.js');
161
- assert.match(auth, /auth\.joinabilityMode\(\) === 'invite_only'/);
161
+ // task 1003579: the branch tests the COMPOSED door, not the `joinability` knob.
162
+ // `apply` is the only door that accepts an application, so a project closed by
163
+ // its VISIBILITY ceiling (stealth, or a `view` grant, with `joinability` still
164
+ // reading `apply`) lands on this page too instead of being handed a request form
165
+ // the application endpoint refuses. Both closed doors show the same sentence,
166
+ // which is ADR 0247 D3 — a refusal may not be read backwards into a state.
167
+ assert.match(auth, /effectiveJoinDoor\(\) !== 'apply'/);
162
168
  assert.match(auth, /'This project is invite-only'/);
163
169
  assert.match(auth, /doesn’t take applications/);
164
170
  });