@bongos/core 1.19.368 → 1.19.369

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/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.368",
3
+ "version": "1.19.369",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.368",
9
+ "version": "1.19.369",
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.368",
3
+ "version": "1.19.369",
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
- <!-- the two settings the sibling tasks (1003043 / 1003044) fill in — placed
661
- BELOW the sections that work, so a founder meets controls before promises -->
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 class="empty"><p>Choosing who can open this project’s pages — everyone, or only people signed in to it — isn’t wired up here yet. For now, every project’s pages are open to everyone.</p></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.
@@ -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
- for (const token of extractTokens(req)) {
92
- const session = await lookupSession(token);
93
- if (session) return session;
94
- }
95
- return null;
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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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
  });
package/src/module-api.js CHANGED
@@ -1166,8 +1166,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1166
1166
  // landed since 1.19.366 with no explicit bump. run 32902059088. (task 1002620)
1167
1167
  // 1.19.368 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1168
1168
  // landed since 1.19.367 with no explicit bump. run 32902864988. (task 1002620)
1169
+ // 1.19.369 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1170
+ // landed since 1.19.368 with no explicit bump. run 32903289072. (task 1002620)
1169
1171
  // ---------------------------------------------------------------------------
1170
- const CORE_VERSION = '1.19.368'; // CI auto-patch carrier (ADR 0161); see changelog above
1172
+ const CORE_VERSION = '1.19.369'; // CI auto-patch carrier (ADR 0161); see changelog above
1171
1173
 
1172
1174
  // A namespaced logger so a module's log lines are attributable + consistent.
1173
1175
  // Usage: const log = api.logger('dev-box'); log.info('mounted');