@bongos/core 1.19.1015 → 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 (82) hide show
  1. package/.bongos-core.json +159 -94
  2. package/.claude/skills/planning-session/SKILL.md +1 -1
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +4 -0
  5. package/clients/bongos-client/index.cjs +4 -0
  6. package/clients/bongos-client/index.d.ts +7 -2
  7. package/clients/bongos-client/index.mjs +4 -0
  8. package/docs/adr/0331-the-sky-is-the-ideators-home.md +12 -0
  9. package/docs/adr/0334-the-recruiting-directory-lists-a-hidden-builder-by-name.md +47 -0
  10. package/docs/adr/0335-an-invite-is-a-pre-approved-row-on-the-projects-own-instance.md +113 -0
  11. package/docs/adr/0336-a-guilds-record-is-the-sum-of-what-its-members-already-show.md +201 -0
  12. package/docs/adr/README.md +3 -0
  13. package/docs/api/openapi.json +60 -5
  14. package/docs/api-reference.md +8 -2
  15. package/docs/architecture.md +4 -1
  16. package/docs/copy-inventory.md +5 -3
  17. package/docs/copy-registry.json +22 -4
  18. package/docs/file-map.md +1 -2
  19. package/docs/module-api-changelog.md +5 -1
  20. package/migrations/core_253_idea_filed_via.sql +59 -0
  21. package/modules/agents/pollers/event-router.js +2 -0
  22. package/modules/discord/discord-bugs.js +2 -0
  23. package/modules/discord/discord-inbound.js +3 -0
  24. package/modules/hall-ui/public/people.html +11 -0
  25. package/modules/hall-ui/public/people.js +77 -11
  26. package/modules/hall-ui/public/people.states.json +1 -1
  27. package/modules/hall-ui/public/sky-data.js +22 -8
  28. package/modules/hall-ui/public/sky-draw.js +3 -2
  29. package/modules/hall-ui/public/sky-panel.js +1 -1
  30. package/modules/ideas/born-of.js +55 -0
  31. package/modules/ideas/inbox.js +23 -8
  32. package/modules/ideas/projection.js +48 -0
  33. package/modules/ideas/routes/inbox.js +20 -0
  34. package/modules/lifecycle/sky.js +25 -4
  35. package/modules/platform-identity/account-visibility.js +33 -2
  36. package/modules/platform-identity/activity-snapshots.js +47 -0
  37. package/modules/platform-identity/builder-search.js +153 -0
  38. package/modules/platform-identity/migrations/platform_identity_022_activity_snapshots.sql +46 -0
  39. package/modules/platform-identity/migrations/platform_identity_023_builder_search_trgm.sql +40 -0
  40. package/modules/platform-identity/module.json +1 -1
  41. package/modules/platform-identity/platform-identity.js +13 -0
  42. package/modules/platform-identity/routes/community-search.js +120 -0
  43. package/modules/platform-identity/routes/scouting.js +14 -7
  44. package/modules/platform-identity/scouting-name-only.js +47 -0
  45. package/modules/platform-identity/tests/activity-snapshots.mjs +68 -0
  46. package/modules/platform-identity/tests/platform-identity.mjs +21 -0
  47. package/modules/public-landing/public/projects-floor.states.json +9 -2
  48. package/modules/public-landing/public/projects.html +69 -4
  49. package/modules/ui-design/skills/impeccable/SKILL.md +1 -1
  50. package/modules/ui-design/styles/expedition/mock.html +16 -3
  51. package/package-lock.json +2 -2
  52. package/package.json +1 -1
  53. package/scripts/gds/capture.js +11 -0
  54. package/scripts/gds/run-unit-tests.js +4 -0
  55. package/src/module-api.js +1 -1
  56. package/tests/account_privacy_flags.mjs +4 -1
  57. package/tests/activity_snapshots_db.mjs +97 -0
  58. package/tests/card_apply_ui.mjs +297 -0
  59. package/tests/community_search_route.mjs +385 -0
  60. package/tests/discord_bugs.mjs +1 -0
  61. package/tests/discord_inbound.mjs +1 -0
  62. package/tests/effective_visibility_predicate.mjs +49 -1
  63. package/tests/goal_map_page.mjs +14 -0
  64. package/tests/guild_namespace_reserved.mjs +76 -0
  65. package/tests/hall_sky.mjs +33 -16
  66. package/tests/idea_filed_via.mjs +185 -0
  67. package/tests/landing_page.mjs +8 -0
  68. package/tests/people_name_only.mjs +262 -0
  69. package/tests/profile_rollup_consent.mjs +5 -1
  70. package/tests/profile_route.mjs +23 -5
  71. package/tests/project_admin_console_model.mjs +105 -0
  72. package/tests/recruiter_sliver_boundary.mjs +3 -0
  73. package/tests/scouting_owner_access_and_reach.mjs +112 -2
  74. package/tests/sky_read.mjs +19 -2
  75. package/tests/ui_design_skills.mjs +9 -0
  76. package/tests/ui_design_styles.mjs +13 -0
  77. package/modules/public-landing/public/world/assets/clouds/cloud-island-large.png +0 -0
  78. package/modules/public-landing/public/world/assets/clouds/cloud-puff.png +0 -0
  79. package/modules/public-landing/public/world/assets/creatures/creature-whale-cargo.png +0 -0
  80. package/modules/public-landing/public/world/assets/creatures/creature-whale-glide.png +0 -0
  81. package/modules/public-landing/public/world/assets/plates/plate-clouds-near.png +0 -0
  82. package/modules/public-landing/public/world/assets/plates/scene-establishing.png +0 -0
