@bongos/core 1.19.368 → 1.19.370
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 +45 -20
- package/docs/adr/0192-platform-visibility-member-door.md +50 -0
- package/docs/adr/0193-pause-task-scoped-box-slices.md +60 -0
- package/docs/adr/README.md +2 -0
- package/docs/copy-inventory.md +158 -157
- package/docs/copy-registry.json +206 -197
- package/modules/dev-box/routes/box.js +22 -5
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/projects.html +201 -3
- package/src/bongos/auth.js +17 -5
- package/src/bongos/platform-visibility-gate.js +251 -0
- package/src/bongos/serve-internal.js +10 -0
- package/src/module-api.js +5 -1
- package/tests/box_task_scope_pause.mjs +66 -0
- package/tests/platform_visibility_gate.mjs +280 -0
- package/tests/projects_hub.mjs +93 -0
- package/tests/projects_hub_pre_uat.mjs +4 -2
|
@@ -55,6 +55,17 @@ const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
|
|
|
55
55
|
const PUBLIC_ORIGIN = process.env.GDS_PUBLIC_ORIGIN
|
|
56
56
|
|| (() => { try { return api.branding().domains.publicOrigin; } catch { return 'http://localhost:3000'; } })();
|
|
57
57
|
|
|
58
|
+
// Task-scoping PAUSE (task 1003285 / ADR 0193 — owner decision, 2026-08-25).
|
|
59
|
+
// While paused, the claim-derived slice is never resolved: scopeKeys stays
|
|
60
|
+
// undefined and decideSourceAccess takes its documented rank fallback — full
|
|
61
|
+
// repo for Metic+, the starter surface below. Rank floor, ADR 0016 live-rank
|
|
62
|
+
// checks, and the ADR 0055 read-only credential are all unchanged; only the
|
|
63
|
+
// module BREADTH stops narrowing. Paused by default; set BOX_TASK_SCOPE=on
|
|
64
|
+
// to re-enable the ADR 0148 claim-driven slice.
|
|
65
|
+
function taskScopingPaused() {
|
|
66
|
+
return String(process.env.BOX_TASK_SCOPE || '').trim().toLowerCase() !== 'on';
|
|
67
|
+
}
|
|
68
|
+
|
|
58
69
|
// The instance's world name + dev-box apex + SSH zone come from the branding pack
|
|
59
70
|
// (ADR 0062 §3), never a hardcoded host brand — a non-OTB instance uses its own
|
|
60
71
|
// configured identity/domains. Passed into the PURE box-onboard renderers.
|
|
@@ -444,10 +455,12 @@ module.exports = function buildBoxRouter() {
|
|
|
444
455
|
// - undefined → decideSourceAccess falls back to the RANK scope (the
|
|
445
456
|
// pre-ADR-0148 behavior, so a port-less instance is unchanged).
|
|
446
457
|
// NEVER fail the source fetch on a scope-read hiccup — degrade to rank scope.
|
|
458
|
+
// While task-scoping is paused (taskScopingPaused above), skip the claim
|
|
459
|
+
// resolution entirely: undefined scopeKeys IS the rank-fallback signal.
|
|
447
460
|
const lifecycle = api.resolveOptional('lifecycle');
|
|
448
461
|
let scopeKeys;
|
|
449
462
|
let hasActiveClaims;
|
|
450
|
-
if (lifecycle && typeof lifecycle.moduleScopeForActiveClaims === 'function') {
|
|
463
|
+
if (!taskScopingPaused() && lifecycle && typeof lifecycle.moduleScopeForActiveClaims === 'function') {
|
|
451
464
|
try {
|
|
452
465
|
scopeKeys = await lifecycle.moduleScopeForActiveClaims(req.builder.id);
|
|
453
466
|
} catch (scopeErr) {
|
|
@@ -560,11 +573,13 @@ module.exports = function buildBoxRouter() {
|
|
|
560
573
|
const status = builder ? builder.status : 'inactive';
|
|
561
574
|
|
|
562
575
|
// Resolve the claim-driven module scope exactly as source-access does
|
|
563
|
-
// (optional lifecycle port; degrade to rank scope on any hiccup
|
|
576
|
+
// (optional lifecycle port; degrade to rank scope on any hiccup; skipped
|
|
577
|
+
// entirely while task-scoping is paused, so this read-only companion can
|
|
578
|
+
// never report a slice the real fetch would no longer grant).
|
|
564
579
|
const lifecycle = api.resolveOptional('lifecycle');
|
|
565
580
|
let scopeKeys;
|
|
566
581
|
let hasActiveClaims;
|
|
567
|
-
if (lifecycle && typeof lifecycle.moduleScopeForActiveClaims === 'function') {
|
|
582
|
+
if (!taskScopingPaused() && lifecycle && typeof lifecycle.moduleScopeForActiveClaims === 'function') {
|
|
568
583
|
try {
|
|
569
584
|
scopeKeys = await lifecycle.moduleScopeForActiveClaims(req.builder.id);
|
|
570
585
|
} catch (scopeErr) {
|
|
@@ -593,8 +608,9 @@ module.exports = function buildBoxRouter() {
|
|
|
593
608
|
}
|
|
594
609
|
const meta = boxAccess.scopeMetadata({ decision, scopeKeys });
|
|
595
610
|
// task_scope true only when a claim actually drove the breadth (scopeKeys is
|
|
596
|
-
// an array). false → the rank fallback (no active claim / no lifecycle port
|
|
597
|
-
|
|
611
|
+
// an array). false → the rank fallback (no active claim / no lifecycle port /
|
|
612
|
+
// task-scoping paused — the paused flag says which, so the app can explain).
|
|
613
|
+
return res.json({ ...meta, task_scope: Array.isArray(scopeKeys), task_scoping_paused: taskScopingPaused() });
|
|
598
614
|
} catch (err) {
|
|
599
615
|
log.error('[gds] GET /box/scope', err);
|
|
600
616
|
res.fail('box_scope_failed', { status: 500, message: 'internal error' });
|
|
@@ -1088,3 +1104,4 @@ module.exports = function buildBoxRouter() {
|
|
|
1088
1104
|
// (tests/box_terminal_ssrf.mjs) without a server, DB, or network.
|
|
1089
1105
|
module.exports.isSafeTerminalUrl = isSafeTerminalUrl;
|
|
1090
1106
|
module.exports.isTunnelReadyStatus = isTunnelReadyStatus;
|
|
1107
|
+
module.exports.taskScopingPaused = taskScopingPaused;
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.370",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.370",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.370",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -56,6 +56,11 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
|
|
|
56
56
|
.backRow{margin:14px 0;font-weight:700;}
|
|
57
57
|
#view-manage .empty{text-align:left;}
|
|
58
58
|
#view-manage .empty p{margin-bottom:0;}
|
|
59
|
+
#view-manage .visOpt{display:flex;gap:10px;align-items:flex-start;padding:10px 12px;border:1px solid var(--rule);border-radius:10px;margin-bottom:8px;cursor:pointer;}
|
|
60
|
+
#view-manage .visOpt:has(input:checked){border-color:var(--accent);}
|
|
61
|
+
#view-manage .visOpt input{margin-top:4px;flex:none;}
|
|
62
|
+
#view-manage .visOpt b{display:block;font-size:15px;}
|
|
63
|
+
#view-manage .visOpt span{display:block;font-size:13px;color:var(--ink-soft);line-height:1.45;}
|
|
59
64
|
|
|
60
65
|
/* The bongo pair on the New-project view. Component-scoped custom properties,
|
|
61
66
|
never :root (C1). The drum head is SKIN — using --bg-card would turn it black
|
|
@@ -657,13 +662,24 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
657
662
|
</details>
|
|
658
663
|
</div>
|
|
659
664
|
|
|
660
|
-
<!--
|
|
661
|
-
|
|
665
|
+
<!-- live since task 1003043: the platform-visibility toggle. The choice
|
|
666
|
+
shown is the platform's REQUEST (its settings row); what the project
|
|
667
|
+
actually RUNS is read back from the project's own public manifest
|
|
668
|
+
(brand.project) — never from the save's reply, which is a receipt:
|
|
669
|
+
a change reaches a live project only by the restart the runner
|
|
670
|
+
performs (ADR 0190). The fold is a SIBLING of the volatile slot (the
|
|
671
|
+
#mRepoVisWhy shape) so no re-render can drop it. -->
|
|
662
672
|
<div class="card" id="mVisibility">
|
|
663
673
|
<h2 style="font-family:var(--font-display);font-size:19px;margin-bottom:12px;">Who can see it</h2>
|
|
664
|
-
<div
|
|
674
|
+
<div id="mVis"><p class="finePrint">Reading the project’s settings…</p></div>
|
|
675
|
+
<details class="finePrint" id="mVisWhat">
|
|
676
|
+
<summary>What “only signed-in members” changes</summary>
|
|
677
|
+
<p>Visitors who aren’t signed in meet a sign-in door instead of the project’s pages — its front page, its status page, and its hall. The door itself stays open, so members can always get in, and members see everything either way. It is separate from the repository’s visibility above: the code on GitHub is its own setting. Changing it restarts the project to pick it up — the line under the choice says what the project is running right now.</p>
|
|
678
|
+
</details>
|
|
665
679
|
</div>
|
|
666
680
|
|
|
681
|
+
<!-- the setting the sibling task (1003044) fills in — placed BELOW the
|
|
682
|
+
sections that work, so a founder meets controls before promises -->
|
|
667
683
|
<div class="card" id="mJoinability">
|
|
668
684
|
<h2 style="font-family:var(--font-display);font-size:19px;margin-bottom:12px;">How people join</h2>
|
|
669
685
|
<div class="empty"><p>Choosing how new people join — walk right in, apply first, or invite only — isn’t wired up here yet. For now, people apply to join and you invite them.</p></div>
|
|
@@ -4360,6 +4376,9 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
4360
4376
|
it: the delete section reads only status + open_intent, both already in
|
|
4361
4377
|
hand, and a queued teardown must show up without a reload */
|
|
4362
4378
|
renderDelete(inst);
|
|
4379
|
+
/* same contract: the visibility section's in-flight line reads open_intent
|
|
4380
|
+
only, so a queued settings push shows up on the tick without a reload */
|
|
4381
|
+
renderVisState(inst);
|
|
4363
4382
|
/* the page names its project — in the heading and in the tab */
|
|
4364
4383
|
$('mTitle').textContent = inst.slug || 'Manage';
|
|
4365
4384
|
document.title = (inst.slug ? inst.slug + ' — ' : '') + BASE_TITLE;
|
|
@@ -4462,6 +4481,9 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
4462
4481
|
/* the SECOND, GitHub-backed read — fired only here (entry + tab
|
|
4463
4482
|
return), never from renderManage, which the fetch-free tick calls */
|
|
4464
4483
|
if (!keepRepo) loadRepoVisibility(inst);
|
|
4484
|
+
/* the settings row + the project's own manifest — the same cadence as
|
|
4485
|
+
the GitHub read: entry + tab return, never the in-flight tick */
|
|
4486
|
+
if (!keepRepo) loadVisibility(inst);
|
|
4465
4487
|
})
|
|
4466
4488
|
.catch(function (err) {
|
|
4467
4489
|
var m = String(err && err.message);
|
|
@@ -4607,6 +4629,182 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
4607
4629
|
if (t.hasAttribute('data-repo-public-no')) { if (manageRepo) renderRepoVis(manageRepo); }
|
|
4608
4630
|
});
|
|
4609
4631
|
|
|
4632
|
+
/* ── who can see it (task 1003043): the platform-visibility toggle. Two
|
|
4633
|
+
reads, kept apart on purpose: the CHOICE comes from the platform's own
|
|
4634
|
+
settings row (what the owner asked for), and the line under it reads what
|
|
4635
|
+
the project's server actually RUNS from its public manifest (brand.project,
|
|
4636
|
+
ADR 0190 §9). The save's reply is a receipt — its push.message is shown
|
|
4637
|
+
verbatim, and its `saved` is never painted as the live state, because a
|
|
4638
|
+
change reaches a running project only by the restart the runner performs.
|
|
4639
|
+
What the project runs is only ever what its own manifest answered. ── */
|
|
4640
|
+
|
|
4641
|
+
var VIS_OPTIONS = {
|
|
4642
|
+
public: { label: 'Everyone', blurb: 'Anyone can open this project’s pages. Signing in is only needed to take part.' },
|
|
4643
|
+
gated: { label: 'Only signed-in members', blurb: 'Visitors meet a sign-in door first. The project’s pages open only to people signed in to it.' }
|
|
4644
|
+
};
|
|
4645
|
+
var mVisSettings = null; /* the platform row's last answer: { value, options } */
|
|
4646
|
+
var mVisRunsHtml = ''; /* what the project's manifest last said it runs */
|
|
4647
|
+
var mVisPushSeen = false; /* a settings push was open — re-read the manifest once it settles */
|
|
4648
|
+
|
|
4649
|
+
function visMsg(html) { $('mVis').innerHTML = html; }
|
|
4650
|
+
|
|
4651
|
+
function renderVis() {
|
|
4652
|
+
var s = mVisSettings;
|
|
4653
|
+
if (!s) return;
|
|
4654
|
+
var opts = (s.options || []).filter(function (v) { return !!VIS_OPTIONS[v]; });
|
|
4655
|
+
visMsg('<div role="radiogroup" aria-label="Who can see it">' + opts.map(function (v) {
|
|
4656
|
+
var o = VIS_OPTIONS[v];
|
|
4657
|
+
return '<label class="visOpt"><input type="radio" name="mVisPick" value="' + esc(v) + '"' +
|
|
4658
|
+
(v === s.value ? ' checked' : '') + '><span><b>' + esc(o.label) + '</b>' + esc(o.blurb) + '</span></label>';
|
|
4659
|
+
}).join('') + '</div>' +
|
|
4660
|
+
'<div class="actions"><button type="button" class="btn small" data-vis-save>Save</button></div>' +
|
|
4661
|
+
'<div class="projMsg" id="mVisMsg" role="status" aria-live="polite"></div>' +
|
|
4662
|
+
'<p class="finePrint" id="mVisRuns">' + mVisRunsHtml + '</p>');
|
|
4663
|
+
renderVisState(manageInst);
|
|
4664
|
+
}
|
|
4665
|
+
|
|
4666
|
+
function visRuns(html) {
|
|
4667
|
+
mVisRunsHtml = html;
|
|
4668
|
+
var el = $('mVisRuns');
|
|
4669
|
+
if (el) el.innerHTML = html;
|
|
4670
|
+
}
|
|
4671
|
+
|
|
4672
|
+
/* fetch-free, from open_intent only (renderManage calls it on every repaint):
|
|
4673
|
+
while a push is open the line says so; the moment it settles, the manifest
|
|
4674
|
+
is asked again — the only source that can say the value took */
|
|
4675
|
+
function visPushOpen(inst) {
|
|
4676
|
+
return !!(inst && inst.open_intent && inst.open_intent.action === 'settings-apply');
|
|
4677
|
+
}
|
|
4678
|
+
|
|
4679
|
+
function renderVisState(inst) {
|
|
4680
|
+
if (!inst || !$('mVisRuns')) return;
|
|
4681
|
+
if (visPushOpen(inst)) {
|
|
4682
|
+
mVisPushSeen = true;
|
|
4683
|
+
visRuns(inst.open_intent.state === 'running'
|
|
4684
|
+
? 'Restarting the project to apply a settings change…'
|
|
4685
|
+
: 'A settings change is queued — the project restarts to pick it up. Until then it keeps its current setting.');
|
|
4686
|
+
return;
|
|
4687
|
+
}
|
|
4688
|
+
if (mVisPushSeen) { mVisPushSeen = false; loadVisRuns(inst); }
|
|
4689
|
+
}
|
|
4690
|
+
|
|
4691
|
+
/* what the project RUNS: its own public manifest, read cross-origin (the
|
|
4692
|
+
same GET /instance the platform's runner reads after a restart) */
|
|
4693
|
+
function loadVisRuns(inst) {
|
|
4694
|
+
if (!inst || inst.status !== 'active' || !inst.domain) { visRuns(''); return; }
|
|
4695
|
+
var asked = manageId;
|
|
4696
|
+
visRuns('Checking what the project is running…');
|
|
4697
|
+
var ctl = typeof AbortController === 'function' ? new AbortController() : null;
|
|
4698
|
+
var timer = ctl ? setTimeout(function () { ctl.abort(); }, 8000) : null;
|
|
4699
|
+
fetch('https://' + inst.domain + '/api/gds/instance', { mode: 'cors', signal: ctl ? ctl.signal : undefined })
|
|
4700
|
+
.then(function (res) {
|
|
4701
|
+
/* a project that ANSWERED but refused (its own pre-launch gate, a proxy
|
|
4702
|
+
error page) is not an older core — say what happened, not a guess */
|
|
4703
|
+
if (!res.ok) throw new Error('http:' + res.status);
|
|
4704
|
+
return res.json();
|
|
4705
|
+
})
|
|
4706
|
+
.then(function (m) {
|
|
4707
|
+
if (asked !== manageId) return;
|
|
4708
|
+
var p = m && m.brand && m.brand.project;
|
|
4709
|
+
var v = p && p.platformVisibility;
|
|
4710
|
+
if (!p || typeof p !== 'object') {
|
|
4711
|
+
visRuns('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.');
|
|
4712
|
+
return;
|
|
4713
|
+
}
|
|
4714
|
+
var o = VIS_OPTIONS[v];
|
|
4715
|
+
if (!o) { visRuns('This project’s server reports a setting this page doesn’t recognise (' + esc(String(v)) + ').'); return; }
|
|
4716
|
+
var want = mVisSettings && mVisSettings.value;
|
|
4717
|
+
visRuns('Right now the project runs as <b>' + esc(o.label) + '</b>' +
|
|
4718
|
+
(want && want !== v && VIS_OPTIONS[want] ? ' — the saved choice (' + esc(VIS_OPTIONS[want].label) + ') hasn’t taken effect there yet.' : '.'));
|
|
4719
|
+
})
|
|
4720
|
+
.catch(function (err) {
|
|
4721
|
+
if (asked !== manageId) return;
|
|
4722
|
+
var http = /^http:(\d+)$/.exec(String(err && err.message));
|
|
4723
|
+
visRuns(http
|
|
4724
|
+
? 'The project answered but didn’t share what it’s running (it replied ' + esc(http[1]) + ').'
|
|
4725
|
+
: 'Couldn’t reach the project to check what it’s running right now.');
|
|
4726
|
+
})
|
|
4727
|
+
.then(function () { if (timer) clearTimeout(timer); });
|
|
4728
|
+
}
|
|
4729
|
+
|
|
4730
|
+
function loadVisibility(inst) {
|
|
4731
|
+
mVisSettings = null; mVisRunsHtml = ''; mVisPushSeen = false;
|
|
4732
|
+
visMsg('<p class="finePrint">Reading the project’s settings…</p>');
|
|
4733
|
+
var asked = manageId; /* a stale answer must never describe a newer project */
|
|
4734
|
+
fetch(API + '/provisioning/instances/' + encodeURIComponent(manageId) + '/settings', { credentials: 'same-origin' })
|
|
4735
|
+
.then(function (res) {
|
|
4736
|
+
if (asked !== manageId) throw new Error('stale');
|
|
4737
|
+
return jsonOrEmpty(res).then(function (data) {
|
|
4738
|
+
if (res.ok) {
|
|
4739
|
+
mVisSettings = {
|
|
4740
|
+
value: (data.settings || {}).platform_visibility,
|
|
4741
|
+
options: (data.options || {}).platform_visibility || Object.keys(VIS_OPTIONS)
|
|
4742
|
+
};
|
|
4743
|
+
renderVis();
|
|
4744
|
+
/* while a push is open the line is the intent's — the manifest is
|
|
4745
|
+
asked the moment it settles (renderVisState), not now */
|
|
4746
|
+
if (!visPushOpen(inst)) loadVisRuns(inst);
|
|
4747
|
+
return;
|
|
4748
|
+
}
|
|
4749
|
+
visMsg('<p class="finePrint">' + (res.status === 401 ? signInAgain() : 'Couldn’t read this project’s settings right now — refresh in a moment.') + '</p>');
|
|
4750
|
+
});
|
|
4751
|
+
})
|
|
4752
|
+
.catch(function (err) {
|
|
4753
|
+
if (String(err && err.message) === 'stale') return;
|
|
4754
|
+
visMsg('<p class="finePrint">Couldn’t read this project’s settings right now — refresh in a moment.</p>');
|
|
4755
|
+
});
|
|
4756
|
+
}
|
|
4757
|
+
|
|
4758
|
+
/* the save sends the CHOICE and shows the platform's receipt verbatim: the
|
|
4759
|
+
same value re-sent is the server's call (no change, or the re-kick of a
|
|
4760
|
+
push that failed) — this page never decides that for it */
|
|
4761
|
+
function doVisSave() {
|
|
4762
|
+
if (!manageInst || !mVisSettings) return;
|
|
4763
|
+
var picked = document.querySelector('#mVis input[name="mVisPick"]:checked');
|
|
4764
|
+
if (!picked) return;
|
|
4765
|
+
var value = picked.value;
|
|
4766
|
+
var box = $('mVisMsg');
|
|
4767
|
+
var btn = document.querySelector('#mVis [data-vis-save]');
|
|
4768
|
+
if (btn) btn.disabled = true;
|
|
4769
|
+
box.className = 'projMsg'; box.textContent = 'Saving…';
|
|
4770
|
+
fetch(API + '/provisioning/instances/' + encodeURIComponent(manageId) + '/settings', {
|
|
4771
|
+
method: 'PATCH', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
|
|
4772
|
+
body: JSON.stringify({ platform_visibility: value })
|
|
4773
|
+
}).then(function (res) {
|
|
4774
|
+
return jsonOrEmpty(res).then(function (data) {
|
|
4775
|
+
if (res.ok) {
|
|
4776
|
+
/* the row now holds the choice — the radio follows it; the "runs"
|
|
4777
|
+
line does NOT, until the project's own manifest says so */
|
|
4778
|
+
mVisSettings.value = (data.saved || {}).platform_visibility || value;
|
|
4779
|
+
/* a queued push may open AND settle before the re-read below lands —
|
|
4780
|
+
arm the re-read here so a fast runner cannot leave a stale line */
|
|
4781
|
+
if (data.push && data.push.queued) mVisPushSeen = true;
|
|
4782
|
+
box.className = 'projMsg ok';
|
|
4783
|
+
box.textContent = (data.push && data.push.message) || 'Saved.';
|
|
4784
|
+
return;
|
|
4785
|
+
}
|
|
4786
|
+
var e = apiError(data, res.status);
|
|
4787
|
+
if (res.status === 401) { box.className = 'projMsg err'; box.innerHTML = signInAgain(); return; }
|
|
4788
|
+
/* 409 intent_conflict: the change IS saved (recorded) — the server's
|
|
4789
|
+
sentence says so and names the run holding the slot; show it as is */
|
|
4790
|
+
box.className = e.code === 'intent_conflict' ? 'projMsg' : 'projMsg err';
|
|
4791
|
+
box.textContent = e.message || 'The change could not be saved — try again in a moment.';
|
|
4792
|
+
});
|
|
4793
|
+
}).catch(function () {
|
|
4794
|
+
box.className = 'projMsg err';
|
|
4795
|
+
box.textContent = 'Couldn’t reach the platform — check your connection and try again.';
|
|
4796
|
+
}).then(function () {
|
|
4797
|
+
if (btn) btn.disabled = false;
|
|
4798
|
+
/* the strip + this section's in-flight line read open_intent — re-read it */
|
|
4799
|
+
loadManage(true, true);
|
|
4800
|
+
});
|
|
4801
|
+
}
|
|
4802
|
+
|
|
4803
|
+
$('mVis').addEventListener('click', function (ev) {
|
|
4804
|
+
var t = ev.target.closest && ev.target.closest('button');
|
|
4805
|
+
if (t && t.hasAttribute('data-vis-save')) doVisSave();
|
|
4806
|
+
});
|
|
4807
|
+
|
|
4610
4808
|
/* ── delete the project (task 1003042): the manage page's teardown control.
|
|
4611
4809
|
It renders from status + open_intent only — no second request — so the
|
|
4612
4810
|
minute tick can repaint it and a queued teardown appears without a reload.
|
package/src/bongos/auth.js
CHANGED
|
@@ -87,12 +87,24 @@ function extractTokens(req) {
|
|
|
87
87
|
|
|
88
88
|
// Resolve the request to a live session by trying every candidate token until
|
|
89
89
|
// one maps to a non-revoked, unexpired session. Returns null if none do.
|
|
90
|
+
// Resolved ONCE per request (task 1003043): the platform-visibility gate asks
|
|
91
|
+
// ahead of every surface, and the page/API gates ask again behind it — each
|
|
92
|
+
// lookup is a SELECT plus a last-seen UPDATE, so a member's page load with its
|
|
93
|
+
// assets must not pay that twice per request. Keyed weakly on the req object;
|
|
94
|
+
// a rejected lookup is shared too, so every gate on the request fails closed
|
|
95
|
+
// the same way.
|
|
96
|
+
const RESOLVED_SESSIONS = new WeakMap();
|
|
90
97
|
async function resolveSession(req) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
98
|
+
if (RESOLVED_SESSIONS.has(req)) return RESOLVED_SESSIONS.get(req);
|
|
99
|
+
const resolved = (async () => {
|
|
100
|
+
for (const token of extractTokens(req)) {
|
|
101
|
+
const session = await lookupSession(token);
|
|
102
|
+
if (session) return session;
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
})();
|
|
106
|
+
RESOLVED_SESSIONS.set(req, resolved);
|
|
107
|
+
return resolved;
|
|
96
108
|
}
|
|
97
109
|
|
|
98
110
|
// ── SESSION-SCOPES — capability attached to the TOKEN (BV1.R99, ADR 0151 §3) ──
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// src/bongos/platform-visibility-gate.js — the project's member door (task 1003043).
|
|
2
|
+
//
|
|
3
|
+
// A project's owner chooses on the hub who can see it: everyone (today's
|
|
4
|
+
// behaviour, the default) or only people signed in to it. That choice reaches
|
|
5
|
+
// the instance as `project.platformVisibility` in its branding pack — pushed
|
|
6
|
+
// into web.env by the provisioning runner and read once per process (ADR 0190).
|
|
7
|
+
// This gate is where the instance ENFORCES it: when the value is 'gated', a
|
|
8
|
+
// visitor with no live session is refused on every surface a stranger could
|
|
9
|
+
// otherwise read — the landing page, the hub pages, the status dashboard, the
|
|
10
|
+
// hall shell, and the public read API — and shown a door that says so, with the
|
|
11
|
+
// sign-in behind it one click away. A member (any live, non-deactivated session
|
|
12
|
+
// on this instance) passes and sees everything they would have seen anyway.
|
|
13
|
+
//
|
|
14
|
+
// - Enforcement is per REQUEST and server-side: nothing here relies on the
|
|
15
|
+
// client hiding anything, and a curl with no cookie gets the same answer a
|
|
16
|
+
// browser does.
|
|
17
|
+
// - It grants nothing. Everything behind it still runs its own rank checks
|
|
18
|
+
// against the database (ADR 0016); the gate only decides whether a stranger
|
|
19
|
+
// may SEE the instance at all, and "stranger" is decided by the same
|
|
20
|
+
// session lookup requireBuilder uses — never cached, never a second store.
|
|
21
|
+
// - The setting is owned by the PLATFORM row and pushed; the instance reads
|
|
22
|
+
// it through branding(), memoized for the process lifetime, which is why a
|
|
23
|
+
// change is applied by a restart and why nothing here re-reads a file.
|
|
24
|
+
// - What a member is shown must not be cached for a stranger: a passed
|
|
25
|
+
// response leaves marked private, whatever the route behind it said.
|
|
26
|
+
//
|
|
27
|
+
// It is NOT the pre-launch gate (stealth-gate.js): that one is a shared
|
|
28
|
+
// password for an unannounced instance, inert unless STEALTH_PASSWORD is set,
|
|
29
|
+
// and it mounts first. This one is a per-project policy with accounts behind it.
|
|
30
|
+
// Both can be on at once; each refuses on its own terms.
|
|
31
|
+
|
|
32
|
+
'use strict';
|
|
33
|
+
|
|
34
|
+
const { branding } = require('../branding');
|
|
35
|
+
const { resolveSession } = require('./auth');
|
|
36
|
+
const { ALL_API_PREFIXES } = require('./api-prefix');
|
|
37
|
+
|
|
38
|
+
const log = require('./logger').logger.child({ src: 'platform-visibility-gate' });
|
|
39
|
+
|
|
40
|
+
const GATED = 'gated';
|
|
41
|
+
|
|
42
|
+
// Every API prefix this instance answers on — versioned, current and legacy
|
|
43
|
+
// (ADR 0117): a CLI pinned to /api/bongos/v1 must find the door where the
|
|
44
|
+
// unversioned one is.
|
|
45
|
+
const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
46
|
+
const API = `(?:${ALL_API_PREFIXES.map(escRe).join('|')})`;
|
|
47
|
+
|
|
48
|
+
// Paths that must answer even to a stranger while the project is gated, each for
|
|
49
|
+
// its own reason:
|
|
50
|
+
// <api>/auth/* the door itself — the web sign-in round trip, the CLI
|
|
51
|
+
// device flow, sign-out. A gated door that cannot be
|
|
52
|
+
// opened locks the members out too.
|
|
53
|
+
// <api>/instance the public manifest (routes/instance.js). A stranger
|
|
54
|
+
// reads it to learn HOW to sign in; the platform's runner
|
|
55
|
+
// reads it after a settings restart to learn whether the
|
|
56
|
+
// value was consumed (provision-units.js settingsConsumed)
|
|
57
|
+
// — including the very push that turns this gate on. It
|
|
58
|
+
// carries the policy itself, public by nature (ADR 0190 §9).
|
|
59
|
+
// <api>/healthz, /healthz, /version, /metrics
|
|
60
|
+
// the probes: the deploy's, the runner's, the uptime
|
|
61
|
+
// poller's, the control plane's liveness sweep; Prometheus
|
|
62
|
+
// is access-gated on its own terms.
|
|
63
|
+
// <api>/provisioning/tls-check, …/health
|
|
64
|
+
// Caddy's on-demand-TLS `ask` and the provisioning probe —
|
|
65
|
+
// server-to-server, cookie-less, and on a control plane a
|
|
66
|
+
// refused ask means no project ever gets a certificate.
|
|
67
|
+
// /downloads/* the installer script and the Dev Box updater — a
|
|
68
|
+
// cookie-less `curl … | sh`, and release redirects carry
|
|
69
|
+
// no project data.
|
|
70
|
+
// /__stealth the pre-launch gate's own submit, when both gates are on.
|
|
71
|
+
const EXEMPT = [
|
|
72
|
+
new RegExp(`^${API}/auth(/|$)`),
|
|
73
|
+
new RegExp(`^${API}/instance$`),
|
|
74
|
+
new RegExp(`^${API}/healthz$`),
|
|
75
|
+
new RegExp(`^${API}/provisioning/(tls-check|health)$`),
|
|
76
|
+
/^\/healthz$/,
|
|
77
|
+
/^\/version$/,
|
|
78
|
+
/^\/metrics$/,
|
|
79
|
+
/^\/__stealth$/,
|
|
80
|
+
/^\/downloads\//,
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
// The policy, read through the memoized pack. A pack that cannot be read at all
|
|
84
|
+
// FAILS CLOSED — the one place a broken read must not quietly un-gate a
|
|
85
|
+
// members-only project (every other branding consumer on the instance is broken
|
|
86
|
+
// by then too, so nothing is lost by refusing). `read` is injectable for tests.
|
|
87
|
+
function mode(read = branding) {
|
|
88
|
+
try { return read().project?.platformVisibility || 'public'; } catch (err) {
|
|
89
|
+
log.error({ err: err && err.message }, 'branding pack unreadable — treating the project as gated');
|
|
90
|
+
return GATED;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isGated() { return mode() === GATED; }
|
|
95
|
+
|
|
96
|
+
// Express routes case-insensitively and tolerates one trailing slash, so the
|
|
97
|
+
// exempt test must see the path the way the router will — otherwise a probe
|
|
98
|
+
// spelled `/HEALTHZ` or `/api/gds/instance/` is refused while the router would
|
|
99
|
+
// have served it. Mismatches still fail CLOSED; this only keeps the list honest.
|
|
100
|
+
function normalizePath(p) {
|
|
101
|
+
let s = String(p || '').toLowerCase();
|
|
102
|
+
if (s.length > 1 && s.endsWith('/')) s = s.slice(0, -1);
|
|
103
|
+
return s;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isExempt(path) {
|
|
107
|
+
const p = normalizePath(path);
|
|
108
|
+
return EXEMPT.some((re) => re.test(p));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isApiPath(path) { return /^\/api\//i.test(String(path || '')); }
|
|
112
|
+
|
|
113
|
+
function productName() {
|
|
114
|
+
try { return branding().identity?.productName || 'This project'; } catch { return 'This project'; }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The sign-in start with a `?return=` back to the page that was refused. The
|
|
118
|
+
// return is RELATIVE (the pre-launch gate's relativeReturn shape): auth.js's
|
|
119
|
+
// safeReturnTo accepts a relative path unconditionally, while an absolute one is
|
|
120
|
+
// only honoured over https on the cookie apex — so a member on a co-located
|
|
121
|
+
// subdomain, a dev port, or plain http would otherwise be dropped at the default
|
|
122
|
+
// page. Only a GET/HEAD path is safe to come back to; anything else returns to '/'.
|
|
123
|
+
function signInHref(req) {
|
|
124
|
+
const base = '/api/gds/auth/web/start';
|
|
125
|
+
const back = (req.method === 'GET' || req.method === 'HEAD') ? relativeReturn(req.originalUrl) : null;
|
|
126
|
+
return back ? `${base}?return=${encodeURIComponent(back)}` : base;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// One leading slash, no '//' (protocol-relative), no backslash, no control
|
|
130
|
+
// bytes — the stealth gate's rule; anything else returns to '/'.
|
|
131
|
+
function relativeReturn(raw) {
|
|
132
|
+
if (typeof raw !== 'string' || !raw.length || raw.length > 512) return null;
|
|
133
|
+
if (!raw.startsWith('/') || raw.startsWith('//') || raw.includes('\\')) return null;
|
|
134
|
+
if (/[\x00-\x1f\x7f]/.test(raw)) return null;
|
|
135
|
+
return raw === '/' ? null : raw;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function escAttr(s) {
|
|
139
|
+
return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// The door. Self-contained on purpose (the pre-launch gate's shape): it is
|
|
143
|
+
// served to someone who may not see the project, so it pulls no stylesheet, no
|
|
144
|
+
// branding bundle, no script — nothing that would show the design to whoever
|
|
145
|
+
// knocks. It names the project and says who it is for, and the one control on
|
|
146
|
+
// it is the way in.
|
|
147
|
+
function doorHtml({ productName: name, signIn }) {
|
|
148
|
+
const safe = String(name || 'This project').replace(/[<>&"]/g, '');
|
|
149
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
150
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
151
|
+
<meta name="robots" content="noindex,nofollow">
|
|
152
|
+
<title>${safe}</title><style>
|
|
153
|
+
:root{color-scheme:light dark}
|
|
154
|
+
*{box-sizing:border-box}
|
|
155
|
+
body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;
|
|
156
|
+
font:500 15px/1.5 ui-rounded,"Quicksand",system-ui,-apple-system,"Segoe UI",sans-serif;
|
|
157
|
+
background:#f9d9a8;color:#2b170e}
|
|
158
|
+
@media(prefers-color-scheme:dark){body{background:#150c06;color:#f4e6cd}}
|
|
159
|
+
.box{width:min(380px,100%);text-align:center}
|
|
160
|
+
.dots{display:flex;gap:10px;justify-content:center;margin-bottom:22px}
|
|
161
|
+
.dots i{display:block;border-radius:50%;background:#ce4806}
|
|
162
|
+
.dots i:first-child{width:38px;height:38px}
|
|
163
|
+
.dots i:last-child{width:28px;height:28px;background:#855b3d;align-self:flex-end}
|
|
164
|
+
h1{font-size:20px;font-weight:700;margin:0 0 6px}
|
|
165
|
+
p{margin:0 0 20px;opacity:.72;font-size:14px}
|
|
166
|
+
a.btn{display:inline-block;padding:11px 22px;border-radius:999px;background:#ce4806;color:#fff;
|
|
167
|
+
font:700 14px/1 inherit;text-decoration:none}
|
|
168
|
+
a.btn:focus{outline:2px solid #ce4806;outline-offset:2px}
|
|
169
|
+
</style></head><body><div class="box">
|
|
170
|
+
<div class="dots"><i></i><i></i></div>
|
|
171
|
+
<h1>${safe}</h1>
|
|
172
|
+
<p>This project’s pages are open to its members. Sign in to continue.</p>
|
|
173
|
+
<a class="btn" href="${escAttr(signIn)}">Sign in</a>
|
|
174
|
+
</div></body></html>`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function refuse(req, res, code) {
|
|
178
|
+
res.set('Cache-Control', 'no-store');
|
|
179
|
+
res.set('Vary', 'Cookie');
|
|
180
|
+
if (isApiPath(req.path)) {
|
|
181
|
+
// The API convention for a caller with no identity (requireBuilder) — the
|
|
182
|
+
// same envelope, so a client's existing 401 handling reads it correctly.
|
|
183
|
+
// res.fail is attachFail's, mounted just before this gate.
|
|
184
|
+
return res.fail(code, code === 'builder_inactive' ? 403 : 401);
|
|
185
|
+
}
|
|
186
|
+
// 401 rather than 200 or 302: an unauthenticated request being refused, and
|
|
187
|
+
// said so — a crawler that ignores the noindex is still told. The door itself
|
|
188
|
+
// carries the way in; a redirect straight into the OAuth round trip would send
|
|
189
|
+
// every stray asset request and crawler to GitHub.
|
|
190
|
+
return res.status(401).type('html').send(doorHtml({ productName: productName(), signIn: signInHref(req) }));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// A member's answer must never be served to a stranger by a SHARED cache: the
|
|
194
|
+
// public read API and the hall assets send `Cache-Control: public, max-age=…`,
|
|
195
|
+
// which a CDN edge honours for everyone regardless of Vary. The member's own
|
|
196
|
+
// browser cache is a different matter — a hall page is thirty assets, and each
|
|
197
|
+
// re-fetch is a session lookup — so the route's freshness is KEPT and only the
|
|
198
|
+
// scope changes: `public` → `private`, `s-maxage` dropped, `max-age` untouched;
|
|
199
|
+
// a response that named no policy gets `private, no-cache` (revalidate, never
|
|
200
|
+
// share); `no-store` stays `no-store`. PURE on the header value.
|
|
201
|
+
function privateCacheControl(value) {
|
|
202
|
+
const parts = String(value || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
203
|
+
if (parts.some((p) => /^no-store$/i.test(p))) return parts.join(', ');
|
|
204
|
+
const kept = parts.filter((p) => !/^(public|private|s-maxage=.*)$/i.test(p));
|
|
205
|
+
if (!kept.length) return 'private, no-cache';
|
|
206
|
+
return ['private', ...kept].join(', ');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Patch writeHead so whatever the route set, the response leaves private —
|
|
210
|
+
// implicit heads (res.end, streams) go through writeHead too.
|
|
211
|
+
function markPrivate(res) {
|
|
212
|
+
const writeHead = res.writeHead;
|
|
213
|
+
res.writeHead = function privateWriteHead(...args) {
|
|
214
|
+
if (!this.headersSent) {
|
|
215
|
+
this.setHeader('Cache-Control', privateCacheControl(this.getHeader('Cache-Control')));
|
|
216
|
+
const vary = String(this.getHeader('Vary') || '');
|
|
217
|
+
if (!/(^|,)\s*cookie\s*(,|$)/i.test(vary)) this.setHeader('Vary', vary ? `${vary}, Cookie` : 'Cookie');
|
|
218
|
+
}
|
|
219
|
+
return writeHead.apply(this, args);
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Mounted ahead of every human and public-read surface (serve-internal.js), after
|
|
224
|
+
// the pre-launch gate and attachFail. `deps` exists for tests only — production
|
|
225
|
+
// reads the real branding pack and the real session lookup.
|
|
226
|
+
function platformVisibilityGate(deps = {}) {
|
|
227
|
+
const gated = deps.isGated || isGated;
|
|
228
|
+
const lookup = deps.resolveSession || resolveSession;
|
|
229
|
+
return function platformVisibility(req, res, next) {
|
|
230
|
+
if (!gated()) return next();
|
|
231
|
+
if (req.method === 'OPTIONS') return next(); // a CORS preflight carries no identity
|
|
232
|
+
if (isExempt(req.path)) return next();
|
|
233
|
+
// Async middleware must own its rejection (Express 4 drops the promise) —
|
|
234
|
+
// and it FAILS CLOSED: if the session cannot be resolved, the caller's
|
|
235
|
+
// identity is unknown, and admitting is never an option (auth.js's rule).
|
|
236
|
+
lookup(req).then((session) => {
|
|
237
|
+
if (!session) return refuse(req, res, 'unauthenticated');
|
|
238
|
+
// A deactivated builder keeps no door (the task 871 hot-path rule): deny
|
|
239
|
+
// only on the known 'inactive' status, never on a missing value.
|
|
240
|
+
if (session.status === 'inactive') return refuse(req, res, 'builder_inactive');
|
|
241
|
+
markPrivate(res);
|
|
242
|
+
return next();
|
|
243
|
+
}).catch((err) => {
|
|
244
|
+
// message only — a lookup error can carry query text
|
|
245
|
+
log.error({ err: err && err.message }, 'session lookup failed — refusing (fail closed)');
|
|
246
|
+
refuse(req, res, 'unauthenticated');
|
|
247
|
+
});
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
module.exports = { platformVisibilityGate, isGated, isExempt, mode, signInHref, privateCacheControl };
|
|
@@ -47,6 +47,7 @@ const { buildDevboxDownloads } = require('../devbox-downloads');
|
|
|
47
47
|
const { buildBongosDownloads } = require('../bongos-downloads');
|
|
48
48
|
const { clientBranding, branding } = require('../branding');
|
|
49
49
|
const { stealthGate } = require('./stealth-gate');
|
|
50
|
+
const { platformVisibilityGate } = require('./platform-visibility-gate');
|
|
50
51
|
const { clientModules, isModuleEnabled } = require('../modules');
|
|
51
52
|
const { hallWidgetScripts, moduleWebSurfaces } = require('../module-loader/loader');
|
|
52
53
|
const { resolveCoreRoot, resolveDocsRoot, resolveInstanceRoot } = require('../instance-config');
|
|
@@ -486,6 +487,15 @@ function mountInternalSurfaces(app) {
|
|
|
486
487
|
// with the router's copy (it just re-installs the same method).
|
|
487
488
|
app.use(attachFail);
|
|
488
489
|
|
|
490
|
+
// THE MEMBER DOOR (task 1003043): when the project's platform visibility is
|
|
491
|
+
// 'gated', a visitor with no live session is refused on every surface below —
|
|
492
|
+
// the landing, the hub pages, the status dashboard, the hall shell, the public
|
|
493
|
+
// read API — and shown a sign-in door; members pass. It mounts AFTER attachFail
|
|
494
|
+
// so an API refusal wears the same envelope requireBuilder's does, and after
|
|
495
|
+
// the metrics middleware so a refused knock is still counted. Inert while the
|
|
496
|
+
// setting is 'public' (the default): one branding read per request.
|
|
497
|
+
app.use(platformVisibilityGate());
|
|
498
|
+
|
|
489
499
|
app.get('/healthz', (_req, res) => {
|
|
490
500
|
res.type('text/plain').send('ok');
|
|
491
501
|
});
|