@bongos/core 1.19.424 → 1.19.426
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 +59 -34
- package/docs/adr/0210-scouting-graduates-on-an-instance-asserted-owner-claim.md +100 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +1 -1
- package/docs/copy-inventory.md +184 -184
- package/docs/copy-registry.json +235 -235
- package/docs/design/mocks/hub/wizard.html +421 -0
- package/docs/design/projects-hub-direction-v2.md +26 -0
- package/docs/file-map.md +2 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/people.js +5 -5
- package/modules/hall-ui/public/settings.html +2 -2
- package/modules/hall-ui/public/settings.js +12 -12
- package/modules/platform-identity/account-visibility.js +47 -1
- package/modules/platform-identity/platform-identity.js +14 -11
- package/modules/platform-identity/routes/scouting.js +61 -21
- package/modules/platform-identity/routes/sso.js +6 -0
- package/modules/platform-identity/scouting-authz.js +139 -0
- package/modules/ui-design/kit/serve.js +3 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +63 -0
- package/public-landing/projects.html +207 -199
- package/public-landing/projects.probes.json +7 -1
- package/public-landing/projects.states.json +7 -1
- package/src/bongos/auth.js +59 -3
- package/src/module-api.js +1 -1
- package/tests/account_privacy_flags.mjs +23 -13
- package/tests/effective_visibility_predicate.mjs +49 -4
- package/tests/projects_hub.mjs +56 -0
- package/tests/scouting_owner_access_and_reach.mjs +544 -0
- package/tests/scouting_page_gate.mjs +185 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// tests/scouting_page_gate.mjs
|
|
2
|
+
//
|
|
3
|
+
// The OTHER half of R14 (task 1002290, ADR 0210 D3): the `/builders/people`
|
|
4
|
+
// SHELL must admit exactly the two audiences `GET /scouting` admits.
|
|
5
|
+
//
|
|
6
|
+
// WHY THIS IS ITS OWN FILE, AND ITS OWN GATE. `runPageGate` grew an optional
|
|
7
|
+
// `alsoAdmits` door — new authorization code that runs on EVERY page-gate
|
|
8
|
+
// denial, for every gate. The route proof
|
|
9
|
+
// (tests/scouting_owner_access_and_reach.mjs) cannot reach it: page gates are
|
|
10
|
+
// driven by a session, and that file's ESM imports pull `auth.js` in through the
|
|
11
|
+
// module graph before any session double could be installed. This file installs
|
|
12
|
+
// the double FIRST, exactly as tests/government_page_gates.mjs does, and so gets
|
|
13
|
+
// the real gate over a real permission resolve.
|
|
14
|
+
//
|
|
15
|
+
// WHAT IT PINS, in order of what would hurt most if it broke:
|
|
16
|
+
// 1. a project owner holding NO permission reaches the shell — without this,
|
|
17
|
+
// the widening is real in the API and invisible in the product, which is
|
|
18
|
+
// the failure D3 exists to prevent;
|
|
19
|
+
// 2. everyone the ROUTE refuses, the shell refuses too, with the byte-identical
|
|
20
|
+
// redirect it always sent (naming the permission, so the hall can say why);
|
|
21
|
+
// 3. every way the second door can fail — module absent, function absent, a
|
|
22
|
+
// throw, a non-boolean, and a name that resolves only through the prototype
|
|
23
|
+
// — fails CLOSED. A fail-open default in an authz path is the bug class;
|
|
24
|
+
// 4. the other four gates are untouched, and their seam is never even
|
|
25
|
+
// consulted, because they declare no second door.
|
|
26
|
+
//
|
|
27
|
+
// DB-free: a session double and a port double. No Postgres, no HTTP.
|
|
28
|
+
//
|
|
29
|
+
// Run: node tests/scouting_page_gate.mjs
|
|
30
|
+
|
|
31
|
+
import { strict as assert } from 'node:assert';
|
|
32
|
+
import { createRequire } from 'node:module';
|
|
33
|
+
|
|
34
|
+
const require = createRequire(import.meta.url);
|
|
35
|
+
|
|
36
|
+
// --- the session double, installed BEFORE auth.js loads ---------------------
|
|
37
|
+
// auth.js destructures lookupSession at require time, so an override that lands
|
|
38
|
+
// after it would never be seen.
|
|
39
|
+
const dbKernel = require('../src/bongos/db-kernel.js');
|
|
40
|
+
let nextSession = null;
|
|
41
|
+
dbKernel.lookupSession = async () => nextSession;
|
|
42
|
+
|
|
43
|
+
const auth = require('../src/bongos/auth.js');
|
|
44
|
+
const seams = require('../src/module-seams.js');
|
|
45
|
+
const catalog = require('../modules/government/catalog.js');
|
|
46
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
47
|
+
|
|
48
|
+
let passed = 0;
|
|
49
|
+
let failed = 0;
|
|
50
|
+
async function t(name, fn) {
|
|
51
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
52
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The cast. `rank` is the caller's rank ON THE HUB; `github_id` is the account
|
|
56
|
+
// whose project memberships the ownership door asks about. The whole point of
|
|
57
|
+
// R14 is the first row: a project's registrant is an Archon on their OWN
|
|
58
|
+
// instance and, here, very often a xenos holding nothing at all.
|
|
59
|
+
const OWNER = { builder_id: 1, github_id: '9001', rank: 'xenos' };
|
|
60
|
+
const STRANGER = { builder_id: 2, github_id: '9002', rank: 'xenos' };
|
|
61
|
+
const OPERATOR = { builder_id: 5, github_id: '9005', rank: 'metic' };
|
|
62
|
+
|
|
63
|
+
// The ownership answer, as the platform-identity.hub port supplies it.
|
|
64
|
+
const OWNS = new Set(['9001']);
|
|
65
|
+
const workingHub = { isProjectOwner: async (githubId) => OWNS.has(String(githubId)) };
|
|
66
|
+
|
|
67
|
+
// A response double that records the one thing a page gate does.
|
|
68
|
+
function fakeRes() {
|
|
69
|
+
const res = {
|
|
70
|
+
_redirect: null,
|
|
71
|
+
status() { return this; },
|
|
72
|
+
json() { return this; },
|
|
73
|
+
redirect(a, b) { this._redirect = b === undefined ? { code: 302, loc: a } : { code: a, loc: b }; return this; },
|
|
74
|
+
end() { return this; },
|
|
75
|
+
};
|
|
76
|
+
// Installed so a regression that answers JSON instead of redirecting fails an
|
|
77
|
+
// assertion rather than blowing up on an undefined method.
|
|
78
|
+
attachFail({}, res, () => {});
|
|
79
|
+
return res;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Run one page gate as `session`. `hub` is what the platform-identity.hub port
|
|
83
|
+
// offers — an object, or null for "the module is not installed at all".
|
|
84
|
+
async function runGate(gate, session, hub) {
|
|
85
|
+
nextSession = session;
|
|
86
|
+
seams._reset();
|
|
87
|
+
seams.registerProvider('government', {
|
|
88
|
+
resolveBuilderPermissions: async () => catalog.seedForRank(session ? session.rank : 'xenos'),
|
|
89
|
+
});
|
|
90
|
+
if (hub) seams.registerProvider('platform-identity.hub', hub);
|
|
91
|
+
const res = fakeRes();
|
|
92
|
+
let nexted = false;
|
|
93
|
+
await auth[gate]({ headers: { cookie: 'gds_session=t' } }, res, () => { nexted = true; });
|
|
94
|
+
return { res, nexted };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const DENIED_CURATE = { code: 302, loc: '/builders?denied=project.curate' };
|
|
98
|
+
|
|
99
|
+
console.log('the /people shell follows its data route:');
|
|
100
|
+
|
|
101
|
+
await t('a PROJECT OWNER reaches the shell, holding no permission at all', async () => {
|
|
102
|
+
const { nexted } = await runGate('requireProjectCuratePage', OWNER, workingHub);
|
|
103
|
+
assert.ok(nexted,
|
|
104
|
+
'the page gate must admit the audience its data route admits — otherwise the widening is real in the API and unreachable in the product');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await t('CONTROL: the permission door still opens the shell on its own', async () => {
|
|
108
|
+
const { nexted } = await runGate('requireProjectCuratePage', OPERATOR, workingHub);
|
|
109
|
+
assert.ok(nexted, 'ADR 0157 put project.curate at the metic floor; R14 did not move it');
|
|
110
|
+
// ...and it does not need the second door at all.
|
|
111
|
+
const { nexted: noHub } = await runGate('requireProjectCuratePage', OPERATOR, null);
|
|
112
|
+
assert.ok(noHub, 'an operator gets in on a vanilla instance with no hub module');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await t('a builder who is neither gets the byte-identical redirect the gate always sent', async () => {
|
|
116
|
+
const { res, nexted } = await runGate('requireProjectCuratePage', STRANGER, workingHub);
|
|
117
|
+
assert.equal(nexted, false, 'building on a project is not owning one');
|
|
118
|
+
assert.deepEqual(res._redirect, DENIED_CURATE,
|
|
119
|
+
'the denial keeps naming the permission (task 1002613), so the hall shell can still render WHY');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await t('an unauthenticated visitor is still bounced into sign-in, never to the second door', async () => {
|
|
123
|
+
const { res, nexted } = await runGate('requireProjectCuratePage', null, workingHub);
|
|
124
|
+
assert.equal(nexted, false);
|
|
125
|
+
assert.match(res._redirect.loc, /auth\/web\/start/, 'no session means sign-in, not a denial page');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await t('every way the second door can fail, it fails CLOSED', async () => {
|
|
129
|
+
// OWNER would be admitted by a working port, so each case isolates one failure
|
|
130
|
+
// of the DOOR rather than a caller who was never eligible.
|
|
131
|
+
const cases = [
|
|
132
|
+
['the hub module is not installed (every non-hub instance)', null],
|
|
133
|
+
['the port exists but never grew the function', { getPublicProfileExtras: () => null }],
|
|
134
|
+
['the function throws', { isProjectOwner: () => { throw new Error('simulated outage'); } }],
|
|
135
|
+
['the function rejects', { isProjectOwner: async () => { throw new Error('simulated async outage'); } }],
|
|
136
|
+
['the function answers a non-boolean falsy', { isProjectOwner: async () => undefined }],
|
|
137
|
+
// An inherited member is a function whose result is truthy, so a plain
|
|
138
|
+
// `hub[name]` lookup would admit EVERYONE. Own-property only.
|
|
139
|
+
['the function resolves only through the prototype', Object.create({ isProjectOwner: () => true })],
|
|
140
|
+
];
|
|
141
|
+
for (const [why, hub] of cases) {
|
|
142
|
+
const { res, nexted } = await runGate('requireProjectCuratePage', OWNER, hub);
|
|
143
|
+
assert.equal(nexted, false, `${why}: must not admit`);
|
|
144
|
+
assert.deepEqual(res._redirect, DENIED_CURATE,
|
|
145
|
+
`${why}: must still redirect rather than hang, throw, or answer JSON`);
|
|
146
|
+
}
|
|
147
|
+
const { nexted } = await runGate('requireProjectCuratePage', OWNER, workingHub);
|
|
148
|
+
assert.ok(nexted, 'CONTROL: the same caller IS admitted when the door works — the six refusals are the door, not the caller');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await t('the second door is consulted only where the AUTHORITY answer was no', async () => {
|
|
152
|
+
// A permission holder must not pay for it, and — more important — the seam
|
|
153
|
+
// must never be able to NARROW a caller the permission already admitted.
|
|
154
|
+
const spy = { isProjectOwner: () => { throw new Error('the seam must not be consulted for a caller the permission already admitted'); } };
|
|
155
|
+
const { nexted } = await runGate('requireProjectCuratePage', OPERATOR, spy);
|
|
156
|
+
assert.ok(nexted, 'the atom holder is admitted before the second door is reached');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
await t('the OTHER page gates are untouched — their seam is never consulted at all', async () => {
|
|
160
|
+
const spy = { isProjectOwner: () => { throw new Error('a gate declaring no second door must not consult the seam'); } };
|
|
161
|
+
// requireGovernmentPage floors at metic (ADR 0157) and declares no alsoAdmits,
|
|
162
|
+
// so a project owner with no rank stays out even with the hub port registered.
|
|
163
|
+
const { res, nexted } = await runGate('requireGovernmentPage', OWNER, spy);
|
|
164
|
+
assert.equal(nexted, false, 'a xenos project owner is not a government-page audience');
|
|
165
|
+
assert.deepEqual(res._redirect, { code: 302, loc: '/builders?denied=page.view.government' },
|
|
166
|
+
'and its denial is unchanged, down to the permission it names');
|
|
167
|
+
const { nexted: pub } = await runGate('requireBuilderPage', OWNER, spy);
|
|
168
|
+
assert.ok(pub, 'the public hall page still admits every signed-in builder, exactly as before');
|
|
169
|
+
const { res: nx } = await runGate('requireNonXenosPage', OWNER, spy);
|
|
170
|
+
assert.deepEqual(nx._redirect, { code: 302, loc: '/builders/not-ready?denied=page.view.builder' },
|
|
171
|
+
'and the orientation rooms still send a xenos to the friendly notice');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
await t('only ONE gate declares a second door (a widening must be opted into, one gate at a time)', () => {
|
|
175
|
+
const src = require('node:fs').readFileSync(new URL('../src/bongos/auth.js', import.meta.url), 'utf8');
|
|
176
|
+
const declarations = [...src.matchAll(/alsoAdmits:\s*'([^']+)'/g)].map((m) => m[1]);
|
|
177
|
+
assert.deepEqual(declarations, ['isProjectOwner'],
|
|
178
|
+
'a second alsoAdmits appearing without its own proof is how a page gate quietly widens');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
seams._reset();
|
|
182
|
+
nextSession = null;
|
|
183
|
+
|
|
184
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
185
|
+
process.exit(failed === 0 ? 0 : 1);
|