@bongos/core 1.19.417 → 1.19.419

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/.bongos-core.json +87 -32
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +22 -0
  4. package/clients/bongos-client/index.cjs +22 -0
  5. package/clients/bongos-client/index.d.ts +33 -0
  6. package/clients/bongos-client/index.mjs +22 -0
  7. package/docs/adr/0206-own-scoped-session-revocation-no-absolute-ceiling.md +59 -0
  8. package/docs/adr/0207-connections-implementation-departures-and-honest-limits.md +90 -0
  9. package/docs/adr/README.md +2 -0
  10. package/docs/api/openapi.json +599 -3
  11. package/docs/api-reference.md +17 -2
  12. package/docs/architecture.md +1 -0
  13. package/docs/copy-inventory.md +51 -36
  14. package/docs/copy-registry.json +175 -40
  15. package/docs/file-map.md +1 -0
  16. package/docs/module-api-changelog.md +4 -0
  17. package/modules/dev-box/app/src/vendor/bongos-client.cjs +22 -0
  18. package/modules/hall-ui/public/settings-sessions.js +119 -0
  19. package/modules/hall-ui/public/settings.css +25 -0
  20. package/modules/hall-ui/public/settings.html +22 -0
  21. package/modules/platform-identity/account-visibility.js +58 -0
  22. package/modules/platform-identity/connections.js +490 -0
  23. package/modules/platform-identity/migrations/platform_identity_015_connections.sql +78 -0
  24. package/modules/platform-identity/module.json +1 -1
  25. package/modules/platform-identity/routes/connections.js +236 -0
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/src/bongos/db.js +46 -0
  29. package/src/bongos/route-rank-check.js +10 -0
  30. package/src/bongos/routes/my-sessions.js +128 -0
  31. package/src/bongos/routes.js +5 -0
  32. package/src/module-api.js +1 -1
  33. package/tests/connections_api.mjs +568 -0
  34. package/tests/connections_ownership.mjs +313 -0
  35. package/tests/own_scope_guard.mjs +10 -0
  36. package/tests/own_session_revoke.mjs +410 -0
