@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.
- package/.bongos-core.json +43 -28
- package/docs/adr/0256-the-visibility-ceiling-is-the-sign-in-gate-too-and-the-reader-moves-to-the-composer.md +204 -0
- package/docs/adr/README.md +1 -0
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/copy-desk/routes/copy-desk.js +4 -0
- package/modules/lifecycle/cascade.js +11 -0
- package/modules/lifecycle/db-tasks.js +37 -7
- package/modules/lifecycle/kickoff-seed.js +5 -0
- package/modules/lifecycle/routes/task-write-routes.js +12 -0
- package/modules/onboarding/newcomer-restock.js +4 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +5 -3
- package/src/bongos/auth-admission.js +48 -50
- package/src/bongos/project-door.js +75 -2
- package/src/bongos/routes/auth.js +17 -4
- package/src/bongos/routes/instance.js +7 -0
- package/src/branding.js +2 -1
- package/src/module-api.js +1 -1
- package/tests/admission_ceiling_gate.mjs +203 -0
- package/tests/joinability_invite_only.mjs +7 -1
- package/tests/reward_gate.mjs +4 -4
- package/tests/task_goal_required.mjs +223 -0
|
@@ -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.
|
|
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.
|
|
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.
|
|
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",
|
package/scripts/gds/fitness.js
CHANGED
|
@@ -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
|
|
538
|
-
// nothing and buys the import-direction guarantee on the file that
|
|
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
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
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
|
+
// strangers — half 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
|
-
//
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
|
|
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 ||
|
|
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
|
-
//
|
|
709
|
-
//
|
|
710
|
-
//
|
|
711
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
});
|