@bongos/core 1.19.1016 → 1.19.1017

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.
Files changed (39) hide show
  1. package/.bongos-core.json +78 -38
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +6 -1
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/adr/0334-the-recruiting-directory-lists-a-hidden-builder-by-name.md +47 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +56 -5
  10. package/docs/api-reference.md +7 -1
  11. package/docs/copy-inventory.md +5 -3
  12. package/docs/copy-registry.json +22 -4
  13. package/docs/file-map.md +1 -1
  14. package/docs/module-api-changelog.md +2 -0
  15. package/modules/hall-ui/public/people.html +11 -0
  16. package/modules/hall-ui/public/people.js +77 -11
  17. package/modules/hall-ui/public/people.states.json +1 -1
  18. package/modules/platform-identity/account-visibility.js +33 -2
  19. package/modules/platform-identity/builder-search.js +153 -0
  20. package/modules/platform-identity/migrations/platform_identity_023_builder_search_trgm.sql +40 -0
  21. package/modules/platform-identity/module.json +1 -1
  22. package/modules/platform-identity/routes/community-search.js +120 -0
  23. package/modules/platform-identity/routes/scouting.js +14 -7
  24. package/modules/platform-identity/scouting-name-only.js +47 -0
  25. package/modules/public-landing/public/projects-floor.states.json +9 -2
  26. package/modules/public-landing/public/projects.html +69 -4
  27. package/modules/ui-design/styles/expedition/mock.html +16 -3
  28. package/package-lock.json +2 -2
  29. package/package.json +1 -1
  30. package/src/module-api.js +1 -1
  31. package/tests/account_privacy_flags.mjs +4 -1
  32. package/tests/card_apply_ui.mjs +297 -0
  33. package/tests/community_search_route.mjs +385 -0
  34. package/tests/effective_visibility_predicate.mjs +49 -1
  35. package/tests/people_name_only.mjs +262 -0
  36. package/tests/profile_route.mjs +23 -5
  37. package/tests/recruiter_sliver_boundary.mjs +3 -0
  38. package/tests/scouting_owner_access_and_reach.mjs +112 -2
  39. package/tests/ui_design_styles.mjs +13 -0