@@ -0,0 +1,153 @@
1
+ // modules/platform-identity/builder-search.js — the PUBLIC builder search (D4,
2
+ // docs/specs/bongos-v1-community-tab.md; task 1002979, goal 1000110 CT3). Answers
3
+ // "find a builder by name, optionally narrowed to a discipline" for the future
4
+ // /community page (tasks 1002980 / 1003363) — NOT the recruiting directory
5
+ // (routes/scouting.js), which stays a separate, project.curate/owner-gated
6
+ // surface with its own consent predicate, and NOT its archon-floor pre-accept
7
+ // sliver (recruiter-sliver.js). This surface reveals nothing beyond the same
8
+ // public-profile rules /profiles/:handle already applies (D8: no new disclosure
9
+ // class).
10
+ //
11
+ // TWO PREDICATES FROM account-visibility.js, ONE PER AXIS D4 NAMES:
12
+ // NAME MATCH — accountVisibleSql (existence: active + public). A
13
+ // hide_stats account is still findable BY NAME — hiding removes the
14
+ // rollup, not the page (the same rule /profiles/:handle already honors).
15
+ // DISCIPLINE MATCH — rollupVisibleSql (existence AND NOT hide_stats),
16
+ // because a discipline is ROLLUP-DERIVED
17
+ // (platform_identity_builder_project_activity.disciplines) and the rollup
18
+ // is exactly what hide_stats suppresses. A hidden account's disciplines are
19
+ // therefore never attached and never filterable — which is what keeps a
20
+ // discipline facet from re-exposing a hidden account's rollup by a side
21
+ // door. Private and provisional accounts never reach either predicate, so
22
+ // they are absent from every result regardless of query or filter.
23
+ // Both predicates are IMPORTED, never re-derived — the sole-owner rule
24
+ // tests/effective_visibility_predicate.mjs enforces.
25
+ //
26
+ // Matching: handle / display_name PREFIX (escaped LIKE) OR pg_trgm fuzzy (the
27
+ // `%` similarity operator) — the two legs D4 asks for. BOTH legs compare
28
+ // lower(column) against lower($1)/lower($2) — the `%` operator is
29
+ // case-SENSITIVE (unlike ILIKE, pg_trgm has no case-insensitive variant), so
30
+ // without the explicit lower() a query differing only in case would silently
31
+ // miss the fuzzy leg while the `sim` column (already lower()-folded) still
32
+ // scored it. Indexed by migration platform_identity_023_builder_search_trgm.sql
33
+ // — a GIN gin_trgm_ops index on lower(handle) and lower(display_name), which
34
+ // accelerates both legs since both now query that same expression.
35
+ // github_id never crosses the projection — handle is the address, exactly as
36
+ // every sibling public read in this module already treats it.
37
+ 'use strict';
38
+
39
+ const { pool: defaultPool } = require('../../src/module-api');
40
+ const { accountVisibleSql, rollupVisibleSql } = require('./account-visibility');
41
+
42
+ const DEFAULT_LIMIT = 20;
43
+ const MAX_LIMIT = 50;
44
+ // Deep OFFSET pagination on an unauthenticated route is a free scan-and-discard
45
+ // knob (?offset=999999999 still walks and throws away every matched row before
46
+ // the LIMIT) — capped so a caller can page a reasonable distance and no further.
47
+ const MAX_OFFSET = 1000;
48
+ // A 1-character query has no usable trigram and a near-unselective LIKE
49
+ // prefix, so it would materialize and sort a large share of the table before
50
+ // LIMIT on every anonymous request. 2 chars is the floor at which a trigram
51
+ // exists at all.
52
+ const MIN_QUERY_CHARS = 2;
53
+ const MAX_QUERY_CHARS = 100;
54
+ const MAX_DISCIPLINE_CHARS = 32; // mirrors upsertActivity's per-discipline cap
55
+
56
+ function clampLimit(limit) {
57
+ const n = parseInt(limit, 10);
58
+ if (!Number.isFinite(n) || n <= 0) return DEFAULT_LIMIT;
59
+ return Math.min(n, MAX_LIMIT);
60
+ }
61
+
62
+ function clampOffset(offset) {
63
+ const n = parseInt(offset, 10);
64
+ if (!Number.isFinite(n) || n < 0) return 0;
65
+ return Math.min(n, MAX_OFFSET);
66
+ }
67
+
68
+ // Escape LIKE's own wildcard characters so a searcher's literal % or _ can't
69
+ // turn a prefix match into an unbounded pattern.
70
+ function escapeLikePrefix(s) {
71
+ return s.replace(/[\\%_]/g, (c) => `\\${c}`);
72
+ }
73
+
74
+ // searchBuilders — the one read behind GET /community/search. `q` is required
75
+ // (too short/empty/oversized → no results, never a throw — the route
76
+ // validates and 400s before calling this; this stays defensive on its own).
77
+ // `discipline` is optional; '' means no filter. Returns
78
+ // { results, limit, offset } — `limit`/`offset` are the CLAMPED values
79
+ // actually used, so a caller (the route) never re-derives them to echo back.
80
+ // A result's `disciplines` is null for a hidden account or one with no
81
+ // rollup activity.
82
+ async function searchBuilders({ q, discipline, limit, offset, pool = defaultPool } = {}) {
83
+ const query = typeof q === 'string' ? q.trim() : '';
84
+ const lim = clampLimit(limit);
85
+ const off = clampOffset(offset);
86
+ if (query.length < MIN_QUERY_CHARS || query.length > MAX_QUERY_CHARS) {
87
+ return { results: [], limit: lim, offset: off };
88
+ }
89
+ const disc = typeof discipline === 'string' ? discipline.trim().slice(0, MAX_DISCIPLINE_CHARS) : '';
90
+
91
+ const { rows } = await pool.query(
92
+ `WITH matches AS (
93
+ SELECT pa.github_id, pa.handle, pa.github_login, pa.display_name, pa.avatar_url,
94
+ (lower(pa.handle) = lower($1)) AS exact_handle,
95
+ GREATEST(similarity(lower(pa.handle), lower($1)),
96
+ similarity(lower(COALESCE(pa.display_name, '')), lower($1))) AS sim
97
+ FROM platform_identity_accounts pa
98
+ WHERE ${accountVisibleSql('pa')}
99
+ AND (
100
+ lower(pa.handle) LIKE lower($2) ESCAPE '\\'
101
+ OR lower(pa.display_name) LIKE lower($2) ESCAPE '\\'
102
+ OR lower(pa.handle) % lower($1)
103
+ OR lower(pa.display_name) % lower($1)
104
+ )
105
+ ), disc AS (
106
+ -- Scoped to the name matches only (never the whole activity table),
107
+ -- which is strictly smaller — though 'matches' itself is bounded only
108
+ -- by MIN_QUERY_CHARS' selectivity, not by a hard cap, since the
109
+ -- discipline filter (below) needs every match's disciplines before
110
+ -- LIMIT can apply.
111
+ SELECT x.github_id, array_agg(DISTINCT x.d) AS disciplines
112
+ FROM (SELECT a.github_id, unnest(a.disciplines) AS d
113
+ FROM platform_identity_builder_project_activity a
114
+ WHERE a.github_id IN (SELECT github_id FROM matches)
115
+ AND EXISTS (
116
+ SELECT 1 FROM platform_identity_accounts pa2
117
+ WHERE pa2.github_id = a.github_id AND ${rollupVisibleSql('pa2')})) x
118
+ WHERE x.d IS NOT NULL
119
+ GROUP BY x.github_id
120
+ )
121
+ SELECT m.handle, m.github_login, m.display_name, m.avatar_url, d.disciplines
122
+ FROM matches m
123
+ LEFT JOIN disc d ON d.github_id = m.github_id
124
+ WHERE ($3::text = '' OR d.disciplines @> ARRAY[$3]::text[])
125
+ ORDER BY m.exact_handle DESC, m.sim DESC, m.handle
126
+ LIMIT $4 OFFSET $5`,
127
+ [query, `${escapeLikePrefix(query)}%`, disc, lim, off],
128
+ );
129
+ return {
130
+ results: rows.map((r) => ({
131
+ handle: r.handle,
132
+ github_login: r.github_login,
133
+ display_name: r.display_name,
134
+ avatar_url: r.avatar_url,
135
+ disciplines: Array.isArray(r.disciplines) ? r.disciplines : null,
136
+ })),
137
+ limit: lim,
138
+ offset: off,
139
+ };
140
+ }
141
+
142
+ module.exports = {
143
+ searchBuilders,
144
+ clampLimit,
145
+ clampOffset,
146
+ escapeLikePrefix,
147
+ DEFAULT_LIMIT,
148
+ MAX_LIMIT,
149
+ MAX_OFFSET,
150
+ MIN_QUERY_CHARS,
151
+ MAX_QUERY_CHARS,
152
+ MAX_DISCIPLINE_CHARS,
153
+ };
@@ -0,0 +1,46 @@
1
+ -- platform_identity_022_activity_snapshots.sql — the windowed data plane's ONE
2
+ -- new table (community spec D2, task 1002977): an append-only history of each
3
+ -- (project, builder)'s reported totals, one row per rollup report WHOSE TOTALS
4
+ -- CHANGED (a no-op re-report inserts nothing — upsertActivity skips it via
5
+ -- activity-snapshots.js:recordActivitySnapshot).
6
+ --
7
+ -- A window's value = latest totals (platform_identity_builder_project_activity)
8
+ -- minus the last snapshot at-or-before the window start, summed across projects;
9
+ -- missing baseline counts as zero (D2 option A, chosen over B/C — see the spec).
10
+ -- Reporting-time semantics, not earning-time: the honest, accepted V1 limitation.
11
+ --
12
+ -- Retention: nothing pruned here. A compaction pass (daily granularity after 90
13
+ -- days) is a later, volume-triggered task, not built now.
14
+ --
15
+ -- Additive + platform_identity_-namespaced (ADR 0083 §5). Idempotent.
16
+
17
+ BEGIN;
18
+
19
+ CREATE TABLE IF NOT EXISTS platform_identity_activity_snapshots (
20
+ id bigserial PRIMARY KEY,
21
+
22
+ -- The project (the sso_client that reported) + the builder (stable github id) —
23
+ -- the same pair platform_identity_builder_project_activity is keyed on.
24
+ client_id text NOT NULL,
25
+ github_id bigint NOT NULL,
26
+
27
+ -- The reported totals AT this point in time (a snapshot, not a delta).
28
+ credits integer NOT NULL DEFAULT 0,
29
+ tasks_shipped integer NOT NULL DEFAULT 0,
30
+ karma integer NOT NULL DEFAULT 0,
31
+
32
+ reported_at timestamptz NOT NULL DEFAULT now()
33
+ );
34
+
35
+ -- The windowed read's whole access path: "the last snapshot at-or-before a given
36
+ -- timestamp, for this (project, builder)". DESC so the common query (the most
37
+ -- recent snapshot <= window start) hits the front of the index; `id DESC`
38
+ -- matches activity-snapshots.js's own tiebreak, so its top-1 probe is a pure
39
+ -- index-order LIMIT 1 rather than relying on an incremental sort.
40
+ CREATE INDEX IF NOT EXISTS platform_identity_activity_snapshots_builder_time
41
+ ON platform_identity_activity_snapshots (client_id, github_id, reported_at DESC, id DESC);
42
+
43
+ COMMENT ON TABLE platform_identity_activity_snapshots IS
44
+ 'Append-only history of reported totals per (client_id, github_id), one row per rollup report whose totals changed (community spec D2, task 1002977). A leaderboard window computes latest minus the last snapshot at-or-before the window start; missing baseline counts as zero. Reporting-time semantics, not earning-time. Nothing pruned in V1 — a later, volume-triggered compaction task handles retention.';
45
+
46
+ COMMIT;
@@ -0,0 +1,40 @@
1
+ -- platform_identity_023_builder_search_trgm.sql — GIN trigram indexes for the
2
+ -- public builder search (D4, docs/specs/bongos-v1-community-tab.md; task
3
+ -- 1002979). builder-search.js's searchBuilders() queries
4
+ -- lower(handle) LIKE lower($2) OR lower(handle) % lower($1) (and the same
5
+ -- pair for display_name) against platform_identity_accounts on EVERY request
6
+ -- to an UNAUTHENTICATED, rate-limited-but-public route — an unindexed
7
+ -- sequential scan there is a cost every anonymous caller can trigger.
8
+ --
9
+ -- INDEXED ON THE EXPRESSION, NOT THE BARE COLUMN. Both query legs compare
10
+ -- lower(handle)/lower(display_name), so a plain gin_trgm_ops index on the raw
11
+ -- column would never be used — Postgres only matches a functional index
12
+ -- against the identical expression. One index per column then serves BOTH
13
+ -- legs (prefix LIKE and % fuzzy), because pg_trgm's GIN opclass supports LIKE
14
+ -- as well as the trigram similarity operators.
15
+ --
16
+ -- pg_trgm itself is already enabled repo-wide by migrations/103_search_chunks.sql
17
+ -- (a core migration, so it runs before any module migration) — nothing to
18
+ -- create here.
19
+ --
20
+ -- WHY NOT CONCURRENTLY. Same reasoning core_222 and modules/ideas/migrations/-
21
+ -- ideas_004_lineage_route_index.sql recorded for their own indexes: CREATE
22
+ -- INDEX CONCURRENTLY cannot run inside a transaction block, and this file is
23
+ -- wrapped BEGIN/COMMIT so it lands atomically. Acceptable here specifically
24
+ -- because platform_identity_accounts is a hub-only, human-signup-paced table
25
+ -- (one row per platform account) — the build is a light, one-time lock against
26
+ -- a small table, not a write-hot one.
27
+ --
28
+ -- Additive + platform_identity_-namespaced (ADR 0083 §5). Forward-safe: index
29
+ -- creation only, no column/type change, so the previous release runs
30
+ -- unmodified against this schema. Idempotent (IF NOT EXISTS).
31
+
32
+ BEGIN;
33
+
34
+ CREATE INDEX IF NOT EXISTS platform_identity_accounts_handle_trgm
35
+ ON platform_identity_accounts USING gin (lower(handle) gin_trgm_ops);
36
+
37
+ CREATE INDEX IF NOT EXISTS platform_identity_accounts_display_name_trgm
38
+ ON platform_identity_accounts USING gin (lower(display_name) gin_trgm_ops);
39
+
40
+ COMMIT;
@@ -10,7 +10,7 @@
10
10
  "maintainer": "Cloud Bongos maintainers",
