@bongos/core 1.19.412 → 1.19.413

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.
@@ -1261,5 +1261,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1261
1261
  landed since 1.19.410 with no explicit bump. run 33218290583. (task 1002620)
1262
1262
  1.19.412 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1263
1263
  landed since 1.19.411 with no explicit bump. run 33259357678. (task 1002620)
1264
+ 1.19.413 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1265
+ landed since 1.19.412 with no explicit bump. run 33259477562. (task 1002620)
1264
1266
  ---------------------------------------------------------------------------
1265
1267
  ```
@@ -93,6 +93,24 @@
93
93
  <p class="settings-status" id="cross-project-status" role="status" aria-live="polite"></p>
94
94
  </section>
95
95
 
96
+ <!-- Profile privacy (platform-identity module, privacy spec D1 / task
97
+ 1002966). The same own-scoped GET/PATCH /me/cross-project as the
98
+ section above, so it reveals and hides with it; P04 later moves
99
+ both into the Profile rail group. -->
100
+ <section class="scroll" id="privacy-scroll" aria-labelledby="privacy-h" data-group="account" hidden>
101
+ <h2 id="privacy-h" class="scroll__h">Profile privacy</h2>
102
+ <p class="scroll__lede">
103
+ Who can find you. Your profile page can be public or private, and
104
+ recruiters looking for builders are allowed in separately — so a
105
+ private profile can still be reachable for work, and a public one
106
+ can stay out of recruiting entirely.
107
+ </p>
108
+ <div id="privacy-rows"></div>
109
+ <p class="settings-status" id="privacy-status" role="status" aria-live="polite"></p>
110
+ <p class="section-note">Your choices are saved to your account now. They start applying as
111
+ the public profile page and the recruiting surfaces land — nothing is hidden yet.</p>
112
+ </section>
113
+
96
114
  <!-- CRAFT — discipline preferences (GET /me, PATCH /me/disciplines). -->
97
115
  <section class="scroll" id="craft-scroll" aria-labelledby="craft-h" data-group="account" hidden>
98
116
  <h2 id="craft-h" class="scroll__h">Preferred disciplines</h2>
@@ -583,29 +583,98 @@
583
583
  '</div>';
584
584
  }
585
585
 
586
- // PATCH the hide flag. Body: { hide_stats: <bool> } (routes/profile.js).
587
- async function saveCrossProject(hide, btn) {
588
- const status = $('#cross-project-status');
586
+ // ---- section: PROFILE PRIVACY (account_visibility + recruiter_discoverable
587
+ // — privacy spec D1/D3, task 1002966)
588
+ //
589
+ // Two more rows on the SAME own-scoped GET/PATCH /me/cross-project, because
590
+ // both flags live on the hub account beside hide_stats (never behavior_prefs).
591
+ // The recruiter row's copy must name EXACTLY what being discoverable grants —
592
+ // the spec's D3 sliver — and that grant shrinks to name-only when the
593
+ // cross-project record is hidden, because the rest of the sliver is derived
594
+ // from it. `recruiter_discoverable` is tri-state server-side: null means the
595
+ // answer follows the visibility setting, which the row says out loud. The
596
+ // toggle is deliberately BINARY (spec D1: "one column, one settings toggle");
597
+ // resetting back to null stays an API affordance, not a third UI state.
598
+ //
599
+ // COPY RULE while the read paths are unenforced: this task stores the flags,
600
+ // it does not gate any public read (that is task 1002967), so nothing here
601
+ // may promise that a page is already hidden — the rows say what the choice
602
+ // WILL do and the section note says plainly that it is not in force yet. A
603
+ // toggle that claims protection it does not have is worse than no toggle.
604
+ function renderPrivacy(data) {
605
+ const wrap = $('#privacy-rows');
606
+ if (!wrap) return;
607
+ const isPublic = !data || data.account_visibility !== 'private';
608
+ const discoverable = !!(data && data.recruiter_discoverable_effective);
609
+ const follows = !data || data.recruiter_discoverable == null;
610
+ const grant = data && data.hide_stats
611
+ ? 'Because your cross-project record is hidden, they will see your name and nothing else.'
612
+ : 'They will see your name, your disciplines, and whether you have been active in the last 30 days — never your credits, works shipped, karma, or the projects you build on.';
613
+ wrap.innerHTML =
614
+ privacyRow('visibility', 'Public profile page', isPublic ? 'Public' : 'Private', isPublic,
615
+ 'Public means anyone can open your profile page and find you by name. Private will hide the page from everyone but you.', '') +
616
+ privacyRow('discoverable', 'Discoverable by recruiters', discoverable ? 'On' : 'Off', discoverable,
617
+ `Recruiters looking for builders will be able to find you. ${grant}`,
618
+ follows ? ' <span class="voices__default">· follows your profile setting</span>' : '');
619
+ }
620
+
621
+ // One row of the privacy section, in the same sound-row markup every toggle on
622
+ // this page uses. `note` is trusted markup (a caller-supplied span), the label
623
+ // and description are escaped.
624
+ function privacyRow(key, label, state, on, desc, note) {
625
+ return `<div class="sound-row" data-privacy="${key}">` +
626
+ '<div class="sound-row__text">' +
627
+ `<div class="sound-row__label">${escapeHtml(label)}${note}</div>` +
628
+ `<div class="sound-row__desc">${escapeHtml(desc)}</div>` +
629
+ '</div>' +
630
+ `<button type="button" class="sound-toggle" data-on="${on ? '1' : '0'}" aria-pressed="${on ? 'true' : 'false'}">${state}</button>` +
631
+ '</div>';
632
+ }
633
+
634
+ // The status/toast line for a privacy toggle. Deliberately says only what the
635
+ // flag itself changed — the exact grant lives in the row's description, which
636
+ // re-renders from the server right after the save.
637
+ function privacyMsg(key, on) {
638
+ if (key === 'visibility') {
639
+ return on
640
+ ? 'Saved — your profile page is set to public.'
641
+ : 'Saved — your profile page is set to private.';
642
+ }
643
+ return on
644
+ ? 'Saved — you are set to be findable by recruiters.'
645
+ : 'Saved — you are set to stay out of recruiting search.';
646
+ }
647
+
648
+ // PATCH any own-scoped privacy flag (routes/profile.js): { hide_stats } and/or
649
+ // { account_visibility, recruiter_discoverable }. Always re-reads afterwards —
650
+ // the effective recruiter answer is derived server-side, so the server's own
651
+ // view is the only state worth rendering.
652
+ async function savePrivacy(body, statusSel, msg, btn) {
653
+ const status = $(statusSel);
589
654
  if (status) status.textContent = 'Saving…';
590
655
  if (btn) btn.disabled = true;
591
656
  try {
592
- const res = await api.request('PATCH', `${API}/me/cross-project`, { body: { hide_stats: hide } });
657
+ const res = await api.request('PATCH', `${API}/me/cross-project`, { body });
593
658
  if (res.status === 401) { toAuth(); return; }
594
659
  if (!res.ok) throw new Error(`cross-project PATCH → ${res.status}`);
595
- await loadCrossProject(); // re-render from the server's truth (flag + record)
596
- const msg = hide
597
- ? 'Cross-project record hidden — removed from your profile and directories immediately.'
598
- : 'Cross-project record shown on your public profile.';
660
+ await loadCrossProject(); // re-render from the server's truth (flags + record)
599
661
  if (status) status.textContent = msg;
600
662
  toast(msg);
601
663
  } catch (err) {
602
- console.error('[settings] save cross-project failed', err);
664
+ console.error('[settings] save privacy failed', err);
603
665
  if (status) status.textContent = 'Could not save. Try again.';
604
- toast('Could not save your cross-project preference. Try again.');
666
+ toast('Could not save your privacy preference. Try again.');
605
667
  if (btn) btn.disabled = false;
606
668
  }
607
669
  }
608
670
 
671
+ // The hide flag. Body: { hide_stats: <bool> } (routes/profile.js).
672
+ function saveCrossProject(hide, btn) {
673
+ return savePrivacy({ hide_stats: hide }, '#cross-project-status', hide
674
+ ? 'Cross-project record hidden — removed from your profile and directories immediately.'
675
+ : 'Cross-project record shown on your public profile.', btn);
676
+ }
677
+
609
678
  let crossProjectWired = false;
610
679
  async function loadCrossProject() {
611
680
  const section = $('#cross-project-scroll');
@@ -615,7 +684,10 @@
615
684
  if (res.status === 401) { toAuth(); return; }
616
685
  if (!res.ok) return; // module off / route absent → the section stays hidden
617
686
  renderCrossProject(res.data);
687
+ renderPrivacy(res.data);
618
688
  section.hidden = false;
689
+ const privacy = $('#privacy-scroll');
690
+ if (privacy) privacy.hidden = false;
619
691
  if (!crossProjectWired) {
620
692
  crossProjectWired = true;
621
693
  $('#cross-project-rows').addEventListener('click', (e) => {
@@ -623,6 +695,16 @@
623
695
  if (!b || b.disabled) return;
624
696
  saveCrossProject(b.dataset.on === '1', b); // shown → hide; hidden → show
625
697
  });
698
+ $('#privacy-rows').addEventListener('click', (e) => {
699
+ const b = e.target.closest('.sound-toggle');
700
+ if (!b || b.disabled) return;
701
+ const row = b.closest('[data-privacy]');
702
+ const on = b.dataset.on === '1';
703
+ const body = row && row.dataset.privacy === 'visibility'
704
+ ? { account_visibility: on ? 'private' : 'public' }
705
+ : { recruiter_discoverable: !on };
706
+ savePrivacy(body, '#privacy-status', privacyMsg(row && row.dataset.privacy, !on), b);
707
+ });
626
708
  }
627
709
  } catch (err) {
628
710
  // Network/parse failure on an optional module section — stay hidden, log only.
@@ -1179,6 +1261,7 @@
1179
1261
  'connections': 'connections-scroll',
1180
1262
  'display-name': 'display-name-scroll',
1181
1263
  'cross-project': 'cross-project-scroll',
1264
+ 'privacy': 'privacy-scroll',
1182
1265
  'disciplines': 'craft-scroll',
1183
1266
  'cli': 'cli-scroll',
1184
1267
  'art-key': 'art-key',
@@ -0,0 +1,59 @@
1
+ -- platform_identity_012_account_privacy.sql — the two account privacy flags
2
+ -- (task 1002966 / goal 1000045; spec docs/specs/bongos-v1-privacy-and-invites.md
3
+ -- D1, which extends ADR 0171 D3/D4 rather than replacing it).
4
+ --
5
+ -- ADR 0171 already owns two axes on this table: profile_state (consent — has a
6
+ -- live hub session ever touched the account) and hide_stats (numbers — suppress
7
+ -- everything rollup-derived, for every reader). This migration adds the other
8
+ -- two the spec's D1 matrix needs:
9
+ --
10
+ -- account_visibility — EXISTENCE. Does /u/<handle> exist to the world?
11
+ -- 'private' means 404-to-strangers and absent from search,
12
+ -- leaderboards, sitemap and every Explore-adjacent surface. Default
13
+ -- 'public' = today's behaviour unchanged, so the backfill IS the
14
+ -- DEFAULT clause (the migration 011 precedent) — no separate UPDATE,
15
+ -- and no existing profile changes state when this lands.
16
+ --
17
+ -- recruiter_discoverable — RECRUITING REACH, and deliberately NULLABLE. The
18
+ -- tri-state is the whole design: NULL means "follow visibility", and
19
+ -- the effective value is
20
+ -- COALESCE(recruiter_discoverable, account_visibility = 'public')
21
+ -- which reconciles two owner texts that only LOOKED contradictory —
22
+ -- recruiting reach is OPT-OUT for a public account (every public
23
+ -- profile is reachable; a builder can remove themselves and stay
24
+ -- public) and OPT-IN for a private one (invisible unless this flag is
25
+ -- explicitly true). One column, one settings toggle, whose default
26
+ -- state tracks visibility. A NOT NULL boolean cannot express that: it
27
+ -- would have to pick one default and get the other case wrong.
28
+ -- The single JS twin of that COALESCE is isRecruiterDiscoverable()
29
+ -- in platform-identity.js — the predicate every reader adopts
30
+ -- (enforcement across the read paths is task 1002967's job).
31
+ --
32
+ -- NEITHER COLUMN IS EVER PUBLIC. They are inputs to the public predicates, not
33
+ -- outputs: no public response body may carry them (tests/profile_route.mjs +
34
+ -- tests/hub_profile_leak.mjs are the sentinels; the module's public reads stay
35
+ -- explicit whitelists).
36
+ --
37
+ -- Additive + platform_identity_-namespaced (ADR 0083 §5). Forward-safe: ADD
38
+ -- COLUMN only, so the previous code release still runs against this schema.
39
+ -- Idempotent (IF NOT EXISTS + the drop-then-add CHECK pattern from migration 011).
40
+
41
+ BEGIN;
42
+
43
+ ALTER TABLE platform_identity_accounts
44
+ ADD COLUMN IF NOT EXISTS account_visibility text NOT NULL DEFAULT 'public',
45
+ ADD COLUMN IF NOT EXISTS recruiter_discoverable boolean;
46
+
47
+ ALTER TABLE platform_identity_accounts
48
+ DROP CONSTRAINT IF EXISTS platform_identity_accounts_account_visibility_check;
49
+ ALTER TABLE platform_identity_accounts
50
+ ADD CONSTRAINT platform_identity_accounts_account_visibility_check
51
+ CHECK (account_visibility IN ('public', 'private'));
52
+
53
+ COMMENT ON COLUMN platform_identity_accounts.account_visibility IS
54
+ 'Existence axis (spec D1): public|private. Private means the public profile read answers 404 to EVERYONE including its owner (the owner sees their page through the own-scope side door instead), and the account is absent from search, leaderboards and the sitemap. Independent of hide_stats, which suppresses numbers without touching addressability.';
55
+
56
+ COMMENT ON COLUMN platform_identity_accounts.recruiter_discoverable IS
57
+ 'Recruiting reach (spec D1), TRI-STATE: NULL = follow visibility. Effective value is COALESCE(recruiter_discoverable, account_visibility = ''public'') — opt-out for public accounts, opt-in for private ones. An explicit true on a private account is exactly the D2 "private, discoverable" row: the recruiter sees the D3 sliver and nothing else. hide_stats still beats it (a hidden account''s sliver is name-only).';
58
+
59
+ COMMIT;
@@ -150,6 +150,7 @@ async function getAccountByLogin(githubLogin, { pool = defaultPool } = {}) {
150
150
  async function getAccount(githubId, { pool = defaultPool } = {}) {
151
151
  const { rows } = await pool.query(
152
152
  `SELECT id, github_id, github_login, display_name, avatar_url, profile_state, hide_stats,
