@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.
- package/.bongos-core.json +64 -44
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0215-the-recruiter-sliver-is-a-ceiling-not-a-step.md +116 -0
- package/docs/adr/0216-a-fake-pool-interpreter-must-fail-loudly-not-silently-ignore.md +102 -0
- package/docs/adr/README.md +2 -0
- package/docs/api/openapi.json +64 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +48 -48
- package/docs/copy-registry.json +57 -57
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/hall-ui/public/settings.js +17 -8
- package/modules/platform-identity/account-visibility.js +47 -0
- package/modules/platform-identity/platform-identity.js +4 -4
- package/modules/platform-identity/recruiter-sliver.js +197 -0
- package/modules/platform-identity/routes/scouting.js +58 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/account_privacy_flags.mjs +115 -9
- package/tests/application_echo.mjs +43 -9
- package/tests/application_lifecycle.mjs +18 -7
- package/tests/connections_ownership.mjs +44 -4
- package/tests/effective_visibility_predicate.mjs +137 -50
- package/tests/government_require_permission.mjs +22 -0
- package/tests/handle_machinery.mjs +15 -1
- package/tests/hub_profile_leak.mjs +18 -4
- package/tests/own_session_revoke.mjs +77 -9
- package/tests/profile_connections_ui.mjs +49 -10
- package/tests/profile_privacy_owner_view.mjs +68 -37
- package/tests/profile_rollup_consent.mjs +46 -14
- package/tests/profile_route.mjs +6 -0
- package/tests/recruiter_sliver_boundary.mjs +841 -0
- package/tests/scouting_owner_access_and_reach.mjs +175 -39
- 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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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, /
|
|
282
|
-
'
|
|
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
|
-
|
|
308
|
-
|
|
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
|
|
311
|
-
+ '
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
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
|
});
|