@@ -0,0 +1,90 @@
1
+ # ADR 0207 — Building the connection graph: three departures from ADR 0202, and the three limits it is honest about
2
+
3
+ - **Status:** accepted
4
+ - **Date:** 2026-08-29
5
+ - **Task:** [task 1002295](https://cloudbongos.com/builders#/task/1002295) (goal 1000045 — Public builder profiles, criterion C6)
6
+ - **Amends:** [ADR 0202](<redacted>.md) (the design of record — this ADR does not replace any decision there, it records where the build had to choose and what the build cannot promise)
7
+ - **Relates to:** ADR 0016 (a connection is never authority), ADR 0171 D4 (the leak-by-subtraction ban), ADR 0182 (a predicate written once as JS and as a SQL fragment), ADR 0083 §5 (module-owned, namespaced migrations), ADR 0206 (the own-scope guard the connection surface joins)
8
+
9
+ > ADR **0205** and **0206** were taken by sibling sessions in flight; this ADR takes 0207 rather than race them (the ADR 0199 / 0202 precedent).
10
+
11
+ ## Context
12
+
13
+ ADR 0202 decided the connection graph completely enough to build from, which is what a design ADR should do. Building it surfaced three places where the decision's *mechanism* could not be implemented as literally written without losing the *property* the decision was actually about, and three places where the shipped code cannot deliver as much as a casual reading of 0202 would suggest.
14
+
15
+ Both kinds are worth writing down. A departure nobody records reads later as a bug. A limit nobody records reads later as a guarantee — and this feature's limits are all privacy-shaped, which is exactly the category where a false guarantee is expensive.
16
+
17
+ None of this changes what 0202 decided. Every decision below is downstream of one of its four.
18
+
19
+ ## Decision
20
+
21
+ ### D1 — Level-wise CTEs, not `WITH RECURSIVE`
22
+
23
+ ADR 0202 D3 says "a recursive CTE seeded at the viewer, hard-capped at depth 3 with a per-level fan-out cap and a deterministic frontier order". **We implement the semantics and not the mechanism**, because a recursive term in PostgreSQL cannot carry either of the two properties D3 was actually deciding: you cannot apply a `LIMIT` to a single level of a recursive term, and you cannot order a level before truncating it. A recursive implementation would therefore have had a *depth* bound and no *fan-out* bound — the half of D3 that matters for a hub-heavy account.
24
+
25
+ `degreeBetween` instead runs level-wise CTEs, staged exactly as D3 describes the three shapes: one query answers 1st and 2nd (and yields the mutual list free from the intersection), and a second runs only when the first found nothing. Each level is `ORDER BY gid LIMIT $n` with the cap bound as a parameter, so a truncation is reproducible rather than a different answer each call — which was D3's stated reason for a deterministic frontier order.
26
+
27
+ **Consequence for readers:** `tests/connections_api.mjs` asserts `RECURSIVE` appears in neither query. That is not a style rule; it is the check that nobody "fixes" this back toward the ADR's wording and loses the per-level cap.
28
+
29
+ ### D2 — 1st degree is an unbounded pair-index probe, never a scan of the capped frontier
30
+
31
+ D3's own words: "1st = one index hit on the accepted-pair index." The first implementation read `is_first` off the already-materialised `v1` frontier instead, because it was there — which is wrong the moment a builder passes `FANOUT_1` connections: a target whose `github_id` sorts past the 500th smallest would be reported **2nd** while simultaneously appearing in that viewer's own connections list.
32
+
33
+ The fix is to keep 1st degree on `platform_identity_connections_pair_accepted`, unbounded, exactly as decided. **A cap may only ever make an answer *less* specific (`null`), never *wrong*.** That sentence is the rule this decision exists to state, and it is why the dedicated partial index earns its write cost.
34
+
35
+ ### D3 — The shared visibility predicate lands in its own file, and task 1002967 adopts it
36
+
37
+ ADR 0202 D4 requires connections to consume "the single effective-visibility reader task 1002967 establishes… published in both forms the ADR 0182 precedent requires". At build time 1002967 had not shipped and `platform-identity.js` was at the 1500-line fitness ratchet with zero headroom, so there was nowhere to put it inside the file the ADR imagined.
38
+
39
+ The definition therefore lands alone in **`modules/platform-identity/account-visibility.js`** — `isAccountVisible(account)`, `accountVisibleSql(alias)`, and `accountActiveSql(alias)` (the consent-only floor the own-scope reads use). **Task 1002967 must ADOPT this file rather than write a second predicate**; a second one is the precise drift D4 exists to prevent, and this ADR is the pointer that keeps that from happening by accident.
40
+
41
+ **Rejected.** *Inlining the predicate in `connections.js`* — it is the drift, one release early. *Growing `platform-identity.js`* — the ratchet is shrink-only and the file is a different concern anyway.
42
+
43
+ ### D4 — Node visibility during traversal is viewer-relative, and asymmetric on purpose
44
+
45
+ 0202 says two things that only reconcile one way. D4 §2: a private account's connections "appear in its own-scope list and in the counterparty's own-scope list". The ADR's title: every degree label is computed "over the graph the viewer is allowed to see". So:
46
+
47
+ - **The viewer's OWN 1-hop floors on `profile_state = 'active'` alone.** Your own connections are yours to see whatever their visibility — otherwise a friend who goes private silently disappears from your own list, which §2 forbids.
48
+ - **Every DEEPER hop clears the full public predicate** (`accountVisibleSql`). A bridge you may not see yields `null`, never "3rd".
49
+
50
+ The asymmetry is the model, not an inconsistency: the first hop is a relationship you are party to, and the rest is other people's.
51
+
52
+ ### D5 — Cancel is a `DELETE`, and an addressee may not use it
53
+
54
+ 0202 names `cancelled` in the lifecycle but gives it no verb, and the task title does not list one. `DELETE /connections/:id` does both jobs, keyed on the row's status: hard-delete an `accepted` row (either party may end it) or `cancelled` a `pending` one (**the requester only**). An addressee calling `DELETE` on a pending row gets 404 and must use `/decline` — otherwise the 90-day cooldown is dodged by calling a decline a delete, which would quietly undo D2's whole point.
55
+
56
+ ### D6 — The create response is `201 { status: 'pending' }` and nothing else
57
+
58
+ D2 §2 requires a swallowed re-ask to return "the same body it returns for a request that was genuinely created". The cheapest way to guarantee that for all time is for the body to contain nothing that a real create has and a swallowed one could not fabricate — no id, no timestamps. Clients read the new row from `GET /connections/requests`.
59
+
60
+ ## The three limits, stated rather than implied
61
+
62
+ ### L1 — A swallowed re-ask is silent in the response and observable in the outbox
63
+
64
+ Nothing is written, so the request never appears in the requester's own outbox, while a genuine one does within milliseconds. The **response** discloses nothing (D2 §2 holds); the **absence afterwards** is observable by anyone who looks.
65
+
66
+ We accept this, because ADR 0202's own Consequences already do — *"a builder can send the same request twice a year and never learn why nothing happens"* — and the alternative is worse: writing a decoy row would put a lie in the database to protect a lie in the API, and a decoy is data neither party consented to. **What changes here is only the claim**: the code comments and this ADR say "the response is silent", not "the system is silent".
67
+
68
+ ### L2 — The target's 1-hop is capped too, and that truncation is deliberately not reported
69
+
70
+ `truncated` is derived from the **viewer's** counts only. If the *target* has more than `FANOUT_1` connections, a genuine mutual can fall outside the intersection and downgrade a real 2nd degree to `null` while `truncated` still reads `false`.
71
+
72
+ Reporting it would leak one bit about a third party's degree ("this account has ≥500 connections") on a read that is otherwise entirely about the viewer. **An occasional `null` is a better trade than a reliable side channel**, and `null` already means "out of network", which is what the UI must render anyway. D2 above removes the case that actually mattered (a 1st-degree connection mislabelled); what remains degrades in the safe direction.
73
+
74
+ ### L3 — The query count is bounded; the frontier's work is not bounded by `FANOUT_2`
75
+
76
+ `degreeBetween` issues at most two queries whatever the graph does, and the *output* of each level is capped — both are asserted against the SQL that really ran. But `ORDER BY … LIMIT` must materialise and sort the frontier before it truncates, so the 2-hop expansion's **work** is O(Σ degree over the viewer's 1-hop), not O(`FANOUT_2`).
77
+
78
+ ADR 0202 D3's envelope ("the cap converts that from a slow query into a *missing* 3rd-degree label") is therefore true of the response and not of the cost. At the hub's present scale — order 10² accounts — the difference is unobservable. **The revisit trigger is a measured p95 on this endpoint, not a guess**; the cheap next move if it bites is a `statement_timeout` on the far query that returns `degree: null, truncated: true`, which is the degradation D3 already chose.
79
+
80
+ ## Consequences
81
+
82
+ - ADR 0202 remains the design of record. This ADR is where a reader finds out why the code does not contain `WITH RECURSIVE`, why `account-visibility.js` exists as its own file, and which of the feature's silences are real.
83
+ - Three rules are now enforced by tests rather than by memory: a cap may not make an answer wrong (D2), the own-scope list floors on consent and not on the public predicate (D4), and a decline may not be resurrected from an expired request (D5's sibling — the expiry guard `acceptConnection` always had and `declineConnection` initially lacked).
84
+ - The connection surface joins `tests/own_scope_guard.mjs`'s inventory (ADR 0206's guard), so a future edit that lets a route take its subject from the request fails in CI rather than in review.
85
+ - `neighbours(githubId)` and `mutualCount(viewer, target)` — named in ADR 0202's "what this unblocks" — are **not** exported yet. The repo's knip ratchet counts an export with no caller as dead code, so they land with their first consumer ([task 1002309](https://cloudbongos.com/builders#/task/1002309)); the one-hop definition they must reuse is `neighboursSql` in `connections.js`, and re-deriving a direction-aware `WHERE` instead is the drift that file's header forbids.
86
+
87
+ ## Deferred (named, not silently dropped)
88
+
89
+ - **A per-builder cap on outbound requests.** The 90-day cooldown stops repeat asks to the *same* person; nothing stops one builder asking a hundred different people, and only the global write limiter is in the way. A real number is an owner-signed value on the same privacy matrix ADR 0202 D2 came from, so it is named here rather than invented by a builder.
90
+ - **A real-Postgres pass on the write path.** CI has no Postgres, so the shipped proofs are DB-free by necessity. The statement most worth executing once against a live PG 15 is `requestConnection`'s expire-then-insert: it depends on the `guard` CTE forcing the modifying CTE to drain before the INSERT's unique check, and on the just-expired tuple reading as dead to that check. Both were reasoned through and reviewed; neither has been observed.
@@ -293,5 +293,7 @@ This keeps the decision history honest and traceable.
293
293
  | 0203 | [**The doorway changelog leaves `src/module-api.js` — the CI auto-patch carrier appends to a markdown ledger instead** ([task 1003335](https://cloudbongos.com/builders#/task/1003335)). Amends [ADR 0161](<redacted>.md) §2. The `CORE_VERSION` release history lived as a comment block inside the doorway file, and ADR 0161's carrier appends two lines to it on EVERY merge-triggered release, pushed by `github-actions[bot]` straight to `main` — bypassing the `unit` gate it breaks. Release 1.19.407 took the file to 1501 lines, one over the `oversized_file_count` ratchet: `unit` went red on `main` and every open PR inherited it, self-inflicting and monotonic. Decision: the ledger moves to `docs/module-api-changelog.md` — markdown, which `fitness-ratchets.js` (`git ls-files '*.js'`) structurally never counts, so it can grow forever. All 1222 lines / 448 versions moved verbatim (only the `// ` marker stripped, kept in a fenced block so the hanging indent survives); the `const CORE_VERSION` literal and its line shape do NOT move, so every reader that greps it is untouched. `bump-version.js` transforms both files before writing either and refuses a half-applied bump; `publish.yml` stages both, pinned by `tests/bump_version.mjs`; the file is registered in `PUBLISH_ALLOWLIST` so the history keeps shipping inside `@bongos/core`; `src/module-api.js` keeps a pointer doc-comment where the block was. Rejected: trimming entries in place (buys one release), raising the baseline (out of policy per `config/fitness-baselines.json`'s own `_comment`), exempting the file from the ratchet.](<redacted>.md) | build pipeline / distribution |
294
294
  | 0204 | [**The apex is dark only; light is the builders' hall's** ([task 1003336](https://cloudbongos.com/builders#/task/1003336), goal 1000080). Supersedes in part [ADR 0196](<redacted>.md) (the light-mode half of the landing build) and the landing direction's 2026-08-27 pick of "light rebuilt to the dark standard". The owner, reviewing the planets: "only black or only white is a bit bland… leaning towards only dark mode for this page and keep the dark/light mode to be solely for the builder hall", confirmed as the WHOLE apex. Decision: `/`, `/projects`, `/privacy`, `/terms` and `/contact` pin `data-mode="dark"` on `<html>` — no pre-paint script, no `cb-color-mode`, no `?mode=`, no OS-preference read; the moon/sun glyph and its wiring leave both bars; the hall's own `hall-theme` switch is the platform's only light/dark control. The light tier of `world.css` / `cosmos.css` and the dark-twins rule STAY until the tier's own removal (the light-pair contrast tests keep measuring). The kit learns `"modes": ["dark"]` in a states file (validated; `render.js` takes it as the default). Rejected: a dark default with the toggle kept; deleting the light tier in the same diff; `--modes dark` on every kit call instead of the page's contract.](<redacted>.md) | apex / design system |
295
295
  | 0205 | [**A federated check-in may not speak for a person the hub never saw sign in: the membership carries the hub's own witness, and identity is never re-synced from client credentials** ([task 1003340](https://cloudbongos.com/builders#/task/1003340), goal 1000045 — closes security report 1000027, high, LIVE on the hub). `POST /sso/membership/check-in` authenticated the CALLER (`client_id` + `client_secret`) and took the SUBJECT (`github_id`, `github_login`, `avatar_url`) from the request body — client credentials say which PROJECT is calling, never who the person is. So any registered SSO client could (1) rewrite a real person's public `github_login` and `avatar_url`, which `upsertAccount`'s `ON CONFLICT` re-synced unconditionally and stored with no scheme check, and (2) mint the `<redacted>` row that [ADR 0171](<redacted>.md) D7's `membership_required` precheck reads — so the anti-impersonation gate on `POST /sso/activity/rollup` was satisfiable by the very call it was meant to gate, and forged credits/karma/rank reached a public profile. Decisions: (1) migration `<redacted>` adds `signin_verified_at`, written ONLY by `recordFederatedSignin` from the two moments the HUB itself authenticated a human for a client — `/sso/token` (a code minted under a live hub session, bound to the pair) and `/sso/device/poll` (a GitHub approval, audience bound from the typed origin) — so no request-body field reaches the statement and the column is evidence a caller structurally cannot write; (2) `upsertMembership` becomes `touchMembership`, an UPDATE gated on that column — a check-in can no longer CREATE a membership, answering `409 signin_required` and writing nothing, while signing in accepts a standing `pending` invite and leaves any other coarse kind to the instance to report; (3) a federated client gets insert-only `ensureAccount` (`DO NOTHING`), and `upsertAccount`'s identity re-sync belongs to hub-authenticated callers only — nothing is lost, because both of those already re-sync on every sign-in from GitHub's own answer; (4) the rollup gate reads `membershipBacksRollup` (non-pending AND witnessed), not row-existence; (5) `safeAvatarUrl` admits absolute https only, applied inside both account writers rather than at each render; (6) NO backfill — no predicate separates a legitimate check-in row from a forged one, and the first sign-in after deploy heals a real membership because the witness is stamped BEFORE the check-in and rollup that follow it, so a profile goes stale, never blank. No wire-format change: the request schemas are byte-identical and the ADR 0177 `membership_kind` window is untouched, so no federated instance upgrades. Proof `tests/sso_checkin_identity_spoof.mjs` (DB-free, in the default unit gate; 10 of 14 assertions fail against the pre-fix code, 2 controls prove the gate is not a wall). Rejected: carrying the hub assertion in the check-in body (strict validation would 400 every un-upgraded instance); ADR 0156-style source-guarding (answers "did the user set this", the wrong question when the writer is a third party); backfilling `signin_verified_at = joined_at`; a silent 200; retro-filtering `listMembershipsForAccount`.](<redacted>.md) | platform identity / security |
296
+ | 0206 | [**A leaked bearer's remedy is the account holder's own revoke, not an absolute session ceiling** ([task 1003341](https://cloudbongos.com/builders#/task/1003341), goal 1000045 — criterion `i-control-my-own-access-and-exposure`). `lookupSession` rewrites `expires_at = now() + 24h` on every authenticated request, so a CLI bearer in daily use never expires; if it leaks, the holder keeps it alive simply by using it. `POST /auth/logout` kills only the token presented on that same request — useless when the token you want dead is the one you no longer hold — and the two endpoints that can kill another session are gated on the admin `auth.session.revoke` atom, so the victim had to find an Archon, and a single-owner self-hosted instance may have none. Decision: ship the OWN-scoped remedy (`GET /me/sessions`, `POST /me/sessions/revoke`, `POST /me/sessions/revoke-all` on `requireBuilder` alone, every statement keyed on the caller's own id from the presented session, the token value never selected or returned, `revoke-all` deliberately including the requesting session) and deliberately NOT an absolute ceiling on the sliding refresh: a ceiling is unconditional (migration 035 already had to backfill a 24h grace so it would not kill the session running it), any cap loose enough not to be a daily nuisance leaves a leaked token live for weeks, and the visibility this task ships — `created_at` beside `last_used_at` in the list — is the compounding half of the fix. A demotion already sweeps sessions, and a deactivated builder is denied on the hot path, so the authority-drift case is closed by other means. Rejected: a `created_at + N` cap now; a shorter TTL; three more routes inside the already-largest `routes/auth.js` (a separate file is what lets the no-caller-named-owner rule be asserted over a WHOLE file); a `keep_current` flag; 404 on a session id that is not yours (it would confirm which ids exist); lowering the `auth.session.revoke` floor. Revisit for machine principals, a compliance maximum, or evidence that people do not prune what the list shows them.](<redacted>.md) | auth / account security |
297
+ | 0207 | [**Building the connection graph: three departures from ADR 0202, and the three limits it is honest about** ([task 1002295](https://cloudbongos.com/builders#/task/1002295), goal 1000045 criterion C6 — the build of the connections wave). ADR 0202 decided the graph; building it surfaced three places where the decision's MECHANISM could not be written literally without losing the PROPERTY it was about, and three places where the shipped code cannot promise as much as a casual reading suggests. Departures: (1) **level-wise CTEs, not `WITH RECURSIVE`** — a recursive term can carry neither a per-level fan-out cap nor an ordered per-level truncation, which are the two things D3 actually decided, so a recursive build would have had a depth bound and no fan-out bound; a test asserts `RECURSIVE` appears in neither query. (2) **1st degree is an unbounded pair-index probe**, never a scan of the capped frontier — reading it off the `FANOUT_1`-truncated 1-hop reports "2nd" for someone in the viewer's own connections list once they pass 500 connections, and the rule this states is that **a cap may only ever make an answer less specific (`null`), never wrong**. (3) The shared visibility predicate lands in its own file, `modules/platform-identity/account-visibility.js` (`isAccountVisible` / `accountVisibleSql` / `accountActiveSql`), because task 1002967 had not shipped and `platform-identity.js` sat at the size ratchet with zero headroom — **1002967 adopts it rather than writing a second predicate**. Also decided: node visibility during traversal is viewer-relative and asymmetric on purpose (your OWN 1-hop floors on consent alone so a friend who goes private does not vanish from your list; every deeper hop clears the public predicate); cancel is a `DELETE` an ADDRESSEE may not use, so the 90-day cooldown cannot be dodged by calling a decline a delete; and the create response is `201 { status: 'pending' }` and nothing else, so a swallowed re-ask has nothing to fabricate. Limits stated rather than implied: a swallowed re-ask is silent in the RESPONSE and observable by its ABSENCE from the requester's outbox (the claim is narrowed, not the code — a decoy row would be a lie in the database to protect a lie in the API); the TARGET's 1-hop is capped too and that truncation is deliberately NOT reported, because saying so leaks one bit about a third party's degree; and the query COUNT is bounded while the frontier's WORK is O(sum of degree over the 1-hop), since `ORDER BY … LIMIT` materialises before it truncates — the revisit trigger is a measured p95, and the cheap next move is a `statement_timeout` returning the `null` D3 already chose. Deferred and named: a per-builder cap on OUTBOUND requests (the cooldown stops repeat asks to one person, not one builder asking a hundred — a real number is owner-signed), and a real-Postgres pass on the expire-then-insert write path, which CI cannot run.](<redacted>.md) | platform identity / social graph / privacy |
296
298
 
297
299
  > ⚠️ **Numbering collisions are now machine-enforced, not narrated.** Twenty numbers are shared by two ADRs each — assigned in parallel sessions before anything checked. The files keep their filenames (renumbering would break every existing citation, and a number once assigned is never reused), and this table disambiguates each pair as `NNNN-a` / `NNNN-b`. The authoritative list is `LEGACY_DUPLICATE_ADRS` in [`scripts/gds/adr-namespace.js`](../../scripts/gds/adr-namespace.js), frozen by exact filename: a **new** collision — or a third file joining a legacy number, or a rename of either half — hard-fails the `unit` CI gate, as does an index row that points at no file or an ADR with no row. The list may only shrink. This paragraph used to narrate the collisions one by one and had fallen eight behind reality; see [ADR 0195](<redacted>.md) for why the check exists and why the pairs are grandfathered rather than renumbered.