@bongos/core 1.19.415 → 1.19.417

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 CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.415",
6
- "core_contract": "1.19.415",
7
- "source_commit": "6e1130920b6477c117c5b10a7f2754db87ef558a",
5
+ "core_version": "1.19.417",
6
+ "core_contract": "1.19.417",
7
+ "source_commit": "2ea84040287b365331cb8eb9278615dc4a0e5642",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-29T16:12:53.136Z",
9
+ "built_at": "2026-08-29T16:38:30.577Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 355,
12
+ "docs_redacted": 356,
13
13
  "agent_docs_stubbed": 23,
14
- "functional_verbatim": 1698,
14
+ "functional_verbatim": 1701,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2076,
20
- "tree_sha256": "992018f989d8c0b69b801d98f5bae82fb871101fb97f42f1dd43c3b4a63548cf",
19
+ "file_count": 2080,
20
+ "tree_sha256": "4d8a309a1363b0a74ea89df369c37d35b0af343cd3ed08d4361f8951165933b3",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -1524,10 +1524,15 @@
1524
1524
  "mode": "0000644",
1525
1525
  "sha256": "ac6a729436ff1e51414ccaf097b5e49c1360dd3bca1efd1ff73f38d4e5fca24d"
1526
1526
  },
1527
+ {
1528
+ "path": "docs/adr/0205-federated-checkin-needs-the-hubs-own-signin-witness.md",
1529
+ "mode": "0000644",
1530
+ "sha256": "dedf9285b041a0d4c54982a8372aca573e57eb3d89a5abf9381a922a8784ff7f"
1531
+ },
1527
1532
  {
1528
1533
  "path": "docs/adr/README.md",
1529
1534
  "mode": "0000644",
1530
- "sha256": "32bf8baf396e3191e46525c1c5aeda326c0f1f7f6a83359f6fc5f5b6c8dda04c"
1535
+ "sha256": "3d9a57147d8a7e8db350df79ecc457cf57bdadd77d08b41749dfa658c733c07c"
1531
1536
  },