153
+ account_visibility, recruiter_discoverable,
153
154
  handle, previous_handle, handle_changed_at, bio, links, display_name_source,
154
155
  created_at, updated_at
155
156
  FROM platform_identity_accounts WHERE github_id = $1`,
@@ -825,7 +826,8 @@ async function setAccountHideStats({ githubId, githubLogin, displayName, avatarU
825
826
  ON CONFLICT (github_id) DO UPDATE SET
826
827
  hide_stats = EXCLUDED.hide_stats,
827
828
  updated_at = now()
828
- RETURNING github_id, github_login, profile_state, hide_stats, handle`,
829
+ RETURNING github_id, github_login, profile_state, hide_stats,
830
+ account_visibility, recruiter_discoverable, handle`,
829
831
  [githubId, githubLogin, displayName ?? null, avatarUrl ?? null, !!hide],
830
832
  );
831
833
  const row = rows[0];
@@ -835,6 +837,90 @@ async function setAccountHideStats({ githubId, githubLogin, displayName, avatarU
835
837
  return row;
836
838
  }
837
839
 
840
+ // -------------------------------------------------------------------------
841
+ // Account privacy: visibility + recruiter reach (task 1002966 — privacy spec D1)
842
+ // -------------------------------------------------------------------------
843
+
844
+ // The two stored values of account_visibility (spec D1's EXISTENCE axis).
845
+ // 'private' means the public profile read 404s to everyone — including the
846
+ // owner, who reaches their own page through the own-scope side door instead.
847
+ const ACCOUNT_VISIBILITY = ['public', 'private'];
848
+
849
+ // isRecruiterDiscoverable(account) — THE effective-recruiter-reach predicate
850
+ // (privacy spec D1). One definition, so every reader answers "may a recruiting
851
+ // surface see this account at all?" the same way; task 1002967 adopts this
852
+ // rather than re-deriving it, and nothing else should spell the COALESCE out.
853
+ //
854
+ // The JS twin of COALESCE(recruiter_discoverable, account_visibility='public').
855
+ // The tri-state is the point: NULL = follow visibility (opt-OUT for a public
856
+ // account, opt-IN for a private one), an explicit boolean overrides — an
857
+ // explicit true on a PRIVATE account is exactly the spec's D2 "private,
858
+ // discoverable" row, which is what the recruiter sliver exists to serve.
859
+ //
860
+ // NOT a complete gate on its own. profile_state='active' still floors every
861
+ // recruiter surface (a provisional account is never visible), and hide_stats
862
+ // still empties the sliver down to name-only — both stay the caller's to apply.
863
+ // Fail-closed on a missing/absent account.
864
+ function isRecruiterDiscoverable(account) {
865
+ if (!account) return false;
866
+ const explicit = account.recruiter_discoverable;
867
+ if (typeof explicit === 'boolean') return explicit;
868
+ return account.account_visibility === 'public';
869
+ }
870
+
871
+ // The own-scoped privacy WRITE (task 1002966) — callers key this by the
872
+ // AUTHENTICATED session's github_id, never a caller-named one. Validates here,
873
+ // next to the data, then upserts on the setAccountProfileFields pattern: the
874
+ // INSERT branch creates a fail-closed provisional account (a hub builder who
875
+ // never federated can still set their privacy ahead of time); the UPDATE branch
876
+ // touches ONLY the flags the caller sent — never identity re-sync, never
877
+ // profile_state (activation stays /sso/authorize's alone, D3), never hide_stats
878
+ // (which keeps its own writer, setAccountHideStats).
879
+ //
880
+ // recruiter_discoverable accepts null on purpose: that is the caller RESETTING
881
+ // the flag to "follow visibility", not an omitted field.
882
+ async function setAccountPrivacy({ githubId, githubLogin, displayName, avatarUrl, fields = {} }, { pool = defaultPool } = {}) {
883
+ if (!isNumericGithubId(githubId)) return { error: 'no_account' };
884
+ const setVisibility = 'account_visibility' in fields;
885
+ const setDiscoverable = 'recruiter_discoverable' in fields;
886
+ if (!setVisibility && !setDiscoverable) return { error: 'no_fields' };
887
+ if (setVisibility && !ACCOUNT_VISIBILITY.includes(fields.account_visibility)) {
888
+ return { error: 'invalid_account_visibility', allowed: ACCOUNT_VISIBILITY };
889
+ }
890
+ if (setDiscoverable && fields.recruiter_discoverable !== null
891
+ && typeof fields.recruiter_discoverable !== 'boolean') {
892
+ return { error: 'invalid_recruiter_discoverable' };
893
+ }
894
+ const { rows } = await pool.query(
895
+ `INSERT INTO platform_identity_accounts
896
+ (github_id, github_login, display_name, avatar_url, account_visibility, recruiter_discoverable)
897
+ VALUES ($1, $2, $3, $4, $5, $6)
898
+ ON CONFLICT (github_id) DO UPDATE SET
899
+ account_visibility = CASE WHEN $7::boolean THEN EXCLUDED.account_visibility
900
+ ELSE platform_identity_accounts.account_visibility END,
901
+ recruiter_discoverable = CASE WHEN $8::boolean THEN EXCLUDED.recruiter_discoverable
902
+ ELSE platform_identity_accounts.recruiter_discoverable END,
903
+ updated_at = now()
904
+ RETURNING github_id, github_login, profile_state, hide_stats,
905
+ account_visibility, recruiter_discoverable, handle`,
906
+ [
907
+ githubId,
908
+ githubLogin,
909
+ displayName ?? null,
910
+ avatarUrl ?? null,
911
+ setVisibility ? fields.account_visibility : 'public',
912
+ setDiscoverable ? fields.recruiter_discoverable : null,
913
+ setVisibility,
914
+ setDiscoverable,
915
+ ],
916
+ );
917
+ const row = rows[0];
918
+ // This upsert can CREATE the account row — seed the handle then, same as
919
+ // upsertAccount / setAccountHideStats.
920
+ if (row && !row.handle) row.handle = await ensureHandle({ githubId, githubLogin }, { pool });
921
+ return row;
922
+ }
923
+
838
924
  // -------------------------------------------------------------------------
839
925
  // Builder handle (P01, task 1002797 — profile-page spec Decision 3, ADR 0171 D8)
840
926
  // -------------------------------------------------------------------------
@@ -1295,6 +1381,10 @@ module.exports = {
1295
1381
  getPublicProfileExtras,
1296
1382
  getOwnProfileExtras,
1297
1383
  setAccountHideStats,
1384
+ // account privacy: visibility + recruiter reach (privacy spec D1)
1385
+ ACCOUNT_VISIBILITY,
1386
+ isRecruiterDiscoverable,
1387
+ setAccountPrivacy,
1298
1388
  // builder handle (P01)
1299
1389
  RESERVED_HANDLES,
1300
1390
  HANDLE_RENAME_WINDOW_MS,
@@ -3,16 +3,23 @@
3
3
  // endpoints behind a live session; both key on req.builder.github_id — never a
4
4
  // caller-named id — so a builder can only ever read or flip their own flag.
5
5
  //
6
- // GET /me/cross-project — the Settings self view: the hide flag + consent
7
- // state + the builder's own rollup, served regardless
8
- // of flags (getOwnProfileExtras — a hidden builder
6
+ // GET /me/cross-project — the Settings self view: the privacy flags (hide,
7
+ // visibility, recruiter reach + its one derived
8
+ // effective answer) + consent state + the builder's
9
+ // own rollup, served regardless of flags
10
+ // (getOwnProfileExtras — a hidden builder
9
11
  // inspects their numbers HERE, not on the public
10
12
  // route, which stays viewer-independent).
11
- // PATCH /me/cross-project — flip hide_stats. Takes effect immediately on every
12
- // public surface (profile cross_project, /people,
13
- // /scouting): enforcement lives in the module's read
14
- // functions (D2) and the hub profile response is
15
- // no-store (D5), so there is no cache to outwait.
13
+ // PATCH /me/cross-project — the privacy write: hide_stats (ADR 0171 D4) plus
14
+ // account_visibility + recruiter_discoverable (task
15
+ // 1002966, privacy spec D1). hide_stats takes effect
16
+ // immediately on every public surface (profile
17
+ // cross_project, /people, /scouting): enforcement
18
+ // lives in the module's read functions (D2) and the
19
+ // hub profile response is no-store (D5), so there is
20
+ // no cache to outwait. The other two are STORED here
21
+ // and read by the predicates task 1002967 wires into
22
+ // the read paths.
16
23
  // PATCH /me/handle — rename the caller's own handle (P01, task 1002797;
17
24
  // spec Decision 3). Low rate limit, previous handle
18
25
  // kept for the 301 window.
@@ -36,13 +43,18 @@ const { pool, requireBuilder, validateOrRespond } = api;
36
43
  module.exports = function profileRoutes() {
37
44
  const router = express.Router();
38
45
 
39
- // rank: any-builder — the caller's OWN cross-project record + hide flag (own data).
46
+ // rank: any-builder — the caller's OWN cross-project record + privacy flags (own data).
40
47
  router.get('/me/cross-project', requireBuilder, async (req, res) => {
41
48
  try {
42
49
  // Core builders include system:* principals whose github_id is not numeric;
43
50
  // they have no platform identity — answer the empty shape, not a 500.
44
51
  if (!pi.isNumericGithubId(req.builder.github_id)) {
45
- return res.json({ hide_stats: false, profile_state: null, handle: null, display_name: null, bio: null, links: null, cross_project: null });
52
+ return res.json({
53
+ hide_stats: false, profile_state: null, handle: null, display_name: null,
54
+ bio: null, links: null, cross_project: null,
55
+ account_visibility: 'public', recruiter_discoverable: null,
56
+ recruiter_discoverable_effective: false,
57
+ });
46
58
  }
47
59
  const [acct, extras] = await Promise.all([
48
60
  pi.getAccount(req.builder.github_id, { pool }),
@@ -50,6 +62,11 @@ module.exports = function profileRoutes() {
50
62
  ]);
51
63
  res.json({
52
64
  hide_stats: !!(acct && acct.hide_stats),
65
+ // The two privacy flags (task 1002966, spec D1) + the ONE derived answer
66
+ // the toggle renders, so the client never re-derives the COALESCE.
67
+ account_visibility: (acct && acct.account_visibility) || 'public',
68
+ recruiter_discoverable: acct ? (acct.recruiter_discoverable ?? null) : null,
69
+ recruiter_discoverable_effective: pi.isRecruiterDiscoverable(acct),
53
70
  profile_state: acct ? acct.profile_state : null,
54
71
  handle: (acct && acct.handle) || null,
55
72
  // P02: the account-global profile fields, for the Settings form (P04).
@@ -64,24 +81,58 @@ module.exports = function profileRoutes() {
64
81
  }
65
82
  });
66
83
 
67
- // rank: any-builder — flip the caller's OWN hide flag (C2's self-serve opt-out;
68
- // own data, keyed on the authenticated github_id).
84
+ // rank: any-builder — flip the caller's OWN privacy flags (C2's self-serve
85
+ // opt-out plus the task-1002966 pair; own data, keyed on the authenticated
86
+ // github_id — a builder can never name another account here). Every field is
87
+ // optional and at least one is required, so a client sends only what changed;
88
+ // recruiter_discoverable accepts an explicit null (reset to "follow
89
+ // visibility"), which is why it is checked with `in` rather than truthiness.
69
90
  router.patch('/me/cross-project', requireBuilder, async (req, res) => {
70
91
  if (validateOrRespond(req, res, {
71
- hide_stats: { required: true, type: 'boolean' },
92
+ hide_stats: { type: 'boolean' },
93
+ account_visibility: { type: 'string', enum: pi.ACCOUNT_VISIBILITY },
94
+ recruiter_discoverable: { type: 'boolean' },
72
95
  })) return;
96
+ const body = req.body || {};
97
+ const fields = {};
98
+ for (const k of ['account_visibility', 'recruiter_discoverable']) {
99
+ if (k in body) fields[k] = body[k];
100
+ }
101
+ const setsHide = 'hide_stats' in body;
102
+ if (!setsHide && !Object.keys(fields).length) {
103
+ return res.fail('no_fields', { status: 400, message: 'send at least one of hide_stats, account_visibility, recruiter_discoverable' });
104
+ }
73
105
  try {
74
106
  if (!pi.isNumericGithubId(req.builder.github_id)) {
75
107
  return res.fail('no_platform_identity', 400);
76
108
  }
77
- const row = await pi.setAccountHideStats({
109
+ const who = {
78
110
  githubId: req.builder.github_id,
79
111
  githubLogin: req.builder.github_login,
80
112
  displayName: req.builder.display_name,
81
113
  avatarUrl: req.builder.avatar_url,
82
- hide: req.body.hide_stats,
83
- }, { pool });
84
- res.json({ hide_stats: !!(row && row.hide_stats) });
114
+ };
115
+ let row = null;
116
+ if (setsHide) row = await pi.setAccountHideStats({ ...who, hide: body.hide_stats }, { pool });
117
+ if (Object.keys(fields).length) {
118
+ const out = await pi.setAccountPrivacy({ ...who, fields }, { pool });
119
+ if (out.error === 'invalid_account_visibility') {
120
+ return res.fail('invalid_account_visibility', { status: 400, message: `account_visibility is one of ${pi.ACCOUNT_VISIBILITY.join(', ')}` });
121
+ }
122
+ if (out.error === 'invalid_recruiter_discoverable') {
123
+ return res.fail('invalid_recruiter_discoverable', { status: 400, message: 'recruiter_discoverable is true, false, or null (follow visibility)' });
124
+ }
125
+ if (out.error) {
126
+ return res.fail('cross_project_write_failed', { status: 500, message: 'internal error' });
127
+ }
128
+ row = out;
129
+ }
130
+ res.json({
131
+ hide_stats: !!(row && row.hide_stats),
132
+ account_visibility: (row && row.account_visibility) || 'public',
133
+ recruiter_discoverable: row ? (row.recruiter_discoverable ?? null) : null,
134
+ recruiter_discoverable_effective: pi.isRecruiterDiscoverable(row),
135
+ });
85
136
  } catch (err) {
86
137
  console.error('[platform-identity] PATCH /me/cross-project', err);
87
138
  res.fail('cross_project_write_failed', { status: 500, message: 'internal error' });
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.412",
3
+ "version": "1.19.413",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.412",
9
+ "version": "1.19.413",
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.412",
3
+ "version": "1.19.413",
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",
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.413'; // 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');