@bongos/core 1.19.430 → 1.19.432

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 (41) hide show
  1. package/.bongos-core.json +64 -44
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +3 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0215-the-recruiter-sliver-is-a-ceiling-not-a-step.md +116 -0
  8. package/docs/adr/0216-a-fake-pool-interpreter-must-fail-loudly-not-silently-ignore.md +102 -0
  9. package/docs/adr/README.md +2 -0
  10. package/docs/api/openapi.json +64 -3
  11. package/docs/api-reference.md +3 -2
  12. package/docs/copy-inventory.md +48 -48
  13. package/docs/copy-registry.json +57 -57
  14. package/docs/file-map.md +1 -0
  15. package/docs/module-api-changelog.md +4 -0
  16. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  17. package/modules/hall-ui/public/settings.html +1 -1
  18. package/modules/hall-ui/public/settings.js +17 -8
  19. package/modules/platform-identity/account-visibility.js +47 -0
  20. package/modules/platform-identity/platform-identity.js +4 -4
  21. package/modules/platform-identity/recruiter-sliver.js +197 -0
  22. package/modules/platform-identity/routes/scouting.js +58 -0
  23. package/package-lock.json +2 -2
  24. package/package.json +1 -1
  25. package/src/module-api.js +1 -1
  26. package/tests/account_privacy_flags.mjs +115 -9
  27. package/tests/application_echo.mjs +43 -9
  28. package/tests/application_lifecycle.mjs +18 -7
  29. package/tests/connections_ownership.mjs +44 -4
  30. package/tests/effective_visibility_predicate.mjs +137 -50
  31. package/tests/government_require_permission.mjs +22 -0
  32. package/tests/handle_machinery.mjs +15 -1
  33. package/tests/hub_profile_leak.mjs +18 -4
  34. package/tests/own_session_revoke.mjs +77 -9
  35. package/tests/profile_connections_ui.mjs +49 -10
  36. package/tests/profile_privacy_owner_view.mjs +68 -37
  37. package/tests/profile_rollup_consent.mjs +46 -14
  38. package/tests/profile_route.mjs +6 -0
  39. package/tests/recruiter_sliver_boundary.mjs +841 -0
  40. package/tests/scouting_owner_access_and_reach.mjs +175 -39
  41. package/tests/scouting_rollup_fanout.mjs +15 -4