11
11
  "maintenance": { "status": "core-maintained" },
12
12
  "contributes": {
13
- "routes": ["sso", "projects", "my-projects", "scouting", "profile", "public-profile", "connections"],
13
+ "routes": ["sso", "projects", "my-projects", "scouting", "profile", "public-profile", "connections", "community-search"],
14
14
  "migrations": true,
15
15
  "pollers": ["code-sweep", "visibility-pull"]
16
16
  },
@@ -24,6 +24,9 @@ const terms = require('./terms-acceptance');
24
24
  // reads the SAME rule as a ship link instead of the "different host" check it had —
25
25
  // see origin-containment.js for why it is downward only and why it lives there.
26
26
  const { urlWithinOrigin } = require('./origin-containment');
27
+ // The windowed data plane's append-only snapshot history (task 1002977, community
28
+ // spec D2) — one call from upsertActivity below; the mechanism itself lives there.
29
+ const { recordActivitySnapshot } = require('./activity-snapshots');
27
30
 
28
31
  const { pool: defaultPool } = api;
29
32
  const { ACCOUNT_VISIBILITY, accountVisibleSql, rollupVisibleSql, isRollupVisible, scoutingListableSql } = visibility;
@@ -677,6 +680,16 @@ async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped =
677
680
  // Postgres ARRAY literal, which the jsonb column would reject.
