@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.
- package/.bongos-core.json +42 -27
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0255-a-public-lists-ordering-is-part-of-its-payload.md +169 -0
- 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 +2 -0
- package/docs/api/openapi.json +7 -3
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/platform-identity/platform-identity.js +2 -1
- package/modules/platform-identity/projects-feed.js +126 -20
- package/modules/platform-identity/routes/projects.js +29 -3
- 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/visible_projects_feed.mjs +184 -13
|
@@ -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.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
|
-
|
|
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
|
});
|