@@ -0,0 +1,197 @@
1
+ // modules/platform-identity/recruiter-sliver.js — THE PRE-ACCEPT BOUNDARY: what
2
+ // a recruiter may see of a builder BEFORE that builder has accepted anything
3
+ // (privacy spec D3, owner-signed 2026-08-16; ADR 0215, task 1002968).
4
+ //
5
+ // WHAT IT IS. One bounded projection — handle, display name, avatar, the
6
+ // disciplines union, and a COARSE "active in the last 30 days" boolean — and
7
+ // nothing else. It exists so a recruiter has enough to decide whether to reach
8
+ // out, and so a builder who has decided nothing yet gives up no more than that.
9
+ // Under `hide_stats` the last two drop (they are rollup-derived) and the sliver
10
+ // is NAME-ONLY, exactly as the Settings toggle promises.
11
+ //
12
+ // NEVER IN IT, and each absence is load-bearing rather than an oversight:
13
+ // credits / works shipped / karma — the numbers are the post-accept record.
14
+ // project names / project count — "the aggregate is not the leak, the name
15
+ // is" (ADR 0171 D6), and a count beside a
16
+ // hidden total is a leak by subtraction,
17
+ // so there is no count either.
18
+ // last_active as a TIMESTAMP — the signal is coarse ON PURPOSE; a date
19
+ // is a behavioural trace, a boolean is a
20
+ // yes/no. Only the boolean crosses.
21
+ // github_id / github_login as FIELDS — neither is a key of the sliver. Note
22
+ // the LIMIT, stated rather than glossed:
23
+ // the avatar D3 grants is GitHub's own URL
24
+ // and embeds the account id verbatim, so
25
+ // granting the avatar grants the id inside
26
+ // it. Recorded in ADR 0215's honest limits
27
+ // and asserted precisely in the proof (the
28
+ // id may ride the avatar and nothing else).
29
+ // a profile-page link — spec D3 forbids it outright. The handle
30
+ // is an ADDRESS, not a door: for a private
31
+ // account GET /profiles/:handle joins
32
+ // accountVisibleSql and answers 404, so
33
+ // handing a recruiter the handle hands
34
+ // them no page. That is the whole meaning
35
+ // of "pre-accept": the sliver is the
36
+ // ceiling, not a step toward the rollup.
37
+ //
38
+ // WHY ITS OWN FILE. The spec says "a port beside getPublicProfileExtras", and
39
+ // platform-identity.js sits ON the 1500-line ratchet with no headroom — the
40
+ // account-visibility.js / scouting-authz.js / application-echo.js precedent.
41
+ // Beside in the module, not in the file.
42
+ //
43
+ // WHO MAY READ IT is not this file's question — routes/scouting.js gates the one
44
+ // surface, on the same door the recruiting directory uses. WHO MAY APPEAR IN IT
45
+ // is enforced HERE, in the query, by the shared recruiterSliverVisibleSql:
46
+ // consent (profile_state='active' — a provisional account is never
47
+ // recruiter-visible) AND recruiting reach. Deliberately NOT the existence axis:
48
+ // the private, explicitly-discoverable account is the row D3 exists to serve,
49
+ // and reach for a private account is an opt-IN the owner had to set by hand.
50
+ //
51
+ // NOT AUTHORITY (ADR 0016). Appearing here confers nothing on the builder and
52
+ // buys the recruiter nothing but a name to write to; nothing in this repo reads
53
+ // the sliver to decide anything. The rollup it is derived from is descriptive
54
+ // analytics, and a projection of descriptive data is still descriptive.
55
+ //
56
+ // Fail-closed everywhere: an absent row, an unreachable account, a non-numeric
57
+ // principal and an unusable handle all answer the SAME null, so a caller cannot
58
+ // tell "no such builder" from "not discoverable" (the ADR 0171 D4 rule).
59
+ //
60
+ // tests/recruiter_sliver_boundary.mjs is the proof — a whitelist over the exact
61
+ // key set, planted sentinels asserted absent from the whole response body, and
62
+ // the 404 that shows the boundary holds.
63
+ 'use strict';
64
+
65
+ const pi = require('./platform-identity');
66
+ const { recruiterSliverVisibleSql } = require('./account-visibility');
67
+
68
+ // The complete, closed key set of a FULL sliver, in the order D3 names them.
69
+ // Exported because the proof asserts against this list rather than a copy of it:
70
+ // a key added to the projection and quietly added to the test's own expectation
71
+ // is the failure mode a whitelist is supposed to make impossible.
72
+ const SLIVER_KEYS = ['handle', 'display_name', 'avatar_url', 'disciplines', 'active_recently'];
73
+
74
+ // The key set that survives `hide_stats`. The other two are rollup-derived, so
75
+ // hiding the cross-project record hides them too — and they are ABSENT, not
76
+ // null: the sliver does not carry an empty box where a number used to be.
77
+ const SLIVER_KEYS_HIDDEN = ['handle', 'display_name', 'avatar_url'];
78
+
79
+ // "Recently" is 30 days (spec D3). A named constant because it is a privacy
80
+ // parameter, not a tuning knob — widening it widens what the boolean discloses.
81
+ const ACTIVE_WINDOW_DAYS = 30;
82
+
83
+ // The two columns a sliver may be addressed BY, as a closed vocabulary. The
84
+ // lookup key is the one thing interpolated into the statement below, so it may
85
+ // never come from a caller's string: a route passes one of these constants or
86
+ // nothing at all (the account-visibility.js alias-guard posture).
87
+ const BY_GITHUB_ID = 'github_id';
88
+ const BY_HANDLE = 'lower(handle)';
89
+ const ADDRESSABLE_BY = [BY_GITHUB_ID, BY_HANDLE];
90
+
91
+ // readSliverAccount(by, value, { pool }) — the ONE account read behind both
92
+ // entry points: the identity columns of an account that passes the shared
93
+ // pre-accept predicate, or null. `hide_stats` is fetched because it decides the
94
+ // SHAPE of the answer; it never crosses into one.
95
+ async function readSliverAccount(by, value, { pool }) {
96
+ if (!ADDRESSABLE_BY.includes(by)) {
97
+ throw new Error('readSliverAccount: unknown lookup column');
98
+ }
99
+ const { rows } = await pool.query(
100
+ `SELECT github_id, handle, display_name, avatar_url, hide_stats
101
+ FROM platform_identity_accounts
102
+ WHERE ${recruiterSliverVisibleSql()} AND ${by} = $1
103
+ LIMIT 1`,
104
+ [value],
105
+ );
106
+ return rows[0] || null;
107
+ }
108
+
109
+ // readSliverActivity(githubId, { pool }) — the rollup-derived half: the
110
+ // disciplines union across every project, and whether ANY of them saw activity
111
+ // inside the window. Aggregated over the UN-FANNED activity table with the
112
+ // disciplines union computed separately, mirroring getRollupForBuilder (the
113
+ // pre-GROUP-BY unnest fan-out was bug task 1002911).
114
+ //
115
+ // The comparison happens in SQL and only its BOOLEAN comes back — the timestamp
116
+ // never enters this process, so no later refactor can accidentally serialize it.
117
+ // A builder with no activity rows at all yields NULL, coerced to false by the
118
+ // caller: "not known to be active" and "not active" are the same answer here.
119
+ async function readSliverActivity(githubId, { pool }) {
120
+ const { rows } = await pool.query(
121
+ `WITH act AS (
122
+ SELECT max(a.last_active) AS last_active
123
+ FROM platform_identity_builder_project_activity a
124
+ WHERE a.github_id = $1
125
+ ), disc AS (
126
+ SELECT array_agg(DISTINCT x.d) AS disciplines
127
+ FROM (SELECT unnest(a.disciplines) AS d
128
+ FROM platform_identity_builder_project_activity a
129
+ WHERE a.github_id = $1) x
130
+ WHERE x.d IS NOT NULL
131
+ )
132
+ SELECT (act.last_active > now() - make_interval(days => $2::int)) AS active_recently,
133
+ disc.disciplines
134
+ FROM act LEFT JOIN disc ON true`,
135
+ [githubId, ACTIVE_WINDOW_DAYS],
136
+ );
137
+ return rows[0] || null;
138
+ }
139
+
140
+ // projectSliver(account, activity) — the WHITELIST, built literally. Every key
141
+ // is written out by hand; no row is ever spread, so a column added to either
142
+ // query cannot ride along into a response. `activity` is null for a hidden
143
+ // account, and the two rollup-derived keys are then absent entirely.
144
+ function projectSliver(account, activity) {
145
+ const sliver = {
146
+ handle: account.handle ?? null,
147
+ display_name: account.display_name ?? null,
148
+ avatar_url: account.avatar_url ?? null,
149
+ };
150
+ if (!activity) return sliver;
151
+ return {
152
+ ...sliver,
153
+ disciplines: Array.isArray(activity.disciplines) ? activity.disciplines : [],
154
+ active_recently: activity.active_recently === true,
155
+ };
156
+ }
157
+
158
+ // sliverFor(account, { pool }) — shared tail of both entry points: decide the
159
+ // shape, and for a hidden account DO NOT ISSUE the activity read at all. Not
160
+ // merely dropping the fields after fetching them is the point — the cheapest way
161
+ // for a future edit to leak a number is for the number to already be in hand.
162
+ async function sliverFor(account, { pool }) {
163
+ if (!account) return null;
164
+ if (account.hide_stats) return projectSliver(account, null);
165
+ return projectSliver(account, await readSliverActivity(account.github_id, { pool }));
166
+ }
167
+
168
+ // getRecruiterSliver(githubId) — THE port the spec names (D3), keyed by the hub
169
+ // account id. Null unless the account consents and is recruiter-reachable.
170
+ // A non-numeric principal (core's 'system:bfg' shape) answers null rather than
171
+ // throwing a bigint cast mid-request, like every other read in this module.
172
+ async function getRecruiterSliver(githubId, { pool } = {}) {
173
+ if (!pi.isNumericGithubId(githubId)) return null;
174
+ return sliverFor(await readSliverAccount(BY_GITHUB_ID, String(githubId), { pool }), { pool });
175
+ }
176
+
177
+ // getRecruiterSliverByHandle(handle) — the same answer, addressed the way the
178
+ // platform addresses a builder publicly (spec Decision 3). An unusable handle
179
+ // answers null without a query.
180
+ //
181
+ // A PREVIOUS handle is deliberately NOT resolved here, unlike resolveHandle: the
182
+ // public page answers 301 so a shared link survives a rename, and a recruiting
183
+ // read has no shared links to keep working. Answering 404 instead of a redirect
184
+ // also keeps this surface from confirming that a rename happened.
185
+ async function getRecruiterSliverByHandle(handle, { pool } = {}) {
186
+ const norm = pi.normalizeHandle(handle);
187
+ if (!norm) return null;
188
+ return sliverFor(await readSliverAccount(BY_HANDLE, norm, { pool }), { pool });
189
+ }
190
+
191
+ module.exports = {
192
+ SLIVER_KEYS,
193
+ SLIVER_KEYS_HIDDEN,
194
+ ACTIVE_WINDOW_DAYS,
195
+ getRecruiterSliver,
196
+ getRecruiterSliverByHandle,
197
+ };
@@ -16,6 +16,19 @@
16
16
  // surface honors both the hide flag and the recruiting opt-out by construction,
