@bongos/core 1.19.1017 → 1.19.1018
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 +33 -23
- package/docs/adr/0208-application-lifecycle-the-handle-waits-and-the-vouch-has-no-minter.md +1 -0
- package/docs/adr/0333-the-application-vouch-is-a-third-token-spent-once-and-bound-to-its-login.md +86 -0
- package/docs/adr/README.md +1 -0
- package/docs/module-api-changelog.md +7 -0
- package/migrations/core_254_access_requests_applicant_handle.sql +23 -0
- package/modules/onboarding/routes/access-requests.js +31 -4
- package/modules/platform-identity/hub-keys.js +51 -32
- package/modules/platform-identity/routes/my-projects.js +33 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/auth-idp.js +93 -48
- package/src/bongos/auth.js +2 -1
- package/src/module-api.js +7 -1
- package/tests/application_echo.mjs +100 -1
- package/tests/application_lifecycle.mjs +145 -6
- package/tests/idp_device_exchange.mjs +174 -0
- package/tests/module_api.mjs +1 -0
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.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.1018",
|
|
6
|
+
"core_contract": "1.19.1018",
|
|
7
|
+
"source_commit": "49eb795ed8e84657315241e6cfa652f3491e6edc",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-25T01:
|
|
9
|
+
"built_at": "2026-09-25T01:46:13.363Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 527,
|
|
13
13
|
"agent_docs_stubbed": 26,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2440,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2993,
|
|
20
|
+
"tree_sha256": "ed9091f7fa992b1a3cb4b1d473869986f200827a94ca69cc0a8c86770b571b71",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -1542,7 +1542,7 @@
|
|
|
1542
1542
|
{
|
|
1543
1543
|
"path": "docs/adr/0208-application-lifecycle-the-handle-waits-and-the-vouch-has-no-minter.md",
|
|
1544
1544
|
"mode": "0000644",
|
|
1545
|
-
"sha256": "
|
|
1545
|
+
"sha256": "84f46e47b23542bf6d4fb78c7e1914f1ebbbf9548e40159814c41bb4003f827e"
|
|
1546
1546
|
},
|
|
1547
1547
|
{
|
|
1548
1548
|
"path": "docs/adr/0209-one-budget-for-the-account-existence-oracle.md",
|
|
@@ -2154,6 +2154,11 @@
|
|
|
2154
2154
|
"mode": "0000644",
|
|
2155
2155
|
"sha256": "659b73c16da06bb150434de1eae1bcc1dfe4f6acf61ac0b2d787f3b52f617187"
|
|
2156
2156
|
},
|
|
2157
|
+
{
|
|
2158
|
+
"path": "docs/adr/0333-the-application-vouch-is-a-third-token-spent-once-and-bound-to-its-login.md",
|
|
2159
|
+
"mode": "0000644",
|
|
2160
|
+
"sha256": "b89c4d4bd55e2fe48686cdeabe5bd7eb08c41d195e4d06ea422096d1b7466e0b"
|
|
2161
|
+
},
|
|
2157
2162
|
{
|
|
2158
2163
|
"path": "docs/adr/0334-the-recruiting-directory-lists-a-hidden-builder-by-name.md",
|
|
2159
2164
|
"mode": "0000644",
|
|
@@ -2172,7 +2177,7 @@
|
|
|
2172
2177
|
{
|
|
2173
2178
|
"path": "docs/adr/README.md",
|
|
2174
2179
|
"mode": "0000644",
|
|
2175
|
-
"sha256": "
|
|
2180
|
+
"sha256": "8227e2293df9bc8364bc66ecd8890341e7a1410b9edc7906c38abae37ce7eccd"
|
|
2176
2181
|
},
|
|
2177
2182
|
{
|
|
2178
2183
|
"path": "docs/api-reference.md",
|
|
@@ -2742,7 +2747,7 @@
|
|
|
2742
2747
|
{
|
|
2743
2748
|
"path": "docs/module-api-changelog.md",
|
|
2744
2749
|
"mode": "0000644",
|
|
2745
|
-
"sha256": "
|
|
2750
|
+
"sha256": "a0b015494e2c8eaf772b17de3bd6c38737a4ad939921a395021172271b445e3f"
|
|
2746
2751
|
},
|
|
2747
2752
|
{
|
|
2748
2753
|
"path": "docs/modules-contract.md",
|
|
@@ -3864,6 +3869,11 @@
|
|
|
3864
3869
|
"mode": "0000644",
|
|
3865
3870
|
"sha256": "968b1f4b893c9368da682fae0845b69ec367aa465ae608f60644c266818b874e"
|
|
3866
3871
|
},
|
|
3872
|
+
{
|
|
3873
|
+
"path": "migrations/core_254_access_requests_applicant_handle.sql",
|
|
3874
|
+
"mode": "0000644",
|
|
3875
|
+
"sha256": "040de8cddf1a8740ac7e97fb35572e56494a5a3b51ce8873887f71759f6a3b9d"
|
|
3876
|
+
},
|
|
3867
3877
|
{
|
|
3868
3878
|
"path": "modules/agents/CLAUDE.md",
|
|
3869
3879
|
"mode": "0000644",
|
|
@@ -6407,7 +6417,7 @@
|
|
|
6407
6417
|
{
|
|
6408
6418
|
"path": "modules/onboarding/routes/access-requests.js",
|
|
6409
6419
|
"mode": "0000644",
|
|
6410
|
-
"sha256": "
|
|
6420
|
+
"sha256": "301c45e4f72024c7b1507245a9fccf4388224bb544ad747712c69155d41d1861"
|
|
6411
6421
|
},
|
|
6412
6422
|
{
|
|
6413
6423
|
"path": "modules/onboarding/routes/onboarding.js",
|
|
@@ -6462,7 +6472,7 @@
|
|
|
6462
6472
|
{
|
|
6463
6473
|
"path": "modules/platform-identity/hub-keys.js",
|
|
6464
6474
|
"mode": "0000644",
|
|
6465
|
-
"sha256": "
|
|
6475
|
+
"sha256": "a8dcce2d4be3d041c349c59cfc0eab2171a08869225f3ccfdb525c70faffb8eb"
|
|
6466
6476
|
},
|
|
6467
6477
|
{
|
|
6468
6478
|
"path": "modules/platform-identity/invite-authz.js",
|
|
@@ -6652,7 +6662,7 @@
|
|
|
6652
6662
|
{
|
|
6653
6663
|
"path": "modules/platform-identity/routes/my-projects.js",
|
|
6654
6664
|
"mode": "0000644",
|
|
6655
|
-
"sha256": "
|
|
6665
|
+
"sha256": "56700357273c03d412afce1ef9937ba811257d0c5110f08d757bbef3e517a416"
|
|
6656
6666
|
},
|
|
6657
6667
|
{
|
|
6658
6668
|
"path": "modules/platform-identity/routes/profile.js",
|
|
@@ -8287,12 +8297,12 @@
|
|
|
8287
8297
|
{
|
|
8288
8298
|
"path": "package-lock.json",
|
|
8289
8299
|
"mode": "0000644",
|
|
8290
|
-
"sha256": "
|
|
8300
|
+
"sha256": "c5cc97b40c78fcf1e0eb46f141d801315cb7cdc9afb99ee7435707df4a067f04"
|
|
8291
8301
|
},
|
|
8292
8302
|
{
|
|
8293
8303
|
"path": "package.json",
|
|
8294
8304
|
"mode": "0000644",
|
|
8295
|
-
"sha256": "
|
|
8305
|
+
"sha256": "3f6fc02ebfabdea595b601e08e7bb9aa2004f576bc4497ecf1d36482889b8efb"
|
|
8296
8306
|
},
|
|
8297
8307
|
{
|
|
8298
8308
|
"path": "public-docs/index.html",
|
|
@@ -10047,12 +10057,12 @@
|
|
|
10047
10057
|
{
|
|
10048
10058
|
"path": "src/bongos/auth-idp.js",
|
|
10049
10059
|
"mode": "0000644",
|
|
10050
|
-
"sha256": "
|
|
10060
|
+
"sha256": "fb4d7e17e113cdb41e118d3ecaefc6dc81676dcb780deb3f13532bd998b1412d"
|
|
10051
10061
|
},
|
|
10052
10062
|
{
|
|
10053
10063
|
"path": "src/bongos/auth.js",
|
|
10054
10064
|
"mode": "0000644",
|
|
10055
|
-
"sha256": "
|
|
10065
|
+
"sha256": "f35226f4ab906fa8dbcd688e1c429ce9f161e616f6b5c4a96dee3cebdf33e4b1"
|
|
10056
10066
|
},
|
|
10057
10067
|
{
|
|
10058
10068
|
"path": "src/bongos/cascade-dispatch.js",
|
|
@@ -10337,7 +10347,7 @@
|
|
|
10337
10347
|
{
|
|
10338
10348
|
"path": "src/module-api.js",
|
|
10339
10349
|
"mode": "0000644",
|
|
10340
|
-
"sha256": "
|
|
10350
|
+
"sha256": "2bc85f866eb82befe5dd49e4e5b3c1ed1195f33eb2d00a8f5c8f952d82c99843"
|
|
10341
10351
|
},
|
|
10342
10352
|
{
|
|
10343
10353
|
"path": "src/module-loader/catalog.js",
|
|
@@ -10607,12 +10617,12 @@
|
|
|
10607
10617
|
{
|
|
10608
10618
|
"path": "tests/application_echo.mjs",
|
|
10609
10619
|
"mode": "0000644",
|
|
10610
|
-
"sha256": "
|
|
10620
|
+
"sha256": "eec3a0404674e2a02d3ba7f38ec9fee92e5f06870240161d98986d525cb03d3f"
|
|
10611
10621
|
},
|
|
10612
10622
|
{
|
|
10613
10623
|
"path": "tests/application_lifecycle.mjs",
|
|
10614
10624
|
"mode": "0000644",
|
|
10615
|
-
"sha256": "
|
|
10625
|
+
"sha256": "975389846058a123344a6cd56d2355b606c86e2ce59a48981782f61204090096"
|
|
10616
10626
|
},
|
|
10617
10627
|
{
|
|
10618
10628
|
"path": "tests/architect.mjs",
|
|
@@ -12677,7 +12687,7 @@
|
|
|
12677
12687
|
{
|
|
12678
12688
|
"path": "tests/idp_device_exchange.mjs",
|
|
12679
12689
|
"mode": "0000644",
|
|
12680
|
-
"sha256": "
|
|
12690
|
+
"sha256": "3a57c5a71dae3b35d90fb360efc8ae39ca889f18dbc18d504a68cbc01f0c219c"
|
|
12681
12691
|
},
|
|
12682
12692
|
{
|
|
12683
12693
|
"path": "tests/idp_federation.mjs",
|
|
@@ -13047,7 +13057,7 @@
|
|
|
13047
13057
|
{
|
|
13048
13058
|
"path": "tests/module_api.mjs",
|
|
13049
13059
|
"mode": "0000644",
|
|
13050
|
-
"sha256": "
|
|
13060
|
+
"sha256": "02f08f0c3449d0d3d7e3df4f2116aba31a01732b7f083dad0d1d11358c90ff53"
|
|
13051
13061
|
},
|
|
13052
13062
|
{
|
|
13053
13063
|
"path": "tests/module_api_lazy.mjs",
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
- **Status:** accepted
|
|
4
4
|
- **Date:** 2026-08-29
|
|
5
5
|
- **Task:** [task 1002284](https://cloudbongos.com/builders#/task/1002284) (R08, goal 1000045 — criteria C3/C4)
|
|
6
|
+
- **Resolved by:** [ADR 0333](0333-the-application-vouch-is-a-third-token-spent-once-and-bound-to-its-login.md) (task 1002285) — D1's handle and D2's minter, verifier tightening and jti choice are built there.
|
|
6
7
|
- **Relates to:** [ADR 0201](0201-cross-project-application-vouched-row-live-record.md) (the design this builds — D1, D3.2, D4), [ADR 0016](0016-trust-boundary-server-enforced-permissions.md) (descriptive analytics are never authority), [ADR 0141](<redacted>.md) (identity federated, authority local), [ADR 0171](0171-profile-rollup-join-and-consent.md) D2 (the non-numeric principal guard), [ADR 0116](0116-api-error-envelope-and-code-taxonomy.md) (a refusal names the action available), [ADR 0202](0202-connections-graph-and-bounded-degree.md) D4 (the `account-visibility.js` "its own small file" precedent this reuses)
|
|
7
8
|
|
|
8
9
|
## Context
|
package/docs/adr/0333-the-application-vouch-is-a-third-token-spent-once-and-bound-to-its-login.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# ADR 0333 — The application vouch is a third token kind, spent once, bound to the login it names, and carries a handle only past the consent floor
|
|
2
|
+
|
|
3
|
+
- **Status:** accepted
|
|
4
|
+
- **Date:** 2026-09-24
|
|
5
|
+
- **Task:** [task 1002285](https://cloudbongos.com/builders#/task/1002285) (R09, goal 1000045 — criterion C4)
|
|
6
|
+
- **Relates to:** [ADR 0201](0201-cross-project-application-vouched-row-live-record.md) D2 (the vouch, the header, the handle), [ADR 0208](0208-application-lifecycle-the-handle-waits-and-the-vouch-has-no-minter.md) D1 + D2 (the handle waits; the minter that cannot express it), [ADR 0260](0260-the-application-is-the-consent-and-the-echo-is-the-gate.md) (the live consumer, built dormant), [ADR 0141](<redacted>.md) §1 (the signed assertion), [ADR 0144](0144-federated-single-logout-backchannel.md) (the logout token), [ADR 0016](0016-trust-boundary-server-enforced-permissions.md) (a vouch is never authority), [ADR 0091](0091-bounding-the-kernel-and-db-carve.md) §2 (boundary-forced duplication)
|
|
7
|
+
|
|
8
|
+
## Context
|
|
9
|
+
|
|
10
|
+
ADR 0201 D2 decided that an application relayed by the hub carries a hub-signed vouch in a header, so a project's reviewer can safely see the applicant's cross-project record beside the name. ADR 0208 D2 then found that the vouch could not be built as D2.1 wrote it: `mintHubAssertion` has no `purpose`, and — worse — `verifyHubAssertion` accepted *any* identity payload, purpose or not, so a captured vouch would have verified as a device sign-in. ADR 0260 built everything downstream of the vouch (the D7 applicant view, the echo-gated `POST /sso/applicant-profile`, the queue's live read keyed on `applicant_github_id`) and left it dormant, because nothing wrote that column.
|
|
11
|
+
|
|
12
|
+
This task writes it. Four choices in the writer are not obvious, and ADR 0208 D2 explicitly asked for one of them to be *chosen* rather than inherited.
|
|
13
|
+
|
|
14
|
+
## Decision
|
|
15
|
+
|
|
16
|
+
### D1 — A third mint/verify pair over one shared envelope, on both sides
|
|
17
|
+
|
|
18
|
+
`mintHubApplicationVouch` (hub) and `<redacted>` (instance) carry `purpose: 'application'` **and** identity (`sub`, `applicant_login`, `handle`). The three token kinds now differ only in their claims, so each side mints and verifies them through one envelope — `signHubToken` in `hub-keys.js`, `verifyHubToken` in `auth-idp.js` (signature first, Ed25519 hardcoded, issuer, audience, expiry) — rather than three copies of the same twenty lines of crypto. Each verifier then demands exactly what the other two lack:
|
|
19
|
+
|
|
20
|
+
| Kind | Identity | `purpose` |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| login assertion | required | **forbidden** |
|
|
23
|
+
| logout token | absent | `backchannel-logout` |
|
|
24
|
+
| application vouch | required, as `applicant_login` | `application` |
|
|
25
|
+
|
|
26
|
+
The existing error messages are preserved byte-for-byte (the envelope takes the kind's name as a label).
|
|
27
|
+
|
|
28
|
+
**The vouch's login rides as `applicant_login`, not `github_login`, and that is what makes it safe on cores that have not upgraded.** Every login verifier any core ever shipped demands `github_login`, so a vouch relayed to a project still on an older pin is refused there as a device sign-in too — the purpose refusal in D2 only exists on cores that run this release, and the hub cannot know which pin a project runs. Found by review before ship: with a shared `github_login` claim, an older project that ignored the header could have exchanged a captured vouch for a CLI session within its ~120s life. The two defences are independent, and `tests/idp_device_exchange.mjs` asserts each on its own.
|
|
29
|
+
|
|
30
|
+
### D2 — `verifyHubAssertion` refuses ANY `purpose`, not a list of known ones
|
|
31
|
+
|
|
32
|
+
ADR 0208 D2 named the window: the tightening is free only while no minter puts a purpose on an identity token, and this task is the one that starts doing so. The check is presence (`hasOwnProperty`), so `purpose: null`, `''` and any future marker are refused too. A list of forbidden purposes would reopen the device-login replay the day someone adds a fourth kind and forgets the list.
|
|
33
|
+
|
|
34
|
+
### D3 — The jti is CHOSEN: a separate single-use store, and a vouch must carry one
|
|
35
|
+
|
|
36
|
+
One vouch vouches one application. The store is its own register — not the CLI assertion map it could have inherited — for the reason the logout store gave: no two token kinds share dedupe state. All three registers now come from one `jtiStore()` factory, so they cannot drift apart. The vouch is spent when it is read, before the INSERT: a write that then fails answers 500, the hub reports the project unreachable, and the applicant's retry mints a fresh vouch — so a spent-but-unwritten vouch costs nothing. `<redacted>` also **requires** a `jti` (its siblings tolerate a missing one) and **requires an audience**: the shared envelope skips the audience check when no client id is given, and a vouch checked that way would accept one minted for any project on the platform.
|
|
37
|
+
|
|
38
|
+
### D4 — The vouch must name the login the request names; otherwise the row is written unvouched
|
|
39
|
+
|
|
40
|
+
The body's `github_login` is a string anyone may type. Without the match, any valid vouch could be stapled to a request naming someone else, and the reviewer would read "Identity vouched" beside a name the vouch never confirmed — the exact impersonation surface ADR 0201 exists to close. A mismatch, like every other failure (absent, forged, expired, foreign, replayed, no hub key), writes the unvouched row: **never a refusal** (ADR 0201 D2.3).
|
|
41
|
+
|
|
42
|
+
### D4b — A vouched re-application takes over an UNVOUCHED pending row for its own login
|
|
43
|
+
|
|
44
|
+
The one-open-row index means a later application for a login that already has a pending row cannot insert. Left alone, anyone who filed an unvouched request under a well-known builder's login first — off the landing page, no header needed — would pre-empt that builder's real, vouched application for as long as the row sat there: the reviewer would see "Name unvouched" beside the squatter's name and note. So when the INSERT hits the index and the request carried a verified vouch for that login, the route UPDATEs the pending row with the vouch **and replaces its display name and note** with the vouched request's own, so the words beside "Identity vouched" are the vouched applicant's. The UPDATE's own `WHERE applicant_github_id IS NULL` means a row that is already vouched is never re-stamped, and an unvouched re-application still changes nothing (D1.4). Found by review before ship; the first draft named it as a limit instead.
|
|
45
|
+
|
|
46
|
+
### D5 — The handle is identity, not record; it rides only past the consent floor, and it is stored
|
|
47
|
+
|
|
48
|
+
`applicant_handle` (`core_254`) is written in the same statement as `applicant_github_id` and `vouched_at`, so the three can never disagree. ADR 0260 D4 says nothing about the applicant is stored — that sentence is about the **record**, which is still resolved live on every render; the handle is in the class of `github_login` and `applicant_github_id`, which the row already holds.
|
|
49
|
+
|
|
50
|
+
The hub puts a handle in the vouch only when the account clears `isAccountActive` (active **and** the platform terms accepted) — the same floor the applicant view applies — so the vouch never discloses a handle the reviewer's own queue would not show. A **private** account's handle does ride: applying is the consent (ADR 0260 D2), and its D3 sliver carries the handle anyway. The identity itself comes from the hub session, not the account row, so a provisional account is still vouched — just handle-less. The instance re-checks the handle against the hub's handle charset (a boundary-forced mirror of `normalizeHandle`, driven against it in `tests/idp_device_exchange.mjs`) and stores null for anything else.
|
|
51
|
+
|
|
52
|
+
**Storing it does not outlive a withdrawal, because nothing withdraws a handle.** The floor it passed is write-once in both halves — `profile_state` never demotes from `active`, and `terms_accepted_at` is COALESCE-stamped once (ADR 0213) — and neither `hide_stats` nor going private withholds a handle on any surface (the sliver carries it; `hide_stats` suppresses numbers, never addressability). So the stored handle never shows a reviewer more than the live applicant view would, except after a rename (below).
|
|
53
|
+
|
|
54
|
+
Since ADR 0260 moved the record read server-side and keyed it on `github_id`, the handle is no longer the lookup key ADR 0201 D2.5–6 needed. It is what the reviewer's queue can call the applicant when the hub is unreachable, and it is returned only by the archon-gated `GET /access-requests`.
|
|
55
|
+
|
|
56
|
+
### D6 — On the hub the vouch is best-effort, never a gate
|
|
57
|
+
|
|
58
|
+
A hub with no signing key sends no header and reads nothing — the project writes the unvouched row it always wrote. A failed account read still vouches the identity, handle-less. A mint failure sends no vouch. None of them delays or fails the application. The echo — which is what authorizes the project's later profile read — is written by the same relay on the same `requested` outcome, so a vouch never arrives without the echo it needs, except when the echo write itself fails (below).
|
|
59
|
+
|
|
60
|
+
### D7 — The instance reaches it through a narrow doorway port
|
|
61
|
+
|
|
62
|
+
`readApplicationVouch(token, { githubLogin })` joins the doorway beside `<redacted>`. Core supplies the hub public key and this instance's client id; the onboarding module passes a token and a login and can neither substitute a key nor re-address the audience.
|
|
63
|
+
|
|
64
|
+
## Consequences
|
|
65
|
+
|
|
66
|
+
- The ADR 0260 path lights up with no UI or route change: a vouched row's `applicant_github_id` drives the live profile read, and `watch.js` already renders the pill and the profile block.
|
|
67
|
+
- It lights up only when **both** ends run this release: the hub mints, the project verifies and has `core_254`. Either alone is inert and safe — an old project ignores the header, an old hub sends none.
|
|
68
|
+
- The approve path and `hasInvitedAccessRequest` are unchanged and still name no vouch column; `tests/application_lifecycle.mjs` now asserts that for a row written by the real writer, not only a seeded one.
|
|
69
|
+
|
|
70
|
+
## Honest limits
|
|
71
|
+
|
|
72
|
+
- **The vouch travels with every relay, including to a project whose door then refuses.** The header is minted before the project answers, so an invite-only or unpublished project's server receives the account id and (past the consent floor) the handle of someone who pressed Apply there. It learns no more than the body already tells it — the login and display name — plus the handle and the numeric GitHub id, both public-shaped identity, and it was the applicant who chose to knock.
|
|
73
|
+
- **The jti store is per-process memory.** A restart inside the ~120s window forgets spent vouches; the audience binding stays the primary control, and a replay can only re-assert the identity it already carried at the project it was minted for.
|
|
74
|
+
- **The handle is a snapshot.** A rename after applying is not followed; the hub's previous-handle 301 covers one, and the live profile block shows the current handle whenever the hub answers.
|
|
75
|
+
- **A vouched row can still show no record** when the relay's best-effort echo write failed, since ADR 0260 D3 gates the profile read on the echo. That is the degrade the queue already renders.
|
|
76
|
+
- **No Postgres executes the new INSERT in CI** — the ADR 0208 limit stands. The fake pool in `tests/application_lifecycle.mjs` matches the column list and `VALUES` exactly, so dropping or reordering a vouch column reds the suite; the migration is additive and idempotent.
|
|
77
|
+
|
|
78
|
+
## Rejected
|
|
79
|
+
|
|
80
|
+
- **A `purpose` argument on `mintHubAssertion`.** ADR 0208 D2's reason: it would look like D2.1 shipped while leaving the device-login replay open.
|
|
81
|
+
- **Reusing the CLI assertion's jti map.** Shared dedupe state across token kinds is what the logout store already declined.
|
|
82
|
+
- **Refusing an invalid or mismatched vouch.** Hands an attacker a way to make applications fail and locks out older hubs (ADR 0201 D2.3).
|
|
83
|
+
- **Taking the row's login from the vouch instead of the body.** A mismatch means something is wrong; silently renaming the request to whoever the vouch names would hide it.
|
|
84
|
+
- **Upgrading a squatted row but keeping its name and note.** It would stamp "Identity vouched" beside words someone else may have typed.
|
|
85
|
+
- **Asking the project's version before minting, to skip older pins.** A second round-trip on every application to learn what a claim name (D1) already guarantees on every pin.
|
|
86
|
+
- **Sending the handle regardless of consent.** A provisional or no-terms account discloses nothing on any other surface; the vouch would have been the one exception.
|
package/docs/adr/README.md
CHANGED
|
@@ -423,6 +423,7 @@ This keeps the decision history honest and traceable.
|
|
|
423
423
|
| 0329 | [**The rendered fit check is a kit floor and an advisory ship-check row, not a grader worker** ([task 1004203](https://cloudbongos.com/builders#/task/1004203), goal 1000095 — UI quality). Nothing in the pipeline rendered a page, so titles clipped by character count ran past their cards (task 1003480, task 1004204) and a second `.mSec` child would have landed on the first (task 1003078), each found only in a browser. Two new kit floors, `text-fit` (text measured word by word past its box; SVG text past its card rect) and `sibling-overlap` (in-flow, block-level, text-bearing siblings), run in every `render.js` render; `scripts/gds/render-check.js` renders the pages a branch changed at 1280 + 390 and `/ship-check` shows them as an ADVISORY row that never flips its exit (ADR 0162). Not a grader worker: that would put a browser, fixtures and a harness on the grader host for signal the builder already saw; the artist gate (ADR 0241) stays the human look before deploy.](0329-the-rendered-fit-check-is-a-kit-floor-and-an-advisory-ship-check-row.md) | ui-design / kit + ship-check |
|
|
424
424
|
| 0330 | [**An ask is answered where it was asked** ([task 1004216](https://cloudbongos.com/builders#/task/1004216), goal 1000095 — BONGOS-V2), amending [ADR 0187](0187-collab-four-decisions.md) §4a. `lifecycle_004` let a builder ASK for help and let the **asker** mark their own row answered; the person being **asked** had no write at all, so the answer happened somewhere Bongos cannot see and the row sat open until its author remembered it — six asks addressed to the owner had been open between four and thirteen days on 2026-09-23, every one of them answerable. That was not an oversight: §4a deferred conversation to the Agora and a test named *"there is deliberately NO reply thread"* enforced it. **The deferral lost its addressee** — task 1002946 is ABANDONED and goal 1000064 ARCHIVED (verified against the live DB), so the rule had stopped meaning "wait for the Agora" and started meaning "an ask stays unanswerable forever to keep a promise to something that does not exist". **Decisions:** (1) an ask carries a thread of replies, APPEND-ONLY in the SCHEMA rather than by convention — no `updated_at`, `edited_by`, `deleted_at` or `parent_reply_id`, no reactions, no mentions, and a trigger that refuses UPDATE and DELETE outright, because a thing you can edit, nest, react to and address at people is a chat and that is what §4a declined; any builder may reply, since the board read is already project-wide and the good case is somebody you did not ask knowing the answer; (2) the **ADDRESSEE** may close an ask (`answered`), widening an asker-only UPDATE that was coherent only while nothing could be said on the row — but `withdrawn` does NOT widen, since "I no longer need this" is the asker's sentence alone and widening it would be a way to make an ask vanish unanswered; `settled_by` records which of the two closed it, and ADR 0187 §3's consent posture is untouched — closing ends a question, it does not accept work; (3) `/collab-review` posts the reply and **leaves the ask open** (owner decision), since an answer the reader has not read is exactly what an unattended run must halt on. **Consequences:** a reply cannot be taken back, by design; the FK **RESTRICTS rather than cascades** — the first draft cascaded, which reads right and was wrong, because a cascade is a plain DELETE the trigger refuses, so deleting an answered ask failed outright (caught by applying the migration to a real Postgres, not by reading it); the old test was REPLACED, not deleted, so the record that the restraint was deliberate survives; and replies inherit the pull-not-push posture, so nothing still tells an asker they were answered. Rejected: a single `answer` column (cheapest and unambiguously not a chat, but a follow-up would have nowhere to go except a second ask, splitting one conversation across two rows); waiting for the Agora (abandoned — waiting means forever); letting anyone close any ask (an open question is somebody's, and a stranger ending it should not be one click away).](0330-an-ask-is-answered-where-it-was-asked.md) | collab / lifecycle / schema |
|
|
425
425
|
| 0331 | [**The sky is the ideator's home, and the project sky takes the goal map's page** ([task 1004229](https://cloudbongos.com/builders#/task/1004229), goal 1000095 — BONGOS-V2), amending [ADR 0227](0227-a-task-network-map-is-refused-the-goal-graph-is-the-map.md) and decision 11 of `docs/specs/<redacted>.md`. The owner reviewed an interactive prototype of the ideator sky and the project sky over five rounds on 2026-09-24 and adopted it AS IS — copy, layout, colours, tour text, toggles, interactions — which contradicts two things already written down, so they are rewritten rather than leaving the build graded against a contract nobody means any more. **Decisions:** (1) the sky is the ideator's HOME surface — it replaces *Your thinking*, and the project sky replaces the goal map page — so "never load-bearing" and "arrives last" are DROPPED as a sequencing hedge from before there was a design to look at; the rule that survives at full force is the accessibility half, that the sky is never the ONLY path to anything it shows (the list drawer — the room — is one key away, and every drawn thing is a row in it); (2) ADR 0227 is AMENDED, not reversed — its measured refusal of a whole-corpus task map stands, and the project sky is §1's own prescription rather than its refusal: the goal graph is the entry point, tasks are drawn only INSIDE one goal past a zoom threshold (the per-goal drill-down §10.5 measured readable at 85.2px pitch worst case), below that they are dust density on the rings with NO identities, and the goal map's table survives as the sky's ledger drawer (§6's table equivalent kept); the nav slot the goal map was deliberately denied is GRANTED to the project sky, under Goals (owner, 2026-09-24), so part 4 changes the page and the test asserting no slot together; (3) the five states and the two channels are UNTOUCHED — criterion `ideator-sky-tells-the-truth` names a model, not a picture, and this amendment changes the picture's standing only; (4) the two HEURISTICS in the prototype — an idea's origin guessed from its text, and "born of a thought" found by scanning text for idea ids — must not ship as ledger facts: parts 3 and 4 hide both layers, part 5 replaces them with a `filed_via` column and a real born-of read. **Consequences:** `docs/design/mocks/sky/sky.template.html` is the PIXEL CONTRACT for parts 2–5, and every deviation a gate or the file ratchet forces is listed in that part's ship notes; no snapshot JSON or built page is ever committed (it carries real task and idea titles and this repo is public); ADR 0264's deleted goal "Living project sky" is superseded by this work under Working area 3. Rejected: shipping the sky as an extra and keeping "never load-bearing" (makes the adopted home surface unroutable); reversing ADR 0227 (its refusal is measured and still correct — the sky needs its drill-down shape, not its repeal); shipping the heuristics behind a disclaimer (a labelled guess is still a guess in a picture whose promise is that it cannot be faked).](0331-the-sky-is-the-ideators-home.md) | design / ideator / sky |
|
|
426
|
+
| 0333 | [**The application vouch is a third token kind, spent once, bound to the login it names, and carries a handle only past the consent floor** ([task 1002285](https://cloudbongos.com/builders#/task/1002285), goal 1000045 — criterion C4), resolving [ADR 0208](0208-application-lifecycle-the-handle-waits-and-the-vouch-has-no-minter.md) D1 + D2. The hub's join relay now carries a signed application vouch in `<redacted>`, so the ADR 0260 live-record path is no longer dormant. `mintHubApplicationVouch` / `<redacted>` share one envelope with the login and logout tokens, and each verifier demands what the other two lack; `verifyHubAssertion` now refuses any `purpose`, closing the device-login replay. The jti is a chosen, separate single-use store; a vouch must carry a jti and an audience, and must name the login the request names, or the row is written unvouched (never refused); a vouched re-application takes over an unvouched pending row for its login. The vouch carries its login as `applicant_login`, so even an un-upgraded core refuses it as a login. `applicant_handle` (`core_254`) is identity, not record, and rides only past the consent floor.](0333-the-application-vouch-is-a-third-token-spent-once-and-bound-to-its-login.md) |
|
|
426
427
|
| 0334 | [**The recruiting directory lists a hidden builder by name, and hide changes a row's shape, not whether it exists** ([task 1002291](https://cloudbongos.com/builders#/task/1002291), R15 — goal 1000110). Privacy spec D2 (owner-signed) lists an active, public, hidden account on the recruiter surface "name-only (if not opted out)"; the directory dropped it outright, making hide a second opt-out while Settings told that builder recruiters would see their name and picture. `GET /scouting` now answers two tiers: `builders` (ranked, `scoutingListableSql`, unchanged) and `name_only` (`scoutingNameOnlySql` = the same predicate with the hide term flipped, so the tiers partition the directory and only the opt-out removes anyone), projected to `github_login`, `display_name`, `avatar_url` by `scouting-name-only.js`. The existence floor stays (a private account is the archon-floor D3 sliver's, never the directory's); presence never depends on activity; the only order is the login; the predicate is pinned to that one reader and its three columns. The hall's `/people` renders the tier as its own panel, never ranked, found by name, never by a role filter. Rejected: fixing only the copy; trailing rows in the ranked ledger; listing private discoverable accounts by name.](0334-the-recruiting-directory-lists-a-hidden-builder-by-name.md) | privacy / recruiting / hall |
|
|
427
428
|
| 0335 | [**An invite is a pre-approved row on the project's own instance, and `project.recruit` says who may send one** ([task 1002813](https://cloudbongos.com/builders#/task/1002813), R1 of the project admin console wave, goal 1000110 — BONGOS-V2). Supersedes ADR 0141 §5's "Invitations (deferred slice)" paragraph only. Formalises spike task 1002786's three decisions and reconciles them with six weeks of shipping: the instance invite route (task 1003044), the PATCH rescind (ADR 0201 D4.2) and the composed join door (ADR 0247) already exist, and the hub invite's dead end is now reachable from the creation wizard. **Decisions:** (D1) authority lives on the project's own instance, and the instance IS the project; the hub is identity, relay, echo, notification and links, and never writes a project's admission row; (D2) a recruit invite is an `access_requests` row with `status='invited'`, so signing in is the acceptance act and the gate is untouched; the row gains `kind ∈ (application, invite)` and `created_by` (the inviter, which a rescind would otherwise overwrite), reuses `note`, backfills exactly (`created_at = resolved_at`), and neither column ever enters an admission read; (D3) one new grantable atom, `project.recruit` (`system:false`, floor archon, widened per instance through the existing governance tab), guards the invite, the outstanding list, the invite rescind and the queue read, while `access_request.review` keeps the verdict; because inviting is admitting, a recruiter without the review atom may not approve or re-admit an APPLICANT through the invite route (409 to the queue); the hub sliver moves onto the atom, and the hub directory and hub invite KEEP `project.curate`, revising ADR 0210/0215/0248, since swapping the directory would raise a gate on a viewing surface (ADR 0217). **Task map:** R2 (task 1002814) = the migration, the invite writes and the sender list, still on `access_request.review`; R3 (task 1002815) = mint the atom, move the gates, the 409 clause, the sliver swap and the pins, and it lands after R2; R5 (task 1002817) is already delivered, so close it into task 1002331; task 1003362 folds into R3. Rejected: a new invitations table; the hub pending row as the invite; widening `access_request.review` instead of minting an atom; a per-project settings row for the floor; letting a recruiter's invite approve applications; `system:true`; swapping `project.curate` on the hub directory.](0335-an-invite-is-a-pre-approved-row-on-the-projects-own-instance.md) | governance / onboarding / platform-identity |
|
|
428
429
|
| 0336 | [**A guild is a hub-side group whose members each consent twice, and its collective record is only ever the sum of what those members already show** ([task 1002299](https://cloudbongos.com/builders#/task/1002299), goal 1000110 — the R23 design spike for goal 1000045's C7/C8/C9). No platform guild existed (every "guild" in the tree is Discord's). Decisions: (1) three hub tables in `platform-identity`, in their own `guilds.js` (the 1,500-line ratchet), distinct from instance-local `goal_members`: guilds (slug, name, `visibility ∈ public|unlisted` with **no default**), members (`membership_kind ∈ owner|member`, never `role` per ADR 0174; a partial unique index holds exactly one owner; a per-member `shown_publicly`), and requests; (2) two consents to join, no self-join, through ADR 0202's connections lifecycle verbatim (silent decline, 90-day swallowed re-ask, 30-day read-time expiry); `accountActiveSql` floors the feature; an **unlisted guild 404s to everyone on the public read, members included**, and takes invites only, because a slug is guessable; (3) the public roster is `shown_publicly AND accountVisibleSql`, and widening a guild to public changes no member's flag but the flipping owner's, so consent given to a small audience is never reused for a large one; (4) **the privacy resolution, pending the owner's ratification:** a member is counted iff shown AND `rollupVisibleSql`, read-time, `no-store`, never stored, and N and M are both lengths of lists the reader can see. The guild record is therefore always a sum of numbers already on members' own public profiles, so nothing can be subtracted out. All-or-nothing is rejected (one member blanks everyone and hiding becomes costly), as is per-member consent to count hidden numbers (it breaks "hide_stats beats everything"). The trade-off: guilds of privacy-minded builders look smaller and rank lower. A union count of all members' projects is refused because it reveals shared private projects; (5) the namespace is `/g/<slug>`, `g` is reserved in the SEO spec's project-slug list, the slug is fixed in V1, and `tests/guild_namespace_reserved.mjs` pins the two documents together; (6) a guild engages a project only through the individual door: a guild application is each member's own vouched application, labelled through ADR 0260's live applicant-profile read (no new instance field); inviting a guild fans out ordinary individual invites to its public roster at that moment, **never a guild-level pre-approval** (that would let a guild owner decide admission to someone else's project); (7) the map ranks public guilds by size and counted works shipped, read-time, with a slug tiebreak, and R29 narrows to the visual. Scope changes for R24–R37 are tabled in the ADR, including R28's dependency on the abandoned R11 and R29/R31's archived orb goals.](0336-a-guilds-record-is-the-sum-of-what-its-members-already-show.md) | platform identity / privacy / social graph |
|
|
@@ -2508,5 +2508,12 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2508
2508
|
landed since 1.19.1015 with no explicit bump. run 36080388706. (task 1002620)
|
|
2509
2509
|
1.19.1017 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2510
2510
|
landed since 1.19.1016 with no explicit bump. run 36081753087. (task 1002620)
|
|
2511
|
+
(next carrier) — readApplicationVouch re-exported (task 1002285, goal 1000110;
|
|
2512
|
+
ADR 0201 D2). The public POST /access-requests' check of the hub's signed
|
|
2513
|
+
application vouch → { githubId, handle } or null. Core supplies the hub
|
|
2514
|
+
public key and this instance's client_id, so the onboarding module passes
|
|
2515
|
+
only a token and a login — the <redacted> precedent.
|
|
2516
|
+
1.19.1018 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2517
|
+
landed since 1.19.1017 with no explicit bump. run 36083406390. (task 1002620)
|
|
2511
2518
|
---------------------------------------------------------------------------
|
|
2512
2519
|
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
-- core_254_access_requests_applicant_handle.sql — the platform handle the hub's
|
|
2
|
+
-- application vouch carried, beside the vouched github id (ADR 0333 D5; the column
|
|
3
|
+
-- core_230 deferred to its writer, ADR 0208 D1).
|
|
4
|
+
--
|
|
5
|
+
-- Identity, not the record: the applicant's profile is still read live at review
|
|
6
|
+
-- and never stored (ADR 0260 D4). NULL = unvouched, or the account had not cleared
|
|
7
|
+
-- the consent floor, or the hub's account read failed. A snapshot — not re-synced
|
|
8
|
+
-- on rename. The vouch MARK stays `applicant_github_id IS NOT NULL`; no admission
|
|
9
|
+
-- path reads this column (ADR 0016).
|
|
10
|
+
--
|
|
11
|
+
-- Purely additive + idempotent; the previous release never names the column, so a
|
|
12
|
+
-- replay or rollback is a no-op. access_requests is CORE-owned, so this is a core_
|
|
13
|
+
-- migration (ADR 0108 §5); migrate.sh records the stem.
|
|
14
|
+
|
|
15
|
+
BEGIN;
|
|
16
|
+
|
|
17
|
+
ALTER TABLE access_requests
|
|
18
|
+
ADD COLUMN IF NOT EXISTS applicant_handle text;
|
|
19
|
+
|
|
20
|
+
COMMENT ON COLUMN access_requests.applicant_handle IS
|
|
21
|
+
'The platform handle the hub application vouch carried at apply time (ADR 0201 D2.6, task 1002285): identity, never the record, which is read live at review. NULL on an unvouched row, or when the account had not cleared the consent floor. A snapshot — not re-synced on rename. DESCRIPTIVE ONLY — no admission path reads it (ADR 0016).';
|
|
22
|
+
|
|
23
|
+
COMMIT;
|
|
@@ -80,6 +80,24 @@ function normalizeLogin(raw) {
|
|
|
80
80
|
return String(raw || '').trim().replace(/^@/, '');
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// The hub relay's signed application vouch rides in a HEADER, not the body: the
|
|
84
|
+
// strict validator would 400 an unknown field on every older core (ADR 0201 D2.2).
|
|
85
|
+
// Mirrored in modules/platform-identity/routes/my-projects.js (ADR 0091 §2).
|
|
86
|
+
const APPLICATION_VOUCH_HEADER = 'X-Bongos-Platform-Assertion';
|
|
87
|
+
|
|
88
|
+
// vouchPendingRow — a vouched re-application takes over an UNVOUCHED pending row
|
|
89
|
+
// for its own login, name and note included, so a row someone else filed under
|
|
90
|
+
// that login cannot pre-empt the real applicant's vouch (ADR 0333 D4). A row
|
|
91
|
+
// already vouched is never re-stamped.
|
|
92
|
+
function vouchPendingRow({ login, displayName, note, vouch }) {
|
|
93
|
+
return pool.query(
|
|
94
|
+
`UPDATE access_requests
|
|
95
|
+
SET display_name = $2, note = $3, applicant_github_id = $4, applicant_handle = $5, vouched_at = $6
|
|
96
|
+
WHERE lower(github_login) = lower($1) AND status = 'pending' AND applicant_github_id IS NULL`,
|
|
97
|
+
[login, displayName, note, vouch.githubId, vouch.handle, new Date()],
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
83
101
|
// How many applicant profiles one queue render may resolve from the hub (privacy
|
|
84
102
|
// spec D7, task 1002972). The list itself returns up to 200 rows, and a render
|
|
85
103
|
// that fired 200 cross-instance round-trips would make a reviewer wait on the
|
|
@@ -156,6 +174,10 @@ module.exports = function buildAccessRequestsRouter() {
|
|
|
156
174
|
}
|
|
157
175
|
const displayName = (req.body.display_name || '').trim() || null;
|
|
158
176
|
const note = (req.body.note || '').trim() || null;
|
|
177
|
+
// A verified vouch that names this login → the account the hub confirmed; anything
|
|
178
|
+
// else → null, and the unvouched row this route always wrote (never a refusal,
|
|
179
|
+
// ADR 0201 D2.3). Descriptive only: no admission path reads it (ADR 0016).
|
|
180
|
+
const vouch = api.readApplicationVouch(req.get(APPLICATION_VOUCH_HEADER), { githubLogin: login });
|
|
159
181
|
|
|
160
182
|
try {
|
|
161
183
|
// Already a builder? They have an account — point them at sign-in instead
|
|
@@ -177,11 +199,12 @@ module.exports = function buildAccessRequestsRouter() {
|
|
|
177
199
|
return res.fail('already_approved', { status: 409, message: "You're already approved — sign in with GitHub to enter." });
|
|
178
200
|
}
|
|
179
201
|
|
|
202
|
+
// The three vouch columns are written in one statement, so they never disagree.
|
|
180
203
|
const { rows } = await pool.query(
|
|
181
|
-
`INSERT INTO access_requests (github_login, display_name, note)
|
|
182
|
-
VALUES ($1, $2, $3)
|
|
204
|
+
`INSERT INTO access_requests (github_login, display_name, note, applicant_github_id, applicant_handle, vouched_at)
|
|
205
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
183
206
|
RETURNING id, github_login, status, created_at`,
|
|
184
|
-
[login, displayName, note]
|
|
207
|
+
[login, displayName, note, vouch ? vouch.githubId : null, vouch ? vouch.handle : null, vouch ? new Date() : null]
|
|
185
208
|
);
|
|
186
209
|
// Post the request to #member-approvals so an Archon can 👍/👎 it from
|
|
187
210
|
// Discord (task 1100). Fire-and-forget + best-effort — never blocks or
|
|
@@ -195,6 +218,10 @@ module.exports = function buildAccessRequestsRouter() {
|
|
|
195
218
|
// Partial unique index → there's already an open request for this login.
|
|
196
219
|
// Treat as success-ish: the Archon already has them in the queue.
|
|
197
220
|
if (err.code === '23505') {
|
|
221
|
+
if (vouch) {
|
|
222
|
+
try { await vouchPendingRow({ login, displayName, note, vouch }); }
|
|
223
|
+
catch (e) { log.error({ err: e.message }, '[gds] POST /access-requests: vouching the open request failed (non-fatal)'); }
|
|
224
|
+
}
|
|
198
225
|
return res.status(200).json({ ok: true, already_pending: true });
|
|
199
226
|
}
|
|
200
227
|
console.error('[gds] POST /access-requests', err);
|
|
@@ -276,7 +303,7 @@ module.exports = function buildAccessRequestsRouter() {
|
|
|
276
303
|
const { rows } = await pool.query(
|
|
277
304
|
`SELECT ar.id, ar.github_login, ar.display_name, ar.note, ar.status,
|
|
278
305
|
ar.created_at, ar.resolved_at,
|
|
279
|
-
ar.applicant_github_id, ar.vouched_at,
|
|
306
|
+
ar.applicant_github_id, ar.applicant_handle, ar.vouched_at,
|
|
280
307
|
(ar.applicant_github_id IS NOT NULL) AS vouched,
|
|
281
308
|
rb.github_login AS resolved_by_login
|
|
282
309
|
FROM access_requests ar
|
|
@@ -14,14 +14,18 @@
|
|
|
14
14
|
// no PKI service.
|
|
15
15
|
//
|
|
16
16
|
// The assertion is a compact JWT (alg=EdDSA). The wire format is mirrored, by
|
|
17
|
-
// necessity, in the instance-side
|
|
18
|
-
//
|
|
19
|
-
//
|
|
17
|
+
// necessity, in the instance-side verifiers (src/bongos/auth-idp.js) — the two
|
|
18
|
+
// sides live across the core<->module boundary and cannot share code (ADR 0091
|
|
19
|
+
// §2 boundary-forced duplication). Keep them in lockstep:
|
|
20
20
|
// token = base64url(JSON(header)) "." base64url(JSON(payload)) "." base64url(sig)
|
|
21
21
|
// header = { alg: "EdDSA", typ: "JWT" }
|
|
22
|
-
// payload = { iss:"cloudbongos-hub", aud:<client_id>, sub:<github_id>,
|
|
23
|
-
// github_login, display_name, avatar_url, iat, exp, jti }
|
|
22
|
+
// payload = { iss:"cloudbongos-hub", aud:<client_id>, sub:<github_id>, …, iat, exp, jti }
|
|
24
23
|
// sig = Ed25519(privkey, utf8(`${b64u(header)}.${b64u(payload)}`))
|
|
24
|
+
// Three token kinds share that envelope, and each verifier demands exactly what
|
|
25
|
+
// the other two lack, so none is ever accepted as another:
|
|
26
|
+
// login assertion … = github_login, display_name, avatar_url (no purpose)
|
|
27
|
+
// logout token … = purpose:'backchannel-logout' (no identity)
|
|
28
|
+
// application vouch … = applicant_login, handle, purpose:'application'
|
|
25
29
|
'use strict';
|
|
26
30
|
|
|
27
31
|
const crypto = require('node:crypto');
|
|
@@ -75,24 +79,17 @@ function hubPublicKeyB64() {
|
|
|
75
79
|
return pub.export({ type: 'spki', format: 'der' }).toString('base64');
|
|
76
80
|
}
|
|
77
81
|
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
function
|
|
82
|
-
{ clientId, githubId, githubLogin, displayName, avatarUrl },
|
|
83
|
-
{ signingKey, nowMs, jti } = {},
|
|
84
|
-
) {
|
|
82
|
+
// signHubToken — the ONE envelope every hub token is minted in: issuer, the
|
|
83
|
+
// caller's claims, then iat/exp (~120s) and a fresh jti. The three minters below
|
|
84
|
+
// differ only in the claims they pass. Throws when no signing key is configured.
|
|
85
|
+
function signHubToken(claims, { signingKey, nowMs, jti } = {}) {
|
|
85
86
|
const key = signingKey || loadHubSigningKey();
|
|
86
87
|
if (!key) throw new Error('hub signing key not configured');
|
|
87
88
|
const iat = Math.floor((nowMs ?? Date.now()) / 1000);
|
|
88
89
|
const header = { alg: 'EdDSA', typ: 'JWT' };
|
|
89
90
|
const payload = {
|
|
90
91
|
iss: ISSUER,
|
|
91
|
-
|
|
92
|
-
sub: String(githubId),
|
|
93
|
-
github_login: githubLogin,
|
|
94
|
-
display_name: displayName ?? null,
|
|
95
|
-
avatar_url: avatarUrl ?? null,
|
|
92
|
+
...claims,
|
|
96
93
|
iat,
|
|
97
94
|
exp: iat + Math.floor(ASSERTION_TTL_MS / 1000),
|
|
98
95
|
jti: jti || crypto.randomBytes(12).toString('base64url'),
|
|
@@ -102,6 +99,22 @@ function mintHubAssertion(
|
|
|
102
99
|
return `${signingInput}.${sig}`;
|
|
103
100
|
}
|
|
104
101
|
|
|
102
|
+
// Mint an audience-scoped, short-lived EdDSA assertion for a federated client. The
|
|
103
|
+
// payload carries IDENTITY ONLY (never a rank). `nowMs`/`jti` are injectable for
|
|
104
|
+
// deterministic tests. Returns the compact token string.
|
|
105
|
+
function mintHubAssertion(
|
|
106
|
+
{ clientId, githubId, githubLogin, displayName, avatarUrl },
|
|
107
|
+
opts = {},
|
|
108
|
+
) {
|
|
109
|
+
return signHubToken({
|
|
110
|
+
aud: String(clientId),
|
|
111
|
+
sub: String(githubId),
|
|
112
|
+
github_login: githubLogin,
|
|
113
|
+
display_name: displayName ?? null,
|
|
114
|
+
avatar_url: avatarUrl ?? null,
|
|
115
|
+
}, opts);
|
|
116
|
+
}
|
|
117
|
+
|
|
105
118
|
// The purpose marker that distinguishes a back-channel LOGOUT token from a login
|
|
106
119
|
// assertion. Both are hub-signed EdDSA JWTs, but a logout token carries `purpose`
|
|
107
120
|
// + NO identity fields, and a login assertion carries identity + NO `purpose` —
|
|
@@ -119,23 +132,28 @@ const LOGOUT_PURPOSE = 'backchannel-logout';
|
|
|
119
132
|
// Same asymmetric signing + ~120s TTL + aud-binding as mintHubAssertion, so a
|
|
120
133
|
// token minted for one instance can't be replayed against another. nowMs/jti
|
|
121
134
|
// injectable for deterministic tests.
|
|
122
|
-
function mintHubLogoutToken({ clientId, githubId },
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
135
|
+
function mintHubLogoutToken({ clientId, githubId }, opts = {}) {
|
|
136
|
+
return signHubToken({ aud: String(clientId), sub: String(githubId), purpose: LOGOUT_PURPOSE }, opts);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The purpose marker on an APPLICATION VOUCH — kept in lockstep with the instance
|
|
140
|
+
// verifier (src/bongos/auth-idp.js HUB_APPLICATION_PURPOSE).
|
|
141
|
+
const APPLICATION_PURPOSE = 'application';
|
|
142
|
+
|
|
143
|
+
// Mint the APPLICATION VOUCH the join relay sends a project (ADR 0201 D2, ADR 0333):
|
|
144
|
+
// "this request comes from this account" — identity + handle, never a rank or record.
|
|
145
|
+
// The login rides as `applicant_login`, NOT `github_login`: every login verifier any
|
|
146
|
+
// core ever shipped demands `github_login`, so even a core that predates the purpose
|
|
147
|
+
// refusal cannot accept a vouch as a device sign-in. `handle` is null unless the
|
|
148
|
+
// caller cleared the consent floor.
|
|
149
|
+
function mintHubApplicationVouch({ clientId, githubId, githubLogin, handle }, opts = {}) {
|
|
150
|
+
return signHubToken({
|
|
129
151
|
aud: String(clientId),
|
|
130
152
|
sub: String(githubId),
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
};
|
|
136
|
-
const signingInput = `${b64uJson(header)}.${b64uJson(payload)}`;
|
|
137
|
-
const sig = crypto.sign(null, Buffer.from(signingInput, 'utf8'), key).toString('base64url');
|
|
138
|
-
return `${signingInput}.${sig}`;
|
|
153
|
+
applicant_login: githubLogin,
|
|
154
|
+
handle: handle ?? null,
|
|
155
|
+
purpose: APPLICATION_PURPOSE,
|
|
156
|
+
}, opts);
|
|
139
157
|
}
|
|
140
158
|
|
|
141
159
|
module.exports = {
|
|
@@ -146,6 +164,7 @@ module.exports = {
|
|
|
146
164
|
loadHubSigningKey,
|
|
147
165
|
hubSigningConfigured,
|
|
148
166
|
hubPublicKeyB64,
|
|
167
|
+
mintHubApplicationVouch,
|
|
149
168
|
mintHubAssertion,
|
|
150
169
|
mintHubLogoutToken,
|
|
151
170
|
};
|
|
@@ -25,6 +25,8 @@ const api = require('../../../src/module-api');
|
|
|
25
25
|
const pi = require('../platform-identity');
|
|
26
26
|
const projectStats = require('../project-stats');
|
|
27
27
|
const applicationEcho = require('../application-echo');
|
|
28
|
+
const hubKeys = require('../hub-keys');
|
|
29
|
+
const { isAccountActive } = require('../account-visibility');
|
|
28
30
|
const { projectPublishability } = require('../publish-gate');
|
|
29
31
|
|
|
30
32
|
const { pool, requireBuilder, validateOrRespond, userAgent } = api;
|
|
@@ -116,6 +118,35 @@ function relayApplicantName(displayName) {
|
|
|
116
118
|
return name || null;
|
|
117
119
|
}
|
|
118
120
|
|
|
121
|
+
// Where the relay carries the vouch — mirrored by the project's public POST
|
|
122
|
+
// (modules/onboarding/routes/access-requests.js; boundary-forced, ADR 0091 §2).
|
|
123
|
+
const APPLICATION_VOUCH_HEADER = 'X-Bongos-Platform-Assertion';
|
|
124
|
+
|
|
125
|
+
// applicationVouchHeaders — the signed vouch for the HUB SESSION's own account
|
|
126
|
+
// (never a body field), as headers to add, or {} (ADR 0333). Best-effort, never a
|
|
127
|
+
// gate. The handle rides only past isAccountActive — the floor the applicant view
|
|
128
|
+
// itself applies, so the vouch shows no handle the reviewer's queue would not.
|
|
129
|
+
async function applicationVouchHeaders(client, builder) {
|
|
130
|
+
const signingKey = hubKeys.loadHubSigningKey();
|
|
131
|
+
if (!signingKey || !pi.isNumericGithubId(builder.github_id)) return {};
|
|
132
|
+
let handle = null;
|
|
133
|
+
try {
|
|
134
|
+
const account = await pi.getAccount(builder.github_id, { pool });
|
|
135
|
+
if (account && isAccountActive(account)) handle = account.handle || null;
|
|
136
|
+
} catch (e) {
|
|
137
|
+
api.logger('platform-identity').warn(`vouch handle read failed, vouching without one (non-fatal): ${e && e.message}`);
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
const vouch = hubKeys.mintHubApplicationVouch({
|
|
141
|
+
clientId: client.client_id, githubId: builder.github_id, githubLogin: builder.github_login, handle,
|
|
142
|
+
}, { signingKey });
|
|
143
|
+
return { [APPLICATION_VOUCH_HEADER]: vouch };
|
|
144
|
+
} catch (e) {
|
|
145
|
+
api.logger('platform-identity').warn(`application vouch mint failed, relaying unvouched (non-fatal): ${e && e.message}`);
|
|
146
|
+
return {};
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
119
150
|
module.exports = function myProjectsRoutes() {
|
|
120
151
|
const router = express.Router();
|
|
121
152
|
|
|
@@ -202,10 +233,11 @@ module.exports = function myProjectsRoutes() {
|
|
|
202
233
|
// the project decides (open-join, or request/invite per ADR 0112). We post to
|
|
203
234
|
// the registry-trusted origin, never the caller's raw input.
|
|
204
235
|
let outcome = null;
|
|
236
|
+
const vouchHeaders = await applicationVouchHeaders(client, req.builder);
|
|
205
237
|
try {
|
|
206
238
|
const r = await fetch(`${client.origin}/api/bongos/access-requests`, {
|
|
207
239
|
method: 'POST',
|
|
208
|
-
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': userAgent() },
|
|
240
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': userAgent(), ...vouchHeaders },
|
|
209
241
|
body: JSON.stringify({
|
|
210
242
|
github_login: req.builder.github_login,
|
|
211
243
|
display_name: relayApplicantName(req.builder.display_name),
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1018",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.1018",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|