678
681
  [clientId, githubId, credits | 0, tasksShipped | 0, karma | 0, safeRank, disc, JSON.stringify(ships)],
679
682
  );
683
+ // Windowed data plane (task 1002977): one snapshot per report whose totals
684
+ // changed, skipped on a no-op re-report — see activity-snapshots.js.
685
+ // Best-effort, the application-echo.js precedent: descriptive analytics, never
686
+ // authority, so a fault here (e.g. migration 022 not yet applied on this
687
+ // instance) must never fail the rollup report that already committed above.
688
+ try {
689
+ await recordActivitySnapshot({ clientId, githubId, credits: credits | 0, tasksShipped: tasksShipped | 0, karma: karma | 0 }, { pool });
690
+ } catch (err) {
691
+ api.logger('platform-identity').error(`activity snapshot write failed (non-fatal): ${err && err.message}`);
692
+ }
680
693
  return rows[0];
681
694
  }
682
695
 
@@ -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 };
@@ -0,0 +1,68 @@
1
+ // modules/platform-identity/tests/activity-snapshots.mjs
2
+ //
3
+ // DB-free unit suite for the windowed data plane's append-only snapshot history
4
+ // (community spec D2, task 1002977). Recording-fake-pool style, matching
5
+ // tests/platform-identity.mjs: proves the SQL SHAPE (one statement, correct
6
+ // table/columns, bound params, the tiebreak) — a fake pool cannot execute the
7
+ // `WHERE NOT EXISTS` itself, so the skip-on-unchanged BEHAVIOUR is proved
8
+ // against real Postgres in tests/activity_snapshots_db.mjs instead.
9
+ //
10
+ // Run standalone: node modules/platform-identity/tests/activity-snapshots.mjs
11
+
12
+ import { strict as assert } from 'node:assert';
13
+ import { createRequire } from 'node:module';
14
+
15
+ const require = createRequire(import.meta.url);
16
+ const { recordActivitySnapshot } = require('../activity-snapshots.js');
17
+
18
+ let passed = 0, failed = 0;
19
+ async function ta(name, fn) {
20
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
21
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
22
+ }
23
+
24
+ // A recording fake pool: returns queued responses in order and records every call.
25
+ function mockPool(responses = []) {
26
+ const calls = [];
27
+ let i = 0;
28
+ return {
29
+ calls,
30
+ query: async (text, params) => {
31
+ calls.push({ text: String(text), params: params || [] });
32
+ const r = responses[i++];
33
+ return r || { rows: [], rowCount: 0 };
34
+ },
35
+ };
36
+ }
37
+
38
+ console.log('recordActivitySnapshot (community spec D2, task 1002977):');
39
+
40
+ await ta('one statement: a CTE for the last snapshot, guarding a conditional INSERT', async () => {
41
+ const pool = mockPool([{ rows: [{ id: '1', reported_at: 'T1' }] }]);
42
+ const out = await recordActivitySnapshot({ clientId: 'c1', githubId: 42, credits: 100, tasksShipped: 5, karma: 3 }, { pool });
43
+ assert.equal(pool.calls.length, 1, 'one round trip, not a read then a write');
44
+ const c = pool.calls[0];
45
+ assert.match(c.text, /WITH last AS/);
46
+ assert.match(c.text, /FROM platform_identity_activity_snapshots/);
47
+ assert.match(c.text, /ORDER BY reported_at DESC, id DESC LIMIT 1/, 'a deterministic tiebreak on an identical reported_at');
48
+ assert.match(c.text, /INSERT INTO platform_identity_activity_snapshots/);
49
+ assert.match(c.text, /WHERE NOT EXISTS \(SELECT 1 FROM last WHERE credits = \$3 AND tasks_shipped = \$4 AND karma = \$5\)/);
50
+ assert.deepEqual(c.params, ['c1', 42, 100, 5, 3]);
51
+ assert.deepEqual(out, { id: '1', reported_at: 'T1' });
52
+ });
53
+
54
+ await ta('a skipped report (the fake pool answers no rows, as a real WHERE NOT EXISTS would on a match) returns null', async () => {
55
+ const pool = mockPool([{ rows: [] }]);
56
+ const out = await recordActivitySnapshot({ clientId: 'c1', githubId: 42, credits: 100, tasksShipped: 5, karma: 3 }, { pool });
57
+ assert.equal(pool.calls.length, 1);
58
+ assert.equal(out, null);
59
+ });
60
+
61
+ await ta('missing totals default to zero, like upsertActivity\'s own defaults', async () => {
62
+ const pool = mockPool([{ rows: [{ id: '3', reported_at: 'T3' }] }]);
63
+ await recordActivitySnapshot({ clientId: 'c1', githubId: 42 }, { pool });
64
+ assert.deepEqual(pool.calls[0].params, ['c1', 42, 0, 0, 0]);
65
+ });
66
+
67
+ console.log(`\n${passed} passed, ${failed} failed`);
68
+ if (failed > 0) process.exit(1);
@@ -596,6 +596,27 @@ await ta('upsertActivity keeps a legitimate core rank key', async () => {
596
596
  assert.equal(pool.calls[0].params[5], 'archon');
597
597
  });