17
17
  // and widening WHO may read it could not widen WHAT is shown.
18
18
  //
19
+ // TWO READS, TWO AUDIENCES — and the difference is the point (ADR 0215 D4, task
20
+ // 1002968). `GET /scouting` is the DIRECTORY: the full rollup for builders who
21
+ // are publicly visible, unhidden and reachable, read by a `project.curate` holder
22
+ // OR a project owner. `GET /scouting/:handle` is the PRE-ACCEPT SLIVER: a bounded
23
+ // projection (name, avatar, disciplines, a coarse activity boolean) of ONE
24
+ // builder — including the private-but-discoverable account the directory
25
+ // deliberately omits — and it is gated at the ARCHON floor, because it is the
26
+ // first surface here that discloses a private account and privacy spec D3 pins it
27
+ // to an archon-floor atom that has not been minted. Sharing one gate would have
28
+ // been tidier; it would also have widened an owner-signed privacy decision by
29
+ // implementation convenience. The blocker on the route below asks the owner
30
+ // whether the wider recruiting audience is authorized.
31
+ //
19
32
  // Reaches core only through the doorway (ADR 0083).
20
33
  'use strict';
21
34
 
@@ -23,6 +36,7 @@ const express = require('express');
23
36
  const api = require('../../../src/module-api');
24
37
  const pi = require('../platform-identity');
25
38
  const scoutingAuthz = require('../scouting-authz');
39
+ const recruiterSliver = require('../recruiter-sliver');
26
40
 
