@bongos/core 1.24.5 → 1.24.6

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.
Files changed (44) hide show
  1. package/.bongos-core.json +70 -45
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +7 -0
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/adr/0396-google-sign-in-a-stand-in-login-and-a-browser-approved-cli-sign-in.md +8 -0
  8. package/docs/api/openapi.json +110 -5
  9. package/docs/api-reference.md +6 -3
  10. package/docs/copy-inventory.md +36 -32
  11. package/docs/copy-registry.json +69 -33
  12. package/docs/module-api-changelog.md +2 -0
  13. package/docs/page-inventory.json +2 -1
  14. package/docs/page-readings.json +63 -55
  15. package/migrations/core_286_credit_log_no_spend.sql +128 -0
  16. package/modules/copy-desk/auto-close.js +5 -3
  17. package/modules/copy-desk/page-status.js +7 -4
  18. package/modules/copy-desk/tests/copy_page_auto_close.mjs +4 -4
  19. package/modules/lifecycle/routes/claims.js +7 -1
  20. package/modules/platform-identity/account-logins.js +131 -0
  21. package/modules/platform-identity/migrations/platform_identity_035_account_login_email_idx.sql +19 -0
  22. package/modules/platform-identity/routes/profile.js +29 -1
  23. package/modules/platform-identity/routes/sso.js +25 -3
  24. package/modules/public-landing/public/account.html +114 -0
  25. package/modules/public-landing/public/account.states.json +1 -0
  26. package/modules/ui-design/kit/serve.js +24 -0
  27. package/package-lock.json +2 -2
  28. package/package.json +1 -1
  29. package/release-notes.json +18 -0
  30. package/scripts/bongos/run-unit-tests.js +4 -0
  31. package/src/bongos/auth-github.js +24 -7
  32. package/src/bongos/routes/auth-google.js +71 -5
  33. package/src/bongos/routes/auth-link.js +132 -0
  34. package/src/bongos/routes.js +2 -0
  35. package/src/bongos/sign-in-pages.js +20 -1
  36. package/src/module-api.js +1 -1
  37. package/tests/account_linking.mjs +289 -0
  38. package/tests/account_logins_db.mjs +56 -0
  39. package/tests/claims_batch_routes.mjs +79 -12
  40. package/tests/copy_desk_page_release.mjs +20 -0
  41. package/tests/copy_stuck_page_tweaks.mjs +2 -2
  42. package/tests/credit_log_no_spend_db.mjs +160 -0
  43. package/tests/effective_visibility_predicate.mjs +8 -0
  44. package/tests/hub_account_page.mjs +91 -5