@@ -0,0 +1,120 @@
1
+ // modules/platform-identity/routes/community-search.js — GET /community/search,
2
+ // the PUBLIC builder-search read behind the future /community page (D4,
3
+ // docs/specs/bongos-v1-community-tab.md; task 1002979, goal 1000110 CT3). A
4
+ // sibling of public-profile.js and scouting.js — same module, same doorway,
5
+ // DIFFERENT surface: this is name+discipline search under exactly the
6
+ // public-profile visibility rules, never the project.curate/owner-gated
7
+ // recruiting directory (routes/scouting.js `GET /scouting`) and never its
8
+ // archon-floor pre-accept sliver (`GET /scouting/:handle`). The visibility
9
+ // decision lives entirely in ../builder-search.js (account-visibility.js's
10
+ // shared predicates); this file is the thin HTTP shell — validation, the
11
+ // per-IP rate limit, the response envelope.
12
+ //
13
+ // Reaches core only through the doorway (ADR 0083).
14
+ 'use strict';
15
+
16
+ const express = require('express');
17
+ const api = require('../../../src/module-api');
18
+ const builderSearch = require('../builder-search');
19
+
20
+ const { pool, corsPublicGet } = api;
21
+
22
+ // Resolved at CALL time, never at load — the scouting.js precedent: a proof
23
+ // that stubs a PARTIAL doorway (pool + the auth gates only) must still be able
24
+ // to require() this file, and a module-scope api.logger() call would break
25
+ // that. A logging line is not worth breaking a privacy proof over.
26
+ let resolvedLog = null;
27
+ const log = {
28
+ error: (msg) => {
29
+ if (!resolvedLog) resolvedLog = typeof api.logger === 'function' ? api.logger('platform-identity') : { error: () => {} };
30
+ resolvedLog.error(msg);
31
+ },
32
+ };
33
+
34
+ // Per-IP read cap for this UNAUTHENTICATED route (the public-profile.js R05
35
+ // posture: the global limiter only meters WRITES, so an uncapped scraper could
36
+ // otherwise run the trigram leg at line rate). Vendored, not shared — the core
37
+ // /public/* limiter is not on the doorway, and each public GET here owns its
38
+ // own budget rather than reaching into a sibling module's copy.
39
+ const READ_RATE_WINDOW_MS = 60 * 1000;
40
+ const READ_RATE_LIMIT = 60;
41
+ const buckets = new Map(); // ip -> timestamp[]
42
+
43
+ const sweepTimer = setInterval(() => {
44
+ const cutoff = Date.now() - READ_RATE_WINDOW_MS;
45
+ for (const [k, arr] of buckets) {
46
+ while (arr.length && arr[0] < cutoff) arr.shift();
47
+ if (arr.length === 0) buckets.delete(k);
48
+ }
49
+ }, 5 * 60 * 1000);
50
+ if (typeof sweepTimer.unref === 'function') sweepTimer.unref();
51
+
52
+ function searchReadRateLimit(req, res, next) {
53
+ const now = Date.now();
54
+ const key = req.ip || req.socket?.remoteAddress || 'unknown';
55
+ let bucket = buckets.get(key);
56
+ if (!bucket) { bucket = []; buckets.set(key, bucket); }
57
+ const cutoff = now - READ_RATE_WINDOW_MS;
58
+ while (bucket.length && bucket[0] < cutoff) bucket.shift();
59
+ if (bucket.length >= READ_RATE_LIMIT) {
60
+ const retryAfter = Math.max(1, Math.ceil((bucket[0] + READ_RATE_WINDOW_MS - now) / 1000));
61
+ res.set('Retry-After', String(retryAfter));
62
+ res.set('X-RateLimit-Scope', 'community-search-read');
63
+ res.set('X-RateLimit-Limit', String(READ_RATE_LIMIT));
64
+ return res.fail('rate_limited', 429, { scope: 'community-search-read', retry_after_seconds: retryAfter });
65
+ }
66
+ bucket.push(now);
67
+ return next();
68
+ }
69
+
70
+ module.exports = function communitySearchRoutes() {
71
+ const router = express.Router();
72
+
73
+ router.use('/community/search', corsPublicGet('/community/search'));
74
+
75
+ // rank: public — a name/discipline search under EXACTLY the public-profile
76
+ // visibility rules (D4): a hide_stats account is findable by name but drops
77
+ // out of a discipline-filtered result (disciplines are rollup-derived, and
78
+ // the rollup is what hide_stats suppresses); private and provisional
79
+ // accounts never appear. no-store: the same hide-must-beat-the-cache posture
80
+ // as /profiles/:handle (ADR 0171 D5) — this reads the identical predicate
81
+ // and a flip must be reflected on the very next request.
82
+ router.get('/community/search', searchReadRateLimit, async (req, res) => {
83
+ const q = (typeof req.query.q === 'string' ? req.query.q : '').trim();
84
+ if (!q) {
85
+ return res.fail('bad_request', { status: 400, message: 'q (a non-empty search string) is required' });
86
+ }
87
+ if (q.length < builderSearch.MIN_QUERY_CHARS) {
88
+ return res.fail('query_too_short', { status: 400, message: `q must be at least ${builderSearch.MIN_QUERY_CHARS} characters` });
89
+ }
90
+ if (q.length > builderSearch.MAX_QUERY_CHARS) {
91
+ return res.fail('query_too_long', { status: 400, message: `q must be at most ${builderSearch.MAX_QUERY_CHARS} characters` });
92
+ }
93
+ const discipline = (typeof req.query.discipline === 'string' ? req.query.discipline : '').trim();
94
+ if (discipline.length > builderSearch.MAX_DISCIPLINE_CHARS) {
95
+ return res.fail('discipline_too_long', { status: 400, message: `discipline must be at most ${builderSearch.MAX_DISCIPLINE_CHARS} characters` });
96
+ }
97
+ try {
98
+ const { results, limit, offset } = await builderSearch.searchBuilders({
99
+ q, discipline, limit: req.query.limit, offset: req.query.offset, pool,
100
+ });
101
+ res.set('Cache-Control', 'no-store');
102
+ res.json({
103
+ query: q,
104
+ discipline: discipline || null,
105
+ count: results.length,
106
+ results,
107
+ page: { limit, offset },
108
+ });
109
+ } catch (err) {
110
+ log.error(`GET /community/search failed: ${err && err.message}`);
111
+ res.fail('search_failed', { status: 500, message: 'internal error' });
112
+ }
113
+ });
114
+
115
+ return router;
116
+ };
117
+
118
+ // Exported for tests — the pure rate-limit bucket logic + limits (the
119
+ // modules/memory/routes/search.js precedent).
120
+ module.exports._internals = { searchReadRateLimit, READ_RATE_LIMIT, READ_RATE_WINDOW_MS };
@@ -10,11 +10,14 @@
10
10
  // ../scouting-authz.js, which carries the full argument for why reading an
