@bongos/core 1.19.416 → 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.416",
6
- "core_contract": "1.19.416",
7
- "source_commit": "1380d5372fffa712f955b0b7375a8b3a99089c8c",
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:18:39.698Z",
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": 1699,
14
+ "functional_verbatim": 1701,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2077,
20
- "tree_sha256": "7e57eaed4b7d4afd8c45bd44503ead5fd9477a96aef101d6f04be3c23ff3bacc",
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",
@@ -1912,7 +1917,7 @@
1912
1917
  {
1913
1918
  "path": "docs/module-api-changelog.md",
1914
1919
  "mode": "0000644",
1915
- "sha256": "d8522f28a0009d2a15f4f4ae69488855022aff8a7d6e17bc33ec2f5d2c95b9d1"
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": "56b89bb9c7f6bea14a6a8f9a5c2e90443950df52ae44aeb73a553ce0db9590ad"
5435
+ "sha256": "64e99f4a6c987054fa4827a376eace859767c9f5edc34676a60b5e0e19cb1581"
5426
5436
  },
5427
5437
  {
5428
5438
  "path": "package.json",
5429
5439
  "mode": "0000644",
5430
- "sha256": "cd9fe4e19f8f3642c0953f045fff13a8392ddd844da6e131d4a9b1a237fa372d"
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": "30e53d05c81c6182e0e58d7c14a5c5c7a053cd0fe818c1dfbba2bb9f77d9c860"
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",
@@ -10154,6 +10164,11 @@
10154
10164
  "mode": "0000644",
10155
10165
  "sha256": "70228aa4be345cc09664b3b725e30c3a1e0eacf4c39573db1304fb219d3245e7"
10156
10166
  },
10167
+ {
10168
+ "path": "tests/sso_checkin_identity_spoof.mjs",
10169
+ "mode": "0000644",
10170
+ "sha256": "27bd2a87e001eec2bc58e13cfa58e53f88c73ebe7504b944c718182247d98ca7"
10171
+ },
10157
10172
  {
10158
10173
  "path": "tests/start_env_guard.mjs",
10159
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": {
@@ -1269,5 +1269,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1269
1269
  landed since 1.19.414 with no explicit bump. run 33262322596. (task 1002620)
1270
1270
  1.19.416 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1271
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)
1272
1274
  ---------------------------------------------------------------------------
