@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.
- package/.bongos-core.json +57 -42
- package/clients/bongos-client/index.d.ts +4 -4
- package/docs/adr/0204-the-apex-is-dark-only.md +36 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +25 -10
- package/docs/api-reference.md +2 -2
- package/docs/copy-inventory.md +405 -403
- package/docs/copy-registry.json +538 -520
- package/docs/design/landing-page-direction-v2.md +2 -0
- package/docs/design/projects-hub-direction-v2.md +4 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/ui-look-before-you-ship.md +4 -1
- 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/modules/ui-design/kit/lib.js +3 -1
- package/modules/ui-design/kit/render.js +5 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +3 -17
- package/public-landing/contact.html +2 -6
- package/public-landing/index.html +5 -44
- package/public-landing/index.states.json +3 -2
- package/public-landing/privacy.html +2 -6
- package/public-landing/projects.html +4 -41
- package/public-landing/projects.probes.json +0 -3
- package/public-landing/projects.states.json +3 -2
- package/public-landing/terms.html +2 -6
- package/scripts/gds/worktree.js +11 -0
- package/src/module-api.js +1 -1
- package/tests/account_privacy_flags.mjs +299 -0
- package/tests/landing_legal_pages.mjs +10 -0
- package/tests/landing_page.mjs +16 -19
- package/tests/projects_hub.mjs +1 -1
- package/tests/projects_hub_pre_uat.mjs +4 -3
- package/tests/ui_design_kit.mjs +10 -0
|
@@ -60,6 +60,8 @@ What the pick settles: the settled constraint (dark-first with a real, tested li
|
|
|
60
60
|
|
|
61
61
|
**2026-08-28, the second note (task 1003334).** Seeing the before/after of task 1003333, the owner found the chrome bongo pair sitting on the top-right sweeping form did not fit ("the random bongos on the blobs background"). Offered three treatments (remove it from the hero; keep it off the blob on plain black or paper; phones only), the owner picked **remove it from the hero**. The pair is no longer a hero object: `.mark`, its arrival and its tokens leave; it stays as the closing figure, the second project orb and the bar's mark. The sweeping form stays as a bare backdrop. Under 900 the masthead is the big blob alone, with the words keeping the 56vw the pair used to hold above them.
|
|
62
62
|
|
|
63
|
+
**2026-08-28, the third note — dark only (task 1003336, [ADR 0204](../adr/<redacted>.md)).** Reviewing the planets (task 1003319) with both grounds in front of them, the owner took option 4 of §5 after all, widened to the whole apex: "only black or only white is a bit bland, frankly I am leaning towards only dark mode for this page and keep the dark/light mode to be solely for the builder hall … we could invest into a cool space themed backdrop for the blobs." Built: `data-mode="dark"` fixed on `<html>` of every apex page, the pre-paint script and the persisted `cb-color-mode` gone, the moon/sun glyph and its wiring out of both bars, the kit rendering dark only. What the 2026-08-27 pick settled about light — the cutouts on paper under a contact shadow, the toggle staying on the landing — no longer describes the apex; the cutouts remain the closing figure and the planets' masks. The light tier of `world.css` stays until its own removal. The space backdrop is the next task (1003337).
|
|
64
|
+
|
|
63
65
|
## 7. Shot list: real imagery still owed
|
|
64
66
|
|
|
65
67
|
No version left a placeholder slot: every image on the three pages is a real plate (the round-2 chrome set, the single blob, the two bongo pairs, the two new blob cutouts) or the real Verro Labs medallion, and the proof is the ledger rendered as page content, so no hall or hub capture is used. What the build will still owe:
|
|
@@ -170,3 +170,7 @@ Three mastheads, rendered dark and light at 1440 / 390 / 320 (ALL CLEAN) and mea
|
|
|
170
170
|
**What was built.** In `cosmos.css` the orb's disc is a masked colour layer over a cutout (`.disc::before`: the plate as both background and mask, `background-blend-mode` by style), sized `--d` by the object's own aspect; `[data-shape]` carries the three crops, `[data-style]` the three blends; the relationship ring is a circle behind the object (a mask clips a box-shadow away), the search spotlight an accent drop-shadow on the disc's filter after the contact shadow (dark's `--orb-shadow` is `opacity(1)`, a no-op, so the two compose). The starfield, its 3.2s twinkle, the 74px gravity grid and `--star-display` are gone; the labels, the badge, the hint and the hover card moved onto the world's type (hairline pill, label face, no accent on a fact, no shadow). In `projects.html` `ORB_TEMPLATES` walks beside `ORB_ACCENTS` in `assignAccents` (consecutive positions change shape and style; nine against five repeat every forty-five projects), `templateOf()` prefers a curated name, an owned-only entry walks by its slug, the orb carries `data-template` / `data-shape` / `data-style`, the physics measures the disc's own height, and the slot widths grew a quarter (a cutout's silhouette reads smaller than a filled disc of the same box). The platform's own orb is the pair in chrome. On the catalog side migration `<redacted>` adds the nullable `template` column with its nine-value CHECK, `upsertProject` carries it COALESCE-preserved, `POST /projects` validates it against `PROJECT_TEMPLATES`, and the public `/projects/featured` projection returns it. The kit's fixture gives Example a curated `bead-wash` and the `in-map` state reads it back off the orb.
|
|
171
171
|
|
|
172
172
|
**Not done, on purpose:** no owner-facing picker — a template is set through the curation route today, the way `featured` is; whether a project's owner chooses their own planet from the manage view is the owner's call. The manage view's small `.orb` spheres keep their old look (the scope named the sky's `a.worldOrb` only).
|
|
173
|
+
|
|
174
|
+
## 12. Dark only (task 1003336)
|
|
175
|
+
|
|
176
|
+
**Date:** 2026-08-28 (decided), 2026-08-29 (built) · the owner's call on the planets review (§11): the apex renders dark only and the light/dark switch is the builders' hall's alone — [ADR 0204](../adr/<redacted>.md). On `/projects`: `data-mode="dark"` is fixed on `<html>`, the pre-paint script is gone, the `#modeToggle` glyph and its `resolvedMode` / `paintModeToggle` wiring leave the bar (Projects, then the chip), the `.modeBtn` rules and the icon-swap twins leave `cosmos.css`, the mode-toggle probe leaves `projects.probes.json`, and `projects.states.json` declares `"modes": ["dark"]` so the kit renders the hub's twelve states at three widths in one mode (48 shots, ALL CLEAN; probes 31/31). The light tier of `cosmos.css` stays until its own removal. Why first: the space backdrop for the field (task 1003337) is a black-ground plate; one mode is what lets the field commit to it.
|
|
@@ -1261,5 +1261,9 @@ 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)
|
|
1266
|
+
1.19.414 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1267
|
+
landed since 1.19.413 with no explicit bump. run 33260596712. (task 1002620)
|
|
1264
1268
|
---------------------------------------------------------------------------
|
|
1265
1269
|
```
|
|
@@ -56,7 +56,8 @@ Then a state's own `expect` (a band open, a dialog present, a field's value, a c
|
|
|
56
56
|
{
|
|
57
57
|
"page": "/projects",
|
|
58
58
|
"surface": "public-landing",
|
|
59
|
-
"
|
|
59
|
+
"modes": ["dark"],
|
|
60
|
+
"modeQuery": false,
|
|
60
61
|
"ignoreRequests": ["/api/(gds|bongos)/me$", "/my-projects"],
|
|
61
62
|
"ignoreConsole": ["\\b401\\b"],
|
|
62
63
|
"ignoreControls": [".skip", ".vh"],
|
|
@@ -69,6 +70,8 @@ Then a state's own `expect` (a band open, a dialog present, a field's value, a c
|
|
|
69
70
|
}
|
|
70
71
|
```
|
|
71
72
|
|
|
73
|
+
`modes` names the modes the page renders (default both). A page that pins one mode — the apex pins `data-mode="dark"` on `<html>` since task 1003336 — declares it here so a plain `--page` render never walks a mode the page cannot show; `--modes` on the command line still overrides. `modeQuery: false` says no query walks a mode.
|
|
74
|
+
|
|
72
75
|
Actions: `click`, `focus`, `press`, `fill`, `wait`, `clickAt x y`, `clickIfVisible`, `dispatchClick`, `check`, `back`, `forward`. Expectations: `expanded`, `dialog`, `visible[]`, `hidden[]`, `open`, `closed`, `activeId`, `activeText`, `expandedIds[]`, `param{}`, `attr[[sel,name,value]]`, `text[[sel,value]]`, `href[[sel,value]]`, `count[[sel,n]]`, `value[[sel,v]]`, `hasClass[[sel,class,bool]]`, `checked`, `noOverflow`, `top[[sel,px]]` (an element's viewport top — assert it closed, open a band, assert it again: the proof an overlay did not push the page). The kit validates the file before it opens a browser and names every problem.
|
|
73
76
|
|
|
74
77
|
The worked examples: `public-landing/projects.states.json` (the hub's thirteen), `public-landing/index.states.json` (the landing's four), `modules/hall-ui/public/ranks.states.json` (a second surface under its own prefix — it renders **red**, deliberately: the hall predates the 24px floor and Ranks has no `<h1>`, and the states file declares the shell's un-fixtured feeds rather than hiding the findings; idea 1000863 carries them).
|
|
@@ -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' });
|
|
@@ -235,7 +235,8 @@ function reducedMotionProblems({ running = [], frames = 0 } = {}) {
|
|
|
235
235
|
|
|
236
236
|
// ── the states file ───────────────────────────────────────────────────────────
|
|
237
237
|
// { "page": "/projects", "surface"?: "public-landing", "stub"?: { prefix?, child? },
|
|
238
|
-
// "
|
|
238
|
+
// "modes"?: ["dark" | "light", …] (the modes the page renders; default both — a page that pins
|
|
239
|
+
// one mode declares it here), "modeQuery"?: "mode" | false, "ignoreRequests"?: [regex strings], "ignoreConsole"?: [...],
|
|
239
240
|
// "ignoreControls"?: [selectors], "states": { name: { url?, auth?, actions?, expect? } },
|
|
240
241
|
// "reducedMotion"?: { auth?, url?, actions? } }
|
|
241
242
|
function validateStatesFile(json) {
|
|
@@ -245,6 +246,7 @@ function validateStatesFile(json) {
|
|
|
245
246
|
if (json.surface !== undefined && typeof json.surface !== 'string') errors.push('surface: a string (a checkout dir or a module key)');
|
|
246
247
|
errors.push(...validateStub(json.stub, 'stub'));
|
|
247
248
|
if (json.modeQuery !== undefined && json.modeQuery !== false && typeof json.modeQuery !== 'string') errors.push('modeQuery: the query param the page reads for its colour mode (default "mode"), or false');
|
|
249
|
+
if (json.modes !== undefined && !(Array.isArray(json.modes) && json.modes.length && json.modes.every((m) => MODES.includes(m)))) errors.push('modes: a non-empty array of "dark" | "light" — the modes the page renders (default both); a page that pins one mode declares it here');
|
|
248
250
|
for (const k of ['ignoreRequests', 'ignoreConsole', 'ignoreControls']) if (json[k] !== undefined && !(Array.isArray(json[k]) && json[k].every((s) => typeof s === 'string'))) errors.push(`${k}: an array of strings`);
|
|
249
251
|
const states = json.states;
|
|
250
252
|
if (!states || typeof states !== 'object' || Array.isArray(states) || !Object.keys(states).length) errors.push('states: required — a non-empty object of name → { url?, auth?, actions?, expect? }');
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
// render.js — render a page's states and audit every render against the platform
|
|
4
4
|
// floors (task 1003320, ADR 0197). The states live in a JSON file BESIDE the
|
|
5
5
|
// surface (<dir>/<page>.states.json: name → url + actions + expectations); each
|
|
6
|
-
// is rendered
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// is rendered at 1440x900 / 390x844 / 320x700 in the modes the states file names
|
|
7
|
+
// (`"modes"`; both by default — the apex pins dark, task 1003336; `--modes` overrides)
|
|
8
|
+
// through two stubs this file starts itself (signed out, signed in), then a
|
|
9
|
+
// reduced-motion pass per mode. Writes the shots + a JSON report; exits non-zero on any problem.
|
|
9
10
|
//
|
|
10
11
|
// node modules/ui-design/kit/render.js --page /projects (public-landing/projects.states.json)
|
|
11
12
|
// node modules/ui-design/kit/render.js --page /builders/ranks --surface hall-ui
|
|
@@ -65,7 +66,7 @@ async function main() {
|
|
|
65
66
|
const args = process.argv.slice(2);
|
|
66
67
|
if (lib.hasFlag(args, '--help')) usage('');
|
|
67
68
|
const { file, json, surface } = locate(args);
|
|
68
|
-
const modes = lib.listArg(args, '--modes', [...lib.MODES]).filter((m) => lib.MODES.includes(m));
|
|
69
|
+
const modes = lib.listArg(args, '--modes', json.modes || [...lib.MODES]).filter((m) => lib.MODES.includes(m));
|
|
69
70
|
const sizes = lib.listArg(args, '--sizes', Object.keys(lib.SIZES)).map(Number).filter((w) => lib.SIZES[w]);
|
|
70
71
|
const only = lib.listArg(args, '--only', null);
|
|
71
72
|
const names = Object.keys(json.states).filter((n) => !only || only.includes(n));
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.414",
|
|
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.414",
|
|
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.414",
|
|
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",
|