@bongos/core 1.19.239 → 1.19.241
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 +39 -24
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0177-goal-membership-kind.md +59 -2
- package/docs/api/openapi.json +5 -1
- package/docs/api-reference.md +1 -1
- package/modules/hall-ui/public/my-projects.js +1 -1
- package/modules/platform-identity/migrations/platform_identity_010_membership_kind.sql +106 -0
- package/modules/platform-identity/platform-identity.js +45 -21
- package/modules/platform-identity/routes/my-projects.js +2 -2
- package/modules/platform-identity/routes/sso.js +20 -5
- package/modules/platform-identity/tests/platform-identity.mjs +26 -13
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +26 -11
- package/scripts/gds/run-unit-tests.js +7 -1
- package/src/bongos/auth.js +21 -6
- package/src/module-api.js +5 -1
- package/tests/government_live_proof.mjs +72 -20
- package/tests/government_live_proof_shape.mjs +124 -0
- package/tests/idp_federation.mjs +8 -6
- package/tests/membership_kind_lockstep.mjs +99 -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.241",
|
|
6
|
+
"core_contract": "1.19.241",
|
|
7
|
+
"source_commit": "3be9d3e72426f99e05a9842dcb5cc9c85cfc75a0",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-18T20:51:17.253Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 312,
|
|
13
13
|
"agent_docs_stubbed": 21,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1327,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1660,
|
|
20
|
+
"tree_sha256": "d8e9cb0898ebdea8fca5a8bc8e94fd925e4725473943002abf4cbbc68e4ae963",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -367,7 +367,7 @@
|
|
|
367
367
|
{
|
|
368
368
|
"path": "clients/bongos-client/index.d.ts",
|
|
369
369
|
"mode": "0000644",
|
|
370
|
-
"sha256": "
|
|
370
|
+
"sha256": "dee98bb9b25e4cf7b2f4644298caa77b1bb9f7ef29560228333131b107be01dd"
|
|
371
371
|
},
|
|
372
372
|
{
|
|
373
373
|
"path": "clients/bongos-client/index.mjs",
|
|
@@ -1367,7 +1367,7 @@
|
|
|
1367
1367
|
{
|
|
1368
1368
|
"path": "docs/adr/0177-goal-membership-kind.md",
|
|
1369
1369
|
"mode": "0000644",
|
|
1370
|
-
"sha256": "
|
|
1370
|
+
"sha256": "1bc4a0df32f8267b5fb3b274974fbcd15d680a161de4c502334dff62da494b17"
|
|
1371
1371
|
},
|
|
1372
1372
|
{
|
|
1373
1373
|
"path": "docs/adr/README.md",
|
|
@@ -1377,12 +1377,12 @@
|
|
|
1377
1377
|
{
|
|
1378
1378
|
"path": "docs/api-reference.md",
|
|
1379
1379
|
"mode": "0000644",
|
|
1380
|
-
"sha256": "
|
|
1380
|
+
"sha256": "c0f20beba35a91ba5f06a1accf3b5df15832d719f14af729824295da60d33e30"
|
|
1381
1381
|
},
|
|
1382
1382
|
{
|
|
1383
1383
|
"path": "docs/api/openapi.json",
|
|
1384
1384
|
"mode": "0000644",
|
|
1385
|
-
"sha256": "
|
|
1385
|
+
"sha256": "50e9b99155cd83e625f1e0289a4e8ab6a2817b2701dc3d62cfb3d902c3acf3bd"
|
|
1386
1386
|
},
|
|
1387
1387
|
{
|
|
1388
1388
|
"path": "docs/architecture.md",
|
|
@@ -3362,7 +3362,7 @@
|
|
|
3362
3362
|
{
|
|
3363
3363
|
"path": "modules/hall-ui/public/my-projects.js",
|
|
3364
3364
|
"mode": "0000644",
|
|
3365
|
-
"sha256": "
|
|
3365
|
+
"sha256": "9b5604fd1d071bcb61c1ce77a8356fbce8060db09440ba5621646aa18b6f5b5f"
|
|
3366
3366
|
},
|
|
3367
3367
|
{
|
|
3368
3368
|
"path": "modules/hall-ui/public/not-ready.html",
|
|
@@ -3904,6 +3904,11 @@
|
|
|
3904
3904
|
"mode": "0000644",
|
|
3905
3905
|
"sha256": "00e2e9feccc56bdb30a5e4f0c6abbe610382fe425222419963a04ae022d3c58a"
|
|
3906
3906
|
},
|
|
3907
|
+
{
|
|
3908
|
+
"path": "modules/platform-identity/migrations/platform_identity_010_membership_kind.sql",
|
|
3909
|
+
"mode": "0000644",
|
|
3910
|
+
"sha256": "8f03dc84d8fe3864aa5f933512df71259c266e8a16214a926cf89eaf01cc5668"
|
|
3911
|
+
},
|
|
3907
3912
|
{
|
|
3908
3913
|
"path": "modules/platform-identity/module.json",
|
|
3909
3914
|
"mode": "0000644",
|
|
@@ -3912,7 +3917,7 @@
|
|
|
3912
3917
|
{
|
|
3913
3918
|
"path": "modules/platform-identity/platform-identity.js",
|
|
3914
3919
|
"mode": "0000644",
|
|
3915
|
-
"sha256": "
|
|
3920
|
+
"sha256": "a563fb701e62d665d10baf25761f9341678264fa2c6c73f1efeb9eb20ba6a439"
|
|
3916
3921
|
},
|
|
3917
3922
|
{
|
|
3918
3923
|
"path": "modules/platform-identity/pollers/code-sweep.js",
|
|
@@ -3927,7 +3932,7 @@
|
|
|
3927
3932
|
{
|
|
3928
3933
|
"path": "modules/platform-identity/routes/my-projects.js",
|
|
3929
3934
|
"mode": "0000644",
|
|
3930
|
-
"sha256": "
|
|
3935
|
+
"sha256": "de5aa85aedf31834009ec0e1a3b4a7ceea2ff60bbb6586a3f231b59eb9549aaf"
|
|
3931
3936
|
},
|
|
3932
3937
|
{
|
|
3933
3938
|
"path": "modules/platform-identity/routes/profile.js",
|
|
@@ -3952,12 +3957,12 @@
|
|
|
3952
3957
|
{
|
|
3953
3958
|
"path": "modules/platform-identity/routes/sso.js",
|
|
3954
3959
|
"mode": "0000644",
|
|
3955
|
-
"sha256": "
|
|
3960
|
+
"sha256": "e4cd3084c44b6ff3e3fdad141e530b098b330a25d609fd08f5ae2290fd251db0"
|
|
3956
3961
|
},
|
|
3957
3962
|
{
|
|
3958
3963
|
"path": "modules/platform-identity/tests/platform-identity.mjs",
|
|
3959
3964
|
"mode": "0000644",
|
|
3960
|
-
"sha256": "
|
|
3965
|
+
"sha256": "6f442cd4ae9ebb770e3537ae82da30e555f7e6ce3a293a60fbd154eff6c1eaf1"
|
|
3961
3966
|
},
|
|
3962
3967
|
{
|
|
3963
3968
|
"path": "modules/platform-identity/ui/.gitkeep",
|
|
@@ -4342,12 +4347,12 @@
|
|
|
4342
4347
|
{
|
|
4343
4348
|
"path": "package-lock.json",
|
|
4344
4349
|
"mode": "0000644",
|
|
4345
|
-
"sha256": "
|
|
4350
|
+
"sha256": "8f961bb896bdf312ad73752daa2342ce9ccf7db02b21ac9d2480694db28857d8"
|
|
4346
4351
|
},
|
|
4347
4352
|
{
|
|
4348
4353
|
"path": "package.json",
|
|
4349
4354
|
"mode": "0000644",
|
|
4350
|
-
"sha256": "
|
|
4355
|
+
"sha256": "ac7bd7958b32f4eee03a29fab16260eb489d49c0e40907691bdd9c3e447d6a5d"
|
|
4351
4356
|
},
|
|
4352
4357
|
{
|
|
4353
4358
|
"path": "public-docs/index.html",
|
|
@@ -4972,7 +4977,7 @@
|
|
|
4972
4977
|
{
|
|
4973
4978
|
"path": "scripts/gds/fitness.js",
|
|
4974
4979
|
"mode": "0000644",
|
|
4975
|
-
"sha256": "
|
|
4980
|
+
"sha256": "add1ee5439ef3ddff171e7eb9a695c63a41b8323c5e8cc6c415b620b812bdfa8"
|
|
4976
4981
|
},
|
|
4977
4982
|
{
|
|
4978
4983
|
"path": "scripts/gds/gate-review.js",
|
|
@@ -5332,7 +5337,7 @@
|
|
|
5332
5337
|
{
|
|
5333
5338
|
"path": "scripts/gds/run-unit-tests.js",
|
|
5334
5339
|
"mode": "0000644",
|
|
5335
|
-
"sha256": "
|
|
5340
|
+
"sha256": "192350d9d65934a1af724d0f285cc74a89a842e75d0fdec37037d34ded5db546"
|
|
5336
5341
|
},
|
|
5337
5342
|
{
|
|
5338
5343
|
"path": "scripts/gds/sandbox-stage.js",
|
|
@@ -5722,7 +5727,7 @@
|
|
|
5722
5727
|
{
|
|
5723
5728
|
"path": "src/bongos/auth.js",
|
|
5724
5729
|
"mode": "0000644",
|
|
5725
|
-
"sha256": "
|
|
5730
|
+
"sha256": "803d4cc68847dda227c8581be493c2b4e799d1bf81acd40794efbf7b2b625a24"
|
|
5726
5731
|
},
|
|
5727
5732
|
{
|
|
5728
5733
|
"path": "src/bongos/cascade-dispatch.js",
|
|
@@ -5962,7 +5967,7 @@
|
|
|
5962
5967
|
{
|
|
5963
5968
|
"path": "src/module-api.js",
|
|
5964
5969
|
"mode": "0000644",
|
|
5965
|
-
"sha256": "
|
|
5970
|
+
"sha256": "d2dd5e30bb7f44cd8a37cbf559bbcdf76aec973043f3db5deec8988b62a59eb6"
|
|
5966
5971
|
},
|
|
5967
5972
|
{
|
|
5968
5973
|
"path": "src/module-loader/catalog.js",
|
|
@@ -6967,7 +6972,12 @@
|
|
|
6967
6972
|
{
|
|
6968
6973
|
"path": "tests/government_live_proof.mjs",
|
|
6969
6974
|
"mode": "0000644",
|
|
6970
|
-
"sha256": "
|
|
6975
|
+
"sha256": "6d829af6ef71bfa744a4ff457a4766b5cce021f317661579b06ecfc14298a523"
|
|
6976
|
+
},
|
|
6977
|
+
{
|
|
6978
|
+
"path": "tests/government_live_proof_shape.mjs",
|
|
6979
|
+
"mode": "0000644",
|
|
6980
|
+
"sha256": "75160048aba7b8c2264478c29aa2fc19636c67f66cd63842a83851574b722469"
|
|
6971
6981
|
},
|
|
6972
6982
|
{
|
|
6973
6983
|
"path": "tests/government_ownership_scope.mjs",
|
|
@@ -7212,7 +7222,7 @@
|
|
|
7212
7222
|
{
|
|
7213
7223
|
"path": "tests/idp_federation.mjs",
|
|
7214
7224
|
"mode": "0000644",
|
|
7215
|
-
"sha256": "
|
|
7225
|
+
"sha256": "5130936408829e4e45742eda0124aac9df50865ece1812274354384f9da498fd"
|
|
7216
7226
|
},
|
|
7217
7227
|
{
|
|
7218
7228
|
"path": "tests/infra_exec_paths.mjs",
|
|
@@ -7334,6 +7344,11 @@
|
|
|
7334
7344
|
"mode": "0000644",
|
|
7335
7345
|
"sha256": "9a1813d6036886ea07d1563e71a466e66c28554b75b22605464c795fd9fa2ebb"
|
|
7336
7346
|
},
|
|
7347
|
+
{
|
|
7348
|
+
"path": "tests/membership_kind_lockstep.mjs",
|
|
7349
|
+
"mode": "0000644",
|
|
7350
|
+
"sha256": "7ebca67bf07f723f47a861eb530a0feee25967fe3c0f4a9e7d76703351dc8d84"
|
|
7351
|
+
},
|
|
7337
7352
|
{
|
|
7338
7353
|
"path": "tests/memory_isolation.mjs",
|
|
7339
7354
|
"mode": "0000644",
|
|
@@ -294,7 +294,7 @@ export interface PostSsoActivityRollupRequest { client_id: string; client_secret
|
|
|
294
294
|
export interface PostSsoActivityRollupResponse { ok: boolean }
|
|
295
295
|
export interface PostSsoDevicePollRequest { device_code: string }
|
|
296
296
|
export interface PostSsoDeviceStartRequest { origin: string }
|
|
297
|
-
export interface PostSsoMembershipCheckInRequest { client_id: string; client_secret: string; github_id: StringifiableId; github_login: string; display_name?: string; avatar_url?: string; role?: string }
|
|
297
|
+
export interface PostSsoMembershipCheckInRequest { client_id: string; client_secret: string; github_id: StringifiableId; github_login: string; display_name?: string; avatar_url?: string; membership_kind?: string; role?: string }
|
|
298
298
|
export interface PostSsoMembershipCheckInResponse { ok: boolean }
|
|
299
299
|
export interface PostSsoTokenRequest { client_id: string; client_secret: string; code: string; redirect_uri: string }
|
|
300
300
|
export interface PostSsoTokenResponse { github_id: unknown; github_login: unknown; display_name: unknown; avatar_url: unknown }
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
- **Status:** Accepted
|
|
4
4
|
- **Date:** 2026-08-18
|
|
5
|
-
- **
|
|
5
|
+
- **Tasks:** [#1003013](https://cloudbongos.com/builders#/task/1003013) (goal 1000068 — government terminology; unlinked from the goal's criteria on 2026-08-18 because it is a live-column migration, not a naming pass)
|
|
6
6
|
- **Extends:** [ADR 0174](<redacted>.md) — the reserved-word rule, applied to the fourth use it did not cover
|
|
7
7
|
- **Related:** ADR 0016 (authority resolved server-side, per request, uncached), 0086 (goals + scope walls), 0154 (goal owner/manager authors criteria), 0137/0161 (land → release → deploy are three gates)
|
|
8
8
|
|
|
@@ -83,6 +83,62 @@ regression available — a membership query reaching for the old column — with
|
|
|
83
83
|
only exemption is a line that CITES the rule (ADR 0174 / ADR 0177 / task 1003013); naming a ban has to
|
|
84
84
|
be legal or the code cannot explain itself. R07 stays scoped to the government surface, unchanged.
|
|
85
85
|
|
|
86
|
+
## Amendment, same day — the FIFTH use: project membership (task [#1003090](https://cloudbongos.com/builders#/task/1003090))
|
|
87
|
+
|
|
88
|
+
The sweep for the fourth use turned up a fifth, and it is worse: the hub's
|
|
89
|
+
`<redacted>.role` (`'owner' | 'builder' | 'visitor' |
|
|
90
|
+
'pending'`, ADR 0141 §3) collides with the reserved word **twice** — the column is named
|
|
91
|
+
`role`, and one of its VALUES is `'builder'`, a craft name pressed into meaning "a
|
|
92
|
+
non-owner member of this project". The migration's own comment ("a COARSE role ONLY …
|
|
93
|
+
the role is owner|builder|visitor, NEVER a rank") is the same qualifier tell ADR 0174
|
|
94
|
+
diagnosed in "rank-role".
|
|
95
|
+
|
|
96
|
+
Decisions 1, 2 and 4 above carry over unchanged: the column becomes `membership_kind`,
|
|
97
|
+
the value becomes `'member'` (`owner` / `visitor` / `pending` untouched), the migration
|
|
98
|
+
is expand-then-contract with a lockstep trigger (`<redacted>`), and the
|
|
99
|
+
fitness wall's file list grows rather than the rule widening. **Decision 3 is the one
|
|
100
|
+
that does NOT carry over.**
|
|
101
|
+
|
|
102
|
+
**Decision 3′ — this rename keeps a compatibility window, and the sender writes BOTH
|
|
103
|
+
spellings.** This is a deliberate, named divergence from decision 3 above *and* from the
|
|
104
|
+
sequence task #1003090 itself prescribed ("the API field renamed with no alias"); the
|
|
105
|
+
task wrote that sequence before anyone had looked at who calls the endpoint. Decision 3
|
|
106
|
+
holds wherever every caller ships in one release, which is why it stays the default — and
|
|
107
|
+
it does not hold here. The goal-membership rename could ship without an alias because every
|
|
108
|
+
caller lived in the same release. This table's writer is `POST
|
|
109
|
+
/sso/membership/check-in` — a **server-to-server call from other instances**, each on
|
|
110
|
+
its own upgrade schedule (ADR 0137/0161: land, release and deploy are three separate
|
|
111
|
+
gates *per instance*). So:
|
|
112
|
+
|
|
113
|
+
- the **hub accepts both** fields, `membership_kind` winning when both arrive, and maps
|
|
114
|
+
a legacy `'builder'` to `'member'` on the way in;
|
|
115
|
+
- the **sender sends both** (`src/bongos/auth.js`). This is the part worth writing down:
|
|
116
|
+
sending only the new field would make an un-upgraded hub silently default every
|
|
117
|
+
membership to non-owner — *dropping the owner flag* — while sending only the legacy
|
|
118
|
+
field would make an upgraded hub translate forever. Sending both is what makes the two
|
|
119
|
+
ends independent;
|
|
120
|
+
- the trigger therefore maps **values as well as columns**, in both directions, which
|
|
121
|
+
`core_220`'s did not have to.
|
|
122
|
+
|
|
123
|
+
**And a trap both triggers share, found by the grader on #1003090.** The INSERT branch has
|
|
124
|
+
to tell "the caller wrote the new column" from "the column's DEFAULT filled it" — and it
|
|
125
|
+
cannot, if the new column has a DEFAULT: **Postgres materializes a column default into
|
|
126
|
+
`NEW` before any BEFORE ROW trigger fires.** With a default in place, an old-release
|
|
127
|
+
`INSERT … (client_id, github_id, role) VALUES (…, 'pending')` — `inviteToProject`'s exact
|
|
128
|
+
statement — took the "new code wrote it" branch and overwrote `role`, turning a pending
|
|
129
|
+
INVITE into an active membership. So the new column stays **DEFAULT-less** while the legacy
|
|
130
|
+
one is still written (NULL then means exactly "not written"), and `DEFAULT` + `NOT NULL`
|
|
131
|
+
arrive together in step 2. `core_220` shipped with the defective form and is corrected
|
|
132
|
+
under task [#1003093](https://cloudbongos.com/builders#/task/1003093) — where the same
|
|
133
|
+
statement demotes a goal's first lead to `member` on a fresh install; the invariant is
|
|
134
|
+
pinned statically by `tests/membership_kind_lockstep.mjs`, since the trigger only
|
|
135
|
+
misbehaves against a live Postgres and CI has none.
|
|
136
|
+
|
|
137
|
+
The window's removal is a filed task, and its condition is explicit: every federated
|
|
138
|
+
instance past the window, not merely "one release later". A time-boxed shim still ships
|
|
139
|
+
with the test of its absence (ADR 0174's Lineage lesson) — that test lands with the
|
|
140
|
+
removal, because until then the legacy field is load-bearing, not vestigial.
|
|
141
|
+
|
|
86
142
|
## Consequences
|
|
87
143
|
|
|
88
144
|
- The word `role` no longer names a goal membership in the API, the hall, or the docs, and in the DB it
|
|
@@ -93,4 +149,5 @@ be legal or the code cannot explain itself. R07 stays scoped to the government s
|
|
|
93
149
|
the only new moving part on the write path, and it is the thing step 2 removes.
|
|
94
150
|
- `docs/canonical-permissions.md` (the goal-management wall) now names `membership_kind`; the
|
|
95
151
|
`authorizeGoalManagement` behaviour is untouched — this ADR changes no authority, floor, or grant.
|
|
96
|
-
- The fifth use (`project_memberships.role`)
|
|
152
|
+
- The fifth use (`project_memberships.role`) shipped the same day under the amendment above — same shape, plus a cross-version window because its writer is another instance.
|
|
153
|
+
- **No sixth use is known.** The remaining `role` spellings in the repo are the three legitimate ones ADR 0174 named: the crafts, `builders.system_role` (machine identity), and Discord's own noun — plus HTML's ARIA attribute, which every wall strips before matching.
|
package/docs/api/openapi.json
CHANGED
|
@@ -11117,7 +11117,7 @@
|
|
|
11117
11117
|
"sso"
|
|
11118
11118
|
],
|
|
11119
11119
|
"summary": "POST /sso/membership/check-in",
|
|
11120
|
-
"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
|
|
11120
|
+
"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.",
|
|
11121
11121
|
"x-rank": "public",
|
|
11122
11122
|
"x-source": "modules/platform-identity/routes/sso.js",
|
|
11123
11123
|
"requestBody": {
|
|
@@ -17333,6 +17333,10 @@
|
|
|
17333
17333
|
"type": "string",
|
|
17334
17334
|
"maxLength": 2048
|
|
17335
17335
|
},
|
|
17336
|
+
"membership_kind": {
|
|
17337
|
+
"type": "string",
|
|
17338
|
+
"maxLength": 16
|
|
17339
|
+
},
|
|
17336
17340
|
"role": {
|
|
17337
17341
|
"type": "string",
|
|
17338
17342
|
"maxLength": 16
|
package/docs/api-reference.md
CHANGED
|
@@ -474,7 +474,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
|
|
|
474
474
|
| GET | `/api/bongos/sso/authorize` | `public` | — | GET /sso/authorize?client_id=&redirect_uri=&state= — the browser authorize leg. |
|
|
475
475
|
| POST | `/api/bongos/sso/device/poll` | `public` | `device_code` | POST /sso/device/poll — poll the hub device flow. |
|
|
476
476
|
| POST | `/api/bongos/sso/device/start` | `public` | `origin` | POST /sso/device/start — begin CLI federation. |
|
|
477
|
-
| POST | `/api/bongos/sso/membership/check-in` | `public` | `client_id`, `client_secret`, `github_id`, `github_login`, `display_name`, `avatar_url`, `role` | POST /sso/membership/check-in — a federated instance reports, on every sign-in, that a builder builds on its project (ADR 0141 §3). |
|
|
477
|
+
| POST | `/api/bongos/sso/membership/check-in` | `public` | `client_id`, `client_secret`, `github_id`, `github_login`, `display_name`, `avatar_url`, `membership_kind`, `role` | POST /sso/membership/check-in — a federated instance reports, on every sign-in, that a builder builds on its project (ADR 0141 §3). |
|
|
478
478
|
| GET | `/api/bongos/sso/pubkey` | `public` | — | GET /sso/pubkey — the hub's Ed25519 assertion-verification key (public). |
|
|
479
479
|
| POST | `/api/bongos/sso/token` | `public` | `client_id`, `client_secret`, `code`, `redirect_uri` | POST /sso/token — server-to-server code redemption. |
|
|
480
480
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
-- platform_identity_010_membership_kind.sql — the FIFTH "role" leaves the schema
|
|
2
|
+
-- (task 1003090; ADR 0174 reserves the word for the three crafts, ADR 0177 §"the
|
|
3
|
+
-- fifth use" records this two-step).
|
|
4
|
+
--
|
|
5
|
+
-- `platform_identity_project_memberships.role` holds the hub's COARSE project
|
|
6
|
+
-- membership — and it collided with the reserved word TWICE over:
|
|
7
|
+
-- • the COLUMN is named `role`, which ADR 0174 reserves for Artist / Ideator /
|
|
8
|
+
-- Builder;
|
|
9
|
+
-- • one of its VALUES is literally 'builder', used to mean "a non-owner member
|
|
10
|
+
-- of this project" — a craft name doing membership work.
|
|
11
|
+
-- The column becomes `membership_kind` and that value becomes 'member'. 'owner',
|
|
12
|
+
-- 'visitor' and 'pending' are unchanged.
|
|
13
|
+
--
|
|
14
|
+
-- ── EXPAND-THEN-CONTRACT, AND WHY THE WINDOW IS LONGER HERE ─────────────────
|
|
15
|
+
-- Same reasoning as core_220 (ADR 0177): `bongos upgrade` auto-rollback reverts the
|
|
16
|
+
-- code pin and NOT the DB, so a bare RENAME COLUMN plus a rolled-back deploy
|
|
17
|
+
-- strands the previous release on a missing column. But this table has a second,
|
|
18
|
+
-- stronger reason: it is written by POST /sso/membership/check-in, a
|
|
19
|
+
-- SERVER-TO-SERVER call from OTHER INSTANCES (ADR 0141 §3) that upgrade on their
|
|
20
|
+
-- own schedule. The legacy `role` column and the legacy request field therefore
|
|
21
|
+
-- outlive one release by design — until every federated instance is past the
|
|
22
|
+
-- window. The removal task carries that condition.
|
|
23
|
+
--
|
|
24
|
+
-- Idempotent, additive, platform_identity_-namespaced (ADR 0083 §5). Forward-safe:
|
|
25
|
+
-- old code writing `role` still lands a complete row (the trigger below), and no
|
|
26
|
+
-- NOT NULL is added to the new column while the old one is still written.
|
|
27
|
+
|
|
28
|
+
BEGIN;
|
|
29
|
+
|
|
30
|
+
ALTER TABLE platform_identity_project_memberships
|
|
31
|
+
ADD COLUMN IF NOT EXISTS membership_kind text;
|
|
32
|
+
|
|
33
|
+
-- Backfill WITH the value rename: 'builder' → 'member', everything else verbatim.
|
|
34
|
+
UPDATE platform_identity_project_memberships
|
|
35
|
+
SET membership_kind = CASE WHEN role = 'builder' THEN 'member' ELSE role END
|
|
36
|
+
WHERE membership_kind IS NULL;
|
|
37
|
+
|
|
38
|
+
-- NO DEFAULT ON THE NEW COLUMN WHILE THE LEGACY ONE IS STILL WRITTEN. This is
|
|
39
|
+
-- load-bearing, and it is subtle: Postgres materializes a column DEFAULT into NEW
|
|
40
|
+
-- *before* any BEFORE ROW trigger fires, so a default here would make
|
|
41
|
+
-- `NEW.membership_kind IS NULL`never true and the trigger could no longer tell
|
|
42
|
+
-- "the caller wrote the new column" from "the default filled it". An old-release
|
|
43
|
+
-- INSERT naming only `role` (inviteToProject's `role = 'pending'`, or an owner
|
|
44
|
+
-- check-in) would then be silently rewritten to the default's mapping — turning a
|
|
45
|
+
-- pending INVITE into an active membership. Leaving the column DEFAULT-less makes
|
|
46
|
+
-- NULL mean exactly "not written", which is what the trigger branches on. The
|
|
47
|
+
-- default (and NOT NULL) arrive in step 2, when `role` is dropped and nothing
|
|
48
|
+
-- writes the old name any more.
|
|
49
|
+
|
|
50
|
+
DO $$
|
|
51
|
+
BEGIN
|
|
52
|
+
IF NOT EXISTS (
|
|
53
|
+
SELECT 1 FROM pg_constraint
|
|
54
|
+
WHERE conrelid = 'platform_identity_project_memberships'::regclass
|
|
55
|
+
AND conname = 'platform_identity_project_memberships_membership_kind_check'
|
|
56
|
+
) THEN
|
|
57
|
+
ALTER TABLE platform_identity_project_memberships
|
|
58
|
+
ADD CONSTRAINT platform_identity_project_memberships_membership_kind_check
|
|
59
|
+
CHECK (membership_kind IN ('owner', 'member', 'visitor', 'pending'));
|
|
60
|
+
END IF;
|
|
61
|
+
END $$;
|
|
62
|
+
|
|
63
|
+
-- The lockstep, with the value MAPPING in both directions — the difference from
|
|
64
|
+
-- core_220's trigger, which only had to copy a value. Direction is decided by
|
|
65
|
+
-- which side changed, so a check-in from an un-upgraded instance (writing `role`
|
|
66
|
+
-- = 'builder') lands membership_kind = 'member', and new code writing
|
|
67
|
+
-- membership_kind = 'member' keeps `role` = 'builder' readable by an old release.
|
|
68
|
+
CREATE OR REPLACE FUNCTION platform_identity_membership_kind_lockstep() RETURNS trigger AS $$
|
|
69
|
+
DECLARE
|
|
70
|
+
kind_from_role text;
|
|
71
|
+
role_from_kind text;
|
|
72
|
+
BEGIN
|
|
73
|
+
kind_from_role := CASE WHEN NEW.role = 'builder' THEN 'member' ELSE NEW.role END;
|
|
74
|
+
role_from_kind := CASE WHEN NEW.membership_kind = 'member' THEN 'builder' ELSE NEW.membership_kind END;
|
|
75
|
+
IF TG_OP = 'INSERT' THEN
|
|
76
|
+
-- NULL means the caller did not write the new column (see the no-DEFAULT note
|
|
77
|
+
-- above): that is an old-release INSERT, so `role` is the source of truth and
|
|
78
|
+
-- the new column follows it. Otherwise the caller is new code and `role`
|
|
79
|
+
-- follows, mapped, so an older release can still read the row.
|
|
80
|
+
IF NEW.membership_kind IS NULL THEN
|
|
81
|
+
NEW.membership_kind := COALESCE(kind_from_role, 'member');
|
|
82
|
+
NEW.role := COALESCE(NEW.role, 'builder');
|
|
83
|
+
ELSE
|
|
84
|
+
NEW.role := role_from_kind;
|
|
85
|
+
END IF;
|
|
86
|
+
ELSE
|
|
87
|
+
IF NEW.membership_kind IS DISTINCT FROM OLD.membership_kind THEN
|
|
88
|
+
NEW.role := role_from_kind;
|
|
89
|
+
ELSIF NEW.role IS DISTINCT FROM OLD.role THEN
|
|
90
|
+
NEW.membership_kind := kind_from_role;
|
|
91
|
+
END IF;
|
|
92
|
+
END IF;
|
|
93
|
+
RETURN NEW;
|
|
94
|
+
END;
|
|
95
|
+
$$ LANGUAGE plpgsql;
|
|
96
|
+
|
|
97
|
+
DROP TRIGGER IF EXISTS platform_identity_membership_kind_lockstep
|
|
98
|
+
ON platform_identity_project_memberships;
|
|
99
|
+
CREATE TRIGGER platform_identity_membership_kind_lockstep
|
|
100
|
+
BEFORE INSERT OR UPDATE ON platform_identity_project_memberships
|
|
101
|
+
FOR EACH ROW EXECUTE FUNCTION platform_identity_membership_kind_lockstep();
|
|
102
|
+
|
|
103
|
+
INSERT INTO schema_migrations (version) VALUES ('platform_identity_010_membership_kind')
|
|
104
|
+
ON CONFLICT DO NOTHING;
|
|
105
|
+
|
|
106
|
+
COMMIT;
|
|
@@ -307,31 +307,53 @@ async function setProjectFeatured(id, featured, { pool = defaultPool } = {}) {
|
|
|
307
307
|
// Project memberships (the cross-project directory — ADR 0141 §3)
|
|
308
308
|
// -------------------------------------------------------------------------
|
|
309
309
|
|
|
310
|
-
|
|
310
|
+
// THE COARSE PROJECT MEMBERSHIP — 'membership_kind', never 'role' (task 1003090,
|
|
311
|
+
// ADR 0177 §"the fifth use"; ADR 0174 reserves that word for the three crafts). The
|
|
312
|
+
// old value 'builder' is now 'member': it meant "a non-owner member of this
|
|
313
|
+
// project", which is membership work done by a craft name.
|
|
314
|
+
//
|
|
315
|
+
// THE LEGACY COLUMN AND FIELD SURVIVE THIS RELEASE ON PURPOSE. This table is
|
|
316
|
+
// written by POST /sso/membership/check-in — a server-to-server call from OTHER
|
|
317
|
+
// INSTANCES (ADR 0141 §3) that upgrade on their own schedule — so both spellings
|
|
318
|
+
// are read on the way in and platform_identity_010's trigger keeps both columns
|
|
319
|
+
// (and both value vocabularies) in lockstep. Write `membership_kind` here; the
|
|
320
|
+
// removal task drops the pair once every federated instance is past the window.
|
|
321
|
+
const MEMBERSHIP_KINDS = ['owner', 'member', 'visitor', 'pending'];
|
|
322
|
+
|
|
323
|
+
// Map the legacy wire/column vocabulary onto the current one and back: the craft
|
|
324
|
+
// name 'builder' ⇄ 'member'; every other value was already right. Exported so the
|
|
325
|
+
// route, the sender and the migration's trigger all agree on one mapping. (task
|
|
326
|
+
// 1003090 — the legacy spelling is named here by necessity: this IS the translator.)
|
|
327
|
+
function membershipKindFromLegacy(legacyValue) {
|
|
328
|
+
return legacyValue === 'builder' ? 'member' : legacyValue;
|
|
329
|
+
}
|
|
330
|
+
function legacyRoleFromKind(kind) {
|
|
331
|
+
return kind === 'member' ? 'builder' : kind;
|
|
332
|
+
}
|
|
311
333
|
|
|
312
334
|
// Upsert a builder's membership in a project (keyed by the reporting client + the
|
|
313
|
-
// builder's github_id). COARSE
|
|
314
|
-
// every check-in. Returns the row.
|
|
315
|
-
async function upsertMembership({ clientId, githubId,
|
|
316
|
-
const
|
|
335
|
+
// builder's github_id). COARSE membership only — never a rank. Refreshes
|
|
336
|
+
// last_active on every check-in. Returns the row.
|
|
337
|
+
async function upsertMembership({ clientId, githubId, membershipKind = 'member' }, { pool = defaultPool } = {}) {
|
|
338
|
+
const k = MEMBERSHIP_KINDS.includes(membershipKind) ? membershipKind : 'member';
|
|
317
339
|
const { rows } = await pool.query(
|
|
318
|
-
`INSERT INTO platform_identity_project_memberships (client_id, github_id,
|
|
340
|
+
`INSERT INTO platform_identity_project_memberships (client_id, github_id, membership_kind)
|
|
319
341
|
VALUES ($1, $2, $3)
|
|
320
342
|
ON CONFLICT (client_id, github_id) DO UPDATE SET
|
|
321
|
-
|
|
343
|
+
membership_kind = EXCLUDED.membership_kind,
|
|
322
344
|
last_active = now()
|
|
323
|
-
RETURNING id, client_id, github_id,
|
|
324
|
-
[clientId, githubId,
|
|
345
|
+
RETURNING id, client_id, github_id, membership_kind, joined_at, last_active`,
|
|
346
|
+
[clientId, githubId, k],
|
|
325
347
|
);
|
|
326
348
|
return rows[0];
|
|
327
349
|
}
|
|
328
350
|
|
|
329
|
-
// One membership row for (project, builder), any
|
|
351
|
+
// One membership row for (project, builder), any membership_kind — or null. Backs the
|
|
330
352
|
// activity-rollup membership requirement (ADR 0171 D7): a client may only report
|
|
331
353
|
// activity for a builder who actually has a membership on that project.
|
|
332
354
|
async function getMembership(clientId, githubId, { pool = defaultPool } = {}) {
|
|
333
355
|
const { rows } = await pool.query(
|
|
334
|
-
`SELECT id, client_id, github_id,
|
|
356
|
+
`SELECT id, client_id, github_id, membership_kind, joined_at, last_active
|
|
335
357
|
FROM platform_identity_project_memberships
|
|
336
358
|
WHERE client_id = $1 AND github_id = $2`,
|
|
337
359
|
[clientId, githubId],
|
|
@@ -343,11 +365,11 @@ async function getMembership(clientId, githubId, { pool = defaultPool } = {}) {
|
|
|
343
365
|
// display fields (name/origin from sso_clients). Powers "My Projects" (task #1002112).
|
|
344
366
|
async function listMembershipsForAccount(githubId, { pool = defaultPool } = {}) {
|
|
345
367
|
const { rows } = await pool.query(
|
|
346
|
-
`SELECT m.client_id, m.
|
|
368
|
+
`SELECT m.client_id, m.membership_kind, m.joined_at, m.last_active,
|
|
347
369
|
c.name AS project_name, c.origin AS project_origin, c.owner_github_id
|
|
348
370
|
FROM platform_identity_project_memberships m
|
|
349
371
|
LEFT JOIN platform_identity_sso_clients c ON c.client_id = m.client_id
|
|
350
|
-
WHERE m.github_id = $1 AND m.
|
|
372
|
+
WHERE m.github_id = $1 AND m.membership_kind <> 'pending'
|
|
351
373
|
ORDER BY m.last_active DESC`,
|
|
352
374
|
[githubId],
|
|
353
375
|
);
|
|
@@ -364,10 +386,10 @@ async function listMembershipsForAccount(githubId, { pool = defaultPool } = {})
|
|
|
364
386
|
// created, or { invited:false, alreadyMember:true } when a membership already existed.
|
|
365
387
|
async function inviteToProject({ clientId, githubId }, { pool = defaultPool } = {}) {
|
|
366
388
|
const { rows } = await pool.query(
|
|
367
|
-
`INSERT INTO platform_identity_project_memberships (client_id, github_id,
|
|
389
|
+
`INSERT INTO platform_identity_project_memberships (client_id, github_id, membership_kind)
|
|
368
390
|
VALUES ($1, $2, 'pending')
|
|
369
391
|
ON CONFLICT (client_id, github_id) DO NOTHING
|
|
370
|
-
RETURNING id, client_id, github_id,
|
|
392
|
+
RETURNING id, client_id, github_id, membership_kind`,
|
|
371
393
|
[clientId, githubId],
|
|
372
394
|
);
|
|
373
395
|
if (rows[0]) return { invited: true, row: rows[0] };
|
|
@@ -381,22 +403,22 @@ async function listPendingInvitesForAccount(githubId, { pool = defaultPool } = {
|
|
|
381
403
|
c.name AS project_name, c.origin AS project_origin
|
|
382
404
|
FROM platform_identity_project_memberships m
|
|
383
405
|
LEFT JOIN platform_identity_sso_clients c ON c.client_id = m.client_id
|
|
384
|
-
WHERE m.github_id = $1 AND m.
|
|
406
|
+
WHERE m.github_id = $1 AND m.membership_kind = 'pending'
|
|
385
407
|
ORDER BY m.joined_at DESC`,
|
|
386
408
|
[githubId],
|
|
387
409
|
);
|
|
388
410
|
return rows;
|
|
389
411
|
}
|
|
390
412
|
|
|
391
|
-
// Resolve a pending invite for (client, builder): accept → flip to '
|
|
413
|
+
// Resolve a pending invite for (client, builder): accept → flip to 'member' (the
|
|
392
414
|
// membership becomes real; the builder then signs into the project, whose own gate
|
|
393
415
|
// still applies + whose check-in keeps it fresh); decline → delete the pending row.
|
|
394
416
|
// Only ever touches a 'pending' row, so it can't downgrade or remove a real member.
|
|
395
417
|
async function resolveInvite({ clientId, githubId, accept }, { pool = defaultPool } = {}) {
|
|
396
418
|
if (accept) {
|
|
397
419
|
const { rows } = await pool.query(
|
|
398
|
-
`UPDATE platform_identity_project_memberships SET
|
|
399
|
-
WHERE client_id = $1 AND github_id = $2 AND
|
|
420
|
+
`UPDATE platform_identity_project_memberships SET membership_kind = 'member', last_active = now()
|
|
421
|
+
WHERE client_id = $1 AND github_id = $2 AND membership_kind = 'pending'
|
|
400
422
|
RETURNING id`,
|
|
401
423
|
[clientId, githubId],
|
|
402
424
|
);
|
|
@@ -404,7 +426,7 @@ async function resolveInvite({ clientId, githubId, accept }, { pool = defaultPoo
|
|
|
404
426
|
}
|
|
405
427
|
const { rowCount } = await pool.query(
|
|
406
428
|
`DELETE FROM platform_identity_project_memberships
|
|
407
|
-
WHERE client_id = $1 AND github_id = $2 AND
|
|
429
|
+
WHERE client_id = $1 AND github_id = $2 AND membership_kind = 'pending'`,
|
|
408
430
|
[clientId, githubId],
|
|
409
431
|
);
|
|
410
432
|
return { resolved: rowCount > 0, accepted: false };
|
|
@@ -1072,7 +1094,9 @@ module.exports = {
|
|
|
1072
1094
|
listAllProjects,
|
|
1073
1095
|
setProjectFeatured,
|
|
1074
1096
|
// memberships
|
|
1075
|
-
|
|
1097
|
+
MEMBERSHIP_KINDS,
|
|
1098
|
+
membershipKindFromLegacy,
|
|
1099
|
+
legacyRoleFromKind,
|
|
1076
1100
|
upsertMembership,
|
|
1077
1101
|
getMembership,
|
|
1078
1102
|
listMembershipsForAccount,
|
|
@@ -26,11 +26,11 @@ module.exports = function myProjectsRoutes() {
|
|
|
26
26
|
const owned = [];
|
|
27
27
|
const building = [];
|
|
28
28
|
for (const m of rows) {
|
|
29
|
-
(m.
|
|
29
|
+
(m.membership_kind === 'owner' ? owned : building).push({
|
|
30
30
|
client_id: m.client_id,
|
|
31
31
|
name: m.project_name || m.project_origin || m.client_id,
|
|
32
32
|
origin: m.project_origin || null,
|
|
33
|
-
|
|
33
|
+
membership_kind: m.membership_kind,
|
|
34
34
|
joined_at: m.joined_at,
|
|
35
35
|
last_active: m.last_active,
|
|
36
36
|
});
|