1532
1537
  {
1533
1538
  "path": "docs/api-reference.md",
@@ -1537,7 +1542,7 @@
1537
1542
  {
1538
1543
  "path": "docs/api/openapi.json",
1539
1544
  "mode": "0000644",
1540
- "sha256": "4ae66f6126c2148d61f4bdbdcc7d06ca8966f2747f34e4b243f348545ce4e3aa"
1545
+ "sha256": "b0f8cab2aa5931ef9090bbee552ff00f3da37a86ce327225b11712f16cd25090"
1541
1546
  },
1542
1547
  {
1543
1548
  "path": "docs/architecture.md",
@@ -1902,7 +1907,7 @@
1902
1907
  {
1903
1908
  "path": "docs/file-map.md",
1904
1909
  "mode": "0000644",
1905
- "sha256": "0eda387feba9769e7bd939ec6072f2e562f54d896b56816272f351d8798cf125"
1910
+ "sha256": "76febc87dd3d8b6641673093df2efa7c699a33428e6a53bc1cf595e0f3daf558"
1906
1911
  },
1907
1912
  {
1908
1913
  "path": "docs/handoff-template.md",
@@ -1912,7 +1917,7 @@
1912
1917
  {
1913
1918
  "path": "docs/module-api-changelog.md",
1914
1919
  "mode": "0000644",
1915
- "sha256": "728e49741b202322cb1e86147fd1a063e9421d094e6fa39ebbf11afa216ab23e"
1920
+ "sha256": "402fcd99a486b88a9c7e0acccd85d95effbbe263dec1f917f7bfc1e07349f46c"
1916
1921
  },
1917
1922
  {
1918
1923
  "path": "docs/modules-contract.md",
@@ -4834,6 +4839,11 @@
4834
4839
  "mode": "0000644",
4835
4840
  "sha256": "50b993d4cdad3981b4ace6c60c4f9ee350076a2ee9b205b7fc7d80de255f492e"
4836
4841
  },
4842
+ {
4843
+ "path": "modules/platform-identity/migrations/platform_identity_014_membership_signin_proof.sql",
4844
+ "mode": "0000644",
4845
+ "sha256": "cf29832de04b2b31e1d36b5d595489bbbb09d14b12c86da7c5ac7557917475bf"
4846
+ },
4837
4847
  {
4838
4848
  "path": "modules/platform-identity/module.json",
4839
4849
  "mode": "0000644",
@@ -4842,7 +4852,7 @@
4842
4852
  {
4843
4853
  "path": "modules/platform-identity/platform-identity.js",
4844
4854
  "mode": "0000644",
4845
- "sha256": "b1b610069c8cc4db794b0a766f7cd608cffd0129c19f6548da86d75ffaa57687"
4855
+ "sha256": "16f86cd9b5a4b934b90a0332651b18c8bd8b1ccbfdbf0c5b23fffe50dbe8d8f8"
4846
4856
  },
4847
4857
  {
4848
4858
  "path": "modules/platform-identity/pollers/code-sweep.js",
@@ -4882,12 +4892,12 @@
4882
4892
  {
4883
4893
  "path": "modules/platform-identity/routes/sso.js",
4884
4894
  "mode": "0000644",
4885
- "sha256": "70e4d1d35964603ac40b459b9cb40a480bb9ec6aeb4cb6c0e93368fcc514f2dd"
4895
+ "sha256": "5e1ca2aed13b91e275cc30725b05498a4332931af6ef08194afc8aff58352316"
4886
4896
  },
4887
4897
  {
4888
4898
  "path": "modules/platform-identity/tests/platform-identity.mjs",
4889
4899
  "mode": "0000644",
4890
- "sha256": "aeeda004820071e63bce92d60a21fa831f72ee8ba449a41d93c6a108e19571e9"
4900
+ "sha256": "5403d391a22cffa58722923beee730db0bf4052ee933a84ab1e11fcbab9ea5e4"
4891
4901
  },
4892
4902
  {
4893
4903
  "path": "modules/platform-identity/ui/.gitkeep",
@@ -5422,12 +5432,12 @@
5422
5432
  {
5423
5433
  "path": "package-lock.json",
5424
5434
  "mode": "0000644",
5425
- "sha256": "5d5bd36ca85d6db8f0d37e036f6660c525eb07e49335f76d3fc23f33a4b56fe5"
5435
+ "sha256": "64e99f4a6c987054fa4827a376eace859767c9f5edc34676a60b5e0e19cb1581"
5426
5436
  },
5427
5437
  {
5428
5438
  "path": "package.json",
5429
5439
  "mode": "0000644",
5430
- "sha256": "b1bf9ff0d5e9155030c651fa5eea3e1ce81a683394b7090c5d48b1a87fc79ada"
5440
+ "sha256": "29aa7a9418a4755ab9c7e20a439d84f12ea48407851c6634ff9669c809940332"
5431
5441
  },
5432
5442
  {
5433
5443
  "path": "public-docs/index.html",
@@ -7542,7 +7552,7 @@
7542
7552
  {
7543
7553
  "path": "src/module-api.js",
7544
7554
  "mode": "0000644",
7545
- "sha256": "85ade7dd85598993897af78be6cee56b8476a8bb1762866b3e6fc9586bad3694"
7555
+ "sha256": "0dce01d81ecb3f709416d11f10605ac834dbdb5ed9faea3f9459ddfceaa8389d"
7546
7556
  },
7547
7557
  {
7548
7558
  "path": "src/module-loader/catalog.js",
@@ -8932,7 +8942,7 @@
8932
8942
  {
8933
8943
  "path": "tests/hub_recent_ships.mjs",
8934
8944
  "mode": "0000644",
8935
- "sha256": "9eff8102b1fce3f98ae2cc0f0fb7a640c3e13fba0d89814c2da3565445487f9e"
8945
+ "sha256": "53c7c8ad2de098d29c9010ed37e43779e8ceda4790018527c7edf45d54e56e80"
8936
8946
  },
8937
8947
  {
8938
8948
  "path": "tests/idea_capture_grades.mjs",
@@ -9227,7 +9237,7 @@
9227
9237
  {
9228
9238
  "path": "tests/memory_isolation.mjs",
9229
9239
  "mode": "0000644",
9230
- "sha256": "2982e61db8b1b3862dffa75d4cc3a9ce1a422b9070a9b84cc080247f4af1fa62"
9240
+ "sha256": "92d1c3d8343b0e2742c4fbbcfa3276a0cc99767229c4eb628a4dfc10a4181d67"
9231
9241
  },
9232
9242
  {
9233
9243
  "path": "tests/memory_map.mjs",
@@ -9449,6 +9459,11 @@
9449
9459
  "mode": "0000644",
9450
9460
  "sha256": "dac9d9891eac706432aff97ce35e27692064a2791dc1e546215c74e68b81e8a8"
9451
9461
  },
9462
+ {
9463
+ "path": "tests/own_scope_guard.mjs",
9464
+ "mode": "0000644",
9465
+ "sha256": "694002ebe39755fd01bdba57bf70e7077201b81a6938df92fff09b44027a29cc"
9466
+ },
9452
9467
  {
9453
9468
  "path": "tests/package_core.mjs",
9454
9469
  "mode": "0000644",
@@ -10149,6 +10164,11 @@
10149
10164
  "mode": "0000644",
10150
10165
  "sha256": "70228aa4be345cc09664b3b725e30c3a1e0eacf4c39573db1304fb219d3245e7"
10151
10166
  },
10167
+ {
10168
+ "path": "tests/sso_checkin_identity_spoof.mjs",
10169
+ "mode": "0000644",
10170
+ "sha256": "27bd2a87e001eec2bc58e13cfa58e53f88c73ebe7504b944c718182247d98ca7"
10171
+ },
10152
10172
  {
10153
10173
  "path": "tests/start_env_guard.mjs",
10154
10174
  "mode": "0000644",
@@ -0,0 +1,79 @@
1
+ # ADR 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
2
+
3
+ - **Status:** accepted
4
+ - **Date:** 2026-08-29
5
+ - **Task:** [task 1003340](https://cloudbongos.com/builders#/task/1003340) (goal 1000045 — public builder profiles + account security)
6
+ - **Closes:** security report 1000027 (high, LIVE on the hub)
7
+ - **Relates to:** ADR 0141 §1/§3/§4 (identity federated, authority local — the check-in and rollup wire contracts), ADR 0171 D7 (the rollup impersonation hardening this completes), ADR 0016 (the trust boundary), ADR 0083 §5 (module-owned, namespaced migrations), ADR 0156 / ADR 0177 (the `display_name` source guard and the `membership_kind` cross-version window this must not break)
8
+
9
+ ## Context
10
+
11
+ `POST /sso/membership/check-in` is how a federated instance tells the hub "this builder builds on my project". It authenticates with `client_id` + `client_secret` — the same constant-time check as `/sso/token` — and takes the **subject** (`github_id`, `github_login`, `avatar_url`) from the request body.
12
+
13
+ That is the whole defect, stated plainly: **client credentials say WHICH PROJECT is calling. They say nothing about WHO the `github_id` in the body is.** Any owner of any registered SSO client, holding only their own legitimate secret, could:
14
+
15
+ 1. **Rewrite a real person's public identity.** `upsertAccount`'s `ON CONFLICT (github_id) DO UPDATE` set `github_login = EXCLUDED.github_login` and `avatar_url = EXCLUDED.avatar_url` unconditionally on the victim's existing row. Both are what `<redacted>` and core `GET /builders/:id/profile` render, and `avatar_url` was stored with no scheme check at all. (`display_name` was already source-guarded by the ADR 0156 mirror; these two were not, deliberately — "neither is user-editable", which was true and beside the point once a *third party* could write them.)
16
+ 2. **Mint the row that unlocks a forged portfolio.** ADR 0171 D7 added a `membership_required` precheck to `POST /sso/activity/rollup` precisely to stop a client publishing credits/karma/rank for someone who does not build on its project. But the membership it demands was created by the *other* endpoint, on the caller's unproven say-so — so the gate was satisfiable by the very call it was meant to gate. Check in as the victim, then push arbitrary totals onto their public cross-project record. The same row also made the attacker's project appear in the victim's "My Projects".
17
+
18
+ D7 hardened the read side of a door whose key was being handed out at the other window.
19
+
20
+ The fact that makes a fix possible: **the hub is the identity provider.** Every federated sign-in passes through the hub itself, at one of exactly two places, and in both the pairing of *person* to *project* is the hub's own finding rather than a caller's claim:
21
+
22
+ - `POST /sso/token` — redeeming a single-use code that `/sso/authorize` minted under a **live hub session**, bound at mint time to `(client_id, github_id, redirect_uri)`;
23
+ - `POST /sso/device/poll` — a GitHub device approval, with the audience **bound at start from the typed origin** so the CLI never names the client (the ADR 0141 §1 confused-deputy fix).
24
+
25
+ And the instance sequences its calls: `src/bongos/auth-idp.js` runs the token exchange or assertion verification, *then* the check-in, *then* the rollup. A legitimate check-in is therefore always preceded, within the same sign-in, by a hub-authenticated moment.
26
+
27
+ ## Decision
28
+
29
+ ### D1 — The membership row carries `signin_verified_at`, and only the hub can stamp it
30
+
31
+ `<redacted>.sql` adds one nullable `timestamptz` to `<redacted>`. It is written by exactly one domain function, `recordFederatedSignin({ clientId, githubId })`, called from exactly the two hub routes above. **No request-body field reaches the statement** — the `client_id` is the authenticated client and the `github_id` comes from the redeemed code or GitHub's own device answer.
32
+
33
+ That is what makes it evidence rather than another self-report: it is a column the caller structurally cannot write.
34
+
35
+ ### D2 — A check-in may only UPDATE a witnessed row; it can no longer create one
36
+
37
+ `upsertMembership` is replaced by `touchMembership`, an `UPDATE … WHERE client_id = $1 AND github_id = $2 AND signin_verified_at IS NOT NULL`. No match → the route answers `409 signin_required` and writes nothing at all. `recordFederatedSignin` is now the only birth path for a membership row, so a project cannot claim a person the hub never saw arrive.
38
+
39
+ Signing in also **accepts a standing invitation**: a `pending` row (pre-seeded by `POST /projects/invite`) becomes `member` when its invitee actually signs in, and any other stored `membership_kind` is left alone — the coarse kind stays the reporting instance's to say, through `touchMembership`, exactly as ADR 0141 §3 intends.
40
+
41
+ The check-in also gains the numeric-`github_id` guard the rollup already had, so a `system:*`-shaped principal is refused before it can hit the `bigint` column.
42
+
43
+ ### D3 — A federated client gets an insert-only account writer
44
+
45
+ `ensureAccount` inserts the identity shell `ON CONFLICT (github_id) DO NOTHING` and returns the stored row. `upsertAccount` — whose conflict branch re-syncs identity — is now documented as belonging to hub-authenticated callers only (`/sso/authorize`, `/sso/device/poll`, the own-scoped profile route).
46
+
47
+ **Nothing is lost by removing the re-sync from check-in.** Both hub-authenticated paths already call `upsertAccount` on every single sign-in, from GitHub's own answer or the live hub session — so a renamed GitHub login and a changed avatar still land, on the pass where the hub actually knows whose they are. The check-in's identity write was pure redundancy that happened to be the attack surface.
48
+
49
+ ### D4 — The rollup gate reads the witness, not the row's existence
50
+
51
+ One predicate, `membershipBacksRollup(membership)`: non-`pending` **and** `signin_verified_at` present. The gate ADR 0171 D7 describes finally means what it says.
52
+
53
+ ### D5 — `avatar_url` is https-only, validated at the write boundary
54
+
55
+ `safeAvatarUrl` returns the value only when it parses as an absolute `https:` URL, and `null` otherwise — `http:`, `data:`, `javascript:`, `file:`, relative paths and malformed strings all collapse to null. It is applied inside **both** account writers rather than at each render, because the avatar is echoed into a public page and an `<img src>` from several surfaces and a per-render check is a rule someone eventually forgets. Every real GitHub avatar is already https, so nothing legitimate is refused.
56
+
57
+ ### D6 — No backfill, and that is the deliberate part
58
+
59
+ Existing membership rows carry no witness, and there is **no predicate that separates a legitimate 2026 check-in row from a forged one** — grandfathering them would preserve precisely the rows the report is about. So the column ships NULL for every existing row and the gates fail closed on them.
60
+
61
+ The cost is bounded to near-zero because of D1's own sequencing: the first federated sign-in after this deploys stamps the witness *before* the check-in and rollup that follow it, so a real builder heals on their next login with no operator step. Already-published activity rows are untouched, so a public profile goes **stale until that login, never blank**.
62
+
63
+ `listMembershipsForAccount` ("My Projects", and the single-logout fan-out) is deliberately **not** retro-filtered on the witness. Once D2 makes new forgery impossible, filtering historical rows would empty every builder's project list for a marginal security gain against rows that were, in all likelihood, legitimate check-ins. The gates belong on the write and publish paths; this is a read of the past.
64
+
65
+ ## Consequences
66
+
67
+ - The three attack steps in report 1000027 now stop at step 3: a client with valid credentials and no witnessed sign-in gets `409 signin_required`, having written nothing.
68
+ - `POST /sso/token` gains a write. It is inside the existing `try`, so a failure to store the witness fails the exchange rather than proceeding into a state where the client's own check-in would be the only evidence the sign-in happened. The code is single-use and already consumed at that point, so the user restarts sign-in — the fail-closed trade the report's severity justifies.
69
+ - **No wire-format change.** The check-in and rollup request schemas are byte-identical, `membership_kind`/`role` and the ADR 0177 cross-version window are untouched, and no federated instance needs to upgrade. The proof moved *inside* the hub, which is the only place it could live without asking every instance on its own schedule to carry a new field.
70
+ - New refusal codes: `signin_required` (409) and `invalid_github_id` (400) on check-in.
71
+ - Proof: `tests/sso_checkin_identity_spoof.mjs` — DB-free, in the default unit gate (a real-Postgres proof would self-skip in CI, which is where this one has to run). Ten of its fourteen assertions fail against the pre-fix code; the two "still works" rows are the control that the gate is a gate and not a wall.
72
+
73
+ ## Rejected
74
+
75
+ - **Carrying the hub assertion in the check-in body.** The strongest-looking fix, and unshippable: `validateOrRespond` is strict, the callers are other instances on their own upgrade schedule (ADR 0141 §3), and requiring a new field would 400 every check-in from every un-upgraded instance. The same reasoning ADR 0201 D2 used to put its assertion in a header applies here — except here the hub already holds the evidence and needs no field at all.
76
+ - **Source-guarding `github_login`/`avatar_url` the way `display_name` is guarded (ADR 0156).** That guard answers "did the user set this themselves", which is the wrong question: neither field is user-editable, and the attacker is a third party, not the user. It would have left the membership half of the report wide open.
77
+ - **Backfilling `signin_verified_at = joined_at`.** Preserves exactly the rows that may be forged. See D6.
78
+ - **Refusing the check-in silently (200 with no write).** The caller swallows errors either way, but a silent success makes the hub's logs lie about what it did; `409 signin_required` says what happened, and matches the shape the rollup gate already uses.
79
+ - **Retro-filtering `listMembershipsForAccount`.** See D6.
@@ -292,5 +292,6 @@ This keeps the decision history honest and traceable.
292
292
  | 0202 | [**A connection is one directed row on the platform account, and every degree label is computed over the graph the viewer is allowed to see** ([task 1002294](https://cloudbongos.com/builders#/task/1002294), goal 1000045 criterion C6 — the design spike for the connections wave). Criterion C6 asks for LinkedIn-shaped connections; the cross-project data plane it must sit on is account-global (ADR 0141) and governed by an owner-signed privacy matrix (`docs/specs/<redacted>.md` D1/D2), where ADR 0171 D4 already bans "a count plus a hidden total" as a leak by subtraction. Decisions: (1) **one directed request row** on a new `platform_identity_connections` — `requester`/`addressee` kept because the inbox, cancel and cooldown all need to know who asked, with `pair_lo`/`pair_hi` GENERATED from LEAST/GREATEST and a partial unique index over `pending|accepted` so a duplicate AND a simultaneous reciprocal request are database errors, not route bugs; module-owned and `platform_identity_`-namespaced (ADR 0083 §5). (2) The `goal_membership_requests` lifecycle (`pending → accepted|rejected|cancelled`, atomic resolve-and-act) with three deliberate divergences: a decline is **silent**, a re-ask inside its 90-day cooldown is **swallowed and answered as if it worked** (ADR 0194 §3 applied to a person — a 409 saying "declined" would disclose what the silence protects), and pending requests expire at **30 days read-time** (privacy spec D4), with expiry resolved in the same statement that inserts, since `now()` cannot live in an index predicate. Remove **hard-deletes** — a "A removed B" tombstone is data neither party consented to keep and no read path uses. (3) Degree = a **recursive CTE seeded at the viewer**, `DEGREE_MAX = 3` with `FANOUT_1`/`FANOUT_2` caps and a deterministic frontier order; past the caps the label is `null` ("out of network"), never a number the query did not establish. Envelope stated honestly: single-digit ms at hub scale, caps bite around 10⁴ accounts, and they convert a hub-heavy account's millions-wide 3-hop into a *missing label* on a page that still renders. (4) Connections get **no predicate of their own** — they consume task 1002967's single effective-visibility reader, published as JS and as `accountVisibleSql(alias)` (the ADR 0182 form); `profile_state='active'` floors the feature, a private account answers **404 not 403** and never appears in a public list, `hide_stats` suppresses the numbers beside an edge but not the edge, degree is an **own-scope read** so viewer-relative labels coexist with viewer-independent public responses — and every count is the length of the array the viewer may see. (5) "Builders you may know" from mutuals + shared projects + recency, one explainable linear score, a named project only where its catalogue row is publicly listed; shared goals refused (instance-local), guilds a reserved empty slot, and **Discord refused outright** (outside the trust boundary; the hub stores no Discord identity), as are the GitHub follower graph and address-book import. Rejected: two mirrored rows on accept; a symmetric edge with no requester; a core table beside `builders`; a materialised closure/adjacency table (privacy-filtered at write time = a cached visibility decision, which ADR 0171 D2 forbids); an in-process graph cache; a graph database. Deferred and named: a connections-specific hide toggle, blocking, what an accepted connection unlocks on a private account, and notifications beyond the existing attention card. Exposes to the guild/orb wave: `neighbours`, `degreeBetween`, `mutualCount` — **not** a stored strength scalar.](<redacted>.md) | platform identity / privacy / social graph |
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
+ | 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 |
295
296
 
296
297
  > ⚠️ **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.
@@ -12603,7 +12603,7 @@
12603
12603
  "sso"
12604
12604
  ],
12605
12605
  "summary": "POST /sso/membership/check-in",
12606
- "description": "POST /sso/membership/check-in — a federated instance reports, on every sign-in, that a builder builds on its project (ADR 0141 §3). Server-to-server, authed by client_id + client_secret (same constant-time check as /sso/token). Upserts the account (so a CLI-only builder still appears in My Projects) + the membership with a COARSE membership_kind. The client_id is taken from the AUTHENTICATED client — the caller cannot report membership for a project it doesn't hold the secret for. THE FIELD IS `membership_kind`; `role` IS ACCEPTED FOR THE WINDOW (task 1003090, ADR 0177 §\"the fifth use\"). ADR 0174 reserves `role` for the three crafts, and the old value 'builder' was a craft name meaning \"non-owner member\" — so both the field and that value are renamed. Unlike the goal-membership rename, this is a CROSS-VERSION wire contract: the callers are other instances on their own upgrade schedule, so an un-upgraded instance still sends `role: 'builder'` and the hub must keep understanding it. `membership_kind` WINS when both arrive (a sender in the window sends both deliberately). rank: public — authenticated by client_id + client_secret, not a builder rank.\n\n**Rank:** `public` — No authentication — any caller.",
12606
+ "description": "POST /sso/membership/check-in — a federated instance reports, on every sign-in, that a builder builds on its project (ADR 0141 §3). Server-to-server, authed by client_id + client_secret (same constant-time check as /sso/token). Refreshes an EXISTING `platform_identity_project_memberships` row's COARSE membership_kind. The client_id is taken from the AUTHENTICATED client — the caller cannot report membership for a project it doesn't hold the secret for. WHAT THIS CALL MAY NOT DO, AND WHY (ADR 0205, security report 1000027). Client credentials say WHICH PROJECT is calling; they say nothing about WHO the github_id in the body is. So this route may neither create a membership nor rewrite an account's identity: it updates a row the HUB already witnessed a sign-in for (signin_verified_at, stamped only at /sso/token and /sso/device/poll), and it creates the account shell only when absent. Before this, a client holding only its own secret could overwrite anyone's public github_login + avatar_url AND mint the membership that /sso/activity/rollup's membership_required precheck (the ADR 0171 D7 anti-impersonation gate) trusts — so the gate was satisfiable by the very call it was meant to gate. Nothing legitimate is lost: check-in only ever fires after a hub-authenticated sign-in (src/bongos/auth-idp.js), which stamps the witness first. THE FIELD IS `membership_kind`; `role` IS ACCEPTED FOR THE WINDOW (task 1003090, ADR 0177 §\"the fifth use\"). ADR 0174 reserves `role` for the three crafts, and the old value 'builder' was a craft name meaning \"non-owner member\" — so both the field and that value are renamed. Unlike the goal-membership rename, this is a CROSS-VERSION wire contract: the callers are other instances on their own upgrade schedule, so an un-upgraded instance still sends `role: 'builder'` and the hub must keep understanding it. `membership_kind` WINS when both arrive (a sender in the window sends both deliberately). rank: public — authenticated by client_id + client_secret, not a builder rank.\n\n**Rank:** `public` — No authentication — any caller.",
12607
12607
  "x-rank": "public",
12608
12608
  "x-source": "modules/platform-identity/routes/sso.js",
12609
12609
  "requestBody": {
package/docs/file-map.md CHANGED
@@ -347,6 +347,7 @@ tests/
347
347
  ├── linkify_refs.mjs ← (#604): the doc-linkifier classifier — pins which `#NNN` rewrite (tasks) vs stay bare (heading anchors, link targets, idea/blocker/session/PR ids, code spans), incl. comma-list entity inheritance + idempotency.
348
348
  ├── sandbox_stage.mjs ← (#927, ADR 0046): sandbox-first review gate — box detection, BOX_PREVIEW_HOSTNAME parsing, previewable-surface matching, decideShipGate table, tree-stamp freshness (temp git repo), runShipGate composition with injected fs/exec/prompt.
349
349
  ├── session_start_freshness.mjs ← (#1277, ADR 0067): the SessionStart freshness decision (decideFreshness) — pull ONLY on a clean `main` full clone strictly behind origin/main; warn in every other behind case; skip when up to date.
350
+ ├── own_scope_guard.mjs ← (task 1003343, goal 1000045 criterion own-scope-held-by-a-test): the OWN-SCOPE structural guard, generalised out of memory_isolation.mjs. An explicit inventory of own-scoped route files; inside them the route's declared PATH decides — a path naming no builder must never read a subject from the request (direct, optional-chained, destructured, aliased, computed-key or header), a path that DOES name one needs a per-route exemption whose gate the guard verifies is present. Repo-wide backstop: any file mounting a `/me` route must be in the inventory. Source-grepping, so it runs in the DB-free `unit` lane.
350
351
  └── public_task_resolver.mjs ← (#604): the public `/public/tasks/:id` projection — title/value_summary exposed only for shipped|confirmed; withheld for in-flight so unshipped/security task names can't leak.
351
352
  ```
352
353
 
@@ -1267,5 +1267,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1267
1267
  landed since 1.19.413 with no explicit bump. run 33260596712. (task 1002620)
1268
1268
  1.19.415 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1269
1269
  landed since 1.19.414 with no explicit bump. run 33262322596. (task 1002620)
1270
+ 1.19.416 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1271
+ landed since 1.19.415 with no explicit bump. run 33262582879. (task 1002620)
1272
+ 1.19.417 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1273
+ landed since 1.19.416 with no explicit bump. run 33263505319. (task 1002620)
1270
1274
  ---------------------------------------------------------------------------
1271
1275
  ```
@@ -0,0 +1,41 @@
1
+ -- platform_identity_014_membership_signin_proof.sql — the hub's OWN witness that a
2
+ -- federated sign-in really happened (security report 1000027, task 1003340, ADR 0205).
3
+ --
4
+ -- WHY. POST /sso/membership/check-in authenticates the CALLER (client_id +
5
+ -- client_secret) and takes the SUBJECT (github_id) from the request body. Client
6
+ -- credentials say "which project is calling", never "which person this is about", so
7
+ -- any registered client could mint a membership row for anyone — and that row is
8
+ -- exactly what the ADR 0171 D7 `membership_required` precheck at POST
9
+ -- /sso/activity/rollup trusts before publishing credits/karma/rank onto a PUBLIC
10
+ -- profile. The precheck was satisfiable by the very call it was meant to gate.
11
+ --
12
+ -- THE COLUMN. `signin_verified_at` is written ONLY by the hub itself, at the two
13
+ -- moments the hub authenticates a human FOR a client and no request body can reach:
14
+ -- • POST /sso/token — redeeming a single-use code minted at /sso/authorize
15
+ -- under a LIVE HUB SESSION, bound to (client_id, github_id);
16
+ -- • POST /sso/device/poll — a GitHub device approval, audience bound from the
17
+ -- registered origin (the CLI never names the client).
18
+ -- Because no caller-supplied field feeds it, the column is proof the caller could not
19
+ -- have written. Check-in may only UPDATE a row that already carries it; the rollup
20
+ -- precheck requires it.
21
+ --
22
+ -- NO BACKFILL, DELIBERATELY. Existing rows carry no proof, and there is no predicate
23
+ -- that separates a legitimate 2026 check-in row from a forged one — grandfathering
24
+ -- them would preserve precisely the rows the report is about. Nothing breaks for a
25
+ -- real builder: every federated sign-in stamps this column BEFORE the check-in and
26
+ -- rollup that follow it (src/bongos/auth-idp.js sequences token/assertion →
27
+ -- check-in → rollup), so the first sign-in after this deploys heals a real membership
28
+ -- with no operator step. Already-published activity rows are untouched — a profile
29
+ -- goes stale until the next sign-in, it never goes blank.
30
+ --
31
+ -- Additive, idempotent, platform_identity_-namespaced (ADR 0083 §5).
32
+
33
+ BEGIN;
34
+
35
+ ALTER TABLE platform_identity_project_memberships
36
+ ADD COLUMN IF NOT EXISTS signin_verified_at timestamptz;
37
+
38
+ INSERT INTO schema_migrations (version) VALUES ('platform_identity_014_membership_signin_proof')
39
+ ON CONFLICT DO NOTHING;
40
+
41
+ COMMIT;
@@ -99,6 +99,37 @@ function redirectUriAllowed(client, redirectUri) {
99
99
  // Accounts
100
100
  // -------------------------------------------------------------------------
101
101
 
102
+ // The avatar a hub profile may render: an absolute **https** URL, else null — http,
103
+ // data:, javascript:, relative and malformed all collapse (ADR 0205 D5). Validated
104
+ // at the ONE write boundary, not at each render: the value is echoed into a public
105
+ // page and an `<img src>` from several surfaces.
106
+ function safeAvatarUrl(avatarUrl) {
107
+ if (avatarUrl == null || avatarUrl === '') return null;
108
+ try {
109
+ return new URL(String(avatarUrl)).protocol === 'https:' ? String(avatarUrl) : null;
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ // Create the account shell for a GitHub identity IF ABSENT, never touching an
116
+ // existing row's identity — THE VARIANT A FEDERATED CLIENT GETS (ADR 0205 D3,
117
+ // security report 1000027). upsertAccount's conflict re-sync is right where the
118
+ // HUB authenticated the human and catastrophic for /sso/membership/check-in,
119
+ // whose subject github_id is an unproven body field: a client could rewrite
120
+ // anyone's public login + avatar. Both hub paths still re-sync every sign-in.
121
+ async function ensureAccount({ githubId, githubLogin, displayName, avatarUrl }, { pool = defaultPool } = {}) {
122
+ await pool.query(
123
+ `INSERT INTO platform_identity_accounts (github_id, github_login, display_name, avatar_url, profile_state)
124
+ VALUES ($1, $2, $3, $4, 'provisional')
125
+ ON CONFLICT (github_id) DO NOTHING`,
126
+ [githubId, githubLogin, displayName ?? null, safeAvatarUrl(avatarUrl)],
127
+ );
128
+ const row = await getAccount(githubId, { pool });
129
+ if (row && !row.handle) row.handle = await ensureHandle({ githubId, githubLogin }, { pool });
130
+ return row;
131
+ }
132
+
102
133
  // Upsert the platform account for a GitHub identity (keyed on the stable
103
134
  // github_id). Called at /sso/authorize for the signed-in hub user. Identity only.
104
135
  //
@@ -113,6 +144,9 @@ function redirectUriAllowed(client, redirectUri) {
113
144
  // display_name_source='user'), no check-in/authorize re-sync overwrites it; a
114
145
  // never-renamed account keeps following GitHub. github_login and avatar_url
115
146
  // re-sync unconditionally — neither is user-editable (spec Decision 4).
147
+ //
148
+ // ONLY HUB-AUTHENTICATED CALLERS MAY USE THIS (ADR 0205 D3) — the conflict branch
149
+ // rewrites identity; a federated check-in gets `ensureAccount`.
116
150
  async function upsertAccount({ githubId, githubLogin, displayName, avatarUrl, activate = false }, { pool = defaultPool } = {}) {
117
151
  const { rows } = await pool.query(
118
152
  `INSERT INTO platform_identity_accounts (github_id, github_login, display_name, avatar_url, profile_state)
@@ -126,7 +160,7 @@ async function upsertAccount({ githubId, githubLogin, displayName, avatarUrl, ac
126
160
  profile_state = CASE WHEN $5::boolean THEN 'active' ELSE platform_identity_accounts.profile_state END,
127
161
  updated_at = now()
128
162
  RETURNING id, github_id, github_login, display_name, avatar_url, profile_state, handle`,
129
- [githubId, githubLogin, displayName ?? null, avatarUrl ?? null, !!activate],
163
+ [githubId, githubLogin, displayName ?? null, safeAvatarUrl(avatarUrl), !!activate],
130
164
  );
131
165
  const row = rows[0];
132
166
  // Seed the handle on first sight (spec Decision 3); a no-op once set.
@@ -409,29 +443,57 @@ function legacyRoleFromKind(kind) {
409
443
  return kind === 'member' ? 'builder' : kind;
410
444
  }
411
445
 
412
- // Upsert a builder's membership in a project (keyed by the reporting client + the
413
- // builder's github_id). COARSE membership only — never a rank. Refreshes
414
- // last_active on every check-in. Returns the row.
415
- async function upsertMembership({ clientId, githubId, membershipKind = 'member' }, { pool = defaultPool } = {}) {
416
- const k = MEMBERSHIP_KINDS.includes(membershipKind) ? membershipKind : 'member';
446
+ // Stamp the hub's OWN witness that this person signed in to this project, and
447
+ // create the membership if it is their first time — THE ONLY WAY A MEMBERSHIP ROW
448
+ // IS BORN (ADR 0205 D1). Called from the two routes where the hub itself
449
+ // authenticated the human for the client and no request body reaches the pairing:
450
+ // POST /sso/token (a code minted at /sso/authorize under a live hub session) and
451
+ // POST /sso/device/poll (a GitHub approval, audience bound from the origin) — which
452
+ // is what makes signin_verified_at evidence rather than another self-report.
453
+ // Signing in accepts a standing 'pending' invite; any other coarse kind is left
454
+ // to the reporting instance to say, via touchMembership.
455
+ async function recordFederatedSignin({ clientId, githubId }, { pool = defaultPool } = {}) {
417
456
  const { rows } = await pool.query(
418
- `INSERT INTO platform_identity_project_memberships (client_id, github_id, membership_kind)
419
- VALUES ($1, $2, $3)
457
+ `INSERT INTO platform_identity_project_memberships (client_id, github_id, membership_kind, signin_verified_at)
458
+ VALUES ($1, $2, 'member', now())
420
459
  ON CONFLICT (client_id, github_id) DO UPDATE SET
421
- membership_kind = EXCLUDED.membership_kind,
460
+ membership_kind = CASE WHEN platform_identity_project_memberships.membership_kind = 'pending'
461
+ THEN 'member' ELSE platform_identity_project_memberships.membership_kind END,
462
+ signin_verified_at = now(),
422
463
  last_active = now()
423
- RETURNING id, client_id, github_id, membership_kind, joined_at, last_active`,
464
+ RETURNING id, client_id, github_id, membership_kind, joined_at, last_active, signin_verified_at`,
465
+ [clientId, githubId],
466
+ );
467
+ return rows[0] || null;
468
+ }
469
+
470
+ // Refresh an EXISTING sign-in-backed membership from a federated check-in: the coarse
471
+ // membership_kind the instance reports, plus last_active. Null when there is no such
472
+ // row — which the route turns into a refusal.
473
+ // UPDATE-ONLY AND GATED ON signin_verified_at BY DESIGN (ADR 0205 D2, security
474
+ // report 1000027). Check-in authenticates the calling PROJECT, never the SUBJECT.
475
+ // When this could INSERT, any client could mint a membership for anyone — the very
476
+ // row the ADR 0171 D7 `membership_required` precheck at /sso/activity/rollup trusts
477
+ // before publishing credits/karma/rank onto a public profile.
478
+ async function touchMembership({ clientId, githubId, membershipKind = 'member' }, { pool = defaultPool } = {}) {
479
+ const k = MEMBERSHIP_KINDS.includes(membershipKind) ? membershipKind : 'member';
480
+ const { rows } = await pool.query(
481
+ `UPDATE platform_identity_project_memberships
482
+ SET membership_kind = $3,
483
+ last_active = now()
484
+ WHERE client_id = $1 AND github_id = $2 AND signin_verified_at IS NOT NULL
485
+ RETURNING id, client_id, github_id, membership_kind, joined_at, last_active, signin_verified_at`,
424
486
  [clientId, githubId, k],
425
487
  );
426
- return rows[0];
488
+ return rows[0] || null;
427
489
  }
428
490
 
429
491
  // One membership row for (project, builder), any membership_kind — or null. Backs the
430
- // activity-rollup membership requirement (ADR 0171 D7): a client may only report
431
- // activity for a builder who actually has a membership on that project.
492
+ // activity-rollup membership requirement (ADR 0171 D7), carrying `signin_verified_at`
493
+ // so the caller can demand the hub's own sign-in witness, not the row's mere existence.
432
494
  async function getMembership(clientId, githubId, { pool = defaultPool } = {}) {
433
495
  const { rows } = await pool.query(
434
- `SELECT id, client_id, github_id, membership_kind, joined_at, last_active
496
+ `SELECT id, client_id, github_id, membership_kind, joined_at, last_active, signin_verified_at
435
497
  FROM platform_identity_project_memberships
436
498
  WHERE client_id = $1 AND github_id = $2`,
437
499
  [clientId, githubId],
@@ -439,6 +501,13 @@ async function getMembership(clientId, githubId, { pool = defaultPool } = {}) {
439
501
  return rows[0] || null;
440
502
  }
441
503
 
504
+ // Does a real federated sign-in stand behind this membership — the ONE predicate the
505
+ // rollup publishes on (ADR 0205 D4)? 'pending' is an invite pre-seed, not a
506
+ // membership; an un-witnessed row is a client's own unproven claim.
507
+ function membershipBacksRollup(membership) {
508
+ return !!membership && membership.membership_kind !== 'pending' && !!membership.signin_verified_at;
509
+ }
510
+
442
511
  // All memberships for one account, newest-active first, joined to the project's
443
512
  // display fields (name/origin from sso_clients). Powers "My Projects" (task #1002112).
444
513
  async function listMembershipsForAccount(githubId, { pool = defaultPool } = {}) {
@@ -1349,8 +1418,10 @@ module.exports = {
1349
1418
  clientSecretValid,
1350
1419
  redirectUriAllowed,
1351
1420
  sessionUsableForFederation,
1421
+ safeAvatarUrl,
1352
1422
  // accounts
1353
1423
  upsertAccount,
1424
+ ensureAccount,
1354
1425
  getAccount,
1355
1426
  getAccountByLogin,
1356
1427
  // clients
@@ -1378,8 +1449,10 @@ module.exports = {
1378
1449
  MEMBERSHIP_KINDS,
1379
1450
  membershipKindFromLegacy,
1380
1451
  legacyRoleFromKind,
1381
- upsertMembership,
1452
+ recordFederatedSignin,
1453
+ touchMembership,
1382
1454
  getMembership,
1455
+ membershipBacksRollup,
1383
1456
  listMembershipsForAccount,
1384
1457
  removeMembership,
1385
1458
  // invitations