@bongos/core 1.19.426 → 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 +65 -30
- package/docs/adr/0212-connections-on-the-profile-surface-and-addressing.md +61 -0
- package/docs/adr/0213-platform-terms-acceptance-is-the-hubs-own-witness.md +71 -0
- package/docs/adr/README.md +2 -0
- package/docs/copy-inventory.md +34 -18
- package/docs/copy-registry.json +179 -25
- package/docs/file-map.md +2 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/profile-connections.js +304 -0
- package/modules/hall-ui/public/profile.html +16 -1
- package/modules/hall-ui/public/profile.js +5 -0
- package/modules/hall-ui/public/style.css +38 -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/scripts/hall-preview/server.js +3 -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 +889 -0
- package/tests/profile_privacy_owner_view.mjs +31 -4
- package/tests/profile_route.mjs +15 -8
- package/tests/terms_acceptance.mjs +573 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// modules/platform-identity/terms-acceptance.js — THE platform T&C acceptance
|
|
2
|
+
// record: which text is current, and the SQL that stamps a builder's acceptance
|
|
3
|
+
// of it (ADR 0213, task 1002913).
|
|
4
|
+
//
|
|
5
|
+
// WHAT THIS ANSWERS: "did the hub itself witness this human accept the platform
|
|
6
|
+
// terms, and which text did they accept?" — the artifact ADR 0141 §4 deferred
|
|
7
|
+
// consent for cross-project aggregation to, and ADR 0171 D3 reserved a place for
|
|
8
|
+
// ("the one path where a human is demonstrably at the hub, and where T&C
|
|
9
|
+
// acceptance can later be captured, task 1002913").
|
|
10
|
+
//
|
|
11
|
+
// WHY ITS OWN FILE. platform-identity.js sits at the 1500-line fitness ratchet
|
|
12
|
+
// with `oversized_file_count: 0` as the baseline — there is no headroom, and
|
|
13
|
+
// four sessions have already set the precedent that new logic lands beside it
|
|
14
|
+
// (account-visibility.js, application-echo.js, routes/connections.js,
|
|
15
|
+
// src/bongos/routes/my-sessions.js). This file is that, for consent.
|
|
16
|
+
//
|
|
17
|
+
// THE WRITE SIDE ONLY — read the record through account-visibility.js. That file
|
|
18
|
+
// is the SOLE owner of every effective-visibility answer, and acceptance is now
|
|
19
|
+
// part of the consent axis it publishes (accountActiveSql / isAccountActive).
|
|
20
|
+
// Nothing here compares terms_accepted_at to anything; these are the statements
|
|
21
|
+
// that SET it. tests/effective_visibility_predicate.mjs enforces that split —
|
|
22
|
+
// its SPELLINGS list fails any file but the owner that writes the acceptance
|
|
23
|
+
// predicate, and deliberately does not match a write.
|
|
24
|
+
//
|
|
25
|
+
// WHY THE CALLER CANNOT FORGE ONE. These fragments have exactly one call site:
|
|
26
|
+
// upsertAccount's `activate` branch, whose only caller is /sso/authorize
|
|
27
|
+
// (routes/sso.js). There `activate: true` is a SOURCE LITERAL and every other
|
|
28
|
+
// field comes from the hub's own resolved session — the ADR 0205 D3 property
|
|
29
|
+
// established for recordFederatedSignin by task 1003340. No request-body field
|
|
30
|
+
// reaches these statements, which is the whole point: an acceptance record a
|
|
31
|
+
// caller could assert would be evidence of nothing.
|
|
32
|
+
//
|
|
33
|
+
// IDEMPOTENT BY CONSTRUCTION: the conflict branch is COALESCE(existing, new), so
|
|
34
|
+
// the FIRST acceptance stands and a second sign-in never rewrites or duplicates
|
|
35
|
+
// it. The two columns are only ever written together, so they cannot diverge.
|
|
36
|
+
//
|
|
37
|
+
// A LATER REVISION IS A KNOWN GAP, NOT A SILENT ONE. Because the version is
|
|
38
|
+
// recorded and never overwritten, publishing a new TERMS_VERSION leaves every
|
|
39
|
+
// existing row naming the text it actually agreed to — so the platform can see
|
|
40
|
+
// who has not accepted the new one. Re-acceptance on revision is deliberately
|
|
41
|
+
// NOT built here (ADR 0213 D4): it is a product flow, not a storage shape.
|
|
42
|
+
'use strict';
|
|
43
|
+
|
|
44
|
+
// The version of the platform terms currently served at /terms. It is stamped
|
|
45
|
+
// onto every acceptance, so it must match the version the page states — the
|
|
46
|
+
// pairing is asserted by tests/terms_acceptance.mjs, which reads both.
|
|
47
|
+
//
|
|
48
|
+
// Date-stamped rather than numbered: the page says "Version <this>", and a
|
|
49
|
+
// reader comparing page to record should not have to translate.
|
|
50
|
+
const TERMS_VERSION = '2026-08-29';
|
|
51
|
+
|
|
52
|
+
// A Postgres placeholder, e.g. '$5'. Interpolated into SQL, so it is validated
|
|
53
|
+
// rather than trusted — the account-visibility.js alias-guard pattern.
|
|
54
|
+
function assertPlaceholder(fn, value) {
|
|
55
|
+
if (!/^\$[1-9][0-9]*$/.test(String(value))) {
|
|
56
|
+
throw new Error(`${fn}: expected a Postgres placeholder like '$5', got ${JSON.stringify(value)}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The acceptance columns, for an INSERT column list. Leading comma: it appends
|
|
61
|
+
// to an existing list rather than standing alone.
|
|
62
|
+
function acceptanceInsertColumns() {
|
|
63
|
+
return ', terms_accepted_at, terms_version';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The matching VALUES fragment. A row created by a NON-activating caller
|
|
67
|
+
// (ensureAccount's check-in / device-poll path) gets NULL — fail-closed: no hub
|
|
68
|
+
// session witnessed that human, so there is no acceptance to record.
|
|
69
|
+
function acceptanceInsertValues(activateParam, versionParam) {
|
|
70
|
+
assertPlaceholder('acceptanceInsertValues', activateParam);
|
|
71
|
+
assertPlaceholder('acceptanceInsertValues', versionParam);
|
|
72
|
+
return `, CASE WHEN ${activateParam}::boolean THEN now() ELSE NULL END`
|
|
73
|
+
+ `, CASE WHEN ${activateParam}::boolean THEN ${versionParam}::text ELSE NULL END`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The ON CONFLICT DO UPDATE SET fragment — the idempotent half. COALESCE keeps
|
|
77
|
+
// the FIRST acceptance, so signing in again re-reads the terms but does not
|
|
78
|
+
// restamp them; a non-activating upsert leaves both columns exactly as they are,
|
|
79
|
+
// mirroring the one-way rule profile_state already follows.
|
|
80
|
+
//
|
|
81
|
+
// Trailing comma: it sits between two other assignments in the SET list.
|
|
82
|
+
function acceptanceConflictSet(activateParam, versionParam) {
|
|
83
|
+
assertPlaceholder('acceptanceConflictSet', activateParam);
|
|
84
|
+
assertPlaceholder('acceptanceConflictSet', versionParam);
|
|
85
|
+
const self = 'platform_identity_accounts';
|
|
86
|
+
return `terms_accepted_at = CASE WHEN ${activateParam}::boolean`
|
|
87
|
+
+ ` THEN COALESCE(${self}.terms_accepted_at, now()) ELSE ${self}.terms_accepted_at END,`
|
|
88
|
+
+ ` terms_version = CASE WHEN ${activateParam}::boolean`
|
|
89
|
+
+ ` THEN COALESCE(${self}.terms_version, ${versionParam}::text) ELSE ${self}.terms_version END,`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = {
|
|
93
|
+
TERMS_VERSION,
|
|
94
|
+
acceptanceInsertColumns,
|
|
95
|
+
acceptanceInsertValues,
|
|
96
|
+
acceptanceConflictSet,
|
|
97
|
+
};
|
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
|
|
|
@@ -116,6 +116,9 @@ const FIXTURE_FALLBACKS = [
|
|
|
116
116
|
// craft and the page window are query string, which fixture matching ignores.
|
|
117
117
|
[/^builders\/\d+\/activity$/, (q) => (q.section === 'goals' ? 'builders__activity-goals' : 'builders__activity-tasks')],
|
|
118
118
|
[/^inbox\/by-builder\/\d+$/, 'inbox__by-builder'],
|
|
119
|
+
// the profile's connections section (task 1002296) — one degree fixture serves
|
|
120
|
+
// any handle, since the handle is the address and the label is the payload.
|
|
121
|
+
[/^connections\/degree\/[^/]+$/, 'connections__degree'],
|
|
119
122
|
];
|
|
120
123
|
|
|
121
124
|
function loadFixture(name) {
|
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();
|