@bongos/core 1.19.431 → 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 +29 -24
- package/docs/adr/0216-a-fake-pool-interpreter-must-fail-loudly-not-silently-ignore.md +102 -0
- package/docs/adr/README.md +1 -0
- package/docs/module-api-changelog.md +2 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- 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 +79 -49
- 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/scouting_owner_access_and_reach.mjs +171 -39
- package/tests/scouting_rollup_fanout.mjs +15 -4
|
@@ -782,55 +782,72 @@ const ACCOUNTS = [
|
|
|
782
782
|
// clause throws — the guard against a predicate quietly degrading to no filter.
|
|
783
783
|
const KNOWN_COLUMNS = new Set(['profile_state', 'account_visibility', 'hide_stats', 'github_id', 'handle', 'previous_handle']);
|
|
784
784
|
|
|
785
|
-
// The columns this interpreter knows how to answer an IS [NOT] NULL test on.
|
|
786
|
-
// Separate from KNOWN_COLUMNS because a NULL test is a different syntax the
|
|
787
|
-
// literal-comparison sweep cannot see (ADR 0213 / task 1002913).
|
|
788
|
-
const KNOWN_NULL_TESTS = new Set(['terms_accepted_at']);
|
|
789
|
-
|
|
790
785
|
/**
|
|
791
786
|
* Build a row test from a statement's own WHERE clause. Literal comparisons
|
|
792
787
|
* (profile_state = 'active') matter as much as placeholders here, because the
|
|
793
788
|
* effective-visibility predicate is made ENTIRELY of literal comparisons — a
|
|
794
789
|
* fake pool that only modelled $N filters would score the privacy terms as
|
|
795
790
|
* absent and pass no matter what.
|
|
791
|
+
*
|
|
792
|
+
* IT READS STRUCTURE, NOT JUST TERMS, which is why this is a CONSUMING parse.
|
|
793
|
+
* Two ways a term-collecting interpreter lies, and this shape closes both:
|
|
794
|
+
*
|
|
795
|
+
* THE MISSING TERM. The ADR 0213 acceptance half is a NULL TEST — a third
|
|
796
|
+
* syntax neither the placeholder sweep nor the literal sweep can see. An
|
|
797
|
+
* unmodelled one degrades to no filter exactly like an unread $N.
|
|
798
|
+
* THE REWRITTEN JOINER. Collecting recognised terms and ANDing them re-imposes
|
|
799
|
+
* an `AND` the SQL may no longer contain: rewrite accountVisibleSql's `AND`
|
|
800
|
+
* to `OR` and Postgres serves every ACTIVE account, private ones included,
|
|
801
|
+
* while the fake answers exactly as before (idea 1000877, from the
|
|
802
|
+
* adversarial review of task 1002968).
|
|
803
|
+
*
|
|
804
|
+
* So each recognised term is REMOVED from the text as it is matched and the
|
|
805
|
+
* residue must be a pure conjunction. That single check subsumes the NULL-test
|
|
806
|
+
* bookkeeping this function used to carry — recognition and application can no
|
|
807
|
+
* longer come apart, because an unapplied term is simply still there.
|
|
796
808
|
*/
|
|
797
809
|
function accountFilter(where, params) {
|
|
798
810
|
const tests = [];
|
|
799
811
|
const consumed = new Set();
|
|
800
|
-
|
|
801
|
-
//
|
|
802
|
-
const
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
// the sweep must prove a filter was APPLIED, not merely that the column is a
|
|
810
|
-
// name it recognises. (Checking recognition alone left this interpreter
|
|
811
|
-
// scoring a deleted filter as fine — found by mutation while writing it.)
|
|
812
|
-
if (/\bterms_accepted_at IS NOT NULL/.test(where)) {
|
|
813
|
-
modelled.add('terms_accepted_at');
|
|
814
|
-
tests.push((r) => r.terms_accepted_at != null);
|
|
815
|
-
}
|
|
816
|
-
if (/\baccount_visibility = 'public'/.test(where)) tests.push((r) => r.account_visibility === 'public');
|
|
817
|
-
if (/\bNOT (?:[a-z_]+\.)?hide_stats\b/.test(where)) tests.push((r) => !r.hide_stats);
|
|
818
|
-
// The recruiting-reach COALESCE (task 1002290). Modelled through the shared JS
|
|
819
|
-
// twin, not re-implemented — an interpreter that read this term its own way
|
|
820
|
-
// could agree with a WRONG query. Unmodelled it would degrade to no filter,
|
|
821
|
-
// which is precisely what this interpreter exists to refuse.
|
|
822
|
-
if (/COALESCE\((?:[a-z_]+\.)?recruiter_discoverable,/.test(where)) tests.push(isRecruiterDiscoverable);
|
|
823
|
-
|
|
824
|
-
const byId = /\b(?:[a-z_]+\.)?github_id = \$(\d+)/.exec(where);
|
|
825
|
-
if (byId) { consumed.add(byId[1]); const v = params[Number(byId[1]) - 1]; tests.push((r) => String(r.github_id) === String(v)); }
|
|
826
|
-
|
|
827
|
-
const byHandle = /lower\(handle\) = \$(\d+) OR lower\(previous_handle\) = \$(\d+)/.exec(where);
|
|
828
|
-
if (byHandle) {
|
|
829
|
-
consumed.add(byHandle[1]); consumed.add(byHandle[2]);
|
|
830
|
-
const v = String(params[Number(byHandle[1]) - 1]);
|
|
831
|
-
tests.push((r) => String(r.handle || '').toLowerCase() === v || String(r.previous_handle || '').toLowerCase() === v);
|
|
832
|
-
}
|
|
812
|
+
let residue = where;
|
|
813
|
+
// Match a term, model it, and CONSUME its text so the leftovers can be judged.
|
|
814
|
+
const take = (re, make) => {
|
|
815
|
+
const m = re.exec(residue);
|
|
816
|
+
if (!m) return null;
|
|
817
|
+
residue = residue.slice(0, m.index) + residue.slice(m.index + m[0].length);
|
|
818
|
+
tests.push(make(m));
|
|
819
|
+
return m;
|
|
820
|
+
};
|
|
833
821
|
|
|
822
|
+
// The recruiting-reach COALESCE (task 1002290) FIRST, because it spells
|
|
823
|
+
// `account_visibility = 'public'` inside itself as its own fallback —
|
|
824
|
+
// consuming it first stops that fallback being scored as a separate existence
|
|
825
|
+
// check. Modelled through the shared JS twin, not re-implemented: an
|
|
826
|
+
// interpreter that read this term its own way could agree with a WRONG query.
|
|
827
|
+
take(/\bCOALESCE\((?:[a-z_]+\.)?recruiter_discoverable,\s*(?:[a-z_]+\.)?account_visibility = 'public'\s*\)/,
|
|
828
|
+
() => isRecruiterDiscoverable);
|
|
829
|
+
take(/\b(?:[a-z_]+\.)?profile_state = 'active'/, () => (r) => r.profile_state === 'active');
|
|
830
|
+
take(/\b(?:[a-z_]+\.)?terms_accepted_at IS NOT NULL/, () => (r) => r.terms_accepted_at != null);
|
|
831
|
+
take(/\b(?:[a-z_]+\.)?account_visibility = 'public'/, () => (r) => r.account_visibility === 'public');
|
|
832
|
+
take(/\bNOT (?:[a-z_]+\.)?hide_stats\b/, () => (r) => !r.hide_stats);
|
|
833
|
+
|
|
834
|
+
take(/\b(?:[a-z_]+\.)?github_id = \$(\d+)/, (m) => {
|
|
835
|
+
consumed.add(m[1]);
|
|
836
|
+
const v = params[Number(m[1]) - 1];
|
|
837
|
+
return (r) => String(r.github_id) === String(v);
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
// The rename window's disjunction is the ONE legitimate OR in this repo's
|
|
841
|
+
// account reads, so it is consumed as a single unit — leaving a bare `OR`
|
|
842
|
+
// behind would trip the structure check below.
|
|
843
|
+
take(/lower\(handle\) = \$(\d+) OR lower\(previous_handle\) = \$(\d+)/, (m) => {
|
|
844
|
+
consumed.add(m[1]); consumed.add(m[2]);
|
|
845
|
+
const v = String(params[Number(m[1]) - 1]);
|
|
846
|
+
return (r) => String(r.handle || '').toLowerCase() === v || String(r.previous_handle || '').toLowerCase() === v;
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
// The TERM guards run before the STRUCTURE guard, so an unreadable term is
|
|
850
|
+
// reported as the unreadable term it is rather than as leftover text.
|
|
834
851
|
for (const m of where.matchAll(/\$(\d+)/g)) {
|
|
835
852
|
if (!consumed.has(m[1])) {
|
|
836
853
|
throw new Error(`fake pool: unrecognised predicate on $${m[1]} in "${where}" — a filter this interpreter cannot read must never degrade to no filter`);
|
|
@@ -841,17 +858,8 @@ function accountFilter(where, params) {
|
|
|
841
858
|
throw new Error(`fake pool: unrecognised literal comparison on ${m[1]} in "${where}"`);
|
|
842
859
|
}
|
|
843
860
|
}
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
// absent-and-fine, which is the failure mode the other two sweeps exist to
|
|
847
|
-
// prevent one syntax over.
|
|
848
|
-
for (const m of where.matchAll(/\b([a-z_]+)\s+IS\s+(?:NOT\s+)?NULL\b/gi)) {
|
|
849
|
-
if (!KNOWN_NULL_TESTS.has(m[1])) {
|
|
850
|
-
throw new Error(`fake pool: unrecognised NULL test on ${m[1]} in "${where}" — a filter this interpreter cannot read must never degrade to no filter`);
|
|
851
|
-
}
|
|
852
|
-
if (!modelled.has(m[1])) {
|
|
853
|
-
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, which is the silent-pass it exists to refuse`);
|
|
854
|
-
}
|
|
861
|
+
if (!/^[\s()]*(?:AND[\s()]*)*$/.test(residue)) {
|
|
862
|
+
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`);
|
|
855
863
|
}
|
|
856
864
|
return (r) => tests.every((fn) => fn(r));
|
|
857
865
|
}
|
|
@@ -943,6 +951,28 @@ function get(path_, headers = {}) {
|
|
|
943
951
|
});
|
|
944
952
|
}
|
|
945
953
|
|
|
954
|
+
t('CALIBRATION: the interpreter refuses what it cannot model, rather than ignoring it', () => {
|
|
955
|
+
// The behavioural half below is worth exactly as much as this. Every 404 it
|
|
956
|
+
// asserts is produced by accountFilter, so an interpreter that silently
|
|
957
|
+
// dropped a term would keep them all green while the predicate rotted.
|
|
958
|
+
assert.throws(() => accountFilter("profile_state = 'active' AND tier = 'gold'", []),
|
|
959
|
+
/unrecognised literal comparison on tier/);
|
|
960
|
+
assert.throws(() => accountFilter("profile_state = 'active' AND cohort = $1", ['beta']),
|
|
961
|
+
/unrecognised predicate on \$1/);
|
|
962
|
+
assert.throws(() => accountFilter("profile_state = 'active' AND deleted_at IS NULL", []),
|
|
963
|
+
/unmodelled boolean structure/,
|
|
964
|
+
'a NULL TEST is a third syntax, invisible to both sweeps above — the ADR 0213 shape');
|
|
965
|
+
assert.throws(() => accountFilter("profile_state = 'active' OR account_visibility = 'public'", []),
|
|
966
|
+
/unmodelled boolean structure/,
|
|
967
|
+
'an AND rewritten to OR serves every ACTIVE account including the private ones; a '
|
|
968
|
+
+ 're-imposed AND would keep every 404 below green (idea 1000877)');
|
|
969
|
+
// ...and the real predicates still parse clean, in every composed form.
|
|
970
|
+
for (const clause of [accountActiveSql('pa'), accountVisibleSql('pa'), rollupVisibleSql('pa'),
|
|
971
|
+
scoutingListableSql('pa'), recruiterSliverVisibleSql('pa')]) {
|
|
972
|
+
assert.doesNotThrow(() => accountFilter(clause, []), `the shared predicate ${clause} must still be readable`);
|
|
973
|
+
}
|
|
974
|
+
});
|
|
975
|
+
|
|
946
976
|
await ta('CONTROL: the fixture and the interpreter really work — a public account resolves', async () => {
|
|
947
977
|
// Without this, every 404 below could be a broken fake rather than a boundary.
|
|
948
978
|
const res = await get('/api/gds/profiles/octo');
|
|
@@ -64,6 +64,12 @@ try {
|
|
|
64
64
|
// simply land with handle NULL — the re-run seeds only those. Idempotence is
|
|
65
65
|
// part of what this proves.
|
|
66
66
|
await q(migrationSql('platform_identity_008_handle.sql')).catch(() => {});
|
|
67
|
+
// getAccount projects them and resolveHandle floors on accountVisibleSql,
|
|
68
|
+
// which reads account_visibility (012) and terms_accepted_at (019) — without
|
|
69
|
+
// these a FRESH database answers 42703 "column does not exist" rather than a
|
|
70
|
+
// handle assertion.
|
|
71
|
+
await q(migrationSql('platform_identity_012_account_privacy.sql'));
|
|
72
|
+
await q(migrationSql('platform_identity_019_terms_acceptance.sql'));
|
|
67
73
|
await cleanup();
|
|
68
74
|
|
|
69
75
|
await q(
|
|
@@ -116,7 +122,15 @@ try {
|
|
|
116
122
|
const prov = await pi.resolveHandle('p01-again', { pool });
|
|
117
123
|
ok(prov === null, 'a provisional account is not addressable — even by its current handle');
|
|
118
124
|
|
|
119
|
-
|
|
125
|
+
// Both consent columns, because since ADR 0213 addressability floors on BOTH
|
|
126
|
+
// and they are stamped by the SAME upsertAccount branch — an UPDATE that set
|
|
127
|
+
// only profile_state would model a state the code cannot produce, and the
|
|
128
|
+
// handle would stay unaddressable for a reason this file is not about.
|
|
129
|
+
await q(
|
|
130
|
+
`UPDATE platform_identity_accounts SET profile_state = 'active', terms_accepted_at = now()
|
|
131
|
+
WHERE github_id = ANY($1::bigint[])`,
|
|
132
|
+
[[FRESH, TWIN_A]],
|
|
133
|
+
);
|
|
120
134
|
|
|
121
135
|
const cur = await pi.resolveHandle('P01-Again', { pool });
|
|
122
136
|
ok(cur && cur.account && cur.account.handle === 'p01-again', 'the current handle resolves case-insensitively');
|
|
@@ -104,6 +104,10 @@ function get(port, path) {
|
|
|
104
104
|
const NEVER_IN_A_BODY = [
|
|
105
105
|
SECRET_CLIENT_PUB, SECRET_CLIENT_PRIV, SECRET_HASH, SECRET_PROJECT, SECRET_PREV,
|
|
106
106
|
'profile_state', 'hide_stats', 'github_id', 'display_name_source',
|
|
107
|
+
// The ADR 0213 consent column joins the list on the same terms as
|
|
108
|
+
// profile_state: it is an INPUT to the public predicate, never an output, and
|
|
109
|
+
// when the account accepted the terms is nobody else's business.
|
|
110
|
+
'terms_accepted_at',
|
|
107
111
|
];
|
|
108
112
|
function assertNoSentinels(text, where) {
|
|
109
113
|
for (const s of NEVER_IN_A_BODY) {
|
|
@@ -119,16 +123,26 @@ try {
|
|
|
119
123
|
await q(migrationSql('platform_identity_007_profile_consent.sql'));
|
|
120
124
|
await q(migrationSql('platform_identity_008_handle.sql'));
|
|
121
125
|
await q(migrationSql('platform_identity_009_profile_fields.sql'));
|
|
126
|
+
// resolveHandle floors on accountVisibleSql, which reads account_visibility
|
|
127
|
+
// (012) and terms_accepted_at (019). Without these a FRESH database answers
|
|
128
|
+
// 42703 "column does not exist" instead of a 404-vs-200 assertion.
|
|
129
|
+
await q(migrationSql('platform_identity_012_account_privacy.sql'));
|
|
130
|
+
await q(migrationSql('platform_identity_019_terms_acceptance.sql'));
|
|
122
131
|
await cleanup();
|
|
123
132
|
|
|
124
133
|
// The active account carries the P02 fields + a user-set name source, so the
|
|
125
134
|
// 200 proves bio/links serialize while display_name_source never does.
|
|
135
|
+
//
|
|
136
|
+
// The consented rows stamp terms_accepted_at as well as profile_state: since
|
|
137
|
+
// ADR 0213 addressability floors on BOTH, and a fixture seeding only the
|
|
138
|
+
// first is a pre-terms account that 404s. The provisional row keeps NULL —
|
|
139
|
+
// it is not consented on either column.
|
|
126
140
|
await q(
|
|
127
141
|
`INSERT INTO platform_identity_accounts
|
|
128
|
-
(github_id, github_login, display_name, avatar_url, profile_state, hide_stats, handle, previous_handle, bio, links, display_name_source)
|
|
129
|
-
VALUES ($1, 'p05-active-login', 'P05 Active', 'https://a.example/a.png', 'active', false, 'p05-active', $4, 'Builds things.', '{"website":"https://p05.example/"}', 'user'),
|
|
130
|
-
($2, 'p05-hidden-login', 'P05 Hidden', NULL, 'active', true, 'p05-hidden', NULL, NULL, NULL, 'github'),
|
|
131
|
-
($3, 'p05-prov-login', 'P05 Prov', NULL, 'provisional', false, 'p05-prov', NULL, NULL, NULL, 'github')`,
|
|
142
|
+
(github_id, github_login, display_name, avatar_url, profile_state, terms_accepted_at, hide_stats, handle, previous_handle, bio, links, display_name_source)
|
|
143
|
+
VALUES ($1, 'p05-active-login', 'P05 Active', 'https://a.example/a.png', 'active', now(), false, 'p05-active', $4, 'Builds things.', '{"website":"https://p05.example/"}', 'user'),
|
|
144
|
+
($2, 'p05-hidden-login', 'P05 Hidden', NULL, 'active', now(), true, 'p05-hidden', NULL, NULL, NULL, 'github'),
|
|
145
|
+
($3, 'p05-prov-login', 'P05 Prov', NULL, 'provisional', NULL, false, 'p05-prov', NULL, NULL, NULL, 'github')`,
|
|
132
146
|
[ACTIVE, HIDDEN, PROV, SECRET_PREV],
|
|
133
147
|
);
|
|
134
148
|
await q(
|
|
@@ -81,28 +81,65 @@ function seed() {
|
|
|
81
81
|
// Build a row filter from the statement's OWN where clause. Every placeholder the
|
|
82
82
|
// clause mentions must be consumed by a fragment below; one that is not throws,
|
|
83
83
|
// so a predicate this cannot read can never degrade into "no constraint".
|
|
84
|
+
//
|
|
85
|
+
// IT READS STRUCTURE, NOT JUST TERMS (task 1003354, idea 1000877). Each matched
|
|
86
|
+
// term is REMOVED from the clause, and whatever survives must be a pure
|
|
87
|
+
// conjunction — whitespace, parentheses and `AND`. Three holes this closes, all
|
|
88
|
+
// of them "the interpreter silently answered a question it was not asked":
|
|
89
|
+
//
|
|
90
|
+
// A LITERAL-ONLY term binds no placeholder, so the sweep below cannot see it.
|
|
91
|
+
// `revoked_at IS NULL` could be deleted from the real revoke statement and
|
|
92
|
+
// this fake would keep answering as though it were there.
|
|
93
|
+
// AN `AND` REWRITTEN TO `OR` is the dangerous one on this table: revoke is
|
|
94
|
+
// `id = $1 AND builder_id = $2`, so an `OR` lets any builder revoke any
|
|
95
|
+
// session by id — the exact own-scope isolation this file exists to prove —
|
|
96
|
+
// while a term-collecting interpreter re-imposes the `AND` and stays green.
|
|
97
|
+
// A CLAUSE WITH NO READABLE TERM used to yield an empty `tests` array, which
|
|
98
|
+
// matches EVERY row. A statement that carries a WHERE must narrow.
|
|
99
|
+
//
|
|
100
|
+
// The session-expiry disjunction is the ONE legitimate `OR` here and is consumed
|
|
101
|
+
// as a whole unit, which is what lets it survive the check.
|
|
84
102
|
function predicateFor(sql, params) {
|
|
85
|
-
|
|
103
|
+
// The tail is not part of the predicate. It has to go before the residue is
|
|
104
|
+
// judged, or `ORDER BY last_used_at DESC` would read as an unmodelled term.
|
|
105
|
+
const raw = sql.includes(' WHERE ')
|
|
106
|
+
? sql.slice(sql.indexOf(' WHERE ') + 7).split(/ (?:ORDER BY|GROUP BY|RETURNING|LIMIT|OFFSET) /)[0]
|
|
107
|
+
: '';
|
|
86
108
|
const tests = [];
|
|
87
109
|
const consumed = new Set();
|
|
110
|
+
let residue = raw;
|
|
111
|
+
// Some terms bind a placeholder and some are pure literals (`revoked_at IS
|
|
112
|
+
// NULL`), so the bound value is resolved only when there IS a capture group —
|
|
113
|
+
// passing `params[NaN]` and relying on the maker to ignore it would work by
|
|
114
|
+
// accident, and would break silently the day a no-capture maker read its arg.
|
|
88
115
|
const take = (re, make) => {
|
|
89
|
-
const m = re.exec(
|
|
116
|
+
const m = re.exec(residue);
|
|
90
117
|
if (!m) return;
|
|
118
|
+
residue = residue.slice(0, m.index) + residue.slice(m.index + m[0].length);
|
|
119
|
+
if (m[1] === undefined) { tests.push(make()); return; }
|
|
91
120
|
consumed.add(m[1]);
|
|
92
121
|
tests.push(make(params[Number(m[1]) - 1]));
|
|
93
122
|
};
|
|
123
|
+
// The expiry disjunction FIRST, as a unit — otherwise the bare-column matchers
|
|
124
|
+
// would tear it in half and leave a dangling `OR` behind.
|
|
125
|
+
take(/\(?expires_at IS NULL OR expires_at > now\(\)\)?/,
|
|
126
|
+
() => (r) => r.expires_at === null || r.expires_at > new Date());
|
|
94
127
|
take(/\b(?:s\.)?token = \$(\d+)/, (v) => (r) => r.token === v);
|
|
95
128
|
take(/(?<!_)\b(?:s\.)?id = \$(\d+)/, (v) => (r) => String(r.id) === String(v));
|
|
96
129
|
take(/\bbuilder_id = \$(\d+)/, (v) => (r) => String(r.builder_id) === String(v));
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
for (const m of where.matchAll(/\$(\d+)/g)) {
|
|
130
|
+
take(/\brevoked_at IS NULL/, () => (r) => r.revoked_at === null);
|
|
131
|
+
|
|
132
|
+
for (const m of raw.matchAll(/\$(\d+)/g)) {
|
|
102
133
|
if (!consumed.has(m[1])) {
|
|
103
|
-
throw new Error(`fake pool: unrecognised predicate on $${m[1]} in "${
|
|
134
|
+
throw new Error(`fake pool: unrecognised predicate on $${m[1]} in "${raw}" — the interpreter must not silently ignore a filter`);
|
|
104
135
|
}
|
|
105
136
|
}
|
|
137
|
+
if (!/^[\s()]*(?:AND[\s()]*)*$/.test(residue)) {
|
|
138
|
+
throw new Error(`fake pool: unmodelled boolean structure "${residue.trim()}" in "${raw}" — 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`);
|
|
139
|
+
}
|
|
140
|
+
if (raw && !tests.length) {
|
|
141
|
+
throw new Error(`fake pool: the predicate in "${raw}" carried NO readable term — an empty filter matches every row, which is the opposite of what a WHERE is for`);
|
|
142
|
+
}
|
|
106
143
|
return (r) => tests.every((fn) => fn(r));
|
|
107
144
|
}
|
|
108
145
|
|
|
@@ -189,7 +226,38 @@ const aliveFor = (builderId) => sessions.filter((s) => s.builder_id === builderI
|
|
|
189
226
|
// 1 — a xenos can see and kill their OWN sessions, with no Archon in sight
|
|
190
227
|
// ===========================================================================
|
|
191
228
|
|
|
192
|
-
console.log('
|
|
229
|
+
console.log('calibration — the instrument is checked before it is trusted:');
|
|
230
|
+
|
|
231
|
+
await t('the interpreter refuses what it cannot model, rather than ignoring it', () => {
|
|
232
|
+
// This file had no calibration at all until task 1003354, which is how it kept
|
|
233
|
+
// three silently-permissive paths: an unread literal, an OR it re-ANDed, and
|
|
234
|
+
// an empty predicate that matched every row. Every isolation assertion below
|
|
235
|
+
// is produced by predicateFor, so each of those made the whole file decorative.
|
|
236
|
+
assert.throws(() => predicateFor('SELECT id FROM builder_sessions WHERE id = $1 AND source = $2', [1, 'cli']),
|
|
237
|
+
/unrecognised predicate on \$2/,
|
|
238
|
+
'an unread placeholder — the older half of the rule');
|
|
239
|
+
assert.throws(() => predicateFor('SELECT id FROM builder_sessions WHERE id = $1 AND source IS NULL', [1]),
|
|
240
|
+
/unmodelled boolean structure/,
|
|
241
|
+
'a literal-only term binds nothing, so the placeholder sweep cannot see it');
|
|
242
|
+
assert.throws(() => predicateFor('UPDATE builder_sessions SET revoked_at = now() WHERE id = $1 OR builder_id = $2', [1, 1]),
|
|
243
|
+
/unmodelled boolean structure/,
|
|
244
|
+
'and the one that matters here: with OR, any builder revokes any session by id — while a '
|
|
245
|
+
+ 'term-collecting interpreter re-imposes the AND and every test below stays green (idea 1000877)');
|
|
246
|
+
assert.throws(() => predicateFor('SELECT id FROM builder_sessions WHERE source IS NOT NULL', []),
|
|
247
|
+
/unmodelled boolean structure/,
|
|
248
|
+
'and a clause with no readable term at all: an empty filter matches every row');
|
|
249
|
+
// ...and the real statements still parse clean.
|
|
250
|
+
assert.doesNotThrow(() => predicateFor(
|
|
251
|
+
'SELECT id FROM builder_sessions WHERE builder_id = $1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now()) ORDER BY last_used_at DESC, id DESC',
|
|
252
|
+
[1],
|
|
253
|
+
), 'the own-scope list, tail and legitimate OR included');
|
|
254
|
+
assert.doesNotThrow(() => predicateFor(
|
|
255
|
+
'UPDATE builder_sessions SET revoked_at = now() WHERE id = $1 AND builder_id = $2 AND revoked_at IS NULL RETURNING id',
|
|
256
|
+
[1, 1],
|
|
257
|
+
), 'and the revoke');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
console.log('\nown scope — a non-admin builder manages their own sessions:');
|
|
193
261
|
|
|
194
262
|
await t('GET /me/sessions lists only the caller\'s LIVE sessions', async () => {
|
|
195
263
|
seed(); calls = [];
|
|
@@ -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', () => {
|