@bongos/core 1.19.427 → 1.19.428
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 +47 -27
- package/docs/adr/0213-platform-terms-acceptance-is-the-hubs-own-witness.md +71 -0
- package/docs/adr/README.md +1 -0
- package/docs/copy-inventory.md +22 -14
- package/docs/copy-registry.json +93 -21
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +2 -0
- package/modules/platform-identity/account-visibility.js +31 -12
- package/modules/platform-identity/connections.js +4 -3
- package/modules/platform-identity/migrations/platform_identity_019_terms_acceptance.sql +47 -0
- package/modules/platform-identity/platform-identity.js +12 -10
- package/modules/platform-identity/routes/connections.js +4 -3
- package/modules/platform-identity/terms-acceptance.js +97 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/terms.html +8 -0
- package/src/module-api.js +1 -1
- package/tests/connections_api.mjs +13 -8
- package/tests/connections_ownership.mjs +6 -1
- package/tests/effective_visibility_predicate.mjs +89 -14
- package/tests/profile_connections_ui.mjs +22 -7
- package/tests/profile_privacy_owner_view.mjs +31 -4
- package/tests/profile_route.mjs +15 -8
- package/tests/terms_acceptance.mjs +573 -0
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.428",
|
|
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.428",
|
|
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.428",
|
|
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",
|
|
@@ -61,6 +61,14 @@
|
|
|
61
61
|
<p>A project stood up from the hub runs on the platform's servers at your request. Any cost is shown before you confirm. Tearing it down stops its server; it keeps its database and backups so it can come back, and its web address stays with it while it can.</p>
|
|
62
62
|
<!--apex-only:end-->
|
|
63
63
|
|
|
64
|
+
<!--apex-only:start-->
|
|
65
|
+
<h2>Your work across projects</h2>
|
|
66
|
+
<p>Each {{productName}} project you build in reports a summary of your work there to the platform: the {{currency}} you earned, the works you shipped, your karma, the rank that project gave you, the disciplines you worked in, and when you were last active. The platform adds these up across every project into one cross-project record. Projects report this themselves — the platform does not read their databases.</p>
|
|
67
|
+
<p><b>These numbers describe; they do not govern.</b> Your cross-project record is descriptive analytics and never authority. What you may do inside any project is decided by that project alone, from its own records, at the moment you ask. A large record grants nothing and a hidden one takes nothing away; no project is required to read it, and none may treat it as permission. A rank shown here is that project’s own standing for you, as that project reported it — the platform holds no rank of its own.</p>
|
|
68
|
+
<p><b>What is public.</b> Once you have signed in here and accepted these terms, your profile and that record are public by default: at your handle’s page, in the people directory, to project owners looking for people to work with, and to search engines. An account that has never signed in here stays provisional and is published nowhere.</p>
|
|
69
|
+
<p><b>What you control.</b> From your settings you can hide your statistics, drop out of the recruiting directory, or make your account private. Each takes effect immediately, everywhere, including on a page someone already has open. Hiding removes the platform’s combined view; it does not retract the per-project numbers each project publishes itself, so a determined reader could still add them up the hard way.</p>
|
|
70
|
+
<p><b>Your acceptance is recorded.</b> The platform stores the date you accepted these terms, and which version, on your account. It is written only by signing in here, and only once — signing in again does not change the original date. This is version <b>2026-08-29</b>.</p>
|
|
71
|
+
<!--apex-only:end-->
|
|
64
72
|
<h2>Fair use</h2>
|
|
65
73
|
<p>Don't use {{productName}} to break the law, to attack this site or other people's projects, to mine, spam or otherwise abuse hosted resources, or to harass other builders. The owner may deactivate a builder or take a project offline for breaking these, and may set further house rules inside the hall.</p>
|
|
66
74
|
|
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.428'; // 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');
|
|
@@ -126,12 +126,15 @@ function call(method, path, body) {
|
|
|
126
126
|
});
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
// The first query every route makes is the caller's own account (the
|
|
130
|
-
//
|
|
131
|
-
|
|
129
|
+
// The first query every route makes is the caller's own account (the CONSENT
|
|
130
|
+
// floor, ADR 0202 D4 §1). Since ADR 0213 that floor is two columns: the hub
|
|
131
|
+
// witnessed the human AND that human accepted the platform terms, both written
|
|
132
|
+
// by the same upsertAccount branch — so a consented fixture carries both.
|
|
133
|
+
const ACCEPTED_AT = '2026-08-29T00:00:00.000Z';
|
|
134
|
+
const meActive = () => result([{ github_id: String(ME.github_id), profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', handle: 'octo' }]);
|
|
132
135
|
const account = (over = {}) => ({
|
|
133
136
|
github_id: String(ADA), handle: 'ada', display_name: 'Ada', avatar_url: null,
|
|
134
|
-
profile_state: 'active', account_visibility: 'public', ...over,
|
|
137
|
+
profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', ...over,
|
|
135
138
|
});
|
|
136
139
|
|
|
137
140
|
// ===========================================================================
|
|
@@ -299,8 +302,9 @@ test('list: my connections, the count IS the array length, and paging is real',
|
|
|
299
302
|
assert.match(calls[1].text, /LIMIT \$2 OFFSET \$3/,
|
|
300
303
|
'a hard cap with no OFFSET would make the 501st connection unreachable while count still read 500');
|
|
301
304
|
assert.deepEqual(calls[1].params, [ME.github_id, 2, 4]);
|
|
302
|
-
assert.match(calls[1].text, /\(pa\.profile_state = 'active'\)/,
|
|
303
|
-
'own scope floors on consent alone — a private account you connected with appears HERE (D4 §2)'
|
|
305
|
+
assert.match(calls[1].text, /\(pa\.profile_state = 'active' AND pa\.terms_accepted_at IS NOT NULL\)/,
|
|
306
|
+
'own scope floors on consent alone — a private account you connected with appears HERE (D4 §2). '
|
|
307
|
+
+ 'Consent is BOTH columns since ADR 0213: the hub witnessed them, and they accepted the platform terms.');
|
|
304
308
|
assert.doesNotMatch(calls[1].text, /account_visibility/,
|
|
305
309
|
"…and applying the PUBLIC predicate here would erase your own connections from your own list");
|
|
306
310
|
});
|
|
@@ -478,8 +482,9 @@ test('degree: 3rd degree runs exactly ONE more query, capped at FANOUT_1 then FA
|
|
|
478
482
|
assertBounded(far.text, 'far');
|
|
479
483
|
assert.deepEqual(far.params, [ME.github_id, String(ADA), conn.FANOUT_1, conn.FANOUT_2],
|
|
480
484
|
'the caps are the ADR 0202 D3 constants, bound as parameters');
|
|
481
|
-
assert.match(far.text, /\(pa\.profile_state = 'active' AND pa\.account_visibility = 'public'\)/,
|
|
482
|
-
'a hop BEYOND the viewer\'s own connections must clear the full public predicate'
|
|
485
|
+
assert.match(far.text, /\(\(pa\.profile_state = 'active' AND pa\.terms_accepted_at IS NOT NULL\) AND pa\.account_visibility = 'public'\)/,
|
|
486
|
+
'a hop BEYOND the viewer\'s own connections must clear the full public predicate — the ADR 0213 '
|
|
487
|
+
+ 'consent pair AND the existence axis, composed rather than restated');
|
|
483
488
|
});
|
|
484
489
|
|
|
485
490
|
test('degree: past DEGREE_MAX the answer is null — "out of network", not a deeper search', async () => {
|
|
@@ -106,9 +106,14 @@ function predicateFor(rawWhere, params) {
|
|
|
106
106
|
return (r) => tests.every((fn) => fn(r));
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
// Consent is two columns since ADR 0213 (task 1002913): the hub witnessed this
|
|
110
|
+
// human AND that human accepted the platform terms. Both are written by the same
|
|
111
|
+
// upsertAccount branch, so a genuinely consented account always carries both —
|
|
112
|
+
// and isAccountActive, which every route here floors on, checks both.
|
|
109
113
|
const ACCOUNT = {
|
|
110
114
|
id: 1, github_id: String(ME), github_login: 'octo', display_name: 'Octo', avatar_url: null,
|
|
111
|
-
profile_state: 'active',
|
|
115
|
+
profile_state: 'active', terms_accepted_at: '2026-08-29T00:00:00.000Z',
|
|
116
|
+
hide_stats: false, account_visibility: 'public',
|
|
112
117
|
recruiter_discoverable: null, handle: 'octo', previous_handle: null,
|
|
113
118
|
};
|
|
114
119
|
|
|
@@ -256,6 +256,14 @@ const SPELLINGS = [
|
|
|
256
256
|
[/\bCOALESCE\s*\(\s*(?:[A-Za-z_$][\w$]*|\$\{[^}]*\})?\s*\.?\s*recruiter_discoverable/gi, 'the recruiter-reach COALESCE'],
|
|
257
257
|
[/\bNOT\s+(?:[A-Za-z_][\w]*\.)?hide_stats\b/g, 'a SQL NOT hide_stats'],
|
|
258
258
|
[/\bhide_stats\s*(?:=|==|===)\s*(?:true|false)\b/g, 'hide_stats compared to a boolean'],
|
|
259
|
+
// The ADR 0213 acceptance half of the consent axis (task 1002913), in the two
|
|
260
|
+
// syntaxes a drift copy would actually be written in: the SQL NULL test the
|
|
261
|
+
// owner emits (bare, aliased, or through a `${q}` prefix) and the JS twin's
|
|
262
|
+
// null comparison. Written against the form the source REALLY carries, because
|
|
263
|
+
// a rule matching only a spelling nobody uses is the hole task 1002290 found in
|
|
264
|
+
// the reach COALESCE — the whole reason that entry now pins four forms.
|
|
265
|
+
[/\bterms_accepted_at\s+IS\s+(?:NOT\s+)?NULL\b/gi, 'a terms-acceptance NULL test'],
|
|
266
|
+
[/\bterms_accepted_at\s*(?:=|==|===|!=|!==|<>)\s*null\b/gi, 'terms_accepted_at compared to null'],
|
|
259
267
|
];
|
|
260
268
|
|
|
261
269
|
function spellingsIn(code) {
|
|
@@ -369,6 +377,16 @@ t('SPELLINGS catch a hand-written variant in either language, and leave writes a
|
|
|
369
377
|
assert.ok(caught('COALESCE(${alias}.recruiter_discoverable, TRUE)'), 'a template alias AND a dot — the drift copy most likely to be written');
|
|
370
378
|
assert.ok(!caught('COALESCE(hide_stats, false)'), 'and it is the REACH column it watches, not any COALESCE');
|
|
371
379
|
assert.ok(caught('AND NOT pa.hide_stats'), 'the hide half of a rollup predicate');
|
|
380
|
+
// The ADR 0213 consent half, in every form that ships or could drift. The bare
|
|
381
|
+
// and `${q}` forms are what the owner emits today; the aliased form is what a
|
|
382
|
+
// drift copy in a joined query would most likely write.
|
|
383
|
+
assert.ok(caught('AND terms_accepted_at IS NOT NULL'), 'the acceptance NULL test, bare');
|
|
384
|
+
assert.ok(caught('AND pa.terms_accepted_at IS NOT NULL'), 'the acceptance NULL test, aliased');
|
|
385
|
+
assert.ok(caught('`AND ${q}terms_accepted_at IS NOT NULL`'), 'the acceptance NULL test, as the builder writes it');
|
|
386
|
+
assert.ok(caught('WHERE terms_accepted_at IS NULL'), 'and the inverted test — the same decision, negated');
|
|
387
|
+
assert.ok(caught('if (a.terms_accepted_at != null) return a;'), 'the JS twin of the acceptance test');
|
|
388
|
+
assert.ok(!caught('COALESCE(platform_identity_accounts.terms_accepted_at, now())'), 'a write is not a predicate');
|
|
389
|
+
assert.ok(!caught('SELECT profile_state, terms_accepted_at, account_visibility'), 'and naming the column in a projection decides nothing');
|
|
372
390
|
// A WRITE names the column but compares it to nothing — it decides no visibility.
|
|
373
391
|
assert.ok(!caught("profile_state = CASE WHEN $5::boolean THEN 'active' ELSE x END"), 'an upsert is not a predicate');
|
|
374
392
|
assert.ok(!caught("account_visibility: (acct && acct.account_visibility) || 'public'"), 'a projection default is not a predicate');
|
|
@@ -439,13 +457,24 @@ t('the alias is the one interpolated thing, and every builder refuses a non-iden
|
|
|
439
457
|
t('the JS ports answer exactly what the SQL asks, row for row', () => {
|
|
440
458
|
// The table IS the D2 matrix. Each row is checked against BOTH forms, which is
|
|
441
459
|
// the only way "a route and a query cannot drift" is more than a slogan.
|
|
460
|
+
// ADR 0213 (task 1002913) made consent TWO columns: the hub witnessed the human
|
|
461
|
+
// (profile_state) AND that human accepted the platform terms (terms_accepted_at).
|
|
462
|
+
// Both are stamped by the same upsertAccount branch, so `active` + NULL only
|
|
463
|
+
// occurs for a row predating the terms — which must NOT publish.
|
|
464
|
+
const ACCEPTED = new Date('2026-08-29T00:00:00Z');
|
|
442
465
|
const rows = [
|
|
443
|
-
{ row: { profile_state: 'active', account_visibility: 'public', hide_stats: false }, visible: true, rollup: true },
|
|
444
|
-
{ row: { profile_state: 'active', account_visibility: 'public', hide_stats: true }, visible: true, rollup: false },
|
|
445
|
-
{ row: { profile_state: 'active', account_visibility: 'private', hide_stats: false }, visible: false, rollup: false },
|
|
446
|
-
{ row: { profile_state: 'active', account_visibility: 'private', hide_stats: true }, visible: false, rollup: false },
|
|
447
|
-
{ row: { profile_state: 'provisional', account_visibility: 'public', hide_stats: false }, visible: false, rollup: false },
|
|
448
|
-
{ row: { profile_state: 'provisional', account_visibility: 'private', hide_stats: false }, visible: false, rollup: false },
|
|
466
|
+
{ row: { profile_state: 'active', terms_accepted_at: ACCEPTED, account_visibility: 'public', hide_stats: false }, visible: true, rollup: true },
|
|
467
|
+
{ row: { profile_state: 'active', terms_accepted_at: ACCEPTED, account_visibility: 'public', hide_stats: true }, visible: true, rollup: false },
|
|
468
|
+
{ row: { profile_state: 'active', terms_accepted_at: ACCEPTED, account_visibility: 'private', hide_stats: false }, visible: false, rollup: false },
|
|
469
|
+
{ row: { profile_state: 'active', terms_accepted_at: ACCEPTED, account_visibility: 'private', hide_stats: true }, visible: false, rollup: false },
|
|
470
|
+
{ row: { profile_state: 'provisional', terms_accepted_at: null, account_visibility: 'public', hide_stats: false }, visible: false, rollup: false },
|
|
471
|
+
{ row: { profile_state: 'provisional', terms_accepted_at: null, account_visibility: 'private', hide_stats: false }, visible: false, rollup: false },
|
|
472
|
+
// The ADR 0213 axis: witnessed but never accepted (a legacy row, or a state
|
|
473
|
+
// no post-0213 write can produce) is NOT consented, so it publishes nothing.
|
|
474
|
+
{ row: { profile_state: 'active', terms_accepted_at: null, account_visibility: 'public', hide_stats: false }, visible: false, rollup: false },
|
|
475
|
+
{ row: { profile_state: 'active', terms_accepted_at: null, account_visibility: 'private', hide_stats: false }, visible: false, rollup: false },
|
|
476
|
+
// …and acceptance alone never substitutes for the hub's own witness.
|
|
477
|
+
{ row: { profile_state: 'provisional', terms_accepted_at: ACCEPTED, account_visibility: 'public', hide_stats: false }, visible: false, rollup: false },
|
|
449
478
|
];
|
|
450
479
|
for (const { row, visible, rollup } of rows) {
|
|
451
480
|
const id = JSON.stringify(row);
|
|
@@ -454,7 +483,9 @@ t('the JS ports answer exactly what the SQL asks, row for row', () => {
|
|
|
454
483
|
// The SQL twin, evaluated the only honest way a DB-free test can: the
|
|
455
484
|
// fragment's own column comparisons, resolved against this row.
|
|
456
485
|
const sqlSaysVisible = /profile_state = 'active'/.test(accountVisibleSql())
|
|
486
|
+
&& /terms_accepted_at IS NOT NULL/.test(accountVisibleSql())
|
|
457
487
|
&& row.profile_state === 'active'
|
|
488
|
+
&& row.terms_accepted_at != null
|
|
458
489
|
&& row.account_visibility === 'public';
|
|
459
490
|
assert.equal(sqlSaysVisible, visible, `the SQL fragment agrees for ${id}`);
|
|
460
491
|
}
|
|
@@ -646,41 +677,59 @@ t('every declared non-public read still exists (a stale declaration is a blind s
|
|
|
646
677
|
|
|
647
678
|
console.log('\nbehaviour (an executed privacy boundary):');
|
|
648
679
|
|
|
680
|
+
// Every consented fixture carries an acceptance stamp, because after ADR 0213
|
|
681
|
+
// (task 1002913) the consent axis is profile_state AND terms_accepted_at — the
|
|
682
|
+
// same upsertAccount branch writes both, so a real consented row always has one.
|
|
683
|
+
const ACCEPTED_AT = '2026-08-29T00:00:00.000Z';
|
|
684
|
+
|
|
649
685
|
const ACCOUNTS = [
|
|
650
686
|
{ github_id: '4242', github_login: 'octo', display_name: 'Octo', avatar_url: null,
|
|
651
687
|
handle: 'octo', previous_handle: null, bio: null, links: null, created_at: 'T',
|
|
652
|
-
profile_state: 'active', account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
|
|
688
|
+
profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
|
|
653
689
|
// The private account this whole task exists for.
|
|
654
690
|
{ github_id: '5150', github_login: 'ghost', display_name: 'Ghost', avatar_url: null,
|
|
655
691
|
handle: 'ghost', previous_handle: null, bio: null, links: null, created_at: 'T',
|
|
656
|
-
profile_state: 'active', account_visibility: 'private', hide_stats: false, recruiter_discoverable: null },
|
|
692
|
+
profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'private', hide_stats: false, recruiter_discoverable: null },
|
|
657
693
|
// Private AND recruiter-discoverable — the spec's D2 "private · discoverable"
|
|
658
694
|
// row. Reach must not buy addressability: this one still 404s.
|
|
659
695
|
{ github_id: '5151', github_login: 'scout', display_name: 'Scout', avatar_url: null,
|
|
660
696
|
handle: 'scout', previous_handle: null, bio: null, links: null, created_at: 'T',
|
|
661
|
-
profile_state: 'active', account_visibility: 'private', hide_stats: false, recruiter_discoverable: true },
|
|
697
|
+
profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'private', hide_stats: false, recruiter_discoverable: true },
|
|
662
698
|
// Public but hidden — the axes are independent: the PAGE resolves, the numbers do not.
|
|
663
699
|
{ github_id: '6060', github_login: 'shy', display_name: 'Shy', avatar_url: null,
|
|
664
700
|
handle: 'shy', previous_handle: null, bio: null, links: null, created_at: 'T',
|
|
665
|
-
profile_state: 'active', account_visibility: 'public', hide_stats: true, recruiter_discoverable: null },
|
|
701
|
+
profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: true, recruiter_discoverable: null },
|
|
666
702
|
{ github_id: '7070', github_login: 'newbie', display_name: 'Newbie', avatar_url: null,
|
|
667
703
|
handle: 'newbie', previous_handle: null, bio: null, links: null, created_at: 'T',
|
|
668
|
-
profile_state: 'provisional', account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
|
|
704
|
+
profile_state: 'provisional', terms_accepted_at: null, account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
|
|
705
|
+
// ADR 0213 (task 1002913): the hub witnessed this human, but no acceptance of
|
|
706
|
+
// the platform terms is on record — a row from before the terms existed. Public
|
|
707
|
+
// and unhidden on every OTHER axis, so if consent were still one column it would
|
|
708
|
+
// resolve. It must 404: the T&C is what "public by default" was always premised
|
|
709
|
+
// on, and this account never saw one. It re-publishes on its next hub sign-in.
|
|
710
|
+
{ github_id: '7171', github_login: 'legacy', display_name: 'Legacy', avatar_url: null,
|
|
711
|
+
handle: 'legacy', previous_handle: null, bio: null, links: null, created_at: 'T',
|
|
712
|
+
profile_state: 'active', terms_accepted_at: null, account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
|
|
669
713
|
// A private account inside the P01 rename window. Its OLD handle must 404 too —
|
|
670
714
|
// a 301 to the current handle would confirm the account exists.
|
|
671
715
|
{ github_id: '8080', github_login: 'moved', display_name: 'Moved', avatar_url: null,
|
|
672
716
|
handle: 'moved-now', previous_handle: 'moved-was', bio: null, links: null, created_at: 'T',
|
|
673
|
-
profile_state: 'active', account_visibility: 'private', hide_stats: false, recruiter_discoverable: null },
|
|
717
|
+
profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'private', hide_stats: false, recruiter_discoverable: null },
|
|
674
718
|
// ...and a PUBLIC one, so the control proves the 301 window still works.
|
|
675
719
|
{ github_id: '9090', github_login: 'shifted', display_name: 'Shifted', avatar_url: null,
|
|
676
720
|
handle: 'shifted-now', previous_handle: 'shifted-was', bio: null, links: null, created_at: 'T',
|
|
677
|
-
profile_state: 'active', account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
|
|
721
|
+
profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
|
|
678
722
|
];
|
|
679
723
|
|
|
680
724
|
// The columns this interpreter knows how to filter on. Anything else in a WHERE
|
|
681
725
|
// clause throws — the guard against a predicate quietly degrading to no filter.
|
|
682
726
|
const KNOWN_COLUMNS = new Set(['profile_state', 'account_visibility', 'hide_stats', 'github_id', 'handle', 'previous_handle']);
|
|
683
727
|
|
|
728
|
+
// The columns this interpreter knows how to answer an IS [NOT] NULL test on.
|
|
729
|
+
// Separate from KNOWN_COLUMNS because a NULL test is a different syntax the
|
|
730
|
+
// literal-comparison sweep cannot see (ADR 0213 / task 1002913).
|
|
731
|
+
const KNOWN_NULL_TESTS = new Set(['terms_accepted_at']);
|
|
732
|
+
|
|
684
733
|
/**
|
|
685
734
|
* Build a row test from a statement's own WHERE clause. Literal comparisons
|
|
686
735
|
* (profile_state = 'active') matter as much as placeholders here, because the
|
|
@@ -691,8 +740,22 @@ const KNOWN_COLUMNS = new Set(['profile_state', 'account_visibility', 'hide_stat
|
|
|
691
740
|
function accountFilter(where, params) {
|
|
692
741
|
const tests = [];
|
|
693
742
|
const consumed = new Set();
|
|
743
|
+
// NULL-test terms this interpreter actually turned into a filter, as opposed
|
|
744
|
+
// to column names it merely recognises. The final sweep compares the two.
|
|
745
|
+
const modelled = new Set();
|
|
694
746
|
|
|
695
747
|
if (/\bprofile_state = 'active'/.test(where)) tests.push((r) => r.profile_state === 'active');
|
|
748
|
+
// The ADR 0213 acceptance half of the consent axis. It is a NULL test, not a
|
|
749
|
+
// literal comparison, so neither refusal sweep below would have caught it —
|
|
750
|
+
// an unmodelled IS NOT NULL degrades to no filter exactly like an unread $N.
|
|
751
|
+
// Hence the third sweep at the end of this function, and hence `modelled`:
|
|
752
|
+
// the sweep must prove a filter was APPLIED, not merely that the column is a
|
|
753
|
+
// name it recognises. (Checking recognition alone left this interpreter
|
|
754
|
+
// scoring a deleted filter as fine — found by mutation while writing it.)
|
|
755
|
+
if (/\bterms_accepted_at IS NOT NULL/.test(where)) {
|
|
756
|
+
modelled.add('terms_accepted_at');
|
|
757
|
+
tests.push((r) => r.terms_accepted_at != null);
|
|
758
|
+
}
|
|
696
759
|
if (/\baccount_visibility = 'public'/.test(where)) tests.push((r) => r.account_visibility === 'public');
|
|
697
760
|
if (/\bNOT (?:[a-z_]+\.)?hide_stats\b/.test(where)) tests.push((r) => !r.hide_stats);
|
|
698
761
|
// The recruiting-reach COALESCE (task 1002290). Modelled through the shared JS
|
|
@@ -721,6 +784,18 @@ function accountFilter(where, params) {
|
|
|
721
784
|
throw new Error(`fake pool: unrecognised literal comparison on ${m[1]} in "${where}"`);
|
|
722
785
|
}
|
|
723
786
|
}
|
|
787
|
+
// A NULL test is a filter too. Without this sweep the ADR 0213 acceptance term
|
|
788
|
+
// could be dropped from the predicate and this interpreter would score it as
|
|
789
|
+
// absent-and-fine, which is the failure mode the other two sweeps exist to
|
|
790
|
+
// prevent one syntax over.
|
|
791
|
+
for (const m of where.matchAll(/\b([a-z_]+)\s+IS\s+(?:NOT\s+)?NULL\b/gi)) {
|
|
792
|
+
if (!KNOWN_NULL_TESTS.has(m[1])) {
|
|
793
|
+
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`);
|
|
794
|
+
}
|
|
795
|
+
if (!modelled.has(m[1])) {
|
|
796
|
+
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`);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
724
799
|
return (r) => tests.every((fn) => fn(r));
|
|
725
800
|
}
|
|
726
801
|
|
|
@@ -913,7 +988,7 @@ await ta('private accounts are absent from the leaderboard/directory rollup', as
|
|
|
913
988
|
const shown = rows.map((r) => r.github_login).sort();
|
|
914
989
|
assert.deepEqual(shown, ['octo', 'shifted'],
|
|
915
990
|
'only active, publicly-visible, unhidden accounts reach /people and /scouting — '
|
|
916
|
-
+ `got ${JSON.stringify(shown)}; ghost/scout are private, shy is hidden, newbie is provisional, moved is private`);
|
|
991
|
+
+ `got ${JSON.stringify(shown)}; ghost/scout are private, shy is hidden, newbie is provisional, moved is private, legacy never accepted the terms (ADR 0213)`);
|
|
917
992
|
});
|
|
918
993
|
|
|
919
994
|
server.close();
|
|
@@ -51,6 +51,16 @@ import vm from 'node:vm';
|
|
|
51
51
|
import { readFileSync } from 'node:fs';
|
|
52
52
|
import { fileURLToPath } from 'node:url';
|
|
53
53
|
import { dirname, join } from 'node:path';
|
|
54
|
+
// The REAL predicates, not a copy. accountFloor() below used to spell these
|
|
55
|
+
// itself, which made it a hand-maintained mirror of account-visibility.js —
|
|
56
|
+
// and it drifted the moment ADR 0213 made consent two columns. Imported, the
|
|
57
|
+
// next change to the shared rule moves this guard with it instead.
|
|
58
|
+
import {
|
|
59
|
+
accountVisibleSql,
|
|
60
|
+
accountActiveSql,
|
|
61
|
+
isAccountVisible,
|
|
62
|
+
isAccountActive,
|
|
63
|
+
} from '../modules/platform-identity/account-visibility.js';
|
|
54
64
|
|
|
55
65
|
const require = createRequire(import.meta.url);
|
|
56
66
|
const express = require('express');
|
|
@@ -86,7 +96,8 @@ let nextEdgeId = 1;
|
|
|
86
96
|
|
|
87
97
|
const acct = (githubId, handle, name, over = {}) => ({
|
|
88
98
|
id: githubId, github_id: String(githubId), github_login: handle, display_name: name,
|
|
89
|
-
avatar_url: null, profile_state: 'active',
|
|
99
|
+
avatar_url: null, profile_state: 'active', terms_accepted_at: '2026-08-29T00:00:00.000Z',
|
|
100
|
+
hide_stats: false, account_visibility: 'public',
|
|
90
101
|
recruiter_discoverable: null, handle, previous_handle: null, handle_changed_at: null,
|
|
91
102
|
bio: null, links: null, display_name_source: 'github',
|
|
92
103
|
created_at: new Date(0).toISOString(), updated_at: new Date(0).toISOString(), ...over,
|
|
@@ -111,7 +122,7 @@ function seed() {
|
|
|
111
122
|
acct(CY, 'cy', 'Cy'),
|
|
112
123
|
acct(ZED, 'zed', 'Zed'),
|
|
113
124
|
acct(VIC, 'vic', 'Vic', { account_visibility: 'private' }),
|
|
114
|
-
acct(PROV, 'prov', 'Prov', { profile_state: 'provisional' }),
|
|
125
|
+
acct(PROV, 'prov', 'Prov', { profile_state: 'provisional', terms_accepted_at: null }),
|
|
115
126
|
acct(SEC, 'sec', 'Sec', { account_visibility: 'private' }),
|
|
116
127
|
acct(NEW, 'newbie', 'Newbie'),
|
|
117
128
|
acct(COOL, 'cooled', 'Cooled'),
|
|
@@ -195,14 +206,14 @@ function predicateFor(rawWhere, params) {
|
|
|
195
206
|
// read that stopped joining one of them stops matching here and throws, which
|
|
196
207
|
// is the point: the pool must not be more permissive than the SQL.
|
|
197
208
|
function accountFloor(sql, { alias = 'pa', axis }) {
|
|
198
|
-
const visible =
|
|
199
|
-
const active =
|
|
209
|
+
const visible = accountVisibleSql(alias);
|
|
210
|
+
const active = accountActiveSql(alias);
|
|
200
211
|
if (axis === 'visible') {
|
|
201
212
|
if (!sql.includes(visible)) throw new Error(`fake pool: expected the public visibility floor ${visible} in — ${sql}`);
|
|
202
|
-
return
|
|
213
|
+
return isAccountVisible;
|
|
203
214
|
}
|
|
204
215
|
if (!sql.includes(active)) throw new Error(`fake pool: expected the consent floor ${active} in — ${sql}`);
|
|
205
|
-
return
|
|
216
|
+
return isAccountActive;
|
|
206
217
|
}
|
|
207
218
|
|
|
208
219
|
const iso = (ms) => (ms == null ? null : new Date(ms).toISOString());
|
|
@@ -230,9 +241,13 @@ const pool = {
|
|
|
230
241
|
const hit = accounts
|
|
231
242
|
.filter((a) => String(a.handle).toLowerCase() === want || String(a.previous_handle).toLowerCase() === want)
|
|
232
243
|
.sort((x, y) => Number(String(y.handle).toLowerCase() === want) - Number(String(x.handle).toLowerCase() === want));
|
|
244
|
+
// Mirrors findAccountByHandle's real projection. terms_accepted_at is part
|
|
245
|
+
// of it since ADR 0213 — a row missing it reads as unconsented to the JS
|
|
246
|
+
// twin, which is the silent deny-all this projection must not fake.
|
|
233
247
|
return { rows: hit.slice(0, 1).map((a) => ({
|
|
234
248
|
github_id: a.github_id, handle: a.handle, display_name: a.display_name,
|
|
235
|
-
avatar_url: a.avatar_url, profile_state: a.profile_state,
|
|
249
|
+
avatar_url: a.avatar_url, profile_state: a.profile_state,
|
|
250
|
+
terms_accepted_at: a.terms_accepted_at, account_visibility: a.account_visibility,
|
|
236
251
|
})), rowCount: Math.min(hit.length, 1) };
|
|
237
252
|
}
|
|
238
253
|
|
|
@@ -76,23 +76,28 @@ const GHOST = '5150'; // active · PRIVATE — the owner-renders subjec
|
|
|
76
76
|
const SHY = '6060'; // active · public · HIDDEN — the no-partial-numbers subject
|
|
77
77
|
const NOSY = '7777'; // active · public — the stranger at the side door
|
|
78
78
|
|
|
79
|
+
// ADR 0213 (task 1002913): consent is profile_state AND an acceptance of the
|
|
80
|
+
// platform terms, both written by the same statement — so a consented fixture
|
|
81
|
+
// carries both, and one carrying only the first is a row from before the terms.
|
|
82
|
+
const ACCEPTED_AT = '2026-08-29T00:00:00.000Z';
|
|
83
|
+
|
|
79
84
|
const ACCOUNTS = [
|
|
80
85
|
{ id: 1, github_id: OCTO, github_login: 'octo', display_name: 'Octo', avatar_url: null,
|
|
81
86
|
handle: 'octo', previous_handle: null, handle_changed_at: null, bio: null, links: null,
|
|
82
87
|
display_name_source: 'github', created_at: 'T0', updated_at: 'T0',
|
|
83
|
-
profile_state: 'active', account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
|
|
88
|
+
profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
|
|
84
89
|
{ id: 2, github_id: GHOST, github_login: 'ghost', display_name: 'Ghost', avatar_url: null,
|
|
85
90
|
handle: 'ghost', previous_handle: null, handle_changed_at: null, bio: 'ghost-bio-515007', links: null,
|
|
86
91
|
display_name_source: 'github', created_at: 'T0', updated_at: 'T0',
|
|
87
|
-
profile_state: 'active', account_visibility: 'private', hide_stats: false, recruiter_discoverable: null },
|
|
92
|
+
profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'private', hide_stats: false, recruiter_discoverable: null },
|
|
88
93
|
{ id: 3, github_id: SHY, github_login: 'shy', display_name: 'Shy', avatar_url: null,
|
|
89
94
|
handle: 'shy', previous_handle: null, handle_changed_at: null, bio: null, links: null,
|
|
90
95
|
display_name_source: 'github', created_at: 'T0', updated_at: 'T0',
|
|
91
|
-
profile_state: 'active', account_visibility: 'public', hide_stats: true, recruiter_discoverable: null },
|
|
96
|
+
profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: true, recruiter_discoverable: null },
|
|
92
97
|
{ id: 4, github_id: NOSY, github_login: 'nosy', display_name: 'Nosy', avatar_url: null,
|
|
93
98
|
handle: 'nosy', previous_handle: null, handle_changed_at: null, bio: null, links: null,
|
|
94
99
|
display_name_source: 'github', created_at: 'T0', updated_at: 'T0',
|
|
95
|
-
profile_state: 'active', account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
|
|
100
|
+
profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
|
|
96
101
|
];
|
|
97
102
|
|
|
98
103
|
// Every number and name the cross-project rollup can produce, per account.
|
|
@@ -135,6 +140,10 @@ function sentinelsOf(githubId) {
|
|
|
135
140
|
// anything else throws — a WHERE it cannot read must never degrade to no WHERE.
|
|
136
141
|
const KNOWN_COLUMNS = new Set(['profile_state', 'account_visibility', 'hide_stats', 'github_id', 'handle', 'previous_handle']);
|
|
137
142
|
|
|
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
|
+
|
|
138
147
|
/**
|
|
139
148
|
* Build a row test from a statement's own WHERE text. Literal comparisons carry
|
|
140
149
|
* as much weight as placeholders here: the effective-visibility predicate is
|
|
@@ -144,8 +153,18 @@ const KNOWN_COLUMNS = new Set(['profile_state', 'account_visibility', 'hide_stat
|
|
|
144
153
|
function accountFilter(where, params) {
|
|
145
154
|
const tests = [];
|
|
146
155
|
const consumed = new Set();
|
|
156
|
+
// NULL-test terms actually turned into a filter, as opposed to column names
|
|
157
|
+
// merely recognised. The final sweep compares the two (ADR 0213).
|
|
158
|
+
const modelled = new Set();
|
|
147
159
|
|
|
148
160
|
if (/\bprofile_state = 'active'/.test(where)) tests.push((r) => r.profile_state === 'active');
|
|
161
|
+
// The ADR 0213 consent half. It is a NULL test, not a literal comparison, so
|
|
162
|
+
// neither sweep below would see it — an unmodelled IS NOT NULL degrades to no
|
|
163
|
+
// filter exactly like an unread $N, which is what those sweeps exist to refuse.
|
|
164
|
+
if (/\bterms_accepted_at IS NOT NULL/.test(where)) {
|
|
165
|
+
modelled.add('terms_accepted_at');
|
|
166
|
+
tests.push((r) => r.terms_accepted_at != null);
|
|
167
|
+
}
|
|
149
168
|
if (/\baccount_visibility = 'public'/.test(where)) tests.push((r) => r.account_visibility === 'public');
|
|
150
169
|
if (/\bNOT (?:[a-z_]+\.)?hide_stats\b/.test(where)) tests.push((r) => !r.hide_stats);
|
|
151
170
|
|
|
@@ -173,6 +192,14 @@ function accountFilter(where, params) {
|
|
|
173
192
|
throw new Error(`fake pool: unrecognised literal comparison on ${m[1]} in "${where}"`);
|
|
174
193
|
}
|
|
175
194
|
}
|
|
195
|
+
for (const m of where.matchAll(/\b([a-z_]+)\s+IS\s+(?:NOT\s+)?NULL\b/gi)) {
|
|
196
|
+
if (!KNOWN_NULL_TESTS.has(m[1])) {
|
|
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
|
+
}
|
|
202
|
+
}
|
|
176
203
|
return (r) => tests.every((fn) => fn(r));
|
|
177
204
|
}
|
|
178
205
|
|
package/tests/profile_route.mjs
CHANGED
|
@@ -354,25 +354,28 @@ const PROOF_PROJECTS = [
|
|
|
354
354
|
const sumOf = (k) => PROOF_PROJECTS.reduce((n, p) => n + p[k], 0);
|
|
355
355
|
|
|
356
356
|
// The one account row the reads consult. setAccountHideStats mutates it.
|
|
357
|
-
|
|
357
|
+
// ADR 0213 (task 1002913): consent is profile_state AND an acceptance of the
|
|
358
|
+
// platform terms, both stamped by the same upsertAccount branch.
|
|
359
|
+
const ACCEPTED_AT = '2026-08-29T00:00:00.000Z';
|
|
360
|
+
const hubAccount = { profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: false, handle: 'r06-handle' };
|
|
358
361
|
|
|
359
362
|
// The scouting "table": one visible builder plus the THREE that consent and
|
|
360
363
|
// visibility exclude (the private row joined at task 1002967 — the same axis,
|
|
361
364
|
// enforced by the same shared predicate).
|
|
362
365
|
const SCOUTING_TABLE = [
|
|
363
|
-
{ profile_state: 'active', account_visibility: 'public', hide_stats: false,
|
|
366
|
+
{ profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: false,
|
|
364
367
|
row: { github_id: '11', github_login: 'r06-visible', display_name: 'Visible', avatar_url: null,
|
|
365
368
|
project_count: 2, total_credits: 5, total_tasks_shipped: 1, total_karma: 0,
|
|
366
369
|
last_active: '2026-08-01T00:00:00.000Z', disciplines: ['engineer'] } },
|
|
367
|
-
{ profile_state: 'active', account_visibility: 'public', hide_stats: true,
|
|
370
|
+
{ profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: true,
|
|
368
371
|
row: { github_id: '12', github_login: HIDDEN_LOGIN, display_name: 'Hidden', avatar_url: null,
|
|
369
372
|
project_count: 7, total_credits: 88817, total_tasks_shipped: 6613, total_karma: 5519,
|
|
370
373
|
last_active: '2026-08-02T00:00:00.000Z', disciplines: ['ui'] } },
|
|
371
|
-
{ profile_state: 'provisional', account_visibility: 'public', hide_stats: false,
|
|
374
|
+
{ profile_state: 'provisional', terms_accepted_at: null, account_visibility: 'public', hide_stats: false,
|
|
372
375
|
row: { github_id: '13', github_login: PROV_LOGIN, display_name: 'Provisional', avatar_url: null,
|
|
373
376
|
project_count: 1, total_credits: 71713, total_tasks_shipped: 1, total_karma: 0,
|
|
374
377
|
last_active: '2026-08-03T00:00:00.000Z', disciplines: [] } },
|
|
375
|
-
{ profile_state: 'active', account_visibility: 'private', hide_stats: false,
|
|
378
|
+
{ profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'private', hide_stats: false,
|
|
376
379
|
row: { github_id: '14', github_login: PRIVATE_LOGIN, display_name: 'Private', avatar_url: null,
|
|
377
380
|
project_count: 3, total_credits: 63127, total_tasks_shipped: 4409, total_karma: 2287,
|
|
378
381
|
last_active: '2026-08-04T00:00:00.000Z', disciplines: ['engineer'] } },
|
|
@@ -389,6 +392,7 @@ const engine = {
|
|
|
389
392
|
if (/AS project_count/.test(sql)) {
|
|
390
393
|
let rows = SCOUTING_TABLE;
|
|
391
394
|
if (/pa\.profile_state = 'active'/.test(sql)) rows = rows.filter((r) => r.profile_state === 'active');
|
|
395
|
+
if (/pa\.terms_accepted_at IS NOT NULL/.test(sql)) rows = rows.filter((r) => r.terms_accepted_at != null);
|
|
392
396
|
if (/pa\.account_visibility = 'public'/.test(sql)) rows = rows.filter((r) => r.account_visibility === 'public');
|
|
393
397
|
if (/NOT pa\.hide_stats/.test(sql)) rows = rows.filter((r) => !r.hide_stats);
|
|
394
398
|
return { rows: rows.map((r) => ({ ...r.row })) };
|
|
@@ -398,10 +402,13 @@ const engine = {
|
|
|
398
402
|
hubAccount.hide_stats = !!params[4];
|
|
399
403
|
return { rows: [{ github_id: String(HUB_GITHUB_ID), github_login: 'jaxri', ...hubAccount }] };
|
|
400
404
|
}
|
|
401
|
-
// getPublicProfileExtras' consent read
|
|
402
|
-
//
|
|
403
|
-
|
|
405
|
+
// getPublicProfileExtras' consent read. Matched on the columns it must
|
|
406
|
+
// carry rather than on the exact SELECT list: pinning the full list meant
|
|
407
|
+
// that adding terms_accepted_at (ADR 0213) silently unhooked this branch and
|
|
408
|
+
// the read fell through to the wrong answer with every other test green.
|
|
409
|
+
if (/SELECT profile_state,.*hide_stats/.test(sql) && /FROM platform_identity_accounts/.test(sql)) {
|
|
404
410
|
return { rows: [{ profile_state: hubAccount.profile_state,
|
|
411
|
+
terms_accepted_at: hubAccount.terms_accepted_at,
|
|
405
412
|
account_visibility: hubAccount.account_visibility, hide_stats: hubAccount.hide_stats }] };
|
|
406
413
|
}
|
|
407
414
|
// getRollupForBuilder's per-project rows (the client_id → origin two-hop).
|