@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
|
@@ -28,8 +28,11 @@
|
|
|
28
28
|
// rollupVisibleSql / isRollupVisible NUMBERS — visible AND NOT
|
|
29
29
|
// hide_stats. ADR 0171 D2's predicate EXTENDED with the existence axis,
|
|
30
30
|
// and COMPOSED from accountVisibleSql rather than restated.
|
|
31
|
-
// isRecruiterDiscoverable
|
|
31
|
+
// recruiterReachSql / isRecruiterDiscoverable REACH — the COALESCE, privacy
|
|
32
32
|
// spec D1. A reach predicate only: it never buys addressability.
|
|
33
|
+
// scoutingListableSql THE DIRECTORY — numbers AND reach,
|
|
34
|
+
// the ONE predicate the recruiting directory joins (ADR 0210, task
|
|
35
|
+
// 1002290). Composed, never restated.
|
|
33
36
|
//
|
|
34
37
|
// The rollup pair was originally left to "the rollup readers" to apply. Task
|
|
35
38
|
// 1002967 moved it here for the reason this file exists: there are two such
|
|
@@ -137,6 +140,47 @@ function isRecruiterDiscoverable(account) {
|
|
|
137
140
|
return account.account_visibility === 'public';
|
|
138
141
|
}
|
|
139
142
|
|
|
143
|
+
// The SAME reach answer as a SQL fragment, for the read that filters in the
|
|
144
|
+
// database (task 1002290). It is the ONE spelling of that COALESCE outside the
|
|
145
|
+
// JS twin above — tests/effective_visibility_predicate.mjs fails any other file
|
|
146
|
+
// that writes it — so a directory query and a route can never disagree about who
|
|
147
|
+
// opted out. NULL still means "follow visibility", exactly as the column comment
|
|
148
|
+
// in platform_identity_012_account_privacy.sql says.
|
|
149
|
+
function recruiterReachSql(alias) {
|
|
150
|
+
if (alias && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(String(alias))) {
|
|
151
|
+
throw new Error('recruiterReachSql: alias must be a bare SQL identifier');
|
|
152
|
+
}
|
|
153
|
+
const q = alias ? `${alias}.` : '';
|
|
154
|
+
return `COALESCE(${q}recruiter_discoverable, ${q}account_visibility = 'public')`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// scoutingListableSql — THE predicate the recruiting directory joins: the rollup
|
|
158
|
+
// axis AND the reach axis (ADR 0210, task 1002290). listScoutingRollup is the
|
|
159
|
+
// one reader; /scouting and the hall's /people are its two surfaces.
|
|
160
|
+
//
|
|
161
|
+
// WHY REACH BELONGS HERE AND NOT IN rollupVisibleSql. The two axes answer
|
|
162
|
+
// different questions and only one surface asks both. `hide_stats` suppresses
|
|
163
|
+
// NUMBERS everywhere, for every reader; the recruiting opt-out removes an
|
|
164
|
+
// account from the RECRUITING SURFACE only. The community spec's D3 leaderboards
|
|
165
|
+
// say so out loud — "the recruiting opt-out does not affect leaderboards; it
|
|
166
|
+
// governs the recruiting surface only" — so folding reach into rollupVisibleSql
|
|
167
|
+
// would silently empty a future public leaderboard of every opted-out builder.
|
|
168
|
+
// A leaderboard reader must therefore join rollupVisibleSql, never this.
|
|
169
|
+
//
|
|
170
|
+
// Composed from rollupVisibleSql and recruiterReachSql rather than restated, so
|
|
171
|
+
// changing what "visible", "hidden" or "reachable" means moves this with it.
|
|
172
|
+
function scoutingListableSql(alias) {
|
|
173
|
+
// Validated HERE as well as in both delegates. The delegates do refuse a bad
|
|
174
|
+
// alias, but only because rollupVisibleSql happens to be evaluated first in the
|
|
175
|
+
// template literal below — a security property should not rest on the
|
|
176
|
+
// evaluation order of a string interpolation, and the error a caller sees
|
|
177
|
+
// should name the function they actually called.
|
|
178
|
+
if (alias && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(String(alias))) {
|
|
179
|
+
throw new Error('scoutingListableSql: alias must be a bare SQL identifier');
|
|
180
|
+
}
|
|
181
|
+
return `(${rollupVisibleSql(alias)} AND ${recruiterReachSql(alias)})`;
|
|
182
|
+
}
|
|
183
|
+
|
|
140
184
|
module.exports = {
|
|
141
185
|
isAccountVisible,
|
|
142
186
|
accountVisibleSql,
|
|
@@ -146,4 +190,6 @@ module.exports = {
|
|
|
146
190
|
isRollupVisible,
|
|
147
191
|
ACCOUNT_VISIBILITY,
|
|
148
192
|
isRecruiterDiscoverable,
|
|
193
|
+
recruiterReachSql,
|
|
194
|
+
scoutingListableSql,
|
|
149
195
|
};
|
|
@@ -20,7 +20,7 @@ const api = require('../../src/module-api');
|
|
|
20
20
|
const visibility = require('./account-visibility');
|
|
21
21
|
|
|
22
22
|
const { pool: defaultPool } = api;
|
|
23
|
-
const { ACCOUNT_VISIBILITY, accountVisibleSql, rollupVisibleSql, isRollupVisible } = visibility;
|
|
23
|
+
const { ACCOUNT_VISIBILITY, accountVisibleSql, rollupVisibleSql, isRollupVisible, scoutingListableSql } = visibility;
|
|
24
24
|
|
|
25
25
|
// Default authorization-code lifetime — deliberately short (~60s). A code is a
|
|
26
26
|
// bearer secret that travels through the browser; a tight TTL bounds the replay
|
|
@@ -722,15 +722,18 @@ async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped =
|
|
|
722
722
|
// disciplines union is computed separately in `disc`. Proven against real SQL by
|
|
723
723
|
// tests/scouting_rollup_fanout.mjs.
|
|
724
724
|
//
|
|
725
|
-
// Consent by construction (ADR 0171 D2/D3, extended by
|
|
726
|
-
// builders whose account passes
|
|
727
|
-
// hidden — appear. /people and
|
|
728
|
-
//
|
|
729
|
-
// one so this list can never
|
|
730
|
-
//
|
|
731
|
-
//
|
|
732
|
-
// that is recruiter-discoverable is absent
|
|
733
|
-
// never its disclosure — the D3 sliver is
|
|
725
|
+
// Consent by construction (ADR 0171 D2/D3, extended by tasks 1002967 + 1002290):
|
|
726
|
+
// only builders whose account passes scoutingListableSql — active, publicly
|
|
727
|
+
// visible, not hidden, AND not opted out of recruiting — appear. /people and
|
|
728
|
+
// /scouting are live cross-project surfaces, so the predicate lives IN the query,
|
|
729
|
+
// not in any caller's memory, and it is the SHARED one so this list can never
|
|
730
|
+
// disagree with /profiles/:handle about who is visible. Provisional, private,
|
|
731
|
+
// hidden and opted-out builders drop out entirely; the projection stays an
|
|
732
|
+
// explicit whitelist. A private account that is recruiter-discoverable is absent
|
|
733
|
+
// here on purpose: the full rollup is never its disclosure — the D3 sliver is.
|
|
734
|
+
// THIS IS THE RECRUITING DIRECTORY: a public LEADERBOARD must not reuse this
|
|
735
|
+
// reader (community spec D3 — the opt-out governs the recruiting surface only);
|
|
736
|
+
// it joins rollupVisibleSql instead. Full argument: scoutingListableSql.
|
|
734
737
|
async function listScoutingRollup({ limit = 100, pool = defaultPool } = {}) {
|
|
735
738
|
const { rows } = await pool.query(
|
|
736
739
|
`WITH agg AS (
|
|
@@ -744,7 +747,7 @@ async function listScoutingRollup({ limit = 100, pool = defaultPool } = {}) {
|
|
|
744
747
|
WHERE EXISTS (
|
|
745
748
|
SELECT 1 FROM platform_identity_accounts pa
|
|
746
749
|
WHERE pa.github_id = a.github_id
|
|
747
|
-
AND ${
|
|
750
|
+
AND ${scoutingListableSql('pa')})
|
|
748
751
|
GROUP BY a.github_id
|
|
749
752
|
), disc AS (
|
|
750
753
|
SELECT x.github_id, array_agg(DISTINCT x.d) AS disciplines
|
|
@@ -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
|
+
};
|
|
@@ -125,7 +125,8 @@ const FLOW_RECEIPTS = {
|
|
|
125
125
|
teardown: { queued: true, message: 'Teardown queued.' },
|
|
126
126
|
restart: { queued: true, message: 'Restart queued.' },
|
|
127
127
|
domain: { recorded: true, queued: true, message: 'Address recorded.' },
|
|
128
|
-
|
|
128
|
+
// the wizard's filing (task 1003346): the receipt names the instance the page then polls — Harbor, the fixture's standing-up row — and the address the page promises is the one the server assigned
|
|
129
|
+
provision: { queued: true, message: 'Queued. The platform picks it up from here.', instance: { id: 102, domain: 'mercury.cloudbongos.com' } },
|
|
129
130
|
settings: { settings: { platform_visibility: 'public', joinability: 'open' }, push: { queued: true, message: 'Saved. The project restarts to apply it.' } },
|
|
130
131
|
};
|
|
131
132
|
// a teardown or restart ask is REMEMBERED for the run: the page re-reads the project right
|
|
@@ -218,6 +219,7 @@ const server = http.createServer((req, res) => {
|
|
|
218
219
|
if (flow) return AUTH ? json(res, 200, flow) : json(res, 401, UNAUTH);
|
|
219
220
|
}
|
|
220
221
|
if (rest.startsWith('/github/repo')) return AUTH ? json(res, 200, { repo: { private: false, admin: true } }) : json(res, 401, UNAUTH);
|
|
222
|
+
if (rest.startsWith('/provisioning/slug-available')) return json(res, 200, { available: true }); /* the wizard's handle check (task 1003346): every handle is free on the stub */
|
|
221
223
|
if (rest === '/auth/logout') return json(res, 200, { ok: true });
|
|
222
224
|
if (rest.startsWith('/auth/web/start')) { res.writeHead(302, { location: PREFIX }); return res.end(); }
|
|
223
225
|
if (rest === '/instance') { const b = pages.size ? pages.values().next().value.brand : {}; return json(res, 200, { brand: { productName: (b && b.productName) || '', project: {} }, modules: {} }); }
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.426",
|
|
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.426",
|
|
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.426",
|
|
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",
|
|
@@ -1386,3 +1386,66 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
|
|
|
1386
1386
|
@media (prefers-reduced-motion:reduce){
|
|
1387
1387
|
*,*::before,*::after{animation:none !important;transition:none !important;}
|
|
1388
1388
|
}
|
|
1389
|
+
/* ═══════════════════════════════════════════════════════════════════════
|
|
1390
|
+
THE CREATE WIZARD, IN THE WORLD (task 1003346; direction record §15, the
|
|
1391
|
+
pick: v1 the rail above, the bongo pair kept). The wizard keeps its markup
|
|
1392
|
+
(the panels, the forms, the ids the driver and the tests pin) and takes the
|
|
1393
|
+
world's grammar by SCOPE: no card ground, the six-step strip drawn as the
|
|
1394
|
+
lifecycle rail, the step title lowercase in the display voice, the doors as
|
|
1395
|
+
hairline pills with the accent only on the primary, the GitHub doors
|
|
1396
|
+
inverted. Its own structures (the hairline fields, the choices as cells of
|
|
1397
|
+
one band, the review as a ledger, the human steps, the standup rail) live
|
|
1398
|
+
in projects.html's wizard block. Dark only (ADR 0204); every colour a
|
|
1399
|
+
token; halos are outlines, never box-shadow.
|
|
1400
|
+
═══════════════════════════════════════════════════════════════════════ */
|
|
1401
|
+
#view-new .wrap{max-width:1180px;}
|
|
1402
|
+
#view-new .card{background:none;border-radius:0;box-shadow:none;padding:34px 0 60px;margin:0;}
|
|
1403
|
+
/* the six-step strip as the rail: six hairlines, a 7px dot, one lit step on the
|
|
1404
|
+
accent with its halo, the number in the display voice, the name in the label
|
|
1405
|
+
face; a step already answered (role=button) stays a way back */
|
|
1406
|
+
#view-new .steps{display:grid;grid-template-columns:repeat(6,1fr);gap:0 10px;align-items:start;margin:0 0 40px;}
|
|
1407
|
+
#view-new .steps li{display:block;position:relative;border-top:1px solid var(--rule);padding:12px 0 0;gap:0;}
|
|
1408
|
+
#view-new .steps li::before{content:"";position:absolute;top:-4px;left:0;right:auto;width:7px;height:7px;margin:0;border-radius:50%;border:1px solid var(--rule);background:var(--bg);}
|
|
1409
|
+
#view-new .steps .snum{display:block;width:auto;height:auto;border:0;border-radius:0;background:none;font-family:var(--font-display);font-weight:300;font-size:22px;letter-spacing:-.02em;line-height:1.1;color:var(--ink-faint);margin-bottom:2px;}
|
|
1410
|
+
#view-new .steps .slbl{display:block;font-family:var(--label-face);font-size:var(--label-size);font-weight:400;letter-spacing:var(--label-space);text-transform:uppercase;line-height:1.3;color:var(--ink-faint);}
|
|
1411
|
+
#view-new .steps li.done{border-top-color:var(--ink-soft);}
|
|
1412
|
+
#view-new .steps li.done::before{background:var(--ink-soft);border-color:var(--ink-soft);}
|
|
1413
|
+
#view-new .steps li.done .snum,#view-new .steps li.done .slbl{color:var(--ink-soft);background:none;}
|
|
1414
|
+
#view-new .steps li.now{border-top-color:var(--accent);}
|
|
1415
|
+
#view-new .steps li.now::before{background:var(--accent);border-color:var(--accent);outline:4px solid color-mix(in srgb,var(--accent) 22%,transparent);}
|
|
1416
|
+
#view-new .steps li.now .snum,#view-new .steps li.now .slbl{color:var(--ink);}
|
|
1417
|
+
#view-new .steps li[role=button]:hover .slbl{color:var(--ink);}
|
|
1418
|
+
/* the step: the title lowercase in the display voice, one clause, the fine
|
|
1419
|
+
print in the body face; the bongo pair beside it (the owner's one mark on
|
|
1420
|
+
every surface), its halo gone */
|
|
1421
|
+
#view-new .createSplit{align-items:start;gap:48px;}
|
|
1422
|
+
#view-new .bongoCell{padding-top:60px;}
|
|
1423
|
+
#view-new .bongos .glow{display:none;}
|
|
1424
|
+
#view-new .panel h1{font-weight:300;font-size:clamp(28px,3.4vw,44px);letter-spacing:-.035em;line-height:1.04;text-transform:lowercase;color:var(--ink);text-shadow:none;margin:0 0 10px;max-width:18ch;text-wrap:balance;}
|
|
1425
|
+
#view-new .panel .lede{font-size:15.5px;font-weight:400;line-height:1.5;color:var(--ink-soft);margin:0 0 26px;max-width:62ch;}
|
|
1426
|
+
#view-new .panel .lede strong,#view-new .panel .lede em{color:var(--ink);font-weight:500;font-style:normal;}
|
|
1427
|
+
/* the done clause's link to Your projects clears the 24px hit floor as a box (an inline's rect is its content box) */
|
|
1428
|
+
#view-new .panel .lede a{display:inline-block;padding:3px 0;min-height:24px;line-height:18px;color:var(--ink);font-weight:400;text-decoration:underline;text-underline-offset:3px;}
|
|
1429
|
+
#view-new .finePrint{font-weight:400;font-size:13.5px;line-height:1.5;color:var(--ink-soft);margin-top:18px;max-width:62ch;}
|
|
1430
|
+
#view-new .finePrint a{color:var(--ink);font-weight:400;text-decoration:underline;text-underline-offset:3px;}
|
|
1431
|
+
#view-new .finePrint em{font-style:normal;color:var(--ink);}
|
|
1432
|
+
#view-new .fieldNote{border:0;border-top:1px solid var(--rule);border-bottom:1px solid var(--rule);border-radius:0;text-align:left;font-weight:400;color:var(--ink-soft);}
|
|
1433
|
+
#view-new .linklike{font-weight:400;color:var(--ink-soft);}
|
|
1434
|
+
#view-new .linklike:hover{color:var(--ink);}
|
|
1435
|
+
/* the doors: pills, lowercase, the accent only on the one that acts; back and
|
|
1436
|
+
skip as hairline pills; the GitHub sign-in inverted; no lift, no shadow */
|
|
1437
|
+
#view-new .btn{min-height:44px;padding:0 22px;font-family:var(--font-body);font-weight:500;font-size:15px;text-transform:lowercase;white-space:nowrap;box-shadow:none;transition:border-color .15s ease,background .15s ease,color .15s ease;}
|
|
1438
|
+
#view-new .btn:hover{transform:none;box-shadow:none;background:color-mix(in srgb,var(--accent) 88%,var(--ink));}
|
|
1439
|
+
#view-new .btn:active{transform:none;}
|
|
1440
|
+
#view-new .btn:disabled{background:none;border:1px solid var(--rule-soft);color:var(--ink-faint);}
|
|
1441
|
+
#view-new .btn.ghost{font-weight:400;color:var(--ink);background:none;border:1px solid var(--rule);}
|
|
1442
|
+
#view-new .btn.ghost:hover{border-color:var(--ink-faint);color:var(--ink);background:none;}
|
|
1443
|
+
#view-new .btn.big{width:auto;min-height:48px;font-size:16px;padding:0 26px;}
|
|
1444
|
+
#view-new #wizSignin{background:var(--ink);color:var(--bg);border:1px solid var(--ink);}
|
|
1445
|
+
#view-new #wizSignin:hover{background:color-mix(in srgb,var(--ink) 88%,var(--bg));}
|
|
1446
|
+
/* the error alert: the error ink between two hairlines, no plate */
|
|
1447
|
+
#view-new .alert{background:none;border:0;border-top:1px solid var(--err);border-bottom:1px solid var(--err);border-radius:0;padding:12px 0;font-weight:400;}
|
|
1448
|
+
@media (max-width:900px){
|
|
1449
|
+
#view-new .steps{grid-template-columns:repeat(3,1fr);gap:18px 10px;margin-bottom:30px;}
|
|
1450
|
+
#view-new .steps .snum{font-size:18px;}
|
|
1451
|
+
}
|