@bongos/core 1.19.373 → 1.19.375
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 +98 -48
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +8 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0194-joinability-one-door-decision.md +42 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +159 -4
- package/docs/api-reference.md +7 -4
- package/docs/copy-inventory.md +252 -233
- package/docs/copy-registry.json +503 -301
- package/modules/builder-settings/profile-nudge-prefs.js +125 -0
- package/modules/builder-settings/routes/settings.js +48 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
- package/modules/government/catalog.js +1 -1
- package/modules/hall-ui/public/builders.js +5 -3
- package/modules/hall-ui/public/goal-inbox.js +10 -0
- package/modules/hall-ui/public/hall-render.js +63 -22
- package/modules/hall-ui/public/index.html +4 -0
- package/modules/hall-ui/public/my-projects.js +6 -1
- package/modules/hall-ui/public/profile-nudge.js +122 -0
- package/modules/hall-ui/public/watch.css +8 -0
- package/modules/hall-ui/public/watch.js +56 -3
- package/modules/onboarding/module.json +1 -1
- package/modules/onboarding/routes/access-requests.js +87 -1
- package/modules/platform-identity/routes/my-projects.js +35 -9
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/projects.html +169 -3
- package/scripts/gds/fitness-checks-write-validation.js +1 -0
- package/scripts/gds/login.js +24 -3
- package/scripts/gds/ship-land.js +94 -3
- package/src/bongos/auth-admission.js +50 -5
- package/src/bongos/auth.js +2 -1
- package/src/bongos/routes/auth.js +15 -0
- package/src/branding.js +3 -2
- package/src/module-api.js +22 -1
- package/tests/access_requests_invite.mjs +165 -0
- package/tests/auth_admission_gate.mjs +42 -0
- package/tests/join_relay_outcome.mjs +61 -0
- package/tests/joinability_apply.mjs +72 -0
- package/tests/joinability_invite_only.mjs +192 -0
- package/tests/joinability_open.mjs +89 -0
- package/tests/module_api.mjs +1 -0
- package/tests/profile_nudge_prefs.mjs +97 -0
- package/tests/projects_hub.mjs +76 -2
- package/tests/projects_hub_pre_uat.mjs +4 -2
- package/tests/public_landing_projects.mjs +10 -3
- package/tests/ship_push_diagnosis.mjs +78 -0
- package/tests/sound_player.mjs +38 -0
|
@@ -121,15 +121,59 @@ async function reportActivityRollup({ builderId, githubId }) {
|
|
|
121
121
|
// already exists for their github_id) ALWAYS passes — the gate never locks out
|
|
122
122
|
// someone already enrolled.
|
|
123
123
|
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
124
|
+
// The project's JOINABILITY decides which of those a first-timer gets (task
|
|
125
|
+
// 1003044, goal 1000058; the owner's 2026-08-16 decision). The owner picks it on
|
|
126
|
+
// the hub's manage page; the platform pushes it into this instance's branding
|
|
127
|
+
// pack as project.joinability (ADR 0190) and it is read through branding(),
|
|
128
|
+
// like platformVisibility — memoized for the process, applied by a restart:
|
|
129
|
+
// open a first-time GitHub sign-in self-enrols at xenos — no request,
|
|
130
|
+
// no invite (what OTB_OPEN_XENOS_ENROLLMENT used to switch on)
|
|
131
|
+
// apply (the default — today's behaviour) a stranger files an access
|
|
132
|
+
// request and an Archon marks it 'invited'; then sign-in admits
|
|
133
|
+
// invite_only the public POST /access-requests refuses; only an access
|
|
134
|
+
// request an Archon CREATED at 'invited' admits a first-timer
|
|
135
|
+
// The legacy OPEN_XENOS_ENROLLMENT flag (ADR 0050) still opens an *apply*
|
|
136
|
+
// project, so a self-hosted instance that set it keeps its open door across the
|
|
137
|
+
// upgrade — but it does not override an explicit invite_only: the stricter,
|
|
138
|
+
// newer choice wins. An unreadable pack fails CLOSED (invite_only): when the
|
|
139
|
+
// door's policy is unknown the widest door is never the fallback.
|
|
140
|
+
function legacyOpenEnrollmentFlag() {
|
|
129
141
|
const v = ic.resolveEnv('OPEN_XENOS_ENROLLMENT');
|
|
130
142
|
return v === '1' || v === 'true';
|
|
131
143
|
}
|
|
132
144
|
|
|
145
|
+
// joinabilityMode — the project's effective membership door policy: 'open' |
|
|
146
|
+
// 'apply' | 'invite_only'. The one reader every admission surface uses (both
|
|
147
|
+
// sign-in flows, the public access-request route via the doorway, the manifest,
|
|
148
|
+
// the refusal pages), so none of them can disagree about which door this is.
|
|
149
|
+
// `read` is injectable for the unit tests only (the pack is memoized).
|
|
150
|
+
let warnedUnknownJoinability = false;
|
|
151
|
+
function joinabilityMode(read = branding) {
|
|
152
|
+
let v;
|
|
153
|
+
try { v = read().project?.joinability; } catch (err) {
|
|
154
|
+
log.error('[auth] joinability: branding pack unreadable — treating the project as invite-only:', err.message);
|
|
155
|
+
return 'invite_only';
|
|
156
|
+
}
|
|
157
|
+
if (v === 'open' || v === 'invite_only') return v;
|
|
158
|
+
if (v === undefined || v === null || v === '' || v === 'apply') {
|
|
159
|
+
return legacyOpenEnrollmentFlag() ? 'open' : 'apply';
|
|
160
|
+
}
|
|
161
|
+
// A value this core cannot read ('invite-only' with a hyphen, a truncated
|
|
162
|
+
// write) is a policy it does not know — never the wider door.
|
|
163
|
+
if (!warnedUnknownJoinability) {
|
|
164
|
+
warnedUnknownJoinability = true;
|
|
165
|
+
log.error(`[auth] joinability: unrecognised value ${JSON.stringify(v)} — treating the project as invite-only`);
|
|
166
|
+
}
|
|
167
|
+
return 'invite_only';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// openEnrollmentEnabled — true when a first-time GitHub account self-enrols
|
|
171
|
+
// (joinability 'open'). Kept as the name the four sign-in call sites and the
|
|
172
|
+
// manifest read; the decision itself is joinabilityMode().
|
|
173
|
+
function openEnrollmentEnabled() {
|
|
174
|
+
return joinabilityMode() === 'open';
|
|
175
|
+
}
|
|
176
|
+
|
|
133
177
|
// PURE admission decision shared by both flows so they CANNOT drift (SR-19).
|
|
134
178
|
// Returns true when a first-time account must be blocked. `existing` is the
|
|
135
179
|
// builder row (or null) from getBuilderByGithubId; `approved` is the result of
|
|
@@ -257,6 +301,7 @@ async function safeSyncRankRole(builder) {
|
|
|
257
301
|
module.exports = {
|
|
258
302
|
firstAdminLogin,
|
|
259
303
|
firstTimeAdmissionBlocked,
|
|
304
|
+
joinabilityMode,
|
|
260
305
|
maybeSeatFirstAdmin,
|
|
261
306
|
membershipKind,
|
|
262
307
|
openEnrollmentEnabled,
|
package/src/bongos/auth.js
CHANGED
|
@@ -55,7 +55,7 @@ const ic = require('../instance-config');
|
|
|
55
55
|
const { branding, userAgent } = require('../branding');
|
|
56
56
|
|
|
57
57
|
const { SCOPE, authConfigured, grantedScopeCoversRepos, idpConfigured, idpEnabled, loadIdpConfig, scopeForWebFlow } = require('./auth-config.js');
|
|
58
|
-
const { firstAdminLogin, firstTimeAdmissionBlocked, maybeSeatFirstAdmin, membershipKind, openEnrollmentEnabled, webAdmissionStatus } = require('./auth-admission.js');
|
|
58
|
+
const { firstAdminLogin, firstTimeAdmissionBlocked, joinabilityMode, maybeSeatFirstAdmin, membershipKind, openEnrollmentEnabled, webAdmissionStatus } = require('./auth-admission.js');
|
|
59
59
|
const { buildAuthorizeUrl, completeWebFlow, pollDeviceFlow, startDeviceFlow } = require('./auth-github.js');
|
|
60
60
|
const { buildIdpAuthorizeUrl, completeDeviceExchange, completeIdpFlow, idpAuthorizeUrl, loadHubPublicKey, logoutTokenJtiSeenAndRecord, verifyHubAssertion, verifyHubLogoutToken } = require('./auth-idp.js');
|
|
61
61
|
const { addDiscordGuildMember, buildDiscordAuthorizeUrl, discordAuthorizeUrl, discordBotConfigured, discordConfigured, exchangeDiscordCode, fetchDiscordUser, isValidDiscordUserId, loadDiscordBotToken, loadDiscordCreds, removeDiscordGuildMember } = require('./auth-discord.js');
|
|
@@ -730,6 +730,7 @@ module.exports = {
|
|
|
730
730
|
loadIdpConfig,
|
|
731
731
|
idpConfigured,
|
|
732
732
|
openEnrollmentEnabled,
|
|
733
|
+
joinabilityMode,
|
|
733
734
|
firstTimeAdmissionBlocked,
|
|
734
735
|
webAdmissionStatus,
|
|
735
736
|
firstAdminLogin,
|
|
@@ -677,6 +677,21 @@ module.exports = function buildAuthRouter() {
|
|
|
677
677
|
// approval brings them back to it — the return cookie says where.
|
|
678
678
|
const founderReturn = safeReturnTo(parseCookie(req, RETURN_COOKIE)) || '';
|
|
679
679
|
const onWayToProject = founderReturn.startsWith('/projects');
|
|
680
|
+
// An invite-only project (task 1003044) takes no applications: say so,
|
|
681
|
+
// and name the one way in — an invite from the admitter — instead of
|
|
682
|
+
// sending a stranger to a request form the project refuses.
|
|
683
|
+
if (auth.joinabilityMode() === 'invite_only') {
|
|
684
|
+
return res.status(403).type('text/html').send(renderAuthErrorPage(
|
|
685
|
+
'This project is invite-only',
|
|
686
|
+
`Signing in with GitHub is only for builders the ${admitterLabel()} has invited. This project doesn’t take applications, so there is nothing to request here. If you know the ${admitterLabel()}, ask them for an invite — once you’re invited, this sign-in lets you in.`,
|
|
687
|
+
{
|
|
688
|
+
subtitle: 'Sign-in is for invited builders. This project is invite-only.',
|
|
689
|
+
cta: { href: '/builders', label: 'Go to the hall' },
|
|
690
|
+
note: (onWayToProject ? 'You were on your way to creating a project — once invited, sign in again and you land back in it. ' : '') +
|
|
691
|
+
`Already invited and still blocked? Ask the ${admitterLabel()} to confirm your invite was recorded.`,
|
|
692
|
+
},
|
|
693
|
+
));
|
|
694
|
+
}
|
|
680
695
|
return res.status(403).type('text/html').send(renderAuthErrorPage(
|
|
681
696
|
'This account is not approved yet',
|
|
682
697
|
`Signing in with GitHub is only for builders the ${admitterLabel()} has already approved. If you are new, request access at the builders’ hall — once the ${admitterLabel()} approves you, this sign-in will let you in. If you were just approved, it may take a moment to be marked invited; try again shortly.`,
|
package/src/branding.js
CHANGED
|
@@ -209,8 +209,9 @@ function clientBranding(b = branding()) {
|
|
|
209
209
|
// says so — and the READ-BACK a manage page needs: a settings PATCH on the
|
|
210
210
|
// platform is only a request until this instance restarts with the value, so
|
|
211
211
|
// the honest place to learn what it RESOLVED from its environment is its own
|
|
212
|
-
// GET /instance manifest.
|
|
213
|
-
//
|
|
212
|
+
// GET /instance manifest. Enforced on the instance by the platform-visibility
|
|
213
|
+
// gate (task 1003043, ADR 0192) and the admission machinery (task 1003044,
|
|
214
|
+
// ADR 0194 — joinabilityMode() in auth-admission.js).
|
|
214
215
|
project: {
|
|
215
216
|
platformVisibility: b.project?.platformVisibility || 'public',
|
|
216
217
|
joinability: b.project?.joinability || 'apply',
|
package/src/module-api.js
CHANGED
|
@@ -1176,8 +1176,22 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1176
1176
|
// landed since 1.19.371 with no explicit bump. run 32905166786. (task 1002620)
|
|
1177
1177
|
// 1.19.373 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1178
1178
|
// landed since 1.19.372 with no explicit bump. run 32906118616. (task 1002620)
|
|
1179
|
+
// 1.19.374 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1180
|
+
// landed since 1.19.373 with no explicit bump. run 32906477349. (task 1002620)
|
|
1181
|
+
// (next carrier) — joinabilityMode re-exported (task 1003044, goal 1000058; ADR 0194).
|
|
1182
|
+
// The project's membership door — open / apply / invite_only — is enforced by
|
|
1183
|
+
// the admission machinery: the two sign-in flows self-enrol only on 'open', and
|
|
1184
|
+
// the onboarding module's public POST /access-requests must refuse an invite-only
|
|
1185
|
+
// project with the SAME decision (SR-19: one gate, no drift), so the reader moves
|
|
1186
|
+
// onto the doorway beside branding. Additive — a MINOR by the 1.19.0 precedent,
|
|
1187
|
+
// but a hand-bumped version cannot pass the publish lane's LIVE test
|
|
1188
|
+
// (tests/bump_version.mjs: HEAD's version must be one the lane's own carrier
|
|
1189
|
+
// commits announced), so it rides the next CI patch; the contradiction with
|
|
1190
|
+
// ADR 0161 §2 is filed as an idea from task 1003044.
|
|
1191
|
+
// 1.19.375 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1192
|
+
// landed since 1.19.374 with no explicit bump. run 32909701598. (task 1002620)
|
|
1179
1193
|
// ---------------------------------------------------------------------------
|
|
1180
|
-
const CORE_VERSION = '1.19.
|
|
1194
|
+
const CORE_VERSION = '1.19.375'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1181
1195
|
|
|
1182
1196
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1183
1197
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -1355,6 +1369,13 @@ module.exports = {
|
|
|
1355
1369
|
// hardcodes, instance strings).
|
|
1356
1370
|
branding: branding.branding,
|
|
1357
1371
|
clientBranding: branding.clientBranding,
|
|
1372
|
+
// joinabilityMode() — the project's effective membership door: 'open' |
|
|
1373
|
+
// 'apply' | 'invite_only' (task 1003044; ADR 0194). Read here, never
|
|
1374
|
+
// re-derived from branding().project by a module: the legacy
|
|
1375
|
+
// open-enrolment flag and the fail-closed default live in this one
|
|
1376
|
+
// reader, and the sign-in flows and the public access-request route
|
|
1377
|
+
// must answer with the same door (SR-19).
|
|
1378
|
+
joinabilityMode: auth.joinabilityMode,
|
|
1358
1379
|
// userAgent — the instance's HTTP User-Agent string (branding-derived). The
|
|
1359
1380
|
// carved lifecycle module's github-push.js stamps every GitHub API request with
|
|
1360
1381
|
// it instead of reaching into src/branding.js directly (BV1.R86).
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// tests/access_requests_invite.mjs — the admission routes against a scripted
|
|
2
|
+
// pool (task 1003044). The Archon's invite is the security-critical write on an
|
|
3
|
+
// invite-only project — it is the only thing that admits anyone — so every
|
|
4
|
+
// branch is pinned: already a member, an invite already standing, a pending
|
|
5
|
+
// request resolved in place, a fresh row, a bad username, a DB failure. Plus
|
|
6
|
+
// the public POST's three doors against the same router: apply reaches the
|
|
7
|
+
// queries, invite_only and open refuse before any.
|
|
8
|
+
//
|
|
9
|
+
// The doorway is stubbed in require.cache BEFORE the route loads (the route
|
|
10
|
+
// destructures pool/validators at require time — the web_admission_status
|
|
11
|
+
// pattern): the session gates admit a fixed Archon, the pool replays a script
|
|
12
|
+
// of results and records every query, and joinabilityMode is a switch.
|
|
13
|
+
//
|
|
14
|
+
// Run: node --test tests/access_requests_invite.mjs
|
|
15
|
+
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import { test, before, after } from 'node:test';
|
|
18
|
+
import http from 'node:http';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const express = require('express');
|
|
23
|
+
|
|
24
|
+
const apiPath = require.resolve('../src/module-api.js');
|
|
25
|
+
const realApi = require(apiPath);
|
|
26
|
+
|
|
27
|
+
let calls = [];
|
|
28
|
+
let script = [];
|
|
29
|
+
let mode = 'apply';
|
|
30
|
+
const pool = {
|
|
31
|
+
async query(sql, params) {
|
|
32
|
+
calls.push({ sql, params });
|
|
33
|
+
if (!script.length) throw new Error(`unscripted query: ${sql.trim().slice(0, 40)}`);
|
|
34
|
+
const next = script.shift();
|
|
35
|
+
if (next instanceof Error) throw next;
|
|
36
|
+
return next;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
require.cache[apiPath].exports = {
|
|
40
|
+
...realApi,
|
|
41
|
+
pool,
|
|
42
|
+
joinabilityMode: () => mode,
|
|
43
|
+
requireBuilder: (req, _res, next) => { req.builder = { id: 42, github_login: 'archon' }; next(); },
|
|
44
|
+
requirePermission: () => (_req, _res, next) => next(),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const buildAccessRequestsRouter = require('../modules/onboarding/routes/access-requests.js');
|
|
48
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
49
|
+
|
|
50
|
+
const app = express();
|
|
51
|
+
app.use(express.json());
|
|
52
|
+
app.use(attachFail);
|
|
53
|
+
app.use('/api/gds', buildAccessRequestsRouter());
|
|
54
|
+
|
|
55
|
+
let server, port;
|
|
56
|
+
before(async () => {
|
|
57
|
+
server = http.createServer(app);
|
|
58
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
59
|
+
port = server.address().port;
|
|
60
|
+
});
|
|
61
|
+
after(async () => { await new Promise((r) => server.close(r)); });
|
|
62
|
+
|
|
63
|
+
function post(pathname, body) {
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
const payload = JSON.stringify(body);
|
|
66
|
+
const req = http.request({
|
|
67
|
+
host: '127.0.0.1', port, path: pathname, method: 'POST',
|
|
68
|
+
headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) },
|
|
69
|
+
}, (res) => {
|
|
70
|
+
let data = '';
|
|
71
|
+
res.on('data', (c) => { data += c; });
|
|
72
|
+
res.on('end', () => resolve({ status: res.statusCode, json: data ? JSON.parse(data) : null }));
|
|
73
|
+
});
|
|
74
|
+
req.on('error', reject);
|
|
75
|
+
req.write(payload);
|
|
76
|
+
req.end();
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
const reset = (m, s) => { mode = m; script = s; calls = []; };
|
|
80
|
+
const none = { rows: [] };
|
|
81
|
+
const one = (row = {}) => ({ rows: [row] });
|
|
82
|
+
|
|
83
|
+
// ── POST /access-requests/invite ─────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
test('invite: already a member → 409, nothing written', async () => {
|
|
86
|
+
reset('invite_only', [one()]);
|
|
87
|
+
const r = await post('/api/gds/access-requests/invite', { github_login: 'octocat' });
|
|
88
|
+
assert.equal(r.status, 409);
|
|
89
|
+
assert.equal(r.json.error.code, 'already_member');
|
|
90
|
+
assert.equal(calls.length, 1);
|
|
91
|
+
assert.match(calls[0].sql, /FROM builders/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('invite: an invite already standing → 200 already_invited, nothing written', async () => {
|
|
95
|
+
reset('invite_only', [none, one()]);
|
|
96
|
+
const r = await post('/api/gds/access-requests/invite', { github_login: 'octocat' });
|
|
97
|
+
assert.equal(r.status, 200);
|
|
98
|
+
assert.equal(r.json.already_invited, true);
|
|
99
|
+
assert.equal(calls.length, 2);
|
|
100
|
+
assert.match(calls[1].sql, /status = 'invited'/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('invite: a pending request is resolved in place — the same row the sign-in gate reads', async () => {
|
|
104
|
+
reset('invite_only', [none, none, one({ id: 7, github_login: 'Octocat', status: 'invited' })]);
|
|
105
|
+
const r = await post('/api/gds/access-requests/invite', { github_login: '@Octocat ' });
|
|
106
|
+
assert.equal(r.status, 200);
|
|
107
|
+
assert.equal(r.json.request.status, 'invited');
|
|
108
|
+
assert.equal(calls.length, 3, 'no insert after a successful resolve');
|
|
109
|
+
assert.match(calls[2].sql, /UPDATE access_requests/);
|
|
110
|
+
assert.match(calls[2].sql, /SET status = 'invited'/);
|
|
111
|
+
assert.match(calls[2].sql, /WHERE lower\(github_login\) = lower\(\$2\) AND status = 'pending'/);
|
|
112
|
+
assert.deepEqual(calls[2].params, [42, 'Octocat'], 'resolved by the Archon; the login is trimmed and un-@ed, case kept for lower()');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('invite: no request at all → a fresh invited row, 201', async () => {
|
|
116
|
+
reset('invite_only', [none, none, none, one({ id: 8, github_login: 'octocat', status: 'invited' })]);
|
|
117
|
+
const r = await post('/api/gds/access-requests/invite', { github_login: 'octocat' });
|
|
118
|
+
assert.equal(r.status, 201);
|
|
119
|
+
assert.equal(r.json.request.status, 'invited');
|
|
120
|
+
assert.match(calls[3].sql, /INSERT INTO access_requests/);
|
|
121
|
+
assert.match(calls[3].sql, /'invited'/);
|
|
122
|
+
assert.deepEqual(calls[3].params, ['octocat', 42]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('invite: a bad username → 400 before any query', async () => {
|
|
126
|
+
reset('invite_only', []);
|
|
127
|
+
const r = await post('/api/gds/access-requests/invite', { github_login: 'not a login!' });
|
|
128
|
+
assert.equal(r.status, 400);
|
|
129
|
+
assert.equal(r.json.error.code, 'bad_github_login');
|
|
130
|
+
assert.equal(calls.length, 0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('invite: a DB failure → 500 invite_failed, no internals in the message', async () => {
|
|
134
|
+
reset('invite_only', [new Error('connection refused')]);
|
|
135
|
+
const r = await post('/api/gds/access-requests/invite', { github_login: 'octocat' });
|
|
136
|
+
assert.equal(r.status, 500);
|
|
137
|
+
assert.equal(r.json.error.code, 'invite_failed');
|
|
138
|
+
assert.doesNotMatch(r.json.error.message, /connection refused/);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ── the public POST's three doors ────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
test('public POST on apply: the door is open — the request is filed', async () => {
|
|
144
|
+
reset('apply', [none, none, one({ id: 9, github_login: 'octocat', status: 'pending' })]);
|
|
145
|
+
const r = await post('/api/gds/access-requests', { github_login: 'octocat' });
|
|
146
|
+
assert.equal(r.status, 201);
|
|
147
|
+
assert.equal(r.json.request.status, 'pending');
|
|
148
|
+
assert.equal(calls.length, 3);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('public POST on invite_only: refused before any query — nothing about the applicant is consulted', async () => {
|
|
152
|
+
reset('invite_only', []);
|
|
153
|
+
const r = await post('/api/gds/access-requests', { github_login: 'octocat' });
|
|
154
|
+
assert.equal(r.status, 403);
|
|
155
|
+
assert.equal(r.json.error.code, 'invite_only');
|
|
156
|
+
assert.equal(calls.length, 0);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('public POST on open: "no request needed", before any query', async () => {
|
|
160
|
+
reset('open', []);
|
|
161
|
+
const r = await post('/api/gds/access-requests', { github_login: 'octocat' });
|
|
162
|
+
assert.equal(r.status, 409);
|
|
163
|
+
assert.equal(r.json.error.code, 'open_enrollment');
|
|
164
|
+
assert.equal(calls.length, 0);
|
|
165
|
+
});
|
|
@@ -72,6 +72,48 @@ t("'0' / '' / 'yes' do NOT enable open enrollment", () => {
|
|
|
72
72
|
}
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
+
// task 1003044: the decision is the project's joinability (branding().project,
|
|
76
|
+
// 'apply' on this repo's pack). The legacy flag still opens an APPLY project —
|
|
77
|
+
// a self-hosted instance that set it keeps its open door across the upgrade.
|
|
78
|
+
// The open / invite_only packs are booted in tests/joinability_*.mjs.
|
|
79
|
+
console.log('\njoinabilityMode — the door the flag now opens (task 1003044):');
|
|
80
|
+
|
|
81
|
+
t('joinabilityMode is exported, and the default pack runs as apply', () => {
|
|
82
|
+
assert.equal(typeof auth.joinabilityMode, 'function');
|
|
83
|
+
withOpenEnrollmentEnv(undefined, () => assert.equal(auth.joinabilityMode(), 'apply'));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
t('the legacy flag turns an apply project into an open one — and nothing else does', () => {
|
|
87
|
+
withOpenEnrollmentEnv('true', () => assert.equal(auth.joinabilityMode(), 'open'));
|
|
88
|
+
withOpenEnrollmentEnv('yes', () => assert.equal(auth.joinabilityMode(), 'apply'));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// the reader is injectable (the pack is memoized): the edges the boots can't reach
|
|
92
|
+
t('an absent value is apply, and the legacy flag opens exactly that', () => {
|
|
93
|
+
withOpenEnrollmentEnv(undefined, () => assert.equal(auth.joinabilityMode(() => ({})), 'apply'));
|
|
94
|
+
withOpenEnrollmentEnv('1', () => assert.equal(auth.joinabilityMode(() => ({ project: {} })), 'open'));
|
|
95
|
+
withOpenEnrollmentEnv('1', () => assert.equal(auth.joinabilityMode(() => ({ project: { joinability: 'apply' } })), 'open'));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
t('a value this core cannot read fails CLOSED — invite-only, never the wider door, flag or no flag', () => {
|
|
99
|
+
withOpenEnrollmentEnv('1', () => {
|
|
100
|
+
for (const bad of ['invite-only', 'Open', 'OPEN', 'apply ', 'gated', 42]) {
|
|
101
|
+
assert.equal(auth.joinabilityMode(() => ({ project: { joinability: bad } })), 'invite_only', JSON.stringify(bad));
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
t('an unreadable pack fails CLOSED', () => {
|
|
107
|
+
withOpenEnrollmentEnv('1', () => {
|
|
108
|
+
assert.equal(auth.joinabilityMode(() => { throw new Error('pack unreadable'); }), 'invite_only');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
t('an explicit invite_only is never reopened by the legacy flag; an explicit open needs no flag', () => {
|
|
113
|
+
withOpenEnrollmentEnv('1', () => assert.equal(auth.joinabilityMode(() => ({ project: { joinability: 'invite_only' } })), 'invite_only'));
|
|
114
|
+
withOpenEnrollmentEnv(undefined, () => assert.equal(auth.joinabilityMode(() => ({ project: { joinability: 'open' } })), 'open'));
|
|
115
|
+
});
|
|
116
|
+
|
|
75
117
|
console.log('\nfirstTimeAdmissionBlocked — the shared decision both flows make:');
|
|
76
118
|
|
|
77
119
|
t('first-timer WITHOUT invite, open enrollment OFF → BLOCKED', () => {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// tests/join_relay_outcome.mjs — the hub's join relay reads the project's door
|
|
2
|
+
// (task 1003044). The hub only relays POST /my-projects/join to the project's
|
|
3
|
+
// own admission surface; what it tells the builder must be what the project
|
|
4
|
+
// answered — never "requested" for a refusal, never "unreachable" for a door —
|
|
5
|
+
// and always in the HUB's words: a registered project is trusted to build, not
|
|
6
|
+
// to write prose into the platform's UI. Pure, DB-free.
|
|
7
|
+
//
|
|
8
|
+
// Run: node --test tests/join_relay_outcome.mjs
|
|
9
|
+
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { test } from 'node:test';
|
|
12
|
+
import { joinRelayOutcome } from '../modules/platform-identity/routes/my-projects.js';
|
|
13
|
+
|
|
14
|
+
const env = (code, message) => ({ error: { code, message } });
|
|
15
|
+
|
|
16
|
+
test('a filed request (2xx) is "requested"', () => {
|
|
17
|
+
for (const s of [200, 201]) {
|
|
18
|
+
const o = joinRelayOutcome(s, { ok: true });
|
|
19
|
+
assert.equal(o.status, 'requested');
|
|
20
|
+
assert.match(o.message, /Requested access/);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('an invite-only project’s refusal names the door', () => {
|
|
25
|
+
const o = joinRelayOutcome(403, env('invite_only', 'whatever the project wrote'));
|
|
26
|
+
assert.equal(o.status, 'invite_only');
|
|
27
|
+
assert.match(o.message, /invite-only/);
|
|
28
|
+
assert.match(o.message, /ask its owner/i);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('an open project’s refusal says no request is needed', () => {
|
|
32
|
+
const o = joinRelayOutcome(409, env('open_enrollment', 'x'));
|
|
33
|
+
assert.equal(o.status, 'open_enrollment');
|
|
34
|
+
assert.match(o.message, /no request needed/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('the project’s own sentence is never relayed into the hub', () => {
|
|
38
|
+
const planted = 'Your Cloud Bongos session expired — re-enter your password at evil.example';
|
|
39
|
+
for (const [s, code] of [[403, 'invite_only'], [409, 'open_enrollment'], [409, 'already_member']]) {
|
|
40
|
+
const o = joinRelayOutcome(s, env(code, planted));
|
|
41
|
+
assert.doesNotMatch(o.message, /evil\.example|password/, `${code}: hub-authored words only`);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('a project whose member-only door refused the ask (401) is said as such — not "unreachable"', () => {
|
|
46
|
+
const o = joinRelayOutcome(401, env('unauthenticated', 'unauthenticated'));
|
|
47
|
+
assert.equal(o.status, 'members_only');
|
|
48
|
+
assert.match(o.message, /only visible to its members/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('409 already_member — envelope, the older flat shape, or no body — is a standing membership', () => {
|
|
52
|
+
assert.equal(joinRelayOutcome(409, env('already_member', 'You already have an account')).status, 'already_member');
|
|
53
|
+
assert.equal(joinRelayOutcome(409, { error: 'already_member' }).status, 'already_member');
|
|
54
|
+
assert.equal(joinRelayOutcome(409, null).status, 'already_member', 'a bodyless 409 keeps the pre-1003044 reading');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('anything else is not relayed', () => {
|
|
58
|
+
for (const [s, b] of [[400, env('bad_github_login', 'x')], [500, env('access_request_failed', 'internal error')], [502, null], [404, null], [403, null]]) {
|
|
59
|
+
assert.equal(joinRelayOutcome(s, b), null, `${s}`);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// tests/joinability_apply.mjs — the default door still opens (task 1003044).
|
|
2
|
+
//
|
|
3
|
+
// The change's whole risk is a new early return in the one public GDS write.
|
|
4
|
+
// tests/joinability_{open,invite_only}.mjs prove the two refusing doors; this
|
|
5
|
+
// boots the DEFAULT pack (this repo's, joinability 'apply') and proves the door
|
|
6
|
+
// does NOT refuse — a POST reaches body validation (a 400 for an empty body,
|
|
7
|
+
// never a door code) and the manifest reports apply. DB-free.
|
|
8
|
+
//
|
|
9
|
+
// Run: node --test tests/joinability_apply.mjs
|
|
10
|
+
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import { test, before, after } from 'node:test';
|
|
13
|
+
import http from 'node:http';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
delete process.env.GDS_BRANDING_FILE; // the repo's own pack, not a leftover override
|
|
18
|
+
const { joinabilityMode, openEnrollmentEnabled } = require('../src/bongos/auth-admission.js');
|
|
19
|
+
const platformMod = require('../src/platform-server.js');
|
|
20
|
+
|
|
21
|
+
test('apply: the default pack runs as apply, door closed to self-enrolment', () => {
|
|
22
|
+
const prev = process.env.OTB_OPEN_XENOS_ENROLLMENT;
|
|
23
|
+
delete process.env.OTB_OPEN_XENOS_ENROLLMENT;
|
|
24
|
+
try {
|
|
25
|
+
assert.equal(joinabilityMode(), 'apply');
|
|
26
|
+
assert.equal(openEnrollmentEnabled(), false);
|
|
27
|
+
} finally {
|
|
28
|
+
if (prev !== undefined) process.env.OTB_OPEN_XENOS_ENROLLMENT = prev;
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
let server, port;
|
|
33
|
+
before(async () => {
|
|
34
|
+
server = http.createServer(platformMod.buildPlatformApp());
|
|
35
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
36
|
+
port = server.address().port;
|
|
37
|
+
});
|
|
38
|
+
after(async () => { await new Promise((r) => server.close(r)); });
|
|
39
|
+
|
|
40
|
+
function request(pathname, { method = 'GET', body } = {}) {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const payload = body === undefined ? null : JSON.stringify(body);
|
|
43
|
+
const req = http.request({
|
|
44
|
+
host: '127.0.0.1', port, path: pathname, method,
|
|
45
|
+
headers: {
|
|
46
|
+
host: 'spike.test',
|
|
47
|
+
...(payload ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } : {}),
|
|
48
|
+
},
|
|
49
|
+
}, (res) => {
|
|
50
|
+
let data = '';
|
|
51
|
+
res.on('data', (c) => { data += c; });
|
|
52
|
+
res.on('end', () => resolve({ status: res.statusCode, body: data }));
|
|
53
|
+
});
|
|
54
|
+
req.on('error', reject);
|
|
55
|
+
if (payload) req.write(payload);
|
|
56
|
+
req.end();
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
test('booted apply: the public POST /access-requests is NOT refused by the door — the body is validated', async () => {
|
|
61
|
+
const res = await request('/api/gds/access-requests', { method: 'POST', body: {} });
|
|
62
|
+
assert.equal(res.status, 400, 'an empty body reaches validation, so the door let it through');
|
|
63
|
+
const code = JSON.parse(res.body).error.code;
|
|
64
|
+
assert.notEqual(code, 'invite_only');
|
|
65
|
+
assert.notEqual(code, 'open_enrollment');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('booted apply: the manifest reports apply, and the legacy binary field says invite-gated', async () => {
|
|
69
|
+
const m = JSON.parse((await request('/api/gds/instance')).body);
|
|
70
|
+
assert.equal(m.brand.project.joinability, 'apply');
|
|
71
|
+
assert.equal(m.admission.policy, 'invite-only');
|
|
72
|
+
});
|