11
11
  // instance-asserted ownership claim is not the ADR 0201 D3 inversion.
12
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.
13
+ // WHO APPEARS IN IT: consent is enforced in the two reads themselves, by shared
14
+ // predicates. `builders` is listScoutingRollup over scoutingListableSql —
15
+ // active, publicly visible, not hidden, not opted out (ADR 0171 D2/D3 + privacy
16
+ // spec D1). `name_only` (ADR 0334, task 1002291) is the hidden-but-reachable
17
+ // builder the spec's D2 lists "name-only": login, name and avatar, via
18
+ // scouting-name-only.js. So the opt-out removes a builder and hide only strips
19
+ // their row to identity, by construction; widening WHO may read it could not
20
+ // widen WHAT is shown.
18
21
  //
19
22
  // TWO READS, TWO AUDIENCES — and the difference is the point (ADR 0215 D4, task
20
23
  // 1002968). `GET /scouting` is the DIRECTORY: the full rollup for builders who
@@ -37,6 +40,7 @@ const api = require('../../../src/module-api');
37
40
  const pi = require('../platform-identity');
38
41
  const scoutingAuthz = require('../scouting-authz');
39
42
  const recruiterSliver = require('../recruiter-sliver');
43
+ const scoutingNameOnly = require('../scouting-name-only');
40
44
 
41
45
  const { pool, requireBuilder, parsePagination } = api;
42
46
 
