@bongos/core 1.19.240 → 1.19.242
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/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0177-goal-membership-kind.md +61 -2
- package/docs/api/openapi.json +5 -1
- package/docs/api-reference.md +1 -1
- package/migrations/core_220_goal_members_membership_kind.sql +22 -11
- 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/src/bongos/auth.js +21 -6
- package/src/module-api.js +5 -1
- package/tests/idp_federation.mjs +8 -6
- package/tests/membership_kind_lockstep.mjs +111 -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.242",
|
|
6
|
+
"core_contract": "1.19.242",
|
|
7
|
+
"source_commit": "900c5fe4161d60490d52cd4dfa2e0857a2748042",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-18T20:
|
|
9
|
+
"built_at": "2026-08-18T20:58:51.212Z",
|
|
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": "367c3937d0fd713cbda6b0e1ea8a32ea5f0be72b756ba5440760d6ff8a07b5d4",
|
|
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": "922dc12e699d3631907292e0a5550806ac88a160394c65c8223f65e58a46bc6f"
|
|
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",
|
|
@@ -2377,7 +2377,7 @@
|
|
|
2377
2377
|
{
|
|
2378
2378
|
"path": "migrations/core_220_goal_members_membership_kind.sql",
|
|
2379
2379
|
"mode": "0000644",
|
|
2380
|
-
"sha256": "
|
|
2380
|
+
"sha256": "e0503f677b4b30eca07badd76ba9b177f603f3740da366c1be40cb67a5537690"
|
|
2381
2381
|
},
|
|
2382
2382
|
{
|
|
2383
2383
|
"path": "modules/autonomy/CLAUDE.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": "026bf4799e0c4eab9e946c1f9d1668f5ee3f47ad487ef5abbfe9de063867cc0f"
|
|
4346
4351
|
},
|
|
4347
4352
|
{
|
|
4348
4353
|
"path": "package.json",
|
|
4349
4354
|
"mode": "0000644",
|
|
4350
|
-
"sha256": "
|
|
4355
|
+
"sha256": "343d7a6a0e9c8123a4f5cf336dfb8bdaf59c735227371c8fe8b632b6a17706a5"
|
|
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",
|
|
@@ -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": "c78192a54a2578a22420c02ac2da5397bccc6fa582540d8184d792dac9b13df3"
|
|
5966
5971
|
},
|
|
5967
5972
|
{
|
|
5968
5973
|
"path": "src/module-loader/catalog.js",
|
|
@@ -7217,7 +7222,7 @@
|
|
|
7217
7222
|
{
|
|
7218
7223
|
"path": "tests/idp_federation.mjs",
|
|
7219
7224
|
"mode": "0000644",
|
|
7220
|
-
"sha256": "
|
|
7225
|
+
"sha256": "5130936408829e4e45742eda0124aac9df50865ece1812274354384f9da498fd"
|
|
7221
7226
|
},
|
|
7222
7227
|
{
|
|
7223
7228
|
"path": "tests/infra_exec_paths.mjs",
|
|
@@ -7339,6 +7344,11 @@
|
|
|
7339
7344
|
"mode": "0000644",
|
|
7340
7345
|
"sha256": "9a1813d6036886ea07d1563e71a466e66c28554b75b22605464c795fd9fa2ebb"
|
|
7341
7346
|
},
|
|
7347
|
+
{
|
|
7348
|
+
"path": "tests/membership_kind_lockstep.mjs",
|
|
7349
|
+
"mode": "0000644",
|
|
7350
|
+
"sha256": "e40df74d518a508417dbea2d8f39952bd8f666e866aff985a8ab72758ead873a"
|
|
7351
|
+
},
|
|
7342
7352
|
{
|
|
7343
7353
|
"path": "tests/memory_isolation.mjs",
|
|
7344
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,64 @@ 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 was corrected
|
|
132
|
+
the same day under task [#1003093](https://cloudbongos.com/builders#/task/1003093) — there
|
|
133
|
+
the same statement demoted a goal's first lead to `member` on a fresh install
|
|
134
|
+
(`<redacted>.sql` runs after the core migrations), and no production
|
|
135
|
+
row was ever affected because the pin still served 1.19.218; the invariant is
|
|
136
|
+
pinned statically by `tests/membership_kind_lockstep.mjs`, since the trigger only
|
|
137
|
+
misbehaves against a live Postgres and CI has none.
|
|
138
|
+
|
|
139
|
+
The window's removal is a filed task, and its condition is explicit: every federated
|
|
140
|
+
instance past the window, not merely "one release later". A time-boxed shim still ships
|
|
141
|
+
with the test of its absence (ADR 0174's Lineage lesson) — that test lands with the
|
|
142
|
+
removal, because until then the legacy field is load-bearing, not vestigial.
|
|
143
|
+
|
|
86
144
|
## Consequences
|
|
87
145
|
|
|
88
146
|
- The word `role` no longer names a goal membership in the API, the hall, or the docs, and in the DB it
|
|
@@ -93,4 +151,5 @@ be legal or the code cannot explain itself. R07 stays scoped to the government s
|
|
|
93
151
|
the only new moving part on the write path, and it is the thing step 2 removes.
|
|
94
152
|
- `docs/canonical-permissions.md` (the goal-management wall) now names `membership_kind`; the
|
|
95
153
|
`authorizeGoalManagement` behaviour is untouched — this ADR changes no authority, floor, or grant.
|
|
96
|
-
- The fifth use (`project_memberships.role`)
|
|
154
|
+
- 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.
|
|
155
|
+
- **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
|
|
|
@@ -43,8 +43,15 @@ ALTER TABLE goal_members
|
|
|
43
43
|
|
|
44
44
|
UPDATE goal_members SET membership_kind = role WHERE membership_kind IS NULL;
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
-- NO DEFAULT WHILE `role` IS STILL WRITTEN — and this is load-bearing, not tidiness
|
|
47
|
+
-- (task 1003093, found by the grader on the sibling migration). Postgres materializes
|
|
48
|
+
-- a column DEFAULT into NEW *before* any BEFORE ROW trigger fires, so a default here
|
|
49
|
+
-- would make `NEW.membership_kind IS NULL` never true and the trigger could no longer
|
|
50
|
+
-- tell "the caller wrote the new column" from "the default filled it". An old-release
|
|
51
|
+
-- INSERT naming only `role` — lifecycle_001_goal_first_leads.sql's `'lead'`, or
|
|
52
|
+
-- createGoal / transferGoalOwnership on a pre-1003013 pin — would then be silently
|
|
53
|
+
-- rewritten to the default, DEMOTING the goal's own lead to a plain member. The
|
|
54
|
+
-- DEFAULT (and NOT NULL) arrive in step 2, when `role` is dropped.
|
|
48
55
|
|
|
49
56
|
DO $$
|
|
50
57
|
BEGIN
|
|
@@ -63,20 +70,24 @@ END $$;
|
|
|
63
70
|
-- statement that wrote either one — no second UPDATE, no window where the two
|
|
64
71
|
-- disagree, and nothing for application code to remember.
|
|
65
72
|
--
|
|
66
|
-
-- Direction is decided by WHICH SIDE
|
|
67
|
-
--
|
|
73
|
+
-- Direction is decided by WHICH SIDE THE CALLER WROTE: new code sets
|
|
74
|
+
-- membership_kind and `role` follows; the historical
|
|
68
75
|
-- lifecycle_001_goal_first_leads.sql seed (and any pre-upgrade code, including
|
|
69
|
-
-- the release a rollback would restore) sets `role` and membership_kind
|
|
70
|
-
--
|
|
71
|
-
--
|
|
72
|
-
--
|
|
76
|
+
-- the release a rollback would restore) sets `role` and membership_kind follows.
|
|
77
|
+
-- On INSERT that question is answered by `NEW.membership_kind IS NULL`, which is
|
|
78
|
+
-- only trustworthy because the new column has NO DEFAULT — see the note above it.
|
|
79
|
+
-- On UPDATE, OLD is available and the comparison is direct.
|
|
73
80
|
CREATE OR REPLACE FUNCTION goal_members_membership_kind_lockstep() RETURNS trigger AS $$
|
|
74
81
|
BEGIN
|
|
75
82
|
IF TG_OP = 'INSERT' THEN
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
83
|
+
-- NULL means the caller did not write the new column (see the no-DEFAULT note
|
|
84
|
+
-- above): an old-release INSERT, so `role` is the source of truth. Otherwise the
|
|
85
|
+
-- caller is new code and `role` follows, so an older release can still read it.
|
|
86
|
+
IF NEW.membership_kind IS NULL THEN
|
|
79
87
|
NEW.membership_kind := COALESCE(NEW.role, 'member');
|
|
88
|
+
NEW.role := COALESCE(NEW.role, 'member');
|
|
89
|
+
ELSE
|
|
90
|
+
NEW.role := NEW.membership_kind;
|
|
80
91
|
END IF;
|
|
81
92
|
ELSE
|
|
82
93
|
IF NEW.membership_kind IS DISTINCT FROM OLD.membership_kind THEN
|
|
@@ -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
|
});
|
|
@@ -358,8 +358,18 @@ module.exports = function ssoRoutes() {
|
|
|
358
358
|
// that a builder builds on its project (ADR 0141 §3). Server-to-server, authed by
|
|
359
359
|
// client_id + client_secret (same constant-time check as /sso/token). Upserts the
|
|
360
360
|
// account (so a CLI-only builder still appears in My Projects) + the membership
|
|
361
|
-
// with a COARSE
|
|
362
|
-
// caller cannot report membership for a project it doesn't hold the
|
|
361
|
+
// with a COARSE membership_kind. The client_id is taken from the AUTHENTICATED
|
|
362
|
+
// client — the caller cannot report membership for a project it doesn't hold the
|
|
363
|
+
// secret for.
|
|
364
|
+
//
|
|
365
|
+
// THE FIELD IS `membership_kind`; `role` IS ACCEPTED FOR THE WINDOW (task 1003090,
|
|
366
|
+
// ADR 0177 §"the fifth use"). ADR 0174 reserves `role` for the three crafts, and
|
|
367
|
+
// the old value 'builder' was a craft name meaning "non-owner member" — so both
|
|
368
|
+
// the field and that value are renamed. Unlike the goal-membership rename, this is
|
|
369
|
+
// a CROSS-VERSION wire contract: the callers are other instances on their own
|
|
370
|
+
// upgrade schedule, so an un-upgraded instance still sends `role: 'builder'` and
|
|
371
|
+
// the hub must keep understanding it. `membership_kind` WINS when both arrive
|
|
372
|
+
// (a sender in the window sends both deliberately).
|
|
363
373
|
//
|
|
364
374
|
// rank: public — authenticated by client_id + client_secret, not a builder rank.
|
|
365
375
|
router.post('/sso/membership/check-in', async (req, res) => {
|
|
@@ -370,9 +380,14 @@ module.exports = function ssoRoutes() {
|
|
|
370
380
|
github_login: { required: true, type: 'string', maxLength: 200 },
|
|
371
381
|
display_name: { type: 'string', maxLength: 400 },
|
|
372
382
|
avatar_url: { type: 'string', maxLength: 2048 },
|
|
383
|
+
membership_kind: { type: 'string', maxLength: 16 },
|
|
384
|
+
// LEGACY, accepted for the cross-version window (task 1003090).
|
|
373
385
|
role: { type: 'string', maxLength: 16 },
|
|
374
386
|
})) return;
|
|
375
|
-
const { client_id, client_secret, github_id, github_login, display_name, avatar_url, role } = req.body || {};
|
|
387
|
+
const { client_id, client_secret, github_id, github_login, display_name, avatar_url, membership_kind, role } = req.body || {};
|
|
388
|
+
const membershipKind = membership_kind
|
|
389
|
+
|| (role ? pi.membershipKindFromLegacy(role) : null)
|
|
390
|
+
|| 'member';
|
|
376
391
|
try {
|
|
377
392
|
const client = await pi.getClient(client_id, { pool });
|
|
378
393
|
if (!client || client.status !== 'active' || !pi.clientSecretValid(client, client_secret)) {
|
|
@@ -381,7 +396,7 @@ module.exports = function ssoRoutes() {
|
|
|
381
396
|
// Upsert the account first so a CLI-only builder is known to the hub, then the
|
|
382
397
|
// membership — keyed to the AUTHENTICATED client_id, never a caller-named one.
|
|
383
398
|
await pi.upsertAccount({ githubId: github_id, githubLogin: github_login, displayName: display_name, avatarUrl: avatar_url }, { pool });
|
|
384
|
-
await pi.upsertMembership({ clientId: client_id, githubId: github_id,
|
|
399
|
+
await pi.upsertMembership({ clientId: client_id, githubId: github_id, membershipKind }, { pool });
|
|
385
400
|
res.json({ ok: true });
|
|
386
401
|
} catch (err) {
|
|
387
402
|
console.error('[platform-identity] /sso/membership/check-in', err);
|
|
@@ -424,7 +439,7 @@ module.exports = function ssoRoutes() {
|
|
|
424
439
|
return res.fail('invalid_github_id', 400);
|
|
425
440
|
}
|
|
426
441
|
const membership = await pi.getMembership(b.client_id, b.github_id, { pool });
|
|
427
|
-
if (!membership || membership.
|
|
442
|
+
if (!membership || membership.membership_kind === 'pending') {
|
|
428
443
|
return res.fail('membership_required', 409);
|
|
429
444
|
}
|
|
430
445
|
await pi.upsertActivity({
|
|
@@ -262,9 +262,9 @@ await ta('registerClient auto-catalogues the project (2nd query) when an origin
|
|
|
262
262
|
|
|
263
263
|
console.log('\nproject memberships (§3 check-in directory):');
|
|
264
264
|
|
|
265
|
-
await ta('upsertMembership upserts on (client_id, github_id) with a coarse
|
|
266
|
-
const pool = mockPool([{ rows: [{ id: '1', client_id: 'c1', github_id: '42',
|
|
267
|
-
await P.upsertMembership({ clientId: 'c1', githubId: 42,
|
|
265
|
+
await ta('upsertMembership upserts on (client_id, github_id) with a coarse membership_kind', async () => {
|
|
266
|
+
const pool = mockPool([{ rows: [{ id: '1', client_id: 'c1', github_id: '42', membership_kind: 'owner' }] }]);
|
|
267
|
+
await P.upsertMembership({ clientId: 'c1', githubId: 42, membershipKind: 'owner' }, { pool });
|
|
268
268
|
const c = pool.calls[0];
|
|
269
269
|
assert.match(c.text, /INSERT INTO platform_identity_project_memberships/);
|
|
270
270
|
assert.match(c.text, /ON CONFLICT \(client_id, github_id\) DO UPDATE/);
|
|
@@ -272,10 +272,23 @@ await ta('upsertMembership upserts on (client_id, github_id) with a coarse role'
|
|
|
272
272
|
assert.deepEqual(c.params, ['c1', 42, 'owner']);
|
|
273
273
|
});
|
|
274
274
|
|
|
275
|
-
await ta('upsertMembership clamps an unknown
|
|
275
|
+
await ta('upsertMembership clamps an unknown kind to member (never trusts a caller blindly)', async () => {
|
|
276
276
|
const pool = mockPool([{ rows: [{}] }]);
|
|
277
|
-
await P.upsertMembership({ clientId: 'c1', githubId: 42,
|
|
278
|
-
assert.equal(pool.calls[0].params[2], '
|
|
277
|
+
await P.upsertMembership({ clientId: 'c1', githubId: 42, membershipKind: 'archon' }, { pool });
|
|
278
|
+
assert.equal(pool.calls[0].params[2], 'member', 'a non-coarse membership_kind is clamped to member');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
await ta('the legacy wire vocabulary maps both ways (the cross-version window, task 1003090)', async () => {
|
|
282
|
+
// Other instances upgrade on their own schedule, so 'builder' keeps arriving on
|
|
283
|
+
// the wire for a while; 'member' is what the hub stores. Nothing else changes.
|
|
284
|
+
assert.equal(P.membershipKindFromLegacy('builder'), 'member');
|
|
285
|
+
assert.equal(P.legacyRoleFromKind('member'), 'builder');
|
|
286
|
+
for (const same of ['owner', 'visitor', 'pending']) {
|
|
287
|
+
assert.equal(P.membershipKindFromLegacy(same), same);
|
|
288
|
+
assert.equal(P.legacyRoleFromKind(same), same);
|
|
289
|
+
}
|
|
290
|
+
assert.deepEqual(P.MEMBERSHIP_KINDS, ['owner', 'member', 'visitor', 'pending'],
|
|
291
|
+
'the craft name is gone from the coarse vocabulary');
|
|
279
292
|
});
|
|
280
293
|
|
|
281
294
|
await ta('removeMembership deletes the (client_id, github_id) row (self-service leave)', async () => {
|
|
@@ -287,20 +300,20 @@ await ta('removeMembership deletes the (client_id, github_id) row (self-service
|
|
|
287
300
|
});
|
|
288
301
|
|
|
289
302
|
await ta('listMembershipsForAccount joins projects + excludes pending', async () => {
|
|
290
|
-
const pool = mockPool([{ rows: [{ client_id: 'c1',
|
|
303
|
+
const pool = mockPool([{ rows: [{ client_id: 'c1', membership_kind: 'member', project_name: 'Proj' }] }]);
|
|
291
304
|
const out = await P.listMembershipsForAccount(42, { pool });
|
|
292
305
|
assert.equal(out.length, 1);
|
|
293
306
|
const c = pool.calls[0];
|
|
294
307
|
assert.match(c.text, /FROM platform_identity_project_memberships m/);
|
|
295
308
|
assert.match(c.text, /LEFT JOIN platform_identity_sso_clients c/);
|
|
296
|
-
assert.match(c.text, /
|
|
309
|
+
assert.match(c.text, /membership_kind <> 'pending'/);
|
|
297
310
|
assert.deepEqual(c.params, [42]);
|
|
298
311
|
});
|
|
299
312
|
|
|
300
313
|
console.log('\ninvitations (§5 — pre-seed pending membership):');
|
|
301
314
|
|
|
302
315
|
await ta('inviteToProject pre-seeds a pending row, never clobbering an existing member', async () => {
|
|
303
|
-
const pool = mockPool([{ rows: [{ id: '1',
|
|
316
|
+
const pool = mockPool([{ rows: [{ id: '1', membership_kind: 'pending' }] }]);
|
|
304
317
|
const out = await P.inviteToProject({ clientId: 'c1', githubId: 42 }, { pool });
|
|
305
318
|
assert.equal(out.invited, true);
|
|
306
319
|
const c = pool.calls[0];
|
|
@@ -320,15 +333,15 @@ await ta('resolveInvite accept flips ONLY a pending row to builder', async () =>
|
|
|
320
333
|
const out = await P.resolveInvite({ clientId: 'c1', githubId: 42, accept: true }, { pool });
|
|
321
334
|
assert.deepEqual(out, { resolved: true, accepted: true });
|
|
322
335
|
const c = pool.calls[0];
|
|
323
|
-
assert.match(c.text, /UPDATE platform_identity_project_memberships SET
|
|
324
|
-
assert.match(c.text, /AND
|
|
336
|
+
assert.match(c.text, /UPDATE platform_identity_project_memberships SET membership_kind = 'member'/);
|
|
337
|
+
assert.match(c.text, /AND membership_kind = 'pending'/, 'only touches pending — never a real member');
|
|
325
338
|
});
|
|
326
339
|
|
|
327
340
|
await ta('resolveInvite decline deletes ONLY a pending row', async () => {
|
|
328
341
|
const pool = mockPool([{ rowCount: 1 }]);
|
|
329
342
|
const out = await P.resolveInvite({ clientId: 'c1', githubId: 42, accept: false }, { pool });
|
|
330
343
|
assert.deepEqual(out, { resolved: true, accepted: false });
|
|
331
|
-
assert.match(pool.calls[0].text, /DELETE FROM platform_identity_project_memberships[\s\S]*AND
|
|
344
|
+
assert.match(pool.calls[0].text, /DELETE FROM platform_identity_project_memberships[\s\S]*AND membership_kind = 'pending'/);
|
|
332
345
|
});
|
|
333
346
|
|
|
334
347
|
await ta('getAccountByLogin resolves case-insensitively, newest first', async () => {
|
|
@@ -376,7 +389,7 @@ await ta('upsertActivity keeps a legitimate core rank key', async () => {
|
|
|
376
389
|
});
|
|
377
390
|
|
|
378
391
|
await ta('getMembership reads one (client, builder) row — the rollup membership requirement', async () => {
|
|
379
|
-
const row = { id: '9', client_id: 'c1', github_id: '42',
|
|
392
|
+
const row = { id: '9', client_id: 'c1', github_id: '42', membership_kind: 'member' };
|
|
380
393
|
const pool = mockPool([{ rows: [row] }]);
|
|
381
394
|
const out = await P.getMembership('c1', 42, { pool });
|
|
382
395
|
assert.deepEqual(out, row);
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.242",
|
|
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.242",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.242",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/scripts/gds/fitness.js
CHANGED
|
@@ -1741,7 +1741,7 @@ function vocabFilesToScan() {
|
|
|
1741
1741
|
return out;
|
|
1742
1742
|
}
|
|
1743
1743
|
|
|
1744
|
-
// ── Check 19b — the FOURTH "role": a
|
|
1744
|
+
// ── Check 19b — the FOURTH and FIFTH "role": a membership is not a craft either ─
|
|
1745
1745
|
//
|
|
1746
1746
|
// task 1003013 / ADR 0177. ADR 0174 reserved `role` for the three crafts and R07
|
|
1747
1747
|
// (checkVocabularyWall above) enforces it on the GOVERNMENT surface — deliberately
|
|
@@ -1751,10 +1751,16 @@ function vocabFilesToScan() {
|
|
|
1751
1751
|
// exactly why it survived the rename: the rule was silent on it, as designed.
|
|
1752
1752
|
//
|
|
1753
1753
|
// So this is a SECOND narrow wall rather than a widened first one — same reason the
|
|
1754
|
-
// first was scoped: the honest unit is a SURFACE, not the repo.
|
|
1755
|
-
//
|
|
1756
|
-
//
|
|
1757
|
-
//
|
|
1754
|
+
// first was scoped: the honest unit is a SURFACE, not the repo. Two surfaces carry
|
|
1755
|
+
// a membership, and both now say `membership_kind`:
|
|
1756
|
+
// • GOAL membership (`goal_members`, task 1003013) — the routes and pure gate that
|
|
1757
|
+
// decide it, the hall page that renders it, and (line-scoped) the membership SQL
|
|
1758
|
+
// in modules/lifecycle/db.js;
|
|
1759
|
+
// • PROJECT membership on the hub (`platform_identity_project_memberships`, task
|
|
1760
|
+
// 1003090) — whose old VALUE 'builder' was itself a craft name. Its check-in
|
|
1761
|
+
// route keeps reading a legacy `role` field for the cross-version federation
|
|
1762
|
+
// window (other instances upgrade on their own schedule), so the lines that
|
|
1763
|
+
// handle that legacy field cite the task and are exempt like any other citation.
|
|
1758
1764
|
//
|
|
1759
1765
|
// Naming the rule stays legal — a line that cites ADR 0174 / ADR 0177 / task
|
|
1760
1766
|
// 1003013 is meta-commentary about the ban, the same principle as
|
|
@@ -1767,14 +1773,23 @@ const GOAL_VOCAB_SURFACE = [
|
|
|
1767
1773
|
'modules/hall-ui/public/goals-page.js',
|
|
1768
1774
|
'modules/hall-ui/public/goals.html',
|
|
1769
1775
|
'modules/hall-ui/public/goals.css',
|
|
1776
|
+
// The FIFTH use (task 1003090): the hub's coarse project membership.
|
|
1777
|
+
'modules/platform-identity/platform-identity.js',
|
|
1778
|
+
'modules/platform-identity/routes/my-projects.js',
|
|
1779
|
+
'modules/hall-ui/public/my-projects.js',
|
|
1770
1780
|
];
|
|
1771
1781
|
// modules/lifecycle/db.js is 5k+ lines and legitimately says "role" about ranks
|
|
1772
1782
|
// (the rank-sync port, builders.system_role). Scanning it whole would need an
|
|
1773
1783
|
// allowlist longer than the rule; scanning the LINES THAT NAME THE TABLE catches
|
|
1774
1784
|
// the only thing that can regress here — a membership query reaching for the old
|
|
1775
1785
|
// column — with no false positives to excuse.
|
|
1776
|
-
const GOAL_VOCAB_LINE_SCOPED = [
|
|
1777
|
-
|
|
1786
|
+
const GOAL_VOCAB_LINE_SCOPED = [
|
|
1787
|
+
{ file: 'modules/lifecycle/db.js', mustMention: /goal_members/ },
|
|
1788
|
+
// sso.js is the federation surface: only the lines that name the membership table
|
|
1789
|
+
// or its upsert are in scope, so the file's other server-to-server prose stays out.
|
|
1790
|
+
{ file: 'modules/platform-identity/routes/sso.js', mustMention: /project_memberships|upsertMembership/ },
|
|
1791
|
+
];
|
|
1792
|
+
const GOAL_VOCAB_CITATION = /ADR 017[47]|task 1003013|task 1003090/;
|
|
1778
1793
|
|
|
1779
1794
|
function checkGoalMembershipVocabulary() {
|
|
1780
1795
|
const violations = [];
|
|
@@ -1789,19 +1804,19 @@ function checkGoalMembershipVocabulary() {
|
|
|
1789
1804
|
const scanLine = line.replace(/\srole\s*=\s*("[^"]*"|'[^']*')/g, ' ');
|
|
1790
1805
|
if (!/\brole/i.test(scanLine)) return;
|
|
1791
1806
|
if (GOAL_VOCAB_CITATION.test(scanLine)) return;
|
|
1792
|
-
violations.push(`${rel}:${i + 1}: "role" — a
|
|
1807
|
+
violations.push(`${rel}:${i + 1}: "role" — a membership is a 'membership_kind' (ADR 0174 reserves "role" for the crafts; ADR 0177). → ${line.trim().slice(0, 90)}`);
|
|
1793
1808
|
});
|
|
1794
1809
|
};
|
|
1795
1810
|
for (const rel of GOAL_VOCAB_SURFACE) scanFile(rel);
|
|
1796
1811
|
for (const { file, mustMention } of GOAL_VOCAB_LINE_SCOPED) scanFile(file, mustMention);
|
|
1797
1812
|
return {
|
|
1798
|
-
name: 'a
|
|
1813
|
+
name: 'a membership is a "membership_kind", never a role (ADR 0177)',
|
|
1799
1814
|
ok: violations.length === 0,
|
|
1800
1815
|
hardFail: violations.length > 0,
|
|
1801
1816
|
violations,
|
|
1802
1817
|
warnings: [],
|
|
1803
|
-
note: `${scanned}
|
|
1804
|
-
+ `${GOAL_VOCAB_LINE_SCOPED.length} line-scoped to the lines naming
|
|
1818
|
+
note: `${scanned} membership-surface file(s) scanned (${GOAL_VOCAB_SURFACE.length} whole — goal + project membership — plus `
|
|
1819
|
+
+ `${GOAL_VOCAB_LINE_SCOPED.length} line-scoped to the lines naming the membership tables); no allowlist — `
|
|
1805
1820
|
+ 'a line that CITES the rule is the only exemption.',
|
|
1806
1821
|
};
|
|
1807
1822
|
}
|
package/src/bongos/auth.js
CHANGED
|
@@ -173,15 +173,27 @@ function idpConfigured() {
|
|
|
173
173
|
}
|
|
174
174
|
|
|
175
175
|
// The COARSE membership role to report to the hub (ADR 0141 §3) — owner if this
|
|
176
|
-
// builder is the instance's configured firstAdmin, else
|
|
177
|
-
|
|
176
|
+
// builder is the instance's configured firstAdmin, else a plain member. NEVER a rank.
|
|
177
|
+
//
|
|
178
|
+
// 'member', not 'builder' (task 1003090, ADR 0177 §"the fifth use"): ADR 0174 reserves
|
|
179
|
+
// `role` — and the craft name 'Builder' — for Artist / Ideator / Builder, so the coarse
|
|
180
|
+
// project membership says what it means. The legacy spelling still goes on the wire for
|
|
181
|
+
// the cross-version window; see reportMembershipCheckIn.
|
|
182
|
+
function membershipKind({ githubLogin } = {}) {
|
|
178
183
|
const fa = firstAdminLogin();
|
|
179
184
|
if (fa && String(githubLogin || '').toLowerCase() === fa.toLowerCase()) return 'owner';
|
|
180
|
-
return '
|
|
185
|
+
return 'member';
|
|
181
186
|
}
|
|
182
187
|
|
|
183
188
|
// Best-effort server-to-server membership check-in to the hub (ADR 0141 §3): "this
|
|
184
|
-
// github_id builds on project <client_id>, coarse
|
|
189
|
+
// github_id builds on project <client_id>, coarse membership <membership_kind>".
|
|
190
|
+
//
|
|
191
|
+
// BOTH SPELLINGS GO ON THE WIRE FOR THE WINDOW (task 1003090). The receiver is the
|
|
192
|
+
// HUB, a different instance on its own upgrade schedule: send only the new field and
|
|
193
|
+
// an un-upgraded hub silently defaults every membership to non-owner (it would drop
|
|
194
|
+
// the owner flag); send only the legacy field and an upgraded hub keeps translating
|
|
195
|
+
// forever. Sending both is what makes the two ends independent — the removal task
|
|
196
|
+
// drops `role` once the hub is past the window. Runs on every
|
|
185
197
|
// FEDERATED sign-in (the only sign-ins a federated instance has) so a CLI-only
|
|
186
198
|
// project still appears in the builder's My Projects with no hub browser session.
|
|
187
199
|
// Fire-and-forget + fully swallowed: a hub outage must NEVER fail a sign-in (mirrors
|
|
@@ -189,6 +201,7 @@ function membershipRole({ githubLogin } = {}) {
|
|
|
189
201
|
async function reportMembershipCheckIn({ githubId, githubLogin, displayName, avatarUrl }) {
|
|
190
202
|
const idp = loadIdpConfig();
|
|
191
203
|
if (!idp) return;
|
|
204
|
+
const kind = membershipKind({ githubLogin });
|
|
192
205
|
try {
|
|
193
206
|
await fetch(`${idp.origin}/api/gds/sso/membership/check-in`, {
|
|
194
207
|
method: 'POST',
|
|
@@ -200,7 +213,9 @@ async function reportMembershipCheckIn({ githubId, githubLogin, displayName, ava
|
|
|
200
213
|
github_login: githubLogin,
|
|
201
214
|
display_name: displayName ?? null,
|
|
202
215
|
avatar_url: avatarUrl ?? null,
|
|
203
|
-
|
|
216
|
+
membership_kind: kind,
|
|
217
|
+
// LEGACY (cross-version window): 'member' → 'builder' for an un-upgraded hub.
|
|
218
|
+
role: kind === 'member' ? 'builder' : kind,
|
|
204
219
|
}),
|
|
205
220
|
});
|
|
206
221
|
} catch (err) {
|
|
@@ -1816,7 +1831,7 @@ module.exports = {
|
|
|
1816
1831
|
verifyHubLogoutToken,
|
|
1817
1832
|
logoutTokenJtiSeenAndRecord,
|
|
1818
1833
|
completeDeviceExchange,
|
|
1819
|
-
|
|
1834
|
+
membershipKind,
|
|
1820
1835
|
extractTokens,
|
|
1821
1836
|
resolveSession,
|
|
1822
1837
|
allowBoxScope,
|
package/src/module-api.js
CHANGED
|
@@ -907,8 +907,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
907
907
|
// landed since 1.19.238 with no explicit bump. run 32174983930. (task 1002620)
|
|
908
908
|
// 1.19.240 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
909
909
|
// landed since 1.19.239 with no explicit bump. run 32182178305. (task 1002620)
|
|
910
|
+
// 1.19.241 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
911
|
+
// landed since 1.19.240 with no explicit bump. run 32184475745. (task 1002620)
|
|
912
|
+
// 1.19.242 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
913
|
+
// landed since 1.19.241 with no explicit bump. run 32185198139. (task 1002620)
|
|
910
914
|
// ---------------------------------------------------------------------------
|
|
911
|
-
const CORE_VERSION = '1.19.
|
|
915
|
+
const CORE_VERSION = '1.19.242'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
912
916
|
|
|
913
917
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
914
918
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/idp_federation.mjs
CHANGED
|
@@ -80,16 +80,18 @@ t('buildIdpAuthorizeUrl throws when the IdP is not configured (never silently mi
|
|
|
80
80
|
});
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
-
console.log('\nmembership check-in
|
|
83
|
+
console.log('\nmembership check-in kind (§3 — coarse, never a rank, never a craft):');
|
|
84
84
|
|
|
85
|
-
t('
|
|
85
|
+
t('membershipKind is member by default, owner for the configured firstAdmin', () => {
|
|
86
86
|
const prev = process.env.CLOUDBONGOS_FIRST_ADMIN;
|
|
87
87
|
process.env.CLOUDBONGOS_FIRST_ADMIN = 'ownerlogin';
|
|
88
88
|
try {
|
|
89
|
-
assert.equal(auth.
|
|
90
|
-
assert.equal(auth.
|
|
91
|
-
|
|
92
|
-
|
|
89
|
+
assert.equal(auth.membershipKind({ githubLogin: 'ownerlogin' }), 'owner');
|
|
90
|
+
assert.equal(auth.membershipKind({ githubLogin: 'OwnerLogin' }), 'owner', 'case-insensitive');
|
|
91
|
+
// 'member', not 'builder' — the craft name left the coarse membership vocabulary
|
|
92
|
+
// with task 1003090 (ADR 0177 §"the fifth use").
|
|
93
|
+
assert.equal(auth.membershipKind({ githubLogin: 'someone-else' }), 'member');
|
|
94
|
+
assert.equal(auth.membershipKind({}), 'member');
|
|
93
95
|
} finally {
|
|
94
96
|
if (prev === undefined) delete process.env.CLOUDBONGOS_FIRST_ADMIN;
|
|
95
97
|
else process.env.CLOUDBONGOS_FIRST_ADMIN = prev;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// tests/membership_kind_lockstep.mjs — the expand-phase lockstep triggers, and the
|
|
2
|
+
// Postgres semantic that makes their shape load-bearing (task 1003090, ADR 0177).
|
|
3
|
+
//
|
|
4
|
+
// Both membership renames keep the legacy column alive for one release, kept in step
|
|
5
|
+
// by a BEFORE INSERT OR UPDATE trigger. The INSERT branch has to answer "did the
|
|
6
|
+
// caller write the NEW column, or is this an old release writing the legacy one?" —
|
|
7
|
+
// and there is exactly ONE way to answer it:
|
|
8
|
+
//
|
|
9
|
+
// POSTGRES MATERIALIZES A COLUMN DEFAULT INTO `NEW` BEFORE ANY BEFORE ROW TRIGGER
|
|
10
|
+
// FIRES. So if the new column carries a DEFAULT, `NEW.<new> IS NULL` is never true
|
|
11
|
+
// and the trigger cannot distinguish "explicitly written" from "default filled".
|
|
12
|
+
//
|
|
13
|
+
// That is not theoretical. With a DEFAULT in place, an old-release
|
|
14
|
+
// `INSERT … (client_id, github_id, role) VALUES (…, 'pending')` — inviteToProject's
|
|
15
|
+
// exact statement — took the "new code wrote it" branch and overwrote `role` with the
|
|
16
|
+
// default's mapping, turning a pending INVITE into an active membership. Caught by
|
|
17
|
+
// the grader on task 1003090 and reproduced against a real Postgres:
|
|
18
|
+
//
|
|
19
|
+
// A old-invite-pending | role=builder | membership_kind=member ← WRONG (before)
|
|
20
|
+
// A old-invite-pending | role=pending | membership_kind=pending ← after the fix
|
|
21
|
+
//
|
|
22
|
+
// core_220 carried the same defect (task 1003093): an old-release
|
|
23
|
+
// `INSERT … (goal_id, builder_id, role) VALUES (…, 'lead')` — which is exactly what
|
|
24
|
+
// lifecycle_001_goal_first_leads.sql issues on a FRESH install, after the core
|
|
25
|
+
// migrations — landed `role=member, membership_kind=member`, silently demoting every
|
|
26
|
+
// new goal's creator out of manager standing.
|
|
27
|
+
//
|
|
28
|
+
// The fix is the absence of a DEFAULT while the legacy column is still written, so
|
|
29
|
+
// NULL means exactly "not written". The DEFAULT (and NOT NULL) belong to step 2,
|
|
30
|
+
// when the legacy column is dropped. This file pins that invariant statically,
|
|
31
|
+
// because the trigger only misbehaves against a live Postgres — which CI has none of.
|
|
32
|
+
//
|
|
33
|
+
// Run: node --test tests/membership_kind_lockstep.mjs
|
|
34
|
+
import assert from 'node:assert/strict';
|
|
35
|
+
import { test } from 'node:test';
|
|
36
|
+
import fs from 'node:fs';
|
|
37
|
+
import path from 'node:path';
|
|
38
|
+
import { fileURLToPath } from 'node:url';
|
|
39
|
+
|
|
40
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
41
|
+
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
42
|
+
|
|
43
|
+
// BOTH expand-phase migrations, since task 1003093 fixed core_220's identical
|
|
44
|
+
// INSERT branch. core_220's version of the bug demoted a goal's own LEAD: on a fresh
|
|
45
|
+
// install, lifecycle_001_goal_first_leads.sql inserts the creator with `role='lead'`
|
|
46
|
+
// AFTER core migrations run, so every new goal's creator landed as a plain member and
|
|
47
|
+
// lost manager standing. Verified against a real Postgres both ways round.
|
|
48
|
+
const EXPAND_MIGRATIONS = [
|
|
49
|
+
{
|
|
50
|
+
rel: 'migrations/core_220_goal_members_membership_kind.sql',
|
|
51
|
+
table: 'goal_members',
|
|
52
|
+
legacy: 'role',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
rel: 'modules/platform-identity/migrations/platform_identity_010_membership_kind.sql',
|
|
56
|
+
table: 'platform_identity_project_memberships',
|
|
57
|
+
legacy: 'role',
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
for (const m of EXPAND_MIGRATIONS) {
|
|
62
|
+
test(`${path.basename(m.rel)}: the new column has NO default while ${m.legacy} is still written`, () => {
|
|
63
|
+
const sql = read(m.rel);
|
|
64
|
+
assert.ok(/ADD COLUMN IF NOT EXISTS membership_kind text/.test(sql), 'the expand step adds the column');
|
|
65
|
+
assert.ok(!/ALTER COLUMN membership_kind SET DEFAULT/.test(sql),
|
|
66
|
+
'a DEFAULT here is materialized into NEW before the BEFORE trigger runs, so the trigger '
|
|
67
|
+
+ 'could no longer tell an explicit write from a filled default — and an old-release INSERT '
|
|
68
|
+
+ `naming only ${m.legacy} would be silently rewritten (task 1003090)`);
|
|
69
|
+
assert.ok(!/ALTER COLUMN membership_kind SET NOT NULL/.test(sql),
|
|
70
|
+
'NOT NULL belongs to step 2, once nothing writes the legacy column (and the forward-safety '
|
|
71
|
+
+ 'linter flags it here for the same reason)');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test(`${path.basename(m.rel)}: the trigger's INSERT branch keys off NULL, not off truthiness`, () => {
|
|
75
|
+
const sql = read(m.rel);
|
|
76
|
+
const fn = sql.slice(sql.indexOf('RETURNS trigger AS'), sql.indexOf('$$ LANGUAGE plpgsql'));
|
|
77
|
+
const insertBranch = fn.slice(fn.indexOf("IF TG_OP = 'INSERT'"), fn.indexOf('ELSE', fn.indexOf('END IF')));
|
|
78
|
+
assert.match(insertBranch, /IF NEW\.membership_kind IS NULL THEN/,
|
|
79
|
+
'NULL is the only reliable signal that the caller did not write the new column');
|
|
80
|
+
assert.ok(!/IF NEW\.membership_kind IS NOT NULL THEN/.test(insertBranch),
|
|
81
|
+
'the inverted form is the task-1003090 bug: it treats a filled default as an explicit write');
|
|
82
|
+
// The UPDATE branch has OLD to compare against, so it may (and does) use IS DISTINCT FROM.
|
|
83
|
+
assert.match(fn, /NEW\.membership_kind IS DISTINCT FROM OLD\.membership_kind/,
|
|
84
|
+
'the UPDATE branch decides direction by which side actually changed');
|
|
85
|
+
assert.ok(/BEFORE INSERT OR UPDATE/.test(sql), 'a BEFORE trigger, so both columns are written by one statement');
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
test('the value mapping is one shared function, not repeated inline', () => {
|
|
90
|
+
// Route, sender and trigger must agree that 'builder' ⇄ 'member'; three copies of a
|
|
91
|
+
// ternary is how they would stop agreeing.
|
|
92
|
+
const pi = read('modules/platform-identity/platform-identity.js');
|
|
93
|
+
assert.match(pi, /function membershipKindFromLegacy\(/);
|
|
94
|
+
assert.match(pi, /function legacyRoleFromKind\(/);
|
|
95
|
+
const sso = read('modules/platform-identity/routes/sso.js');
|
|
96
|
+
assert.match(sso, /pi\.membershipKindFromLegacy\(role\)/,
|
|
97
|
+
'the check-in translates the legacy field through the shared mapping');
|
|
98
|
+
const sql = read('modules/platform-identity/migrations/platform_identity_010_membership_kind.sql');
|
|
99
|
+
assert.match(sql, /CASE WHEN NEW\.role = 'builder' THEN 'member'/, 'the trigger maps the same pair');
|
|
100
|
+
assert.match(sql, /CASE WHEN NEW\.membership_kind = 'member' THEN 'builder'/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('the sender writes BOTH spellings, computing the kind once (the cross-version window)', () => {
|
|
104
|
+
const auth = read('src/bongos/auth.js');
|
|
105
|
+
const body = auth.slice(auth.indexOf('async function reportMembershipCheckIn'), auth.indexOf('async function reportMembershipCheckIn') + 2400);
|
|
106
|
+
assert.match(body, /membership_kind: kind/, 'the new field goes on the wire');
|
|
107
|
+
assert.match(body, /role: kind === 'member' \? 'builder' : kind/,
|
|
108
|
+
'and the legacy field too — an un-upgraded hub would otherwise drop the owner flag');
|
|
109
|
+
assert.equal((body.match(/membershipKind\(\{ githubLogin \}\)/g) || []).length, 1,
|
|
110
|
+
'the kind is computed ONCE per check-in, not per field');
|
|
111
|
+
});
|