@bongos/core 1.19.212 → 1.19.214
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 +22 -17
- package/modules/lifecycle/db.js +39 -6
- package/modules/lifecycle/routes/goals.js +8 -2
- package/modules/platform-identity/routes/sso.js +9 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/routes/auth.js +10 -3
- package/src/module-api.js +5 -1
- package/tests/auth_device_start_status.mjs +101 -0
- package/tests/goal_membership_requests.mjs +89 -0
- package/tests/goal_tier.mjs +10 -5
- package/tests/setup_device_flow_fallback.mjs +8 -2
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.214",
|
|
6
|
+
"core_contract": "1.19.214",
|
|
7
|
+
"source_commit": "33a353b2b8d41b5d78870cf04e367ba35270db01",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-17T23:
|
|
9
|
+
"built_at": "2026-08-17T23:48:10.122Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 310,
|
|
13
13
|
"agent_docs_stubbed": 21,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1302,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1633,
|
|
20
|
+
"tree_sha256": "42e8f882bc77acfe1def79c56518e80678fb0bd4d4b635f94d013e55474b1ac1",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -3557,7 +3557,7 @@
|
|
|
3557
3557
|
{
|
|
3558
3558
|
"path": "modules/lifecycle/db.js",
|
|
3559
3559
|
"mode": "0000644",
|
|
3560
|
-
"sha256": "
|
|
3560
|
+
"sha256": "3d88ff6be3d8378f0cf2826af92437dfafbeb589a677aa8928d9f325124eac5b"
|
|
3561
3561
|
},
|
|
3562
3562
|
{
|
|
3563
3563
|
"path": "modules/lifecycle/done-when.js",
|
|
@@ -3652,7 +3652,7 @@
|
|
|
3652
3652
|
{
|
|
3653
3653
|
"path": "modules/lifecycle/routes/goals.js",
|
|
3654
3654
|
"mode": "0000644",
|
|
3655
|
-
"sha256": "
|
|
3655
|
+
"sha256": "9e963e289039a6b1c64b17deae161895876831de20e4e06b947ea3b3f49afdf6"
|
|
3656
3656
|
},
|
|
3657
3657
|
{
|
|
3658
3658
|
"path": "modules/lifecycle/routes/lifecycle.js",
|
|
@@ -3892,7 +3892,7 @@
|
|
|
3892
3892
|
{
|
|
3893
3893
|
"path": "modules/platform-identity/routes/sso.js",
|
|
3894
3894
|
"mode": "0000644",
|
|
3895
|
-
"sha256": "
|
|
3895
|
+
"sha256": "72796558274b45faec2b1a63e332100061a6ab0c690dadc42a54f1f39cb0c68e"
|
|
3896
3896
|
},
|
|
3897
3897
|
{
|
|
3898
3898
|
"path": "modules/platform-identity/tests/platform-identity.mjs",
|
|
@@ -4282,12 +4282,12 @@
|
|
|
4282
4282
|
{
|
|
4283
4283
|
"path": "package-lock.json",
|
|
4284
4284
|
"mode": "0000644",
|
|
4285
|
-
"sha256": "
|
|
4285
|
+
"sha256": "02845f76d2382f9e1568f039c838a34505297ae619cdb9cdbe5d8b583cd1dd40"
|
|
4286
4286
|
},
|
|
4287
4287
|
{
|
|
4288
4288
|
"path": "package.json",
|
|
4289
4289
|
"mode": "0000644",
|
|
4290
|
-
"sha256": "
|
|
4290
|
+
"sha256": "6abe7a4d894357f207a16ab1735c642f46e2f5905d3845c436b6df438f66a3ac"
|
|
4291
4291
|
},
|
|
4292
4292
|
{
|
|
4293
4293
|
"path": "public-docs/index.html",
|
|
@@ -5792,7 +5792,7 @@
|
|
|
5792
5792
|
{
|
|
5793
5793
|
"path": "src/bongos/routes/auth.js",
|
|
5794
5794
|
"mode": "0000644",
|
|
5795
|
-
"sha256": "
|
|
5795
|
+
"sha256": "c5688194f21600a64ced6b27aa5c12b4f580db786ffcf85a151422635269aa40"
|
|
5796
5796
|
},
|
|
5797
5797
|
{
|
|
5798
5798
|
"path": "src/bongos/routes/backup.js",
|
|
@@ -5902,7 +5902,7 @@
|
|
|
5902
5902
|
{
|
|
5903
5903
|
"path": "src/module-api.js",
|
|
5904
5904
|
"mode": "0000644",
|
|
5905
|
-
"sha256": "
|
|
5905
|
+
"sha256": "53487d418120e292c09bc2f647581d2541fbf7966052420827b8d6c49c77940d"
|
|
5906
5906
|
},
|
|
5907
5907
|
{
|
|
5908
5908
|
"path": "src/module-loader/catalog.js",
|
|
@@ -6074,6 +6074,11 @@
|
|
|
6074
6074
|
"mode": "0000644",
|
|
6075
6075
|
"sha256": "0be7221de2aa3969a54dc856a9fa1548959abd18b1a6c55e67b6ca5030e7ac1d"
|
|
6076
6076
|
},
|
|
6077
|
+
{
|
|
6078
|
+
"path": "tests/auth_device_start_status.mjs",
|
|
6079
|
+
"mode": "0000644",
|
|
6080
|
+
"sha256": "059fededeef783635945496d3183387c1c6407e84fdddf67d14e0d14a48e9dc9"
|
|
6081
|
+
},
|
|
6077
6082
|
{
|
|
6078
6083
|
"path": "tests/auth_duplicate_cookie.mjs",
|
|
6079
6084
|
"mode": "0000644",
|
|
@@ -6792,7 +6797,7 @@
|
|
|
6792
6797
|
{
|
|
6793
6798
|
"path": "tests/goal_membership_requests.mjs",
|
|
6794
6799
|
"mode": "0000644",
|
|
6795
|
-
"sha256": "
|
|
6800
|
+
"sha256": "660dd4143c21de0fe81b4507f3cb9f0731e5722e6c77806de8327156b18198f6"
|
|
6796
6801
|
},
|
|
6797
6802
|
{
|
|
6798
6803
|
"path": "tests/goal_routes.mjs",
|
|
@@ -6807,7 +6812,7 @@
|
|
|
6807
6812
|
{
|
|
6808
6813
|
"path": "tests/goal_tier.mjs",
|
|
6809
6814
|
"mode": "0000644",
|
|
6810
|
-
"sha256": "
|
|
6815
|
+
"sha256": "c2e37bb797c1e9af23970bfee7587b16cd13f1fac23c0e334a5ea0d362174491"
|
|
6811
6816
|
},
|
|
6812
6817
|
{
|
|
6813
6818
|
"path": "tests/goal_wall_proof.mjs",
|
|
@@ -7842,7 +7847,7 @@
|
|
|
7842
7847
|
{
|
|
7843
7848
|
"path": "tests/setup_device_flow_fallback.mjs",
|
|
7844
7849
|
"mode": "0000644",
|
|
7845
|
-
"sha256": "
|
|
7850
|
+
"sha256": "0a7c2f4a0ee050573040a88c6a246c773e7b13074115df4b4d715ab99c903a9c"
|
|
7846
7851
|
},
|
|
7847
7852
|
{
|
|
7848
7853
|
"path": "tests/setup_smoke.mjs",
|
package/modules/lifecycle/db.js
CHANGED
|
@@ -854,15 +854,48 @@ async function achieveGoalIfComplete({ goalId }, deps = {}) {
|
|
|
854
854
|
// owner's row — bypassing authorizeMemberRoleChange's owner-only-demotion
|
|
855
855
|
// guards. Role changes go through PATCH /goals/:id/members/:builderId only
|
|
856
856
|
// (the acceptMembershipRequestAndAddMember precedent). role defaults to 'member'.
|
|
857
|
+
//
|
|
858
|
+
// ARRIVING AS A MEMBER CLOSES ANY PENDING ASK (task 1002888 / idea 1000688).
|
|
859
|
+
// The membership write used to be a bare upsert that never looked at
|
|
860
|
+
// goal_membership_requests, so a builder's pending row SURVIVED their arrival by
|
|
861
|
+
// any other route — the goal flips to public and they self-join, or an Archon
|
|
862
|
+
// adds them directly. The stale row lingered in the owner/manager inbox, a later
|
|
863
|
+
// approve resolved a request for an existing member, and a later deny stamped
|
|
864
|
+
// 'rejected' on someone who is in fact a member. Harmless to access control,
|
|
865
|
+
// wrong in the audit trail.
|
|
866
|
+
//
|
|
867
|
+
// BOTH kinds are cancelled, not just 'request'. The partial-unique index
|
|
868
|
+
// (core_188: one pending row per goal+builder, either direction) means there is
|
|
869
|
+
// at most ONE pending row to close, and a stale pending INVITE is the same bug
|
|
870
|
+
// mirrored — the invitee could later decline an invitation to a goal they are
|
|
871
|
+
// already in, stamping 'rejected' on a member. 'cancelled' is the right terminal
|
|
872
|
+
// state (core_188: "withdrawn/rescinded"); 'accepted' stays reserved for an
|
|
873
|
+
// accept/approve flow that actually ran, so the inbox history never claims a
|
|
874
|
+
// decision nobody made. resolvedBy is the ACTOR — the joiner themselves on
|
|
875
|
+
// /join, the adding owner/manager/Archon on /members.
|
|
876
|
+
//
|
|
877
|
+
// One CTE, not two queries — the acceptMembershipRequestAndAddMember precedent:
|
|
878
|
+
// the cancel commits with the membership write or not at all, so an error
|
|
879
|
+
// between them can't leave the pair half-applied. The two arms are independent
|
|
880
|
+
// (Postgres runs a data-modifying CTE to completion whether or not the primary
|
|
881
|
+
// query reads it), and both see the same snapshot.
|
|
857
882
|
|
|
858
|
-
async function addGoalMember({ goalId, builderId, role = 'member' }, deps = {}) {
|
|
883
|
+
async function addGoalMember({ goalId, builderId, role = 'member', resolvedBy = null }, deps = {}) {
|
|
859
884
|
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
860
885
|
const { rows } = await activePool.query(
|
|
861
|
-
`
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
886
|
+
`WITH added AS (
|
|
887
|
+
INSERT INTO goal_members (goal_id, builder_id, role)
|
|
888
|
+
VALUES ($1, $2, $3)
|
|
889
|
+
ON CONFLICT (goal_id, builder_id) DO UPDATE SET role = goal_members.role
|
|
890
|
+
RETURNING goal_id, builder_id, role, joined_at
|
|
891
|
+
), cancelled AS (
|
|
892
|
+
UPDATE goal_membership_requests
|
|
893
|
+
SET status = 'cancelled', resolved_by = $4, resolved_at = now(), updated_at = now()
|
|
894
|
+
WHERE goal_id = $1 AND builder_id = $2 AND status = 'pending'
|
|
895
|
+
RETURNING id
|
|
896
|
+
)
|
|
897
|
+
SELECT goal_id, builder_id, role, joined_at FROM added`,
|
|
898
|
+
[goalId, builderId, role, resolvedBy]
|
|
866
899
|
);
|
|
867
900
|
return rows[0];
|
|
868
901
|
}
|
|
@@ -580,7 +580,10 @@ module.exports = function buildGoalsRouter() {
|
|
|
580
580
|
return res.fail('goal_private', { status: 409, message: 'This goal is private — request to join (POST /goals/:id/requests) or wait for an invitation.' });
|
|
581
581
|
}
|
|
582
582
|
}
|
|
583
|
-
|
|
583
|
+
// resolvedBy: the joiner — arriving as a member cancels their own still-
|
|
584
|
+
// pending ask on this goal, atomically with the membership write (task
|
|
585
|
+
// 1002888). Self-joining is the same intent as withdrawing the request.
|
|
586
|
+
const member = await db.addGoalMember({ goalId: id, builderId: req.builder.id, role: 'member', resolvedBy: req.builder.id });
|
|
584
587
|
return res.status(201).json({ ok: true, member });
|
|
585
588
|
} catch (err) {
|
|
586
589
|
console.error('[gds] POST /goals/:id/join', err);
|
|
@@ -939,7 +942,10 @@ module.exports = function buildGoalsRouter() {
|
|
|
939
942
|
if (scopeIncludesProtected(goal.scope_modules) && !METIC_PLUS.includes(target.rank)) {
|
|
940
943
|
return res.fail('metic_required_for_protected_scope', { status: 403, message: 'A protected-scope goal requires a Metic+ member; sub-Metic membership is deferred to the Phase-5 ADR.' });
|
|
941
944
|
}
|
|
942
|
-
|
|
945
|
+
// resolvedBy: the ADDER (not the target) — a direct add closes the target's
|
|
946
|
+
// pending ask, and the audit trail should name whoever admitted them
|
|
947
|
+
// (task 1002888).
|
|
948
|
+
const member = await db.addGoalMember({ goalId: id, builderId, role, resolvedBy: req.builder.id });
|
|
943
949
|
return res.status(201).json({ ok: true, member });
|
|
944
950
|
} catch (err) {
|
|
945
951
|
console.error('[gds] POST /goals/:id/members', err);
|
|
@@ -303,7 +303,10 @@ module.exports = function ssoRoutes() {
|
|
|
303
303
|
res.json(out);
|
|
304
304
|
} catch (err) {
|
|
305
305
|
console.error('[platform-identity] /sso/device/start', err);
|
|
306
|
-
|
|
306
|
+
// 424, not 502: Cloudflare replaces origin 5xx bodies with its own page, so a
|
|
307
|
+
// proxied upstream refusal must ride a 4xx to keep its JSON diagnosis readable
|
|
308
|
+
// externally (finding W2, task 1003052 — the core device/start twin).
|
|
309
|
+
res.fail('device_start_failed', { status: 424, message: String(err.message || 'error').slice(0, 200) });
|
|
307
310
|
}
|
|
308
311
|
});
|
|
309
312
|
|
|
@@ -343,7 +346,11 @@ module.exports = function ssoRoutes() {
|
|
|
343
346
|
res.json({ state: 'ok', assertion });
|
|
344
347
|
} catch (err) {
|
|
345
348
|
console.error('[platform-identity] /sso/device/poll', err);
|
|
346
|
-
|
|
349
|
+
// 424, matching the core device/poll (finding W2, task 1003052): this catch
|
|
350
|
+
// proxies the hub's GitHub poll refusals, and a 5xx body never survives
|
|
351
|
+
// Cloudflare. (The !out.client_id branch above keeps its 500: an internal
|
|
352
|
+
// invariant breach, not an upstream proxy.)
|
|
353
|
+
res.fail('device_poll_failed', { status: 424, message: 'internal error' });
|
|
347
354
|
}
|
|
348
355
|
});
|
|
349
356
|
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.214",
|
|
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.214",
|
|
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.214",
|
|
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",
|
|
@@ -320,9 +320,13 @@ module.exports = function buildAuthRouter() {
|
|
|
320
320
|
// Surface the underlying reason (task 2126). A thrown device-flow start is almost
|
|
321
321
|
// always a GitHub OAuth-App misconfig (device_flow_disabled / bad client) — safe,
|
|
322
322
|
// non-secret, and actionable — so return it instead of an opaque "internal error".
|
|
323
|
-
// 502: the failure
|
|
323
|
+
// 424 (failed dependency), NOT 502: the failure IS upstream (GitHub), but
|
|
324
|
+
// Cloudflare replaces an origin 5xx body with its own bare error page, so the
|
|
325
|
+
// diagnosis never reached an external caller — the walk's single most likely
|
|
326
|
+
// fresh-instance auth failure could only be read via curl on the box itself
|
|
327
|
+
// (finding W2, task 1003052). A 4xx passes the proxy with the JSON intact.
|
|
324
328
|
const reason = String(err && err.message ? err.message : 'internal error').slice(0, 300);
|
|
325
|
-
res.fail('device_start_failed', { status:
|
|
329
|
+
res.fail('device_start_failed', { status: 424, message: reason });
|
|
326
330
|
}
|
|
327
331
|
});
|
|
328
332
|
|
|
@@ -349,7 +353,10 @@ module.exports = function buildAuthRouter() {
|
|
|
349
353
|
return res.status(403).json({ state: 'not_approved', error: 'not_approved' });
|
|
350
354
|
}
|
|
351
355
|
console.error('[gds] device/poll', err);
|
|
352
|
-
|
|
356
|
+
// 424 for the same reason as device/start above (finding W2, task 1003052):
|
|
357
|
+
// this catch proxies GitHub's poll refusals too, and a 5xx body never
|
|
358
|
+
// survives Cloudflare — the JSON envelope (its code at minimum) must.
|
|
359
|
+
res.fail('device_poll_failed', { status: 424, message: 'internal error' });
|
|
353
360
|
}
|
|
354
361
|
});
|
|
355
362
|
|
package/src/module-api.js
CHANGED
|
@@ -851,8 +851,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
851
851
|
// landed since 1.19.210 with no explicit bump. run 32079512489. (task 1002620)
|
|
852
852
|
// 1.19.212 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
853
853
|
// landed since 1.19.211 with no explicit bump. run 32080157462. (task 1002620)
|
|
854
|
+
// 1.19.213 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
855
|
+
// landed since 1.19.212 with no explicit bump. run 32081051255. (task 1002620)
|
|
856
|
+
// 1.19.214 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
857
|
+
// landed since 1.19.213 with no explicit bump. run 32081917908. (task 1002620)
|
|
854
858
|
// ---------------------------------------------------------------------------
|
|
855
|
-
const CORE_VERSION = '1.19.
|
|
859
|
+
const CORE_VERSION = '1.19.214'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
856
860
|
|
|
857
861
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
858
862
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// tests/auth_device_start_status.mjs — a proxied upstream refusal must SURVIVE
|
|
2
|
+
// Cloudflare (walk finding W2, task 1003052).
|
|
3
|
+
//
|
|
4
|
+
// The repro this pins: GitHub refuses POST /auth/device/start (e.g. Device Flow
|
|
5
|
+
// disabled on the app) and the route used to proxy that as HTTP 502 with a JSON
|
|
6
|
+
// diagnosis in the body — but Cloudflare REPLACES origin 5xx bodies with its own
|
|
7
|
+
// bare "error code: 502" page, so an external caller got zero information; the
|
|
8
|
+
// real message was only readable via curl on the box itself. The fix is the
|
|
9
|
+
// status-code choice: a 4xx (424 failed dependency) passes the proxy with the
|
|
10
|
+
// JSON envelope intact.
|
|
11
|
+
//
|
|
12
|
+
// DB-free + network-free: auth.startDeviceFlow / authConfigured are stubbed on
|
|
13
|
+
// the cached auth module (the github_token_store.mjs pattern).
|
|
14
|
+
//
|
|
15
|
+
// THE SWEEP (task 1003052's done-when requires it recorded): upstream-refusal
|
|
16
|
+
// proxies found on the auth surface and how each was dispositioned —
|
|
17
|
+
// • device_start_failed (core routes/auth.js + the platform-identity SSO
|
|
18
|
+
// twin) — FIXED, 502 → 424 (the W2 repro itself).
|
|
19
|
+
// • device_poll_failed catch blocks (both files) — FIXED, 500 → 424: the
|
|
20
|
+
// poll leg proxies GitHub refusals the same way.
|
|
21
|
+
// • sso.js `!out.client_id` → device_poll_failed 500 — KEPT 500: an internal
|
|
22
|
+
// invariant breach, not an upstream proxy.
|
|
23
|
+
// • routes/github.js github_error 502 ×4 (repo list/create + visibility
|
|
24
|
+
// read/flip) — NOTED, not fixed: same eaten-body class, but the consuming
|
|
25
|
+
// UI already renders generic copy on any failure; candidates for the same
|
|
26
|
+
// 4xx treatment.
|
|
27
|
+
// • platform-identity my-projects.js join_relay_failed 502 — NOTED: same class.
|
|
28
|
+
// • auth_not_configured / idp_not_configured 503s — NOTED: config-state
|
|
29
|
+
// answers, not upstream proxies, but their bodies are equally eaten by
|
|
30
|
+
// Cloudflare (the blocker-1000105 class) if external diagnosability ever
|
|
31
|
+
// matters there.
|
|
32
|
+
// • api-errors.js catalogs bad_gateway=502 — any route leaning on it
|
|
33
|
+
// inherits the hazard.
|
|
34
|
+
|
|
35
|
+
import { strict as assert } from 'node:assert';
|
|
36
|
+
import { test } from 'node:test';
|
|
37
|
+
import { createRequire } from 'node:module';
|
|
38
|
+
import fs from 'node:fs';
|
|
39
|
+
import path from 'node:path';
|
|
40
|
+
import { fileURLToPath } from 'node:url';
|
|
41
|
+
|
|
42
|
+
const require = createRequire(import.meta.url);
|
|
43
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
44
|
+
const auth = require(path.join(ROOT, 'src/bongos/auth.js'));
|
|
45
|
+
const buildAuthRouter = require(path.join(ROOT, 'src/bongos/routes/auth.js'));
|
|
46
|
+
const { attachFail } = require(path.join(ROOT, 'src/bongos/middleware/error-envelope.js'));
|
|
47
|
+
|
|
48
|
+
function fakeRes() {
|
|
49
|
+
const res = { _status: 200, _body: null };
|
|
50
|
+
res.status = (c) => { res._status = c; return res; };
|
|
51
|
+
res.json = (b) => { res._body = b; return res; };
|
|
52
|
+
attachFail({}, res, () => {});
|
|
53
|
+
return res;
|
|
54
|
+
}
|
|
55
|
+
function finalHandler(router, method, pth) {
|
|
56
|
+
const layer = router.stack.find((l) => l.route && l.route.path === pth && l.route.methods[method]);
|
|
57
|
+
const stack = layer.route.stack;
|
|
58
|
+
return stack[stack.length - 1].handle;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
test('an upstream device-flow refusal answers 424 with the diagnosis intact — never a Cloudflare-eaten 5xx', async () => {
|
|
62
|
+
const orig = { configured: auth.authConfigured, start: auth.startDeviceFlow };
|
|
63
|
+
auth.authConfigured = () => true;
|
|
64
|
+
auth.startDeviceFlow = async () => {
|
|
65
|
+
throw new Error('GitHub rejected device-flow start: device_flow_disabled — terminal sign-in needs Device Flow enabled on the app.');
|
|
66
|
+
};
|
|
67
|
+
try {
|
|
68
|
+
const handler = finalHandler(buildAuthRouter(), 'post', '/auth/device/start');
|
|
69
|
+
const res = fakeRes();
|
|
70
|
+
await handler({}, res);
|
|
71
|
+
assert.equal(res._status, 424, 'a 4xx passes Cloudflare with its body; a 5xx does not');
|
|
72
|
+
assert.ok(res._status < 500, 'the whole 5xx range is the Cloudflare-intercepted range');
|
|
73
|
+
assert.equal(res._body.error.code, 'device_start_failed');
|
|
74
|
+
assert.match(res._body.error.message, /device_flow_disabled/,
|
|
75
|
+
'the upstream reason travels in the body — this is the diagnosis W2 lost');
|
|
76
|
+
} finally {
|
|
77
|
+
auth.authConfigured = orig.configured;
|
|
78
|
+
auth.startDeviceFlow = orig.start;
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('the sweep holds: no device-flow refusal site anywhere proxies a 5xx from a catch', () => {
|
|
83
|
+
// The same refusal codes are minted in two places (the core routes and the
|
|
84
|
+
// platform-identity SSO hub twins); a consumer must never see two statuses.
|
|
85
|
+
// The one sanctioned 500 is sso.js's `!out.client_id` internal-invariant
|
|
86
|
+
// branch — recognizable because it is NOT inside a catch's console.error run.
|
|
87
|
+
for (const file of [
|
|
88
|
+
'src/bongos/routes/auth.js',
|
|
89
|
+
'modules/platform-identity/routes/sso.js',
|
|
90
|
+
]) {
|
|
91
|
+
const src = fs.readFileSync(path.join(ROOT, file), 'utf8');
|
|
92
|
+
for (const m of src.matchAll(/device_start_failed',\s*\{\s*status:\s*(\d+)/g)) {
|
|
93
|
+
assert.ok(Number(m[1]) < 500,
|
|
94
|
+
`${file} proxies device_start_failed as ${m[1]} — Cloudflare eats 5xx bodies (W2)`);
|
|
95
|
+
}
|
|
96
|
+
for (const m of src.matchAll(/console\.error\([^\n]*device\/poll[^\n]*\);[\s\S]{0,400}?device_poll_failed',\s*\{\s*status:\s*(\d+)/g)) {
|
|
97
|
+
assert.ok(Number(m[1]) < 500,
|
|
98
|
+
`${file}'s device/poll catch proxies device_poll_failed as ${m[1]} — same W2 class`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
});
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
// • listInboxFor — the three buckets, and that joinRequests gates on
|
|
14
14
|
// owner (goals.created_by) OR manager (goal_members 'lead').
|
|
15
15
|
// • listPendingRequestsForGoal — pending both-kinds for one goal.
|
|
16
|
+
// • addGoalMember (task 1002888) — arriving as a member by ANY route cancels
|
|
17
|
+
// that builder's still-pending ask, atomically and kind-agnostically.
|
|
16
18
|
// • the migration is additive + private-safe (default 'public', partial unique).
|
|
17
19
|
|
|
18
20
|
import { strict as assert } from 'node:assert';
|
|
@@ -31,6 +33,7 @@ const {
|
|
|
31
33
|
getMembershipRequest,
|
|
32
34
|
resolveMembershipRequest,
|
|
33
35
|
acceptMembershipRequestAndAddMember,
|
|
36
|
+
addGoalMember,
|
|
34
37
|
listInboxFor,
|
|
35
38
|
listPendingRequestsForGoal,
|
|
36
39
|
} = require('../modules/lifecycle/db.js');
|
|
@@ -190,6 +193,92 @@ await test('listPendingRequestsForGoal: pending both-kinds for one goal', async
|
|
|
190
193
|
assert.deepEqual(find(pool.queries, /goal_membership_requests/).params, [5]);
|
|
191
194
|
});
|
|
192
195
|
|
|
196
|
+
// =========================================================================
|
|
197
|
+
// addGoalMember — arriving as a member CLOSES any pending ask (task 1002888 /
|
|
198
|
+
// idea 1000688). The bug: the membership write was a bare upsert that never
|
|
199
|
+
// read goal_membership_requests, so a pending row survived the builder's
|
|
200
|
+
// arrival by any OTHER route (visibility flips to public and they self-join;
|
|
201
|
+
// an Archon adds them directly). The stale row sat in the owner/manager inbox,
|
|
202
|
+
// a later approve resolved a request for an existing member, and a later deny
|
|
203
|
+
// stamped 'rejected' on someone who is in fact a member.
|
|
204
|
+
// =========================================================================
|
|
205
|
+
|
|
206
|
+
const ADD_MEMBER_ROW = { goal_id: 7, builder_id: 42, role: 'member', joined_at: 'T0' };
|
|
207
|
+
const addMemberPool = () => fakePool((s) => (/^WITH added AS/.test(s) ? { rows: [ADD_MEMBER_ROW] } : null));
|
|
208
|
+
|
|
209
|
+
// join-cancels-request: the joiner arrives under their own steam.
|
|
210
|
+
await test('addGoalMember: a join cancels the joiner\'s pending ask in ONE statement', async () => {
|
|
211
|
+
const pool = addMemberPool();
|
|
212
|
+
const out = await addGoalMember({ goalId: 7, builderId: 42, role: 'member', resolvedBy: 42 }, { pool });
|
|
213
|
+
assert.deepEqual(out, ADD_MEMBER_ROW, 'still returns the member row — the route response shape is unchanged');
|
|
214
|
+
assert.equal(pool.queries.length, 1, 'ONE statement: the cancel commits with the membership write or not at all');
|
|
215
|
+
const q = find(pool.queries, /^WITH added AS/);
|
|
216
|
+
assert.ok(/INSERT INTO goal_members[\s\S]*ON CONFLICT \(goal_id, builder_id\) DO UPDATE SET role = goal_members\.role/.test(norm(q.sql)),
|
|
217
|
+
'the membership upsert is the first arm, role still preserved (task 1002689)');
|
|
218
|
+
assert.ok(/UPDATE goal_membership_requests[\s\S]*SET status = 'cancelled', resolved_by = \$4/.test(norm(q.sql)),
|
|
219
|
+
'the second arm closes the ask as cancelled, attributed to the actor');
|
|
220
|
+
assert.ok(/WHERE goal_id = \$1 AND builder_id = \$2 AND status = 'pending'/.test(norm(q.sql)),
|
|
221
|
+
'scoped to THIS goal+builder, and only a pending row');
|
|
222
|
+
assert.deepEqual(q.params, [7, 42, 'member', 42], 'goal, builder, role, resolvedBy — in that order');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// add-cancels-request: an owner/manager/Archon admits them directly. Same
|
|
226
|
+
// statement; resolvedBy is the ADDER, so the audit trail names who admitted them.
|
|
227
|
+
await test('addGoalMember: a direct member-add cancels the target\'s pending ask, attributed to the adder', async () => {
|
|
228
|
+
const pool = addMemberPool();
|
|
229
|
+
await addGoalMember({ goalId: 7, builderId: 42, role: 'lead', resolvedBy: 99 }, { pool });
|
|
230
|
+
const q = find(pool.queries, /^WITH added AS/);
|
|
231
|
+
assert.deepEqual(q.params, [7, 42, 'lead', 99],
|
|
232
|
+
'resolved_by is the ADDER (99), not the target (42) — who admitted them is the audit fact');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// The mirror-case decision (task 1002888): a stale pending INVITE is the same
|
|
236
|
+
// bug reflected — the invitee could later decline an invitation to a goal they
|
|
237
|
+
// are already in, stamping 'rejected' on a member. core_188's partial-unique
|
|
238
|
+
// index allows at most ONE pending row per (goal, builder) in EITHER direction,
|
|
239
|
+
// so "any pending row" is unambiguous and the cancel is deliberately kind-blind.
|
|
240
|
+
await test('addGoalMember: the cancel is kind-AGNOSTIC — a stale pending invite closes too', async () => {
|
|
241
|
+
const pool = addMemberPool();
|
|
242
|
+
await addGoalMember({ goalId: 7, builderId: 42, resolvedBy: 42 }, { pool });
|
|
243
|
+
const cancelArm = norm(find(pool.queries, /^WITH added AS/).sql).match(/UPDATE goal_membership_requests.*?RETURNING id/)[0];
|
|
244
|
+
assert.ok(!/kind\s*=/.test(cancelArm),
|
|
245
|
+
'no kind filter: both a pending request and a pending invite are closed by arrival');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// 'accepted' stays reserved for an accept/approve flow that actually ran, so the
|
|
249
|
+
// inbox history never claims a decision nobody made; resolved rows are the audit
|
|
250
|
+
// trail and must survive untouched.
|
|
251
|
+
await test('addGoalMember: closes as \'cancelled\' (never \'accepted\'), and only pending rows', async () => {
|
|
252
|
+
const pool = addMemberPool();
|
|
253
|
+
await addGoalMember({ goalId: 7, builderId: 42, resolvedBy: 42 }, { pool });
|
|
254
|
+
const sql = norm(find(pool.queries, /^WITH added AS/).sql);
|
|
255
|
+
assert.ok(!/status = 'accepted'/.test(sql), "arrival never fabricates an 'accepted' decision");
|
|
256
|
+
assert.ok(/status = 'pending'/.test(sql), 'already-resolved rows stay as the audit trail');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
await test('addGoalMember: resolvedBy defaults to null for a non-route caller', async () => {
|
|
260
|
+
const pool = addMemberPool();
|
|
261
|
+
await addGoalMember({ goalId: 7, builderId: 42 }, { pool });
|
|
262
|
+
assert.equal(find(pool.queries, /^WITH added AS/).params[3], null);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// Both route call sites must actually SUPPLY the actor — the db helper defaults
|
|
266
|
+
// resolvedBy to null, so a missed wire-up would silently drop the attribution
|
|
267
|
+
// while still cancelling. The routes are verified against live Postgres (see
|
|
268
|
+
// goal_routes.mjs header), so this pins the wiring at the source level.
|
|
269
|
+
await test('routes: both /join and /members pass resolvedBy to addGoalMember', () => {
|
|
270
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
271
|
+
const src = readFileSync(join(here, '..', 'modules', 'lifecycle', 'routes', 'goals.js'), 'utf8');
|
|
272
|
+
const calls = src.match(/db\.addGoalMember\(\{[^}]*\}\)/g) || [];
|
|
273
|
+
assert.equal(calls.length, 2, 'exactly two membership-add call sites: POST /join and POST /members');
|
|
274
|
+
for (const c of calls) {
|
|
275
|
+
assert.ok(/resolvedBy:\s*req\.builder\.id/.test(c),
|
|
276
|
+
`every membership add attributes the cancel to the acting builder — got: ${c}`);
|
|
277
|
+
}
|
|
278
|
+
assert.ok(calls.some((c) => /builderId:\s*req\.builder\.id/.test(c)), '/join adds the caller themselves');
|
|
279
|
+
assert.ok(calls.some((c) => /builderId,/.test(c)), '/members adds the named target builder');
|
|
280
|
+
});
|
|
281
|
+
|
|
193
282
|
// =========================================================================
|
|
194
283
|
// The migration is additive + private-safe
|
|
195
284
|
// =========================================================================
|
package/tests/goal_tier.mjs
CHANGED
|
@@ -154,20 +154,25 @@ await test('setGoalStatus: unknown goal returns null', async () => {
|
|
|
154
154
|
// =========================================================================
|
|
155
155
|
// addGoalMember
|
|
156
156
|
// =========================================================================
|
|
157
|
+
// task 1002888 wrapped the bare upsert in a CTE that also cancels any pending
|
|
158
|
+
// membership request, so the statement now STARTS with `WITH added AS (` — the
|
|
159
|
+
// INSERT is the first arm. The pending-ask cancellation itself is pinned in
|
|
160
|
+
// tests/goal_membership_requests.mjs; here we only re-anchor the role contract.
|
|
157
161
|
await test('addGoalMember: upsert PRESERVES the existing role (re-join cannot demote a lead)', async () => {
|
|
158
|
-
const pool = fakePool((s) => (/^
|
|
162
|
+
const pool = fakePool((s) => (/^WITH added AS/.test(s) ? { rows: [{ ...MEMBER_ROW, role: 'lead' }] } : null));
|
|
159
163
|
const out = await addGoalMember({ goalId: 7, builderId: 42, role: 'member' }, { pool });
|
|
160
164
|
assert.equal(out.role, 'lead', 'an existing lead row survives a role:member re-add');
|
|
161
|
-
const ins = find(pool.queries, /^
|
|
162
|
-
assert.deepEqual(ins.params, [7, 42, 'member']);
|
|
165
|
+
const ins = find(pool.queries, /^WITH added AS/);
|
|
166
|
+
assert.deepEqual(ins.params, [7, 42, 'member', null], 'resolvedBy defaults to null when no actor is supplied');
|
|
167
|
+
assert.match(ins.sql, /INSERT INTO goal_members/, 'the membership write is the first CTE arm');
|
|
163
168
|
assert.match(ins.sql, /ON CONFLICT \(goal_id, builder_id\) DO UPDATE SET role = goal_members\.role/,
|
|
164
169
|
'conflict keeps the stored role — role changes go through PATCH /goals/:id/members/:builderId only (task 1002689)');
|
|
165
170
|
});
|
|
166
171
|
|
|
167
172
|
await test('addGoalMember: role defaults to member', async () => {
|
|
168
|
-
const pool = fakePool((s) => (/^
|
|
173
|
+
const pool = fakePool((s) => (/^WITH added AS/.test(s) ? { rows: [MEMBER_ROW] } : null));
|
|
169
174
|
await addGoalMember({ goalId: 7, builderId: 42 }, { pool });
|
|
170
|
-
assert.equal(find(pool.queries, /^
|
|
175
|
+
assert.equal(find(pool.queries, /^WITH added AS/).params[2], 'member');
|
|
171
176
|
});
|
|
172
177
|
|
|
173
178
|
// =========================================================================
|
|
@@ -51,10 +51,12 @@ const ta = async (name, fn) => {
|
|
|
51
51
|
|
|
52
52
|
console.log('isDeviceFlowDisabled — the refusal detector:');
|
|
53
53
|
|
|
54
|
-
// The REAL shape: routes/auth.js res.fail('device_start_failed', {status:
|
|
54
|
+
// The REAL shape: routes/auth.js res.fail('device_start_failed', {status:424,
|
|
55
55
|
// message: 'GitHub rejected device-flow start: device_flow_disabled — …'}).
|
|
56
|
+
// (424 since task 1003052 — a 502's body was eaten by Cloudflare; the detector
|
|
57
|
+
// matches on TEXT, so both the new 424 and an older server's 502 register.)
|
|
56
58
|
const REAL_REFUSAL = {
|
|
57
|
-
ok: false, status:
|
|
59
|
+
ok: false, status: 424,
|
|
58
60
|
data: { error: { code: 'device_start_failed', message: 'GitHub rejected device-flow start: device_flow_disabled — terminal sign-in needs Device Flow enabled on the app: open https://github.com/settings/apps, open your app, check "Enable Device Flow", and save. Web sign-in is unaffected.' } },
|
|
59
61
|
};
|
|
60
62
|
|
|
@@ -62,6 +64,10 @@ t('matches the real device_start_failed envelope carrying device_flow_disabled',
|
|
|
62
64
|
assert.equal(setup.isDeviceFlowDisabled(REAL_REFUSAL), true);
|
|
63
65
|
});
|
|
64
66
|
|
|
67
|
+
t('still matches an older server\'s 502 shape — the CLI talks to many core versions', () => {
|
|
68
|
+
assert.equal(setup.isDeviceFlowDisabled({ ...REAL_REFUSAL, status: 502 }), true);
|
|
69
|
+
});
|
|
70
|
+
|
|
65
71
|
t('matches a legacy flat error shape too', () => {
|
|
66
72
|
assert.equal(setup.isDeviceFlowDisabled({ ok: false, status: 502, data: { error: 'device_start_failed', message: 'x device_flow_disabled y' } }), true);
|
|
67
73
|
});
|