598
598
 
599
+ await ta('upsertActivity also appends to the snapshot history (task 1002977, community spec D2) — deleting that call would leave this red', async () => {
600
+ const pool = mockPool([{ rows: [{ id: '1' }] }, { rows: [{ id: 's1', reported_at: 'T' }] }]);
601
+ await P.upsertActivity({ clientId: 'c1', githubId: 42, credits: 100, tasksShipped: 5, karma: 3 }, { pool });
602
+ assert.equal(pool.calls.length, 2, 'the rollup upsert, then the snapshot statement');
603
+ assert.match(pool.calls[1].text, /INSERT INTO platform_identity_activity_snapshots/);
604
+ assert.deepEqual(pool.calls[1].params, ['c1', 42, 100, 5, 3]);
605
+ });
606
+
607
+ await ta('upsertActivity survives a snapshot-write failure (best-effort — the application-echo.js precedent): the rollup report still succeeds', async () => {
608
+ const pool = {
609
+ calls: [],
610
+ query: async (text, params) => {
611
+ pool.calls.push({ text: String(text), params });
612
+ if (/INSERT INTO platform_identity_activity_snapshots/.test(text)) throw new Error('relation "platform_identity_activity_snapshots" does not exist');
613
+ return { rows: [{ id: '1' }] };
614
+ },
615
+ };
616
+ const out = await P.upsertActivity({ clientId: 'c1', githubId: 42, credits: 100, tasksShipped: 5, karma: 3 }, { pool });
617
+ assert.equal(out.id, '1', 'the rollup upsert result still returns despite the snapshot write throwing');
618
+ });
619
+
599
620
  await ta('getMembership reads one (client, builder) row — the rollup membership requirement', async () => {
600
621
  const row = { id: '9', client_id: 'c1', github_id: '42', membership_kind: 'member', signin_verified_at: 'T' };
601
622
  const pool = mockPool([{ rows: [row] }]);
@@ -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
  },