@bongos/core 1.19.374 → 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 +72 -42
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +4 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0194-joinability-one-door-decision.md +42 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +85 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +249 -233
- package/docs/copy-registry.json +475 -300
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/government/catalog.js +1 -1
- package/modules/hall-ui/public/builders.js +5 -3
- package/modules/hall-ui/public/hall-render.js +63 -22
- package/modules/hall-ui/public/my-projects.js +6 -1
- 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/login.js +24 -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 +20 -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/projects_hub.mjs +76 -2
- package/tests/projects_hub_pre_uat.mjs +4 -2
- package/tests/public_landing_projects.mjs +10 -3
|
@@ -678,11 +678,19 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
678
678
|
</details>
|
|
679
679
|
</div>
|
|
680
680
|
|
|
681
|
-
<!--
|
|
682
|
-
|
|
681
|
+
<!-- live since task 1003044: how people join — the membership door. The
|
|
682
|
+
visibility section's two reads, key for key: the choice shown is
|
|
683
|
+
the platform's REQUEST (its settings row); what the project actually
|
|
684
|
+
RUNS is read back from its own public manifest (brand.project.
|
|
685
|
+
joinability) — never from the save's reply, which is a receipt. The
|
|
686
|
+
fold is a SIBLING of the volatile slot so no re-render can drop it. -->
|
|
683
687
|
<div class="card" id="mJoinability">
|
|
684
688
|
<h2 style="font-family:var(--font-display);font-size:19px;margin-bottom:12px;">How people join</h2>
|
|
685
|
-
<div
|
|
689
|
+
<div id="mJoin"><p class="finePrint">Reading the project’s settings…</p></div>
|
|
690
|
+
<details class="finePrint" id="mJoinWhat">
|
|
691
|
+
<summary>What each choice changes</summary>
|
|
692
|
+
<p><b>Anyone can join</b> — whoever signs in with GitHub becomes a member at once, at the newcomer rank. <b>Apply to join</b> — people ask from the project’s hall, you approve each one from the hall’s Watch page, and then they sign in. <b>Invite only</b> — no one can apply: the project’s hall and its sign-in say so, and you invite people by GitHub username from the hall’s Watch page; only an invited person’s sign-in gets in — and people who asked before you switched stay in your queue, to approve or dismiss. Whichever you pick, people who are already members stay members. Changing it restarts the project to pick it up — the line under the choice says what the project is running right now.</p>
|
|
693
|
+
</details>
|
|
686
694
|
</div>
|
|
687
695
|
|
|
688
696
|
<!-- live since task 1003042: the teardown control, for a project in ANY
|
|
@@ -4379,6 +4387,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
4379
4387
|
/* same contract: the visibility section's in-flight line reads open_intent
|
|
4380
4388
|
only, so a queued settings push shows up on the tick without a reload */
|
|
4381
4389
|
renderVisState(inst);
|
|
4390
|
+
renderJoinState(inst);
|
|
4382
4391
|
/* the page names its project — in the heading and in the tab */
|
|
4383
4392
|
$('mTitle').textContent = inst.slug || 'Manage';
|
|
4384
4393
|
document.title = (inst.slug ? inst.slug + ' — ' : '') + BASE_TITLE;
|
|
@@ -4484,6 +4493,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
4484
4493
|
/* the settings row + the project's own manifest — the same cadence as
|
|
4485
4494
|
the GitHub read: entry + tab return, never the in-flight tick */
|
|
4486
4495
|
if (!keepRepo) loadVisibility(inst);
|
|
4496
|
+
if (!keepRepo) loadJoinability(inst);
|
|
4487
4497
|
})
|
|
4488
4498
|
.catch(function (err) {
|
|
4489
4499
|
var m = String(err && err.message);
|
|
@@ -4805,6 +4815,162 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
4805
4815
|
if (t && t.hasAttribute('data-vis-save')) doVisSave();
|
|
4806
4816
|
});
|
|
4807
4817
|
|
|
4818
|
+
/* ── how people join (task 1003044): the joinability toggle — the visibility
|
|
4819
|
+
section's shape, key for key: the CHOICE from the platform's settings row,
|
|
4820
|
+
what the project RUNS from its own manifest (brand.project.joinability),
|
|
4821
|
+
the save's push.message shown verbatim and never painted as the live
|
|
4822
|
+
state. visPushOpen() is shared: one settings push carries every key. ── */
|
|
4823
|
+
|
|
4824
|
+
var JOIN_OPTIONS = {
|
|
4825
|
+
open: { label: 'Anyone can join', blurb: 'Whoever signs in with GitHub becomes a member right away, at the newcomer rank.' },
|
|
4826
|
+
apply: { label: 'Apply to join', blurb: 'People ask to join from the project’s hall; you approve each one, then they sign in.' },
|
|
4827
|
+
invite_only: { label: 'Invite only', blurb: 'No one can apply. You invite people by GitHub username from the hall, and only an invited person can sign in.' }
|
|
4828
|
+
};
|
|
4829
|
+
var mJoinSettings = null; /* the platform row's last answer: { value, options } */
|
|
4830
|
+
var mJoinRunsHtml = ''; /* what the project's manifest last said it runs */
|
|
4831
|
+
var mJoinPushSeen = false; /* a settings push was open — re-read the manifest once it settles */
|
|
4832
|
+
|
|
4833
|
+
function joinMsg(html) { $('mJoin').innerHTML = html; }
|
|
4834
|
+
|
|
4835
|
+
function renderJoin() {
|
|
4836
|
+
var s = mJoinSettings;
|
|
4837
|
+
if (!s) return;
|
|
4838
|
+
var opts = (s.options || []).filter(function (v) { return !!JOIN_OPTIONS[v]; });
|
|
4839
|
+
joinMsg('<div role="radiogroup" aria-label="How people join">' + opts.map(function (v) {
|
|
4840
|
+
var o = JOIN_OPTIONS[v];
|
|
4841
|
+
return '<label class="visOpt"><input type="radio" name="mJoinPick" value="' + esc(v) + '"' +
|
|
4842
|
+
(v === s.value ? ' checked' : '') + '><span><b>' + esc(o.label) + '</b>' + esc(o.blurb) + '</span></label>';
|
|
4843
|
+
}).join('') + '</div>' +
|
|
4844
|
+
'<div class="actions"><button type="button" class="btn small" data-join-save>Save</button></div>' +
|
|
4845
|
+
'<div class="projMsg" id="mJoinMsg" role="status" aria-live="polite"></div>' +
|
|
4846
|
+
'<p class="finePrint" id="mJoinRuns">' + mJoinRunsHtml + '</p>');
|
|
4847
|
+
renderJoinState(manageInst);
|
|
4848
|
+
}
|
|
4849
|
+
|
|
4850
|
+
function joinRuns(html) {
|
|
4851
|
+
mJoinRunsHtml = html;
|
|
4852
|
+
var el = $('mJoinRuns');
|
|
4853
|
+
if (el) el.innerHTML = html;
|
|
4854
|
+
}
|
|
4855
|
+
|
|
4856
|
+
/* fetch-free, from open_intent only (renderManage calls it on every repaint) */
|
|
4857
|
+
function renderJoinState(inst) {
|
|
4858
|
+
if (!inst || !$('mJoinRuns')) return;
|
|
4859
|
+
if (visPushOpen(inst)) {
|
|
4860
|
+
mJoinPushSeen = true;
|
|
4861
|
+
joinRuns(inst.open_intent.state === 'running'
|
|
4862
|
+
? 'Restarting the project to apply a settings change…'
|
|
4863
|
+
: 'A settings change is queued — the project restarts to pick it up. Until then it keeps its current setting.');
|
|
4864
|
+
return;
|
|
4865
|
+
}
|
|
4866
|
+
if (mJoinPushSeen) { mJoinPushSeen = false; loadJoinRuns(inst); }
|
|
4867
|
+
}
|
|
4868
|
+
|
|
4869
|
+
/* what the project RUNS: its own public manifest, read cross-origin */
|
|
4870
|
+
function loadJoinRuns(inst) {
|
|
4871
|
+
if (!inst || inst.status !== 'active' || !inst.domain) { joinRuns(''); return; }
|
|
4872
|
+
var asked = manageId;
|
|
4873
|
+
joinRuns('Checking what the project is running…');
|
|
4874
|
+
var ctl = typeof AbortController === 'function' ? new AbortController() : null;
|
|
4875
|
+
var timer = ctl ? setTimeout(function () { ctl.abort(); }, 8000) : null;
|
|
4876
|
+
fetch('https://' + inst.domain + '/api/gds/instance', { mode: 'cors', signal: ctl ? ctl.signal : undefined })
|
|
4877
|
+
.then(function (res) {
|
|
4878
|
+
if (!res.ok) throw new Error('http:' + res.status);
|
|
4879
|
+
return res.json();
|
|
4880
|
+
})
|
|
4881
|
+
.then(function (m) {
|
|
4882
|
+
if (asked !== manageId) return;
|
|
4883
|
+
var p = m && m.brand && m.brand.project;
|
|
4884
|
+
var v = p && p.joinability;
|
|
4885
|
+
if (!p || typeof p !== 'object') {
|
|
4886
|
+
joinRuns('This project’s server doesn’t report this setting yet — it runs an older platform version, so the setting can’t take effect until the project is updated.');
|
|
4887
|
+
return;
|
|
4888
|
+
}
|
|
4889
|
+
var o = JOIN_OPTIONS[v];
|
|
4890
|
+
if (!o) { joinRuns('This project’s server reports a setting this page doesn’t recognise (' + esc(String(v)) + ').'); return; }
|
|
4891
|
+
var want = mJoinSettings && mJoinSettings.value;
|
|
4892
|
+
joinRuns('Right now the project runs as <b>' + esc(o.label) + '</b>' +
|
|
4893
|
+
(want && want !== v && JOIN_OPTIONS[want] ? ' — the saved choice (' + esc(JOIN_OPTIONS[want].label) + ') hasn’t taken effect there yet.' : '.'));
|
|
4894
|
+
})
|
|
4895
|
+
.catch(function (err) {
|
|
4896
|
+
if (asked !== manageId) return;
|
|
4897
|
+
var http = /^http:(\d+)$/.exec(String(err && err.message));
|
|
4898
|
+
joinRuns(http
|
|
4899
|
+
? 'The project answered but didn’t share what it’s running (it replied ' + esc(http[1]) + ').'
|
|
4900
|
+
: 'Couldn’t reach the project to check what it’s running right now.');
|
|
4901
|
+
})
|
|
4902
|
+
.then(function () { if (timer) clearTimeout(timer); });
|
|
4903
|
+
}
|
|
4904
|
+
|
|
4905
|
+
function loadJoinability(inst) {
|
|
4906
|
+
mJoinSettings = null; mJoinRunsHtml = ''; mJoinPushSeen = false;
|
|
4907
|
+
joinMsg('<p class="finePrint">Reading the project’s settings…</p>');
|
|
4908
|
+
var asked = manageId; /* a stale answer must never describe a newer project */
|
|
4909
|
+
fetch(API + '/provisioning/instances/' + encodeURIComponent(manageId) + '/settings', { credentials: 'same-origin' })
|
|
4910
|
+
.then(function (res) {
|
|
4911
|
+
if (asked !== manageId) throw new Error('stale');
|
|
4912
|
+
return jsonOrEmpty(res).then(function (data) {
|
|
4913
|
+
if (res.ok) {
|
|
4914
|
+
mJoinSettings = {
|
|
4915
|
+
value: (data.settings || {}).joinability,
|
|
4916
|
+
options: (data.options || {}).joinability || Object.keys(JOIN_OPTIONS)
|
|
4917
|
+
};
|
|
4918
|
+
renderJoin();
|
|
4919
|
+
if (!visPushOpen(inst)) loadJoinRuns(inst);
|
|
4920
|
+
return;
|
|
4921
|
+
}
|
|
4922
|
+
joinMsg('<p class="finePrint">' + (res.status === 401 ? signInAgain() : 'Couldn’t read this project’s settings right now — refresh in a moment.') + '</p>');
|
|
4923
|
+
});
|
|
4924
|
+
})
|
|
4925
|
+
.catch(function (err) {
|
|
4926
|
+
if (String(err && err.message) === 'stale') return;
|
|
4927
|
+
joinMsg('<p class="finePrint">Couldn’t read this project’s settings right now — refresh in a moment.</p>');
|
|
4928
|
+
});
|
|
4929
|
+
}
|
|
4930
|
+
|
|
4931
|
+
/* the save sends the CHOICE and shows the platform's receipt verbatim */
|
|
4932
|
+
function doJoinSave() {
|
|
4933
|
+
if (!manageInst || !mJoinSettings) return;
|
|
4934
|
+
var picked = document.querySelector('#mJoin input[name="mJoinPick"]:checked');
|
|
4935
|
+
if (!picked) return;
|
|
4936
|
+
var value = picked.value;
|
|
4937
|
+
var box = $('mJoinMsg');
|
|
4938
|
+
var btn = document.querySelector('#mJoin [data-join-save]');
|
|
4939
|
+
if (btn) btn.disabled = true;
|
|
4940
|
+
box.className = 'projMsg'; box.textContent = 'Saving…';
|
|
4941
|
+
fetch(API + '/provisioning/instances/' + encodeURIComponent(manageId) + '/settings', {
|
|
4942
|
+
method: 'PATCH', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
|
|
4943
|
+
body: JSON.stringify({ joinability: value })
|
|
4944
|
+
}).then(function (res) {
|
|
4945
|
+
return jsonOrEmpty(res).then(function (data) {
|
|
4946
|
+
if (res.ok) {
|
|
4947
|
+
/* the row now holds the choice — the radio follows it; the "runs"
|
|
4948
|
+
line does NOT, until the project's own manifest says so */
|
|
4949
|
+
mJoinSettings.value = (data.saved || {}).joinability || value;
|
|
4950
|
+
if (data.push && data.push.queued) mJoinPushSeen = true;
|
|
4951
|
+
box.className = 'projMsg ok';
|
|
4952
|
+
box.textContent = (data.push && data.push.message) || 'Saved.';
|
|
4953
|
+
return;
|
|
4954
|
+
}
|
|
4955
|
+
var e = apiError(data, res.status);
|
|
4956
|
+
if (res.status === 401) { box.className = 'projMsg err'; box.innerHTML = signInAgain(); return; }
|
|
4957
|
+
box.className = e.code === 'intent_conflict' ? 'projMsg' : 'projMsg err';
|
|
4958
|
+
box.textContent = e.message || 'The change could not be saved — try again in a moment.';
|
|
4959
|
+
});
|
|
4960
|
+
}).catch(function () {
|
|
4961
|
+
box.className = 'projMsg err';
|
|
4962
|
+
box.textContent = 'Couldn’t reach the platform — check your connection and try again.';
|
|
4963
|
+
}).then(function () {
|
|
4964
|
+
if (btn) btn.disabled = false;
|
|
4965
|
+
loadManage(true, true);
|
|
4966
|
+
});
|
|
4967
|
+
}
|
|
4968
|
+
|
|
4969
|
+
$('mJoin').addEventListener('click', function (ev) {
|
|
4970
|
+
var t = ev.target.closest && ev.target.closest('button');
|
|
4971
|
+
if (t && t.hasAttribute('data-join-save')) doJoinSave();
|
|
4972
|
+
});
|
|
4973
|
+
|
|
4808
4974
|
/* ── delete the project (task 1003042): the manage page's teardown control.
|
|
4809
4975
|
It renders from status + open_intent only — no second request — so the
|
|
4810
4976
|
minute tick can repaint it and a queued teardown appears without a reload.
|
package/scripts/gds/login.js
CHANGED
|
@@ -189,11 +189,20 @@ async function main() {
|
|
|
189
189
|
}
|
|
190
190
|
const instanceName = man.data.name || base;
|
|
191
191
|
const admission = (man.data.admission && man.data.admission.policy) || 'invite-only';
|
|
192
|
+
// The project's door (task 1003044): brand.project.joinability names which of
|
|
193
|
+
// open / apply / invite_only it runs; an older core reports none, and then the
|
|
194
|
+
// binary admission.policy above is all there is to say.
|
|
195
|
+
// admission.policy 'open' wins over a pushed 'apply': a self-hosted instance on
|
|
196
|
+
// the legacy open-enrolment flag reports the pack's default while its door is open.
|
|
197
|
+
const joinability = admission === 'open' ? 'open'
|
|
198
|
+
: (man.data.brand && man.data.brand.project && man.data.brand.project.joinability) || null;
|
|
199
|
+
const DOOR_WORD = { open: '', apply: ' (apply to join)', invite_only: ' (invite-only)' };
|
|
200
|
+
const doorWord = joinability in DOOR_WORD ? DOOR_WORD[joinability] : (admission === 'invite-only' ? ' (invite-only)' : '');
|
|
192
201
|
// Federation (ADR 0141 §1): a hosted instance advertises auth.idp = { origin,
|
|
193
202
|
// client_id } — sign-in then runs through the Cloud Bongos hub, not a local
|
|
194
203
|
// GitHub app. null → the direct-GitHub path, unchanged.
|
|
195
204
|
const idp = (man.data.auth && man.data.auth.idp) || null;
|
|
196
|
-
log(` Signing in to ${instanceName}${
|
|
205
|
+
log(` Signing in to ${instanceName}${doorWord}${idp ? ' — via Cloud Bongos platform sign-in' : ''}`);
|
|
197
206
|
|
|
198
207
|
// 2. Device flow.
|
|
199
208
|
let flow = await runDeviceFlow(base, { log, idp });
|
|
@@ -202,6 +211,12 @@ async function main() {
|
|
|
202
211
|
// then finish sign-in with a fresh device flow (the first code is spent).
|
|
203
212
|
if (flow.state === 'not_approved') {
|
|
204
213
|
log('');
|
|
214
|
+
// An invite-only project takes no applications — say so instead of
|
|
215
|
+
// collecting a username for a request the project would refuse.
|
|
216
|
+
if (joinability === 'invite_only') {
|
|
217
|
+
console.error(`✖ ${instanceName} is invite-only — it doesn’t take applications. Ask its owner for an invite, then re-run \`bongos login ${base}\`.`);
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
205
220
|
log(` You're not admitted to ${instanceName} yet.`);
|
|
206
221
|
const ghLogin = (await prompt(' Your GitHub username: ')).replace(/^@/, '');
|
|
207
222
|
if (!ghLogin) {
|
|
@@ -212,12 +227,18 @@ async function main() {
|
|
|
212
227
|
method: 'POST',
|
|
213
228
|
body: JSON.stringify({ github_login: ghLogin, note: 'via bongos login' }),
|
|
214
229
|
});
|
|
215
|
-
|
|
230
|
+
// The refusal envelope is { error: { code, message } } (ADR 0116); an older
|
|
231
|
+
// instance answered a flat { error: 'code' } — read both.
|
|
232
|
+
const reqErr = (reqRes.data && reqRes.data.error) || null;
|
|
233
|
+
const reqCode = reqErr && (reqErr.code || reqErr);
|
|
234
|
+
const reqMessage = reqErr && reqErr.message;
|
|
235
|
+
if (reqRes.status === 409 && reqCode === 'already_member') {
|
|
216
236
|
log(' You already have an account — retrying sign-in…');
|
|
217
237
|
} else if (reqRes.ok || reqRes.status === 200 || reqRes.status === 201) {
|
|
218
238
|
log(` ✓ Requested access to ${instanceName}. An Archon approves you with a 👍 in Discord (#member-approvals).`);
|
|
219
239
|
} else {
|
|
220
|
-
|
|
240
|
+
// the project's own sentence when it gave one (an invite-only or open door)
|
|
241
|
+
console.error(reqMessage ? `✖ ${reqMessage}` : `✖ could not file an access request (HTTP ${reqRes.status}).`);
|
|
221
242
|
process.exit(1);
|
|
222
243
|
}
|
|
223
244
|
log(' Waiting for approval (safe to leave running)…');
|
|
@@ -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
|
@@ -1178,8 +1178,20 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1178
1178
|
// landed since 1.19.372 with no explicit bump. run 32906118616. (task 1002620)
|
|
1179
1179
|
// 1.19.374 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1180
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)
|
|
1181
1193
|
// ---------------------------------------------------------------------------
|
|
1182
|
-
const CORE_VERSION = '1.19.
|
|
1194
|
+
const CORE_VERSION = '1.19.375'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1183
1195
|
|
|
1184
1196
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1185
1197
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -1357,6 +1369,13 @@ module.exports = {
|
|
|
1357
1369
|
// hardcodes, instance strings).
|
|
1358
1370
|
branding: branding.branding,
|
|
1359
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,
|
|
1360
1379
|
// userAgent — the instance's HTTP User-Agent string (branding-derived). The
|
|
1361
1380
|
// carved lifecycle module's github-push.js stamps every GitHub API request with
|
|
1362
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', () => {
|