@bongos/core 1.19.412 → 1.19.414

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.
Files changed (38) hide show
  1. package/.bongos-core.json +57 -42
  2. package/clients/bongos-client/index.d.ts +4 -4
  3. package/docs/adr/0204-the-apex-is-dark-only.md +36 -0
  4. package/docs/adr/README.md +1 -0
  5. package/docs/api/openapi.json +25 -10
  6. package/docs/api-reference.md +2 -2
  7. package/docs/copy-inventory.md +405 -403
  8. package/docs/copy-registry.json +538 -520
  9. package/docs/design/landing-page-direction-v2.md +2 -0
  10. package/docs/design/projects-hub-direction-v2.md +4 -0
  11. package/docs/module-api-changelog.md +4 -0
  12. package/docs/recipes/ui-look-before-you-ship.md +4 -1
  13. package/modules/hall-ui/public/settings.html +18 -0
  14. package/modules/hall-ui/public/settings.js +93 -10
  15. package/modules/platform-identity/migrations/platform_identity_012_account_privacy.sql +59 -0
  16. package/modules/platform-identity/platform-identity.js +91 -1
  17. package/modules/platform-identity/routes/profile.js +68 -17
  18. package/modules/ui-design/kit/lib.js +3 -1
  19. package/modules/ui-design/kit/render.js +5 -4
  20. package/package-lock.json +2 -2
  21. package/package.json +1 -1
  22. package/public-landing/assets/cosmos.css +3 -17
  23. package/public-landing/contact.html +2 -6
  24. package/public-landing/index.html +5 -44
  25. package/public-landing/index.states.json +3 -2
  26. package/public-landing/privacy.html +2 -6
  27. package/public-landing/projects.html +4 -41
  28. package/public-landing/projects.probes.json +0 -3
  29. package/public-landing/projects.states.json +3 -2
  30. package/public-landing/terms.html +2 -6
  31. package/scripts/gds/worktree.js +11 -0
  32. package/src/module-api.js +1 -1
  33. package/tests/account_privacy_flags.mjs +299 -0
  34. package/tests/landing_legal_pages.mjs +10 -0
  35. package/tests/landing_page.mjs +16 -19
  36. package/tests/projects_hub.mjs +1 -1
  37. package/tests/projects_hub_pre_uat.mjs +4 -3
  38. package/tests/ui_design_kit.mjs +10 -0
@@ -16,8 +16,9 @@
16
16
  · dark mode — a true-black ground with the card lifted just off it, white
17
17
  ink, and the accent pair flipping direction so the link tone goes BRIGHTER
18
18
 
19
- Mode resolution: prefers-color-scheme by default; an explicit choice
20
- sets data-mode="light|dark" on <html> (persisted by the page script).
19
+ Mode resolution: the apex pages pin data-mode="dark" on <html> (task 1003336,
20
+ ADR 0204) — dark is their only mode. The light tier below stays until its
21
+ own removal (the light-pair contrast tests still measure it).
21
22
 
22
23
  TOKEN TIERS:
23
24
  · CONTRACT tier — names the branding pack overrides (themeMarkup()
@@ -585,19 +586,6 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
585
586
  .who{gap:9px;}
586
587
  .who img{width:26px;height:26px;border-radius:var(--radius-pill);object-fit:cover;flex:none;
587
588
  border:1px solid var(--rule);}
588
- .modeBtn{display:inline-flex;align-items:center;justify-content:center;width:44px;height:44px;
589
- cursor:pointer;background:none;border:0;border-radius:var(--radius-pill);color:var(--ink-soft);
590
- transition:color .2s cubic-bezier(.2,.7,.3,1);}
591
- .modeBtn:hover{color:var(--ink);}
592
- .modeBtn svg{width:20px;height:20px;}
593
- /* the glyph shows the mode you would GET: the moon in light, the sun in dark */
594
- .modeBtn .sun{display:none;}
595
- :root[data-mode="dark"] .modeBtn .sun{display:block;}
596
- :root[data-mode="dark"] .modeBtn .moon{display:none;}
597
- @media (prefers-color-scheme:dark){
598
- :root:not([data-mode="light"]) .modeBtn .sun{display:block;}
599
- :root:not([data-mode="light"]) .modeBtn .moon{display:none;}
600
- }
601
589
  /* the one filled shape on a bar with no pills: the landing's sign-in door */
602
590
  .signinDoor{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:0 26px;
603
591
  border-radius:var(--radius-pill);background:color-mix(in srgb,var(--ink) 92%,var(--bg));color:var(--bg);
@@ -685,8 +673,6 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
685
673
  .qWrap{flex:1 1 90px;max-width:480px;gap:10px;}
686
674
  .trig{font-size:13.5px;}
687
675
  .who>span{display:none;}
688
- .modeBtn{width:40px;height:40px;}
689
- .modeBtn svg{width:18px;height:18px;}
690
676
  .signinDoor{padding:0 16px;font-size:13.5px;}
691
677
  .bandIn{padding:0 12px 16px;}
692
678
  .bandRow{grid-template-columns:1fr;gap:0;}
@@ -1,5 +1,5 @@
1
1
  <!DOCTYPE html>
2
- <html lang="en">
2
+ <html lang="en" data-mode="dark">
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -17,11 +17,7 @@
17
17
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
18
18
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400&family=Manrope:wght@300;400;500;600&display=swap">
19
19
  <link rel="stylesheet" href="/assets/cosmos.css">
20
- <script>
21
- /* Mode, resolved before first paint — the same contract as every landing page. */
22
- (function(){try{var m=localStorage.getItem('cb-color-mode');
23
- if(m==='light'||m==='dark')document.documentElement.setAttribute('data-mode',m);}catch(e){}})();
24
- </script>
20
+ <!-- Dark only, like every apex page (task 1003336, ADR 0204): data-mode="dark" is fixed on <html>. -->
25
21
  <style>
26
22
  /* The reading column only. No :root block — the pack's injected theme owns the
27
23
  tokens (landing direction C1); everything below reads them. */
@@ -1,13 +1,11 @@
1
1
  <!DOCTYPE html>
2
- <html lang="en">
2
+ <html lang="en" data-mode="dark">
3
3
  <head>
4
4
  <meta charset="utf-8">
5
- <script>
6
- /* Mode, resolved before first paint so an explicit choice never flashes. ?mode=
7
- wins (it is how a reviewer walks both modes on one URL), then the stored
8
- choice, then the OS preference. */
9
- (function(){try{var q=new URLSearchParams(location.search).get('mode');var m=(q==='light'||q==='dark')?q:localStorage.getItem('cb-color-mode');if(m==='light'||m==='dark')document.documentElement.setAttribute('data-mode',m);}catch(e){}})();
10
- </script>
5
+ <!-- The apex is dark only (task 1003336, ADR 0204): data-mode="dark" is fixed on
6
+ <html> above — no pre-paint script, no stored choice, no OS preference, no
7
+ ?mode=. Light is the builders' hall's. The light tier of world.css stays
8
+ until its own removal. -->
11
9
  <meta name="viewport" content="width=device-width, initial-scale=1">
12
10
  <!-- ── S0 · HEAD METADATA — SERVER-STAMPED ──────────────────────────────────
13
11
  The {{…}} tokens below are replaced by landingPage() (src/platform-server.js)
@@ -168,11 +166,6 @@ main:focus{outline:none;box-shadow:none}
168
166
  .who{gap:9px}
169
167
  .who img{width:26px;height:26px;border-radius:var(--radius-pill);object-fit:cover;flex:none;border:1px solid var(--rule)}
170
168
  .who-name{max-width:14ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
171
- .modeBtn{display:inline-flex;align-items:center;justify-content:center;width:44px;height:44px;cursor:pointer;
172
- background:none;border:0;border-radius:var(--radius-pill);color:var(--ink-soft);transition:color .2s cubic-bezier(.2,.7,.3,1)}
173
- .modeBtn:hover{color:var(--ink)}
174
- .modeBtn svg{width:20px;height:20px}
175
- .sun{display:none}
176
169
  /* the sign-in door: the one filled shape on a bar with no pills — inverted ink, lowercase */
177
170
  .pill{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:0 22px;
178
171
  border-radius:var(--radius-pill);font-family:inherit;font-size:15px;color:var(--ink);cursor:pointer;
@@ -180,10 +173,6 @@ main:focus{outline:none;box-shadow:none}
180
173
  .signin{background:color-mix(in srgb,var(--ink) 92%,var(--bg));color:var(--bg);border:0;
181
174
  text-transform:lowercase;font-weight:500;padding:0 26px}
182
175
  .signin:hover{background:var(--ink);color:var(--bg)}
183
- :root[data-mode="dark"] .sun{display:block}
184
- :root[data-mode="dark"] .moon{display:none}
185
- @media (prefers-color-scheme:dark){:root:not([data-mode="light"]) .sun{display:block}
186
- :root:not([data-mode="light"]) .moon{display:none}}
187
176
 
188
177
  /* the menu bands (.menuBand — .band is this page's content sections): not floating cards. Full width on the card ground, closed by one hairline, a LAYER
189
178
  over the page under the bar — the page does not reflow (owner, 2026-08-28; task 1003332). Cells in a
@@ -491,8 +480,6 @@ body{
491
480
  .mark-word{display:none}
492
481
  .trig{font-size:13.5px}
493
482
  .who-name{display:none}
494
- .modeBtn{width:40px;height:40px}
495
- .modeBtn svg{width:18px;height:18px}
496
483
  .pill{min-height:42px;font-size:13.5px}
497
484
  .signin{padding:0 16px}
498
485
  .bandIn{padding:0 12px 16px}
@@ -683,17 +670,6 @@ composition A; landing composition v3, picked by the owner 2026-08-27. No roll,
683
670
  </div>
684
671
  <div class="barRight">
685
672
  <button class="trig" type="button" id="projTrig" data-menu="projects" aria-expanded="false" aria-controls="menuProjects">Projects<svg class="cv" viewBox="0 0 20 20" aria-hidden="true"><path d="m5.6 8 4.4 4.4L14.4 8"/></svg></button>
686
- <button class="modeBtn" id="mode" type="button" aria-label="Toggle light or dark">
687
- <svg class="moon" viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor"
688
- stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
689
- <path d="M16.2 12.4A6.8 6.8 0 0 1 7.6 3.8a6.8 6.8 0 1 0 8.6 8.6z"/>
690
- </svg>
691
- <svg class="sun" viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor"
692
- stroke-width="1.4" stroke-linecap="round">
693
- <circle cx="10" cy="10" r="3.9"/>
694
- <path d="M10 1.6v2.1M10 16.3v2.1M1.6 10h2.1M16.3 10h2.1M4.1 4.1l1.5 1.5M14.4 14.4l1.5 1.5M15.9 4.1l-1.5 1.5M5.6 14.4l-1.5 1.5"/>
695
- </svg>
696
- </button>
697
673
  <!-- the identity slot: the sign-in door signed out; the chip (the profile trigger) once
698
674
  the session resolves — swapped client-side, never a wrong-state flash -->
699
675
  <span class="whoSlot" id="who">
@@ -933,24 +909,9 @@ composition A; landing composition v3, picked by the owner 2026-08-27. No roll,
933
909
 
934
910
  <script>
935
911
  (function () {
936
- var root = document.documentElement;
937
912
  var API = '/api/bongos';
938
913
  var GDS = '/api/gds';
939
914
 
940
- /* ── the light/dark switch. The pre-paint script in <head> already applied the
941
- stored choice; this only flips it. The icon swap is CSS. ── */
942
- function isDark() {
943
- var m = root.getAttribute('data-mode');
944
- if (m === 'dark') return true;
945
- if (m === 'light') return false;
946
- return matchMedia('(prefers-color-scheme: dark)').matches;
947
- }
948
- document.getElementById('mode').addEventListener('click', function () {
949
- var next = isDark() ? 'light' : 'dark';
950
- root.setAttribute('data-mode', next);
951
- try { localStorage.setItem('cb-color-mode', next); } catch (e) {}
952
- });
953
-
954
915
  /* ── the tagline. It is ONE server-stamped string, but the composition wants it
955
916
  on two lines, so the two halves are baked as separate spans. A host whose
956
917
  tagline is not those two halves gets it back as one wrapping line rather
@@ -1,8 +1,9 @@
1
1
  {
2
- "_": "The landing's states for modules/ui-design/kit/render.js (task 1003320): the bar and its two bands, signed out and in.",
2
+ "_": "The landing's states for modules/ui-design/kit/render.js (task 1003320): the bar and its two bands, signed out and in. Dark only (task 1003336): the apex pins data-mode=\"dark\", so `modes` names the one mode and there is no query to walk.",
3
3
  "page": "/",
4
4
  "surface": "public-landing",
5
- "modeQuery": "mode",
5
+ "modes": ["dark"],
6
+ "modeQuery": false,
6
7
  "ignoreRequests": ["/api/(gds|bongos)/me$", "/my-projects", "/provisioning/"],
7
8
  "ignoreConsole": ["/api/(gds|bongos)/me", "\\b401\\b", "Unauthorized"],
8
9
  "ignoreControls": [".skip", ".vh"],
@@ -1,5 +1,5 @@
1
1
  <!DOCTYPE html>
2
- <html lang="en">
2
+ <html lang="en" data-mode="dark">
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -15,11 +15,7 @@
15
15
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
16
16
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400&family=Manrope:wght@300;400;500;600&display=swap">
17
17
  <link rel="stylesheet" href="/assets/cosmos.css">
18
- <script>
19
- /* Mode, resolved before first paint — the same contract as every landing page. */
20
- (function(){try{var m=localStorage.getItem('cb-color-mode');
21
- if(m==='light'||m==='dark')document.documentElement.setAttribute('data-mode',m);}catch(e){}})();
22
- </script>
18
+ <!-- Dark only, like every apex page (task 1003336, ADR 0204): data-mode="dark" is fixed on <html>. -->
23
19
  <style>
24
20
  /* The reading column only. No :root block — the pack's injected theme owns the
25
21
  tokens (landing direction C1); everything below reads them. */
@@ -1,5 +1,5 @@
1
1
  <!DOCTYPE html>
2
- <html lang="en">
2
+ <html lang="en" data-mode="dark">
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -20,11 +20,9 @@
20
20
  re-injects the branding :root AFTER stylesheets, so the pack stays
21
21
  authoritative. Design contract: docs/design/projects-surfaces-redesign-direction.md -->
22
22
  <link rel="stylesheet" href="/assets/cosmos.css">
23
- <script>
24
- /* apply the saved color mode BEFORE first paint so there's no flash;
25
- no saved choice = follow the OS (prefers-color-scheme) */
26
- try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')document.documentElement.setAttribute('data-mode',_m);}catch(e){}
27
- </script>
23
+ <!-- The apex is dark only (task 1003336, ADR 0204): data-mode="dark" is fixed on
24
+ <html> above — no pre-paint script, no stored choice, no OS preference. Light
25
+ is the builders' hall's. cosmos.css keeps its light tier until its own removal. -->
28
26
  <style>
29
27
  /* Page-local layout only. /projects is the project HUB (task 1002743): one
30
28
  surface holding the map, your projects, and creating a new one. Every
@@ -381,19 +379,6 @@ summary{min-height:24px;padding:3px 0;}
381
379
  </div>
382
380
  <div class="barRight">
383
381
  <button class="trig" type="button" id="projTrig" data-menu="projects" aria-expanded="false" aria-controls="menuProjects">Projects<svg class="cv" viewBox="0 0 20 20" aria-hidden="true"><path d="m5.6 8 4.4 4.4L14.4 8"/></svg></button>
384
- <!-- the glyph shows the mode you would GET: the moon in light, the sun in
385
- dark, swapped by cosmos.css on data-mode; the script labels it -->
386
- <button type="button" class="modeBtn" id="modeToggle" aria-label="Switch light or dark mode">
387
- <svg class="moon" viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor"
388
- stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
389
- <path d="M16.2 12.4A6.8 6.8 0 0 1 7.6 3.8a6.8 6.8 0 1 0 8.6 8.6z"/>
390
- </svg>
391
- <svg class="sun" viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor"
392
- stroke-width="1.4" stroke-linecap="round">
393
- <circle cx="10" cy="10" r="3.9"/>
394
- <path d="M10 1.6v2.1M10 16.3v2.1M1.6 10h2.1M16.3 10h2.1M4.1 4.1l1.5 1.5M14.4 14.4l1.5 1.5M15.9 4.1l-1.5 1.5M5.6 14.4l-1.5 1.5"/>
395
- </svg>
396
- </button>
397
382
  <!-- the identity chip IS the profile trigger; renderWho fills it. Signed
398
383
  out it does not exist and the sign-in door takes its slot. -->
399
384
  <button class="trig who" type="button" id="who" data-menu="profile" aria-expanded="false" aria-controls="menuProfile" hidden></button>
@@ -1098,28 +1083,6 @@ summary{min-height:24px;padding:3px 0;}
1098
1083
 
1099
1084
  function $(id) { return document.getElementById(id); }
1100
1085
 
1101
- /* ── light/dark: the sky is constant, the mode governs the cards. No saved
1102
- choice = follow the OS; the toggle stores an explicit one. ── */
1103
- function resolvedMode() {
1104
- var m = document.documentElement.getAttribute('data-mode');
1105
- if (m === 'light' || m === 'dark') return m;
1106
- return (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
1107
- }
1108
- /* the glyph shows the mode you would GET (the moon in light, the sun in
1109
- dark — cosmos.css swaps them on data-mode), so the label must say the same.
1110
- No aria-pressed — this swaps a mode, it is not a two-state toggle button. */
1111
- function paintModeToggle() {
1112
- var dark = resolvedMode() === 'dark';
1113
- var el = $('modeToggle');
1114
- el.setAttribute('aria-label', dark ? 'Switch to light mode' : 'Switch to dark mode');
1115
- }
1116
- $('modeToggle').addEventListener('click', function () {
1117
- var next = resolvedMode() === 'dark' ? 'light' : 'dark';
1118
- document.documentElement.setAttribute('data-mode', next);
1119
- try { localStorage.setItem('cb-color-mode', next); } catch (e) {}
1120
- paintModeToggle();
1121
- });
1122
- paintModeToggle();
1123
1086
  function esc(s) {
1124
1087
  return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
1125
1088
  return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
@@ -82,9 +82,6 @@
82
82
  { "name": "a solo Shift tap focuses the search on the map", "continue": true,
83
83
  "steps": [["clickAt", 700, 600], ["press", "Shift"], ["wait", 100]],
84
84
  "expect": { "activeId": "mapQ" } },
85
- { "name": "the mode toggle: the label says what the press does, and the press does it", "continue": true,
86
- "steps": [["clickAt", 700, 600], ["click", "#modeToggle"], ["wait", 100]],
87
- "expect": { "attr": [["#modeToggle", "aria-label", "Switch to light mode"], ["html", "data-mode", "dark"]] } },
88
85
  { "name": "the Table deep link (the landing's band item) lands on the table, Table current (task 1003332)", "auth": true, "width": 1440, "url": "/projects?view=map&sub=table",
89
86
  "steps": [["wait", 300]],
90
87
  "expect": { "visible": ["#sub-table-body", ".tblMast"], "hidden": ["#sub-sky-body"], "text": [["[aria-current=\"page\"]", "Table"], [".tblTitle", "every project"]] } },
@@ -1,8 +1,9 @@
1
1
  {
2
- "_": "The hub's states for modules/ui-design/kit/render.js (task 1003320): name → url + actions + expectations. `auth` picks the signed-in stub. Rendered dark + light at 1440/390/320 and audited against the platform floors. The card states (task 1003301) walk the poster's ladder: the full poster, the artless two-up band, the all-null floor, the platform's own card.",
2
+ "_": "The hub's states for modules/ui-design/kit/render.js (task 1003320): name → url + actions + expectations. `auth` picks the signed-in stub. Rendered dark only at 1440/390/320 (the apex pins data-mode=\"dark\", task 1003336) and audited against the platform floors. The card states (task 1003301) walk the poster's ladder: the full poster, the artless two-up band, the all-null floor, the platform's own card.",
3
3
  "page": "/projects",
4
4
  "surface": "public-landing",
5
- "modeQuery": "mode",
5
+ "modes": ["dark"],
6
+ "modeQuery": false,
6
7
  "ignoreRequests": ["/api/(gds|bongos)/me$", "/my-projects", "/provisioning/"],
7
8
  "ignoreConsole": ["/api/(gds|bongos)/me", "\\b401\\b", "Unauthorized"],
8
9
  "ignoreControls": [".skip", ".vh"],
@@ -1,5 +1,5 @@
1
1
  <!DOCTYPE html>
2
- <html lang="en">
2
+ <html lang="en" data-mode="dark">
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -15,11 +15,7 @@
15
15
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
16
16
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400&family=Manrope:wght@300;400;500;600&display=swap">
17
17
  <link rel="stylesheet" href="/assets/cosmos.css">
18
- <script>
19
- /* Mode, resolved before first paint — the same contract as every landing page. */
20
- (function(){try{var m=localStorage.getItem('cb-color-mode');
21
- if(m==='light'||m==='dark')document.documentElement.setAttribute('data-mode',m);}catch(e){}})();
22
- </script>
18
+ <!-- Dark only, like every apex page (task 1003336, ADR 0204): data-mode="dark" is fixed on <html>. -->
23
19
  <style>
24
20
  /* The reading column only. No :root block — the pack's injected theme owns the
25
21
  tokens (landing direction C1); everything below reads them. */
@@ -11,6 +11,17 @@
11
11
  // junction BEFORE git worktree remove. Worktrees INSIDE the repo
12
12
  // (.claude/worktrees/<name>, the project default) need no junction at all.
13
13
  //
14
+ // THE OTHER SHARED-STATE TRAP — `git stash` (task 1003344). The stash stack
15
+ // belongs to the REPOSITORY, not the worktree: every worktree shares one stack
16
+ // with the main checkout and with each other, so a stash here and a pop there
17
+ // hands one agent another's tree and silently drops the original entry. Commit
18
+ // on your own branch instead — a commit is worktree-local, a stash is not. The
19
+ // full rule (named-stash escape hatch, clean-baseline advice, and the
20
+ // `git fsck --unreachable` recovery path) has ONE canonical copy, in
21
+ // scripts/gds/CLAUDE.md under "Gotchas / invariants", and is learning 1000215,
22
+ // which claim.js surfaces at claim time. Deliberately short here: two full
23
+ // copies of the same prose would drift apart.
24
+ //
14
25
  // Usage:
15
26
  // node scripts/gds/worktree.js add <task-id|name> [--path <dir> | --outside] [--base <ref>]
16
27
  // node scripts/gds/worktree.js remove <name|path> [--force]
package/src/module-api.js CHANGED
@@ -33,7 +33,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
33
33
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
34
34
  // the entry to that file. Look for a version's history there, not here.
35
35
  // ---------------------------------------------------------------------------
36
- const CORE_VERSION = '1.19.412'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
36
+ const CORE_VERSION = '1.19.414'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
37
37
 
38
38
  // A namespaced logger so a module's log lines are attributable + consistent.
39
39
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,299 @@
1
+ // tests/account_privacy_flags.mjs — account_visibility + recruiter_discoverable
2
+ // (task 1002966, goal 1000045; docs/specs/bongos-v1-privacy-and-invites.md D1/D3).
3
+ //
4
+ // These two flags are the keystone of the privacy model — task 1002967 (the
5
+ // shared effective-visibility predicate), task 1002968 (recruiter-sliver
6
+ // gating), task 1002806 (the private-profile 404 proof) and task 1002290
7
+ // (opt out of recruiting) all read them — so the MODEL is what this pins, not
8
+ // the plumbing:
9
+ //
10
+ // 1. the migration's stored shape: visibility NOT NULL DEFAULT 'public' with a
11
+ // two-value CHECK, discoverable NULLABLE (the tri-state is deliberate);
12
+ // 2. isRecruiterDiscoverable() — the ONE predicate, the JS twin of
13
+ // COALESCE(recruiter_discoverable, account_visibility = 'public'): a fresh
14
+ // account is public + discoverable, private flips the effective answer
15
+ // while the column stays NULL, and an explicit boolean beats both (the
16
+ // spec's D2 "private · discoverable" row is allowed, not refused);
17
+ // 3. the own-scoped write: only the flags sent are written, never identity,
18
+ // never consent, never hide_stats — and the account is always the SESSION's
19
+ // (a body can neither name nor reach another one);
20
+ // 4. the leak rule: neither column may appear in a public response body.
21
+ //
22
+ // Pure — a recording fake pool, no Postgres, no network. Runs in the DB-free
23
+ // unit gate.
24
+ //
25
+ // Run: node tests/account_privacy_flags.mjs
26
+
27
+ import { strict as assert } from 'node:assert';
28
+ import { test, after } from 'node:test';
29
+ import { createRequire } from 'node:module';
30
+ import http from 'node:http';
31
+ import { readFileSync } from 'node:fs';
32
+ import { fileURLToPath } from 'node:url';
33
+ import { dirname, join } from 'node:path';
34
+
35
+ const require = createRequire(import.meta.url);
36
+ const express = require('express');
37
+
38
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
39
+ const MIGRATION = readFileSync(
40
+ join(ROOT, 'modules/platform-identity/migrations/platform_identity_012_account_privacy.sql'), 'utf8');
41
+
42
+ // ---- the recording fake pool ----------------------------------------------
43
+ //
44
+ // One shared pool object so the route's `pool` (pinned into module-api below,
45
+ // resolved once at require time) and each test's queue are the same thing.
46
+ let queued = [];
47
+ let calls = [];
48
+ const pool = {
49
+ query: async (text, params) => {
50
+ calls.push({ text: String(text), params: params || [] });
51
+ return queued.shift() || { rows: [], rowCount: 0 };
52
+ },
53
+ };
54
+ function expect(...responses) { queued = responses; calls = []; }
55
+
56
+ // ---- the route over the real domain, with core pinned ----------------------
57
+
58
+ const SESSION = { github_id: 4242, github_login: 'octo', display_name: 'Octo', avatar_url: 'https://a.example/o.png' };
59
+ const OTHER_ACCOUNT = 9999;
60
+
61
+ const moduleApiPath = require.resolve('../src/module-api.js');
62
+ const helpers = require('../src/bongos/routes/_helpers.js');
63
+ require.cache[moduleApiPath] = {
64
+ id: moduleApiPath, filename: moduleApiPath, loaded: true,
65
+ exports: {
66
+ pool,
67
+ validateOrRespond: helpers.validateOrRespond,
68
+ // The own-scope stub: a live session, exactly as requireBuilder leaves it.
69
+ requireBuilder: (req, _res, next) => { req.builder = { ...SESSION }; next(); },
70
+ },
71
+ };
72
+ const pi = require('../modules/platform-identity/platform-identity.js');
73
+ const buildProfileRoutes = require('../modules/platform-identity/routes/profile.js');
74
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
75
+
76
+ const app = express();
77
+ app.use(express.json());
78
+ app.use(attachFail);
79
+ app.use('/api/bongos', buildProfileRoutes());
80
+ const server = app.listen(0, '127.0.0.1');
81
+ await new Promise((r) => server.once('listening', r));
82
+ const port = server.address().port;
83
+ after(() => server.close());
84
+
85
+ function call(method, path, body) {
86
+ return new Promise((resolve, reject) => {
87
+ const payload = body === undefined ? null : Buffer.from(JSON.stringify(body));
88
+ const req = http.request({
89
+ host: '127.0.0.1', port, method, path,
90
+ headers: payload ? { 'content-type': 'application/json', 'content-length': payload.length } : {},
91
+ }, (res) => {
92
+ const chunks = [];
93
+ res.on('data', (c) => chunks.push(c));
94
+ res.on('end', () => {
95
+ const text = Buffer.concat(chunks).toString('utf8');
96
+ let json = null; try { json = JSON.parse(text); } catch { /* non-JSON body */ }
97
+ resolve({ status: res.statusCode, json, text });
98
+ });
99
+ });
100
+ req.on('error', reject);
101
+ req.end(payload);
102
+ });
103
+ }
104
+
105
+ // A stored row as the module's RETURNING clause hands it back.
106
+ const row = (over = {}) => ({
107
+ github_id: String(SESSION.github_id), github_login: 'octo', profile_state: 'active',
108
+ hide_stats: false, account_visibility: 'public', recruiter_discoverable: null,
109
+ handle: 'octo', ...over,
110
+ });
111
+
112
+ // ---- 1. the stored shape ---------------------------------------------------
113
+
114
+ test('migration 012 stores visibility NOT NULL DEFAULT public and discoverable NULLABLE', () => {
115
+ assert.match(MIGRATION, /ADD COLUMN IF NOT EXISTS account_visibility text NOT NULL DEFAULT 'public'/,
116
+ "public is the default, and the DEFAULT clause IS the backfill — no existing profile changes state");
117
+ assert.match(MIGRATION, /ADD COLUMN IF NOT EXISTS recruiter_discoverable boolean;/,
118
+ 'recruiter_discoverable is a bare nullable boolean');
119
+ assert.doesNotMatch(MIGRATION, /recruiter_discoverable boolean NOT NULL/,
120
+ 'NOT NULL would destroy the tri-state: NULL is what "follow visibility" is made of');
121
+ assert.match(MIGRATION, /CHECK \(account_visibility IN \('public', 'private'\)\)/,
122
+ 'the engine refuses a third visibility value');
123
+ assert.match(MIGRATION, /ALTER TABLE platform_identity_accounts/, 'the flags live on the hub account (ADR 0171 D4 precedent)');
124
+ });
125
+
126
+ // ---- 2. the ONE predicate --------------------------------------------------
127
+
128
+ test('a fresh account is public AND discoverable — the opt-out half of D1', () => {
129
+ assert.equal(pi.isRecruiterDiscoverable(row()), true);
130
+ assert.equal(pi.isRecruiterDiscoverable(row({ recruiter_discoverable: false })), false,
131
+ 'a public account can leave recruiting and stay public');
132
+ });
133
+
134
+ test('private flips the effective answer to false while the column stays NULL', () => {
135
+ const acct = row({ account_visibility: 'private' });
136
+ assert.equal(acct.recruiter_discoverable, null, 'the stored flag is untouched — only the derived answer moves');
137
+ assert.equal(pi.isRecruiterDiscoverable(acct), false, 'recruiting is opt-IN for a private account');
138
+ });
139
+
140
+ test('an explicit true on a private account WINS (the spec D2 private-discoverable row)', () => {
141
+ assert.equal(pi.isRecruiterDiscoverable(row({ account_visibility: 'private', recruiter_discoverable: true })), true,
142
+ 'the spec allows this state — it is exactly what the D3 recruiter sliver serves');
143
+ });
144
+
145
+ test('isRecruiterDiscoverable is fail-closed on a missing account or a missing column', () => {
146
+ assert.equal(pi.isRecruiterDiscoverable(null), false);
147
+ assert.equal(pi.isRecruiterDiscoverable({}), false, 'no visibility column read → not discoverable');
148
+ assert.deepEqual(pi.ACCOUNT_VISIBILITY, ['public', 'private']);
149
+ });
150
+
151
+ // ---- 3. the own-scoped write -----------------------------------------------
152
+
153
+ test('setAccountPrivacy writes ONLY the flags sent — never identity, consent, or hide_stats', async () => {
154
+ expect({ rows: [row({ account_visibility: 'private' })] });
155
+ const out = await pi.setAccountPrivacy(
156
+ { githubId: SESSION.github_id, githubLogin: 'octo', fields: { account_visibility: 'private' } }, { pool });
157
+ assert.equal(out.account_visibility, 'private');
158
+ const sql = calls[0].text;
159
+ const updateBranch = sql.slice(sql.indexOf('DO UPDATE'), sql.indexOf('RETURNING'));
160
+ assert.match(updateBranch, /account_visibility\s+= CASE WHEN \$7::boolean THEN EXCLUDED\.account_visibility/);
161
+ assert.match(updateBranch, /recruiter_discoverable = CASE WHEN \$8::boolean THEN EXCLUDED\.recruiter_discoverable/);
162
+ assert.doesNotMatch(updateBranch, /github_login|display_name|avatar_url|profile_state|hide_stats/,
163
+ 'a privacy toggle is not an identity refresh, a consent grant, or a hide');
164
+ assert.equal(calls[0].params[6], true, 'visibility was sent');
165
+ assert.equal(calls[0].params[7], false, 'discoverable was not — its CASE arm keeps the stored value');
166
+ });
167
+
168
+ test('setAccountPrivacy accepts an explicit null discoverable — the reset to "follow visibility"', async () => {
169
+ expect({ rows: [row()] });
170
+ await pi.setAccountPrivacy(
171
+ { githubId: SESSION.github_id, fields: { recruiter_discoverable: null } }, { pool });
172
+ assert.equal(calls[0].params[5], null, 'null is written, not skipped');
173
+ assert.equal(calls[0].params[7], true, 'and it counts as sent');
174
+ });
175
+
176
+ test('setAccountPrivacy refuses a value outside the model, before any query', async () => {
177
+ expect();
178
+ assert.equal((await pi.setAccountPrivacy(
179
+ { githubId: SESSION.github_id, fields: { account_visibility: 'stealth' } }, { pool })).error,
180
+ 'invalid_account_visibility');
181
+ assert.equal((await pi.setAccountPrivacy(
182
+ { githubId: SESSION.github_id, fields: { recruiter_discoverable: 'yes' } }, { pool })).error,
183
+ 'invalid_recruiter_discoverable');
184
+ assert.equal((await pi.setAccountPrivacy({ githubId: SESSION.github_id, fields: {} }, { pool })).error, 'no_fields');
185
+ assert.equal((await pi.setAccountPrivacy(
186
+ { githubId: 'system:bfg', fields: { account_visibility: 'private' } }, { pool })).error, 'no_account');
187
+ assert.equal(calls.length, 0, 'a rejected write never reaches the database');
188
+ });
189
+
190
+ test('PATCH keys the write on the SESSION account — a body can neither name nor reach another one', async () => {
191
+ expect({ rows: [row({ account_visibility: 'private' })] });
192
+ const res = await call('PATCH', '/api/bongos/me/cross-project', { account_visibility: 'private' });
193
+ assert.equal(res.status, 200);
194
+ assert.equal(calls[0].params[0], SESSION.github_id, 'the write is keyed on the authenticated github_id');
195
+ assert.ok(!JSON.stringify(calls[0].params).includes(String(OTHER_ACCOUNT)));
196
+
197
+ expect({ rows: [row()] });
198
+ const named = await call('PATCH', '/api/bongos/me/cross-project',
199
+ { account_visibility: 'private', github_id: OTHER_ACCOUNT });
200
+ assert.equal(named.status, 400, 'a body that names an account is rejected outright (strict validation)');
201
+ assert.equal(named.json.error.code, 'validation_failed');
202
+ assert.equal(calls.length, 0, 'and nothing was written');
203
+ });
204
+
205
+ test('PATCH refuses a visibility outside the model and an empty body', async () => {
206
+ expect();
207
+ const bad = await call('PATCH', '/api/bongos/me/cross-project', { account_visibility: 'stealth' });
208
+ assert.equal(bad.status, 400);
209
+ assert.equal(bad.json.error.code, 'validation_failed');
210
+ const empty = await call('PATCH', '/api/bongos/me/cross-project', {});
211
+ assert.equal(empty.status, 400);
212
+ assert.equal(empty.json.error.code, 'no_fields');
213
+ assert.equal(calls.length, 0);
214
+ });
215
+
216
+ test('PATCH answers with the stored flags plus the ONE derived effective answer', async () => {
217
+ expect({ rows: [row({ account_visibility: 'private', recruiter_discoverable: true })] });
218
+ const res = await call('PATCH', '/api/bongos/me/cross-project', { recruiter_discoverable: true });
219
+ assert.deepEqual(res.json, {
220
+ hide_stats: false, account_visibility: 'private',
221
+ recruiter_discoverable: true, recruiter_discoverable_effective: true,
222
+ });
223
+ });
224
+
225
+ test('PATCH { hide_stats } alone still works and carries the privacy flags back', async () => {
226
+ expect({ rows: [row({ hide_stats: true })] });
227
+ const res = await call('PATCH', '/api/bongos/me/cross-project', { hide_stats: true });
228
+ assert.equal(res.status, 200);
229
+ assert.equal(res.json.hide_stats, true);
230
+ assert.equal(res.json.account_visibility, 'public');
231
+ assert.equal(res.json.recruiter_discoverable_effective, true);
232
+ assert.match(calls[0].text, /hide_stats/, 'the hide flag keeps its own writer (ADR 0171 D4)');
233
+ });
234
+
235
+ test('GET serves the own-scope view: both flags plus the effective answer', async () => {
236
+ expect(
237
+ { rows: [row({ account_visibility: 'private' })] },
238
+ { rows: [{ projects: 1, credits: 10, works_shipped: 1, karma: 0, last_active: 'T', disciplines: null }] },
239
+ { rows: [] },
240
+ );
241
+ const res = await call('GET', '/api/bongos/me/cross-project');
242
+ assert.equal(res.status, 200);
243
+ assert.equal(res.json.account_visibility, 'private');
244
+ assert.equal(res.json.recruiter_discoverable, null);
245
+ assert.equal(res.json.recruiter_discoverable_effective, false, 'derived server-side, so no client re-derives it');
246
+ });
247
+
248
+ // ---- 4. the leak rule ------------------------------------------------------
249
+
250
+ test('neither flag crosses a PUBLIC response — the /u/<handle> read stays a whitelist', async () => {
251
+ // The stored row carries both flags AND a sentinel private value; the public
252
+ // shape must be built from the whitelist, not spread from the row.
253
+ expect(
254
+ { rows: [{
255
+ github_id: '4242', github_login: 'octo', display_name: 'Octo', avatar_url: null,
256
+ handle: 'octo', bio: null, links: null, created_at: 'T', is_current: true,
257
+ account_visibility: 'private', recruiter_discoverable: true, profile_state: 'active', hide_stats: false,
258
+ }] },
259
+ { rows: [{ profile_state: 'active', hide_stats: false }] },
260
+ { rows: [{ projects: 1, credits: 10, works_shipped: 1, karma: 0, last_active: 'T', disciplines: null }] },
261
+ { rows: [] },
262
+ );
263
+ const out = await pi.getPublicProfileByHandle('octo', { pool });
264
+ const text = JSON.stringify(out);
265
+ for (const leak of ['account_visibility', 'recruiter_discoverable', 'profile_state', 'hide_stats', 'github_id']) {
266
+ assert.ok(!text.includes(leak), `${leak} must never serialize into a public body`);
267
+ }
268
+ assert.deepEqual(Object.keys(out.account).sort(),
269
+ ['avatar_url', 'bio', 'display_name', 'github_login', 'handle', 'links', 'member_since']);
270
+ });
271
+
272
+ // ---- 5. the settings copy states the grant ---------------------------------
273
+
274
+ test('the discoverable toggle names EXACTLY what it grants — the D3 sliver, name-only when hidden', () => {
275
+ const js = readFileSync(join(ROOT, 'modules/hall-ui/public/settings.js'), 'utf8');
276
+ const html = readFileSync(join(ROOT, 'modules/hall-ui/public/settings.html'), 'utf8');
277
+ assert.match(js, /your name, your disciplines, and whether you have been active in the last 30 days/,
278
+ 'the sliver is name + disciplines + a coarse activity signal — spelled out, not implied');
279
+ assert.match(js, /never your credits, works shipped, karma, or the projects you build on/,
280
+ 'and the copy says what it does NOT grant');
281
+ assert.match(js, /cross-project record is hidden, they will see your name and nothing else/,
282
+ 'hide_stats shrinks the grant to name-only, because the rest is rollup-derived');
283
+ assert.match(html, /<section[^>]*id="privacy-scroll"[^>]*hidden>/,
284
+ 'the section ships hidden — it reveals only where the hub module answers');
285
+ });
286
+
287
+ test('no toggle claims a protection this task has not built — the read paths are still open', () => {
288
+ const js = readFileSync(join(ROOT, 'modules/hall-ui/public/settings.js'), 'utf8');
289
+ const html = readFileSync(join(ROOT, 'modules/hall-ui/public/settings.html'), 'utf8');
290
+ // Storing a flag is not enforcing it (task 1002967 wires the read paths). A
291
+ // builder flipping this for personal safety must not be told they are hidden
292
+ // while GET /profiles/:handle still serves the page.
293
+ assert.match(html, /saved to your account now\. They start applying as[\s\S]*nothing is hidden yet/,
294
+ 'the section states plainly that the choice is recorded, not yet in force');
295
+ assert.doesNotMatch(js, /Private hides the page|is private — hidden from everyone/,
296
+ 'no present-tense promise that the page is already hidden');
297
+ assert.match(js, /Private will hide the page from everyone but you/,
298
+ 'the row says what the choice WILL do');
299
+ });