@bongos/core 1.19.532 → 1.19.534
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 +113 -48
- package/clients/bongos-client/index.d.ts +1 -1
- package/config/branding.neutral.json +3 -2
- package/docs/adr/0244-a-catalog-search-is-a-view-over-the-rows-never-an-edit-to-the-selection.md +116 -0
- package/docs/adr/0245-the-way-back-into-an-optional-step-is-that-step-s-own-answer-route.md +144 -0
- package/docs/adr/0246-catalog-membership-is-a-projection-not-a-federation-side-effect.md +96 -0
- package/docs/adr/0247-the-join-door-is-one-composed-answer-and-dark-matter-has-no-live-verdict-yet.md +202 -0
- package/docs/adr/README.md +5 -0
- package/docs/api/openapi.json +8 -1
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +129 -124
- package/docs/copy-registry.json +205 -159
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +16 -0
- package/modules/onboarding/module.json +1 -1
- package/modules/onboarding/routes/access-requests.js +21 -6
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/project-catalog.js +202 -0
- package/modules/platform-identity/routes/my-projects.js +7 -2
- package/modules/platform-identity/routes/projects.js +6 -0
- package/modules/platform-identity/routes/sso.js +10 -0
- package/modules/provisioning/catalog-bridge.js +105 -0
- package/modules/provisioning/provisioning.js +28 -2
- package/modules/provisioning/routes/provisioning.js +45 -11
- package/modules/provisioning/tests/provisioning.mjs +11 -8
- package/modules/public-landing/public/assets/cosmos.css +15 -0
- package/modules/public-landing/public/projects.html +221 -2
- package/modules/public-landing/public/projects.states.json +2 -1
- package/modules/ui-design/kit/serve.js +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +9 -0
- package/scripts/gds/provision-net.js +7 -0
- package/src/bongos/project-door.js +138 -0
- package/src/branding.js +8 -0
- package/src/module-api.js +12 -1
- package/tests/access_requests_invite.mjs +2 -2
- package/tests/application_lifecycle.mjs +1 -0
- package/tests/catalog_only_client.mjs +242 -0
- package/tests/currency_label.mjs +8 -5
- package/tests/module_api.mjs +1 -0
- package/tests/project_catalog_bridge.mjs +355 -0
- package/tests/project_catalog_routes.mjs +213 -0
- package/tests/project_join_door.mjs +166 -0
- package/tests/project_join_door_route.mjs +206 -0
- package/tests/project_modules_ui.mjs +318 -0
- package/tests/projects_hub.mjs +28 -1
- package/tests/projects_hub_module_picker.mjs +207 -0
- package/tests/projects_hub_pre_uat.mjs +2 -2
- package/tests/provision_settings_apply.mjs +6 -5
- package/tests/provisioning_module_selection.mjs +164 -3
- package/tests/provisioning_settings.mjs +10 -7
- package/tests/provisioning_settings_apply.mjs +3 -3
- package/tests/provisioning_settings_env.mjs +7 -4
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// src/bongos/project-door.js — THE project's join/apply door, composed from every
|
|
2
|
+
// knob that can close it (BV1.R12, task 1002331; ADR 0182 D6, ADR 0247).
|
|
3
|
+
//
|
|
4
|
+
// WHAT IT ANSWERS: "a stranger has arrived at this project's door — what happens?"
|
|
5
|
+
// One value, so no two surfaces can answer it differently:
|
|
6
|
+
//
|
|
7
|
+
// open no request needed; signing in is the way in
|
|
8
|
+
// apply an application is accepted and queued for the owner
|
|
9
|
+
// invite_only no application is taken; an invite is the only way in
|
|
10
|
+
// locked nothing — the project is not published, so there is no door
|
|
11
|
+
//
|
|
12
|
+
// WHY IT EXISTS. Three separate owner-set knobs each narrow the same door, and
|
|
13
|
+
// before this file only one of them (`joinability`) was read at the door — so an
|
|
14
|
+
// owner could set their project `private` or `stealth` on the platform's map and
|
|
15
|
+
// still have its public application endpoint behave exactly as before. ADR 0182 D6
|
|
16
|
+
// wrote the composition rule down ("visibility sets the ceiling; join policy may
|
|
17
|
+
// only narrow it; the stricter of the two always wins, and neither can widen the
|
|
18
|
+
// other") and left it unimplemented. This is that rule, as code, in one place.
|
|
19
|
+
//
|
|
20
|
+
// THE THREE KNOBS (all owner-set on the platform, pushed into this instance's
|
|
21
|
+
// branding pack by the provisioning runner, applied by a restart):
|
|
22
|
+
//
|
|
23
|
+
// visibility public | private | stealth how the project stands on the map
|
|
24
|
+
// joinGrant view | apply | full what a PUBLIC join grants (R12)
|
|
25
|
+
// joinability open | apply | invite_only how someone becomes a member (ADR 0194)
|
|
26
|
+
//
|
|
27
|
+
// plus `publishable`, which is DERIVED and never stored (ADR 0182 D1/D4: a project
|
|
28
|
+
// with no name or no description is "dark matter" — off the map and un-joinable).
|
|
29
|
+
//
|
|
30
|
+
// THE REFUSAL NEVER NAMES THE STATE. `stealth`, `public`+`view`, and an explicit
|
|
31
|
+
// `invite_only` all answer with the SAME door, so a refusal can never be read
|
|
32
|
+
// backwards to learn which state a project is in. That is not a nicety: ADR 0182
|
|
33
|
+
// D2a makes stealth's anonymity a privacy primitive, and a door that said "this
|
|
34
|
+
// project is stealth" would hand back exactly what the feed redaction protects.
|
|
35
|
+
// A refused application is also never STORED, so a closed project accumulates no
|
|
36
|
+
// queue to leak (D6).
|
|
37
|
+
//
|
|
38
|
+
// FAIL CLOSED. An unreadable value — a truncated env write, a fourth enum value a
|
|
39
|
+
// future release adds, a pack this core cannot parse — is never the wider door.
|
|
40
|
+
// Same rule ADR 0194 D2 applies to `joinability` alone, and the same direction R06
|
|
41
|
+
// took for the feed (an unrecognised visibility draws as a black hole, not a planet).
|
|
42
|
+
|
|
43
|
+
'use strict';
|
|
44
|
+
|
|
45
|
+
// The door vocabulary, ORDERED widest → strictest. The order IS the composition
|
|
46
|
+
// rule: two knobs never negotiate, the higher index simply wins.
|
|
47
|
+
const JOIN_DOORS = Object.freeze(['open', 'apply', 'invite_only', 'locked']);
|
|
48
|
+
const DOOR_RANK = Object.freeze(Object.fromEntries(JOIN_DOORS.map((d, i) => [d, i])));
|
|
49
|
+
|
|
50
|
+
// What a join of a PUBLIC project grants — the owner's own words from the project
|
|
51
|
+
// interview (BV1.R12). It is a CEILING on the visibility axis, not a second
|
|
52
|
+
// admission policy: `full` means "this axis does not narrow anything", which is
|
|
53
|
+
// why it is the default (DEFAULT_SETTINGS must be today's behaviour).
|
|
54
|
+
const PROJECT_JOIN_GRANTS = Object.freeze(['view', 'apply', 'full']);
|
|
55
|
+
const DOOR_BY_GRANT = Object.freeze({ full: 'open', apply: 'apply', view: 'invite_only' });
|
|
56
|
+
|
|
57
|
+
// The ceiling each stored visibility state sets (ADR 0182 D6's table). `public` is
|
|
58
|
+
// absent on purpose — it is the one state that defers to the grant.
|
|
59
|
+
const DOOR_BY_VISIBILITY = Object.freeze({ private: 'apply', stealth: 'invite_only' });
|
|
60
|
+
|
|
61
|
+
// The joinability policies this core can read (ADR 0194). Its own reader is already
|
|
62
|
+
// fail-closed, so this is the guard against a caller that bypassed it.
|
|
63
|
+
const JOINABILITY_DOOR = Object.freeze({ open: 'open', apply: 'apply', invite_only: 'invite_only' });
|
|
64
|
+
|
|
65
|
+
// The strictest of two doors. PURE. An unknown name is treated as the strictest
|
|
66
|
+
// door there is rather than ignored, so a caller can never widen this by passing
|
|
67
|
+
// a value the table does not carry.
|
|
68
|
+
function strictestDoor(a, b) {
|
|
69
|
+
const ra = DOOR_RANK[a] === undefined ? DOOR_RANK.locked : DOOR_RANK[a];
|
|
70
|
+
const rb = DOOR_RANK[b] === undefined ? DOOR_RANK.locked : DOOR_RANK[b];
|
|
71
|
+
return JOIN_DOORS[Math.max(ra, rb)];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The ceiling the project's place on the map sets, before `joinability` narrows it
|
|
75
|
+
// (ADR 0182 D6). PURE. `public` hands the answer to the grant; every other value —
|
|
76
|
+
// including one this core does not recognise — is at most `invite_only`.
|
|
77
|
+
function visibilityCeiling(visibility, joinGrant) {
|
|
78
|
+
if (visibility === 'public') {
|
|
79
|
+
return DOOR_BY_GRANT[joinGrant] || 'invite_only';
|
|
80
|
+
}
|
|
81
|
+
return DOOR_BY_VISIBILITY[visibility] || 'invite_only';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// projectJoinDoor — the whole decision, PURE, from the four inputs a door surface
|
|
85
|
+
// holds. This is the function every join/apply surface must call instead of reading
|
|
86
|
+
// any single knob, so the public application endpoint, the hall's landing form, the
|
|
87
|
+
// CLI and the hub's join relay cannot disagree about which door this project runs.
|
|
88
|
+
//
|
|
89
|
+
// `publishable` is a TRI-STATE and the third value matters: `false` locks the door
|
|
90
|
+
// (dark matter — ADR 0182 D6: "refused — there is no door to knock on"), `true`
|
|
91
|
+
// does not open anything on its own, and `null`/undefined means NO VERDICT — the
|
|
92
|
+
// caller does not hold a publish gate and must not invent one (the same word
|
|
93
|
+
// `provisioning.publishVerdict` uses when the `project.publishGate` port is absent).
|
|
94
|
+
// A caller with no verdict is answered on the three stored knobs alone.
|
|
95
|
+
function projectJoinDoor({ visibility, joinGrant, joinability, publishable = null } = {}) {
|
|
96
|
+
if (publishable === false) return 'locked';
|
|
97
|
+
const ceiling = visibilityCeiling(visibility, joinGrant);
|
|
98
|
+
// An unreadable `joinability` closes to `invite_only`, not to `locked`: its own
|
|
99
|
+
// reader already chose that value for this failure (ADR 0194 D2), and `locked`
|
|
100
|
+
// is a specific CLAIM — "this project is not published" — that an unknown policy
|
|
101
|
+
// is no evidence for. Refusing honestly matters more than refusing hardest.
|
|
102
|
+
return strictestDoor(ceiling, JOINABILITY_DOOR[joinability] || 'invite_only');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// effectiveJoinDoor — this project's LIVE door: projectJoinDoor() over the policy
|
|
106
|
+
// this instance actually resolved. The one call a door surface makes; published
|
|
107
|
+
// through the module doorway as `api.projectJoinDoor()` so a module never
|
|
108
|
+
// re-derives the composition (the drift `joinabilityMode` exists to prevent).
|
|
109
|
+
//
|
|
110
|
+
// The requires are deliberately lazy: this file's pure half must stay importable by
|
|
111
|
+
// a test with no branding pack, no env and no database behind it.
|
|
112
|
+
//
|
|
113
|
+
// A pack that will not load leaves BOTH visibility and the grant unknown, and
|
|
114
|
+
// `visibilityCeiling` answers that with `invite_only` — the same fail-closed door
|
|
115
|
+
// ADR 0194 D2 chose for the same failure, so this composition never reopens
|
|
116
|
+
// something that reader had already closed.
|
|
117
|
+
function effectiveJoinDoor(deps = {}) {
|
|
118
|
+
const readBranding = deps.branding || require('../branding').branding; // eslint-disable-line global-require
|
|
119
|
+
const readJoinability = deps.joinabilityMode || require('./auth-admission').joinabilityMode; // eslint-disable-line global-require
|
|
120
|
+
let project = {};
|
|
121
|
+
try { project = readBranding().project || {}; } catch { project = {}; }
|
|
122
|
+
return projectJoinDoor({
|
|
123
|
+
visibility: project.visibility,
|
|
124
|
+
joinGrant: project.joinGrant,
|
|
125
|
+
joinability: readJoinability(),
|
|
126
|
+
publishable: deps.publishable === undefined ? null : deps.publishable,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = {
|
|
131
|
+
JOIN_DOORS,
|
|
132
|
+
DOOR_RANK,
|
|
133
|
+
PROJECT_JOIN_GRANTS,
|
|
134
|
+
strictestDoor,
|
|
135
|
+
visibilityCeiling,
|
|
136
|
+
projectJoinDoor,
|
|
137
|
+
effectiveJoinDoor,
|
|
138
|
+
};
|
package/src/branding.js
CHANGED
|
@@ -65,6 +65,10 @@ const ENV_OVERRIDES = [
|
|
|
65
65
|
// this instance's own sign-in door; this one is how the project stands on the
|
|
66
66
|
// platform's map, and D6 makes it the ceiling JOINABILITY may only narrow.
|
|
67
67
|
['VISIBILITY', ['project', 'visibility']],
|
|
68
|
+
// What a join of this project grants while it is PUBLIC (BV1.R12, task 1002331) —
|
|
69
|
+
// view | apply | full. The ceiling on the visibility axis that JOINABILITY may
|
|
70
|
+
// only narrow; composed into one door by src/bongos/project-door.js.
|
|
71
|
+
['JOIN_GRANT', ['project', 'joinGrant']],
|
|
68
72
|
];
|
|
69
73
|
|
|
70
74
|
const isObj = (v) => v && typeof v === 'object' && !Array.isArray(v);
|
|
@@ -224,6 +228,10 @@ function clientBranding(b = branding()) {
|
|
|
224
228
|
// two neighbours: it is how the project asks to be seen on the map, and it is
|
|
225
229
|
// the READ-BACK a manage page needs to tell a saved choice from a live one.
|
|
226
230
|
visibility: b.project?.visibility || 'public',
|
|
231
|
+
// What a public join grants (task 1002331). Public for the same reason: a
|
|
232
|
+
// stranger's client should be able to say what THIS door does before knocking.
|
|
233
|
+
// `full` = this axis narrows nothing, so an un-pushed instance is unchanged.
|
|
234
|
+
joinGrant: b.project?.joinGrant || 'full',
|
|
227
235
|
},
|
|
228
236
|
};
|
|
229
237
|
}
|
package/src/module-api.js
CHANGED
|
@@ -15,6 +15,7 @@ const util = require('node:util');
|
|
|
15
15
|
const { logger: coreLogger } = require('./bongos/logger');
|
|
16
16
|
const { pool, instanceDbName } = require('./bongos/pool');
|
|
17
17
|
const branding = require('./branding');
|
|
18
|
+
const projectDoor = require('./bongos/project-door');
|
|
18
19
|
const instanceConfig = require('./instance-config');
|
|
19
20
|
const staleTimer = require('./stale-timer');
|
|
20
21
|
// Destructured, not namespaced, deliberately — the same form src/bongos/routes/
|
|
@@ -45,7 +46,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
45
46
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
46
47
|
// the entry to that file. Look for a version's history there, not here.
|
|
47
48
|
// ---------------------------------------------------------------------------
|
|
48
|
-
const CORE_VERSION = '1.19.
|
|
49
|
+
const CORE_VERSION = '1.19.534'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
49
50
|
|
|
50
51
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
51
52
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -242,6 +243,16 @@ module.exports = {
|
|
|
242
243
|
// reader, and the sign-in flows and the public access-request route
|
|
243
244
|
// must answer with the same door (SR-19).
|
|
244
245
|
joinabilityMode: auth.joinabilityMode,
|
|
246
|
+
// projectJoinDoor() — the project's COMPOSED join/apply door: 'open' | 'apply' |
|
|
247
|
+
// 'invite_only' | 'locked' (BV1.R12, task 1002331; ADR 0182 D6, ADR 0247).
|
|
248
|
+
// `joinabilityMode` above answers one knob; this answers the whole door,
|
|
249
|
+
// because the project's place on the platform's map (`visibility`) and what a
|
|
250
|
+
// public join grants (`joinGrant`) each narrow it too, and "the stricter of
|
|
251
|
+
// the two always wins" is only true if one function says so. Every join/apply
|
|
252
|
+
// surface calls THIS; a module that read a single knob would re-open a door
|
|
253
|
+
// the other two had closed. Optional `{ publishable }` for a caller that
|
|
254
|
+
// holds a publish verdict — see src/bongos/project-door.js.
|
|
255
|
+
projectJoinDoor: (opts) => projectDoor.effectiveJoinDoor(opts),
|
|
245
256
|
// userAgent — the instance's HTTP User-Agent string (branding-derived). The
|
|
246
257
|
// carved lifecycle module's github-push.js stamps every GitHub API request with
|
|
247
258
|
// it instead of reaching into src/branding.js directly (BV1.R86).
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// The doorway is stubbed in require.cache BEFORE the route loads (the route
|
|
10
10
|
// destructures pool/validators at require time — the web_admission_status
|
|
11
11
|
// pattern): the session gates admit a fixed Archon, the pool replays a script
|
|
12
|
-
// of results and records every query, and
|
|
12
|
+
// of results and records every query, and projectJoinDoor is a switch.
|
|
13
13
|
//
|
|
14
14
|
// Run: node --test tests/access_requests_invite.mjs
|
|
15
15
|
|
|
@@ -39,7 +39,7 @@ const pool = {
|
|
|
39
39
|
require.cache[apiPath].exports = {
|
|
40
40
|
...realApi,
|
|
41
41
|
pool,
|
|
42
|
-
|
|
42
|
+
projectJoinDoor: () => mode,
|
|
43
43
|
requireBuilder: (req, _res, next) => { req.builder = { id: 42, github_login: 'archon' }; next(); },
|
|
44
44
|
requirePermission: () => (_req, _res, next) => next(),
|
|
45
45
|
};
|
|
@@ -252,6 +252,7 @@ require.cache[apiPath].exports = {
|
|
|
252
252
|
...realApi,
|
|
253
253
|
pool,
|
|
254
254
|
joinabilityMode: () => 'apply',
|
|
255
|
+
projectJoinDoor: () => 'apply',
|
|
255
256
|
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
|
256
257
|
requireBuilder: (req, _res, next) => { req.builder = { id: 42, github_login: 'owner' }; next(); },
|
|
257
258
|
// requirePermission is NOT stubbed: realApi's own gate is used, so the denial
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// tests/catalog_only_client.mjs — A CATALOG-ONLY CLIENT IS AN IDENTITY, NEVER A CREDENTIAL
|
|
2
|
+
// (task 1003576, ADR 0246). The security half of the catalog bridge.
|
|
3
|
+
//
|
|
4
|
+
// The bridge mints an `platform_identity_sso_clients` row for EVERY control-plane project so
|
|
5
|
+
// the map, the membership directory and `POST /projects/invite` have something to name — a
|
|
6
|
+
// large increase in the number of registered clients. This file is the proof that the row it
|
|
7
|
+
// mints can do nothing but be named: it carries an EMPTY secret_hash and an EMPTY
|
|
8
|
+
// redirect-URI allow-list, and every SSO leg refuses it.
|
|
9
|
+
//
|
|
10
|
+
// Four of the five legs already fail closed on machinery that predates this task —
|
|
11
|
+
// clientSecretValid() on a secretless client, redirectUriAllowed() on an empty allow-list —
|
|
12
|
+
// and that is exactly why those are asserted here rather than assumed: they are load-bearing
|
|
13
|
+
// for a decision made after they were written. The fifth, /sso/device/start, resolved a
|
|
14
|
+
// client from a typed origin and checked NEITHER, so it grew an explicit guard.
|
|
15
|
+
//
|
|
16
|
+
// Also pinned: the invite route stops 404ing for a wizard-made project (task 1003580's
|
|
17
|
+
// GAP 1). Its `project.curate` gate is untouched and is 1003580's own work.
|
|
18
|
+
//
|
|
19
|
+
// DB-free: the real routers driven over loopback, with the data-access functions stubbed on
|
|
20
|
+
// the cached module objects (the provisioning_settings.mjs pattern).
|
|
21
|
+
//
|
|
22
|
+
// Run: node --test --test-reporter=tap tests/catalog_only_client.mjs
|
|
23
|
+
|
|
24
|
+
import assert from 'node:assert/strict';
|
|
25
|
+
import { test, after } from 'node:test';
|
|
26
|
+
import { createRequire } from 'node:module';
|
|
27
|
+
import http from 'node:http';
|
|
28
|
+
|
|
29
|
+
const require = createRequire(import.meta.url);
|
|
30
|
+
const pi = require('../modules/platform-identity/platform-identity.js');
|
|
31
|
+
const catalog = require('../modules/platform-identity/project-catalog.js');
|
|
32
|
+
const hubKeys = require('../modules/platform-identity/hub-keys.js');
|
|
33
|
+
const hubDevice = require('../modules/platform-identity/hub-device.js');
|
|
34
|
+
const api = require('../src/module-api.js');
|
|
35
|
+
|
|
36
|
+
// The row the bridge writes: named, active, and holding nothing it could authenticate with.
|
|
37
|
+
const CATALOG_ONLY = Object.freeze({
|
|
38
|
+
client_id: 'spike-harbor', secret_hash: '', name: 'spike-harbor',
|
|
39
|
+
redirect_uris: [], origin: 'https://spike-harbor.cloudbongos.test',
|
|
40
|
+
owner_github_id: 9001, status: 'active',
|
|
41
|
+
});
|
|
42
|
+
// A real federated client, for the contrast — the same shape with a credential.
|
|
43
|
+
const FEDERATED = Object.freeze({
|
|
44
|
+
...CATALOG_ONLY, client_id: 'mercury', secret_hash: 'a'.repeat(64),
|
|
45
|
+
redirect_uris: ['https://mercury.test/api/gds/auth/web/callback'], origin: 'https://mercury.test',
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// ── the predicate itself ─────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
test('clientIsCredentialed separates "the hub can name it" from "it may sign people in"', () => {
|
|
51
|
+
assert.equal(catalog.clientIsCredentialed(FEDERATED), true);
|
|
52
|
+
assert.equal(catalog.clientIsCredentialed(CATALOG_ONLY), false);
|
|
53
|
+
for (const secret_hash of [null, undefined, '']) {
|
|
54
|
+
assert.equal(catalog.clientIsCredentialed({ ...FEDERATED, secret_hash }), false);
|
|
55
|
+
}
|
|
56
|
+
assert.equal(catalog.clientIsCredentialed(null), false, 'no client is not a credentialed one');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('clientSecretValid fails closed on a secretless client, whatever is presented', () => {
|
|
60
|
+
// The load-bearing pre-existing guard: it hashes and constant-time-compares against a decoy
|
|
61
|
+
// when there is no real secret, then refuses because there was none. So the empty string,
|
|
62
|
+
// the literal decoy input and a lucky guess all lose.
|
|
63
|
+
for (const presented of ['', 'anything', 'a'.repeat(64), 'spike-harbor']) {
|
|
64
|
+
assert.equal(pi.clientSecretValid(CATALOG_ONLY, presented), false);
|
|
65
|
+
}
|
|
66
|
+
assert.equal(pi.clientSecretValid(FEDERATED, 'wrong'), false, 'and a real client still needs the real secret');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('redirectUriAllowed fails closed on an empty allow-list', () => {
|
|
70
|
+
assert.equal(pi.redirectUriAllowed(CATALOG_ONLY, 'https://spike-harbor.cloudbongos.test/api/gds/auth/web/callback'), false);
|
|
71
|
+
assert.equal(pi.redirectUriAllowed(CATALOG_ONLY, ''), false);
|
|
72
|
+
assert.equal(pi.redirectUriAllowed(FEDERATED, FEDERATED.redirect_uris[0]), true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ── the routes, executed ─────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
// The auth middleware is DESTRUCTURED off the doorway at route-module load, so these stubs
|
|
78
|
+
// have to be in place before the routers are required.
|
|
79
|
+
api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'curator', rank: 'metic' }; next(); };
|
|
80
|
+
api.requirePermission = () => (_req, _res, next) => next();
|
|
81
|
+
|
|
82
|
+
const express = require('express');
|
|
83
|
+
const ssoRoutes = require('../modules/platform-identity/routes/sso.js');
|
|
84
|
+
const projectsRoutes = require('../modules/platform-identity/routes/projects.js');
|
|
85
|
+
|
|
86
|
+
// The routes read the hub's own config through these; the SSO legs 503 without them, which
|
|
87
|
+
// would hide the refusals under test.
|
|
88
|
+
hubKeys.hubSigningConfigured = () => true;
|
|
89
|
+
hubKeys.hubPublicKeyB64 = () => 'stub-public-key';
|
|
90
|
+
hubDevice.hubDeviceConfigured = () => true;
|
|
91
|
+
let deviceStarts = 0;
|
|
92
|
+
hubDevice.startHubGithubDevice = async () => { deviceStarts++; return { device_code: 'd', user_code: 'U' }; };
|
|
93
|
+
|
|
94
|
+
// The client registry, stubbed on the cached module object.
|
|
95
|
+
const CLIENTS = new Map([[CATALOG_ONLY.client_id, CATALOG_ONLY], [FEDERATED.client_id, FEDERATED]]);
|
|
96
|
+
pi.getClient = async (id) => CLIENTS.get(id) || null;
|
|
97
|
+
pi.getClientByOrigin = async (origin) => {
|
|
98
|
+
const norm = String(origin || '').replace(/\/+$/, '');
|
|
99
|
+
return [...CLIENTS.values()].find((c) => c.origin === norm && c.status === 'active') || null;
|
|
100
|
+
};
|
|
101
|
+
// The invite leg's two other reads — the point is which refusal the route reaches, not what
|
|
102
|
+
// a real membership write does.
|
|
103
|
+
pi.getAccountByLogin = async (login) => (login === 'ghost' ? null : { github_id: 5150, github_login: login });
|
|
104
|
+
pi.inviteToProject = async ({ clientId, githubId }) => ({ invited: true, clientId, githubId });
|
|
105
|
+
|
|
106
|
+
const app = express();
|
|
107
|
+
app.use(express.json());
|
|
108
|
+
app.use((_req, res, next) => {
|
|
109
|
+
res.fail = (code, opts, details) => {
|
|
110
|
+
const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
|
|
111
|
+
res.status(status).json({ error: { code, ...(typeof opts === 'object' ? opts : {}), ...(details || {}) } });
|
|
112
|
+
};
|
|
113
|
+
next();
|
|
114
|
+
});
|
|
115
|
+
app.use('/api/bongos', ssoRoutes());
|
|
116
|
+
app.use('/api/bongos', projectsRoutes());
|
|
117
|
+
|
|
118
|
+
const server = app.listen(0);
|
|
119
|
+
await new Promise((r) => server.once('listening', r));
|
|
120
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
121
|
+
after(() => server.close());
|
|
122
|
+
|
|
123
|
+
function call(method, path, body) {
|
|
124
|
+
return new Promise((resolve, reject) => {
|
|
125
|
+
const payload = body === undefined ? null : JSON.stringify(body);
|
|
126
|
+
const req = http.request(`${base}${path}`, {
|
|
127
|
+
method,
|
|
128
|
+
headers: payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : {},
|
|
129
|
+
}, (res) => {
|
|
130
|
+
let d = '';
|
|
131
|
+
res.on('data', (c) => { d += c; });
|
|
132
|
+
res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* html */ } resolve({ status: res.statusCode, body: j, raw: d }); });
|
|
133
|
+
});
|
|
134
|
+
req.on('error', reject);
|
|
135
|
+
if (payload) req.write(payload);
|
|
136
|
+
req.end();
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
test('/sso/authorize REFUSES a catalog-only client — no redirect_uri is registered for it', async () => {
|
|
141
|
+
// The open-redirect guard doing double duty: an empty allow-list means every candidate
|
|
142
|
+
// callback fails exact match, so the browser leg never mints a code and never redirects.
|
|
143
|
+
const uri = 'https://spike-harbor.cloudbongos.test/api/gds/auth/web/callback';
|
|
144
|
+
const res = await call('GET', `/sso/authorize?client_id=spike-harbor&redirect_uri=${encodeURIComponent(uri)}`);
|
|
145
|
+
assert.equal(res.status, 400);
|
|
146
|
+
assert.match(res.raw, /redirect_uri is not registered/);
|
|
147
|
+
assert.doesNotMatch(res.raw, /code=/, 'no authorization code may appear on any refusal path');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('/sso/token REFUSES a catalog-only client for every secret it could be handed', async () => {
|
|
151
|
+
for (const client_secret of ['', 'guess', 'spike-harbor', 'a'.repeat(64)]) {
|
|
152
|
+
const res = await call('POST', '/sso/token', {
|
|
153
|
+
client_id: 'spike-harbor', client_secret: client_secret || 'x',
|
|
154
|
+
code: 'c', redirect_uri: 'https://spike-harbor.cloudbongos.test/api/gds/auth/web/callback',
|
|
155
|
+
});
|
|
156
|
+
assert.equal(res.status, 401, `secret "${client_secret}" must not exchange`);
|
|
157
|
+
assert.equal(res.body.error.code, 'invalid_client');
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('/sso/device/start REFUSES a catalog-only origin — the leg that checked neither guard', async () => {
|
|
162
|
+
// This is the ONE that needed new code. It resolves the client from the USER-TYPED origin
|
|
163
|
+
// and checked only `status`, so without the guard every catalogued project would have had a
|
|
164
|
+
// device flow nobody registered a credential for.
|
|
165
|
+
deviceStarts = 0;
|
|
166
|
+
const res = await call('POST', '/sso/device/start', { origin: 'https://spike-harbor.cloudbongos.test' });
|
|
167
|
+
assert.equal(res.status, 401);
|
|
168
|
+
assert.equal(res.body.error.code, 'invalid_client');
|
|
169
|
+
assert.equal(deviceStarts, 0, 'and the GitHub device flow is never even started');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('/sso/device/start still serves a real federated client', async () => {
|
|
173
|
+
// The guard must refuse the new row shape and nothing else.
|
|
174
|
+
deviceStarts = 0;
|
|
175
|
+
const res = await call('POST', '/sso/device/start', { origin: 'https://mercury.test' });
|
|
176
|
+
assert.equal(res.status, 200);
|
|
177
|
+
assert.equal(res.body.user_code, 'U');
|
|
178
|
+
assert.equal(deviceStarts, 1);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('/sso/membership/check-in REFUSES a catalog-only client', async () => {
|
|
182
|
+
const res = await call('POST', '/sso/membership/check-in', {
|
|
183
|
+
client_id: 'spike-harbor', client_secret: 'x', github_id: 5150, github_login: 'someone',
|
|
184
|
+
});
|
|
185
|
+
assert.equal(res.status, 401);
|
|
186
|
+
assert.equal(res.body.error.code, 'invalid_client');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('/sso/activity/rollup REFUSES a catalog-only client', async () => {
|
|
190
|
+
const res = await call('POST', '/sso/activity/rollup', {
|
|
191
|
+
client_id: 'spike-harbor', client_secret: 'x', github_id: 5150, credits: 999, rank: 'archon',
|
|
192
|
+
});
|
|
193
|
+
assert.equal(res.status, 401);
|
|
194
|
+
assert.equal(res.body.error.code, 'invalid_client');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ── what the catalog row DOES buy: a resolvable invite target (task 1003580) ──
|
|
198
|
+
|
|
199
|
+
test('POST /projects/invite no longer 404s for a wizard-made project', async () => {
|
|
200
|
+
// GAP 1 of task 1003580: the route resolves its target with getClient() and refused
|
|
201
|
+
// `unknown_project` because a standalone project was never an sso_clients row at all. It
|
|
202
|
+
// now is. The route's `project.curate` gate is deliberately untouched — widening it to the
|
|
203
|
+
// project's own owner is 1003580's job, and this test stubs the permission middleware so
|
|
204
|
+
// the assertion is about the TARGET resolving, not about who may call it.
|
|
205
|
+
const res = await call('POST', '/projects/invite', { client_id: 'spike-harbor', github_login: 'someone' });
|
|
206
|
+
assert.notEqual(res.status, 404);
|
|
207
|
+
assert.equal(res.status, 201);
|
|
208
|
+
assert.equal(res.body.invited, true);
|
|
209
|
+
assert.equal(res.body.clientId, 'spike-harbor');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('an unknown project still 404s — the catalog row is what resolves, not the route', async () => {
|
|
213
|
+
const res = await call('POST', '/projects/invite', { client_id: 'never-provisioned', github_login: 'someone' });
|
|
214
|
+
assert.equal(res.status, 404);
|
|
215
|
+
assert.equal(res.body.error.code, 'unknown_project');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// ── the seam the write half travels on ───────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
test('mounting the projects routes registers the `project.catalog` port', async () => {
|
|
221
|
+
// The write half of the same seam `project.publishGate` is the read half of. provisioning
|
|
222
|
+
// owns these projects and may not import this module (ADR 0083), so the switchboard is how
|
|
223
|
+
// the projection reaches the tables that hold the map.
|
|
224
|
+
assert.equal(api.hasProvider('project.catalog'), true);
|
|
225
|
+
assert.equal(api.resolve('project.catalog'), catalog.projectCatalogPort);
|
|
226
|
+
assert.equal(typeof api.resolve('project.catalog').publish, 'function');
|
|
227
|
+
assert.ok(Object.isFrozen(catalog.projectCatalogPort), 'a consumer must not be able to re-point the port');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('the port declares the catalog write and nothing else', () => {
|
|
231
|
+
// One verb. A consumer maps its own row onto the entry shape, so the catalog never learns
|
|
232
|
+
// another module’s schema and no module gets a general-purpose handle on these tables.
|
|
233
|
+
assert.deepEqual(Object.keys(catalog.projectCatalogPort), ['publish']);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('the module manifest DECLARES the port it registers', async () => {
|
|
237
|
+
// verifyPortsSatisfied reads the manifests at boot, not the registry — an undeclared
|
|
238
|
+
// provider is invisible to it.
|
|
239
|
+
const manifest = JSON.parse(await (await import('node:fs/promises')).readFile(
|
|
240
|
+
new URL('../modules/platform-identity/module.json', import.meta.url), 'utf8'));
|
|
241
|
+
assert.ok(manifest.provides.includes('project.catalog'));
|
|
242
|
+
});
|
package/tests/currency_label.mjs
CHANGED
|
@@ -48,11 +48,14 @@ test('R57: clientBranding exposes theme.ui (web), NOT theme.palette (server-only
|
|
|
48
48
|
});
|
|
49
49
|
|
|
50
50
|
test('clientBranding exposes project policy — the settings read-back (task 1003140), defaulting to today\'s behavior', () => {
|
|
51
|
-
// `visibility` — the join door — joined the block in task 1002324 (ADR 0182 D1)
|
|
52
|
-
|
|
51
|
+
// `visibility` — the join door — joined the block in task 1002324 (ADR 0182 D1);
|
|
52
|
+
// `joinGrant` — what a PUBLIC join grants — in task 1002331 (ADR 0247), defaulting
|
|
53
|
+
// to `full`, the value that narrows nothing, so an un-pushed instance is unchanged
|
|
54
|
+
assert.deepEqual(clientBranding({}).project,
|
|
55
|
+
{ platformVisibility: 'public', joinability: 'apply', visibility: 'public', joinGrant: 'full' });
|
|
53
56
|
assert.deepEqual(
|
|
54
|
-
clientBranding({ project: { platformVisibility: 'gated', joinability: 'invite_only', visibility: 'stealth' } }).project,
|
|
55
|
-
{ platformVisibility: 'gated', joinability: 'invite_only', visibility: 'stealth' },
|
|
57
|
+
clientBranding({ project: { platformVisibility: 'gated', joinability: 'invite_only', visibility: 'stealth', joinGrant: 'view' } }).project,
|
|
58
|
+
{ platformVisibility: 'gated', joinability: 'invite_only', visibility: 'stealth', joinGrant: 'view' },
|
|
56
59
|
'what the instance actually resolved (env override included) is what it publishes');
|
|
57
60
|
});
|
|
58
61
|
|
|
@@ -71,7 +74,7 @@ test('clientBranding is a SMALL allow-list — no server-only field leaks to the
|
|
|
71
74
|
// `project` joined the allow-list in task 1003140: the per-project policy is public
|
|
72
75
|
// by nature and is the settings read-back a manage page needs (see clientBranding).
|
|
73
76
|
assert.deepEqual(Object.keys(projected).sort(), ['copy', 'currency', 'identity', 'project', 'rankLabels', 'reward', 'theme']);
|
|
74
|
-
assert.deepEqual(Object.keys(projected.project).sort(), ['joinability', 'platformVisibility', 'visibility']);
|
|
77
|
+
assert.deepEqual(Object.keys(projected.project).sort(), ['joinGrant', 'joinability', 'platformVisibility', 'visibility']);
|
|
75
78
|
for (const leak of ['domains', 'repo', 'db', 'envPrefix', 'llmProjectName']) {
|
|
76
79
|
assert.equal(projected[leak], undefined, `${leak} must not reach the client`);
|
|
77
80
|
}
|
package/tests/module_api.mjs
CHANGED
|
@@ -36,6 +36,7 @@ const PUBLISHED_SURFACE = [
|
|
|
36
36
|
'resolveStaleClaimHours', 'staleClaimMinutes', // added by task 1003476 (ADR 0226; goal 1000072): the PROJECT's stale-claim timer — the sweep that releases a silent claim and the Archon roster that reports one read the same number through here, after eighteen hours of disagreeing (6 vs 24)
|
|
37
37
|
'resolveRotDays', 'projectSettings', // added by task 1003279 (ADR 0232; goal 1000072): the ROT timer + the live knob store behind it. A DIFFERENT question from the stale-claim timer above — days not hours, DB not env, work nobody picked up rather than a builder who went quiet, and it only ever ASKS a human where that one ACTS. Both ride the doorway so no module hardcodes either.
|
|
38
38
|
'joinabilityMode', // added by task 1003044 (ADR 0194; the carrier after 1.19.374): the project's membership door, one reader for the sign-in gate AND the onboarding module's public POST
|
|
39
|
+
'projectJoinDoor', // added by task 1002331 (ADR 0182 D6 / ADR 0247): the COMPOSED join/apply door — visibility × join grant × joinability, one answer, so a module reading a single knob cannot re-open a door the other two closed
|
|
39
40
|
'enabledDisciplines', // BV1.R81: instance offered-disciplines (1.9.0; onboarding restock backstop)
|
|
40
41
|
'registerProvider', 'hasProvider', 'resolve', 'resolveOptional', 'listPorts', 'verifyPortsSatisfied',
|
|
41
42
|
'on', 'emit', 'emitAsync', 'listEvents', 'seamSnapshot',
|