1273
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
@@ -259,6 +259,15 @@ module.exports = function ssoRoutes() {
259
259
  if (identity.redirect_uri !== redirect_uri) {
260
260
  return res.fail('invalid_grant', 400);
261
261
  }
262
+ // THE SIGN-IN WITNESS (ADR 0205, security report 1000027). A redeemed code
263
+ // means a human with a live hub session authorized THIS client for THIS
264
+ // github_id — the strongest binding the hub ever holds, and one no request
265
+ // body can fabricate. Record it here, so the /sso/membership/check-in and
266
+ // /sso/activity/rollup gates have something a caller cannot write. Inside
267
+ // the try on purpose: if the witness cannot be stored the sign-in fails
268
+ // rather than proceeding into a state where the client's own check-in is
269
+ // the only evidence it ever happened.
270
+ await pi.recordFederatedSignin({ clientId: client_id, githubId: identity.github_id }, { pool });
262
271
  // IDENTITY ONLY — never a rank, credits, or a session token (ADR 0016 / 0141).
263
272
  return res.json({
264
273
  github_id: Number(identity.github_id),
@@ -342,6 +351,10 @@ module.exports = function ssoRoutes() {
342
351
  displayName: id.display_name,
343
352
  avatarUrl: id.avatar_url,
344
353
  }, { pool });
354
+ // The CLI leg's sign-in witness (ADR 0205) — the device-flow twin of the
355
+ // /sso/token stamp. GitHub approved this person and the client was BOUND at
356
+ // start from the typed origin, so the pairing is the hub's own finding.
357
+ await pi.recordFederatedSignin({ clientId: out.client_id, githubId: id.github_id }, { pool });
345
358
  const assertion = hubKeys.mintHubAssertion({
346
359
  clientId: out.client_id, // BOUND at start from the origin — not caller-supplied
347
360
  githubId: id.github_id,
@@ -362,11 +375,23 @@ module.exports = function ssoRoutes() {
362
375
 
363
376
  // POST /sso/membership/check-in — a federated instance reports, on every sign-in,
364
377
  // that a builder builds on its project (ADR 0141 §3). Server-to-server, authed by
365
- // client_id + client_secret (same constant-time check as /sso/token). Upserts the
366
- // account (so a CLI-only builder still appears in My Projects) + the membership
367
- // with a COARSE membership_kind. The client_id is taken from the AUTHENTICATED
368
- // client — the caller cannot report membership for a project it doesn't hold the
369
- // secret for.
378
+ // client_id + client_secret (same constant-time check as /sso/token). Refreshes an
379
+ // EXISTING `platform_identity_project_memberships` row's COARSE membership_kind.
380
+ // The client_id is taken from the AUTHENTICATED client — the caller cannot report
381
+ // membership for a project it doesn't hold the secret for.
382
+ //
383
+ // WHAT THIS CALL MAY NOT DO, AND WHY (ADR 0205, security report 1000027). Client
384
+ // credentials say WHICH PROJECT is calling; they say nothing about WHO the
385
+ // github_id in the body is. So this route may neither create a membership nor
386
+ // rewrite an account's identity: it updates a row the HUB already witnessed a
387
+ // sign-in for (signin_verified_at, stamped only at /sso/token and
388
+ // /sso/device/poll), and it creates the account shell only when absent. Before
389
+ // this, a client holding only its own secret could overwrite anyone's public
390
+ // github_login + avatar_url AND mint the membership that /sso/activity/rollup's
391
+ // membership_required precheck (the ADR 0171 D7 anti-impersonation gate) trusts —
392
+ // so the gate was satisfiable by the very call it was meant to gate. Nothing
393
+ // legitimate is lost: check-in only ever fires after a hub-authenticated sign-in
394
+ // (src/bongos/auth-idp.js), which stamps the witness first.
370
395
  //
371
396
  // THE FIELD IS `membership_kind`; `role` IS ACCEPTED FOR THE WINDOW (task 1003090,
372
397
  // ADR 0177 §"the fifth use"). ADR 0174 reserves `role` for the three crafts, and
@@ -399,10 +424,23 @@ module.exports = function ssoRoutes() {
399
424
  if (!client || client.status !== 'active' || !pi.clientSecretValid(client, client_secret)) {
400
425
  return res.fail('invalid_client', 401);
401
426
  }
402
- // Upsert the account first so a CLI-only builder is known to the hub, then the
403
- // membership — keyed to the AUTHENTICATED client_id, never a caller-named one.
404
- await pi.upsertAccount({ githubId: github_id, githubLogin: github_login, displayName: display_name, avatarUrl: avatar_url }, { pool });
405
- await pi.upsertMembership({ clientId: client_id, githubId: github_id, membershipKind }, { pool });
427
+ // Reject a non-numeric github_id before it reaches the bigint column (the
428
+ // rollup's isNumericGithubId guard, which this route was missing).
429
+ if (!pi.isNumericGithubId(github_id)) {
430
+ return res.fail('invalid_github_id', 400);
431
+ }
432
+ // The membership FIRST, and update-only: no hub-witnessed sign-in for
433
+ // (this client, this github_id) means the caller cannot speak for this
434
+ // person, and nothing at all is written — not the account shell either.
435
+ const membership = await pi.touchMembership({ clientId: client_id, githubId: github_id, membershipKind }, { pool });
436
+ if (!membership) {
437
+ return res.fail('signin_required', 409);
438
+ }
439
+ // Then the account shell, so a CLI-only builder is known to the hub —
440
+ // INSERT-ONLY (ensureAccount), never a re-sync of an existing row's
441
+ // identity. In the normal sequence the sign-in that stamped the witness
442
+ // above already created it, so this is a defensive no-op.
443
+ await pi.ensureAccount({ githubId: github_id, githubLogin: github_login, displayName: display_name, avatarUrl: avatar_url }, { pool });
406
444
  res.json({ ok: true });
407
445
  } catch (err) {
408
446
  log.error('[platform-identity] /sso/membership/check-in', err);
@@ -446,11 +484,17 @@ module.exports = function ssoRoutes() {
446
484
  // it), so it does not satisfy the requirement. The instance sequences its
447
485
  // check-in BEFORE this rollup (src/bongos/auth.js), so the legit first
448
486
  // sign-in always has its membership row by the time this arrives.
487
+ //
488
+ // AND THE ROW MUST CARRY THE HUB'S OWN SIGN-IN WITNESS (ADR 0205, security
489
+ // report 1000027). Row-existence alone was not a gate: check-in minted the
490
+ // row this precheck reads, so a client could satisfy D7 for any github_id
491
+ // by first calling the other endpoint. membershipBacksRollup demands
492
+ // signin_verified_at — a column only /sso/token and /sso/device/poll stamp.
449
493
  if (!pi.isNumericGithubId(b.github_id)) {
450
494
  return res.fail('invalid_github_id', 400);
451
495
  }
452
496
  const membership = await pi.getMembership(b.client_id, b.github_id, { pool });
453
- if (!membership || membership.membership_kind === 'pending') {
497
+ if (!pi.membershipBacksRollup(membership)) {
454
498
  return res.fail('membership_required', 409);
455
499
  }
456
500
  await pi.upsertActivity({
@@ -408,19 +408,37 @@ await ta('registerClient auto-catalogues the project (2nd query) when an origin
408
408
 
409
409
  console.log('\nproject memberships (§3 check-in directory):');
410
410
 
411
- await ta('upsertMembership upserts on (client_id, github_id) with a coarse membership_kind', async () => {
412
- const pool = mockPool([{ rows: [{ id: '1', client_id: 'c1', github_id: '42', membership_kind: 'owner' }] }]);
413
- await P.upsertMembership({ clientId: 'c1', githubId: 42, membershipKind: 'owner' }, { pool });
411
+ await ta('recordFederatedSignin is the ONE path that creates a membership, and stamps the witness', async () => {
412
+ const pool = mockPool([{ rows: [{ id: '1', client_id: 'c1', github_id: '42', membership_kind: 'member' }] }]);
413
+ await P.recordFederatedSignin({ clientId: 'c1', githubId: 42 }, { pool });
414
414
  const c = pool.calls[0];
415
415
  assert.match(c.text, /INSERT INTO platform_identity_project_memberships/);
416
416
  assert.match(c.text, /ON CONFLICT \(client_id, github_id\) DO UPDATE/);
417
+ assert.match(c.text, /signin_verified_at = now\(\)/);
418
+ assert.match(c.text, /membership_kind = CASE WHEN platform_identity_project_memberships\.membership_kind = 'pending'/,
419
+ "signing in accepts a standing invite, and never overwrites a real coarse kind");
420
+ assert.deepEqual(c.params, ['c1', 42], 'no caller-supplied field reaches this statement');
421
+ });
422
+
423
+ await ta('touchMembership UPDATEs only, and only a row the hub witnessed a sign-in for', async () => {
424
+ const pool = mockPool([{ rows: [{ id: '1', client_id: 'c1', github_id: '42', membership_kind: 'owner' }] }]);
425
+ await P.touchMembership({ clientId: 'c1', githubId: 42, membershipKind: 'owner' }, { pool });
426
+ const c = pool.calls[0];
427
+ assert.match(c.text, /UPDATE platform_identity_project_memberships/);
428
+ assert.doesNotMatch(c.text, /INSERT/, 'a check-in must never create a membership (report 1000027)');
429
+ assert.match(c.text, /signin_verified_at IS NOT NULL/);
417
430
  assert.match(c.text, /last_active = now\(\)/);
418
431
  assert.deepEqual(c.params, ['c1', 42, 'owner']);
419
432
  });
420
433
 
421
- await ta('upsertMembership clamps an unknown kind to member (never trusts a caller blindly)', async () => {
434
+ await ta('touchMembership returns null when nothing matched (no witness → the route refuses)', async () => {
435
+ const pool = mockPool([{ rows: [] }]);
436
+ assert.equal(await P.touchMembership({ clientId: 'c1', githubId: 42 }, { pool }), null);
437
+ });
438
+
439
+ await ta('touchMembership clamps an unknown kind to member (never trusts a caller blindly)', async () => {
422
440
  const pool = mockPool([{ rows: [{}] }]);
423
- await P.upsertMembership({ clientId: 'c1', githubId: 42, membershipKind: 'archon' }, { pool });
441
+ await P.touchMembership({ clientId: 'c1', githubId: 42, membershipKind: 'archon' }, { pool });
424
442
  assert.equal(pool.calls[0].params[2], 'member', 'a non-coarse membership_kind is clamped to member');
425
443
  });
426
444
 
@@ -538,12 +556,13 @@ await ta('upsertActivity keeps a legitimate core rank key', async () => {
538
556
  });
539
557
 
540
558
  await ta('getMembership reads one (client, builder) row — the rollup membership requirement', async () => {
541
- const row = { id: '9', client_id: 'c1', github_id: '42', membership_kind: 'member' };
559
+ const row = { id: '9', client_id: 'c1', github_id: '42', membership_kind: 'member', signin_verified_at: 'T' };
542
560
  const pool = mockPool([{ rows: [row] }]);
543
561
  const out = await P.getMembership('c1', 42, { pool });
544
562
  assert.deepEqual(out, row);
545
563
  const c = pool.calls[0];
546
564
  assert.match(c.text, /FROM platform_identity_project_memberships/);
565
+ assert.match(c.text, /signin_verified_at/, 'the rollup gate reads the witness, not just the row');
547
566
  assert.deepEqual(c.params, ['c1', 42]);
548
567
  const miss = await P.getMembership('c1', 43, { pool });
549
568
  assert.equal(miss, null);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.416",
3
+ "version": "1.19.417",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.416",
9
+ "version": "1.19.417",
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.416",
3
+ "version": "1.19.417",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
package/src/module-api.js CHANGED
@@ -33,7 +33,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
33
33
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
34
34
  // the entry to that file. Look for a version's history there, not here.
35
35
  // ---------------------------------------------------------------------------
36
- const CORE_VERSION = '1.19.416'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
36
+ const CORE_VERSION = '1.19.417'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
37
37
 
38
38
  // A namespaced logger so a module's log lines are attributable + consistent.
39
39
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -163,7 +163,10 @@ await t('the route passes recent_ships through and takes the origin from the CLI
163
163
  return { rows: [{ client_id: 'c1', secret_hash: pi.sha256hex(SECRET), status: 'active', origin: ORIGIN }] };
164
164
  }
165
165
  if (/FROM platform_identity_project_memberships/.test(sql)) {
166
- return { rows: [{ id: 1, client_id: 'c1', github_id: 42, membership_kind: 'member' }] };
166
+ // signin_verified_at is what ADR 0205 made the rollup gate read: a
167
+ // membership row alone was never proof, because the check-in that wrote it
168
+ // is the caller's own unverified claim. A real sign-in stamps it.
169
+ return { rows: [{ id: 1, client_id: 'c1', github_id: 42, membership_kind: 'member', signin_verified_at: '2026-08-29T00:00:00.000Z' }] };
167
170
  }
168
171
  return { rows: [{ id: 1 }] };
169
172
  });
@@ -0,0 +1,348 @@
1
+ // tests/sso_checkin_identity_spoof.mjs — the hub SSO check-in spoof (security
2
+ // report 1000027, task 1003340, goal 1000045; ADR 0205).
3
+ //
4
+ // THE HOLE THIS PINS SHUT. `POST /sso/membership/check-in` authenticates the
5
+ // CALLER (client_id + client_secret) and takes the SUBJECT (`github_id`) from the
6
+ // request body. Client credentials say WHICH PROJECT is calling; they say nothing
7
+ // about WHO that github_id is. So a project holding only its OWN credentials could:
8
+ //
9
+ // 1. rewrite the `github_login` + `avatar_url` on ANY person's hub account —
10
+ // `upsertAccount`'s ON CONFLICT re-synced both unconditionally — which is
11
+ // what the public profile renders; and
12
+ // 2. mint the `platform_identity_project_memberships` row that
13
+ // `POST /sso/activity/rollup`'s `membership_required` precheck reads. That
14
+ // precheck IS the ADR 0171 D7 anti-impersonation gate, so the gate was
15
+ // satisfiable by the very call it was meant to gate: check in as the victim,
16
+ // then publish forged credits/karma/rank onto their PUBLIC profile.
17
+ //
18
+ // THE FIX, AND WHAT MAKES IT A GATE. A membership row is now born only at the two
19
+ // moments the HUB itself authenticated a human for a client — `POST /sso/token`
20
+ // (redeeming a code minted under a live hub session) and `POST /sso/device/poll`
21
+ // (a GitHub device approval, audience bound from the registered origin) — each
22
+ // stamping `signin_verified_at`, a column no request body can reach. Check-in may
23
+ // only UPDATE a row carrying that witness, and creates the account shell
24
+ // insert-only. The rollup precheck demands the witness, not merely a row.
25
+ //
26
+ // EVERY ASSERTION BELOW IS MUTATION-SENSITIVE: run this file against the code
27
+ // before the fix and the spoof rows fail (200 instead of 409, an identity
28
+ // re-sync where there must be none, an INSERT where there must be an UPDATE).
29
+ // The two "still works" rows are the control — the fix is a gate, not a wall.
30
+ //
31
+ // DB-free: a recording fake pool, the REAL sso router, the REAL domain module,
32
+ // core pinned through the doorway (the tests/account_privacy_flags.mjs pattern).
33
+ // It therefore runs in the default unit gate, where a real-Postgres proof would
34
+ // silently self-skip.
35
+ //
36
+ // Run: node tests/sso_checkin_identity_spoof.mjs
37
+
38
+ import { strict as assert } from 'node:assert';
39
+ import { test, after } from 'node:test';
40
+ import http from 'node:http';
41
+ import { createRequire } from 'node:module';
42
+ import { existsSync, readFileSync } from 'node:fs';
43
+ import { dirname, join } from 'node:path';
44
+ import { fileURLToPath } from 'node:url';
45
+
46
+ const require = createRequire(import.meta.url);
47
+ const express = require('express');
48
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
49
+
50
+ // ---- the recording fake pool ----------------------------------------------
51
+ //
52
+ // One shared pool object: the router's `pool` is destructured from the pinned
53
+ // doorway at require time, so it must be the same object each test queues into.
54
+ // SQL is whitespace-normalised on the way in so the assertions can match shapes
55
+ // without caring how a statement is wrapped.
56
+ let queued = [];
57
+ let calls = [];
58
+ const pool = {
59
+ query: async (text, params) => {
60
+ calls.push({ text: String(text).replace(/\s+/g, ' ').trim(), params: params || [] });
61
+ return queued.shift() || { rows: [], rowCount: 0 };
62
+ },
63
+ };
64
+ function expect(...responses) { queued = responses; calls = []; }
65
+ const sqlFor = (re) => calls.filter((c) => re.test(c.text));
66
+
67
+ // ---- the real routes over the real domain, with core pinned ----------------
68
+
69
+ const noopLog = { info() {}, warn() {}, error() {}, debug() {} };
70
+ const moduleApiPath = require.resolve('../src/module-api.js');
71
+ const helpers = require('../src/bongos/routes/_helpers.js');
72
+ require.cache[moduleApiPath] = {
73
+ id: moduleApiPath, filename: moduleApiPath, loaded: true,
74
+ exports: {
75
+ pool,
76
+ validateOrRespond: helpers.validateOrRespond,
77
+ resolveSession: async () => null, // the browser leg is not under test
78
+ logger: () => noopLog,
79
+ hasProvider: () => true, // the hub port is already registered
80
+ registerProvider: () => {},
81
+ on: () => () => {}, // the SLO listener is not under test
82
+ resolveEnv: () => null, // no hub signing key ⇒ device legs 503
83
+ userAgent: () => 'cloud-bongos-tests',
84
+ LIVE_RANK_LADDER: ['xenos', 'thetes', 'metic', 'archon'],
85
+ },
86
+ };
87
+
88
+ const pi = require('../modules/platform-identity/platform-identity.js');
89
+ const buildSsoRoutes = require('../modules/platform-identity/routes/sso.js');
90
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
91
+
92
+ const app = express();
93
+ app.use(express.json());
94
+ app.use(attachFail);
95
+ app.use(buildSsoRoutes());
96
+ const server = app.listen(0, '127.0.0.1');
97
+ await new Promise((r) => server.once('listening', r));
98
+ const port = server.address().port;
99
+ after(() => server.close());
100
+
101
+ function call(method, path, body) {
102
+ return new Promise((resolve, reject) => {
103
+ const payload = body === undefined ? null : Buffer.from(JSON.stringify(body));
104
+ const req = http.request({
105
+ host: '127.0.0.1', port, method, path,
106
+ headers: payload ? { 'content-type': 'application/json', 'content-length': payload.length } : {},
107
+ }, (res) => {
108
+ const chunks = [];
109
+ res.on('data', (c) => chunks.push(c));
110
+ res.on('end', () => {
111
+ const text = Buffer.concat(chunks).toString('utf8');
112
+ let json = null; try { json = JSON.parse(text); } catch { /* non-JSON body */ }
113
+ resolve({ status: res.statusCode, json, text });
114
+ });
115
+ });
116
+ req.on('error', reject);
117
+ req.end(payload);
118
+ });
119
+ }
120
+
121
+ // ---- fixtures --------------------------------------------------------------
122
+ //
123
+ // The attacker holds a fully legitimate, active SSO client and its real secret —
124
+ // the whole point is that valid client credentials must not be authority over a
125
+ // person. VICTIM is somebody else's github_id entirely.
126
+
127
+ const CLIENT_ID = 'sec1000027-attacker-client';
128
+ const CLIENT_SECRET = 'sec1000027-attacker-secret';
129
+ const REDIRECT = 'https://attacker.example/api/gds/auth/web/callback';
130
+ const VICTIM = 9002801777;
131
+ const SIGNED_IN = 4242;
132
+
133
+ const clientRow = () => ({
134
+ client_id: CLIENT_ID,
135
+ secret_hash: pi.sha256hex(CLIENT_SECRET),
136
+ name: 'Attacker Project',
137
+ redirect_uris: [REDIRECT],
138
+ origin: 'https://attacker.example',
139
+ owner_github_id: '1',
140
+ status: 'active',
141
+ });
142
+ const membershipRow = (over = {}) => ({
143
+ id: '5', client_id: CLIENT_ID, github_id: String(VICTIM),
144
+ membership_kind: 'member', joined_at: 'T0', last_active: 'T0',
145
+ signin_verified_at: null, ...over,
146
+ });
147
+ const checkInBody = (over = {}) => ({
148
+ client_id: CLIENT_ID,
149
+ client_secret: CLIENT_SECRET,
150
+ github_id: VICTIM,
151
+ github_login: 'pwned-login',
152
+ display_name: 'Pwned',
153
+ avatar_url: 'https://attacker.example/av.png',
154
+ membership_kind: 'member',
155
+ ...over,
156
+ });
157
+ const rollupBody = (over = {}) => ({
158
+ client_id: CLIENT_ID,
159
+ client_secret: CLIENT_SECRET,
160
+ github_id: VICTIM,
161
+ credits: 999999,
162
+ karma: 999999,
163
+ tasks_shipped: 9999,
164
+ rank: 'archon',
165
+ disciplines: ['engineer'],
166
+ ...over,
167
+ });
168
+
169
+ // ===========================================================================
170
+ // 1. The attack itself — a client with valid credentials, an unaffiliated person
171
+ // ===========================================================================
172
+
173
+ test('check-in for an unaffiliated github_id is REFUSED (no hub sign-in witness)', async () => {
174
+ // getClient → the attacker's real client; the membership lookup finds nothing.
175
+ expect({ rows: [clientRow()] }, { rows: [] });
176
+ const r = await call('POST', '/sso/membership/check-in', checkInBody());
177
+ assert.equal(r.status, 409, 'valid client credentials are not authority over a person');
178
+ assert.equal(r.json.error.code, 'signin_required');
179
+ });
180
+
181
+ test('the refused check-in writes NOTHING — not the identity, not a membership', async () => {
182
+ expect({ rows: [clientRow()] }, { rows: [] });
183
+ await call('POST', '/sso/membership/check-in', checkInBody());
184
+ assert.equal(sqlFor(/platform_identity_accounts/).length, 0,
185
+ "the victim's account row must not be touched at all by a caller who cannot speak for them");
186
+ assert.equal(sqlFor(/INSERT INTO platform_identity_project_memberships/).length, 0,
187
+ 'a check-in must never CREATE a membership — that is what unlocked the forged rollup');
188
+ });
189
+
190
+ test('an accepted check-in re-syncs no identity: the account write is insert-only', async () => {
191
+ expect(
192
+ { rows: [clientRow()] },
193
+ { rows: [membershipRow({ signin_verified_at: '2026-08-29T00:00:00.000Z' })] },
194
+ { rows: [{ id: '9', github_id: String(VICTIM), github_login: 'real-login', handle: 'real-login' }] },
195
+ );
196
+ const r = await call('POST', '/sso/membership/check-in', checkInBody());
197
+ assert.equal(r.status, 200);
198
+
199
+ const writes = sqlFor(/INSERT INTO platform_identity_accounts|UPDATE platform_identity_accounts/);
200
+ assert.equal(writes.length, 1, 'exactly one account WRITE');
201
+ assert.match(writes[0].text, /ON CONFLICT \(github_id\) DO NOTHING/,
202
+ 'an existing account is left alone; only an absent one is created');
203
+ for (const c of sqlFor(/platform_identity_accounts/)) {
204
+ assert.doesNotMatch(c.text, /github_login = EXCLUDED\.github_login/,
205
+ 'the public GitHub login must never be re-synced from an unproven request body');
206
+ assert.doesNotMatch(c.text, /avatar_url = EXCLUDED\.avatar_url/,
207
+ 'nor the avatar the public profile renders');
208
+ assert.doesNotMatch(c.text, /DO UPDATE/,
209
+ 'no statement this route issues may rewrite an existing account');
210
+ }
211
+ });
212
+
213
+ test('an accepted check-in only UPDATEs a membership the hub witnessed', async () => {
214
+ expect(
215
+ { rows: [clientRow()] },
216
+ { rows: [membershipRow({ signin_verified_at: '2026-08-29T00:00:00.000Z' })] },
217
+ { rows: [{ id: '9', handle: 'real-login' }] },
218
+ );
219
+ await call('POST', '/sso/membership/check-in', checkInBody());
220
+
221
+ const membership = sqlFor(/platform_identity_project_memberships/);
222
+ assert.equal(membership.length, 1, 'exactly one membership statement');
223
+ assert.match(membership[0].text, /^UPDATE platform_identity_project_memberships/);
224
+ assert.match(membership[0].text, /signin_verified_at IS NOT NULL/,
225
+ 'the witness is the predicate — without it the UPDATE is a disguised INSERT');
226
+ assert.equal(membership[0].params[0], CLIENT_ID,
227
+ 'keyed to the AUTHENTICATED client, never a caller-named one');
228
+ });
229
+
230
+ test('a non-numeric github_id is refused before it can reach the bigint column', async () => {
231
+ expect({ rows: [clientRow()] });
232
+ const r = await call('POST', '/sso/membership/check-in', checkInBody({ github_id: 'system:bfg' }));
233
+ assert.equal(r.status, 400);
234
+ assert.equal(r.json.error.code, 'invalid_github_id');
235
+ });
236
+
237
+ // ===========================================================================
238
+ // 2. The rollup gate the check-in used to unlock (ADR 0171 D7)
239
+ // ===========================================================================
240
+
241
+ test('the forged portfolio is refused: a membership row without the witness is not a membership', async () => {
242
+ expect({ rows: [clientRow()] }, { rows: [membershipRow({ signin_verified_at: null })] });
243
+ const r = await call('POST', '/sso/activity/rollup', rollupBody());
244
+ assert.equal(r.status, 409, 'row-existence alone was never proof — the writer of the row was the caller');
245
+ assert.equal(r.json.error.code, 'membership_required');
246
+ assert.equal(sqlFor(/platform_identity_builder_project_activity/).length, 0,
247
+ 'nothing reaches the table the public profile aggregates');
248
+ });
249
+
250
+ test('a pending INVITE row still fails the rollup gate, witness or not', async () => {
251
+ expect({ rows: [clientRow()] }, { rows: [membershipRow({ membership_kind: 'pending', signin_verified_at: '2026-08-29T00:00:00.000Z' })] });
252
+ const r = await call('POST', '/sso/activity/rollup', rollupBody());
253
+ assert.equal(r.status, 409);
254
+ assert.equal(r.json.error.code, 'membership_required');
255
+ });
256
+
257
+ test('CONTROL: a witnessed, non-pending membership still publishes its rollup', async () => {
258
+ expect(
259
+ { rows: [clientRow()] },
260
+ { rows: [membershipRow({ signin_verified_at: '2026-08-29T00:00:00.000Z' })] },
261
+ { rows: [{ id: '1', client_id: CLIENT_ID, github_id: String(VICTIM) }] },
262
+ );
263
+ const r = await call('POST', '/sso/activity/rollup', rollupBody());
264
+ assert.equal(r.status, 200, 'the fix is a gate on proof, not a blanket denial of federation');
265
+ assert.equal(sqlFor(/INSERT INTO platform_identity_builder_project_activity/).length, 1);
266
+ });
267
+
268
+ // ===========================================================================
269
+ // 3. Where the proof comes from — the hub's own sign-in, never a request body
270
+ // ===========================================================================
271
+
272
+ test('POST /sso/token stamps the witness, for the github_id the REDEEMED code names', async () => {
273
+ expect(
274
+ { rows: [clientRow()] },
275
+ { rows: [{ github_id: String(SIGNED_IN), github_login: 'octo', display_name: 'Octo', avatar_url: 'https://a.example/o.png', redirect_uri: REDIRECT }] },
276
+ { rows: [{ id: '5', signin_verified_at: 'now' }] },
277
+ );
278
+ const r = await call('POST', '/sso/token', {
279
+ client_id: CLIENT_ID, client_secret: CLIENT_SECRET, code: 'the-code', redirect_uri: REDIRECT,
280
+ });
281
+ assert.equal(r.status, 200);
282
+
283
+ const stamp = sqlFor(/signin_verified_at = now\(\)/);
284
+ assert.equal(stamp.length, 1, 'the redeem is where a membership is born');
285
+ assert.deepEqual(stamp[0].params, [CLIENT_ID, String(SIGNED_IN)],
286
+ 'the subject comes from the single-use code the hub minted, never from the caller');
287
+ assert.match(stamp[0].text, /INSERT INTO platform_identity_project_memberships/);
288
+ });
289
+
290
+ test('the token leg accepts no caller-named subject at all (strict schema)', async () => {
291
+ expect({ rows: [clientRow()] });
292
+ const r = await call('POST', '/sso/token', {
293
+ client_id: CLIENT_ID, client_secret: CLIENT_SECRET, code: 'the-code', redirect_uri: REDIRECT,
294
+ github_id: VICTIM,
295
+ });
296
+ assert.equal(r.status, 400, 'an undeclared github_id must be rejected, not quietly honoured');
297
+ assert.equal(r.json.error.code, 'validation_failed');
298
+ });
299
+
300
+ test('a failed redeem stamps nothing — no code, no witness', async () => {
301
+ expect({ rows: [clientRow()] }, { rows: [] });
302
+ const r = await call('POST', '/sso/token', {
303
+ client_id: CLIENT_ID, client_secret: CLIENT_SECRET, code: 'wrong', redirect_uri: REDIRECT,
304
+ });
305
+ assert.equal(r.status, 400);
306
+ assert.equal(r.json.error.code, 'invalid_grant');
307
+ assert.equal(sqlFor(/signin_verified_at = now\(\)/).length, 0);
308
+ });
309
+
310
+ // ===========================================================================
311
+ // 4. The stored shape + the avatar scheme check
312
+ // ===========================================================================
313
+
314
+ test('migration 014 adds the witness column and deliberately does NOT backfill it', () => {
315
+ const file = join(ROOT, 'modules/platform-identity/migrations/platform_identity_014_membership_signin_proof.sql');
316
+ assert.ok(existsSync(file), 'the witness column needs a migration to exist in');
317
+ const sql = readFileSync(file, 'utf8');
318
+ assert.match(sql, /ADD COLUMN IF NOT EXISTS signin_verified_at timestamptz/);
319
+ assert.doesNotMatch(sql, /SET signin_verified_at\s*=/,
320
+ 'grandfathering existing rows would preserve exactly the rows the report is about');
321
+ });
322
+
323
+ test('safeAvatarUrl admits https and nothing else', () => {
324
+ assert.equal(pi.safeAvatarUrl('https://avatars.example/u/1.png'), 'https://avatars.example/u/1.png');
325
+ for (const bad of [
326
+ 'http://avatars.example/u/1.png',
327
+ 'data:image/svg+xml;base64,PHN2Zy8+',
328
+ 'javascript:alert(1)',
329
+ 'file:///etc/passwd',
330
+ '/relative/path.png',
331
+ 'not a url at all',
332
+ '',
333
+ ]) {
334
+ assert.equal(pi.safeAvatarUrl(bad), null, `${bad || '(empty)'} must not reach storage`);
335
+ }
336
+ assert.equal(pi.safeAvatarUrl(null), null);
337
+ assert.equal(pi.safeAvatarUrl(undefined), null);
338
+ });
339
+
340
+ test('both account writers bind the sanitized avatar, never the raw string', async () => {
341
+ expect({ rows: [{ handle: 'h' }] });
342
+ await pi.upsertAccount({ githubId: 1, githubLogin: 'x', avatarUrl: 'javascript:alert(1)' }, { pool });
343
+ assert.equal(calls[0].params[3], null, 'upsertAccount (the hub-authenticated writer)');
344
+
345
+ expect({ rows: [{ handle: 'h' }] });
346
+ await pi.ensureAccount({ githubId: 1, githubLogin: 'x', avatarUrl: 'data:text/html,<script>' }, { pool });
347
+ assert.equal(calls[0].params[3], null, 'ensureAccount (the federated-client writer)');
348
+ });