@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.
- package/.bongos-core.json +30 -20
- package/clients/bongos-client/index.d.ts +4 -4
- package/docs/api/openapi.json +25 -10
- package/docs/api-reference.md +2 -2
- package/docs/copy-inventory.md +96 -92
- package/docs/copy-registry.json +147 -111
- package/docs/module-api-changelog.md +2 -0
- package/modules/hall-ui/public/settings.html +18 -0
- package/modules/hall-ui/public/settings.js +93 -10
- package/modules/platform-identity/migrations/platform_identity_012_account_privacy.sql +59 -0
- package/modules/platform-identity/platform-identity.js +91 -1
- package/modules/platform-identity/routes/profile.js +68 -17
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/account_privacy_flags.mjs +299 -0
|
@@ -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
|
-
//
|
|
587
|
-
|
|
588
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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 —
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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 +
|
|
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({
|
|
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
|
|
68
|
-
// own data, keyed on the authenticated
|
|
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: {
|
|
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
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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');
|