@bongos/core 1.24.5 → 1.24.7

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 (53) hide show
  1. package/.bongos-core.json +79 -54
  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 +8 -1
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/adr/0371-auto-pickup-applies-a-submitted-tweak-on-the-runner.md +1 -0
  8. package/docs/adr/0396-google-sign-in-a-stand-in-login-and-a-browser-approved-cli-sign-in.md +8 -0
  9. package/docs/api/openapi.json +114 -6
  10. package/docs/api-reference.md +7 -4
  11. package/docs/copy-inventory.md +52 -43
  12. package/docs/copy-registry.json +145 -63
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/page-inventory.json +2 -1
  15. package/docs/page-readings.json +71 -63
  16. package/migrations/core_286_credit_log_no_spend.sql +128 -0
  17. package/modules/copy-desk/auto-close.js +5 -3
  18. package/modules/copy-desk/page-status.js +7 -4
  19. package/modules/copy-desk/tests/copy_page_auto_close.mjs +4 -4
  20. package/modules/hall-ui/public/project-settings.html +4 -4
  21. package/modules/hall-ui/public/project-settings.js +51 -1
  22. package/modules/lifecycle/autopickup.js +26 -9
  23. package/modules/lifecycle/db-claims.js +1 -1
  24. package/modules/lifecycle/routes/claims.js +7 -1
  25. package/modules/lifecycle/routes/project-settings.js +71 -10
  26. package/modules/platform-identity/account-logins.js +131 -0
  27. package/modules/platform-identity/migrations/platform_identity_035_account_login_email_idx.sql +19 -0
  28. package/modules/platform-identity/routes/profile.js +29 -1
  29. package/modules/platform-identity/routes/sso.js +25 -3
  30. package/modules/public-landing/public/account.html +114 -0
  31. package/modules/public-landing/public/account.states.json +1 -0
  32. package/modules/ui-design/kit/serve.js +24 -0
  33. package/package-lock.json +2 -2
  34. package/package.json +1 -1
  35. package/release-notes.json +24 -0
  36. package/scripts/bongos/run-unit-tests.js +4 -0
  37. package/src/bongos/auth-github.js +24 -7
  38. package/src/bongos/project-settings.js +59 -15
  39. package/src/bongos/routes/auth-google.js +71 -5
  40. package/src/bongos/routes/auth-link.js +132 -0
  41. package/src/bongos/routes.js +2 -0
  42. package/src/bongos/sign-in-pages.js +20 -1
  43. package/src/module-api.js +1 -1
  44. package/tests/account_linking.mjs +289 -0
  45. package/tests/account_logins_db.mjs +56 -0
  46. package/tests/autopickup_cap.mjs +203 -11
  47. package/tests/claims_batch_routes.mjs +79 -12
  48. package/tests/copy_desk_page_release.mjs +20 -0
  49. package/tests/copy_stuck_page_tweaks.mjs +2 -2
  50. package/tests/credit_log_no_spend_db.mjs +160 -0
  51. package/tests/effective_visibility_predicate.mjs +8 -0
  52. package/tests/hub_account_page.mjs +91 -5
  53. package/tests/project_settings_page.mjs +8 -5
