@bongos/core 1.24.3 → 1.24.5

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 (76) hide show
  1. package/.bongos-core.json +142 -67
  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 -0
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/config/branding.neutral.json +0 -1
  8. package/docs/adr/0396-google-sign-in-a-stand-in-login-and-a-browser-approved-cli-sign-in.md +11 -0
  9. package/docs/api/openapi.json +121 -4
  10. package/docs/api-reference.md +8 -1
  11. package/docs/branding-contract.md +1 -1
  12. package/docs/copy-inventory.md +11 -11
  13. package/docs/copy-registry.json +12 -12
  14. package/docs/file-map.md +3 -2
  15. package/docs/module-api-changelog.md +2 -2
  16. package/docs/page-readings.json +1 -1
  17. package/modules/copy-desk/auto-close.js +46 -8
  18. package/modules/copy-desk/page-status.js +11 -2
  19. package/modules/copy-desk/tests/copy_no_cms.mjs +8 -1
  20. package/modules/copy-desk/tests/copy_page_auto_close.mjs +64 -2
  21. package/modules/copy-desk/tests/copy_page_status.mjs +5 -1
  22. package/modules/discord/ship-broadcast.js +7 -0
  23. package/modules/economy/credit-wording.js +93 -0
  24. package/modules/economy/credits.js +26 -12
  25. package/modules/economy/reward-policy.js +49 -3
  26. package/modules/economy/reward.js +5 -0
  27. package/modules/government/routes/government.js +7 -0
  28. package/modules/hall-ui/public/tweak-editor.js +2 -1
  29. package/modules/ideas/routes/inbox.js +6 -0
  30. package/modules/lifecycle/db-claims.js +2 -2
  31. package/modules/lifecycle/db-ship.js +2 -2
  32. package/modules/lifecycle/routes/project-settings.js +6 -4
  33. package/modules/market-watch/ask.js +425 -0
  34. package/modules/market-watch/migrations/market_watch_003_ask_pool.sql +21 -0
  35. package/modules/market-watch/module.json +10 -3
  36. package/modules/market-watch/pollers/weekly-ask.js +89 -0
  37. package/modules/market-watch/public/hall-widget.js +271 -0
  38. package/modules/market-watch/public/weekly-ask.css +39 -0
  39. package/modules/market-watch/routes/ask.js +95 -0
  40. package/modules/onboarding/routes/access-requests.js +38 -10
  41. package/modules/platform-identity/account-logins.js +13 -0
  42. package/modules/platform-identity/routes/my-projects.js +10 -7
  43. package/modules/platform-identity/routes/sso.js +11 -6
  44. package/package-lock.json +2 -2
  45. package/package.json +1 -1
  46. package/release-notes.json +37 -1
  47. package/scripts/bongos/backfill-subagent-rewards.js +7 -7
  48. package/scripts/bongos/codemod-rename-scripts-gds.js +1 -3
  49. package/scripts/bongos/dev-box-guard.js +0 -1
  50. package/scripts/bongos/publish-manifest.js +1 -1
  51. package/scripts/bongos/run-unit-tests.js +4 -0
  52. package/scripts/hall-preview/server.js +13 -0
  53. package/src/bongos/agent-standing-rules.js +44 -0
  54. package/src/bongos/auth-idp.js +17 -5
  55. package/src/bongos/db-kernel.js +14 -1
  56. package/src/bongos/module-scope-map.js +4 -2
  57. package/src/bongos/routes/me.js +4 -1
  58. package/src/module-api.js +1 -1
  59. package/tests/account_logins.mjs +6 -4
  60. package/tests/agent_standing_rules.mjs +142 -0
  61. package/tests/backfill_subagent_rewards.mjs +7 -7
  62. package/tests/claims_batch_routes.mjs +36 -13
  63. package/tests/copy_stuck_page_tweaks.mjs +3 -1
  64. package/tests/credit_grant.mjs +7 -7
  65. package/tests/credit_wording.mjs +154 -0
  66. package/tests/google_halls.mjs +211 -0
  67. package/tests/government_laws_session.mjs +2 -1
  68. package/tests/hall_tweak_editor.mjs +11 -0
  69. package/tests/host_topology_skips.mjs +2 -0
  70. package/tests/market_watch_ask.mjs +369 -0
  71. package/tests/market_watch_ask_db.mjs +122 -0
  72. package/tests/market_watch_data_db.mjs +4 -0
  73. package/tests/market_watch_module.mjs +9 -5
  74. package/tests/project_settings_page.mjs +1 -1
  75. package/tests/reward_cost_scale.mjs +200 -0
  76. package/tests/run_node_reroute.mjs +136 -0
