@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
|
@@ -163,7 +163,7 @@ function predicateFor(rawWhere, params) {
|
|
|
163
163
|
where = where.replace(m[0], ' TRUE ');
|
|
164
164
|
}
|
|
165
165
|
// The unordered pair, likewise stripped before the one-sided matchers.
|
|
166
|
-
m = /pair_lo = LEAST\(\$(\d+)::bigint, \$(\d+)::bigint\)\s*AND\s*(?:
|
|
166
|
+
m = /(?:[a-z_]+\.)?pair_lo = LEAST\(\$(\d+)::bigint, \$(\d+)::bigint\)\s*AND\s*(?:[a-z_]+\.)?pair_hi = GREATEST\(\$(\d+)::bigint, \$(\d+)::bigint\)/.exec(where);
|
|
167
167
|
if (m) {
|
|
168
168
|
const a = p(m[1]); const b = p(m[2]); p(m[3]); p(m[4]);
|
|
169
169
|
const lo = Math.min(Number(a), Number(b)); const hi = Math.max(Number(a), Number(b));
|
|
@@ -172,26 +172,33 @@ function predicateFor(rawWhere, params) {
|
|
|
172
172
|
where = where.replace(m[0], ' TRUE ');
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
-
|
|
175
|
+
// Each matcher BLANKS what it consumed, so the residue below is exactly the
|
|
176
|
+
// part of the clause this interpreter did not understand.
|
|
177
|
+
const eat = () => { where = where.replace(m[0], ' TRUE '); };
|
|
178
|
+
if ((m = /\b(?:[a-z_]+\.)?id = ANY\(\$(\d+)::bigint\[\]\)/.exec(where))) {
|
|
176
179
|
const ids = p(m[1]).map(String);
|
|
177
180
|
tests.push((r) => ids.includes(String(r.id)));
|
|
181
|
+
eat();
|
|
178
182
|
}
|
|
179
|
-
if ((m = /\
|
|
180
|
-
if ((m = /\
|
|
181
|
-
if ((m = /\
|
|
182
|
-
if (/\
|
|
183
|
-
if ((m = /\
|
|
184
|
-
if ((m = /created_at > now\(\) - make_interval\(days => \$(\d+)::int\)/.exec(where))) {
|
|
183
|
+
if ((m = /\b(?:[a-z_]+\.)?id = \$(\d+)\b/.exec(where))) { const v = p(m[1]); tests.push((r) => String(r.id) === String(v)); eat(); }
|
|
184
|
+
if ((m = /\b(?:[a-z_]+\.)?addressee_github_id = \$(\d+)::bigint/.exec(where))) { const v = p(m[1]); tests.push((r) => String(r.addressee_github_id) === String(v)); eat(); }
|
|
185
|
+
if ((m = /\b(?:[a-z_]+\.)?requester_github_id = \$(\d+)::bigint/.exec(where))) { const v = p(m[1]); tests.push((r) => String(r.requester_github_id) === String(v)); eat(); }
|
|
186
|
+
if ((m = /\b(?:[a-z_]+\.)?seen_at IS NULL\b/.exec(where))) { tests.push((r) => !r.seen_at); eat(); }
|
|
187
|
+
if ((m = /\b(?:[a-z_]+\.)?status = '(\w+)'/.exec(where))) { const v = m[1]; tests.push((r) => r.status === v); eat(); }
|
|
188
|
+
if ((m = /(?:[a-z_]+\.)?created_at > now\(\) - make_interval\(days => \$(\d+)::int\)/.exec(where))) {
|
|
185
189
|
const days = Number(p(m[1]));
|
|
186
190
|
tests.push((r) => r.created_at > Date.now() - days * DAY);
|
|
191
|
+
eat();
|
|
187
192
|
}
|
|
188
|
-
if ((m = /created_at <= now\(\) - make_interval\(days => \$(\d+)::int\)/.exec(where))) {
|
|
193
|
+
if ((m = /(?:[a-z_]+\.)?created_at <= now\(\) - make_interval\(days => \$(\d+)::int\)/.exec(where))) {
|
|
189
194
|
const days = Number(p(m[1]));
|
|
190
195
|
tests.push((r) => r.created_at <= Date.now() - days * DAY);
|
|
196
|
+
eat();
|
|
191
197
|
}
|
|
192
|
-
if ((m = /resolved_at > now\(\) - make_interval\(days => \$(\d+)::int\)/.exec(where))) {
|
|
198
|
+
if ((m = /(?:[a-z_]+\.)?resolved_at > now\(\) - make_interval\(days => \$(\d+)::int\)/.exec(where))) {
|
|
193
199
|
const days = Number(p(m[1]));
|
|
194
200
|
tests.push((r) => r.resolved_at != null && r.resolved_at > Date.now() - days * DAY);
|
|
201
|
+
eat();
|
|
195
202
|
}
|
|
196
203
|
|
|
197
204
|
for (const ph of rawWhere.matchAll(/\$(\d+)/g)) {
|
|
@@ -199,6 +206,18 @@ function predicateFor(rawWhere, params) {
|
|
|
199
206
|
throw new Error(`fake pool: unread predicate on $${ph[1]} in "${rawWhere}" — the interpreter must never silently ignore a filter`);
|
|
200
207
|
}
|
|
201
208
|
}
|
|
209
|
+
// AND THE JOINER IS PART OF THE PREDICATE (task 1003354, idea 1000877). What
|
|
210
|
+
// survives must be a pure conjunction: whitespace, parentheses and `AND`. A
|
|
211
|
+
// literal-only term binds no placeholder and is invisible to the sweep above,
|
|
212
|
+
// and an `AND` rewritten to `OR` would let accept/decline act on a row
|
|
213
|
+
// matching the id alone — anyone answering anyone's request — while a
|
|
214
|
+
// term-collecting interpreter re-imposes the `AND` and stays green. The two
|
|
215
|
+
// legitimate ORs (the either-side clause and the unordered pair) are consumed
|
|
216
|
+
// as whole units above, which is what lets them survive this check.
|
|
217
|
+
const residual = where.replace(/\bTRUE\b/gi, ' ');
|
|
218
|
+
if (!/^[\s()]*(?:AND[\s()]*)*$/.test(residual)) {
|
|
219
|
+
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, never a silently different filter`);
|
|
220
|
+
}
|
|
202
221
|
return (r) => tests.every((fn) => fn(r));
|
|
203
222
|
}
|
|
204
223
|
|
|
@@ -505,6 +524,26 @@ test('the fake pool refuses a read that dropped its account floor', () => {
|
|
|
505
524
|
'a read that stopped joining the floor must break the harness, not pass it');
|
|
506
525
|
});
|
|
507
526
|
|
|
527
|
+
test('the fake pool refuses a boolean SHAPE it cannot model, not just a term', () => {
|
|
528
|
+
// Task 1003354 / idea 1000877. The placeholder sweep above cannot see a
|
|
529
|
+
// literal-only term at all, and nothing saw the JOINER: an `AND` rewritten to
|
|
530
|
+
// `OR` would let accept/decline act on a row matching the id alone — anyone
|
|
531
|
+
// answering anyone's request — while a term-collecting interpreter re-imposes
|
|
532
|
+
// the `AND` and every isolation assertion in this file stays green.
|
|
533
|
+
assert.throws(() => predicateFor("id = $1 AND note IS NULL", [1]),
|
|
534
|
+
/unmodelled boolean structure/,
|
|
535
|
+
'a literal-only term binds no placeholder');
|
|
536
|
+
assert.throws(() => predicateFor("id = $1 OR addressee_github_id = $2::bigint", [1, ME]),
|
|
537
|
+
/unmodelled boolean structure/,
|
|
538
|
+
'and the joiner is part of the predicate');
|
|
539
|
+
// The two legitimate ORs are consumed as whole units, so they still parse.
|
|
540
|
+
assert.doesNotThrow(() => predicateFor(
|
|
541
|
+
"id = $1 AND status = 'accepted' AND (requester_github_id = $2::bigint OR addressee_github_id = $2::bigint)", [1, ME]));
|
|
542
|
+
assert.doesNotThrow(() => predicateFor(
|
|
543
|
+
"c.pair_lo = LEAST($1::bigint, $2::bigint) AND c.pair_hi = GREATEST($1::bigint, $2::bigint) AND c.status = 'pending' AND c.created_at <= now() - make_interval(days => $3::int)",
|
|
544
|
+
[ME, ADA, 30]), 'the aliased expiry clause, alias prefixes included');
|
|
545
|
+
});
|
|
546
|
+
|
|
508
547
|
// ===========================================================================
|
|
509
548
|
// 1. The surface: the section exists, ships hidden, and profile.js calls it
|
|
510
549
|
// ===========================================================================
|
|
@@ -79,6 +79,10 @@ const NOSY = '7777'; // active · public — the stranger at the side
|
|
|
79
79
|
// ADR 0213 (task 1002913): consent is profile_state AND an acceptance of the
|
|
80
80
|
// platform terms, both written by the same statement — so a consented fixture
|
|
81
81
|
// carries both, and one carrying only the first is a row from before the terms.
|
|
82
|
+
// Every fixture here accepts, deliberately: the never-accepted row and the 404
|
|
83
|
+
// it earns belong to tests/effective_visibility_predicate.mjs, which owns the
|
|
84
|
+
// consent matrix. This file's subject is the OWNER's page, so re-seeding that
|
|
85
|
+
// row here would duplicate a proof rather than add one.
|
|
82
86
|
const ACCEPTED_AT = '2026-08-29T00:00:00.000Z';
|
|
83
87
|
|
|
84
88
|
const ACCOUNTS = [
|
|
@@ -140,48 +144,64 @@ function sentinelsOf(githubId) {
|
|
|
140
144
|
// anything else throws — a WHERE it cannot read must never degrade to no WHERE.
|
|
141
145
|
const KNOWN_COLUMNS = new Set(['profile_state', 'account_visibility', 'hide_stats', 'github_id', 'handle', 'previous_handle']);
|
|
142
146
|
|
|
143
|
-
// Columns this interpreter can answer an IS [NOT] NULL test on — a different
|
|
144
|
-
// syntax from the literal comparisons above, hence its own set (ADR 0213).
|
|
145
|
-
const KNOWN_NULL_TESTS = new Set(['terms_accepted_at']);
|
|
146
|
-
|
|
147
147
|
/**
|
|
148
148
|
* Build a row test from a statement's own WHERE text. Literal comparisons carry
|
|
149
149
|
* as much weight as placeholders here: the effective-visibility predicate is
|
|
150
150
|
* made ENTIRELY of literal comparisons, so a fake that modelled only $N filters
|
|
151
151
|
* would score the privacy terms as absent and pass however the code changed.
|
|
152
|
+
*
|
|
153
|
+
* IT READS STRUCTURE, NOT JUST TERMS, which is why this is a CONSUMING parse
|
|
154
|
+
* rather than a list of `.test()` calls. An interpreter that merely COLLECTS the
|
|
155
|
+
* terms it recognises and ANDs them re-imposes an `AND` the SQL may no longer
|
|
156
|
+
* contain: rewrite accountVisibleSql's `AND` to `OR` and Postgres serves every
|
|
157
|
+
* ACTIVE account — private ones included — while a term-collecting fake answers
|
|
158
|
+
* exactly as before and every 404 assertion in this file stays green (idea
|
|
159
|
+
* 1000877, from the adversarial review of task 1002968). So each recognised term
|
|
160
|
+
* is REMOVED from the text as it is matched, and whatever survives must be
|
|
161
|
+
* nothing but whitespace, parentheses and `AND`.
|
|
162
|
+
*
|
|
163
|
+
* That also subsumes the NULL-test bookkeeping this function used to carry: an
|
|
164
|
+
* `IS NOT NULL` nobody modelled now survives in the residue and is refused
|
|
165
|
+
* there, so recognition and application cannot come apart (ADR 0213).
|
|
152
166
|
*/
|
|
153
167
|
function accountFilter(where, params) {
|
|
154
168
|
const tests = [];
|
|
155
169
|
const consumed = new Set();
|
|
156
|
-
|
|
157
|
-
//
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
170
|
+
let residue = where;
|
|
171
|
+
// Match a term, model it, and CONSUME its text so the leftovers can be judged.
|
|
172
|
+
const take = (re, make) => {
|
|
173
|
+
const m = re.exec(residue);
|
|
174
|
+
if (!m) return null;
|
|
175
|
+
residue = residue.slice(0, m.index) + residue.slice(m.index + m[0].length);
|
|
176
|
+
tests.push(make(m));
|
|
177
|
+
return m;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
take(/\b(?:[a-z_]+\.)?profile_state = 'active'/, () => (r) => r.profile_state === 'active');
|
|
161
181
|
// The ADR 0213 consent half. It is a NULL test, not a literal comparison, so
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
consumed.add(byId[1]);
|
|
174
|
-
const v = params[Number(byId[1]) - 1];
|
|
175
|
-
tests.push((r) => String(r.github_id) === String(v));
|
|
176
|
-
}
|
|
182
|
+
// the two term sweeps below cannot see it at all — only the structure guard
|
|
183
|
+
// can, which is exactly why that guard exists.
|
|
184
|
+
take(/\b(?:[a-z_]+\.)?terms_accepted_at IS NOT NULL/, () => (r) => r.terms_accepted_at != null);
|
|
185
|
+
take(/\b(?:[a-z_]+\.)?account_visibility = 'public'/, () => (r) => r.account_visibility === 'public');
|
|
186
|
+
take(/\bNOT (?:[a-z_]+\.)?hide_stats\b/, () => (r) => !r.hide_stats);
|
|
187
|
+
|
|
188
|
+
take(/\b(?:[a-z_]+\.)?github_id = \$(\d+)/, (m) => {
|
|
189
|
+
consumed.add(m[1]);
|
|
190
|
+
const v = params[Number(m[1]) - 1];
|
|
191
|
+
return (r) => String(r.github_id) === String(v);
|
|
192
|
+
});
|
|
177
193
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
194
|
+
// The rename window's disjunction is the ONE legitimate OR in this repo's
|
|
195
|
+
// account reads, so it is consumed as a single unit — leaving a bare `OR`
|
|
196
|
+
// behind would trip the structure check below.
|
|
197
|
+
take(/lower\(handle\) = \$(\d+) OR lower\(previous_handle\) = \$(\d+)/, (m) => {
|
|
198
|
+
consumed.add(m[1]); consumed.add(m[2]);
|
|
199
|
+
const v = String(params[Number(m[1]) - 1]);
|
|
200
|
+
return (r) => String(r.handle || '').toLowerCase() === v || String(r.previous_handle || '').toLowerCase() === v;
|
|
201
|
+
});
|
|
184
202
|
|
|
203
|
+
// The TERM guards run before the STRUCTURE guard, so an unreadable term is
|
|
204
|
+
// reported as the unreadable term it is rather than as leftover text.
|
|
185
205
|
for (const m of where.matchAll(/\$(\d+)/g)) {
|
|
186
206
|
if (!consumed.has(m[1])) {
|
|
187
207
|
throw new Error(`fake pool: unread predicate on $${m[1]} in "${where}" — a filter this interpreter cannot read must never degrade to no filter`);
|
|
@@ -192,13 +212,8 @@ function accountFilter(where, params) {
|
|
|
192
212
|
throw new Error(`fake pool: unrecognised literal comparison on ${m[1]} in "${where}"`);
|
|
193
213
|
}
|
|
194
214
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
throw new Error(`fake pool: unrecognised NULL test on ${m[1]} in "${where}"`);
|
|
198
|
-
}
|
|
199
|
-
if (!modelled.has(m[1])) {
|
|
200
|
-
throw new Error(`fake pool: NULL test on ${m[1]} in "${where}" was recognised but never MODELLED — the clause is present and this interpreter is ignoring it`);
|
|
201
|
-
}
|
|
215
|
+
if (!/^[\s()]*(?:AND[\s()]*)*$/.test(residue)) {
|
|
216
|
+
throw new Error(`fake pool: unmodelled boolean structure "${residue.trim()}" in "${where}" — every recognised term is ANDed here, so a predicate that is not a pure conjunction must be a loud error, never a silently different filter`);
|
|
202
217
|
}
|
|
203
218
|
return (r) => tests.every((fn) => fn(r));
|
|
204
219
|
}
|
|
@@ -332,6 +347,22 @@ test('the interpreter refuses a predicate it cannot read, rather than ignoring i
|
|
|
332
347
|
/unrecognised literal comparison on shadow_banned/,
|
|
333
348
|
'the privacy predicate is made of LITERAL comparisons — an unmodelled one must not read as absent',
|
|
334
349
|
);
|
|
350
|
+
assert.throws(
|
|
351
|
+
() => accountFilter("profile_state = 'active' AND deleted_at IS NULL", []),
|
|
352
|
+
/unmodelled boolean structure/,
|
|
353
|
+
'a NULL TEST is a third syntax again, invisible to both sweeps above — the shape that let the '
|
|
354
|
+
+ 'ADR 0213 acceptance term be dropped silently elsewhere (task 1003354)',
|
|
355
|
+
);
|
|
356
|
+
assert.throws(
|
|
357
|
+
() => accountFilter("profile_state = 'active' OR account_visibility = 'public'", []),
|
|
358
|
+
/unmodelled boolean structure/,
|
|
359
|
+
'and an AND rewritten to OR: in Postgres that serves every ACTIVE account including the private '
|
|
360
|
+
+ 'ones, so an interpreter that re-imposed the AND would keep every 404 below green (idea 1000877)',
|
|
361
|
+
);
|
|
362
|
+
assert.doesNotThrow(
|
|
363
|
+
() => accountFilter("(profile_state = 'active' AND terms_accepted_at IS NOT NULL) AND account_visibility = 'public'", []),
|
|
364
|
+
'and the real predicate still parses clean — this is a filter on the unreadable, not a blanket refusal',
|
|
365
|
+
);
|
|
335
366
|
});
|
|
336
367
|
|
|
337
368
|
test('the interpreter really does narrow on the visibility literals', () => {
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
// Proves the consent + hide semantics against real SQL — pg-mem cannot execute
|
|
4
4
|
// unnest(), so the rollup queries need a live Postgres (the task-1002911
|
|
5
5
|
// precedent). Replays the module's actual migrations, then asserts:
|
|
6
|
-
// 1. listScoutingRollup lists ONLY
|
|
7
|
-
// provisional and
|
|
8
|
-
// construction.
|
|
6
|
+
// 1. listScoutingRollup lists ONLY consented + not-hidden builders (D2/D3) —
|
|
7
|
+
// provisional, hidden and never-accepted accounts drop out of /people +
|
|
8
|
+
// /scouting by construction. Consent is TWO columns since ADR 0213
|
|
9
|
+
// (profile_state='active' AND terms_accepted_at IS NOT NULL), and the
|
|
10
|
+
// LEGACY fixture is the pre-terms row that proves the second one.
|
|
9
11
|
// 2. getRollupForBuilder aggregates un-fanned and names ONLY projects whose
|
|
10
12
|
// catalog row is publicly listed (featured + active); everything else is
|
|
11
13
|
// counted in more_projects, never named. client_id never crosses.
|
|
@@ -44,8 +46,12 @@ try {
|
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
// High-numbered fixture ids so a shared DB can never collide with real data.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
// LEGACY is the ADR 0213 shape: profile_state='active' from before the platform
|
|
50
|
+
// terms existed, so terms_accepted_at is NULL. Consent is TWO columns now, and
|
|
51
|
+
// this is the row that makes that second column load-bearing here — delete the
|
|
52
|
+
// acceptance term from accountActiveSql and this fixture is the one that flips.
|
|
53
|
+
const ACTIVE = 90002278, HIDDEN = 90002279, PROV = 90002280, LAZY = 90002281, LEGACY = 90002283;
|
|
54
|
+
const ALL_IDS = [ACTIVE, HIDDEN, PROV, LAZY, LEGACY];
|
|
49
55
|
const PUB_CLIENT = 't1002278-pub', PRIV_CLIENT = 't1002278-priv', LOST_CLIENT = 't1002278-lost';
|
|
50
56
|
const ALL_CLIENTS = [PUB_CLIENT, PRIV_CLIENT, LOST_CLIENT];
|
|
51
57
|
const PUB_ORIGIN = 'https://t1002278-pub.example', PRIV_ORIGIN = 'https://t1002278-priv.example';
|
|
@@ -63,19 +69,32 @@ let pass = 0;
|
|
|
63
69
|
const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
|
|
64
70
|
|
|
65
71
|
try {
|
|
66
|
-
// The module's actual migrations (idempotent).
|
|
72
|
+
// The module's actual migrations (idempotent). 012 and 019 are here because
|
|
73
|
+
// the predicates under test READ their columns: accountVisibleSql compares
|
|
74
|
+
// account_visibility, scoutingListableSql COALESCEs recruiter_discoverable
|
|
75
|
+
// (012), and the consent floor tests terms_accepted_at (019). Without them a
|
|
76
|
+
// FRESH database answers 42703 "column does not exist" — an infrastructure
|
|
77
|
+
// error dressed up as a boundary failure — instead of a clean assertion.
|
|
67
78
|
await q(migrationSql('platform_identity_001_tables.sql'));
|
|
68
79
|
await q(migrationSql('platform_identity_004_projects.sql'));
|
|
69
80
|
await q(migrationSql('platform_identity_005_builder_activity.sql'));
|
|
70
81
|
await q(migrationSql('platform_identity_007_profile_consent.sql'));
|
|
82
|
+
await q(migrationSql('platform_identity_012_account_privacy.sql'));
|
|
83
|
+
await q(migrationSql('platform_identity_019_terms_acceptance.sql'));
|
|
71
84
|
await cleanup();
|
|
72
85
|
|
|
86
|
+
// CONSENT IS TWO COLUMNS (ADR 0213, task 1002913): profile_state='active' AND
|
|
87
|
+
// an acceptance of the platform terms. A fixture that seeds only the first is
|
|
88
|
+
// a row from BEFORE the terms shipped, and is deliberately not consented — so
|
|
89
|
+
// every consented fixture here stamps both, exactly as upsertAccount's
|
|
90
|
+
// activate branch does, and LEGACY stamps only the first on purpose.
|
|
73
91
|
await q(
|
|
74
|
-
`INSERT INTO platform_identity_accounts (github_id, github_login, display_name, profile_state, hide_stats)
|
|
75
|
-
VALUES ($1, 'active-b', 'Active', 'active', false),
|
|
76
|
-
($2, 'hidden-b', 'Hidden', 'active', true),
|
|
77
|
-
($3, 'prov-b', 'Prov', 'provisional', false)
|
|
78
|
-
|
|
92
|
+
`INSERT INTO platform_identity_accounts (github_id, github_login, display_name, profile_state, hide_stats, terms_accepted_at)
|
|
93
|
+
VALUES ($1, 'active-b', 'Active', 'active', false, now()),
|
|
94
|
+
($2, 'hidden-b', 'Hidden', 'active', true, now()),
|
|
95
|
+
($3, 'prov-b', 'Prov', 'provisional', false, NULL),
|
|
96
|
+
($4, 'legacy-b', 'Legacy', 'active', false, NULL)`,
|
|
97
|
+
[ACTIVE, HIDDEN, PROV, LEGACY],
|
|
79
98
|
);
|
|
80
99
|
// Three reporting projects: one publicly catalogued (featured+active), one
|
|
81
100
|
// catalogued but NOT featured (→ never named), one with no catalog row at all.
|
|
@@ -96,15 +115,21 @@ try {
|
|
|
96
115
|
($2, $4, 50, 2, 1, 'archon', '{artist}', '2026-02-01T00:00:00Z'),
|
|
97
116
|
($3, $4, 25, 1, 0, NULL, '{}', '2026-03-01T00:00:00Z'),
|
|
98
117
|
($1, $5, 60, 4, 2, 'metic', '{engineer}', '2026-01-01T00:00:00Z'),
|
|
99
|
-
($1, $6, 10, 1, 0, 'xenos', '{engineer}', '2026-01-01T00:00:00Z')
|
|
100
|
-
|
|
118
|
+
($1, $6, 10, 1, 0, 'xenos', '{engineer}', '2026-01-01T00:00:00Z'),
|
|
119
|
+
($1, $7, 90, 3, 1, 'metic', '{engineer}', '2026-01-01T00:00:00Z')`,
|
|
120
|
+
[...ALL_CLIENTS, ACTIVE, HIDDEN, PROV, LEGACY],
|
|
101
121
|
);
|
|
102
122
|
|
|
103
123
|
// 1 — the scouting/people predicate (D2/D3).
|
|
104
124
|
const listed = (await pi.listScoutingRollup({ limit: 500, pool })).map((r) => Number(r.github_id));
|
|
105
|
-
ok(listed.includes(ACTIVE), 'active + visible builder is listed');
|
|
125
|
+
ok(listed.includes(ACTIVE), 'active + accepted + visible builder is listed');
|
|
106
126
|
ok(!listed.includes(HIDDEN), 'hide_stats builder drops out of the scouting rollup');
|
|
107
127
|
ok(!listed.includes(PROV), 'provisional (no hub-session consent) builder drops out');
|
|
128
|
+
// The ADR 0213 half, against real SQL. LEGACY differs from ACTIVE in ONE
|
|
129
|
+
// column, so this is the acceptance term and nothing else: an account
|
|
130
|
+
// activated before the platform terms existed has no consent to aggregate on
|
|
131
|
+
// and does not publish until it re-accepts at its next hub sign-in.
|
|
132
|
+
ok(!listed.includes(LEGACY), 'active but never-accepted (pre-terms) builder drops out too — consent is two columns');
|
|
108
133
|
|
|
109
134
|
// 2 — getRollupForBuilder semantics.
|
|
110
135
|
const r = await pi.getRollupForBuilder(ACTIVE, { pool });
|
|
@@ -124,6 +149,7 @@ try {
|
|
|
124
149
|
// 3 — the port reads.
|
|
125
150
|
ok(await pi.getPublicProfileExtras(HIDDEN, { pool }) === null, 'public read → null for a hidden builder');
|
|
126
151
|
ok(await pi.getPublicProfileExtras(PROV, { pool }) === null, 'public read → null for a provisional builder');
|
|
152
|
+
ok(await pi.getPublicProfileExtras(LEGACY, { pool }) === null, 'public read → null for an active builder who never accepted the terms (ADR 0213)');
|
|
127
153
|
ok(await pi.getPublicProfileExtras(99999999999, { pool }) === null, 'public read → null with no account');
|
|
128
154
|
ok(await pi.getPublicProfileExtras('system:bfg', { pool }) === null, 'non-numeric principal → null, no bigint cast error');
|
|
129
155
|
const pub = await pi.getPublicProfileExtras(ACTIVE, { pool });
|
|
@@ -138,9 +164,15 @@ try {
|
|
|
138
164
|
await pi.upsertAccount({ githubId: LAZY, githubLogin: 'lazy-b', activate: true }, { pool });
|
|
139
165
|
acct = await pi.getAccount(LAZY, { pool });
|
|
140
166
|
ok(acct.profile_state === 'active', 'an authorize-path upsert (activate) flips it active');
|
|
167
|
+
// BOTH consent columns come from that ONE branch (ADR 0213), which is what
|
|
168
|
+
// makes "active without an acceptance" a legacy shape rather than a state the
|
|
169
|
+
// code can still produce — and what makes the LEGACY fixture above honest.
|
|
170
|
+
ok(acct.terms_accepted_at !== null, 'the same activate branch stamps the T&C acceptance');
|
|
171
|
+
const firstAccept = acct.terms_accepted_at;
|
|
141
172
|
await pi.upsertAccount({ githubId: LAZY, githubLogin: 'lazy-b' }, { pool });
|
|
142
173
|
acct = await pi.getAccount(LAZY, { pool });
|
|
143
174
|
ok(acct.profile_state === 'active', 'a later plain upsert never demotes an active account');
|
|
175
|
+
ok(String(acct.terms_accepted_at) === String(firstAccept), 'and never rewrites the acceptance — the FIRST one stands (idempotent by COALESCE)');
|
|
144
176
|
|
|
145
177
|
// 4b — the live hide toggle (R03, task 1002279): flipping hide_stats takes
|
|
146
178
|
// effect IMMEDIATELY on every public read — the profile port and the
|
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
|
},
|