@bongos/core 1.19.425 → 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.
@@ -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 task 1002967): only
726
- // builders whose account passes rollupVisibleSql — active, publicly visible, not
727
- // hidden — appear. /people and /scouting are live cross-project surfaces, so the
728
- // predicate lives IN the query, not in any caller's memory, and it is the SHARED
729
- // one so this list can never disagree with /profiles/:handle about who is
730
- // visible. Provisional, private and hidden builders drop out entirely; the
731
- // projection stays an explicit whitelist (no raw-row spread). A private account
732
- // that is recruiter-discoverable is absent here on purpose: the full rollup is
733
- // never its disclosure — the D3 sliver is (a separate authorization-scoped port).
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 ${rollupVisibleSql('pa')})
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 INTERNAL cross-project
2
- // scouting view (ADR 0141 §4, task #1002114; Metic floor per ADR 0157). Ranks
3
- // builders across ALL hosted projects from platform_identity_builder_project_activity.
4
- // A talent tool for the platform operator — NOT authority (the rollup is
5
- // descriptive; authority stays local, ADR 0141 / ADR 0016). Consent is enforced
6
- // IN listScoutingRollup itself (ADR 0171 D2/D3): only profile_state='active',
7
- // non-hidden builders appear, so this surface honors hide by construction.
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, requirePermission, parsePagination } = api;
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
- // rank: archon — the internal talent directory across every project. The Archon
22
- // gate here is the load-bearing protection ("NOT shown to builders"): a non-Archon
23
- // is 403'd before any cross-builder data is read.
24
- router.get('/scouting', requireBuilder, requirePermission('project.curate'), async (req, res) => {
25
- const { limit } = parsePagination(req, { defaultLimit: 100, maxLimit: 500 });
26
- try {
27
- const builders = await pi.listScoutingRollup({ limit, pool });
28
- res.json({ builders });
29
- } catch (err) {
30
- console.error('[platform-identity] GET /scouting', err);
31
- res.fail('scouting_failed', { status: 500, message: 'internal error' });
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.425",
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.425",
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.425",
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",
@@ -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
- requireProjectCuratePage: Object.freeze({ permission: 'project.curate', deniedTo: '/builders' }),
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. Spec R14 (goal 1000045) widens the atom's audience
492
- // later; this gate follows it automatically.
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.425'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
36
+ const CORE_VERSION = '1.19.426'; // 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 — visibility in force, recruiting not yet', () => {
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, and the two
291
- // rows are at different stages. Task 1002967 wired VISIBILITY into every
292
- // public read, so its row moved to the present tense here — and it must also
293
- // say the page 404s to the OWNER, because that is the surprising half of D2
294
- // (the owner reads their record through the own-scope side door instead).
295
- // DISCOVERABILITY is still stored-only: no recruiting surface reads it, so a
296
- // present-tense promise there would be the "protection you do not have" this
297
- // assertion pair exists to prevent.
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(html, /profile page setting applies now[\s\S]*Recruiter discoverability is saved to your[\s\S]*starts applying as the recruiting surfaces land/,
303
- 'the section note splits the two: one in force, one recorded and waiting');
304
- assert.doesNotMatch(js, /Recruiters looking for builders (?:can|are able to) find you\b/,
305
- 'no present-tense promise about recruiting reach — nothing reads that flag yet');
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
- [/\bCOALESCE\s*\(\s*recruiter_discoverable/gi, 'the recruiter-reach COALESCE'],
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
- /** Does this function body route through one of the shared predicates? */
322
- const ADOPTS = /\b(?:accountVisibleSql|accountActiveSql|rollupVisibleSql|isAccountVisible|isAccountActive|isRollupVisible)\s*\(/;
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('the alias is the one interpolated thing, and both builders refuse a non-identifier', () => {
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)); }