@@ -0,0 +1,289 @@
1
+ // tests/account_linking.mjs — GitHub and Google as two ways into ONE account,
2
+ // linked and unlinked by the person (task 1004995, ADR 0393/0396).
3
+ //
4
+ // WHY. The owner's rules: a signed-in person can link the other provider and
5
+ // both open the same account; never remove the last way in; when emails match,
6
+ // suggest — never join; and an account that already exists separately is said
7
+ // plainly and never merged. And the ADR 0393 carry-over: once linking exists,
8
+ // every GitHub sign-in must find the person first, or a GitHub account linked to
9
+ // someone who joined with Google signs in as a second person.
10
+ // No database, no network: a recording fake pool, a stubbed fetch for GitHub,
11
+ // and the db-kernel functions stubbed before the files that destructure them load.
12
+ //
13
+ // Run: node --test tests/account_linking.mjs
14
+
15
+ import assert from 'node:assert/strict';
16
+ import { test, before, after } from 'node:test';
17
+ import http from 'node:http';
18
+ import { createRequire } from 'node:module';
19
+
20
+ const require = createRequire(import.meta.url);
21
+ const express = require('express');
22
+
23
+ process.env.BONGOS_GITHUB_CLIENT_ID = process.env.BONGOS_GITHUB_CLIENT_ID || 'gh-test-client';
24
+ process.env.BONGOS_GITHUB_CLIENT_SECRET = process.env.BONGOS_GITHUB_CLIENT_SECRET || 'gh-test-secret';
25
+ process.env.BONGOS_OPEN_XENOS_ENROLLMENT = '1';
26
+
27
+ function fakePool(script) {
28
+ const p = { log: [], script };
29
+ const run = async (sql, params = []) => {
30
+ p.log.push({ sql, params });
31
+ for (const [re, answer] of p.script) {
32
+ if (re.test(sql)) return typeof answer === 'function' ? answer(sql, params, p.log) : answer;
33
+ }
34
+ return { rows: [], rowCount: 0 };
35
+ };
36
+ p.query = run;
37
+ p.connect = async () => ({ query: run, release() {} });
38
+ return p;
39
+ }
40
+
41
+ // ── stubs, installed before anything destructures them ──
42
+ const kernel = require('../src/bongos/db-kernel.js');
43
+ const upserts = [];
44
+ kernel.getBuilderByGithubId = async () => null;
45
+ kernel.upsertBuilder = async (b) => { upserts.push(b); return { id: 9, github_id: String(b.githubId), github_login: b.githubLogin, status: 'active', rank: 'xenos' }; };
46
+ kernel.createSession = async () => ({ id: 1 });
47
+ const dbMod = require('../src/bongos/db.js');
48
+ dbMod.createSession = kernel.createSession;
49
+ const admission = require('../src/bongos/auth-admission.js');
50
+ admission.safeSyncRankRole = async () => {};
51
+ let ME = '-7';
52
+ const apiPath = require.resolve('../src/module-api.js');
53
+ const realApi = require(apiPath);
54
+ const routePool = fakePool([]);
55
+ require.cache[apiPath].exports = {
56
+ ...realApi,
57
+ pool: routePool,
58
+ requireBuilder: (req, _res, next) => { req.builder = { id: 1, github_id: ME, github_login: '~ana' }; next(); },
59
+ };
60
+ const seams = require('../src/module-seams.js');
61
+ const hub = { personKeyForGithub: async () => null, recordHubSignin: async () => {} };
62
+ if (!seams.hasProvider('platform-identity.hub')) seams.registerProvider('platform-identity.hub', hub);
63
+ const registered = seams.resolveOptional('platform-identity.hub');
64
+
65
+ const logins = require('../modules/platform-identity/account-logins.js');
66
+ const profileRoutes = require('../modules/platform-identity/routes/profile.js');
67
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
68
+ const { completeWebFlow } = require('../src/bongos/auth-github.js');
69
+ const { linkLanding } = require('../src/bongos/routes/auth-link.js');
70
+ const { renderExistingAccountHint } = require('../src/bongos/sign-in-pages.js');
71
+
72
+ const dupe = () => { const e = new Error('duplicate key'); e.code = '23505'; throw e; };
73
+
74
+ // ── 1. who a GitHub sign-in reaches ──────────────────────────────────────────
75
+
76
+ test('a GitHub id linked to someone who joined with Google reaches THEIR key; an unknown one reaches no one', async () => {
77
+ const pool = fakePool([[/FROM platform_identity_account_logins l/, (_s, params) => (params[1] === '555' ? { rows: [{ account_id: 3, person_key: '-7' }] } : { rows: [] })]]);
78
+ assert.equal(await logins.personKeyForGithub('555', { pool }), '-7');
79
+ assert.equal(await logins.personKeyForGithub('556', { pool }), null);
80
+ assert.equal(await logins.personKeyForGithub('-7', { pool }), null, 'a minted key is never a GitHub id');
81
+ });
82
+
83
+ test('the hub’s GitHub web sign-in signs a linked GitHub account in as the person, not a second account', async () => {
84
+ const realFetch = globalThis.fetch;
85
+ globalThis.fetch = async (url) => ({
86
+ ok: true,
87
+ json: async () => (String(url).includes('access_token') ? { access_token: 't', scope: '' } : { id: 555, login: 'ana-gh', name: 'Ana', avatar_url: null, email: 'ana@example.com' }),
88
+ });
89
+ const asked = [];
90
+ const signins = [];
91
+ registered.personKeyForGithub = async (id) => { asked.push(id); return id === '555' ? '-7' : null; };
92
+ registered.recordHubSignin = async (r) => { signins.push(r); };
93
+ upserts.length = 0;
94
+ try {
95
+ await completeWebFlow({ code: 'c', redirectUri: 'https://hub.example/cb' });
96
+ } finally { globalThis.fetch = realFetch; }
97
+ assert.deepEqual(asked, ['555'], 'the hub is asked who the GitHub id reaches, first');
98
+ assert.equal(upserts[0].githubId, '-7', 'the builder row is the person’s, keyed on their minted key');
99
+ assert.equal(upserts[0].githubLogin, 'ana-gh', 'with the real GitHub login, now that GitHub is linked');
100
+ assert.equal(signins[0].githubId, '-7', 'the hub account touched is the person’s');
101
+ assert.equal(signins[0].githubUserId, '555', 'and the GitHub way in itself is stamped as used');
102
+ });
103
+
104
+ test('with no hub (any other instance) the GitHub id is the key, exactly as before', async () => {
105
+ const realFetch = globalThis.fetch;
106
+ globalThis.fetch = async (url) => ({ ok: true, json: async () => (String(url).includes('access_token') ? { access_token: 't' } : { id: 42, login: 'octo' }) });
107
+ const saved = registered.personKeyForGithub;
108
+ delete registered.personKeyForGithub;
109
+ upserts.length = 0;
110
+ try { await completeWebFlow({ code: 'c', redirectUri: 'https://x.example/cb' }); } finally { globalThis.fetch = realFetch; registered.personKeyForGithub = saved; }
111
+ assert.equal(upserts[0].githubId, '42');
112
+ });
113
+
114
+ // ── 2. linking — never a merge ───────────────────────────────────────────────
115
+
116
+ const ownedBy = (key) => [/FROM platform_identity_account_logins l\s+JOIN/, key ? { rows: [{ account_id: 1, person_key: key }] } : { rows: [] }];
117
+
118
+ test('a way in that already reaches ANOTHER person is refused as taken, and nothing is written', async () => {
119
+ const pool = fakePool([ownedBy('99')]);
120
+ assert.deepEqual(await logins.linkLogin({ personKey: '-7', provider: 'github', subject: '555', githubLogin: 'x' }, { pool }), { refused: 'taken' });
121
+ assert.equal(pool.log.filter((q) => /INSERT|UPDATE|DELETE/.test(q.sql)).length, 0);
122
+ });
123
+
124
+ test('linking what is already yours is a no-op success', async () => {
125
+ const pool = fakePool([ownedBy('-7')]);
126
+ assert.deepEqual(await logins.linkLogin({ personKey: '-7', provider: 'google', subject: 'g-1' }, { pool }), { linked: true, already: true });
127
+ });
128
+
129
+ test('linking GitHub adds the way in and writes the real login on the account', async () => {
130
+ const pool = fakePool([ownedBy(null), [/SELECT id FROM platform_identity_accounts WHERE github_id/, { rows: [{ id: 4 }] }]]);
131
+ assert.deepEqual(await logins.linkLogin({ personKey: '-7', provider: 'github', subject: '555', email: 'ana@example.com', githubLogin: 'ana-gh' }, { pool }), { linked: true, already: false });
132
+ const ins = pool.log.find((q) => /INSERT INTO platform_identity_account_logins/.test(q.sql));
133
+ assert.deepEqual(ins.params, [4, 'github', '555', 'ana@example.com']);
134
+ const upd = pool.log.find((q) => /UPDATE platform_identity_accounts SET github_login/.test(q.sql));
135
+ assert.deepEqual(upd.params, [4, 'ana-gh']);
136
+ });
137
+
138
+ test('linking Google writes no GitHub login', async () => {
139
+ const pool = fakePool([ownedBy(null), [/SELECT id FROM platform_identity_accounts WHERE github_id/, { rows: [{ id: 4 }] }]]);
140
+ await logins.linkLogin({ personKey: '42', provider: 'google', subject: 'g-1' }, { pool });
141
+ assert.equal(pool.log.filter((q) => /github_login/.test(q.sql) && /UPDATE/.test(q.sql)).length, 0);
142
+ });
143
+
144
+ test('a race: someone else took it a moment ago → taken; you already hold that provider → already_linked', async () => {
145
+ let n = 0;
146
+ const raced = fakePool([
147
+ [/FROM platform_identity_account_logins l\s+JOIN/, () => (n++ === 0 ? { rows: [] } : { rows: [{ account_id: 2, person_key: '99' }] })],
148
+ [/SELECT id FROM platform_identity_accounts WHERE github_id/, { rows: [{ id: 4 }] }],
149
+ [/INSERT INTO platform_identity_account_logins/, dupe],
150
+ ]);
151
+ assert.deepEqual(await logins.linkLogin({ personKey: '-7', provider: 'google', subject: 'g-1' }, { pool: raced }), { refused: 'taken' });
152
+ const held = fakePool([ownedBy(null), [/SELECT id FROM platform_identity_accounts WHERE github_id/, { rows: [{ id: 4 }] }], [/INSERT INTO platform_identity_account_logins/, dupe]]);
153
+ assert.deepEqual(await logins.linkLogin({ personKey: '-7', provider: 'google', subject: 'g-2' }, { pool: held }), { refused: 'already_linked' });
154
+ });
155
+
156
+ // ── 3. unlinking — never the last way in ─────────────────────────────────────
157
+
158
+ test('the refusal rule: never the last way in; never GitHub from an account born with it', () => {
159
+ assert.equal(logins.unlinkRefusal({ personKey: '-7', provider: 'google', count: 1 }), 'last_way_in');
160
+ assert.equal(logins.unlinkRefusal({ personKey: '-7', provider: 'github', count: 2 }), null, 'a Google-born person may drop GitHub');
161
+ assert.equal(logins.unlinkRefusal({ personKey: '-7', provider: 'google', count: 2 }), null);
162
+ assert.equal(logins.unlinkRefusal({ personKey: '42', provider: 'github', count: 2 }), 'born_with', 'its key IS its GitHub id');
163
+ assert.equal(logins.unlinkRefusal({ personKey: '42', provider: 'google', count: 2 }), null);
164
+ });
165
+
166
+ const held = (...providers) => [/FOR UPDATE OF l/, { rows: providers.map((provider, i) => ({ id: 10 + i, provider, account_id: 4 })) }];
167
+
168
+ test('unlink counts and deletes under one lock, and clears the GitHub login with GitHub', async () => {
169
+ const pool = fakePool([held('github', 'google')]);
170
+ assert.deepEqual(await logins.unlinkLogin({ personKey: '-7', provider: 'github' }, { pool }), { unlinked: true });
171
+ assert.ok(pool.log.some((q) => /BEGIN/i.test(q.sql)), 'inside a transaction');
172
+ assert.deepEqual(pool.log.find((q) => /DELETE FROM platform_identity_account_logins/.test(q.sql)).params, [10]);
173
+ assert.deepEqual(pool.log.find((q) => /SET github_login = NULL/.test(q.sql)).params, [4]);
174
+ });
175
+
176
+ test('unlink refuses the last way in, a GitHub-born account’s GitHub, and one not linked — deleting nothing', async () => {
177
+ for (const [key, provider, rows, want] of [
178
+ ['-7', 'google', ['google'], 'last_way_in'],
179
+ ['42', 'github', ['github', 'google'], 'born_with'],
180
+ ['-7', 'github', ['google'], 'not_linked'],
181
+ ]) {
182
+ const pool = fakePool([held(...rows)]);
183
+ assert.deepEqual(await logins.unlinkLogin({ personKey: key, provider }, { pool }), { refused: want }, `${key} ${provider}`);
184
+ assert.equal(pool.log.filter((q) => /^\s*(DELETE|UPDATE)/.test(q.sql)).length, 0);
185
+ }
186
+ });
187
+
188
+ // ── 4. a matching email suggests, never joins ────────────────────────────────
189
+
190
+ test('the first-sign-in hint names providers only, and only while the identity reaches no one', async () => {
191
+ const pool = fakePool([ownedBy(null), [/SELECT DISTINCT provider/, { rows: [{ provider: 'github' }] }]]);
192
+ assert.deepEqual(await logins.existingAccountHint({ provider: 'google', subject: 'g-1', email: 'Ana@Example.com' }, { pool }), ['github']);
193
+ const q = pool.log.find((x) => /SELECT DISTINCT provider/.test(x.sql));
194
+ assert.match(q.sql, /provider <> \$1 AND lower\(email\) = lower\(\$2\)/);
195
+ assert.doesNotMatch(q.sql, /github_id|handle|account_id|SELECT[^F]*email/, 'it reads back no key, handle or address');
196
+ const known = fakePool([ownedBy('-7')]);
197
+ assert.deepEqual(await logins.existingAccountHint({ provider: 'google', subject: 'g-1', email: 'a@b.c' }, { pool: known }), [], 'a returning person is never stopped');
198
+ assert.deepEqual(await logins.existingAccountHint({ provider: 'google', subject: 'g-1', email: null }, { pool }), []);
199
+ });
200
+
201
+ test('the hint page offers the existing account or a new one, joins nothing and names no one', () => {
202
+ const html = renderExistingAccountHint({ githubHref: '/api/bongos/auth/web/start?via=github&return=%2Faccount%23aWaysIn', newAccountHref: '/api/bongos/auth/google/start?fresh=1' });
203
+ assert.match(html, /Sign in with GitHub/);
204
+ assert.match(html, /Make a new account with Google/);
205
+ assert.match(html, /href="\/api\/bongos\/auth\/google\/start\?fresh=1"/);
206
+ assert.match(html, /We never join accounts on our own, even when the emails match\./);
207
+ });
208
+
209
+ test('every link answer lands on the account page’s ways section', () => {
210
+ assert.equal(linkLanding('github', { linked: true }), '/account?linked=github#aWaysIn');
211
+ assert.equal(linkLanding('google', { refused: 'taken' }), '/account?link_refused=taken&provider=google#aWaysIn');
212
+ assert.equal(linkLanding('google', null), '/account?link_refused=failed&provider=google#aWaysIn');
213
+ });
214
+
215
+ // ── 5. the own-scoped routes ─────────────────────────────────────────────────
216
+
217
+ let server; let port;
218
+ before(async () => {
219
+ const app = express();
220
+ app.use(express.json());
221
+ app.use(attachFail);
222
+ app.use('/api/bongos', profileRoutes());
223
+ server = http.createServer(app);
224
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
225
+ port = server.address().port;
226
+ });
227
+ after(() => server.close());
228
+ const call = (method, path) => new Promise((resolve, reject) => {
229
+ const r = http.request({ host: '127.0.0.1', port, path, method }, (resp) => {
230
+ let data = ''; resp.on('data', (c) => { data += c; }); resp.on('end', () => resolve({ status: resp.statusCode, body: data ? JSON.parse(data) : null }));
231
+ });
232
+ r.on('error', reject); r.end();
233
+ });
234
+
235
+ test('GET /me/sign-in-methods says, per way in, why it cannot be removed', async () => {
236
+ ME = '-7';
237
+ routePool.script = [[/FROM platform_identity_account_logins l\s+JOIN platform_identity_accounts a ON a.id = l.account_id\s+WHERE a.github_id/, { rows: [{ provider: 'google', email: 'ana@example.com' }] }]];
238
+ const res = await call('GET', '/api/bongos/me/sign-in-methods');
239
+ assert.equal(res.status, 200);
240
+ assert.deepEqual(res.body.methods, [{ provider: 'google', email: 'ana@example.com', unlink_refused: 'last_way_in' }]);
241
+ });
242
+
243
+ test('DELETE /me/sign-in-methods/:provider removes one, keyed on the session, and refuses in plain words', async () => {
244
+ ME = '-7';
245
+ routePool.script = [held('github', 'google')];
246
+ routePool.log.length = 0;
247
+ assert.equal((await call('DELETE', '/api/bongos/me/sign-in-methods/google')).status, 200);
248
+ assert.deepEqual(routePool.log.find((q) => /FOR UPDATE/.test(q.sql)).params, ['-7'], 'the person is the session’s, never a parameter');
249
+ routePool.script = [held('google')];
250
+ const last = await call('DELETE', '/api/bongos/me/sign-in-methods/google');
251
+ assert.equal(last.status, 409);
252
+ assert.equal(last.body.error.code, 'last_way_in');
253
+ assert.equal((await call('DELETE', '/api/bongos/me/sign-in-methods/discord')).status, 404, 'only the known providers');
254
+ });
255
+
256
+ // ── 6. Link GitHub, end to end through its callback ──────────────────────────
257
+
258
+ test('Link GitHub: a GitHub account that already has its own account here is refused, never merged', async () => {
259
+ const auth = require('../src/bongos/auth.js');
260
+ const buildAuthLinkRouter = require('../src/bongos/routes/auth-link.js');
261
+ const linked = [];
262
+ registered.linkLogin = async (r) => { linked.push(r); return { linked: true, already: false }; };
263
+ const realSession = auth.resolveSession;
264
+ auth.resolveSession = async () => ({ github_id: '-7', display_name: 'Ana', status: 'active' });
265
+ const realFetch = globalThis.fetch;
266
+ globalThis.fetch = async (url) => ({ ok: true, json: async () => (String(url).includes('access_token') ? { access_token: 't' } : { id: 555, login: 'ana-gh' }) });
267
+ const realGet = dbMod.getBuilderByGithubId;
268
+ const app = express();
269
+ app.use(buildAuthLinkRouter());
270
+ const srv = http.createServer(app);
271
+ await new Promise((r) => srv.listen(0, '127.0.0.1', r));
272
+ const hit = () => new Promise((resolve, reject) => {
273
+ http.get({ host: '127.0.0.1', port: srv.address().port, path: '/auth/web/callback/link?state=s1&code=c', headers: { cookie: 'bongos_link_state=s1' } },
274
+ (resp) => { resp.resume(); resolve(resp.headers.location); }).on('error', reject);
275
+ });
276
+ try {
277
+ dbMod.getBuilderByGithubId = async (id) => (id === '555' ? { id: 3 } : null);
278
+ assert.equal(await hit(), '/account?link_refused=taken&provider=github#aWaysIn');
279
+ assert.equal(linked.length, 0, 'the hub was never asked to link it');
280
+ dbMod.getBuilderByGithubId = async () => null;
281
+ upserts.length = 0;
282
+ assert.equal(await hit(), '/account?linked=github#aWaysIn');
283
+ assert.deepEqual(linked[0], { personKey: '-7', provider: 'github', subject: '555', email: null, githubLogin: 'ana-gh' });
284
+ assert.equal(upserts[0].githubId, '-7');
285
+ assert.equal(upserts[0].githubLogin, 'ana-gh', 'the hall shows the real login from now on');
286
+ } finally {
287
+ auth.resolveSession = realSession; globalThis.fetch = realFetch; dbMod.getBuilderByGithubId = realGet; srv.close();
288
+ }
289
+ });
@@ -154,6 +154,62 @@ try {
154
154
  const after = await accountRow(first.personKey);
155
155
  same([after.github_login, after.profile_state], [null, 'active'], 'the hub sign-in activates the person and writes no login');
156
156
 
157
+ // 9 — linking and removing ways in (task 1004995), against the real constraints
158
+ // and real row locks, which a fake pool cannot execute.
159
+ await q(migrationSql('platform_identity_035_account_login_email_idx.sql'));
160
+ await q(migrationSql('platform_identity_035_account_login_email_idx.sql'));
161
+ ok((await q(`SELECT 1 FROM pg_indexes WHERE indexname = 'platform_identity_account_logins_lower_email_idx'`)).rows.length === 1,
162
+ 'the address index exists, and its migration re-runs safely');
163
+
164
+ const GH_LINK = '90499203'; // a GitHub id nobody holds yet
165
+ const ana = await logins.signInWithLogin({ provider: 'google', subject: SUB('ana'), email: 'ana@example.com', displayName: 'Ana' }, { pool });
166
+ const bo = await logins.signInWithLogin({ provider: 'google', subject: SUB('bo'), email: 'bo@example.com', displayName: 'Bo' }, { pool });
167
+
168
+ // Two people link the same GitHub account at once: the UNIQUE (provider, subject)
169
+ // decides, so exactly one gets it and the other is told it is taken.
170
+ const racedLinks = await Promise.all([
171
+ logins.linkLogin({ personKey: ana.personKey, provider: 'github', subject: GH_LINK, githubLogin: 'ana-gh' }, { pool }),
172
+ logins.linkLogin({ personKey: bo.personKey, provider: 'github', subject: GH_LINK, githubLogin: 'ana-gh' }, { pool }),
173
+ ]);
174
+ same(racedLinks.map((r) => (r.linked ? 'linked' : r.refused)).sort(), ['linked', 'taken'], 'two people racing for one GitHub account: one links, one is refused, nothing merges');
175
+ const winner = racedLinks[0].linked ? ana : bo;
176
+ const loser = racedLinks[0].linked ? bo : ana;
177
+ same((await accountRow(winner.personKey)).github_login, 'ana-gh', 'the winner’s account now shows the real GitHub login');
178
+ same((await accountRow(loser.personKey)).github_login, null, 'the other account is untouched');
179
+ same(await logins.personKeyForGithub(GH_LINK, { pool }), winner.personKey, 'a GitHub sign-in with that id now reaches the winner');
180
+ same(await logins.linkLogin({ personKey: loser.personKey, provider: 'github', subject: GH_LINK, githubLogin: 'x' }, { pool }), { refused: 'taken' },
181
+ 'linking it again later is still refused');
182
+ same(await logins.linkLogin({ personKey: winner.personKey, provider: 'github', subject: '90499204', githubLogin: 'y' }, { pool }), { refused: 'already_linked' },
183
+ 'a person holds one GitHub way in; a second is refused by the database');
184
+
185
+ // Two removals at once on a person with two ways in: the row lock serialises
186
+ // them, so exactly one goes and the other finds it would be the last.
187
+ const removed = await Promise.all([
188
+ logins.unlinkLogin({ personKey: winner.personKey, provider: 'google' }, { pool }),
189
+ logins.unlinkLogin({ personKey: winner.personKey, provider: 'github' }, { pool }),
190
+ ]);
191
+ same(removed.filter((r) => r.unlinked).length, 1, 'two racing removals: exactly one goes through');
192
+ same(removed.filter((r) => r.refused === 'last_way_in').length, 1, 'the other is refused as the last way in');
193
+ same((await logins.listLoginsForPerson(winner.personKey, { pool })).length, 1, 'the person keeps one way in');
194
+
195
+ // GitHub never comes off an account born with it (its key IS its GitHub id).
196
+ same(await logins.unlinkLogin({ personKey: String(GH), provider: 'github' }, { pool }), { refused: 'born_with' }, 'a GitHub-born account keeps GitHub');
197
+ same((await logins.listLoginsForPerson(String(GH), { pool })).map((m) => m.provider), ['github', 'google'], 'and nothing was deleted');
198
+
199
+ // A GitHub way in removed from a person who joined with Google clears the login.
200
+ const cy = await logins.signInWithLogin({ provider: 'google', subject: SUB('cy'), email: 'cy@example.com', displayName: 'Cy' }, { pool });
201
+ await logins.linkLogin({ personKey: cy.personKey, provider: 'github', subject: '90499205', githubLogin: 'cy-gh' }, { pool });
202
+ same(await logins.unlinkLogin({ personKey: cy.personKey, provider: 'github' }, { pool }), { unlinked: true }, 'a linked GitHub comes off a Google-born person');
203
+ same([(await accountRow(cy.personKey)).github_login, await logins.personKeyForGithub('90499205', { pool })], [null, null],
204
+ 'their login is cleared and that GitHub id reaches no one again');
205
+
206
+ // The first-sign-in hint: the address another way in shows, provider names only.
207
+ await logins.touchGithubLogin({ githubId: String(GH), email: 'Octo-GH@Example.com' }, { pool });
208
+ same(await logins.existingAccountHint({ provider: 'google', subject: SUB('newcomer'), email: 'octo-gh@example.com' }, { pool }), ['github'],
209
+ 'a new Google identity with a GitHub way in’s address (any case) is offered GitHub');
210
+ same(await logins.existingAccountHint({ provider: 'google', subject: SUB('octo'), email: 'octo-gh@example.com' }, { pool }), [],
211
+ 'a Google identity that already reaches someone is never stopped');
212
+
157
213
  console.log(`\naccount_logins_db.mjs: ${pass} passed`);
158
214
  } finally {
159
215
  await cleanup();
@@ -341,16 +341,78 @@ function chainFor(path) {
341
341
  return layer.route.stack.map((s) => s.handle.name || '<handler>');
342
342
  }
343
343
 
344
- test('POST /claims/batch middleware chain is pinned: requireBuilder → gateNewcomerClaim → handler', () => {
345
- assert.deepEqual(chainFor('/claims/batch'), ['requireBuilder', 'gateNewcomerClaim', '<handler>']);
344
+ test('POST /claims/batch middleware chain is pinned: requireBuilder → gateOneActiveClaimPerSession → gateNewcomerClaim → handler', () => {
345
+ assert.deepEqual(chainFor('/claims/batch'),
346
+ ['requireBuilder', 'gateOneActiveClaimPerSession', 'gateNewcomerClaim', '<handler>']);
346
347
  });
347
348
 
348
- test('POST /claims carries the one-claim-per-session gate that POST /claims/batch does not (the documented difference)', () => {
349
- const single = chainFor('/claims');
350
- assert.ok(single.includes('gateOneActiveClaimPerSession'));
351
- assert.ok(single.includes('gateNewcomerClaim'));
352
- assert.ok(!chainFor('/claims/batch').includes('gateOneActiveClaimPerSession'),
353
- 'if batch claim gains the session gate, update this pin (task 1005005 asks whether it should)');
349
+ // task 1005005 (found by task 1004983): the batch route used to skip the
350
+ // session gate. Both routes now run the SAME middleware function, in the same
351
+ // order — one check, never a second copy of it.
352
+ test('POST /claims and POST /claims/batch run the identical gate functions in the identical order', () => {
353
+ const gates = (p) => router.stack.find((l) => l.route && l.route.path === p && l.route.methods.post)
354
+ .route.stack.slice(0, -1).map((s) => s.handle);
355
+ const single = gates('/claims');
356
+ const batch = gates('/claims/batch');
357
+ assert.equal(batch.length, single.length);
358
+ single.forEach((fn, i) => assert.equal(batch[i], fn, `gate ${i} (${fn.name}) is the same function on both routes`));
359
+ });
360
+
361
+ // ── POST /claims/batch — the one-claim-per-session gate ──────────────────────
362
+
363
+ test('a thetes session already holding a claim in this tree is refused 409 session_already_has_active_claim; nothing claimed', async () => {
364
+ caller = { id: '42', rank: 'thetes' };
365
+ activeClaims = [{ claim_id: '88', task_id: '700', title: 'First task', creator_session_id: 'sess-A', worktree_name: 'wt', requested_worktree_name: 'wt' }];
366
+ let batchCalls = 0;
367
+ db.claimTasksBatch = async () => { batchCalls += 1; return { claims: [] }; };
368
+ const r = await post(CLAIM, { task_ids: [501, 502], worktree_name: 'wt' });
369
+ assert.equal(r.status, 409);
370
+ assert.equal(r.json.error.code, 'session_already_has_active_claim');
371
+ assert.equal(r.json.error.details.active_task_id, '700');
372
+ assert.equal(batchCalls, 0, 'claimTasksBatch never ran');
373
+ assert.equal(connects, 0, 'no transaction was opened');
374
+ assert.deepEqual(writes(), []);
375
+ });
376
+
377
+ test('the same session in a DIFFERENT tree may batch-claim (one claim per tree, as on POST /claims)', async () => {
378
+ activeClaims = [{ claim_id: '88', task_id: '700', title: 'First task', creator_session_id: 'sess-A', worktree_name: 'other', requested_worktree_name: 'other' }];
379
+ let batchCalls = 0;
380
+ db.claimTasksBatch = async () => { batchCalls += 1; return { claims: [] }; };
381
+ const r = await post(CLAIM, { task_ids: [501], worktree_name: 'wt' });
382
+ assert.equal(r.status, 201);
383
+ assert.equal(batchCalls, 1);
384
+ });
385
+
386
+ test('a claim held by ANOTHER session in the same tree does not refuse this session', async () => {
387
+ activeClaims = [{ claim_id: '88', task_id: '700', title: 'First task', creator_session_id: 'sess-Z', worktree_name: 'wt', requested_worktree_name: 'wt' }];
388
+ db.claimTasksBatch = async () => ({ claims: [] });
389
+ const r = await post(CLAIM, { task_ids: [501], worktree_name: 'wt' });
390
+ assert.equal(r.status, 201);
391
+ });
392
+
393
+ test('bind_session:false (the hall) has no session to scope to: no session lookup, claim proceeds', async () => {
394
+ activeClaims = [{ claim_id: '88', task_id: '700', title: 'First task', creator_session_id: 'sess-A', worktree_name: null, requested_worktree_name: null }];
395
+ let batchCalls = 0;
396
+ db.claimTasksBatch = async () => { batchCalls += 1; return { claims: [] }; };
397
+ const r = await post(CLAIM, { task_ids: [501, 502], bind_session: false });
398
+ assert.equal(r.status, 201);
399
+ assert.equal(batchCalls, 1);
400
+ assert.deepEqual(calls, []);
401
+ });
402
+
403
+ test('the session lookup failing refuses the batch (fail closed), nothing claimed', async () => {
404
+ const realGet = db.getActiveClaimsForSession;
405
+ db.getActiveClaimsForSession = async () => { throw new Error('db down'); };
406
+ let batchCalls = 0;
407
+ db.claimTasksBatch = async () => { batchCalls += 1; return { claims: [] }; };
408
+ try {
409
+ const r = await post(CLAIM, { task_ids: [501] });
410
+ assert.equal(r.status, 409);
411
+ assert.equal(r.json.error.code, 'session_claim_check_failed');
412
+ assert.equal(batchCalls, 0);
413
+ } finally {
414
+ db.getActiveClaimsForSession = realGet;
415
+ }
354
416
  });
355
417
 
356
418
  test('POST /claims/batch/release middleware chain is pinned: requireBuilder → handler', () => {
@@ -371,10 +433,11 @@ test('a xenos with a claim in flight is refused by gateNewcomerClaim: 403, and n
371
433
  assert.equal(batchCalls, 0, 'claimTasksBatch never ran');
372
434
  assert.equal(connects, 0, 'no transaction was opened');
373
435
  assert.deepEqual(writes(), []);
374
- assert.equal(calls.length, 1, 'only the gate\'s own active-claims read');
436
+ // The session gate's read (its claim is unbound, so it passes) + the newcomer gate's read.
437
+ assert.equal(calls.length, 2, 'only the two gates\' own active-claims reads');
375
438
  });
376
439
 
377
- test('a thetes with a claim in flight is NOT held by the newcomer gate — and makes no gate lookup', async () => {
440
+ test('a thetes with an unbound claim in flight is NOT held by the newcomer gate — and makes no newcomer lookup', async () => {
378
441
  caller = { id: '42', rank: 'thetes' };
379
442
  activeClaims = [{ claim_id: '88', task_id: '700', title: 'First task' }];
380
443
  let batchCalls = 0;
@@ -382,7 +445,9 @@ test('a thetes with a claim in flight is NOT held by the newcomer gate — and m
382
445
  const r = await post(CLAIM, { task_ids: [501] });
383
446
  assert.equal(r.status, 201);
384
447
  assert.equal(batchCalls, 1);
385
- assert.deepEqual(calls, []);
448
+ // Only the session gate's read, keyed on the session id.
449
+ assert.equal(calls.length, 1);
450
+ assert.deepEqual(calls[0].params, ['sess-A']);
386
451
  });
387
452
 
388
453
  // ── POST /claims/batch — each documented error maps to its status and body ────
@@ -440,5 +505,7 @@ test('claim body: empty → 400 task_ids_required, 21 ids → 400, non-numeric
440
505
  assert.equal(bad.status, 400);
441
506
  assert.equal(bad.json.error.code, 'task_ids_must_be_numbers');
442
507
  assert.equal(batchCalls, 0);
443
- assert.deepEqual(calls, []);
508
+ // The session gate runs before body validation (as on POST /claims): reads only.
509
+ assert.deepEqual(writes(), []);
510
+ assert.ok(calls.every((c) => /^\s*SELECT/i.test(c.sql)), 'only the session gate\'s reads');
444
511
  });
@@ -184,6 +184,26 @@ try {
184
184
  assert.equal(task(7006).status, 'active');
185
185
  assert.equal((await lifecyclePort.releaseStuckTweak({ taskId: '7003', builderId: CARL, expectedStatus: 'active' })).outcome, 'round_moved');
186
186
  });
187
+
188
+ // task 1004976: the whole path, the sweep into the real lifecycle port. An idle
189
+ // draft expires after one day and its words are still on the task afterwards.
190
+ await test('an idle draft expires after a day through the real port, and the draft text stays in the record', async () => {
191
+ const autoClose = require('../modules/copy-desk/auto-close.js');
192
+ const draftLines = [{ key: 'L0002', section: 'hero', before: 'Build here', after: 'Build it right here', placement: 'placed' }];
193
+ const draftBlock = pages.composeDraftBlock({ page_id: 'status:explore', reading_hash: 'h', saved_at: '2026-09-19T00:00:00Z', lines: draftLines });
194
+ const body = `Page tweak round 2 for status:explore.\n\n${draftBlock}`;
195
+ const now = Date.parse('2026-09-21T00:00:00Z');
196
+ db.tasks.push({ id: '7010', status: 'ready', title: 'Tweak: Explore', description: body, source: 'page-tweak', source_ref: pages.roundSourceRef('status:explore', 2), touches: [], updated_at: '2026-09-19T23:00:00Z' });
197
+ db.tasks.push({ id: '7011', status: 'ready', title: 'Tweak: Explore', description: body, source: 'page-tweak', source_ref: pages.roundSourceRef('status:explore', 3), touches: [], updated_at: '2026-09-20T12:00:00Z' });
198
+ const r = await autoClose.runAutoClose({ now, founder: async () => ({ id: CARL }) });
199
+ const mine = r.closed.filter((c) => ['7010', '7011'].includes(c.task_id));
200
+ assert.deepEqual(mine.map((c) => [c.task_id, c.why, c.outcome]), [['7010', 'draft_expired', 'abandoned']], 'the draft touched 12 hours ago stays');
201
+ assert.equal(task(7010).status, 'abandoned');
202
+ assert.match(task(7010).value_summary, /^Abandoned: Closed by itself: the draft sat untouched for a day\./);
203
+ assert.equal(task(7010).description, body, 'the draft is kept: the description is not touched');
204
+ assert.equal(pages.parseDraftBlock(task(7010).description).ok, true, 'and still parses as the draft');
205
+ assert.equal(task(7011).status, 'ready');
206
+ });
187
207
  } finally {
188
208
  server.close();
189
209
  fs.rmSync(tmp, { recursive: true, force: true });
@@ -56,7 +56,7 @@ await test('every state has an owner and a deadline: overdue only past it, told
56
56
  const approved = pages.composeWordsApprovedBlock({ at: ago(1), by: ARTIST });
57
57
  // [state, task shape, owner, days until overdue]
58
58
  const table = [
59
- ['draft', (id) => ({ status: 'ready', description: desc(id, [draftBlock(id)]) }), 'artist', 7],
59
+ ['draft', (id) => ({ status: 'ready', description: desc(id, [draftBlock(id)]) }), 'artist', 1], // task 1004976: due when it expires, no reminder first,
60
60
  ['writing', () => ({ status: 'active', holder_id: ARTIST, holder_worktree: false }), 'artist', 7],
61
61
  ['words_review', (id) => ({ status: 'ready', description: desc(id, [batchBlock(id)]) }), 'artist', 3],
62
62
  ['submitted', (id) => ({ status: 'ready', description: desc(id, [batchBlock(id), approved]) }), 'appliers', 3],
@@ -82,7 +82,7 @@ await test('every state has an owner and a deadline: overdue only past it, told
82
82
  }
83
83
  const draft = status.composePages({ inventory: INVENTORY, readings: null, tasks: [round(51, 'landing:projects', { status: 'ready', description: desc('landing:projects', [draftBlock('landing:projects')]), updated_at: ago(2) })], now: NOW }).in_flight[0];
84
84
  assert.equal(Date.parse(draft.expires_at) - Date.parse(draft.since), status.DRAFT_EXPIRES_AFTER, 'a draft says when it expires');
85
- assert.equal(status.DRAFT_EXPIRES_AFTER, 14 * D);
85
+ assert.equal(status.DRAFT_EXPIRES_AFTER, D, 'a draft expires after one day (owner, task 1004971)');
86
86
  });
87
87
 
88
88
  await test('submitted words left unreviewed wait on their artist, not the appliers (task 1004900)', () => {