27
41
  const { pool, requireBuilder, parsePagination } = api;
28
42
 
@@ -64,6 +78,10 @@ module.exports = function scoutingRoutes() {
64
78
  const { limit } = parsePagination(req, { defaultLimit: 100, maxLimit: 500 });
65
79
  try {
66
80
  const builders = await pi.listScoutingRollup({ limit, pool });
81
+ // Same rollup-derived data as the sliver, so the same rule: hide and the
82
+ // recruiting opt-out must beat any cache (ADR 0171 D5). This route
83
+ // predates that posture and was the only rollup read still missing it.
84
+ res.set('Cache-Control', 'no-store');
67
85
  res.json({ builders });
68
86
  } catch (err) {
69
87
  log.error(`GET /scouting failed: ${err && err.message}`);
@@ -72,5 +90,45 @@ module.exports = function scoutingRoutes() {
72
90
  },
73
91
  );
74
92
 
93
+ // rank: archon — the PRE-ACCEPT SLIVER for ONE builder (privacy spec D3, ADR
94
+ // 0215 D4, task 1002968). This is the FIRST surface on the platform that
95
+ // discloses a PRIVATE account, and its audience is deliberately NOT the
96
+ // directory's.
97
+ //
98
+ // WHY A RANK GATE AND NOT THE RECRUITING DOOR ABOVE. Spec D3 gates this read on
99
+ // `project.recruit`, an atom whose declared shape is FLOOR ARCHON, widenable
100
+ // per instance (spec D1). That atom does not exist — ADR 0201 D3 and ADR 0210
101
+ // both refuse to mint it outside the project-admin-console wave that defines it
102
+ // (spike 1002786 R3), and this task does not either. The directory's door
103
+ // (`project.curate` at metic, OR any project owner — ADR 0210 D2) is WIDER than
104
+ // the signed floor on both halves, so adopting it here would widen an
105
+ // owner-signed privacy decision by implementation convenience. Until the atom
106
+ // lands, the honest reading of D3 is its floor, held by rank. Whether the wider
107
+ // recruiting audience is authorized for this surface is the owner's call and is
108
+ // filed as a blocker, NOT resolved here; when it is answered, this one line
109
+ // moves and nothing else does.
110
+ //
111
+ // 404, never 403, for a builder who is not recruiter-reachable: a 403 would
112
+ // confirm the handle exists, which is the disclosure the opt-out removes. The
113
+ // sliver's own predicate does the deciding, inside the module.
114
+ router.get(
115
+ '/scouting/:handle',
116
+ requireBuilder,
117
+ api.requireRank('archon'),
118
+ async (req, res) => {
119
+ try {
120
+ const sliver = await recruiterSliver.getRecruiterSliverByHandle(req.params.handle, { pool });
121
+ // The rollup half is live-derived and hide must beat any cache
122
+ // (ADR 0171 D5) — the /profiles/:handle posture, for the same reason.
123
+ res.set('Cache-Control', 'no-store');
124
+ if (!sliver) return res.fail('builder_not_found', 404);
125
+ res.json({ sliver });
126
+ } catch (err) {
127
+ log.error(`GET /scouting/:handle failed: ${err && err.message}`);
128
+ res.fail('sliver_read_failed', { status: 500, message: 'internal error' });
129
+ }
130
+ },
131
+ );
132
+
75
133
  return router;
76
134
  };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.430",
3
+ "version": "1.19.432",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.430",
9
+ "version": "1.19.432",
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.430",
3
+ "version": "1.19.432",
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
@@ -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.430'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
36
+ const CORE_VERSION = '1.19.432'; // 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');
@@ -32,6 +32,12 @@ import { readFileSync } from 'node:fs';
32
32
  import { fileURLToPath } from 'node:url';
33
33
  import { dirname, join } from 'node:path';
34
34
 
35
+ // The sliver's own key lists — the copy guard below is written against THESE,
36
+ // not against a transcription of them, so a sixth granted field cannot be added
37
+ // without the Settings copy being made to name it (privacy spec D3: the toggle's
38
+ // copy "must state exactly this grant").
39
+ import { SLIVER_KEYS, SLIVER_KEYS_HIDDEN } from '../modules/platform-identity/recruiter-sliver.js';
40
+
35
41
  const require = createRequire(import.meta.url);
36
42
  const express = require('express');
37
43
 
@@ -173,6 +179,51 @@ test('setAccountPrivacy accepts an explicit null discoverable — the reset to "
173
179
  assert.equal(calls[0].params[7], true, 'and it counts as sent');
174
180
  });
175
181
 
