@bongos/core 1.19.430 → 1.19.431
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 +49 -34
- 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/README.md +1 -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 +2 -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/effective_visibility_predicate.mjs +58 -1
- package/tests/government_require_permission.mjs +22 -0
- package/tests/profile_route.mjs +6 -0
- package/tests/recruiter_sliver_boundary.mjs +841 -0
- package/tests/scouting_owner_access_and_reach.mjs +4 -0
|
@@ -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.431",
|
|
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.431",
|
|
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.431",
|
|
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.431'; // 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
|
+
});
|
|
@@ -79,6 +79,8 @@ import {
|
|
|
79
79
|
isRecruiterDiscoverable,
|
|
80
80
|
recruiterReachSql,
|
|
81
81
|
scoutingListableSql,
|
|
82
|
+
recruiterSliverVisibleSql,
|
|
83
|
+
isRecruiterSliverVisible,
|
|
82
84
|
} from '../modules/platform-identity/account-visibility.js';
|
|
83
85
|
|
|
84
86
|
const require = createRequire(import.meta.url);
|
|
@@ -119,6 +121,10 @@ const SPELLING_EXEMPTIONS = [
|
|
|
119
121
|
text: 'hide_stats = true',
|
|
120
122
|
count: 1,
|
|
121
123
|
why: "hiddenGithubIds asks the OPPOSITE question to visibility — 'which of these accounts have hidden themselves' — for the closeness-graph consent gate. It must stay fail-closed on hide alone (ADR 0171 D4), so it is not expressible as the shared predicate and is not a variant of it.",
|
|
124
|
+
}, {
|
|
125
|
+
text: "account_visibility = '",
|
|
126
|
+
count: 1,
|
|
127
|
+
why: "setAccountPrivacy's ON CONFLICT branch, and a WRITE rather than a read gate — it reads EXCLUDED, the value being written, to decide whether this PATCH re-arms recruiter_discoverable to its 'follow visibility' default. Going private without naming reach must clear an explicit true, because that true was set while PUBLIC and meant 'recruiters may see my public profile' (ADR 0215, task 1002968); the D3 sliver has no existence floor and would otherwise read that stale consent as fresh. It gates no read, so it cannot be expressed as the shared predicate and is not a variant of one — the SPELLINGS comment already excludes writes in principle, and this is the occurrence its regex cannot tell apart.",
|
|
122
128
|
}],
|
|
123
129
|
obligation: (code) => /require\(\s*'\.\/account-visibility'\s*\)/.test(code),
|
|
124
130
|
demands: 'still imports the shared predicate rather than re-deriving one',
|
|
@@ -341,8 +347,14 @@ function accountStatements(code) {
|
|
|
341
347
|
* recruiterReachSql inside the owner file, so a reader that joins it has adopted
|
|
342
348
|
* the shared rule — but it must be named here, or wiring the recruiting reach in
|
|
343
349
|
* would read as a reader that stopped adopting anything.
|
|
350
|
+
* recruiterSliverVisibleSql (task 1002968) is here on the same terms and one
|
|
351
|
+
* caveat: it is the ONE composed answer that deliberately omits the existence
|
|
352
|
+
* axis, because the private-but-discoverable account is the D3 sliver's subject.
|
|
353
|
+
* That is a decision recorded in the owner file and asserted above — not a
|
|
354
|
+
* reader quietly dropping a term, which is what this regex would otherwise let
|
|
355
|
+
* through unnoticed.
|
|
344
356
|
*/
|
|
345
|
-
const ADOPTS = /\b(?:accountVisibleSql|accountActiveSql|rollupVisibleSql|scoutingListableSql|isAccountVisible|isAccountActive|isRollupVisible)\s*\(/;
|
|
357
|
+
const ADOPTS = /\b(?:accountVisibleSql|accountActiveSql|rollupVisibleSql|scoutingListableSql|recruiterSliverVisibleSql|isAccountVisible|isAccountActive|isRollupVisible|isRecruiterSliverVisible)\s*\(/;
|
|
346
358
|
|
|
347
359
|
// ===========================================================================
|
|
348
360
|
// Self-tests — the machinery is the guard, so it gets proved too.
|
|
@@ -513,6 +525,30 @@ t('the recruiter-reach predicate still lives here, and is still the only COALESC
|
|
|
513
525
|
'reach never buys addressability — that is what the D3 sliver is for');
|
|
514
526
|
});
|
|
515
527
|
|
|
528
|
+
t('recruiterSliverVisibleSql is COMPOSED from consent AND reach, and omits existence ON PURPOSE (task 1002968)', () => {
|
|
529
|
+
// The pre-accept sliver is the one effective answer that must NOT floor on the
|
|
530
|
+
// existence axis: the privacy spec's D2 "private · discoverable" row is the
|
|
531
|
+
// account it exists to serve, and ANDing accountVisibleSql would empty it of
|
|
532
|
+
// exactly that account while leaving every test above still green.
|
|
533
|
+
assert.ok(recruiterSliverVisibleSql('pa').includes(accountActiveSql('pa')),
|
|
534
|
+
'the consent floor, literally contained — a provisional account is never recruiter-visible (spec D3)');
|
|
535
|
+
assert.ok(recruiterSliverVisibleSql('pa').includes(recruiterReachSql('pa')),
|
|
536
|
+
'and the reach axis, literally contained — the opt-out empties this surface too');
|
|
537
|
+
assert.ok(!recruiterSliverVisibleSql('pa').includes(accountVisibleSql('pa')),
|
|
538
|
+
'and NOT the existence axis: dropping it is safe only because reach is an explicit opt-IN for a private account');
|
|
539
|
+
assert.doesNotMatch(recruiterSliverVisibleSql(), /hide_stats/,
|
|
540
|
+
'hide changes the SHAPE of the sliver (name-only), never who appears in it');
|
|
541
|
+
// The JS twin, over the same D2 matrix the SQL is judged on.
|
|
542
|
+
const accepted = { terms_accepted_at: 'T1' }; // the T&C half of the consent floor (ADR 0213)
|
|
543
|
+
assert.equal(isRecruiterSliverVisible({ ...accepted, profile_state: 'active', account_visibility: 'private', recruiter_discoverable: true }), true);
|
|
544
|
+
assert.equal(isRecruiterSliverVisible({ ...accepted, profile_state: 'active', account_visibility: 'private', recruiter_discoverable: null }), false);
|
|
545
|
+
assert.equal(isRecruiterSliverVisible({ ...accepted, profile_state: 'provisional', account_visibility: 'public', recruiter_discoverable: true }), false);
|
|
546
|
+
assert.equal(isRecruiterSliverVisible({ profile_state: 'active', account_visibility: 'private', recruiter_discoverable: true }), false,
|
|
547
|
+
'and the sliver inherits the T&C floor by COMPOSITION — an account that never accepted is not recruiter-visible');
|
|
548
|
+
assert.equal(isRecruiterSliverVisible(null), false, 'fail-closed on an absent account');
|
|
549
|
+
assert.throws(() => recruiterSliverVisibleSql('pa; DROP TABLE x --'), /bare SQL identifier/);
|
|
550
|
+
});
|
|
551
|
+
|
|
516
552
|
// ===========================================================================
|
|
517
553
|
// 2. The sole-owner rule — nothing else spells a variant
|
|
518
554
|
// ===========================================================================
|
|
@@ -640,6 +676,27 @@ t('the shared predicate has real adopters (the wiring did not silently unwire it
|
|
|
640
676
|
}
|
|
641
677
|
});
|
|
642
678
|
|
|
679
|
+
t('the EXISTENCE-LESS predicate has exactly one reader, and it is the sliver (task 1002968)', () => {
|
|
680
|
+
// recruiterSliverVisibleSql counts as adoption in ADOPTS above, which is what
|
|
681
|
+
// lets the sliver's own read pass the classification rule — and that is a hole
|
|
682
|
+
// unless the predicate is also PINNED to its one caller. It is the only shared
|
|
683
|
+
// answer that omits `account_visibility = 'public'`, so a future public read
|
|
684
|
+
// that joined it would look adopted while quietly serving private accounts.
|
|
685
|
+
// BOTH FORMS, because the same diff put both into ADOPTS. Pinning only the SQL
|
|
686
|
+
// one left the JS twin as an open door: a new module file doing
|
|
687
|
+
// `rows.filter(isRecruiterSliverVisible)` over an ungated
|
|
688
|
+
// `SELECT … FROM platform_identity_accounts WHERE handle ILIKE $1` passed this
|
|
689
|
+
// entire guard — that is the hub profile search this file's own header names
|
|
690
|
+
// as the read that must fail the build, sailing through on the one predicate
|
|
691
|
+
// that omits the existence axis (found in adversarial review, task 1002968).
|
|
692
|
+
// Enumerated from source, so a second reader fails here rather than in review.
|
|
693
|
+
const readers = scanned
|
|
694
|
+
.filter((f) => f.rel !== PREDICATE_OWNER && /\b(?:recruiterSliverVisibleSql|isRecruiterSliverVisible)\s*\(/.test(f.code))
|
|
695
|
+
.map((f) => f.rel);
|
|
696
|
+
assert.deepEqual(readers, ['modules/platform-identity/recruiter-sliver.js'],
|
|
697
|
+
'the pre-accept predicate is the recruiting sliver\'s alone — every other read must floor on the existence axis');
|
|
698
|
+
});
|
|
699
|
+
|
|
643
700
|
t('getPublicProfileExtras gates on the shared JS port (its SQL carries no predicate to join)', () => {
|
|
644
701
|
const pi = scanned.find((f) => f.rel === 'modules/platform-identity/platform-identity.js');
|
|
645
702
|
const span = functionSpans(pi.code).find((s) => s.name === 'getPublicProfileExtras');
|
|
@@ -352,6 +352,28 @@ test('every modules/*/routes gate is migrated, bar the one documented holdout',
|
|
|
352
352
|
// worth doing — it means minting an atom over the write — but it is the
|
|
353
353
|
// continuing R107/R108 migration's job, not this ADR's.
|
|
354
354
|
'security/routes/scans.js',
|
|
355
|
+
// Still requireRank after BV1.R105 — and for a reason the other holdouts do
|
|
356
|
+
// not have: the atom this gate WANTS is specified, named, and deliberately
|
|
357
|
+
// owned by another wave. `GET /scouting/:handle` is the privacy spec's D3
|
|
358
|
+
// pre-accept recruiter sliver (task 1002968, ADR 0215 D4), and D3 gates it on
|
|
359
|
+
// `project.recruit` — declared in spec D1 as FLOOR ARCHON, widenable per
|
|
360
|
+
// instance. That atom does not exist: ADR 0201 D3 and ADR 0210 both refuse to
|
|
361
|
+
// mint it outside the project-admin-console wave that defines it (spike
|
|
362
|
+
// 1002786 R3), because a third definition before any had shipped is the drift
|
|
363
|
+
// they were avoiding.
|
|
364
|
+
//
|
|
365
|
+
// The three ways out are all worse. Minting it here forks its definition.
|
|
366
|
+
// Borrowing a different archon atom (`access_request.review`, say) would name
|
|
367
|
+
// the wrong authority on a privacy surface. And reusing the sibling route's
|
|
368
|
+
// `project.curate`-or-project-owner door — the tidy option, and what the first
|
|
369
|
+
// draft did — is WIDER than the signed floor on both halves, on the first
|
|
370
|
+
// surface on this platform that discloses a PRIVATE account.
|
|
371
|
+
//
|
|
372
|
+
// So the floor is held by rank until the atom lands, which is the one spelling
|
|
373
|
+
// that neither invents authority nor widens it. It is temporary by
|
|
374
|
+
// construction: blocker 1000126 carries the audience question to the owner,
|
|
375
|
+
// and spike 1002786 R3 mints the atom that removes this entry.
|
|
376
|
+
'platform-identity/routes/scouting.js',
|
|
355
377
|
].sort();
|
|
356
378
|
|
|
357
379
|
const modulesDir = path.join(ROOT, 'modules');
|
package/tests/profile_route.mjs
CHANGED
|
@@ -159,6 +159,9 @@ require.cache[authPath] = {
|
|
|
159
159
|
requireBuilder: (_req, _res, next) => next(),
|
|
160
160
|
requireRank: () => (_req, _res, next) => next(),
|
|
161
161
|
requirePermission: () => (_req, _res, next) => next(),
|
|
162
|
+
// ADR 0215 D4: the sliver route beside the directory gates on RANK, so the
|
|
163
|
+
// partial doorway must answer requireRank too or the router will not load.
|
|
164
|
+
requireRank: () => (_req, _res, next) => next(),
|
|
162
165
|
},
|
|
163
166
|
};
|
|
164
167
|
|
|
@@ -434,6 +437,9 @@ require.cache[moduleApiPath] = {
|
|
|
434
437
|
pool: engine,
|
|
435
438
|
requireBuilder: (_req, _res, next) => next(),
|
|
436
439
|
requirePermission: () => (_req, _res, next) => next(),
|
|
440
|
+
// ADR 0215 D4: the sliver route beside the directory gates on RANK, so the
|
|
441
|
+
// partial doorway must answer requireRank too or the router will not load.
|
|
442
|
+
requireRank: () => (_req, _res, next) => next(),
|
|
437
443
|
parsePagination: () => ({ limit: 100, offset: 0 }),
|
|
438
444
|
corsPublicGet: () => (_req, _res, next) => next(),
|
|
439
445
|
},
|