@bongos/core 1.19.425 → 1.19.427
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 +66 -31
- package/docs/adr/0210-scouting-graduates-on-an-instance-asserted-owner-claim.md +100 -0
- package/docs/adr/0212-connections-on-the-profile-surface-and-addressing.md +61 -0
- package/docs/adr/README.md +2 -0
- package/docs/api/openapi.json +1 -1
- package/docs/copy-inventory.md +14 -6
- package/docs/copy-registry.json +91 -9
- package/docs/file-map.md +3 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/people.js +5 -5
- package/modules/hall-ui/public/profile-connections.js +304 -0
- package/modules/hall-ui/public/profile.html +16 -1
- package/modules/hall-ui/public/profile.js +5 -0
- package/modules/hall-ui/public/settings.html +2 -2
- package/modules/hall-ui/public/settings.js +12 -12
- package/modules/hall-ui/public/style.css +38 -0
- 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/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/hall-preview/server.js +3 -0
- 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/profile_connections_ui.mjs +874 -0
- package/tests/scouting_owner_access_and_reach.mjs +544 -0
- package/tests/scouting_page_gate.mjs +185 -0
|
@@ -1,10 +1,20 @@
|
|
|
1
|
-
// modules/platform-identity/routes/scouting.js — the
|
|
2
|
-
//
|
|
3
|
-
// builders across ALL hosted projects from
|
|
4
|
-
// A talent tool
|
|
5
|
-
// descriptive; authority stays local, ADR 0141 / ADR 0016).
|
|
6
|
-
//
|
|
7
|
-
//
|
|
1
|
+
// modules/platform-identity/routes/scouting.js — the cross-project RECRUITING
|
|
2
|
+
// DIRECTORY (ADR 0141 §4, task 1002114; graduated to project owners by ADR 0210,
|
|
3
|
+
// task 1002290). Ranks builders across ALL hosted projects from
|
|
4
|
+
// platform_identity_builder_project_activity. A talent tool — NOT authority (the
|
|
5
|
+
// rollup is descriptive; authority stays local, ADR 0141 / ADR 0016).
|
|
6
|
+
//
|
|
7
|
+
// WHO READS IT: a hub operator holding `project.curate` (floor metic since ADR
|
|
8
|
+
// 0157) OR a project owner — someone whose own project reported them as its
|
|
9
|
+
// owner at check-in and whose sign-in the hub itself witnessed. That OR lives in
|
|
10
|
+
// ../scouting-authz.js, which carries the full argument for why reading an
|
|
11
|
+
// instance-asserted ownership claim is not the ADR 0201 D3 inversion.
|
|
12
|
+
//
|
|
13
|
+
// WHO APPEARS IN IT: consent is enforced IN listScoutingRollup itself, by the
|
|
14
|
+
// shared scoutingListableSql predicate — active, publicly visible, not hidden,
|
|
15
|
+
// and not opted out of recruiting (ADR 0171 D2/D3 + privacy spec D1). So this
|
|
16
|
+
// surface honors both the hide flag and the recruiting opt-out by construction,
|
|
17
|
+
// and widening WHO may read it could not widen WHAT is shown.
|
|
8
18
|
//
|
|
9
19
|
// Reaches core only through the doorway (ADR 0083).
|
|
10
20
|
'use strict';
|
|
@@ -12,25 +22,55 @@
|
|
|
12
22
|
const express = require('express');
|
|
13
23
|
const api = require('../../../src/module-api');
|
|
14
24
|
const pi = require('../platform-identity');
|
|
25
|
+
const scoutingAuthz = require('../scouting-authz');
|
|
15
26
|
|
|
16
|
-
const { pool, requireBuilder,
|
|
27
|
+
const { pool, requireBuilder, parsePagination } = api;
|
|
28
|
+
|
|
29
|
+
// Resolved at CALL time, never at load. Proofs that exercise this route pin a
|
|
30
|
+
// PARTIAL doorway (tests/profile_route.mjs stubs pool + the two gates and
|
|
31
|
+
// nothing else), and a module-scope api.logger() would make this file
|
|
32
|
+
// unrequireable there — a logging line is not worth breaking a privacy proof
|
|
33
|
+
// over. my-projects.js reaches the doorway logger the same lazy way.
|
|
34
|
+
let resolvedLog = null;
|
|
35
|
+
const log = {
|
|
36
|
+
error: (msg) => {
|
|
37
|
+
if (!resolvedLog) resolvedLog = typeof api.logger === 'function' ? api.logger('platform-identity') : { error: () => {} };
|
|
38
|
+
resolvedLog.error(msg);
|
|
39
|
+
},
|
|
40
|
+
};
|
|
17
41
|
|
|
18
42
|
module.exports = function scoutingRoutes() {
|
|
19
43
|
const router = express.Router();
|
|
20
44
|
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
45
|
+
// The atom is spelled in the middleware list below, not hidden inside
|
|
46
|
+
// requireScoutingReader, so route-rank-check.js and gen-api-docs.js classify
|
|
47
|
+
// this route from its real gate. A helper-hidden gate would record a
|
|
48
|
+
// privileged route as any-builder — wrong in the dangerous direction.
|
|
49
|
+
|
|
50
|
+
// rank: metic+archon — the internal talent directory across every project.
|
|
51
|
+
// Read by a `project.curate` holder OR a project owner (ADR 0210, task 1002290);
|
|
52
|
+
// a caller who is neither gets the unchanged permission refusal before any
|
|
53
|
+
// cross-builder data is read.
|
|
54
|
+
router.get(
|
|
55
|
+
'/scouting',
|
|
56
|
+
requireBuilder,
|
|
57
|
+
// Ownership is asked FIRST — one indexed LIMIT-1 lookup, paid by the operator
|
|
58
|
+
// path too. The alternative is asking the permission gate first, and it is not
|
|
59
|
+
// available: a middleware that has denied has already SENT its response, so
|
|
60
|
+
// there is nothing to fall through from. Paying a round-trip is what keeps the
|
|
61
|
+
// 403 byte-identical to the one this route always produced (ADR 0210 D2).
|
|
62
|
+
scoutingAuthz.requireScoutingReader({ curateGate: api.requirePermission('project.curate'), pool, log }),
|
|
63
|
+
async (req, res) => {
|
|
64
|
+
const { limit } = parsePagination(req, { defaultLimit: 100, maxLimit: 500 });
|
|
65
|
+
try {
|
|
66
|
+
const builders = await pi.listScoutingRollup({ limit, pool });
|
|
67
|
+
res.json({ builders });
|
|
68
|
+
} catch (err) {
|
|
69
|
+
log.error(`GET /scouting failed: ${err && err.message}`);
|
|
70
|
+
res.fail('scouting_failed', { status: 500, message: 'internal error' });
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
);
|
|
34
74
|
|
|
35
75
|
return router;
|
|
36
76
|
};
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
const express = require('express');
|
|
18
18
|
const api = require('../../../src/module-api');
|
|
19
19
|
const pi = require('../platform-identity');
|
|
20
|
+
const scoutingAuthz = require('../scouting-authz');
|
|
20
21
|
const hubKeys = require('../hub-keys');
|
|
21
22
|
const hubDevice = require('../hub-device');
|
|
22
23
|
// task 1003208: structured logging (pino via the doorway) — was console.*.
|
|
@@ -114,6 +115,11 @@ function registerSeams() {
|
|
|
114
115
|
// THROWS on a bad read by design — an empty set means "nobody is hidden",
|
|
115
116
|
// which a database error must never be allowed to say.
|
|
116
117
|
hiddenGithubIds: (githubIds) => pi.hiddenGithubIds(githubIds, { pool }),
|
|
118
|
+
// task 1002290 (ADR 0210): "does this hub account own a project?", the
|
|
119
|
+
// second audience the /people page gate admits beside `project.curate`.
|
|
120
|
+
// The ownership fact lives in this module, so the answer does too; core
|
|
121
|
+
// asks through the port and treats an absent one as a denial.
|
|
122
|
+
isProjectOwner: (githubId) => scoutingAuthz.hasOwnerMembership(githubId, { pool }),
|
|
117
123
|
});
|
|
118
124
|
}
|
|
119
125
|
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// modules/platform-identity/scouting-authz.js — WHO may read the cross-project
|
|
2
|
+
// recruiting directory (GET /scouting; the hall's /people page renders the same
|
|
3
|
+
// data). ADR 0210, task 1002290 — goal 1000045 C5: "project owners (not only
|
|
4
|
+
// Archons) browse builder profiles to recruit".
|
|
5
|
+
//
|
|
6
|
+
// TWO DOORS, DELIBERATELY DIFFERENT KINDS — the modules/lifecycle/goal-authz.js
|
|
7
|
+
// shape (owner OR rank), which the project-admin-console spec names as the
|
|
8
|
+
// design of record:
|
|
9
|
+
//
|
|
10
|
+
// RANK — `project.curate`, floor metic (ADR 0157 lowered it from archon).
|
|
11
|
+
// The hub OPERATOR's door, unchanged: the atom, the 403 body, the
|
|
12
|
+
// floors map. Nothing about it moves here.
|
|
13
|
+
// OWNERSHIP — an instance-asserted, hub-witnessed `membership_kind = 'owner'`
|
|
14
|
+
// project membership. The PROJECT OWNER's door, and the whole of
|
|
15
|
+
// what this file adds.
|
|
16
|
+
//
|
|
17
|
+
// WHY OWNERSHIP IS SAFE TO READ HERE, WHEN ADR 0201 D3 SAYS A HUB-HELD OWNER
|
|
18
|
+
// LIST IS NEVER A GATE. The thing that ADR rejects is `platform_identity_-
|
|
19
|
+
// projects.owner_github_id`: a hub CATALOG column, written by hub curation, with
|
|
20
|
+
// no instance behind it — consulting it before an ADMISSION would be the hub
|
|
21
|
+
// deciding who may admit on someone else's box, the ADR 0141 inversion.
|
|
22
|
+
// `platform_identity_project_memberships.membership_kind` is a different record
|
|
23
|
+
// with a different provenance: the PROJECT ITSELF reports it over the
|
|
24
|
+
// client_id + client_secret channel at `POST /sso/membership/check-in`
|
|
25
|
+
// (src/bongos/auth-admission.js decides the value from its OWN first-admin
|
|
26
|
+
// config), and `touchMembership` refuses to write it unless the hub has already
|
|
27
|
+
// witnessed that person sign in (`signin_verified_at`, ADR 0205 D1/D2). So this
|
|
28
|
+
// gate reads the instance's own claim about its own archon, mirrored — authority
|
|
29
|
+
// stays local, exactly as ADR 0141 requires. And the act being gated is a HUB
|
|
30
|
+
// READ of the hub's own directory, not an admission on anyone else's box.
|
|
31
|
+
//
|
|
32
|
+
// NOT AUTHORITY, IN EITHER DIRECTION (ADR 0016). Owning a project buys exactly
|
|
33
|
+
// one thing: reading a descriptive, consent-filtered list. It confers no rank, no
|
|
34
|
+
// claim right, no admission power, and it is never consulted by anything else.
|
|
35
|
+
// The reverse also holds and is load-bearing for task 1002290's other half: a
|
|
36
|
+
// builder's recruiting opt-out changes who can SEE them and grants or denies
|
|
37
|
+
// them nothing — the two axes never touch.
|
|
38
|
+
//
|
|
39
|
+
// LIVE GAP, NAMED ON PURPOSE (ADR 0210 §Consequences; blocker filed). "Project
|
|
40
|
+
// owner" per the goal spec is "the registrant plus delegates they grant". Today
|
|
41
|
+
// an instance reports `owner` for its FIRST ADMIN only, so the REGISTRANT half
|
|
42
|
+
// works and DELEGATES do not: a second archon on a project holds `member` on the
|
|
43
|
+
// hub and reaches /scouting only via `project.curate`. Nothing here needs to
|
|
44
|
+
// change when that is fixed — this gate reads whatever the instance reports.
|
|
45
|
+
'use strict';
|
|
46
|
+
|
|
47
|
+
const pi = require('./platform-identity');
|
|
48
|
+
|
|
49
|
+
// The coarse membership value that means "this person is an owner of this
|
|
50
|
+
// project", in the CURRENT vocabulary (ADR 0177 — `role`/'builder' is the legacy
|
|
51
|
+
// spelling platform_identity_010's trigger keeps in lockstep; 'owner' is
|
|
52
|
+
// unchanged across the rename, so there is no legacy value to also accept).
|
|
53
|
+
const OWNER_MEMBERSHIP_KIND = 'owner';
|
|
54
|
+
|
|
55
|
+
// The one `platform_identity_sso_clients.status` value that means the project is
|
|
56
|
+
// still live on the platform (the other is 'suspended' — migration
|
|
57
|
+
// platform_identity_001_tables.sql). Suspending a client is the operator's
|
|
58
|
+
// revocation lever; see hasOwnerMembership for why this gate answers to it.
|
|
59
|
+
const ACTIVE_CLIENT_STATUS = 'active';
|
|
60
|
+
|
|
61
|
+
// isProjectOwnerMembership(row) — the PURE predicate: is this one membership row
|
|
62
|
+
// an owner membership the hub itself witnessed a sign-in for? Fail-closed on an
|
|
63
|
+
// absent row, and on a row the hub never witnessed — an un-witnessed row is a
|
|
64
|
+
// client's own unproven claim (ADR 0205 D4, the `membershipBacksRollup` rule).
|
|
65
|
+
function isProjectOwnerMembership(membership) {
|
|
66
|
+
return !!membership
|
|
67
|
+
&& membership.membership_kind === OWNER_MEMBERSHIP_KIND
|
|
68
|
+
&& !!membership.signin_verified_at;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// hasOwnerMembership(githubId) — does this hub account own at least one project
|
|
72
|
+
// that is still ACTIVE on the platform? EXISTENCE only: which project, and how
|
|
73
|
+
// many, is not this gate's question, so the query stops at the first row. A
|
|
74
|
+
// non-numeric principal (core's 'system:bfg' shape) answers false rather than
|
|
75
|
+
// throwing a bigint cast mid-request — the isNumericGithubId guard every read in
|
|
76
|
+
// this module carries.
|
|
77
|
+
//
|
|
78
|
+
// THE CLIENT JOIN IS THE REVOCATION DIMENSION, and without it this door could
|
|
79
|
+
// never be shut. A membership row is written once and never expires: nothing
|
|
80
|
+
// ages it out, and the only ways it stops saying `owner` are the instance
|
|
81
|
+
// reporting a lower kind at a later check-in, or the builder leaving of their
|
|
82
|
+
// own accord. Neither is available to the platform operator dealing with a rogue
|
|
83
|
+
// or compromised project. Suspending its client (`setClientStatus`) IS that
|
|
84
|
+
// lever — it is what already stops the project signing anyone in — so the read
|
|
85
|
+
// it derives must answer to it too. Otherwise suspending a project would leave
|
|
86
|
+
// its owner reading every builder on the platform, indefinitely.
|
|
87
|
+
async function hasOwnerMembership(githubId, { pool }) {
|
|
88
|
+
if (!pi.isNumericGithubId(githubId)) return false;
|
|
89
|
+
const { rows } = await pool.query(
|
|
90
|
+
`SELECT m.membership_kind, m.signin_verified_at
|
|
91
|
+
FROM platform_identity_project_memberships m
|
|
92
|
+
JOIN platform_identity_sso_clients c ON c.client_id = m.client_id
|
|
93
|
+
WHERE m.github_id = $1 AND m.membership_kind = $2
|
|
94
|
+
AND m.signin_verified_at IS NOT NULL
|
|
95
|
+
AND c.status = $3
|
|
96
|
+
LIMIT 1`,
|
|
97
|
+
[githubId, OWNER_MEMBERSHIP_KIND, ACTIVE_CLIENT_STATUS],
|
|
98
|
+
);
|
|
99
|
+
return isProjectOwnerMembership(rows[0]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// requireScoutingReader({ curateGate, pool, log }) — the OR gate as one
|
|
103
|
+
// middleware, mounted after requireBuilder. `curateGate` is the ALREADY-BUILT
|
|
104
|
+
// `requirePermission('project.curate')` middleware: the atom is spelled in the
|
|
105
|
+
// route's own middleware list, where route-rank-check.js and gen-api-docs.js can
|
|
106
|
+
// see it, so widening the door cannot make the route's recorded rank read looser
|
|
107
|
+
// than it is.
|
|
108
|
+
//
|
|
109
|
+
// ORDER IS THE DESIGN. The ownership question is asked FIRST and, when it
|
|
110
|
+
// answers yes, the request proceeds without the permission gate ever running.
|
|
111
|
+
// When it answers no — or throws — the request falls through to the UNMODIFIED
|
|
112
|
+
// permission gate, so a caller who is neither owner nor operator gets exactly the
|
|
113
|
+
// 403 this route has always produced: the same code, the same `required`/`held`,
|
|
114
|
+
// the same floors map naming the metic floor. The widening can therefore only
|
|
115
|
+
// ever ADMIT; it cannot change, soften, or swallow a refusal.
|
|
116
|
+
//
|
|
117
|
+
// FAIL-CLOSED ON THE OWNERSHIP QUESTION ITSELF. A database blip while asking
|
|
118
|
+
// "does this person own a project" must not admit, and must not 500 either: it
|
|
119
|
+
// falls to the permission gate, which denies unless the caller holds the atom.
|
|
120
|
+
// The failure mode of the new door is "the old door, unchanged".
|
|
121
|
+
function requireScoutingReader({ curateGate, pool, log }) {
|
|
122
|
+
return async function scoutingReaderGate(req, res, next) {
|
|
123
|
+
try {
|
|
124
|
+
if (await hasOwnerMembership(req.builder && req.builder.github_id, { pool })) return next();
|
|
125
|
+
} catch (err) {
|
|
126
|
+
// Message only — a query error can carry row data (the requirePermission
|
|
127
|
+
// precedent). The request continues to the rank door, which denies.
|
|
128
|
+
log.error(`scouting owner check failed, falling back to the permission gate: ${err && err.message}`);
|
|
129
|
+
}
|
|
130
|
+
return curateGate(req, res, next);
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = {
|
|
135
|
+
OWNER_MEMBERSHIP_KIND,
|
|
136
|
+
isProjectOwnerMembership,
|
|
137
|
+
hasOwnerMembership,
|
|
138
|
+
requireScoutingReader,
|
|
139
|
+
};
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.427",
|
|
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.427",
|
|
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.427",
|
|
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",
|
|
@@ -116,6 +116,9 @@ const FIXTURE_FALLBACKS = [
|
|
|
116
116
|
// craft and the page window are query string, which fixture matching ignores.
|
|
117
117
|
[/^builders\/\d+\/activity$/, (q) => (q.section === 'goals' ? 'builders__activity-goals' : 'builders__activity-tasks')],
|
|
118
118
|
[/^inbox\/by-builder\/\d+$/, 'inbox__by-builder'],
|
|
119
|
+
// the profile's connections section (task 1002296) — one degree fixture serves
|
|
120
|
+
// any handle, since the handle is the address and the label is the payload.
|
|
121
|
+
[/^connections\/degree\/[^/]+$/, 'connections__degree'],
|
|
119
122
|
];
|
|
120
123
|
|
|
121
124
|
function loadFixture(name) {
|
package/src/bongos/auth.js
CHANGED
|
@@ -390,9 +390,54 @@ const PAGE_GATES = Object.freeze({
|
|
|
390
390
|
requireNonXenosPage: Object.freeze({ permission: 'page.view.builder', deniedTo: '/builders/not-ready' }),
|
|
391
391
|
requireGovernmentPage: Object.freeze({ permission: 'page.view.government', deniedTo: '/builders' }),
|
|
392
392
|
requireGovernmentManagePage: Object.freeze({ permission: 'government.manage', deniedTo: '/builders' }),
|
|
393
|
-
|
|
393
|
+
// `alsoAdmits` — a SECOND, non-rank audience this page's DATA ROUTE admits, so
|
|
394
|
+
// the page can follow it (ADR 0210, task 1002290). /people fronts GET /scouting,
|
|
395
|
+
// which since R14 admits a `project.curate` holder OR a PROJECT OWNER; a page
|
|
396
|
+
// gate that could only ever ask about a permission would leave the widened
|
|
397
|
+
// audience staring at a redirect. The value names a function on the
|
|
398
|
+
// `platform-identity.hub` port — the module that owns the ownership fact — and
|
|
399
|
+
// is consulted ONLY after the permission check has already refused, so it can
|
|
400
|
+
// widen and never narrow. Absent port, absent function, or a throw: no admit.
|
|
401
|
+
requireProjectCuratePage: Object.freeze({
|
|
402
|
+
permission: 'project.curate', deniedTo: '/builders', alsoAdmits: 'isProjectOwner',
|
|
403
|
+
}),
|
|
394
404
|
});
|
|
395
405
|
|
|
406
|
+
// The `alsoAdmits` door — "is this visitor the page's OTHER audience?", asked of
|
|
407
|
+
// the module that owns the answer, only for the one gate that declares one.
|
|
408
|
+
//
|
|
409
|
+
// FAIL-CLOSED, EVERY WAY IT CAN FAIL: a gate with no `alsoAdmits`, a hub module
|
|
410
|
+
// that is off (every non-hub instance), a port that never grew the function, or a
|
|
411
|
+
// resolver that throws all answer FALSE — the visitor is redirected exactly as
|
|
412
|
+
// before. The only outcome that admits is an explicit truthy answer.
|
|
413
|
+
//
|
|
414
|
+
// Not authority (ADR 0016): the answer gates a PAGE SHELL and nothing else, and
|
|
415
|
+
// every data endpoint behind that shell still carries its own server-side gate.
|
|
416
|
+
async function pageGateAlsoAdmits(name, session) {
|
|
417
|
+
const { alsoAdmits } = PAGE_GATES[name];
|
|
418
|
+
if (!alsoAdmits) return false;
|
|
419
|
+
// The WHOLE lookup is inside the try, not just the call: resolving a seam can
|
|
420
|
+
// throw too, and a throw there would 500 the page instead of taking the clean
|
|
421
|
+
// deny-redirect every other failure here takes. One catch, one outcome.
|
|
422
|
+
try {
|
|
423
|
+
const hub = seams.resolveOptional('platform-identity.hub');
|
|
424
|
+
// OWN property only. Every object inherits `constructor`, `valueOf` and
|
|
425
|
+
// `toString`, each of which is a function whose result is truthy — so a plain
|
|
426
|
+
// `hub[name]` lookup would let a mis-typed or mis-declared `alsoAdmits` admit
|
|
427
|
+
// EVERYONE. Unreachable today (the only value is a frozen literal), but a
|
|
428
|
+
// fail-OPEN default has no business sitting in an authorization path.
|
|
429
|
+
if (!hub || !Object.prototype.hasOwnProperty.call(hub, alsoAdmits)) return false;
|
|
430
|
+
const ask = hub[alsoAdmits];
|
|
431
|
+
if (typeof ask !== 'function') return false;
|
|
432
|
+
return !!(await ask(session.github_id));
|
|
433
|
+
} catch (err) {
|
|
434
|
+
// Message only — the answer came from a query that can carry row data.
|
|
435
|
+
require('./logger').logger.child({ src: 'auth' })
|
|
436
|
+
.error({ err: err && err.message }, `${name} ${alsoAdmits} check failed`);
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
396
441
|
// A builder's effective permission set, or NULL when authority cannot be
|
|
397
442
|
// ESTABLISHED (the `governance` port is absent or malformed, or the resolver
|
|
398
443
|
// threw). Every caller must read null as a DENIAL: admitting when the authority
|
|
@@ -446,6 +491,15 @@ async function runPageGate(name, req, res, next) {
|
|
|
446
491
|
}
|
|
447
492
|
const held = await resolveHeldPermissions(session.builder_id, session.raw_permission_keys);
|
|
448
493
|
if (held === null || !held.has(permission)) {
|
|
494
|
+
// The rank door said no. A page whose data route admits a SECOND audience
|
|
495
|
+
// asks that door before redirecting (ADR 0210) — consulted here and nowhere
|
|
496
|
+
// else, so it can only widen. Asked on the authority-outage branch too,
|
|
497
|
+
// because the ownership fact does not come from the authority system: not
|
|
498
|
+
// asking would make the page narrower than the route it fronts.
|
|
499
|
+
if (await pageGateAlsoAdmits(name, session)) {
|
|
500
|
+
next();
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
449
503
|
// N-1 (task 1002613): a bare deniedTo rendered nothing — every denial read as
|
|
450
504
|
// "I signed in and got dumped at the home page". Name the permission (and an
|
|
451
505
|
// authority outage, distinctly) in the query so the hall shell can say why.
|
|
@@ -488,8 +542,10 @@ async function requireGovernmentManagePage(req, res, next) {
|
|
|
488
542
|
// The /people builder-directory page (task 1002577) — gated on the
|
|
489
543
|
// `project.curate` ATOM, the same key its one data route (GET /scouting,
|
|
490
544
|
// modules/platform-identity) checks, so the page layer can never drift wider
|
|
491
|
-
// than the API it fronts.
|
|
492
|
-
//
|
|
545
|
+
// than the API it fronts. R14 (goal 1000045, ADR 0210) widened that route to a
|
|
546
|
+
// SECOND audience that is not a rank at all — a project owner — so this gate
|
|
547
|
+
// carries the same OR through `alsoAdmits` above: the two doors of the page are
|
|
548
|
+
// exactly the two doors of the route, and neither layer can drift from it.
|
|
493
549
|
async function requireProjectCuratePage(req, res, next) {
|
|
494
550
|
return runPageGate('requireProjectCuratePage', req, res, next);
|
|
495
551
|
}
|
package/src/module-api.js
CHANGED
|
@@ -33,7 +33,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
33
33
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
34
34
|
// the entry to that file. Look for a version's history there, not here.
|
|
35
35
|
// ---------------------------------------------------------------------------
|
|
36
|
-
const CORE_VERSION = '1.19.
|
|
36
|
+
const CORE_VERSION = '1.19.427'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
37
37
|
|
|
38
38
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
39
39
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -284,23 +284,33 @@ test('the discoverable toggle names EXACTLY what it grants — the D3 sliver, na
|
|
|
284
284
|
'the section ships hidden — it reveals only where the hub module answers');
|
|
285
285
|
});
|
|
286
286
|
|
|
287
|
-
test('each toggle claims exactly what is enforced —
|
|
287
|
+
test('each toggle claims exactly what is enforced — and BOTH are now in force', () => {
|
|
288
288
|
const js = readFileSync(join(ROOT, 'modules/hall-ui/public/settings.js'), 'utf8');
|
|
289
289
|
const html = readFileSync(join(ROOT, 'modules/hall-ui/public/settings.html'), 'utf8');
|
|
290
|
-
// A toggle may promise only what a read path actually enforces
|
|
291
|
-
//
|
|
292
|
-
// public read, so its row moved to the present tense
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
290
|
+
// A toggle may promise only what a read path actually enforces. The two rows
|
|
291
|
+
// reached that point one task apart. Task 1002967 wired VISIBILITY into every
|
|
292
|
+
// public read, so its row moved to the present tense — and it must also say
|
|
293
|
+
// the page 404s to the OWNER, because that is the surprising half of D2 (the
|
|
294
|
+
// owner reads their record through the own-scope side door instead). Task
|
|
295
|
+
// 1002290 then wired RECRUITING REACH into listScoutingRollup, the one reader
|
|
296
|
+
// behind /scouting and the hall's People directory, so its row followed on the
|
|
297
|
+
// same terms. The assertions are unchanged in KIND — copy must track
|
|
298
|
+
// enforcement — only in which tense each row is now required to be in.
|
|
298
299
|
assert.match(js, /Private hides the page from everyone — including you/,
|
|
299
300
|
'visibility is enforced now, so the row says so — and names the 404-to-self');
|
|
300
301
|
assert.doesNotMatch(js, /Private will hide the page/,
|
|
301
302
|
'the future tense outlived its truth the moment resolveHandle joined the predicate');
|
|
302
|
-
assert.match(
|
|
303
|
-
'
|
|
304
|
-
assert.doesNotMatch(js, /
|
|
305
|
-
'
|
|
303
|
+
assert.match(js, /Recruiters looking for builders can find you\b/,
|
|
304
|
+
'recruiting reach is enforced now (task 1002290), so the row promises it in the present tense');
|
|
305
|
+
assert.doesNotMatch(js, /will be able to find you/,
|
|
306
|
+
'the future tense outlived its truth the moment listScoutingRollup joined the reach predicate');
|
|
307
|
+
assert.match(js, /takes you out of the People directory straight away/,
|
|
308
|
+
'and the row names the ONE surface that reads the flag today — a promise you can go and check');
|
|
309
|
+
assert.doesNotMatch(js, /every recruiting view/,
|
|
310
|
+
'never a promise over surfaces that do not exist yet: the D3 recruiter sliver is unbuilt, so no '
|
|
311
|
+
+ 'guard here could hold that claim, and the copy rule is that a toggle promises exactly what is enforced');
|
|
312
|
+
assert.match(html, /profile page setting applies now[\s\S]*Recruiter discoverability applies now too/,
|
|
313
|
+
'the section note now says both are live, rather than splitting them');
|
|
314
|
+
assert.doesNotMatch(html, /starts applying as the recruiting surfaces land/,
|
|
315
|
+
'the "recorded and waiting" note must not outlive the surface that started reading it');
|
|
306
316
|
});
|
|
@@ -77,6 +77,8 @@ import {
|
|
|
77
77
|
rollupVisibleSql,
|
|
78
78
|
isRollupVisible,
|
|
79
79
|
isRecruiterDiscoverable,
|
|
80
|
+
recruiterReachSql,
|
|
81
|
+
scoutingListableSql,
|
|
80
82
|
} from '../modules/platform-identity/account-visibility.js';
|
|
81
83
|
|
|
82
84
|
const require = createRequire(import.meta.url);
|
|
@@ -244,7 +246,14 @@ function blankComments(src) {
|
|
|
244
246
|
const SPELLINGS = [
|
|
245
247
|
[/\bprofile_state\s*(?:=|==|===|!=|!==|<>)\s*['"]/g, "profile_state compared to a literal"],
|
|
246
248
|
[/\baccount_visibility\s*(?:=|==|===|!=|!==|<>)\s*['"]/g, 'account_visibility compared to a literal'],
|
|
247
|
-
|
|
249
|
+
// The reach COALESCE, in every form a real query writes it. The unqualified
|
|
250
|
+
// spelling alone was a hole: the ONE production copy of this predicate is
|
|
251
|
+
// written `COALESCE(${q}recruiter_discoverable, …)` and emits
|
|
252
|
+
// `COALESCE(pa.recruiter_discoverable, …)`, and NEITHER matched — so the rule
|
|
253
|
+
// that is supposed to make this file the only home for the reach answer was
|
|
254
|
+
// catching only the spelling nobody uses. A drift copy would most likely be
|
|
255
|
+
// aliased too (task 1002290, found in this task's adversarial review).
|
|
256
|
+
[/\bCOALESCE\s*\(\s*(?:[A-Za-z_$][\w$]*|\$\{[^}]*\})?\s*\.?\s*recruiter_discoverable/gi, 'the recruiter-reach COALESCE'],
|
|
248
257
|
[/\bNOT\s+(?:[A-Za-z_][\w]*\.)?hide_stats\b/g, 'a SQL NOT hide_stats'],
|
|
249
258
|
[/\bhide_stats\s*(?:=|==|===)\s*(?:true|false)\b/g, 'hide_stats compared to a boolean'],
|
|
250
259
|
];
|
|
@@ -318,8 +327,14 @@ function accountStatements(code) {
|
|
|
318
327
|
return out;
|
|
319
328
|
}
|
|
320
329
|
|
|
321
|
-
/**
|
|
322
|
-
|
|
330
|
+
/**
|
|
331
|
+
* Does this function body route through one of the shared predicates?
|
|
332
|
+
* scoutingListableSql (task 1002290) is COMPOSED from rollupVisibleSql and
|
|
333
|
+
* recruiterReachSql inside the owner file, so a reader that joins it has adopted
|
|
334
|
+
* the shared rule — but it must be named here, or wiring the recruiting reach in
|
|
335
|
+
* would read as a reader that stopped adopting anything.
|
|
336
|
+
*/
|
|
337
|
+
const ADOPTS = /\b(?:accountVisibleSql|accountActiveSql|rollupVisibleSql|scoutingListableSql|isAccountVisible|isAccountActive|isRollupVisible)\s*\(/;
|
|
323
338
|
|
|
324
339
|
// ===========================================================================
|
|
325
340
|
// Self-tests — the machinery is the guard, so it gets proved too.
|
|
@@ -342,6 +357,17 @@ t('SPELLINGS catch a hand-written variant in either language, and leave writes a
|
|
|
342
357
|
assert.ok(caught("AND account_visibility = 'public'"), 'the existence axis in SQL');
|
|
343
358
|
assert.ok(caught("acct.account_visibility === 'private'"), 'the existence axis in JS');
|
|
344
359
|
assert.ok(caught('COALESCE(recruiter_discoverable, account_visibility)'), 'the recruiter COALESCE');
|
|
360
|
+
// The forms a REAL query carries — an alias, a template variable, and the two
|
|
361
|
+
// together. Testing only the bare spelling is how this rule came to match
|
|
362
|
+
// nothing that ships: the ONE production copy emits `COALESCE(pa.recruiter_-
|
|
363
|
+
// discoverable, …)` from source that reads `COALESCE(${q}recruiter_-
|
|
364
|
+
// discoverable, …)`, and the rule matched NEITHER (task 1002290, found in this
|
|
365
|
+
// task's adversarial review — and then again one form later, which is why all
|
|
366
|
+
// four are pinned rather than the one that failed first).
|
|
367
|
+
assert.ok(caught('COALESCE(pa.recruiter_discoverable, TRUE)'), 'the reach COALESCE, aliased');
|
|
368
|
+
assert.ok(caught('COALESCE(${q}recruiter_discoverable, x)'), 'the reach COALESCE, as the builder writes it');
|
|
369
|
+
assert.ok(caught('COALESCE(${alias}.recruiter_discoverable, TRUE)'), 'a template alias AND a dot — the drift copy most likely to be written');
|
|
370
|
+
assert.ok(!caught('COALESCE(hide_stats, false)'), 'and it is the REACH column it watches, not any COALESCE');
|
|
345
371
|
assert.ok(caught('AND NOT pa.hide_stats'), 'the hide half of a rollup predicate');
|
|
346
372
|
// A WRITE names the column but compares it to nothing — it decides no visibility.
|
|
347
373
|
assert.ok(!caught("profile_state = CASE WHEN $5::boolean THEN 'active' ELSE x END"), 'an upsert is not a predicate');
|
|
@@ -390,10 +416,24 @@ t('rollupVisibleSql is COMPOSED from accountVisibleSql, never restated', () => {
|
|
|
390
416
|
assert.match(rollupVisibleSql('pa'), /AND NOT pa\.hide_stats/, 'and add exactly the hide term');
|
|
391
417
|
});
|
|
392
418
|
|
|
393
|
-
t('
|
|
419
|
+
t('scoutingListableSql is COMPOSED from the rollup AND the reach halves (task 1002290)', () => {
|
|
420
|
+
// The recruiting directory asks BOTH questions; a leaderboard asks only the
|
|
421
|
+
// first. Composition is what keeps that distinction from becoming two rules.
|
|
422
|
+
assert.ok(scoutingListableSql('pa').includes(rollupVisibleSql('pa')),
|
|
423
|
+
'the directory predicate must literally contain the rollup predicate');
|
|
424
|
+
assert.ok(scoutingListableSql('pa').includes(recruiterReachSql('pa')),
|
|
425
|
+
'and the reach predicate — wiring reach in is the whole of task 1002290');
|
|
426
|
+
assert.ok(!rollupVisibleSql('pa').includes('recruiter_discoverable'),
|
|
427
|
+
'and reach must NOT leak back down into the rollup rule: the community spec D3 '
|
|
428
|
+
+ 'leaderboards join rollupVisibleSql and the opt-out must not empty them');
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
t('the alias is the one interpolated thing, and every builder refuses a non-identifier', () => {
|
|
394
432
|
const inject = "pa; DROP TABLE platform_identity_accounts --";
|
|
395
433
|
assert.throws(() => accountVisibleSql(inject), /bare SQL identifier/);
|
|
396
434
|
assert.throws(() => rollupVisibleSql(inject), /bare SQL identifier/);
|
|
435
|
+
assert.throws(() => recruiterReachSql(inject), /bare SQL identifier/);
|
|
436
|
+
assert.throws(() => scoutingListableSql(inject), /bare SQL identifier/);
|
|
397
437
|
});
|
|
398
438
|
|
|
399
439
|
t('the JS ports answer exactly what the SQL asks, row for row', () => {
|
|
@@ -655,6 +695,11 @@ function accountFilter(where, params) {
|
|
|
655
695
|
if (/\bprofile_state = 'active'/.test(where)) tests.push((r) => r.profile_state === 'active');
|
|
656
696
|
if (/\baccount_visibility = 'public'/.test(where)) tests.push((r) => r.account_visibility === 'public');
|
|
657
697
|
if (/\bNOT (?:[a-z_]+\.)?hide_stats\b/.test(where)) tests.push((r) => !r.hide_stats);
|
|
698
|
+
// The recruiting-reach COALESCE (task 1002290). Modelled through the shared JS
|
|
699
|
+
// twin, not re-implemented — an interpreter that read this term its own way
|
|
700
|
+
// could agree with a WRONG query. Unmodelled it would degrade to no filter,
|
|
701
|
+
// which is precisely what this interpreter exists to refuse.
|
|
702
|
+
if (/COALESCE\((?:[a-z_]+\.)?recruiter_discoverable,/.test(where)) tests.push(isRecruiterDiscoverable);
|
|
658
703
|
|
|
659
704
|
const byId = /\b(?:[a-z_]+\.)?github_id = \$(\d+)/.exec(where);
|
|
660
705
|
if (byId) { consumed.add(byId[1]); const v = params[Number(byId[1]) - 1]; tests.push((r) => String(r.github_id) === String(v)); }
|