@@ -47,7 +47,8 @@ function answer(sql, params, via) {
47
47
  // The release's locked read (FOR UPDATE) — distinct from the newcomer gate's
48
48
  // active-claims read, which joins the same tables.
49
49
  if (/FROM claims c JOIN tasks t ON t\.id = c\.task_id/.test(sql) && /FOR UPDATE/.test(sql)) {
50
- const wanted = new Set(params[0].map(String));
50
+ // An array for the batch release; a single id for resolveClaim.
51
+ const wanted = new Set([].concat(params[0]).map(String));
51
52
  return { rows: claimFixture.filter((r) => wanted.has(String(r.id))) };
52
53
  }
53
54
  if (/FROM claims c/.test(sql) && /released_at IS NULL/.test(sql)) {
@@ -215,19 +216,41 @@ test('own claim with matching id types (both strings, as production sends them)
215
216
  assert.equal(claimReleases().length, 1);
216
217
  });
217
218
 
218
- // KNOWN BUG (task 1005004, filed by task 1004983): releaseClaimsBatch compares
219
- // claim.builder_id !== builderId strictly, so a STRING row id (node-pg bigint)
220
- // against a NUMBER caller id refuses the caller's OWN claim as NOT_YOUR_CLAIM.
221
- // Production is unaffected today only because req.builder.id is also a string.
222
- // It fails closed (never releases a stranger's claim), so this pins CURRENT
223
- // behaviour; when the compare is fixed, flip this to expect a release.
224
- test('KNOWN BUG: own claim as string builder_id vs a NUMBER caller id is refused NOT_YOUR_CLAIM (fails closed)', async () => {
225
- caller = { id: 42, rank: 'thetes' };
226
- claimFixture = [row({ builder_id: '42' })];
227
- const r = await post(RELEASE, { claim_ids: [101] });
228
- assert.deepEqual(r.json.released, []);
229
- assert.deepEqual(r.json.failures, [{ claim_id: 101, reason: 'NOT_YOUR_CLAIM' }]);
219
+ // task 1005004 (found by task 1004983): the ownership compare was a strict
220
+ // claim.builder_id !== builderId, so a STRING row id (node-pg bigint) against a
221
+ // NUMBER caller id refused the caller's OWN claim as NOT_YOUR_CLAIM. It now
222
+ // compares one spelling of each side (db-shared idKey). The stranger matrix
223
+ // above still refuses in every combination.
224
+ test('own claim is released whatever the id types — string or number on either side', async () => {
225
+ for (const [rowId, callerId] of [['42', 42], [42, '42'], [42, 42], ['42', '42']]) {
226
+ calls = [];
227
+ caller = { id: callerId, rank: 'thetes' };
228
+ claimFixture = [row({ builder_id: rowId })];
229
+ const r = await post(RELEASE, { claim_ids: [101] });
230
+ const label = `row builder_id ${JSON.stringify(rowId)} vs caller ${JSON.stringify(callerId)}`;
231
+ assert.deepEqual(r.json.released.map((x) => x.claim_id), [101], label);
232
+ assert.deepEqual(r.json.failures, [], label);
233
+ assert.equal(claimReleases().length, 1, label);
234
+ }
235
+ });
236
+
237
+ // The same compare in resolveClaim (db-ship.js), the single-claim ship/release
238
+ // path: a number caller id must reach past the ownership check on its own
239
+ // claim, and a stranger's claim is still NOT_YOUR_CLAIM before any write.
240
+ test('resolveClaim: own claim with a NUMBER caller id passes the ownership check; a stranger\'s is refused, nothing written', async () => {
241
+ const resolve = (builderId) => db.resolveClaim({
242
+ claimId: 101, builderId, outcome: 'abandoned', resolverSessionId: 'sess-A', force: true,
243
+ }).then(() => null, (e) => e);
244
+
245
+ claimFixture = [row({ builder_id: '7', creator_session_id: 'sess-Z' })];
246
+ const stranger = await resolve(42);
247
+ assert.equal(stranger && stranger.code, 'NOT_YOUR_CLAIM');
230
248
  assert.deepEqual(writes(), []);
249
+
250
+ calls = [];
251
+ claimFixture = [row({ builder_id: '42' })];
252
+ const own = await resolve(42);
253
+ assert.notEqual(own && own.code, 'NOT_YOUR_CLAIM', `own claim refused: ${own && (own.code || own.message)}`);
231
254
  });
232
255
 
233
256
  // ── the session check, and force ─────────────────────────────────────────────
@@ -63,7 +63,9 @@ await test('every state has an owner and a deadline: overdue only past it, told
63
63
  ['applying', () => ({ status: 'active', holder_id: '99', holder_worktree: true }), 'appliers', 1],
64
64
  ['waiting_for_artist', () => ({ status: 'completed', grade_passed: true }), 'artist', 3],
65
65
  ['landing', () => ({ status: 'confirmed' }), 'appliers', 1],
66
- ['unknown', () => ({ status: 'ready' }), 'appliers', 0],
66
+ ['unknown', () => ({ status: 'active', holder_id: null }), 'appliers', 0],
67
+ // task 1004972: a blank round waits on its artist and is due when it closes itself (1 day).
68
+ ['blank', () => ({ status: 'ready' }), 'artist', 1],
67
69
  ];
68
70
  for (const [state, shape, owner, days] of table) {
69
71
  const id = 'landing:projects';
@@ -490,20 +490,20 @@ await test('REWARD_MARGIN_PCT exported as 20', () => {
490
490
  // per-task ESTIMATE payout on. The cost-plus session reward is untouched.
491
491
  // =========================================================================
492
492
 
493
- await test('rewardPolicy: default (no mode) → pay both streams; margin/peg resolve', () => {
493
+ await test('rewardPolicy: default (no mode) → pay both streams; margin/scale resolve', () => {
494
494
  const p = rewardPolicy({});
495
495
  assert.equal(p.mode, REWARD_MODE_DEFAULT);
496
496
  assert.equal(p.perTaskCredit, true, 'a default instance pays the per-task estimate');
497
497
  assert.equal(p.marginPct, 20);
498
- assert.equal(p.usdPeg, 1);
498
+ assert.equal(p.costScale, 1);
499
499
  });
500
500
 
501
501
  await test('rewardPolicy: mode "cost-plus-only" → per-task estimate payout OFF, cost-plus untouched', () => {
502
- const p = rewardPolicy({ mode: 'cost-plus-only', marginPct: 20, usdPeg: 1 });
502
+ const p = rewardPolicy({ mode: 'cost-plus-only', marginPct: 20, costScale: 1 });
503
503
  assert.equal(p.mode, REWARD_MODE_COST_PLUS_ONLY);
504
504
  assert.equal(p.perTaskCredit, false, 'cost-plus-only suppresses the per-task estimate payout');
505
505
  assert.equal(p.marginPct, 20, 'the cost-plus margin is unaffected — that stream is still paid');
506
- assert.equal(p.usdPeg, 1);
506
+ assert.equal(p.costScale, 1);
507
507
  });
508
508
 
509
509
  await test('rewardPolicy: unknown/garbage mode → default (a typo never silently zeroes rewards)', () => {
@@ -546,11 +546,11 @@ await test('rewardPolicy: ideatorCredit is opt-out-only — only an explicit fal
546
546
  }
547
547
  });
548
548
 
549
- await test('rewardPolicy: cost-plus-only still honors a custom margin + peg', () => {
550
- const p = rewardPolicy({ mode: 'cost-plus-only', marginPct: 35, usdPeg: 2 });
549
+ await test('rewardPolicy: cost-plus-only still honors a custom margin + scale', () => {
550
+ const p = rewardPolicy({ mode: 'cost-plus-only', marginPct: 35, costScale: 2 });
551
551
  assert.equal(p.perTaskCredit, false);
552
552
  assert.equal(p.marginPct, 35);
553
- assert.equal(p.usdPeg, 2);
553
+ assert.equal(p.costScale, 2);
554
554
  });
555
555
 
556
556
  await test('insertMultipliedConfirmCredit: cost-plus-only mode writes NO credit_log row', async () => {
@@ -0,0 +1,154 @@
1
+ // tests/credit_wording.mjs — no investment wording next to credits or models
2
+ // (task 1004969; ADR 0385 decision 9).
3
+ //
4
+ // Credits are a contribution meter, not money and not a stake. This fails when
5
+ // the hall's words, the role packs, the onboarding docs or the branding pack
6
+ // describe credits or models as an investment, a return, appreciation, passive
7
+ // income, equity or ownership — and proves the server's publish paths FLAG such
8
+ // text in the log while publishing it unchanged.
9
+ //
10
+ // Run: node tests/credit_wording.mjs
11
+
12
+ import { test } from 'node:test';
13
+ import { strict as assert } from 'node:assert';
14
+ import { createRequire } from 'node:module';
15
+ import { fileURLToPath } from 'node:url';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const ROOT = fileURLToPath(new URL('..', import.meta.url));
21
+ const wording = require('../modules/economy/credit-wording.js');
22
+ const reward = require('../modules/economy/reward.js');
23
+ const shipBroadcast = require('../modules/discord/ship-broadcast.js');
24
+
25
+ const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
26
+ function walk(rel) {
27
+ const out = [];
28
+ for (const e of fs.readdirSync(path.join(ROOT, rel), { withFileTypes: true })) {
29
+ const p = `${rel}/${e.name}`;
30
+ if (e.isDirectory()) out.push(...walk(p));
31
+ else if (/\.(md|mmd|json|txt|html)$/.test(e.name)) out.push(p);
32
+ }
33
+ return out;
34
+ }
35
+
36
+ // The corpus the task names: hall copy (the generated copy registry — every
37
+ // string the hall, status page and landing render), the role packs, the
38
+ // onboarding docs and the branding pack.
39
+ function corpus() {
40
+ const docs = [
41
+ 'config/branding.neutral.json',
42
+ ...fs.readdirSync(path.join(ROOT, 'docs/packs')).filter((f) => f.endsWith('.md')).map((f) => `docs/packs/${f}`),
43
+ ...walk('docs/onboarding'),
44
+ ].map((rel) => ({ where: rel, text: read(rel) }));
45
+ const registry = JSON.parse(read('docs/copy-registry.json'));
46
+ const copy = registry.entries.map((e) => ({ where: `hall copy ${e.file}:${e.line}`, text: e.text }));
47
+ return { docs, copy };
48
+ }
49
+
50
+ test('the corpus is really read (a scan that finds nothing to scan is not a pass)', () => {
51
+ const { docs, copy } = corpus();
52
+ assert.ok(docs.length >= 8, `expected the packs, onboarding docs and branding pack, read ${docs.length}`);
53
+ assert.ok(copy.length > 1000, `expected the hall copy registry, read ${copy.length} strings`);
54
+ assert.ok(docs.some((d) => /credit/i.test(d.text)) && copy.some((c) => /credit/i.test(c.text)), 'the corpus talks about credits at all');
55
+ });
56
+
57
+ test('no investment wording next to credits or models in hall copy, packs, onboarding or branding', () => {
58
+ const { docs, copy } = corpus();
59
+ const hits = [];
60
+ for (const { where, text } of [...docs, ...copy]) {
61
+ for (const h of wording.findInvestmentWording(text)) hits.push(`${where}: "${h.phrase}" near "${h.anchor}" — ${h.sentence.slice(0, 160)}`);
62
+ }
63
+ assert.deepEqual(hits, [], `credits and models are not an investment (ADR 0385 decision 9):\n ${hits.join('\n ')}`);
64
+ });
65
+
66
+ test('a planted phrase is caught — the probe that shows the scan can go red', () => {
67
+ const planted = [
68
+ ['Credits are a smart investment in the project.', 'investment'],
69
+ ['Hold your credits and watch for a return on them.', 'return'],
70
+ ['Your credits earn a return every month.', 'return'],
71
+ ['Expect appreciation of credits as the project grows.', 'appreciation'],
72
+ ['Credits will appreciate in value over time.', 'appreciation'],
73
+ ['Turn your credits into passive income.', 'passive income'],
74
+ ['Credits give you equity in the project.', 'equity'],
75
+ ['Owning the model means ownership of what it builds.', 'ownership'],
76
+ ['Investing in models pays off.', 'investment'],
77
+ ];
78
+ for (const [text, term] of planted) {
79
+ const hits = wording.findInvestmentWording(text);
80
+ assert.deepEqual(hits.map((h) => h.term), [term], `"${text}" should flag ${term}`);
81
+ }
82
+ // The same corpus with one planted sentence goes red.
83
+ const { docs } = corpus();
84
+ const doctored = `${docs[0].text}\n\nCredits are a smart investment.`;
85
+ assert.equal(wording.findInvestmentWording(doctored).length, 1);
86
+ });
87
+
88
+ test('the wording we want is left alone: denials, the verb "return", and investment words with no credit nearby', () => {
89
+ const fine = [
90
+ 'Credits are not an investment.',
91
+ 'They are not money, not redeemable and not equity.',
92
+ 'Credits measure contribution, never money, never equity.',
93
+ 'When a claim is released the task returns to ready, and no credits are awarded.',
94
+ 'Return to the hall to see your credits.',
95
+ 'Equity, if a project offers any, is granted by the project separately.',
96
+ 'We appreciate every credit you earn.',
97
+ ];
98
+ for (const text of fine) assert.deepEqual(wording.findInvestmentWording(text), [], text);
99
+ // Too far apart to be talking about each other.
100
+ const far = 'Credits are what you earn for shipped work. '.repeat(1) + 'Some unrelated paragraph about how a company may raise money, take outside investment and sell equity.';
101
+ assert.deepEqual(wording.findInvestmentWording(far), []);
102
+ });
103
+
104
+ test('flagInvestmentWording logs one line per hit, returns the hits, and never throws', () => {
105
+ const lines = [];
106
+ const hits = wording.flagInvestmentWording('Credits are an investment. Credits are not equity.', { where: 'ship summary', ref: 'task 7', log: (l) => lines.push(l) });
107
+ assert.equal(hits.length, 1);
108
+ assert.equal(lines.length, 1);
109
+ assert.match(lines[0], /^\[credit-wording\] flagged ship summary task 7: "investment" near "credits"/);
110
+ assert.match(lines[0], /published unchanged/);
111
+ assert.deepEqual(wording.flagInvestmentWording(null, { log: () => { throw new Error('x'); } }), []);
112
+ assert.deepEqual(wording.flagInvestmentWording('Credits are an investment.', { log: () => { throw new Error('log down'); } }), []);
113
+ });
114
+
115
+ test('the reward port carries both, so other modules reach it without importing economy', () => {
116
+ assert.equal(typeof reward.findInvestmentWording, 'function');
117
+ assert.equal(typeof reward.flagInvestmentWording, 'function');
118
+ assert.equal(reward.findInvestmentWording('Credits are an investment.').length, 1);
119
+ });
120
+
121
+ test('a flagged ship summary is RECORDED and posted unchanged — never blocked, never rewritten', async () => {
122
+ const summary = 'Builders can now treat their credits as an investment in the project.';
123
+ const posts = [];
124
+ const logs = [];
125
+ const r = await shipBroadcast.postShipBroadcast(
126
+ { id: 42, value_summary: summary, security_sensitive: false, kind: 'feature' },
127
+ { postWebhook: async (channel, line) => { posts.push([channel, line]); }, log: (l) => logs.push(l), reward },
128
+ );
129
+ assert.deepEqual(r.posted, ['ship_feed', 'ship_news']);
130
+ assert.equal(r.wording_flags, 1);
131
+ assert.deepEqual(posts, [['ship_feed', `#42 shipped — ${summary}`], ['ship_news', summary]], 'the text goes out exactly as written');
132
+ assert.equal(logs.filter((l) => l.startsWith('[credit-wording] flagged ship summary task 42')).length, 1);
133
+
134
+ // A clean summary logs nothing; no economy module means no check, still posted.
135
+ const quiet = [];
136
+ const clean = await shipBroadcast.postShipBroadcast({ id: 43, value_summary: 'The Treasury counts credits.', security_sensitive: false, kind: 'bug' }, { postWebhook: async () => {}, log: (l) => quiet.push(l), reward });
137
+ assert.equal(clean.wording_flags, 0);
138
+ assert.deepEqual(quiet, []);
139
+ const none = await shipBroadcast.postShipBroadcast({ id: 44, value_summary: summary, security_sensitive: false, kind: 'bug' }, { postWebhook: async () => {}, log: () => {}, reward: null });
140
+ assert.deepEqual(none.posted, ['ship_feed', 'ship_news']);
141
+ assert.equal(none.wording_flags, undefined);
142
+ });
143
+
144
+ test('the idea and board publish paths flag through the port and never return early on it', () => {
145
+ const inbox = read('modules/ideas/routes/inbox.js');
146
+ const gov = read('modules/government/routes/government.js');
147
+ for (const [src, where] of [[inbox, "where: 'idea'"], [gov, "where: 'board item'"]]) {
148
+ const at = src.indexOf(where);
149
+ assert.ok(at > 0, `${where} is wired`);
150
+ const block = src.slice(src.lastIndexOf("resolveOptional('reward')", at), at + 80);
151
+ assert.match(block, /flagInvestmentWording\(/);
152
+ assert.doesNotMatch(block, /return\s+res\.|res\.fail|throw /, 'a flag never refuses or rewrites the text');
153
+ }
154
+ });
@@ -0,0 +1,211 @@
1
+ // tests/google_halls.mjs — a project's hall takes a person who joined with Google
2
+ // (task 1004994, ADR 0393/0396).
3
+ //
4
+ // WHY. Someone with no GitHub account must be able to apply to a project, be
5
+ // admitted, and sign in to its hall like anyone else. What must hold:
6
+ // - the hub hands a Google-only person (a negative person key) only to a hall
7
+ // whose authorize link says it can take one; an older hall never gets one;
8
+ // - the join relay carries them by their stand-in `~handle` with a vouch for
9
+ // their key, and a hall too old to take a stand-in is said as such;
10
+ // - the hall files a stand-in application only with that vouch, and matches
11
+ // their approval on the vouched key, never on the handle (a handle can be
12
+ // renamed and later taken by someone else);
13
+ // - a stand-in key and a stand-in login always travel as a pair;
14
+ // - people read the handle, not `~handle`.
15
+ // No server beyond an in-process express app, no database, no network.
16
+ //
17
+ // Run: node tests/google_halls.mjs
18
+
19
+ import { strict as assert } from 'node:assert';
20
+ import http from 'node:http';
21
+ import { createRequire } from 'node:module';
22
+ // ESM imports so knip credits the exports (a createRequire import reads as
23
+ // unused). Every stub below is read at call time, so hoisting is harmless.
24
+ import { idpAuthorizeUrl, applicationVouchIdentity, PERSON_KEYS_PARAM as CORE_PARAM } from '../src/bongos/auth-idp.js';
25
+ import { hasInvitedAccessRequest } from '../src/bongos/db-kernel.js';
26
+ import { PERSON_KEYS_PARAM as HUB_PARAM, hallTakesPersonKey } from '../modules/platform-identity/account-logins.js';
27
+ import { joinRelayOutcome } from '../modules/platform-identity/routes/my-projects.js';
28
+ import buildAccessRequestsRouter, { shownLogin, accessRequestDocketMatter } from '../modules/onboarding/routes/access-requests.js';
29
+
30
+ const require = createRequire(import.meta.url);
31
+ const express = require('express');
32
+
33
+ let passed = 0;
34
+ let failed = 0;
35
+ async function t(name, fn) {
36
+ try { await fn(); passed++; console.log(` ok ${name}`); } catch (err) { failed++; console.log(` FAIL ${name}\n ${err && err.stack}`); }
37
+ }
38
+
39
+ // ── stubs, installed before anything destructures them ──
40
+ const { pool } = require('../src/bongos/pool.js');
41
+ let queries = [];
42
+ let answer = () => ({ rows: [] });
43
+ pool.query = async (sql, params) => { queries.push({ sql, params }); return answer(sql, params); };
44
+
45
+ const auth = require('../src/bongos/auth.js');
46
+ const projectDoor = require('../src/bongos/project-door.js');
47
+ projectDoor.effectiveJoinDoor = () => 'apply';
48
+
49
+ const hubKeys = require('../modules/platform-identity/hub-keys.js');
50
+ const logins = { PERSON_KEYS_PARAM: HUB_PARAM, hallTakesPersonKey };
51
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
52
+
53
+ const { privateKeyB64, publicKeyB64 } = hubKeys.generateHubKeypair();
54
+ const crypto = require('node:crypto');
55
+ const signingKey = crypto.createPrivateKey({ key: Buffer.from(privateKeyB64, 'base64'), format: 'der', type: 'pkcs8' });
56
+ const publicKey = crypto.createPublicKey({ key: Buffer.from(publicKeyB64, 'base64'), format: 'der', type: 'spki' });
57
+ const vouch = (githubId, githubLogin) => hubKeys.mintHubApplicationVouch(
58
+ { clientId: 'client-x', githubId, githubLogin, handle: 'ana' }, { signingKey },
59
+ );
60
+ const vouchOpts = (githubLogin) => ({ githubLogin, publicKey, clientId: 'client-x' });
61
+
62
+ console.log('the hub sends a Google-only person only to a hall that says it can take one:');
63
+
64
+ await t('a current hall’s authorize link says so, under the name the hub reads', () => {
65
+ const u = new URL(idpAuthorizeUrl('https://hub.example', 'client-x', 'st', 'https://p.example/cb'));
66
+ assert.equal(CORE_PARAM, logins.PERSON_KEYS_PARAM, 'core and hub must spell the marker the same');
67
+ assert.equal(u.searchParams.get(CORE_PARAM), '1');
68
+ });
69
+
70
+ await t('a GitHub person goes to any hall; a minted key only to one that said person_keys=1', () => {
71
+ assert.equal(logins.hallTakesPersonKey('42', {}), true, 'GitHub people are unchanged on older halls');
72
+ assert.equal(logins.hallTakesPersonKey('-5', {}), false, 'an older hall never receives a minted key');
73
+ assert.equal(logins.hallTakesPersonKey('-5', { person_keys: '0' }), false);
74
+ assert.equal(logins.hallTakesPersonKey('-5', { person_keys: ['1', '1'] }), false, 'a repeated param is not a yes');
75
+ assert.equal(logins.hallTakesPersonKey('-5', { person_keys: '1' }), true);
76
+ });
77
+
78
+ await t('/sso/authorize asks hallTakesPersonKey and the old blanket refusal is gone', () => {
79
+ const src = require('node:fs').readFileSync(new URL('../modules/platform-identity/routes/sso.js', import.meta.url), 'utf8');
80
+ assert.match(src, /if \(!hallTakesPersonKey\(session\.github_id, req\.query\)\)/);
81
+ assert.doesNotMatch(src, /Google sign-in yet\. That is coming soon/);
82
+ const join = require('node:fs').readFileSync(new URL('../modules/platform-identity/routes/my-projects.js', import.meta.url), 'utf8');
83
+ assert.doesNotMatch(join, /google_join_not_yet/, 'the join relay no longer refuses a Google-only person');
84
+ });
85
+
86
+ console.log('\nthe join relay:');
87
+
88
+ await t('an older hall refusing a stand-in is "needs an update", not "could not be reached"', () => {
89
+ const o = joinRelayOutcome(400, { error: { code: 'bad_github_login', message: 'x' } }, { standIn: true });
90
+ assert.equal(o.status, 'needs_update');
91
+ assert.match(o.message, /older version/);
92
+ assert.equal(joinRelayOutcome(400, { error: { code: 'bad_github_login' } }), null,
93
+ 'a GitHub login refused is still not relayed: that is not a version gap');
94
+ });
95
+
96
+ console.log('\nthe vouch — a stand-in key and a stand-in login travel together:');
97
+
98
+ await t('a minted key vouched with its stand-in login yields the key', () => {
99
+ assert.deepEqual(applicationVouchIdentity(vouch(-5, '~ana'), vouchOpts('~ana')), { githubId: '-5', handle: 'ana' });
100
+ });
101
+
102
+ await t('a minted key with a GitHub login, or a GitHub id with a stand-in, is no vouch', () => {
103
+ assert.equal(applicationVouchIdentity(vouch(-5, 'octo'), vouchOpts('octo')), null);
104
+ assert.equal(applicationVouchIdentity(vouch(42, '~ana'), vouchOpts('~ana')), null);
105
+ });
106
+
107
+ console.log('\nadmission on the hall:');
108
+
109
+ await t('a Google-only person’s approval is matched on the vouched key, never the handle', async () => {
110
+ queries = [];
111
+ answer = () => ({ rows: [{ '?column?': 1 }] });
112
+ assert.equal(await hasInvitedAccessRequest('~ana', '-5'), true);
113
+ assert.equal(queries.length, 1);
114
+ assert.match(queries[0].sql, /applicant_github_id = \$1/);
115
+ assert.doesNotMatch(queries[0].sql, /github_login/, 'whoever is ~ana next must not inherit the approval');
116
+ assert.deepEqual(queries[0].params, ['-5']);
117
+ });
118
+
119
+ await t('a GitHub person is still matched by login', async () => {
120
+ queries = [];
121
+ await hasInvitedAccessRequest('octo', '42');
122
+ await hasInvitedAccessRequest('octo');
123
+ for (const q of queries) {
124
+ assert.match(q.sql, /lower\(github_login\) = lower\(\$1\)/);
125
+ assert.deepEqual(q.params, ['octo']);
126
+ }
127
+ });
128
+
129
+ await t('both hub sign-ins on the hall pass the key to the admission read', () => {
130
+ const src = require('node:fs').readFileSync(new URL('../src/bongos/auth-idp.js', import.meta.url), 'utf8');
131
+ assert.equal((src.match(/hasInvitedAccessRequest\(ghLogin, ghId\)/g) || []).length, 2);
132
+ });
133
+
134
+ // The hall's public POST /access-requests, in-process.
135
+ const app = express();
136
+ app.use(express.json());
137
+ app.use(attachFail);
138
+ app.use(buildAccessRequestsRouter());
139
+ const server = http.createServer(app);
140
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
141
+ const base = `http://127.0.0.1:${server.address().port}`;
142
+ const post = (body, headers = {}) => fetch(`${base}/access-requests`, {
143
+ method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body),
144
+ });
145
+
146
+ let vouchFor = null;
147
+ auth.readApplicationVouch = (token, { githubLogin }) => (token === 'good' ? vouchFor(githubLogin) : null);
148
+
149
+ await t('a stand-in application with the hub’s vouch is filed, keyed on the person', async () => {
150
+ queries = [];
151
+ answer = (sql) => (sql.startsWith('INSERT') ? { rows: [{ id: 9, github_login: '~ana', status: 'pending' }] } : { rows: [] });
152
+ vouchFor = (login) => (login === '~ana' ? { githubId: '-5', handle: 'ana' } : null);
153
+ const res = await post({ github_login: '~ana', display_name: 'Ana' }, { 'X-Bongos-Platform-Assertion': 'good' });
154
+ assert.equal(res.status, 201);
155
+ const member = queries.find((q) => /FROM builders/.test(q.sql));
156
+ assert.match(member.sql, /github_id = \$1/, 'the already-a-member check is on the key');
157
+ assert.deepEqual(member.params, ['-5']);
158
+ const insert = queries.find((q) => q.sql.startsWith('INSERT'));
159
+ assert.equal(insert.params[0], '~ana');
160
+ assert.equal(insert.params[3], '-5', 'applicant_github_id is the key the approval will match');
161
+ });
162
+
163
+ await t('a stale approval under a handle someone else now holds does not block the new holder', async () => {
164
+ // An approved row for `~ana` belongs to person -4; person -5 holds the handle now.
165
+ const approved = [{ github_login: '~ana', applicant_github_id: '-4' }];
166
+ queries = [];
167
+ answer = (sql, params) => {
168
+ if (sql.startsWith('INSERT')) return { rows: [{ id: 11, github_login: '~ana', status: 'pending' }] };
169
+ if (/FROM access_requests/.test(sql) && /status = 'invited'/.test(sql)) {
170
+ const hit = /applicant_github_id = \$1/.test(sql)
171
+ ? approved.some((r) => r.applicant_github_id === params[0])
172
+ : approved.some((r) => r.github_login.toLowerCase() === String(params[0]).toLowerCase());
173
+ return { rows: hit ? [{ '?column?': 1 }] : [] };
174
+ }
175
+ return { rows: [] };
176
+ };
177
+ vouchFor = () => ({ githubId: '-5', handle: 'ana' });
178
+ const res = await post({ github_login: '~ana' }, { 'X-Bongos-Platform-Assertion': 'good' });
179
+ assert.equal(res.status, 201, 'the new holder’s application is filed, not answered "already approved"');
180
+ vouchFor = () => ({ githubId: '-4', handle: 'ana' });
181
+ const again = await post({ github_login: '~ana' }, { 'X-Bongos-Platform-Assertion': 'good' });
182
+ assert.equal(again.status, 409, 'the approved person themselves is told they are already approved');
183
+ });
184
+
185
+ await t('a stand-in typed by anyone, with no vouch, is refused and nothing is written', async () => {
186
+ queries = [];
187
+ const res = await post({ github_login: '~ana' });
188
+ assert.equal(res.status, 400);
189
+ assert.equal(queries.length, 0);
190
+ });
191
+
192
+ await t('a GitHub login still files with or without a vouch', async () => {
193
+ answer = (sql) => (sql.startsWith('INSERT') ? { rows: [{ id: 10, github_login: 'octo', status: 'pending' }] } : { rows: [] });
194
+ const res = await post({ github_login: 'octo' });
195
+ assert.equal(res.status, 201);
196
+ });
197
+
198
+ server.close();
199
+
200
+ console.log('\nwhat people read:');
201
+
202
+ await t('the handle is shown without its ~; a GitHub login is unchanged', () => {
203
+ assert.equal(shownLogin('~ana'), 'ana');
204
+ assert.equal(shownLogin('octo'), 'octo');
205
+ const m = accessRequestDocketMatter({ id: 1, github_login: '~ana', display_name: null, created_at: new Date() });
206
+ assert.equal(m.title, 'Admit ana as a builder');
207
+ assert.equal(m.short, 'admit ana');
208
+ });
209
+
210
+ console.log(`\ngoogle_halls: ${passed} passed, ${failed} failed`);
211
+ if (failed > 0) process.exit(1);
@@ -91,7 +91,8 @@ test('GET /me carries the block and resolves the settings slices through the loc
91
91
  assert.match(src, /government\.lawsForSession\(\)/, '/me reads the laws through the government port');
92
92
  assert.match(src, /settings\.describeWithLaws\(behaviorPrefs, \(sessionLaws && sessionLaws\.laws\) \|\| \[\]/, 'the slices are resolved with the laws applied');
93
93
  for (const slice of ['wandering', 'interaction', 'model_allocation']) {
94
- assert.match(src, new RegExp(`${slice}: lawed \\? lawed\\.${slice} :`), `${slice} reads the locked slice`);
94
+ // interaction is wrapped by withStandingRules (task 1005015); the locked slice is still what it wraps.
95
+ assert.match(src, new RegExp(`${slice}: (?:withStandingRules\\()?lawed \\? lawed\\.${slice} :`), `${slice} reads the locked slice`);
95
96
  }
96
97
  assert.match(src, /laws: sessionLaws \? \{/, '/me answers a laws block');
97
98
  assert.equal(typeof settingsPort.describeWithLaws, 'function');
@@ -328,6 +328,17 @@ try {
328
328
  assert.equal(b.el('tw-notice').hidden, true);
329
329
  });
330
330
 
331
+ // task 1004972: a blank round (opened, nothing written) used to read as
332
+ // 'unknown' and the editor told the viewer it was "submitted and on its way".
333
+ await test('a blank round nobody holds offers the take button, never "submitted"', async () => {
334
+ const read = editorRead({ held: false, state: 'ready', draftLines: null });
335
+ assert.equal(read.round.state, 'blank');
336
+ const b = bootEditor({ answers: { 'GET /copy-desk/pages/landing:index/draft': ok(read) } });
337
+ await b.settle();
338
+ assert.ok(b.el('tw-claim'), 'the notice carries the take button');
339
+ assert.doesNotMatch(b.el('tw-notice').innerHTML, /submitted/);
340
+ });
341
+
331
342
  await test('someone else writing it: read-only, and named', async () => {
332
343
  const b = bootEditor({ answers: { 'GET /copy-desk/pages/landing:index/draft': ok(editorRead({ holder: '8' })) } });
333
344
  await b.settle();
@@ -30,6 +30,7 @@ const TESTS_DIR = path.dirname(fileURLToPath(import.meta.url));
30
30
  // Every test file permitted to skip itself wholesale, with the topology it needs.
31
31
  // Adding a line here is a deliberate, reviewable act — that is the point.
32
32
  const ALLOWED = {
33
+ agent_standing_rules: '.claude/hooks/conductor.js + stop-slopcheck.js (package-excluded)',
33
34
  allow_rule_guard: '.claude/hooks/allow-rule-guard.js (package-excluded)',
34
35
  art_stage: 'scripts/bongos/art-stage.js (art-pipeline tooling, not in the core)',
35
36
  bongos_downloads: 'config/bongos-cli.json (instance content)',
@@ -45,6 +46,7 @@ const ALLOWED = {
45
46
  preview_server: 'src/preview-server.js (game module, not in the core)',
46
47
  recall_hints: '.claude/hooks/recall-hints.js (package-excluded)',
47
48
  rubric_fixtures: 'modules/art-pipeline/template/rubric_fixtures (module not installed)',
49
+ run_node_reroute: '.claude/hooks/run-node.sh (package-excluded)',
48
50
  run_routine_two_root: '.claude/hooks/_core-resolve.js (package-excluded)',
49
51
  'session-cards': '.claude/hooks/session-cards.js (package-excluded)',
50
52
  session_start_freshness: '.claude/hooks/session-start.js (package-excluded)',