@bongos/core 1.19.567 → 1.19.569

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.
@@ -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.567'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
52
+ const CORE_VERSION = '1.19.569'; // 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
  });