@@ -77,12 +81,15 @@ module.exports = function scoutingRoutes() {
77
81
  async (req, res) => {
78
82
  const { limit } = parsePagination(req, { defaultLimit: 100, maxLimit: 500 });
79
83
  try {
80
- const builders = await pi.listScoutingRollup({ limit, pool });
84
+ const [builders, nameOnly] = await Promise.all([
85
+ pi.listScoutingRollup({ limit, pool }),
86
+ scoutingNameOnly.listScoutingNameOnly({ limit, pool }),
87
+ ]);
81
88
  // Same rollup-derived data as the sliver, so the same rule: hide and the
82
89
  // recruiting opt-out must beat any cache (ADR 0171 D5). This route
83
90
  // predates that posture and was the only rollup read still missing it.
84
91
  res.set('Cache-Control', 'no-store');
85
- res.json({ builders });
92
+ res.json({ builders, name_only: nameOnly });
86
93
  } catch (err) {
87
94
  log.error(`GET /scouting failed: ${err && err.message}`);
88
95
  res.fail('scouting_failed', { status: 500, message: 'internal error' });
@@ -0,0 +1,47 @@
1
+ // modules/platform-identity/scouting-name-only.js — the recruiting directory's
2
+ // NAME-ONLY tier: builders who hid their cross-project record but are still open
3
+ // to recruiting (ADR 0334, task 1002291 / R15; privacy spec D2, whose recruiter
4
+ // column lists a hidden builder "name-only (if not opted out)").
5
+ //
6
+ // WHAT IT RETURNS. For each such account: its GitHub login, display name and
7
+ // avatar — what that builder's own public profile page already shows — and
8
+ // nothing else. No id, no count, no date, no craft: every one of those is
9
+ // derived from the record the builder hid. The projection is written out key by
10
+ // key, so a column added to the statement later cannot ride into a response.
11
+ //
12
+ // WHO IS IN IT is scoutingNameOnlySql (account-visibility.js): publicly visible
13
+ // AND hidden AND reachable. Two things it deliberately does NOT do:
14
+ // - consult the activity rollup. Listing only hidden builders who have built
15
+ // something would make presence here say so, and that is rollup-derived.
16
+ // - order by anything but the login. The ranked tier's order IS the record, so
17
+ // a hidden row ordered by it would leak its rank by position.
18
+ //
19
+ // WHO MAY READ IT is the directory's door (routes/scouting.js, ADR 0210). Not
20
+ // authority in either direction (ADR 0016).
21
+ //
22
+ // Its own file because platform-identity.js sits at the 1,500-line ratchet.
23
+ // Proof: tests/scouting_owner_access_and_reach.mjs §2b; the one-reader pin is in
24
+ // tests/effective_visibility_predicate.mjs.
25
+ 'use strict';
26
+
27
+ const { scoutingNameOnlySql } = require('./account-visibility');
28
+
29
+ // listScoutingNameOnly({ limit, pool }) — the name-only tier, alphabetical by
30
+ // login, bounded by the same `limit` the ranked tier takes.
31
+ async function listScoutingNameOnly({ limit, pool }) {
32
+ const { rows } = await pool.query(
33
+ `SELECT github_login, display_name, avatar_url
34
+ FROM platform_identity_accounts
35
+ WHERE ${scoutingNameOnlySql()}
36
+ ORDER BY lower(github_login), github_login
37
+ LIMIT $1`,
38
+ [limit],
39
+ );
40
+ return rows.map((r) => ({
41
+ github_login: r.github_login ?? null,
42
+ display_name: r.display_name ?? null,
43
+ avatar_url: r.avatar_url ?? null,
44
+ }));
45
+ }
46
+
47
+ module.exports = { listScoutingNameOnly };
@@ -1,5 +1,5 @@
1
1
  {
2
- "_": "The all-null floor card (task 1003301) on the hub's own catalog (task 1003345): the same page, the same stub, the catalog aliased to featured-hub.json — the landing's three plus Tidewater, a null row nobody owns. Demo, the landing catalog's floor row, is owned by the signed-in stub, and an owned orb's plain click opens its manage view, never its card. Since task 1003352 it also carries the card BACKDROP state: featured-hub gives Example World a card_backdrop, so the owner's plate can be seen standing in front of the project's own picture. Rendered by name: node modules/ui-design/kit/render.js --states public-landing/projects-floor.states.json",
2
+ "_": "The all-null floor card (task 1003301) on the hub's own catalog (task 1003345): the same page, the same stub, the catalog aliased to featured-hub.json — the landing's three plus Tidewater, a null row nobody owns. Demo, the landing catalog's floor row, is owned by the signed-in stub, and an owned orb's plain click opens its manage view, never its card. Since task 1003352 it also carries the card BACKDROP state: featured-hub gives Example World a card_backdrop, so the owner's plate can be seen standing in front of the project's own picture. Tidewater's null relationship is also the ONLY fixture row the signed-in stub neither owns nor builds on, which is exactly what the Apply button needs (R10, task 1002286) — card-floor is its one rendered state. Rendered by name: node modules/ui-design/kit/render.js --states public-landing/projects-floor.states.json",
3
3
  "page": "/projects",
4
4
  "surface": "public-landing",
5
5
  "modes": [
@@ -55,7 +55,14 @@
55
55
  "visible": [
56
56
  "#cardOpen",
57
57
  "#cardRuns",
58
- "#cardListed"
58
+ "#cardListed",
59
+ "#cardApply"
60
+ ],
61
+ "text": [
62
+ [
63
+ "#cardApply",
64
+ "Apply to join"
65
+ ]
59
66
  ]
60
67
  }
61
68
  },
@@ -1649,6 +1649,7 @@ summary{min-height:24px;padding:3px 0;}
1649
1649
  }
1650
1650
  var catalog = [];
1651
1651
  var mineOrigins = {}; /* normalized host → 'owner' | 'builder' */
1652
+ var appliedOrigins = {}; /* normalized host → true; GET /my-projects' applied echo (R10, task 1002286) */
1652
1653
  var ownedInstances = []; /* the viewer's OWN provisioning rows (never torn_down) */
1653
1654
  var ownedByHost = {}; /* lower(domain) → instance row — the dedupe join */
1654
1655
  /* What the platform's own entry is FOR SEARCH. It is deliberately not pushed
@@ -2166,6 +2167,63 @@ summary{min-height:24px;padding:3px 0;}
2166
2167
  : '';
2167
2168
  }
2168
2169
 
2170
+ /* ══ APPLY TO JOIN (R10, task 1002286) ═══════════════════════════════
2171
+ The card's own door onto the join relay platform-identity already runs
2172
+ (POST /my-projects/join, R08 task 1002284): one click, on the origin the
2173
+ card already has — no URL to type, unlike the separate join-by-URL field
2174
+ task 1003774 is adding to View · Your projects. Hidden for the platform's
2175
+ own card, a member (the “yours”/“you build here” chip already says so) and
2176
+ a signed-out visitor (there is no session to file the request under).
2177
+ Applied is read from the hub's own echo (GET /my-projects' `applied`,
2178
+ loadMemberships) and updated the moment a request lands — the echo
2179
+ carries no status (ADR 0201 D1.3), so Applied IS pending here; there is
2180
+ nothing truer to say until membership itself appears. ══ */
2181
+ function cardApplyHtml(p, rel) {
2182
+ if (p.isSelf || isMember(rel) || !me) return '';
2183
+ return appliedOrigins[hostOf(p.origin)]
2184
+ ? '<button type="button" class="btn ghost" id="cardApply" disabled>Applied</button>'
2185
+ : '<button type="button" class="btn ghost" id="cardApply">Apply to join</button>';
2186
+ }
2187
+ /* The relay's own outcome (my-projects.js RELAY_SENTENCE) is shown verbatim
2188
+ — this page invents no wording of its own for a door it did not build.
2189
+ Only `status === 'requested'` moves the button to Applied; every other
2190
+ 2xx (already_member, invite_only, open_enrollment, members_only,
2191
+ not_published) is the door's answer, not a click that went through. */
2192
+ function submitApply(btn, p) {
2193
+ btn.disabled = true;
2194
+ btn.textContent = 'Applying…';
2195
+ var msg = $('cardApplyMsg');
2196
+ fetch(API + '/my-projects/join', {
2197
+ method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
2198
+ body: JSON.stringify({ origin: p.origin }),
2199
+ }).then(function (res) {
2200
+ if (res.status === 401) {
2201
+ btn.textContent = 'Apply to join'; btn.disabled = false;
2202
+ if (msg) { msg.className = 'projMsg err'; msg.innerHTML = signInAgain(); }
2203
+ return;
2204
+ }
2205
+ return jsonOrEmpty(res).then(function (data) {
2206
+ if (res.ok && data && data.status === 'requested') {
2207
+ appliedOrigins[hostOf(p.origin)] = true;
2208
+ btn.textContent = 'Applied';
2209
+ if (msg) { msg.className = 'projMsg ok'; msg.textContent = data.message || 'Requested.'; }
2210
+ return;
2211
+ }
2212
+ btn.textContent = 'Apply to join';
2213
+ btn.disabled = false;
2214
+ var e = apiError(data, res.status);
2215
+ if (msg) {
2216
+ msg.className = 'projMsg' + (res.ok ? '' : ' err');
2217
+ msg.textContent = (res.ok && data && data.message) || e.message || 'Could not reach that project — try again in a moment.';
2218
+ }
2219
+ });
2220
+ }).catch(function () {
2221
+ btn.textContent = 'Apply to join';
2222
+ btn.disabled = false;
2223
+ if (msg) { msg.className = 'projMsg err'; msg.textContent = 'Couldn’t reach the platform — check your connection and try again.'; }
2224
+ });
2225
+ }
2226
+
2169
2227
  function openCard(p, rel, returnTo) {
2170
2228
  var href = p.isSelf ? '/' : safeHttpUrl(p.origin);
2171
2229
  /* the art: catalog text an Archon typed, scheme-checked server-side and
@@ -2195,13 +2253,16 @@ summary{min-height:24px;padding:3px 0;}
2195
2253
  is member-gated AND address-gated: a member's entry, only where the hall
2196
2254
  is somewhere other than the site the first door already opens. */
2197
2255
  var hall = isMember(rel) ? hallUrlOf(p) : '';
2256
+ var applyBtn = cardApplyHtml(p, rel);
2198
2257
  $('cardActions').innerHTML = (href
2199
2258
  ? '<a class="cardOpen" id="cardOpen" href="' + esc(href) + '"' +
2200
2259
  (p.isSelf ? '' : ' target="_blank" rel="noopener"') + '>' +
2201
2260
  (p.isSelf ? 'Home →' : 'Open site →') + '</a>'
2202
2261
  : '') + (hall
2203
2262
  ? '<a class="cardHall" id="cardHall" href="' + esc(hall) + '" target="_blank" rel="noopener">' + HALL_GLYPH + 'Builders\' hall</a>'
2204
- : '');
2263
+ : '') + applyBtn + (applyBtn ? '<p class="projMsg" id="cardApplyMsg"></p>' : '');
2264
+ var applyEl = $('cardApply');
2265
+ if (applyEl && !applyEl.disabled) applyEl.addEventListener('click', function () { submitApply(applyEl, p); });
2205
2266
  closeMenus();
2206
2267
  cardReturn = returnTo || null;
2207
2268
  $('cardScrim').hidden = false;
@@ -2446,16 +2507,20 @@ summary{min-height:24px;padding:3px 0;}
2446
2507
  /* "Mine" is a MEMBERSHIP, not the catalog's advisory owner column: the
2447
2508
  authority is platform_identity_project_memberships, whose role is
2448
2509
  owner|builder|visitor, and GET /my-projects already returns the
2449
- { owned, building } split. 401 (signed out) is not an error here. */
2510
+ { owned, building, applied } split — `applied` is the statusless echo
2511
+ (ADR 0201 D1.3) the card's Apply button reads back (R10, task 1002286).
2512
+ 401 (signed out) is not an error here. */
2450
2513
  function loadMemberships() {
2451
2514
  return fetch(API + '/my-projects', { credentials: 'same-origin' })
2452
- .then(function (res) { return res.ok ? res.json() : { owned: [], building: [] }; })
2515
+ .then(function (res) { return res.ok ? res.json() : { owned: [], building: [], applied: [] }; })
2453
2516
  .then(function (d) {
2454
2517
  mineOrigins = {};
2518
+ appliedOrigins = {};
2455
2519
  (d.owned || []).forEach(function (m) { if (m.origin) mineOrigins[hostOf(m.origin)] = 'owner'; });
2456
2520
  (d.building || []).forEach(function (m) { if (m.origin) mineOrigins[hostOf(m.origin)] = 'builder'; });
2521
+ (d.applied || []).forEach(function (m) { if (m.origin) appliedOrigins[hostOf(m.origin)] = true; });
2457
2522
  })
2458
- .catch(function () { mineOrigins = {}; });
2523
+ .catch(function () { mineOrigins = {}; appliedOrigins = {}; });
2459
2524
  }
2460
2525
 
2461
2526
  /* The owner-scoped half of the sky (task 1002744). The public catalog
@@ -39,9 +39,7 @@ body {
39
39
  --gutter: clamp(16px, 4vw, 26px);
40
40
  --band: clamp(40px, 7vh, 72px);
41
41
  margin: 0;
42
- background: var(--bg);
43
- background-image: radial-gradient(var(--dots) .9px, transparent 1px);
44
- background-size: var(--dot-pitch) var(--dot-pitch);
42
+ background-color: var(--bg);
45
43
  color: var(--ink);
46
44
  font-family: var(--font-body);
47
45
  font-size: 16px;
@@ -101,6 +99,21 @@ body {
101
99
  }
102
100
  }
103
101
 
102
+ /* the halftone screen, fixed under the content. Not body's own background-image on purpose:
103
+ the branding pack's `backdrop` emits a `body{background:...}` rule after the page's own
104
+ stylesheet (serve-internal.js themeMarkup()), and that shorthand resets background-image
105
+ to none on any served page. A texture on body silently does not exist; a texture on a
106
+ layer does (the same pattern as the blueprint look's grid, task 1003633). */
107
+ body::before {
108
+ content: "";
109
+ position: fixed;
110
+ inset: 0;
111
+ z-index: -1;
112
+ pointer-events: none;
113
+ background-image: radial-gradient(var(--dots) .9px, transparent 1px);
114
+ background-size: var(--dot-pitch) var(--dot-pitch);
115
+ }
116
+
104
117
  *, *::before, *::after { box-sizing: border-box; }
105
118
  a { color: inherit; }
106
119
  :focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1016",
3
+ "version": "1.19.1017",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.1016",
9
+ "version": "1.19.1017",
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.1016",
3
+ "version": "1.19.1017",
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",
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.1016'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1017'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -416,7 +416,10 @@ test('the recruiting surface really is exactly two reads, and each one joins REA
416
416
  assert.match(route, /'\/scouting\/:handle'/, 'and the pre-accept sliver');
417
417
 
418
418
  const av = readFileSync(join(ROOT, 'modules/platform-identity/account-visibility.js'), 'utf8');
419
- for (const composed of ['scoutingListableSql', 'recruiterSliverVisibleSql']) {
419
+ // The directory reads TWO tiers since task 1002291 — ranked, and the hidden
420
+ // builders it lists by name — and the opt-out must empty both, or "removes you
421
+ // from the People directory" is false for a builder who also hid their record.
422
+ for (const composed of ['scoutingListableSql', 'scoutingNameOnlySql', 'recruiterSliverVisibleSql']) {
420
423
  const body = av.slice(av.indexOf(`function ${composed}(`));
421
424
  assert.match(body.slice(0, body.indexOf('\n}')), /recruiterReachSql\(alias\)/,
422
425
  `${composed} stopped composing the reach axis — opting out would no longer empty that surface`);