@@ -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)', () => {
@@ -0,0 +1,160 @@
1
+ // tests/credit_log_no_spend_db.mjs — task 1004988 (goal 1000092; ADR 0385
2
+ // decision 2, ADR 0387). Real DB.
3
+ //
4
+ // Builds credit_log in a throwaway schema, applies the REAL migration
5
+ // (migrations/core_286_credit_log_no_spend.sql), and proves the DATABASE refuses a row that spends,
6
+ // redeems or transfers credits, while every existing reversal path still books:
7
+ //
8
+ // - task.confirmed_reversal — the only reversal path that exists today: the
9
+ // ADR 0120 clawback, migrations/core_194_confirmed_backlog_clawback.sql. It is
10
+ // re-run here, verbatim, AFTER the trigger, exactly as the integration suite's
11
+ // clawback_confirmed_backlog test re-runs it on a fully migrated database.
12
+ // - reversal:<id> — the general shape the trigger defines for any future one.
13
+ //
14
+ // modules/economy itself writes no negative row today (every writer there books
15
+ // earnings, and the descent and idea writers drop a non-positive delta), so the
16
+ // economy writers are covered by the "positive and zero rows are untouched"
17
+ // cases rather than by a reversal of their own.
18
+ //
19
+ // The probe the task asks for is built in: the last block DROPS the trigger and
20
+ // shows the same spend row is then admitted, so a test that kept passing without
21
+ // the trigger would fail that block.
22
+ //
23
+ // Self-skips without a reachable Postgres.
24
+ // Run: DATABASE_URL=... node tests/credit_log_no_spend_db.mjs
25
+ import { createRequire } from 'node:module';
26
+ import { readFileSync } from 'node:fs';
27
+ import { fileURLToPath } from 'node:url';
28
+ import { dirname, join } from 'node:path';
29
+ import { strict as assert } from 'node:assert';
30
+ const require = createRequire(import.meta.url);
31
+ const { Pool } = require('pg');
32
+ const here = dirname(fileURLToPath(import.meta.url));
33
+ const MIGRATION = 'core_286_credit_log_no_spend.sql';
34
+
35
+ const pgPool = new Pool({ connectionString: process.env.DATABASE_URL || undefined, max: 1 });
36
+ let client;
37
+ try { client = await pgPool.connect(); } catch (e) {
38
+ console.log(`credit_log_no_spend_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
39
+ await pgPool.end().catch(() => {});
40
+ process.exit(0);
41
+ }
42
+ const SCHEMA = `t1004988_${process.pid}`;
43
+ let pass = 0;
44
+ const ok = (c, m) => { assert.ok(c, m); pass++; console.log(` ok - ${m}`); };
45
+ const refused = async (sql, params, m) => {
46
+ await client.query('SAVEPOINT s');
47
+ let err = null;
48
+ try { await client.query(sql, params); } catch (e) { err = e; }
49
+ await client.query('ROLLBACK TO SAVEPOINT s');
50
+ ok(err && err.code === '23514', `${m}${err ? '' : ' (it was admitted)'}`);
51
+ };
52
+ const ins = (builder, task, delta, reason) => client.query(
53
+ 'INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES ($1, $2, $3, $4) RETURNING id',
54
+ [builder, task, delta, reason],
55
+ ).then((r) => r.rows[0].id);
56
+ const balance = async (b) => Number((await client.query('SELECT total_credits FROM builders WHERE id = $1', [b])).rows[0].total_credits);
57
+
58
+ try {
59
+ await client.query(`CREATE SCHEMA ${SCHEMA}`);
60
+ await client.query(`SET search_path TO ${SCHEMA}`);
61
+ // The columns credit_log and its balance trigger carry (migrations/003_pms.sql
62
+ // plus the later description/session columns), and the task columns core_194
63
+ // reads and writes.
64
+ await client.query(`CREATE TABLE builders (id bigserial PRIMARY KEY, total_credits integer NOT NULL DEFAULT 0)`);
65
+ await client.query(`CREATE TABLE tasks (id bigserial PRIMARY KEY, status text NOT NULL,
66
+ needs_rebase boolean NOT NULL DEFAULT false, needs_rebase_reason text, needs_rebase_at timestamptz, updated_at timestamptz)`);
67
+ await client.query(`CREATE TABLE credit_log (id bigserial PRIMARY KEY, builder_id bigint NOT NULL REFERENCES builders(id),
68
+ task_id bigint REFERENCES tasks(id), delta integer NOT NULL, reason text NOT NULL, description text,
69
+ session_id text, recorded_at timestamptz NOT NULL DEFAULT now())`);
70
+ await client.query(`CREATE FUNCTION pms_apply_credit() RETURNS trigger AS $$
71
+ BEGIN UPDATE builders SET total_credits = total_credits + NEW.delta WHERE id = NEW.builder_id; RETURN NEW; END;
72
+ $$ LANGUAGE plpgsql`);
73
+ await client.query('CREATE TRIGGER trg_credit_log_apply AFTER INSERT ON credit_log FOR EACH ROW EXECUTE FUNCTION pms_apply_credit()');
74
+ await client.query(readFileSync(join(here, '..', 'migrations', MIGRATION), 'utf8'));
75
+
76
+ await client.query('INSERT INTO builders DEFAULT VALUES'); // 1
77
+ await client.query('INSERT INTO builders DEFAULT VALUES'); // 2
78
+ await client.query('INSERT INTO builders DEFAULT VALUES'); // 3
79
+ await client.query("INSERT INTO tasks (status) VALUES ('confirmed'), ('shipped')"); // 1, 2
80
+ await client.query('BEGIN');
81
+
82
+ // Earning and audit rows are untouched.
83
+ const earned = await ins(1, 2, 30, 'task.shipped');
84
+ await ins(1, null, 12, 'session.token_reward');
85
+ await ins(1, null, 0, 'rank_change');
86
+ ok(await balance(1) === 42, 'positive and zero rows book as before');
87
+ // Every reason the economy writers book today (modules/economy/credits.js and
88
+ // achievements.js). All are positive; none may be caught by the spend names.
89
+ const ECONOMY_REASONS = [
90
+ 'task.shipped', 'task.confirmed', 'session.token_reward', 'idea_promotion_bonus',
91
+ 'ship.net_negative', 'tweak.page_approved:2', 'idea.credit.task_ship:7:2',
92
+ 'idea.credit.full_pass:7', 'idea.credit.governance_pass:7', 'achievement.first-ship',
93
+ ];
94
+ for (const reason of ECONOMY_REASONS) await ins(3, null, 1, reason);
95
+ ok(await balance(3) === ECONOMY_REASONS.length, `every economy writer's reason still books (${ECONOMY_REASONS.join(', ')})`);
96
+
97
+ // Spend, redemption, transfer: refused.
98
+ await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (1, NULL, -5, $1)', ['spend'], 'a raw negative spend row is refused');
99
+ await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (1, NULL, -5, $1)', ['redemption'], 'a raw negative redemption row is refused');
100
+ await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (1, NULL, -5, $1)', ['module.purchase'], 'any other negative row is refused');
101
+ await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (2, NULL, 5, $1)', ['transfer.in'], 'the positive half of a transfer is refused too');
102
+ await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (1, NULL, 0, $1)', ['redemption'], 'a zero-value redemption row is refused');
103
+ ok(await balance(1) === 42, 'no refused row moved the balance');
104
+ // The refused names match as whole words, so a reason that only starts with
105
+ // the letters still books.
106
+ await ins(3, null, 1, 'achievement.spender-of-the-month');
107
+ await ins(3, null, 1, 'spender-of-the-month');
108
+ ok(await balance(3) === ECONOMY_REASONS.length + 2, 'a reason that merely starts with "spend" is not refused');
109
+ await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (1, NULL, 1, $1)', ['spend:7'], 'spend:<x> is refused');
110
+ await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (1, NULL, 1, $1)', ['Transfer-out'], 'and the match ignores case');
111
+
112
+ // reversal:<id> — exists, same builder, positive, never more than booked.
113
+ await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (1, NULL, -5, $1)', ['reversal:999999'], 'a reversal naming no row is refused');
114
+ await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (2, NULL, -5, $1)', [`reversal:${earned}`], 'a reversal of another builder\'s row is refused');
115
+ await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (1, NULL, -31, $1)', [`reversal:${earned}`], 'a reversal larger than the row is refused');
116
+ await ins(1, 2, -20, `reversal:${earned}`);
117
+ await ins(1, 2, -10, `reversal:${earned}`);
118
+ ok(await balance(1) === 12, 'reversal:<id> books, in parts, up to the whole row');
119
+ await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (1, NULL, -1, $1)', [`reversal:${earned}`], 'a reversal past the row\'s total is refused');
120
+ const audit = (await client.query("SELECT id FROM credit_log WHERE reason = 'rank_change'")).rows[0].id;
121
+ await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (1, NULL, -1, $1)', [`reversal:${audit}`], 'a zero row cannot be reversed');
122
+
123
+ // task.confirmed_reversal — the ADR 0120 clawback, re-run verbatim.
124
+ await ins(2, 1, 15, 'task.confirmed');
125
+ const core194 = readFileSync(join(here, '..', 'migrations', 'core_194_confirmed_backlog_clawback.sql'), 'utf8')
126
+ .replace(/^\s*(BEGIN|COMMIT);\s*$/gim, ''); // already inside this test's transaction
127
+ await client.query(core194);
128
+ ok(await balance(2) === 0, 'core_194 (task.confirmed_reversal) still reverses a confirmed task\'s credit');
129
+ await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (2, 1, -1, $1)', ['task.confirmed_reversal'], 'that reversal cannot go past what was booked');
130
+ await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (2, 2, -1, $1)', ['task.confirmed_reversal'], 'nor reverse a task that booked nothing');
131
+ await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (2, NULL, -1, $1)', ['task.confirmed_reversal'], 'nor run without a task');
132
+
133
+ // Existing rows are never rewritten: the trigger is INSERT-only.
134
+ const { rows: trig } = await client.query(
135
+ "SELECT tgtype FROM pg_trigger WHERE tgname = 'trg_credit_log_refuse_spend' AND tgrelid = 'credit_log'::regclass");
136
+ // tgtype bits: 1 ROW, 2 BEFORE, 4 INSERT, 8 DELETE, 16 UPDATE.
137
+ ok(trig.length === 1 && (trig[0].tgtype & 2) && (trig[0].tgtype & 4) && !(trig[0].tgtype & 24), 'the trigger is BEFORE INSERT only');
138
+ const { rows: idx } = await client.query(
139
+ "SELECT pg_get_indexdef(indexrelid) AS def FROM pg_index WHERE indexrelid = 'idx_credit_log_reversals'::regclass");
140
+ ok(idx.length === 1 && /\(builder_id, reason\) WHERE \(delta < 0\)/.test(idx[0].def),
141
+ 'the reversal sums read a partial index of negative rows only, not the whole ledger');
142
+ const { rows: cmt } = await client.query(`SELECT col_description('credit_log'::regclass,
143
+ (SELECT attnum FROM pg_attribute WHERE attrelid = 'credit_log'::regclass AND attname = 'delta')) AS d,
144
+ obj_description('credit_log'::regclass) AS t`);
145
+ ok(!/redemption|spent \(future\)/i.test(`${cmt[0].d} ${cmt[0].t}`), 'the comments no longer offer redemption or a future spend');
146
+
147
+ // The probe: without the trigger the same spend row is admitted.
148
+ await client.query('DROP TRIGGER trg_credit_log_refuse_spend ON credit_log');
149
+ await ins(1, null, -5, 'spend');
150
+ ok(await balance(1) === 7, 'probe: with the trigger dropped, the spend row books — the trigger is what refuses it');
151
+
152
+ await client.query('ROLLBACK');
153
+ console.log(`credit_log_no_spend_db: ${pass} passed`);
154
+ } finally {
155
+ await client.query('ROLLBACK').catch(() => {});
156
+ await client.query('SET search_path TO public').catch(() => {});
157
+ await client.query(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`).catch(() => {});
158
+ client.release();
159
+ await pgPool.end();
160
+ }
@@ -172,6 +172,14 @@ const NON_PUBLIC_READS = [
172
172
  fn: 'listLoginsForPerson',
173
173
  why: 'the own-scope read behind GET /me/sign-in-methods (task 1004992): which ways in the signed-in person has, keyed on their authenticated key only. It returns their own providers and addresses to themselves; a private account must still see its own sign-in methods.',
174
174
  },
175
+ {
176
+ fn: 'linkLogin',
177
+ why: 'linking a second way in (task 1004995): finds the signed-in person\'s own account row by their authenticated key to attach the login to. It returns only linked/refused to that person; a private account must still be able to link.',
178
+ },
179
+ {
180
+ fn: 'unlinkLogin',
181
+ why: 'removing a way in (task 1004995): locks the signed-in person\'s own logins by their authenticated key to count them. It returns only unlinked/refused to that person; a private account must still be able to unlink.',
182
+ },
175
183
  {
176
184
  fn: 'hiddenGithubIds',
177
185
  why: 'the closeness-graph consent gate — it answers only "which of these accounts set hide_stats", the OPPOSITE question to visibility. Its callers are requireBuilder-gated and it must stay fail-closed on hide alone (ADR 0171 D4); joining addressability would silently empty the graph of every private account for readers who are allowed to see the edge.',
@@ -83,8 +83,8 @@ test('the script builds no markup from strings, and the controls are real markup
83
83
 
84
84
  test('the door returns here, and the page calls only its own-scoped routes', () => {
85
85
  assert.match(PAGE, /href="\/api\/bongos\/auth\/web\/start\?return=\/account&amp;repos=1"/, 'sign-in lands back on /account, asking for repo access once (task 1004549)');
86
- const calls = [...SCRIPT.matchAll(/request\('(GET|PATCH|POST)', '([^']+)'/g)].map((m) => `${m[1]} ${m[2]}`);
87
- assert.deepEqual([...new Set(calls)].sort(), ['GET /me/cross-project', 'PATCH /me/cross-project', 'PATCH /me/handle', 'PATCH /me/profile', 'GET /my-projects', 'POST /my-projects/', 'GET /me/guilds'].sort());
86
+ const calls = [...SCRIPT.matchAll(/request\('(GET|PATCH|POST|DELETE)', '([^']+)'/g)].map((m) => `${m[1]} ${m[2]}`);
87
+ assert.deepEqual([...new Set(calls)].sort(), ['GET /me/cross-project', 'PATCH /me/cross-project', 'PATCH /me/handle', 'PATCH /me/profile', 'GET /my-projects', 'POST /my-projects/', 'GET /me/guilds', 'GET /me/sign-in-methods', 'DELETE /me/sign-in-methods/'].sort());
88
88
  // the bar's two calls live in the shared asset: the identity read and sign out
89
89
  const barCalls = [...BAR.matchAll(/fetch\(API \+ '([^']+)', \{ method: '(GET|POST)'/g)].map((m) => `${m[2]} ${m[1]}`);
90
90
  assert.deepEqual(barCalls.sort(), ['GET /me', 'POST /auth/logout']);
@@ -183,7 +183,7 @@ const ACCOUNT = () => ({
183
183
  // A DOM stub just wide enough for this page: every id in the markup is an
184
184
  // element (hidden where the markup says so), every radio and link input is
185
185
  // addressable by the selectors the script uses, and fetch answers from a handler.
186
- function boot(answer) {
186
+ function boot(answer, { search = '' } = {}) {
187
187
  const els = new Map();
188
188
  const listeners = (o) => { o._on = {}; o.addEventListener = (t, f) => { (o._on[t] ||= []).push(f); }; return o; };
189
189
  // textContent reads the node's own text plus its children's, and setting it
@@ -299,7 +299,7 @@ function boot(answer) {
299
299
  };
300
300
  const location = { hostname: 'cloudbongos.com', pathname: '/account' };
301
301
  // one global, as in the browser: hub-bar.js sets window.HubBar, then the page reads it
302
- const ctx = vm.createContext({ document, fetch, location, window: { location: { assign(p) { dom.assigned = p; } } }, URL, Array, Object, JSON, Promise });
302
+ const ctx = vm.createContext({ document, fetch, location, window: { location: { search, assign(p) { dom.assigned = p; } } }, URL, Array, Object, JSON, Promise });
303
303
  vm.runInContext(BAR, ctx);
304
304
  vm.runInContext(SCRIPT, ctx);
305
305
  return dom;
@@ -498,7 +498,7 @@ test('the section ships inside the signed-in body, last, as a list with an empty
498
498
  test('signed in: the list is read once, after the account, and each row names its project and host with its own Leave', async () => {
499
499
  const dom = boot(building(BUILDING));
500
500
  await settle();
501
- assert.deepEqual(dom.calls.filter((c) => c.method === 'GET' && c.path !== '/me').map((c) => c.path), ['/me/cross-project', '/my-projects', '/me/guilds']);
501
+ assert.deepEqual(dom.calls.filter((c) => c.method === 'GET' && c.path !== '/me').map((c) => c.path), ['/me/cross-project', '/my-projects', '/me/guilds', '/me/sign-in-methods']);
502
502
  assert.equal(dom.els.get('buildingLoading').hidden, true);
503
503
  assert.equal(dom.els.get('buildingEmpty').hidden, true);
504
504
  const rows = rowsOf(dom);
@@ -857,3 +857,89 @@ test('the hall trusts only a bare https hub origin (OTB.hubOrigin, shared by Set
857
857
  assert.equal(origin('https://cloudbongos.com/account'), null, 'a value with a path is not an origin');
858
858
  assert.equal(origin('https://user@cloudbongos.com'), null);
859
859
  });
860
+
861
+ // ── ways to sign in (task 1004995) ───────────────────────────────────────────
862
+ // Link is a link to the sign-in routes; their answer comes back in the address.
863
+ // A way in the server says cannot be removed shows why, never a Remove.
864
+
865
+ const WAYS = (methods) => (m, p) => {
866
+ if (m === 'GET' && p === '/me/cross-project') return { status: 200, data: ACCOUNT() };
867
+ if (m === 'GET' && p === '/me/sign-in-methods') return { status: 200, data: { methods, providers: ['github', 'google'] } };
868
+ if (m === 'DELETE' && p === '/me/sign-in-methods/google') return { status: 200, data: { ok: true } };
869
+ if (m === 'DELETE' && p === '/me/sign-in-methods/github') return { status: 409, data: { error: { code: 'last_way_in', message: 'this is your only way in, so it cannot be removed — link another first' } } };
870
+ return { status: 500, data: null };
871
+ };
872
+ const waysOf = (dom) => dom.els.get('waysList').children;
873
+ const actionOf = (row) => row.children[1].children[0];
874
+
875
+ test('the ways section sits in the signed-in body with its list and status line', () => {
876
+ const body = PAGE.split('<div id="acctBody" hidden>')[1].split('</main>')[0];
877
+ const sec = (body.match(/<section class="aSec" id="aWaysIn" aria-labelledby="aWaysIn-h">[\s\S]*?<\/section>/) || [''])[0];
878
+ assert.ok(sec, 'ways to sign in is a section of the account body (the link routes land on #aWaysIn)');
879
+ assert.match(sec, /<ul class="mineRows" id="waysList"><\/ul>/);
880
+ assert.match(sec, /Linking never joins two accounts\./, 'the page says the no-merge rule up front');
881
+ });
882
+
883
+ test('one row per provider: a held way in shows its address and Remove; a missing one links to its sign-in', async () => {
884
+ const dom = boot(WAYS([
885
+ { provider: 'github', email: null, unlink_refused: 'born_with' },
886
+ ]));
887
+ await settle();
888
+ const rows = waysOf(dom);
889
+ assert.equal(rows.length, 2);
890
+ assert.equal(rows[0].children[0].children[0].textContent, 'GitHub');
891
+ assert.equal(rows[0].children[0].children[1].textContent, 'linked', 'no address shown → linked');
892
+ assert.equal(rows[0].children[0].children[2].textContent, 'Your account was made with GitHub, so GitHub stays linked.');
893
+ assert.equal(rows[0].children[1].children.length, 0, 'no Remove for a way in the server keeps');
894
+ assert.equal(rows[1].children[0].children[1].textContent, 'not linked');
895
+ assert.equal(actionOf(rows[1]).textContent, 'Link Google');
896
+ assert.equal(actionOf(rows[1]).href, '/api/bongos/auth/google/start?link=1');
897
+ });
898
+
899
+ test('Link GitHub goes to the GitHub link route; the only way in says why it stays', async () => {
900
+ const dom = boot(WAYS([{ provider: 'google', email: 'ana@example.com', unlink_refused: 'last_way_in' }]));
901
+ await settle();
902
+ const [gh, goo] = waysOf(dom);
903
+ assert.equal(actionOf(gh).href, '/api/bongos/auth/link/github/start');
904
+ assert.equal(goo.children[0].children[1].textContent, 'ana@example.com');
905
+ assert.match(goo.children[0].children[2].textContent, /^Your only way in, so it stays\./);
906
+ });
907
+
908
+ test('Remove sends one DELETE, says so, and re-reads the list', async () => {
909
+ const dom = boot(WAYS([
910
+ { provider: 'github', email: null, unlink_refused: null },
911
+ { provider: 'google', email: 'ana@example.com', unlink_refused: null },
912
+ ]));
913
+ await settle();
914
+ const google = waysOf(dom)[1];
915
+ dom.click(actionOf(google));
916
+ assert.equal(actionOf(google).textContent, 'Removing…');
917
+ await settle();
918
+ assert.deepEqual(dom.calls.filter((c) => c.method === 'DELETE').map((c) => c.path), ['/me/sign-in-methods/google']);
919
+ assert.equal(dom.text('waysMsg'), 'Google is removed. It no longer opens this account.');
920
+ assert.equal(dom.calls.filter((c) => c.path === '/me/sign-in-methods' && c.method === 'GET').length, 2);
921
+ });
922
+
923
+ test('a refused Remove is said in the server’s words and keeps the button', async () => {
924
+ const dom = boot(WAYS([
925
+ { provider: 'github', email: null, unlink_refused: null },
926
+ { provider: 'google', email: null, unlink_refused: null },
927
+ ]));
928
+ await settle();
929
+ const gh = waysOf(dom)[0];
930
+ dom.click(actionOf(gh));
931
+ await settle();
932
+ assert.equal(dom.text('waysMsg'), 'This is your only way in, so it cannot be removed — link another first.');
933
+ assert.equal(actionOf(gh).disabled, false);
934
+ assert.equal(actionOf(gh).textContent, 'Remove');
935
+ });
936
+
937
+ test('a link’s answer is said plainly: linked, already its own account (never merged), cancelled', async () => {
938
+ const say = async (search) => { const d = boot(WAYS([]), { search }); await settle(); return [d.text('waysMsg'), d.els.get('waysMsg').className]; };
939
+ assert.deepEqual(await say('?linked=google'), ['Google is linked. You can now sign in with either.', 'projMsg ok']);
940
+ const [taken, cls] = await say('?link_refused=taken&provider=github');
941
+ assert.match(taken, /^That GitHub account already has its own account here, so it was not linked\. We never join two accounts; nothing changed\.$/);
942
+ assert.equal(cls, 'projMsg err');
943
+ assert.match((await say('?link_refused=cancelled&provider=google'))[0], /^You cancelled linking Google\. Nothing changed\.$/);
944
+ assert.match((await say('?link_refused=whatever&provider=google'))[0], /^Couldn’t link Google\. Try again\.$/);
945
+ });
@@ -65,9 +65,11 @@ function buildRouterWithStubs({ timer, categories, reward = WORKING_REWARD }) {
65
65
  list: gc.listCategories,
66
66
  set: api.projectSettings.set,
67
67
  resolveShipMode: api.projectSettings.resolveShipMode,
68
+ resolveTweak: api.projectSettings.resolveTweakAutopickup,
68
69
  };
69
70
  api.resolveRotDays = async () => timer;
70
71
  api.projectSettings.resolveShipMode = async () => ({ mode: 'auto', source: 'default', chosen: false, invalid: null });
72
+ api.projectSettings.resolveTweakAutopickup = async () => ({ mode: 'off', cap: 200, rank_caps: {}, source: { mode: 'default', cap: 'default', rank_caps: 'default' }, invalid: [] });
71
73
  // reward: WORKING_REWARD (default) | null (port absent) | 'throws' (rewardConfig raises).
72
74
  const rewardPort = reward === 'throws'
73
75
  ? { rewardConfig: () => { throw new Error('reward boom'); } }
@@ -84,6 +86,7 @@ function buildRouterWithStubs({ timer, categories, reward = WORKING_REWARD }) {
84
86
  gc.listCategories = saved.list;
85
87
  api.projectSettings.set = saved.set;
86
88
  api.projectSettings.resolveShipMode = saved.resolveShipMode;
89
+ api.projectSettings.resolveTweakAutopickup = saved.resolveTweak;
87
90
  };
88
91
  // The handler is the LAST layer on the matching route (requireBuilder and the
89
92
  // permission gate sit in front); calling it directly is what keeps this a unit test
@@ -103,22 +106,22 @@ const CATS = [
103
106
  { id: 2, slug: 'retired-thing', label: 'Retired thing', sort_order: 20, active: false },
104
107
  ];
105
108
 
106
- test('one read returns all six fundamentals, each naming where its value came from', async () => {
109
+ test('one read returns all seven fundamentals, each naming where its value came from', async () => {
107
110
  const { handlerFor, restore } = buildRouterWithStubs({ timer: TIMER_OK, categories: CATS });
108
111
  try {
109
112
  const res = fakeRes();
110
113
  await handlerFor('get', '/project-settings')({ query: {} }, res);
111
114
  const rows = res.payload.rows;
112
- assert.equal(rows.length, 6, 'the page draws six rows, so the read returns six');
115
+ assert.equal(rows.length, 7, 'the page draws seven rows, so the read returns seven');
113
116
  assert.deepEqual(rows.map((r) => r.key),
114
- ['ship_mode', 'rot_timer', 'work_categories', 'credit_calculation', 'equity_calculation', 'upmarket_percent']);
117
+ ['ship_mode', 'tweak_round_cap', 'rot_timer', 'work_categories', 'credit_calculation', 'equity_calculation', 'upmarket_percent']);
115
118
  // Provenance is the point of the page: every row says where its value came from.
116
119
  for (const r of rows) {
117
120
  assert.ok(r.reads && r.reads.file, `${r.key} names no file`);
118
121
  assert.ok(r.sentence && r.sentence.length > 20, `${r.key} has no readable sentence`);
119
122
  }
120
- // Three knobs: the criterion's two, plus ship mode (task 1004628).
121
- assert.deepEqual(rows.filter((r) => r.editable).map((r) => r.key), ['ship_mode', 'rot_timer', 'work_categories']);
123
+ // Four knobs: the criterion's two, ship mode (task 1004628) and the monthly tweak-round cap (task 1005003).
124
+ assert.deepEqual(rows.filter((r) => r.editable).map((r) => r.key), ['ship_mode', 'tweak_round_cap', 'rot_timer', 'work_categories']);
122
125
  // A no-store receipt: a cached settings read is wrong the moment a knob turns.
123
126
  assert.equal(res.headers['Cache-Control'], 'no-store');
124
127
  } finally { restore(); }