@bongos/core 1.19.1023 → 1.19.1025
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 +55 -40
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +4 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0213-platform-terms-acceptance-is-the-hubs-own-witness.md +2 -0
- package/docs/api/openapi.json +58 -4
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +18 -16
- package/docs/copy-registry.json +43 -25
- package/docs/module-api-changelog.md +4 -0
- package/migrations/core_255_access_requests_invite_kind.sql +52 -0
- package/modules/government/catalog.js +1 -1
- package/modules/hall-ui/public/people.html +1 -1
- package/modules/onboarding/routes/access-requests.js +62 -11
- package/modules/platform-identity/connection-suggestions.js +1 -1
- package/modules/platform-identity/migrations/platform_identity_024_terms_grandfathered.sql +68 -0
- package/modules/platform-identity/terms-acceptance.js +34 -14
- package/modules/public-landing/public/terms.html +2 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/run-unit-tests.js +5 -0
- package/src/bongos/auth-github.js +4 -2
- package/src/bongos/db-kernel.js +1 -1
- package/src/module-api.js +1 -1
- package/tests/access_requests_invite.mjs +81 -4
- package/tests/application_lifecycle.mjs +174 -6
- package/tests/auth_inactive_builder.mjs +84 -0
- package/tests/connection_suggestions.mjs +1 -1
- package/tests/connections_api.mjs +1 -1
- package/tests/handle_machinery.mjs +2 -0
- package/tests/people_name_only.mjs +2 -1
- package/tests/profile_connections_ui.mjs +2 -2
- package/tests/profile_rollup_consent.mjs +3 -0
- package/tests/terms_acceptance.mjs +264 -14
- package/tests/terms_grandfather_db.mjs +144 -0
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
// so a Cookie header stuffed with dozens of gds_session=<junk> values can't
|
|
15
15
|
// amplify into dozens of per-request DB lookups (DoS).
|
|
16
16
|
//
|
|
17
|
+
// 3. An offboarded builder's GitHub web sign-in must not re-seed the
|
|
18
|
+
// github_oauth_tokens row deactivateBuilder deleted (task 1004245, ADR 0332).
|
|
19
|
+
//
|
|
17
20
|
// lookupSession is stubbed on the db module BEFORE auth.js is required, because
|
|
18
21
|
// auth.js destructures it at load time. No server/DB/network.
|
|
19
22
|
//
|
|
@@ -90,5 +93,86 @@ await t('extractTokens caps the candidates it returns (DoS amplification guard)'
|
|
|
90
93
|
assert.equal(tokens[0], 'realone', 'the Bearer token is tried first');
|
|
91
94
|
});
|
|
92
95
|
|
|
96
|
+
// ---- web sign-in cannot re-seed an offboarded builder's GitHub token --------
|
|
97
|
+
// The admission gate admits every EXISTING builder, so an inactive one still
|
|
98
|
+
// completes the web flow. The fake DB runs the REAL upsertBuilder SQL and answers
|
|
99
|
+
// only the columns its RETURNING list names, so this fails if `status` ever drops
|
|
100
|
+
// out of that list. GitHub is a stubbed fetch; the callback is the real handler.
|
|
101
|
+
const { pool } = require('../src/bongos/pool.js');
|
|
102
|
+
const { envPrefix } = require('../src/instance-config.js');
|
|
103
|
+
const coreDb = require('../src/bongos/db.js');
|
|
104
|
+
const buildAuthRouter = require('../src/bongos/routes/auth.js');
|
|
105
|
+
|
|
106
|
+
process.env[`${envPrefix()}_GITHUB_CLIENT_ID`] = 'test-client';
|
|
107
|
+
process.env[`${envPrefix()}_GITHUB_CLIENT_SECRET`] = 'test-secret';
|
|
108
|
+
|
|
109
|
+
const GH_TOKEN = 'gho_repo_scoped_token';
|
|
110
|
+
const ROW = {
|
|
111
|
+
id: 77, github_id: '4242', github_login: 'offboarded-ghost', display_name: 'Ghost', avatar_url: null,
|
|
112
|
+
total_credits: 12, created_at: new Date(0), rank: 'xenos', preferred_disciplines: null,
|
|
113
|
+
};
|
|
114
|
+
let builderRow = null;
|
|
115
|
+
const pick = (list) => Object.fromEntries(list.split(',').map((c) => c.trim()).map((c) => [c, builderRow[c]]));
|
|
116
|
+
pool.query = async (sql) => {
|
|
117
|
+
if (/INSERT INTO builders\b/.test(sql)) return { rows: [pick(sql.split('RETURNING')[1])] };
|
|
118
|
+
if (/FROM builders WHERE github_id/.test(sql)) return { rows: [pick(sql.match(/SELECT([\s\S]*?)FROM/)[1])] };
|
|
119
|
+
return { rows: [], rowCount: 0 };
|
|
120
|
+
};
|
|
121
|
+
globalThis.fetch = async (url) => {
|
|
122
|
+
if (String(url) === 'https://github.com/login/oauth/access_token') {
|
|
123
|
+
return { ok: true, json: async () => ({ access_token: GH_TOKEN, scope: 'read:user,repo' }) };
|
|
124
|
+
}
|
|
125
|
+
if (String(url) === 'https://api.github.com/user') {
|
|
126
|
+
return { ok: true, json: async () => ({ id: 4242, login: ROW.github_login, name: 'Ghost', avatar_url: null }) };
|
|
127
|
+
}
|
|
128
|
+
throw new Error(`unexpected fetch in test: ${url}`);
|
|
129
|
+
};
|
|
130
|
+
const tokenWrites = [];
|
|
131
|
+
coreDb.upsertGithubToken = async (args) => { tokenWrites.push(args); return { stored: true }; };
|
|
132
|
+
|
|
133
|
+
const callback = buildAuthRouter().stack
|
|
134
|
+
.find((l) => l.route?.path === '/auth/web/callback' && l.route.methods.get).route.stack[0].handle;
|
|
135
|
+
async function signInWithRepoOptIn() {
|
|
136
|
+
const req = {
|
|
137
|
+
query: { state: 'st8', code: 'c0de' }, secure: false,
|
|
138
|
+
headers: { cookie: 'gds_oauth_state=st8; gds_oauth_repos=1', host: 'localhost', 'user-agent': 'test' },
|
|
139
|
+
};
|
|
140
|
+
const res = {
|
|
141
|
+
redirectedTo: null, statusCode: 200, cookies: {},
|
|
142
|
+
cookie(name, value) { this.cookies[name] = value; return this; },
|
|
143
|
+
clearCookie() { return this; },
|
|
144
|
+
redirect(to) { this.redirectedTo = to; },
|
|
145
|
+
status(code) { this.statusCode = code; return this; },
|
|
146
|
+
type() { return this; },
|
|
147
|
+
send() { return this; },
|
|
148
|
+
};
|
|
149
|
+
await callback(req, res);
|
|
150
|
+
return res;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
await t('offboarded builder: an opted-in web sign-in never reaches upsertGithubToken', async () => {
|
|
154
|
+
builderRow = { ...ROW, status: 'inactive' };
|
|
155
|
+
tokenWrites.length = 0;
|
|
156
|
+
const res = await signInWithRepoOptIn();
|
|
157
|
+
assert.equal(tokenWrites.length, 0, 'the token deactivateBuilder deleted must not be written back');
|
|
158
|
+
assert.ok(res.redirectedTo, 'sign-in itself is not refused: requireBuilder denies the session per request');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await t('offboarded builder: completeWebFlow never hands the repo token back', async () => {
|
|
162
|
+
builderRow = { ...ROW, status: 'inactive' };
|
|
163
|
+
const out = await auth.completeWebFlow({ code: 'c0de', redirectUri: 'http://localhost/cb', captureRepoToken: true });
|
|
164
|
+
assert.equal(out.githubToken, undefined);
|
|
165
|
+
assert.equal(out.githubScope, undefined);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await t('active builder: an opted-in web sign-in still captures the repo token (control)', async () => {
|
|
169
|
+
builderRow = { ...ROW, status: 'active' };
|
|
170
|
+
tokenWrites.length = 0;
|
|
171
|
+
await signInWithRepoOptIn();
|
|
172
|
+
assert.equal(tokenWrites.length, 1, 'the wizard repo-token capture keeps working');
|
|
173
|
+
assert.equal(tokenWrites[0].builderId, ROW.id);
|
|
174
|
+
assert.equal(tokenWrites[0].token, GH_TOKEN);
|
|
175
|
+
});
|
|
176
|
+
|
|
93
177
|
console.log(`\n${pass} passed, ${fail} failed`);
|
|
94
178
|
process.exit(fail === 0 ? 0 : 1);
|
|
@@ -76,7 +76,7 @@ test('scores mutuals and shared projects per D5\'s weights, caps mutuals at 10,
|
|
|
76
76
|
assert.deepEqual(out.map((s) => s.handle), ['ada', 'grace'], '2003 is excluded and never appears; 2001 outranks 2002');
|
|
77
77
|
assert.deepEqual(Object.keys(out[0]).sort(), ['avatar_url', 'display_name', 'handle', 'reason'],
|
|
78
78
|
'an explicit whitelist — no score, no github_id');
|
|
79
|
-
assert.equal(out[0].reason, '3
|
|
79
|
+
assert.equal(out[0].reason, '3 connections in common', 'mutuals is the primary (and only) signal for ada — worded to match the profile page (task 1004253)');
|
|
80
80
|
assert.equal(out[1].reason, 'You both build on Acme', 'shared projects outweighs the single mutual, and the project is named');
|
|
81
81
|
});
|
|
82
82
|
|
|
@@ -554,7 +554,7 @@ test('suggestions: returns the ranked list with reasons, and costs the bounded r
|
|
|
554
554
|
const res = await call('GET', '/api/bongos/connections/suggestions');
|
|
555
555
|
assert.equal(res.status, 200);
|
|
556
556
|
assert.deepEqual(res.json.suggestions.map((s) => s.handle), ['bob']);
|
|
557
|
-
assert.equal(res.json.suggestions[0].reason, '2
|
|
557
|
+
assert.equal(res.json.suggestions[0].reason, '2 connections in common');
|
|
558
558
|
assert.equal(res.json.count, res.json.suggestions.length, 'the count is the array\'s own length');
|
|
559
559
|
assert.equal(calls.length, 6, 'own account, then the three candidate reads, then identity + last-active');
|
|
560
560
|
});
|
|
@@ -70,6 +70,8 @@ try {
|
|
|
70
70
|
// handle assertion.
|
|
71
71
|
await q(migrationSql('platform_identity_012_account_privacy.sql'));
|
|
72
72
|
await q(migrationSql('platform_identity_019_terms_acceptance.sql'));
|
|
73
|
+
// upsertAccount writes terms_accepted_source (024, task 1003360).
|
|
74
|
+
await q(migrationSql('platform_identity_024_terms_grandfathered.sql'));
|
|
73
75
|
await cleanup();
|
|
74
76
|
|
|
75
77
|
await q(
|
|
@@ -149,7 +149,8 @@ await test('the page carries the name-only panel, hidden until the server sends
|
|
|
149
149
|
assert.match(PEOPLE_HTML, /id="people-named-h"[^>]*>[^<]+<span class="count-pill" id="people-named-count" hidden>/,
|
|
150
150
|
'a heading with its own count');
|
|
151
151
|
const lede = (PEOPLE_HTML.match(/<p class="scroll__lede" id="people-named-lede">([^<]+)<\/p>/) || [])[1] || '';
|
|
152
|
-
assert.match(lede, /
|
|
152
|
+
assert.match(lede, /hidden/i, 'the lede says why these builders show no record, in the same word Settings uses (hide_stats)');
|
|
153
|
+
assert.doesNotMatch(lede, /private/i, '"private" is account_visibility\'s word, not hide_stats\'');
|
|
153
154
|
assert.match(lede, /ranked/i, 'and that they are never ranked');
|
|
154
155
|
assert.match(lede, /filter/i, 'and that a role filter never finds them');
|
|
155
156
|
assert.match(PEOPLE_HTML, /id="people-named-body"/, 'and a mount for the ledger');
|
|
@@ -950,12 +950,12 @@ test('the own profile renders "builders you may know" with the server\'s own rea
|
|
|
950
950
|
reset();
|
|
951
951
|
const page = boot({
|
|
952
952
|
transport: suggestTransport([
|
|
953
|
-
{ handle: 'grace', display_name: 'Grace H', avatar_url: null, reason: '3
|
|
953
|
+
{ handle: 'grace', display_name: 'Grace H', avatar_url: null, reason: '3 connections in common' },
|
|
954
954
|
]),
|
|
955
955
|
});
|
|
956
956
|
await page.render(profileOf('octo', 7), 7);
|
|
957
957
|
assert.match(page.html(), /Builders you may know/);
|
|
958
|
-
assert.match(page.html(), /3
|
|
958
|
+
assert.match(page.html(), /3 connections in common/, 'the server picked the reason; the page states it verbatim');
|
|
959
959
|
assert.match(page.html(), /data-act="suggest-connect"/);
|
|
960
960
|
assert.match(page.html(), /data-handle="grace"/);
|
|
961
961
|
});
|
|
@@ -96,6 +96,9 @@ try {
|
|
|
96
96
|
await q(migrationSql('platform_identity_012_recent_ships.sql'));
|
|
97
97
|
await q(migrationSql('platform_identity_019_terms_acceptance.sql'));
|
|
98
98
|
await q(migrationSql('platform_identity_020_visibility_pull.sql'));
|
|
99
|
+
// upsertAccount writes terms_accepted_source (024, task 1003360). It runs
|
|
100
|
+
// before the fixtures, so its one-shot grandfather never sees LEGACY below.
|
|
101
|
+
await q(migrationSql('platform_identity_024_terms_grandfathered.sql'));
|
|
99
102
|
await q(migrationSql('platform_identity_022_activity_snapshots.sql'));
|
|
100
103
|
await cleanup();
|
|
101
104
|
|
|
@@ -115,6 +115,18 @@ t('the acceptance the page promises is the one the code actually keeps', () => {
|
|
|
115
115
|
'and that it is written once — the property the COALESCE below actually provides');
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
+
t('the page says which acceptances were given on a builder’s behalf, and that the record marks them (task 1003360)', () => {
|
|
119
|
+
assert.match(termsHtml, /accepted it on your behalf/i,
|
|
120
|
+
'the owner chose to grandfather AND to say so on the page — the page must tell a pre-existing builder what was recorded for them');
|
|
121
|
+
assert.match(termsHtml, /marks that acceptance as grandfathered/i,
|
|
122
|
+
'and that the record keeps it distinguishable from one they gave');
|
|
123
|
+
assert.match(termsHtml, /your own acceptance replaces it the next time you sign in/i,
|
|
124
|
+
'and what happens when they do sign in — the conflict branch below really does replace it');
|
|
125
|
+
assert.doesNotMatch(termsHtml, /written only by signing in/i,
|
|
126
|
+
'the page still says acceptance is written ONLY by signing in, which is false for every grandfathered account');
|
|
127
|
+
assert.doesNotMatch(stripApexOnly(termsHtml), /grandfathered/i, 'a claim about the platform record survived into a child project page');
|
|
128
|
+
});
|
|
129
|
+
|
|
118
130
|
t('the cross-project claim is apex-only — it never ships in a child project page', () => {
|
|
119
131
|
const stripped = stripApexOnly(termsHtml);
|
|
120
132
|
assert.doesNotMatch(stripped, /Your work across projects/,
|
|
@@ -139,20 +151,24 @@ t('the acceptance fragments refuse anything that is not a Postgres placeholder',
|
|
|
139
151
|
}
|
|
140
152
|
});
|
|
141
153
|
|
|
142
|
-
t('the conflict fragment
|
|
154
|
+
t('the conflict fragment keeps the first acceptance, and replaces only a grandfather', () => {
|
|
143
155
|
const set = acceptanceConflictSet('$5', '$6');
|
|
144
|
-
|
|
156
|
+
const self = 'platform_identity_accounts.';
|
|
157
|
+
const replaces = `CASE WHEN $5::boolean AND ${self}terms_accepted_source = 'grandfathered'`;
|
|
158
|
+
assert.ok(set.includes(`terms_accepted_at = ${replaces} THEN now() WHEN $5::boolean THEN COALESCE(${self}terms_accepted_at, now()) ELSE ${self}terms_accepted_at END,`),
|
|
145
159
|
'without COALESCE every sign-in restamps, and the record stops meaning "when you accepted"');
|
|
146
|
-
assert.
|
|
147
|
-
assert.
|
|
148
|
-
'a
|
|
160
|
+
assert.ok(set.includes(`terms_version = ${replaces} THEN $6::text WHEN $5::boolean THEN COALESCE(${self}terms_version, $6::text) ELSE ${self}terms_version END,`));
|
|
161
|
+
assert.ok(set.includes(`terms_accepted_source = CASE WHEN $5::boolean THEN 'signin' ELSE ${self}terms_accepted_source END,`),
|
|
162
|
+
'a sign-in is a human accepting: the source must say so, or a grandfather mark could outlive a real acceptance');
|
|
149
163
|
});
|
|
150
164
|
|
|
151
|
-
t('the insert fragment writes NULL for a caller the hub did not witness', () => {
|
|
165
|
+
t('the insert fragment writes NULL for a caller the hub did not witness, and marks a witnessed one as a sign-in', () => {
|
|
152
166
|
const values = acceptanceInsertValues('$5', '$6');
|
|
153
167
|
assert.match(values, /CASE WHEN \$5::boolean THEN now\(\) ELSE NULL END/,
|
|
154
168
|
'a check-in / device-poll account has no acceptance — fail-closed');
|
|
155
|
-
assert.
|
|
169
|
+
assert.match(values, /CASE WHEN \$5::boolean THEN 'signin' ELSE NULL END/,
|
|
170
|
+
'an acceptance written at sign-in must say so (task 1003360) — an unmarked one is indistinguishable from a grandfather');
|
|
171
|
+
assert.equal(acceptanceInsertColumns(), ', terms_accepted_at, terms_version, terms_accepted_source');
|
|
156
172
|
});
|
|
157
173
|
|
|
158
174
|
// ===========================================================================
|
|
@@ -183,10 +199,11 @@ function splitTopLevel(s) {
|
|
|
183
199
|
// permissive `.*?` match: every shape this understands is spelled out, and an
|
|
184
200
|
// unrecognised one THROWS. A SET list matched loosely is how a deleted audit
|
|
185
201
|
// trail passes a test that looks like it is checking one.
|
|
186
|
-
function evalAcceptance(expr, { col, activate, existing, params, now }) {
|
|
202
|
+
function evalAcceptance(expr, { col, activate, existing, oldRow = {}, params, now }) {
|
|
187
203
|
const self = `platform_identity_accounts.${col}`;
|
|
188
204
|
const src = (tok) => {
|
|
189
205
|
if (/^now\(\)$/i.test(tok)) return now;
|
|
206
|
+
if (tok === "'signin'") return 'signin';
|
|
190
207
|
const m = /^\$(\d+)::text$/.exec(tok);
|
|
191
208
|
if (m) return params[Number(m[1]) - 1];
|
|
192
209
|
throw new Error(`unrecognised source expression "${tok}" for ${col}`);
|
|
@@ -194,7 +211,18 @@ function evalAcceptance(expr, { col, activate, existing, params, now }) {
|
|
|
194
211
|
const esc = self.replace(/[.]/g, '\\.');
|
|
195
212
|
let m;
|
|
196
213
|
|
|
197
|
-
// The shipped
|
|
214
|
+
// The shipped shape (task 1003360): a grandfather is replaced, anything else is
|
|
215
|
+
// COALESCE'd. It reads the OLD row's source, as Postgres evaluates every SET
|
|
216
|
+
// right-hand side against the row before the update.
|
|
217
|
+
m = new RegExp(`^CASE WHEN \\$(\\d+)::boolean AND platform_identity_accounts\\.terms_accepted_source = 'grandfathered' THEN (.+?) WHEN \\$(\\d+)::boolean THEN COALESCE\\(${esc}, (.+?)\\) ELSE ${esc} END$`, 'i').exec(expr);
|
|
218
|
+
if (m) {
|
|
219
|
+
if (!activate) return existing;
|
|
220
|
+
if (oldRow.terms_accepted_source === 'grandfathered') return src(m[2]);
|
|
221
|
+
return existing != null ? existing : src(m[4]);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// The ADR 0213 shape: activate ? (existing ?? src) : existing. Recognised so a
|
|
225
|
+
// regression to it is caught by behaviour (a grandfather that never gives way).
|
|
198
226
|
m = new RegExp(`^CASE WHEN \\$(\\d+)::boolean THEN COALESCE\\(${esc}, (.+?)\\) ELSE ${esc} END$`, 'i').exec(expr);
|
|
199
227
|
if (m) return activate ? (existing != null ? existing : src(m[2])) : existing;
|
|
200
228
|
|
|
@@ -218,6 +246,9 @@ function evalAcceptance(expr, { col, activate, existing, params, now }) {
|
|
|
218
246
|
|
|
219
247
|
const NOW = '2026-08-29T12:00:00.000Z';
|
|
220
248
|
const LATER = '2026-08-30T09:30:00.000Z';
|
|
249
|
+
const GRANDFATHERED_AT = '2026-08-29T00:00:00.000Z';
|
|
250
|
+
// The three columns one acceptance writes (task 1003360 added the source).
|
|
251
|
+
const ACCEPTANCE_COLUMNS = ['terms_accepted_at', 'terms_version', 'terms_accepted_source'];
|
|
221
252
|
|
|
222
253
|
function makePool() {
|
|
223
254
|
const accounts = new Map(); // github_id -> row
|
|
@@ -242,7 +273,7 @@ function makePool() {
|
|
|
242
273
|
const existing = accounts.get(id);
|
|
243
274
|
|
|
244
275
|
if (/ON CONFLICT \(github_id\) DO NOTHING/i.test(sql)) {
|
|
245
|
-
if (!existing) accounts.set(id, { github_id: id, github_login: params[1], profile_state: 'provisional', terms_accepted_at: null, terms_version: null, account_visibility: 'public', hide_stats: false, handle: 'h' });
|
|
276
|
+
if (!existing) accounts.set(id, { github_id: id, github_login: params[1], profile_state: 'provisional', terms_accepted_at: null, terms_version: null, terms_accepted_source: null, account_visibility: 'public', hide_stats: false, handle: 'h' });
|
|
246
277
|
return { rows: [], rowCount: existing ? 0 : 1 };
|
|
247
278
|
}
|
|
248
279
|
|
|
@@ -255,7 +286,7 @@ function makePool() {
|
|
|
255
286
|
let row;
|
|
256
287
|
if (!existing) {
|
|
257
288
|
row = { github_id: id, github_login: params[1], account_visibility: 'public', hide_stats: false, handle: 'h' };
|
|
258
|
-
for (const col of
|
|
289
|
+
for (const col of ACCEPTANCE_COLUMNS) {
|
|
259
290
|
const i = cols.indexOf(col);
|
|
260
291
|
assert.notEqual(i, -1, `the upsert stopped writing ${col} on insert`);
|
|
261
292
|
row[col] = evalAcceptance(values[i], { col, activate, existing: null, params, now: clock });
|
|
@@ -271,9 +302,10 @@ function makePool() {
|
|
|
271
302
|
const eq = a.indexOf('=');
|
|
272
303
|
assigns.set(a.slice(0, eq).trim(), a.slice(eq + 1).trim());
|
|
273
304
|
}
|
|
274
|
-
|
|
305
|
+
const oldRow = { ...row };
|
|
306
|
+
for (const col of ACCEPTANCE_COLUMNS) {
|
|
275
307
|
assert.ok(assigns.has(col), `the conflict branch stopped writing ${col} — an account that signs in again would never record its acceptance`);
|
|
276
|
-
row[col] = evalAcceptance(assigns.get(col), { col, activate, existing:
|
|
308
|
+
row[col] = evalAcceptance(assigns.get(col), { col, activate, existing: oldRow[col], oldRow, params, now: clock });
|
|
277
309
|
}
|
|
278
310
|
if (activate) row.profile_state = 'active';
|
|
279
311
|
}
|
|
@@ -375,6 +407,8 @@ await ta('CONTROL: a hub-session authorize records the acceptance', async () =>
|
|
|
375
407
|
assert.ok(row, 'no account was written at all — the rest of this section would pass vacuously');
|
|
376
408
|
assert.equal(row.terms_accepted_at, NOW, 'the acceptance was not stamped by the hub sign-in');
|
|
377
409
|
assert.equal(row.terms_version, TERMS_VERSION, 'the record must name WHICH text was accepted');
|
|
410
|
+
assert.equal(row.terms_accepted_source, 'signin',
|
|
411
|
+
'and HOW it was given — a sign-in, queryably distinct from a grandfather (task 1003360)');
|
|
378
412
|
assert.equal(row.profile_state, 'active');
|
|
379
413
|
});
|
|
380
414
|
|
|
@@ -389,12 +423,13 @@ await ta('the acceptance cannot be forged from the query string or the body', as
|
|
|
389
423
|
const res = await request(
|
|
390
424
|
`/sso/authorize?client_id=proj-a&redirect_uri=${encodeURIComponent(REDIRECT)}`
|
|
391
425
|
+ `&terms_accepted_at=${encodeURIComponent(forged)}&terms_version=FORGED&activate=true`
|
|
392
|
-
+ '&profile_state=active',
|
|
426
|
+
+ '&profile_state=active&terms_accepted_source=grandfathered',
|
|
393
427
|
);
|
|
394
428
|
assert.equal(res.status, 302);
|
|
395
429
|
const row = pool.accounts.get('4242');
|
|
396
430
|
assert.equal(row.terms_accepted_at, NOW, 'a caller-supplied timestamp reached the acceptance record');
|
|
397
431
|
assert.equal(row.terms_version, TERMS_VERSION, 'a caller-supplied version reached the acceptance record');
|
|
432
|
+
assert.equal(row.terms_accepted_source, 'signin', 'a caller-supplied source reached the acceptance record');
|
|
398
433
|
|
|
399
434
|
// And structurally: the forged values are nowhere in the statement's params,
|
|
400
435
|
// so there is no path by which a later refactor could start reading them.
|
|
@@ -403,6 +438,7 @@ await ta('the acceptance cannot be forged from the query string or the body', as
|
|
|
403
438
|
const flat = JSON.stringify(upserts[0].params);
|
|
404
439
|
assert.doesNotMatch(flat, /FORGED/, 'a caller-controlled value was bound into the acceptance statement');
|
|
405
440
|
assert.doesNotMatch(flat, /1999/, 'a caller-controlled timestamp was bound into the acceptance statement');
|
|
441
|
+
assert.doesNotMatch(flat, /grandfathered/, 'a caller-controlled source was bound into the acceptance statement');
|
|
406
442
|
assert.equal(upserts[0].params.length, 6,
|
|
407
443
|
'the statement grew a parameter — every one of them must come from the hub session or a constant');
|
|
408
444
|
assert.equal(upserts[0].params[5], TERMS_VERSION, 'the version parameter must be the server constant');
|
|
@@ -448,6 +484,7 @@ await ta('signing in twice does not rewrite or duplicate the acceptance', async
|
|
|
448
484
|
assert.equal(row.terms_accepted_at, NOW,
|
|
449
485
|
`the second sign-in restamped the acceptance (${row.terms_accepted_at}) — the record no longer says when consent was actually given`);
|
|
450
486
|
assert.equal(row.terms_version, TERMS_VERSION);
|
|
487
|
+
assert.equal(row.terms_accepted_source, 'signin');
|
|
451
488
|
assert.equal(pool.accounts.size, 1, 'a second row was created — one account, one acceptance');
|
|
452
489
|
});
|
|
453
490
|
|
|
@@ -478,6 +515,63 @@ await ta('a federated check-in creates an account with NO acceptance at all', as
|
|
|
478
515
|
assert.equal(row.profile_state, 'provisional');
|
|
479
516
|
});
|
|
480
517
|
|
|
518
|
+
// A grandfathered acceptance (platform_identity_024, task 1003360) was given on
|
|
519
|
+
// the builder's behalf, not by them. It keeps the account published, but it is
|
|
520
|
+
// not the first acceptance a HUMAN gave — so it must give way to one.
|
|
521
|
+
const grandfathered = () => ({
|
|
522
|
+
github_id: '4242', github_login: 'octo', profile_state: 'active', handle: 'h',
|
|
523
|
+
account_visibility: 'public', hide_stats: false,
|
|
524
|
+
terms_accepted_at: GRANDFATHERED_AT, terms_version: '2026-08-29', terms_accepted_source: 'grandfathered',
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
await ta('a grandfathered acceptance gives way to the builder’s own at their next sign-in', async () => {
|
|
528
|
+
SESSION = HUB_SESSION;
|
|
529
|
+
pool.accounts.clear();
|
|
530
|
+
pool.accounts.set('4242', grandfathered());
|
|
531
|
+
pool.setClock(LATER);
|
|
532
|
+
await authorize();
|
|
533
|
+
const row = pool.accounts.get('4242');
|
|
534
|
+
assert.equal(row.terms_accepted_source, 'signin',
|
|
535
|
+
'the builder signed in and accepted, yet the record still says the acceptance was given on their behalf');
|
|
536
|
+
assert.equal(row.terms_accepted_at, LATER, 'the date must be when THEY accepted, not when the grandfather ran');
|
|
537
|
+
assert.equal(row.terms_version, TERMS_VERSION, 'and name the text served when they did');
|
|
538
|
+
|
|
539
|
+
// ...and from then on it is a sign-in like any other: written once.
|
|
540
|
+
pool.setClock('2026-09-01T00:00:00.000Z');
|
|
541
|
+
await authorize();
|
|
542
|
+
assert.equal(pool.accounts.get('4242').terms_accepted_at, LATER,
|
|
543
|
+
'a second sign-in restamped an acceptance the builder gave themselves');
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
await ta('an acceptance with no source on record is a real sign-in: it keeps its date and is marked signin', async () => {
|
|
547
|
+
// The deploy window: migrate.sh commits 024 while the old server still runs, and
|
|
548
|
+
// a builder signing in then gets an acceptance the old code wrote without a
|
|
549
|
+
// source. Treating "not signin" as "replace me" would erase the date they gave.
|
|
550
|
+
SESSION = HUB_SESSION;
|
|
551
|
+
pool.accounts.clear();
|
|
552
|
+
pool.accounts.set('4242', { ...grandfathered(), terms_accepted_at: NOW, terms_version: TERMS_VERSION, terms_accepted_source: null });
|
|
553
|
+
pool.setClock(LATER);
|
|
554
|
+
await authorize();
|
|
555
|
+
const row = pool.accounts.get('4242');
|
|
556
|
+
assert.equal(row.terms_accepted_at, NOW, 'a sign-in restamped an acceptance a human gave, because its source was blank');
|
|
557
|
+
assert.equal(row.terms_accepted_source, 'signin', 'and the source should now say how it was given');
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
await ta('a write that is not the capture point leaves a grandfathered acceptance marked grandfathered', async () => {
|
|
561
|
+
pool.accounts.clear();
|
|
562
|
+
pool.accounts.set('4242', grandfathered());
|
|
563
|
+
pool.setClock(LATER);
|
|
564
|
+
const pi = require('../modules/platform-identity/platform-identity.js');
|
|
565
|
+
// The non-activating upsert (the /me/handle and device-poll paths) and the
|
|
566
|
+
// federated check-in. Neither witnessed an acceptance, so neither may turn a
|
|
567
|
+
// grandfather into a sign-in — that would be the fiction the mark prevents.
|
|
568
|
+
await pi.upsertAccount({ githubId: '4242', githubLogin: 'octo', displayName: 'x', avatarUrl: null }, { pool });
|
|
569
|
+
await pi.ensureAccount({ githubId: '4242', githubLogin: 'octo', displayName: 'x', avatarUrl: null }, { pool });
|
|
570
|
+
const row = pool.accounts.get('4242');
|
|
571
|
+
assert.equal(row.terms_accepted_source, 'grandfathered', 'a write that witnessed no acceptance relabelled a grandfather as a sign-in');
|
|
572
|
+
assert.equal(row.terms_accepted_at, GRANDFATHERED_AT);
|
|
573
|
+
});
|
|
574
|
+
|
|
481
575
|
server.close();
|
|
482
576
|
|
|
483
577
|
// ===========================================================================
|
|
@@ -503,12 +597,168 @@ t('the shared consent predicate requires the acceptance, in both forms', () => {
|
|
|
503
597
|
assert.match(accountActiveSql('pa'), /pa\.terms_accepted_at IS NOT NULL/, 'and qualify it under an alias');
|
|
504
598
|
});
|
|
505
599
|
|
|
600
|
+
t('a grandfathered account is consented and published — nothing goes dark (owner decision, blocker 1000123)', () => {
|
|
601
|
+
// The predicate asks whether an acceptance exists, never how it was given: the
|
|
602
|
+
// source is for the ledger, not a second visibility rule (ADR 0202 D4).
|
|
603
|
+
const row = { profile_state: 'active', terms_accepted_at: GRANDFATHERED_AT, terms_accepted_source: 'grandfathered', account_visibility: 'public' };
|
|
604
|
+
assert.equal(isAccountActive(row), true, 'a grandfathered account went dark — the outcome the owner chose against');
|
|
605
|
+
assert.equal(isAccountVisible(row), true);
|
|
606
|
+
assert.doesNotMatch(accountActiveSql(), /terms_accepted_source/,
|
|
607
|
+
'the consent predicate started reading the source — a grandfather would stop publishing, or a second rule was born');
|
|
608
|
+
});
|
|
609
|
+
|
|
506
610
|
t('the page and the code agree on which version is current', () => {
|
|
507
611
|
assert.match(termsHtml, new RegExp(TERMS_VERSION.replace(/-/g, '\\-')),
|
|
508
612
|
`the page must state the version the code stamps (${TERMS_VERSION}) — a record naming a text the reader `
|
|
509
613
|
+ 'cannot find is not evidence of anything');
|
|
510
614
|
});
|
|
511
615
|
|
|
616
|
+
// ===========================================================================
|
|
617
|
+
// 5b. The grandfather (platform_identity_024) — one-shot, marked, and narrow
|
|
618
|
+
// ===========================================================================
|
|
619
|
+
|
|
620
|
+
// The owner chose to grandfather existing accounts (blocker 1000123) and to MARK
|
|
621
|
+
// them as grandfathered. Static checks on the migration's own text pin that it
|
|
622
|
+
// is shaped that way; tests/terms_grandfather_db.mjs executes it against a real
|
|
623
|
+
// Postgres in the integration lane.
|
|
624
|
+
|
|
625
|
+
console.log('\nthe grandfather (existing accounts, marked as such):');
|
|
626
|
+
|
|
627
|
+
const MIGRATION = 'platform_identity_024_terms_grandfathered.sql';
|
|
628
|
+
const migrationSrc = fs.readFileSync(path.join(repo, 'modules', 'platform-identity', 'migrations', MIGRATION), 'utf8');
|
|
629
|
+
const migrationSql = migrationSrc.replace(/--[^\n]*/g, '').replace(/\s+/g, ' ');
|
|
630
|
+
|
|
631
|
+
t('the migration adds a source column that can only say signin or grandfathered', () => {
|
|
632
|
+
assert.match(migrationSql, /ADD COLUMN terms_accepted_source text CHECK \(terms_accepted_source IN \('signin', 'grandfathered'\)\)/,
|
|
633
|
+
'the source must be constrained — a free-text marker is one typo away from a third, meaningless kind of acceptance');
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
t('existing sign-in acceptances are marked as sign-ins BEFORE anything is grandfathered', () => {
|
|
637
|
+
const signin = migrationSql.indexOf("SET terms_accepted_source = 'signin' WHERE terms_accepted_at IS NOT NULL");
|
|
638
|
+
const grandfather = migrationSql.indexOf("terms_accepted_source = 'grandfathered'");
|
|
639
|
+
assert.notEqual(signin, -1, 'acceptances written by a real sign-in before this migration must be marked signin');
|
|
640
|
+
assert.notEqual(grandfather, -1, 'the grandfather write is missing');
|
|
641
|
+
assert.ok(signin < grandfather,
|
|
642
|
+
'the signin backfill must run first — after the grandfather it would relabel every grandfathered row as a sign-in');
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
t('only accounts the hub already witnessed are grandfathered, and nobody is activated by it', () => {
|
|
646
|
+
// The version is the text in force when the grandfather ran. It is pinned, not
|
|
647
|
+
// read from TERMS_VERSION: a later revision must not rewrite what these rows were accepted under.
|
|
648
|
+
assert.match(migrationSql,
|
|
649
|
+
/SET terms_accepted_at = now\(\), terms_version = '2026-08-29', terms_accepted_source = 'grandfathered' WHERE profile_state = 'active' AND terms_accepted_at IS NULL;/,
|
|
650
|
+
'the grandfather must stamp all three columns, and only on active accounts with no acceptance yet');
|
|
651
|
+
assert.equal((migrationSql.match(/profile_state/g) || []).length, 1,
|
|
652
|
+
'the migration names profile_state outside its one WHERE — it must never activate an account (a provisional account was never public)');
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
t('the grandfather runs once — replaying the migration cannot grandfather an account created since', () => {
|
|
656
|
+
const guard = migrationSql.search(/IF NOT EXISTS \( SELECT 1 FROM information_schema\.columns WHERE table_schema = current_schema\(\) AND table_name = 'platform_identity_accounts' AND column_name = 'terms_accepted_source' \) THEN/);
|
|
657
|
+
const endIf = migrationSql.indexOf('END IF;');
|
|
658
|
+
assert.notEqual(guard, -1, 'the data writes must be guarded on the column not existing yet');
|
|
659
|
+
for (const write of ['ADD COLUMN terms_accepted_source', "terms_accepted_source = 'signin'", "terms_accepted_source = 'grandfathered'"]) {
|
|
660
|
+
const at = migrationSql.indexOf(write);
|
|
661
|
+
assert.ok(at > guard && at < endIf, `${write} sits outside the one-shot guard`);
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
// ===========================================================================
|
|
666
|
+
// 5c. The write-site guard — one writer, and no runtime grandfather
|
|
667
|
+
// ===========================================================================
|
|
668
|
+
|
|
669
|
+
// tests/effective_visibility_predicate.mjs deliberately does NOT match a write
|
|
670
|
+
// (it proves so), so it cannot stop a second write site. This does: only
|
|
671
|
+
// terms-acceptance.js may spell a write of the acceptance columns, and no server
|
|
672
|
+
// code at all may write the grandfather marker — the migration is its only author.
|
|
673
|
+
|
|
674
|
+
console.log('\nthe write-site guard (one writer, no runtime grandfather):');
|
|
675
|
+
|
|
676
|
+
// Drop // and /* */ comments, keeping string and template-literal contents.
|
|
677
|
+
function stripComments(src) {
|
|
678
|
+
let out = '';
|
|
679
|
+
let i = 0;
|
|
680
|
+
let quote = null;
|
|
681
|
+
while (i < src.length) {
|
|
682
|
+
const c = src[i];
|
|
683
|
+
const n = src[i + 1];
|
|
684
|
+
if (quote) {
|
|
685
|
+
out += c;
|
|
686
|
+
if (c === '\\') { out += n || ''; i += 2; continue; }
|
|
687
|
+
if (c === quote) quote = null;
|
|
688
|
+
i++;
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
if (c === '/' && n === '/') { while (i < src.length && src[i] !== '\n') i++; continue; }
|
|
692
|
+
if (c === '/' && n === '*') { const end = src.indexOf('*/', i + 2); i = end === -1 ? src.length : end + 2; continue; }
|
|
693
|
+
if (c === "'" || c === '"' || c === '`') quote = c;
|
|
694
|
+
out += c;
|
|
695
|
+
i++;
|
|
696
|
+
}
|
|
697
|
+
return out;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const ACCEPTANCE_WRITE = [
|
|
701
|
+
/\bterms_(?:accepted_at|version|accepted_source)\s*=(?!=)/g,
|
|
702
|
+
/INSERT\s+INTO\s+platform_identity_accounts\s*\([^)]*\bterms_(?:accepted_at|version|accepted_source)\b/gi,
|
|
703
|
+
];
|
|
704
|
+
const GRANDFATHER_LITERAL = /(['"`])grandfathered\1/;
|
|
705
|
+
const acceptanceWritesIn = (code) => ACCEPTANCE_WRITE.flatMap((re) => [...code.matchAll(re)].map((m) => m[0]));
|
|
706
|
+
|
|
707
|
+
function serverSources() {
|
|
708
|
+
const out = [];
|
|
709
|
+
const walk = (dir) => {
|
|
710
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
711
|
+
const p = path.join(dir, e.name);
|
|
712
|
+
if (e.isDirectory()) { if (!['node_modules', 'public', 'tests', 'migrations'].includes(e.name)) walk(p); }
|
|
713
|
+
else if (e.name.endsWith('.js')) out.push(p);
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
walk(path.join(repo, 'src'));
|
|
717
|
+
walk(path.join(repo, 'modules'));
|
|
718
|
+
return out.map((abs) => ({ rel: path.relative(repo, abs).split(path.sep).join('/'), code: stripComments(fs.readFileSync(abs, 'utf8')) }));
|
|
719
|
+
}
|
|
720
|
+
const SOURCES = serverSources();
|
|
721
|
+
const WRITER = 'modules/platform-identity/terms-acceptance.js';
|
|
722
|
+
|
|
723
|
+
t('the guard catches a write in every form, and leaves reads and comparisons alone', () => {
|
|
724
|
+
assert.ok(acceptanceWritesIn('UPDATE platform_identity_accounts SET terms_accepted_at = now()').length, 'an UPDATE SET');
|
|
725
|
+
assert.ok(acceptanceWritesIn("terms_accepted_source='signin'").length, 'an unspaced assignment');
|
|
726
|
+
assert.ok(acceptanceWritesIn('INSERT INTO platform_identity_accounts (github_id, terms_version) VALUES ($1, $2)').length, 'an INSERT column list');
|
|
727
|
+
assert.ok(!acceptanceWritesIn('account.terms_accepted_at != null').length, 'a JS null comparison is not a write');
|
|
728
|
+
assert.ok(!acceptanceWritesIn('row.terms_accepted_source === x').length, 'nor a strict comparison');
|
|
729
|
+
assert.ok(!acceptanceWritesIn('SELECT profile_state, terms_accepted_at FROM platform_identity_accounts').length, 'nor a projection');
|
|
730
|
+
assert.ok(!acceptanceWritesIn(stripComments('// SET terms_accepted_at = now()\nconst x = 1;')).length, 'nor a comment');
|
|
731
|
+
assert.ok(stripComments("const u = 'https://x.example/a'; // c").includes('https://x.example/a'), 'a // inside a string is not a comment');
|
|
732
|
+
assert.ok(GRANDFATHER_LITERAL.test("a = 'grandfathered'"), 'the grandfather literal');
|
|
733
|
+
assert.ok(!GRANDFATHER_LITERAL.test(stripComments('// marked grandfathered\nconst y = 2;')), 'but not the word in prose');
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
t('terms-acceptance.js is the only server file that writes an acceptance', () => {
|
|
737
|
+
const writer = SOURCES.find((s) => s.rel === WRITER);
|
|
738
|
+
assert.ok(writer && acceptanceWritesIn(writer.code).length >= 3,
|
|
739
|
+
'the one writer no longer spells its writes — this guard would now pass vacuously');
|
|
740
|
+
const others = SOURCES.filter((s) => s.rel !== WRITER && acceptanceWritesIn(s.code).length)
|
|
741
|
+
.map((s) => `${s.rel}: ${acceptanceWritesIn(s.code).join(' | ')}`);
|
|
742
|
+
assert.deepEqual(others, [],
|
|
743
|
+
'a second acceptance write site — it bypasses the source marker and the capture-point proof above');
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
t('no server code can mark an acceptance grandfathered — only the migration ever did', () => {
|
|
747
|
+
assert.ok(SOURCES.length > 50, `the walk found only ${SOURCES.length} files — it is not looking where the code is`);
|
|
748
|
+
const hits = SOURCES.filter((s) => s.rel !== WRITER && GRANDFATHER_LITERAL.test(s.code)).map((s) => s.rel);
|
|
749
|
+
assert.deepEqual(hits, [],
|
|
750
|
+
'server code names the grandfather marker — a runtime grandfather records a consent nobody gave');
|
|
751
|
+
// The writer may READ the marker (a grandfather is the one acceptance a sign-in
|
|
752
|
+
// replaces) and nothing else: every mention is a test of the OLD row's source.
|
|
753
|
+
// Writing it is also refused behaviourally — the interpreter above knows no
|
|
754
|
+
// 'grandfathered' source expression, so a SET or VALUES producing it throws.
|
|
755
|
+
const writer = SOURCES.find((s) => s.rel === WRITER).code;
|
|
756
|
+
const mentions = (writer.match(/'grandfathered'/g) || []).length;
|
|
757
|
+
const reads = (writer.match(/\$\{self\}\.terms_accepted_source = 'grandfathered'/g) || []).length;
|
|
758
|
+
assert.ok(mentions >= 1 && mentions === reads,
|
|
759
|
+
`terms-acceptance.js names the grandfather marker ${mentions} time(s), only ${reads} as a read of the old row`);
|
|
760
|
+
});
|
|
761
|
+
|
|
512
762
|
// ===========================================================================
|
|
513
763
|
// 6. The projection rule — a twin cannot judge a column it was not given
|
|
514
764
|
// ===========================================================================
|