182
+ /**
183
+ * Resolve what the UPDATE branch would store in `recruiter_discoverable`, from
184
+ * the statement's OWN text — the WHERE-derived-fake principle applied to a CASE.
185
+ * Each arm is recognised, and an arm this reader cannot see simply is not
186
+ * modelled, so deleting one from the SQL genuinely changes the answer here.
187
+ */
188
+ function resolveStoredReach(sql, params, stored) {
189
+ const from = sql.indexOf('recruiter_discoverable = CASE');
190
+ assert.ok(from >= 0, 'the reach column lost its CASE entirely');
191
+ const body = sql.slice(from, sql.indexOf('END', from));
192
+ const [visibilityWritten, reachWritten, setVisibility, setReach] = params.slice(4, 8);
193
+ assert.match(body, /ELSE platform_identity_accounts\.recruiter_discoverable/,
194
+ 'the CASE must still fall through to the STORED value — without the ELSE every PATCH would clear the flag');
195
+ if (/WHEN \$8::boolean THEN EXCLUDED\.recruiter_discoverable/.test(body) && setReach) return reachWritten;
196
+ if (/WHEN \$7::boolean AND EXCLUDED\.account_visibility = 'private' THEN NULL/.test(body)
197
+ && setVisibility && visibilityWritten === 'private') return null;
198
+ return stored;
199
+ }
200
+
201
+ test('GOING PRIVATE re-arms the reach default, so a consent given while public is not silently reused', async () => {
202
+ // THE DEFECT THIS CLOSES (found in adversarial review of task 1002968). A
203
+ // public builder toggles "Discoverable by recruiters" off and on again: the
204
+ // column is now an explicit `true`, not NULL. They later set the profile to
205
+ // Private, reading a row that promises the page "answers not found to every
206
+ // visitor". That stale `true` used to be INERT — every recruiting surface
207
+ // floored on the existence axis. The D3 sliver does not, by design, so it
208
+ // would have served them under a consent they gave to a different question.
209
+ //
210
+ // Each row is resolved from the statement's own CASE, so removing the new arm
211
+ // makes the first row answer `true` and this test red.
212
+ const cases = [
213
+ ['private, reach unnamed → RESET', { account_visibility: 'private' }, true, null],
214
+ ['private, reach named true → the owner re-consented, so it stands', { account_visibility: 'private', recruiter_discoverable: true }, true, true],
215
+ ['private, reach named false → explicit off', { account_visibility: 'private', recruiter_discoverable: false }, true, false],
216
+ ['public, reach unnamed → untouched', { account_visibility: 'public' }, true, true],
217
+ ['reach alone, no visibility change → untouched', { recruiter_discoverable: false }, true, false],
218
+ ['private on an account that never opted in → still the default', { account_visibility: 'private' }, null, null],
219
+ ];
220
+ for (const [name, fields, stored, want] of cases) {
221
+ expect({ rows: [row({ recruiter_discoverable: want })] });
222
+ await pi.setAccountPrivacy({ githubId: SESSION.github_id, fields }, { pool });
223
+ assert.equal(resolveStoredReach(calls[0].text, calls[0].params, stored), want, name);
224
+ }
225
+ });
226
+
176
227
  test('setAccountPrivacy refuses a value outside the model, before any query', async () => {
177
228
  expect();
178
229
  assert.equal((await pi.setAccountPrivacy(
@@ -271,15 +322,45 @@ test('neither flag crosses a PUBLIC response — the /u/<handle> read stays a wh
271
322
 
272
323
  // ---- 5. the settings copy states the grant ---------------------------------
273
324
 
274
- test('the discoverable toggle names EXACTLY what it grants — the D3 sliver, name-only when hidden', () => {
325
+ // How each granted FIELD has to read to a person. Keyed by the module's export,
326
+ // so this map is the place a sixth key must be described before it can ship —
327
+ // and the "every key is described" assertion below is what makes that binding.
328
+ const GRANT_WORDS = {
329
+ handle: /your name/,
330
+ display_name: /your name/,
331
+ avatar_url: /your picture/,
332
+ disciplines: /your disciplines/,
333
+ active_recently: /active in the last 30 days/,
334
+ };
335
+
336
+ test('the discoverable toggle names EXACTLY what it grants — checked against SLIVER_KEYS itself', () => {
275
337
  const js = readFileSync(join(ROOT, 'modules/hall-ui/public/settings.js'), 'utf8');
276
338
  const html = readFileSync(join(ROOT, 'modules/hall-ui/public/settings.html'), 'utf8');
277
- assert.match(js, /your name, your disciplines, and whether you have been active in the last 30 days/,
278
- 'the sliver is name + disciplines + a coarse activity signal — spelled out, not implied');
339
+ // Privacy spec D3 makes this a MUST: "The discoverable toggle's settings copy
340
+ // must state exactly this grant." It was checked against three transcribed
341
+ // phrases, and the transcription had gone stale in the direction that matters:
342
+ // the copy said the hidden sliver was "your name and nothing else" while
343
+ // SLIVER_KEYS_HIDDEN also ships avatar_url, and the shown branch never named
344
+ // the picture at all. A user was being told something false about their own
345
+ // privacy. It is now checked against the shipped key lists.
346
+ const shown = /: 'They will see ([^']+)'/.exec(js);
347
+ const hidden = /\? 'Because your cross-project record is hidden, ([^']+)'/.exec(js);
348
+ assert.ok(shown && hidden, 'both branches of the grant string must still be findable');
349
+
350
+ for (const key of SLIVER_KEYS) {
351
+ assert.ok(GRANT_WORDS[key], `SLIVER_KEYS grew "${key}" and nothing here says how to describe it — spec D3 says the copy must state the grant, so a new granted field cannot ship undescribed`);
352
+ assert.match(shown[1], GRANT_WORDS[key], `the toggle must name "${key}" — it is in the sliver a recruiter receives`);
353
+ }
354
+ for (const key of SLIVER_KEYS_HIDDEN) {
355
+ assert.match(hidden[1], GRANT_WORDS[key], `hidden, the sliver still ships "${key}", so the name-only copy must still name it`);
356
+ }
357
+ for (const key of SLIVER_KEYS.filter((k) => !SLIVER_KEYS_HIDDEN.includes(k))) {
358
+ assert.doesNotMatch(hidden[1], GRANT_WORDS[key], `"${key}" drops under hide_stats, so the copy must not keep promising it`);
359
+ }
279
360
  assert.match(js, /never your credits, works shipped, karma, or the projects you build on/,
280
361
  'and the copy says what it does NOT grant');
281
- assert.match(js, /cross-project record is hidden, they will see your name and nothing else/,
282
- 'hide_stats shrinks the grant to name-only, because the rest is rollup-derived');
362
+ assert.match(js, /the one thing a recruiter can see while your profile page is private/,
363
+ 'a private account is told the sliver is the exception to "not found to every visitor" — the surprising half of D2/D3');
283
364
  assert.match(html, /<section[^>]*id="privacy-scroll"[^>]*hidden>/,
284
365
  'the section ships hidden — it reveals only where the hub module answers');
285
366
  });
@@ -304,13 +385,38 @@ test('each toggle claims exactly what is enforced — and BOTH are now in force'
304
385
  'recruiting reach is enforced now (task 1002290), so the row promises it in the present tense');
305
386
  assert.doesNotMatch(js, /will be able to find you/,
306
387
  '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');
388
+ // WIDENED BY TASK 1002968, on the same rule that narrowed it. 1002290 cut this
389
+ // to "the People directory" because the D3 sliver was unbuilt and no guard here
390
+ // could hold a wider claim. The sliver is now built and gated, so BOTH
391
+ // recruiting reads join the reach axis and the row may name both — and the
392
+ // wider claim is held by the guard below rather than by this sentence.
393
+ assert.match(js, /removes you from both recruiting surfaces/,
394
+ 'the row names both surfaces that read the flag today — a promise you can go and check');
309
395
  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');
396
+ 'still never an OPEN-ENDED promise: "both" is a claim a guard can hold, "every" is not — a third '
397
+ + 'recruiting read would make it false with nothing to notice');
312
398
  assert.match(html, /profile page setting applies now[\s\S]*Recruiter discoverability applies now too/,
313
399
  'the section note now says both are live, rather than splitting them');
314
400
  assert.doesNotMatch(html, /starts applying as the recruiting surfaces land/,
315
401
  'the "recorded and waiting" note must not outlive the surface that started reading it');
316
402
  });
403
+
404
+ test('the recruiting surface really is exactly two reads, and each one joins REACH', () => {
405
+ // This is what makes the widened copy above checkable rather than a slogan.
406
+ // Both halves are enumerated from source: a third route on the recruiting
407
+ // surface, or either reader dropping the reach axis, reds here — which is the
408
+ // signal that the toggle's promise has stopped being true.
409
+ const route = readFileSync(join(ROOT, 'modules/platform-identity/routes/scouting.js'), 'utf8');
410
+ const mounted = route.match(/router\.get\(\s*\n?\s*'([^']+)'/g) || [];
411
+ assert.equal(mounted.length, 2,
412
+ `the recruiting surface mounts ${mounted.length} route(s), not 2 — the Settings copy promises "both recruiting surfaces" and can no longer name them`);
413
+ assert.match(route, /'\/scouting'/, 'the directory');
414
+ assert.match(route, /'\/scouting\/:handle'/, 'and the pre-accept sliver');
415
+
416
+ const av = readFileSync(join(ROOT, 'modules/platform-identity/account-visibility.js'), 'utf8');
417
+ for (const composed of ['scoutingListableSql', 'recruiterSliverVisibleSql']) {
418
+ const body = av.slice(av.indexOf(`function ${composed}(`));
419
+ assert.match(body.slice(0, body.indexOf('\n}')), /recruiterReachSql\(alias\)/,
420
+ `${composed} stopped composing the reach axis — opting out would no longer empty that surface`);
421
+ }
422
+ });
@@ -111,9 +111,18 @@ function seed() {
111
111
  { github_id: ME, client_id: 'proj-joined', applied_at: now - 5 * DAY },
112
112
  { github_id: ME, client_id: 'proj-stale', applied_at: now - 40 * DAY },
113
113
  { github_id: 9999, client_id: 'proj-someone-elses', applied_at: now - DAY },
114
+ // Applied to a project that has since INVITED them — a pending membership,
115
+ // which is not building on it. The echo must survive.
116
+ { github_id: ME, client_id: 'proj-invited', applied_at: now - 3 * DAY },
114
117
  ];
115
118
  memberships = [
116
119
  { client_id: 'proj-joined', membership_kind: 'builder', joined_at: now - 4 * DAY, last_active: now, project_name: 'Joined', project_origin: 'https://joined.example', owner_github_id: null },
120
+ // The row `listMembershipsForAccount`'s `membership_kind <> 'pending'` exists
121
+ // to exclude. Without it a pending INVITE would read as membership, and the
122
+ // card would flip to "you build here" for a project the person never joined
123
+ // — so this fixture is what makes that term observable rather than merely
124
+ // modelled (task 1003354).
125
+ { client_id: 'proj-invited', membership_kind: 'pending', joined_at: now - DAY, last_active: null, project_name: 'Invited', project_origin: 'https://invited.example', owner_github_id: null },
117
126
  ];
118
127
  echoFails = false;
119
128
  statements = [];
@@ -140,6 +149,11 @@ function predicateFor(rawWhere, params) {
140
149
  if ((m = /\bclient_id = \$(\d+)/.exec(where))) {
141
150
  const v = p(m[1]); tests.push((r) => r.client_id === v); eat();
142
151
  }
152
+ // listMembershipsForAccount's pending exclusion. Modelled rather than stripped
153
+ // before interpreting, so deleting it from the real query changes this answer.
154
+ if ((m = /\bm\.membership_kind <> 'pending'/.exec(where))) {
155
+ tests.push((r) => r.membership_kind !== 'pending'); eat();
156
+ }
143
157
 
144
158
  for (const ph of rawWhere.matchAll(/\$(\d+)/g)) {
145
159
  if (!consumed.has(ph[1])) {
@@ -155,12 +169,16 @@ function predicateFor(rawWhere, params) {
155
169
  // had already hidden one real filter, so the residual is swept too: this is
156
170
  // what makes "refuses a predicate it cannot read" true of ALL predicates
157
171
  // rather than only parameterised ones.
158
- const residual = where
159
- .replace(/\bTRUE\b/gi, ' ')
160
- .replace(/\b(?:AND|OR)\b/gi, ' ')
161
- .replace(/[()\s]/g, '');
162
- if (residual) {
163
- throw new Error(`fake pool: unmodelled predicate "${residual}" in "${rawWhere}" — a filter the interpreter cannot read must be a loud error, not a silent no-op`);
172
+ //
173
+ // AND THE JOINER IS PART OF THE PREDICATE (task 1003354, idea 1000877). This
174
+ // sweep used to erase `OR` alongside `AND`, so an `AND` rewritten to `OR` left
175
+ // the fake still ANDing its collected terms and every test green — while the
176
+ // real query would return a row matching ANY one of its terms, i.e. every
177
+ // other account's applications. What survives must therefore be a PURE
178
+ // CONJUNCTION: whitespace, parentheses and `AND`, nothing else.
179
+ const residual = where.replace(/\bTRUE\b/gi, ' ');
180
+ if (!/^[\s()]*(?:AND[\s()]*)*$/.test(residual)) {
181
+ throw new Error(`fake pool: unmodelled boolean structure "${residual.trim()}" in "${rawWhere}" — every recognised term is ANDed here, so a predicate the interpreter cannot read as a pure conjunction must be a loud error, not a silent no-op`);
164
182
  }
165
183
  return (r) => tests.every((fn) => fn(r));
166
184
  }
@@ -182,7 +200,11 @@ const pool = {
182
200
 
183
201
  if (/FROM platform_identity_project_memberships m/.test(sql)) {
184
202
  m = /WHERE (.+?) ORDER BY/.exec(sql);
185
- return { rows: memberships.filter(predicateFor(m[1].replace(/ AND m\.membership_kind <> 'pending'/, ''), params)) };
203
+ // The pending exclusion is MODELLED, not stripped. It used to be deleted
204
+ // from the clause before interpreting, which meant dropping it from the
205
+ // real query would have been invisible here — the `.replace` would simply
206
+ // no-op and the residual sweep would never see the term (task 1003354).
207
+ return { rows: memberships.filter(predicateFor(m[1], params)) };
186
208
  }
187
209
 
188
210
  if (/^DELETE FROM platform_identity_project_memberships WHERE client_id = \$1 AND github_id = \$2$/.test(sql)) {
@@ -296,8 +318,11 @@ const join = (note) => call('POST', '/api/gds/my-projects/join', { origin: 'http
296
318
 
297
319
  test('the interpreter refuses a predicate it cannot read — parameterised or not', () => {
298
320
  assert.throws(() => predicateFor('github_id = $1::bigint AND something = $2', [1, 2]), /unread predicate on \$2/);
299
- assert.throws(() => predicateFor("github_id = $1::bigint AND client_id <> 'x'", [1]), /unmodelled predicate/,
321
+ assert.throws(() => predicateFor("github_id = $1::bigint AND status <> 'x'", [1]), /unmodelled boolean structure/,
300
322
  'a literal-only filter binds nothing, so the placeholder sweep alone would miss it');
323
+ assert.throws(() => predicateFor('github_id = $1::bigint OR client_id = $2', [1, 'p']), /unmodelled boolean structure/,
324
+ 'and an AND rewritten to OR: the real query would then return every OTHER account\'s rows, '
325
+ + 'while an interpreter that re-imposed the AND would stay green (idea 1000877, task 1003354)');
301
326
  });
302
327
 
303
328
  // ── the echo is written only for an application the project actually took ────
@@ -369,7 +394,8 @@ test('GET /my-projects reports what was applied to and when — and no status',
369
394
  const r = await call('GET', '/api/gds/my-projects');
370
395
  assert.equal(r.status, 200);
371
396
  const applied = r.json.applied;
372
- assert.deepEqual(applied.map((a) => a.client_id), ['proj-open']);
397
+ assert.deepEqual(applied.map((a) => a.client_id), ['proj-open', 'proj-invited'],
398
+ 'newest first, and proj-invited is here because a PENDING membership is an invitation, not a join');
373
399
  assert.equal(applied[0].name, 'Target', 'a client_id alone is not renderable');
374
400
  assert.equal(applied[0].origin, 'https://target.example');
375
401
  assert.ok(applied[0].applied_at);
@@ -383,6 +409,14 @@ test('a project the applicant now BUILDS on drops out of applied — membership
383
409
  assert.deepEqual(r.json.building.map((b) => b.client_id), ['proj-joined']);
384
410
  assert.ok(!r.json.applied.some((a) => a.client_id === 'proj-joined'),
385
411
  'an approval becomes visible by the card flipping to member, never by the echo claiming it');
412
+ // The other side of that same term: a PENDING row is an invitation, not a
413
+ // membership, so the project stays in `applied` and out of `building`. Delete
414
+ // `membership_kind <> 'pending'` from listMembershipsForAccount and both
415
+ // halves flip — which is what makes the read's own exclusion provable here.
416
+ assert.ok(!r.json.building.some((b) => b.client_id === 'proj-invited'),
417
+ 'a pending invite is not building on the project');
418
+ assert.ok(r.json.applied.some((a) => a.client_id === 'proj-invited'),
419
+ 'and their application is still on file — nothing has approved it');
386
420
  });
387
421
 
388
422
  test('an echo older than the window is invisible, not deleted', async () => {
@@ -126,12 +126,17 @@ function predicateFor(rawWhere, params) {
126
126
  // had already hidden one real filter, so the residual is swept too: this is
127
127
  // what makes "refuses a predicate it cannot read" true of ALL predicates
128
128
  // rather than only parameterised ones.
129
- const residual = where
130
- .replace(/\bTRUE\b/gi, ' ')
131
- .replace(/\b(?:AND|OR)\b/gi, ' ')
132
- .replace(/[()\s]/g, '');
133
- if (residual) {
134
- throw new Error(`fake pool: unmodelled predicate "${residual}" in "${rawWhere}" — a filter the interpreter cannot read must be a loud error, not a silent no-op`);
129
+ //
130
+ // AND THE JOINER IS PART OF THE PREDICATE (task 1003354, idea 1000877). This
131
+ // sweep used to erase `OR` alongside `AND`, so an `AND` rewritten to `OR` left
132
+ // the fake still ANDing its collected terms and every test green — while the
133
+ // real UPDATE would resolve a row matching ANY one of its terms. What survives
134
+ // must therefore be a PURE CONJUNCTION: whitespace, parentheses and `AND`. The
135
+ // transition guard's legitimate `OR` is consumed as a whole unit above, which
136
+ // is what lets it survive this tightening.
137
+ const residual = where.replace(/\bTRUE\b/gi, ' ');
138
+ if (!/^[\s()]*(?:AND[\s()]*)*$/.test(residual)) {
139
+ throw new Error(`fake pool: unmodelled boolean structure "${residual.trim()}" in "${rawWhere}" — every recognised term is ANDed here, so a predicate the interpreter cannot read as a pure conjunction must be a loud error, not a silent no-op`);
135
140
  }
136
141
  return (r) => tests.every((fn) => fn(r));
137
142
  }
@@ -312,9 +317,15 @@ test('the interpreter refuses a predicate it cannot read, rather than ignoring i
312
317
  test('the interpreter refuses a LITERAL-ONLY predicate too — the hole review found', () => {
313
318
  assert.throws(
314
319
  () => predicateFor("id = $1 AND resolved_at IS NULL", [1]),
315
- /unmodelled predicate/,
320
+ /unmodelled boolean structure/,
316
321
  'a filter that binds nothing is invisible to the placeholder sweep, so the residual is swept as well'
317
322
  );
323
+ assert.throws(
324
+ () => predicateFor("id = $1 OR status = 'pending'", [1]),
325
+ /unmodelled boolean structure/,
326
+ 'and an AND rewritten to OR: the resolve UPDATE would then act on a row matching ANY one of its '
327
+ + 'terms, while an interpreter that re-imposed the AND stays green (idea 1000877, task 1003354)'
328
+ );
318
329
  // and the modelled shapes still parse clean
319
330
  assert.doesNotThrow(() => predicateFor("lower(github_login) = lower($1) AND status = 'invited'", ['ada']));
320
331
  });