@bongos/core 1.24.3 → 1.24.4

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 (52) hide show
  1. package/.bongos-core.json +106 -46
  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/module-api-changelog.md +2 -2
  13. package/modules/discord/ship-broadcast.js +7 -0
  14. package/modules/economy/credit-wording.js +93 -0
  15. package/modules/economy/credits.js +26 -12
  16. package/modules/economy/reward-policy.js +49 -3
  17. package/modules/economy/reward.js +5 -0
  18. package/modules/government/routes/government.js +7 -0
  19. package/modules/ideas/routes/inbox.js +6 -0
  20. package/modules/lifecycle/routes/project-settings.js +6 -4
  21. package/modules/market-watch/ask.js +425 -0
  22. package/modules/market-watch/migrations/market_watch_003_ask_pool.sql +21 -0
  23. package/modules/market-watch/module.json +10 -3
  24. package/modules/market-watch/pollers/weekly-ask.js +89 -0
  25. package/modules/market-watch/public/hall-widget.js +271 -0
  26. package/modules/market-watch/public/weekly-ask.css +39 -0
  27. package/modules/market-watch/routes/ask.js +95 -0
  28. package/modules/onboarding/routes/access-requests.js +38 -10
  29. package/modules/platform-identity/account-logins.js +13 -0
  30. package/modules/platform-identity/routes/my-projects.js +10 -7
  31. package/modules/platform-identity/routes/sso.js +11 -6
  32. package/package-lock.json +2 -2
  33. package/package.json +1 -1
  34. package/release-notes.json +17 -1
  35. package/scripts/bongos/backfill-subagent-rewards.js +7 -7
  36. package/scripts/bongos/run-unit-tests.js +4 -0
  37. package/scripts/hall-preview/server.js +13 -0
  38. package/src/bongos/auth-idp.js +17 -5
  39. package/src/bongos/db-kernel.js +14 -1
  40. package/src/bongos/module-scope-map.js +1 -0
  41. package/src/module-api.js +1 -1
  42. package/tests/account_logins.mjs +6 -4
  43. package/tests/backfill_subagent_rewards.mjs +7 -7
  44. package/tests/credit_grant.mjs +7 -7
  45. package/tests/credit_wording.mjs +154 -0
  46. package/tests/google_halls.mjs +211 -0
  47. package/tests/market_watch_ask.mjs +369 -0
  48. package/tests/market_watch_ask_db.mjs +122 -0
  49. package/tests/market_watch_data_db.mjs +4 -0
  50. package/tests/market_watch_module.mjs +9 -5
  51. package/tests/project_settings_page.mjs +1 -1
  52. package/tests/reward_cost_scale.mjs +200 -0
@@ -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);
@@ -0,0 +1,369 @@
1
+ // tests/market_watch_ask.mjs — the Market Watch weekly ask card (task 1004940, goal 1000137).
2
+ //
3
+ // DB-free: the flow (modules/market-watch/ask.js) runs over an in-memory store that keeps the
4
+ // same rules the SQL store keeps (the UNIQUE constraints, the project guard on an answer's
5
+ // ask, the pooled-proposal lookup), and the real routes run over it in a real Express app.
6
+ // tests/market_watch_ask_db.mjs runs the SQL store itself on Postgres (INTEGRATION set).
7
+ //
8
+ // - pure: the week, normalising, chips from the project's own data, parsing and its caps
9
+ // - the poller: once per project per week, by the wall clock
10
+ // - THE WHOLE PATH: the card the hall draws → a member taps and names one → POST → the
11
+ // proposals with their agree counts, read back as the next card's team rows
12
+ // - members only (403), nobody answers for anyone else (403), closed weeks expire quietly
13
+
14
+ import { test } from 'node:test';
15
+ import { strict as assert } from 'node:assert';
16
+ import { createRequire } from 'node:module';
17
+ import http from 'node:http';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const ask = require('../modules/market-watch/ask.js');
21
+ const widget = require('../modules/market-watch/public/hall-widget.js');
22
+ const api = require('../src/module-api.js');
23
+
24
+ const PROJECT = 'https://acme.example';
25
+ const OTHER = 'https://other.example';
26
+ // Thursday of ISO week 44, 2026; its Monday is 26 October.
27
+ const NOW = new Date('2026-10-29T12:00:00Z');
28
+
29
+ // ---- the in-memory store --------------------------------------------------
30
+
31
+ function memoryStore({ items = [], activeRules = { [PROJECT]: 11 }, names = {} } = {}) {
32
+ const s = { asks: [], answers: [], proposals: [], seq: 0, items, activeRules, names, locks: [] };
33
+ const q = {
34
+ async insertAnswer({ project, askId, builderId, choice, freeText }) {
35
+ const a = s.asks.find((x) => x.id === askId && x.project === project);
36
+ if (!a) return null; // the SQL guard: an ask of another project inserts nothing
37
+ if (s.answers.some((x) => x.ask_id === askId && x.builder_id === builderId && x.choice === choice)) return null;
38
+ const row = { id: ++s.seq, ask_id: askId, builder_id: builderId, choice, free_text: freeText };
39
+ s.answers.push(row);
40
+ return row.id;
41
+ },
42
+ async lockPool(project, key) { s.locks.push(`${project}:${key}`); },
43
+ async findPooled(project, key) {
44
+ const p = s.proposals.find((x) => x.project === project && x.origin === 'weekly_ask' && x.payload.pool_key === key && ['open', 'approved', 'applied'].includes(x.status));
45
+ return p ? p.id : null;
46
+ },
47
+ async insertProposal(x) {
48
+ const row = { id: ++s.seq, project: x.project, kind: x.kind, origin: 'weekly_ask', rule_version_id: x.ruleVersionId, answer_id: x.answerId, payload: x.payload, reason: x.reason, proposed_by: x.proposedBy, status: 'open', created_at: NOW };
49
+ if (s.proposals.some((p) => p.answer_id === row.answer_id)) throw new Error('one proposal per answer');
50
+ s.proposals.push(row);
51
+ return row.id;
52
+ },
53
+ };
54
+ return {
55
+ state: s,
56
+ async asksForWeek(project, weekOf) { return s.asks.filter((a) => a.project === project && a.week_of === weekOf); },
57
+ async insertAsk(project, a) {
58
+ if (s.asks.some((x) => x.project === project && x.week_of === a.weekOf && x.question_key === a.questionKey)) return false;
59
+ s.asks.push({ id: ++s.seq, project, week_of: a.weekOf, question_key: a.questionKey, prompt: a.prompt, options: JSON.parse(JSON.stringify(a.options)), closes_at: a.closesAt });
60
+ return true;
61
+ },
62
+ async chipSource(project) { return s.items.filter((i) => (i.project || PROJECT) === project && (i.status || 'active') === 'active'); },
63
+ async answeredWeek(project, weekOf, builderId) {
64
+ const ids = new Set(s.asks.filter((a) => a.project === project && a.week_of === weekOf).map((a) => a.id));
65
+ return s.answers.some((x) => ids.has(x.ask_id) && x.builder_id === builderId);
66
+ },
67
+ async streak() { return 4; },
68
+ async teamProposals(project) {
69
+ return s.proposals.filter((p) => p.project === project && ['open', 'approved', 'applied'].includes(p.status)).map((p) => {
70
+ const agreeing = new Set(s.answers.filter((x) => {
71
+ const a = s.asks.find((k) => k.id === x.ask_id);
72
+ return a && a.project === project && `${a.question_key}|${x.choice}` === p.payload.pool_key;
73
+ }).map((x) => x.builder_id));
74
+ return { ...p, who: s.names[p.proposed_by] || null, agree: agreeing.size };
75
+ }).sort((a, b) => b.agree - a.agree || b.id - a.id).slice(0, 5);
76
+ },
77
+ async activeRuleVersionId(project) { return s.activeRules[project] ?? null; },
78
+ async tx(fn) { return fn(q); },
79
+ };
80
+ }
81
+
82
+ const ITEMS = [
83
+ { kind: 'competitor', name: 'Cognition (Devin)', category: 'AI software-engineering agent' },
84
+ { kind: 'competitor', name: 'Factory', category: 'AI software-engineering agent' },
85
+ { kind: 'competitor', name: 'Lovable', category: 'AI app builder' },
86
+ { kind: 'competitor', name: 'Bolt.new (StackBlitz)', category: 'AI app builder' },
87
+ { kind: 'competitor', name: 'Cursor (Anysphere)', category: 'AI IDE + background/cloud agents' },
88
+ { kind: 'competitor', name: 'Gone Inc', category: 'Removed kind', status: 'removed' },
89
+ { kind: 'partner', name: 'NLnet Foundation', category: 'open-source funding' },
90
+ { kind: 'partner', name: 'FLOSS/fund (Zerodha)', category: 'open-source funding' },
91
+ { kind: 'partner', name: 'Colony', category: 'DAO tooling' },
92
+ { kind: 'partner', name: 'Igalia', category: 'worker co-op (software)' },
93
+ { kind: 'topic', name: 'Worker co-op (software)', category: '' },
94
+ ];
95
+
96
+ // ---- pure -------------------------------------------------------------------
97
+
98
+ test('the week is the Monday (UTC) and the card names its ISO week number', () => {
99
+ assert.equal(ask.weekStart(NOW), '2026-10-26');
100
+ assert.equal(ask.weekStart(new Date('2026-10-26T00:00:00Z')), '2026-10-26', 'Monday midnight is the new week');
101
+ assert.equal(ask.weekStart(new Date('2026-10-25T23:59:59Z')), '2026-10-19', 'Sunday night is still last week');
102
+ assert.equal(ask.isoWeek('2026-10-26'), 44);
103
+ assert.equal(ask.isoWeek('2025-12-29'), 1, 'the week holding the year\'s first Thursday is week 1');
104
+ });
105
+
106
+ test('similar answers pool: case, punctuation, spacing and a leading article do not count', () => {
107
+ for (const s of ['Kilo Code', ' kilo-code ', 'The Kilo Code!', 'KILO_CODE']) assert.equal(ask.normalise(s), 'kilo code');
108
+ assert.equal(ask.normalise('Kilo Code'), 'kilo code', 'full-width letters fold');
109
+ assert.equal(ask.normalise('the'), 'the', 'a lone word is never emptied');
110
+ assert.ok(!ask.normalise('~nothing').includes('~'), 'no entry can collide with the nothing-new marker');
111
+ const hidden = (n) => String.fromCharCode(n); // spelled as code points: no hidden character sits in this file
112
+ assert.equal(ask.cleanText(`a${hidden(0)}b${hidden(0x202e)}c\n d${hidden(0x200b)}`), 'a b c d', 'control and direction characters go');
113
+ });
114
+
115
+ test('chips come from the project\'s own items: competitor kinds, then partner areas it does not already watch', () => {
116
+ const c = ask.chipOptions(ITEMS);
117
+ assert.deepEqual(c.rival.map((o) => o.label), ['AI app builder', 'AI software-engineering agent', 'AI IDE + background/cloud agents']);
118
+ assert.equal(c.rival[1].example, 'Cognition, Factory', 'the parenthetical is dropped from an example');
119
+ assert.ok(!c.rival.some((o) => o.label === 'Removed kind'), 'a removed item is not a chip');
120
+ assert.deepEqual(c.watch.map((o) => o.label), ['Open-source funding', 'DAO tooling'],
121
+ 'the area already watched as a topic is not offered again');
122
+ assert.deepEqual(ask.chipOptions([]), { rival: [], watch: [] }, 'a project with no data still gets the free entries');
123
+ });
124
+
125
+ test('parseAnswer refuses what the card cannot send, and caps free text', () => {
126
+ assert.equal(ask.parseAnswer(null).error, 'bad_answer');
127
+ assert.equal(ask.parseAnswer({}).error, 'empty_answer');
128
+ assert.equal(ask.parseAnswer({ picks: [{ question: 'salary', choice: 'x' }] }).error, 'unknown_question');
129
+ assert.equal(ask.parseAnswer({ names: [{ question: 'rival', text: 'x'.repeat(81) }] }).error, 'text_too_long');
130
+ assert.equal(ask.parseAnswer({ names: [{ question: 'rival', text: ' !! ' }] }).error, 'empty_answer');
131
+ assert.equal(ask.parseAnswer({ picks: Array.from({ length: 21 }, () => ({ question: 'rival', choice: 'a' })) }).error, 'too_many');
132
+ const ok = ask.parseAnswer({ names: [{ question: 'rival', text: ' Kilo Code ' }] });
133
+ assert.deepEqual(ok.entries, [{ question: 'rival', choice: 'kilo code', label: 'Kilo Code', free: true }]);
134
+ assert.deepEqual(ask.parseAnswer({ nothing: true }), { entries: [], nothing: true });
135
+ });
136
+
137
+ // ---- the poller -------------------------------------------------------------
138
+
139
+ test('the poller issues each week once per project, by the wall clock', async () => {
140
+ const tick = require('../modules/market-watch/pollers/weekly-ask.js').tick;
141
+ const store = memoryStore({ items: ITEMS });
142
+ const first = await tick({ project: PROJECT, store, now: () => NOW });
143
+ assert.equal(first.created, 2);
144
+ assert.equal((await tick({ project: PROJECT, store, now: () => new Date('2026-10-30T09:00:00Z') })).created, 0, 'a later tick the same week is a no-op');
145
+ assert.equal(store.state.asks.length, 2);
146
+ const [rival] = store.state.asks;
147
+ assert.equal(rival.week_of, '2026-10-26');
148
+ assert.equal(new Date(rival.closes_at).toISOString(), '2026-11-02T00:00:00.000Z', 'it closes when the next week opens');
149
+ const next = await tick({ project: PROJECT, store, now: () => new Date('2026-11-02T00:30:00Z') });
150
+ assert.equal(next.created, 2, 'the next week gets its own card');
151
+ assert.equal((await tick({ project: OTHER, store, now: () => NOW })).created, 2, 'per project');
152
+ assert.deepEqual(await tick({ project: null, store }), { skipped: 'no_project_origin' });
153
+ });
154
+
155
+ // ---- the routes ---------------------------------------------------------------
156
+
157
+ let viewer = { id: 7, rank: 'xenos', status: 'active' };
158
+ Object.defineProperty(api, 'requireBuilder', {
159
+ configurable: true,
160
+ value: (req, res, next) => { if (!viewer) { res.status(401).json({ error: { code: 'unauthenticated' } }); return; } req.builder = viewer; next(); },
161
+ });
162
+ const express = require('express');
163
+ let store = memoryStore({ items: ITEMS, names: { 7: 'ada', 8: 'ben', 9: 'cy' } });
164
+ let clock = NOW;
165
+ const app = express();
166
+ app.use((req, res, next) => {
167
+ res.fail = (code, opts) => res.status(typeof opts === 'number' ? opts : (opts && opts.status) || 400).json({ error: { code } });
168
+ next();
169
+ });
170
+ app.use(express.json());
171
+ app.use('/api/bongos', require('../modules/market-watch/routes/ask.js')({ store: () => store, project: PROJECT, now: () => clock }));
172
+ const server = app.listen(0);
173
+ await new Promise((r) => server.once('listening', r));
174
+ const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
175
+ test.after(() => server.close());
176
+
177
+ function call(method, path, body) {
178
+ return new Promise((resolve, reject) => {
179
+ const data = body === undefined ? null : JSON.stringify(body);
180
+ const req = http.request(`${base}${path}`, { method, headers: data ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } : {} }, (res) => {
181
+ const chunks = [];
182
+ res.on('data', (c) => chunks.push(c));
183
+ res.on('end', () => { const raw = Buffer.concat(chunks).toString(); resolve({ status: res.statusCode, raw, body: JSON.parse(raw) }); });
184
+ });
185
+ req.on('error', reject);
186
+ if (data) req.write(data);
187
+ req.end();
188
+ });
189
+ }
190
+
191
+ async function freshWeek() {
192
+ store = memoryStore({ items: ITEMS, names: { 7: 'ada', 8: 'ben', 9: 'cy' } });
193
+ clock = NOW;
194
+ await ask.issueWeeklyAsk(store, { project: PROJECT, now: NOW });
195
+ }
196
+
197
+ test('THE WHOLE PATH: a member answers from the hall card and the answer shows up as a proposal', async () => {
198
+ await freshWeek();
199
+ viewer = { id: 7, rank: 'xenos', status: 'active' };
200
+
201
+ // 1. The card the hall draws, from the route the hall calls.
202
+ const got = await call('GET', '/market-watch/ask');
203
+ assert.equal(got.status, 200);
204
+ const card = got.body.card;
205
+ assert.equal(card.week_no, 44);
206
+ assert.equal(card.next_on, '2026-11-02');
207
+ const html = widget.cardHtml(card);
208
+ assert.match(html, /market watch · week 44 · about 30 seconds/);
209
+ assert.match(html, /what counts as a competitor\?/);
210
+ assert.match(html, /and what else should we watch\?/);
211
+ assert.match(html, /data-mwask-choice="AI app builder"/);
212
+ assert.match(html, /\+ name one/);
213
+ assert.match(html, /\+ a topic/);
214
+ assert.match(html, /class="hask ginbox__item mwask"/, 'drawn as an inbox row, so the attention card counts it');
215
+
216
+ // 2. The member taps a competitor chip and a watch chip, and names one; the page builds the body.
217
+ const body = widget.answerBody(card.week_of, [
218
+ { question: 'rival', choice: 'AI app builder' },
219
+ { question: 'watch', choice: 'Open-source funding' },
220
+ { question: 'rival', text: 'Kilo Code' },
221
+ ]);
222
+ assert.ok(!('builder_id' in body), 'the page never names who is answering');
223
+ const sent = await call('POST', '/market-watch/ask/answer', body);
224
+ assert.equal(sent.status, 200, sent.raw);
225
+ assert.deepEqual([sent.body.answers, sent.body.proposals, sent.body.pooled], [3, 3, 0]);
226
+
227
+ // 3. Each answer is stored, under the caller.
228
+ assert.deepEqual(store.state.answers.map((a) => [a.builder_id, a.choice, a.free_text]), [
229
+ [7, 'ai app builder', null], [7, 'open source funding', null], [7, 'kilo code', 'Kilo Code'],
230
+ ]);
231
+
232
+ // 4. ... and is a proposal: change a definition, add a topic, add an item.
233
+ const byKey = Object.fromEntries(store.state.proposals.map((p) => [p.payload.pool_key, p]));
234
+ assert.equal(byKey['rival|ai app builder'].kind, 'rule_change');
235
+ assert.equal(byKey['rival|ai app builder'].rule_version_id, 11, 'it changes the rules in use');
236
+ assert.equal(byKey['rival|ai app builder'].payload.include, 'AI app builder');
237
+ assert.deepEqual([byKey['watch|open source funding'].kind, byKey['watch|open source funding'].payload.item_kind], ['add_item', 'topic']);
238
+ assert.deepEqual([byKey['rival|kilo code'].kind, byKey['rival|kilo code'].payload.item_kind, byKey['rival|kilo code'].payload.name], ['add_item', 'competitor', 'Kilo Code']);
239
+ for (const p of store.state.proposals) assert.equal(p.proposed_by, 7);
240
+
241
+ // 5. Answered: the card is gone for them (no second ask, no reminder).
242
+ assert.equal((await call('GET', '/market-watch/ask')).body.card, null);
243
+
244
+ // 6. Two more members name the same rival their own way: one pool, three agree.
245
+ viewer = { id: 8, rank: 'thetes', status: 'active' };
246
+ const ben = await call('POST', '/market-watch/ask/answer', { week_of: card.week_of, names: [{ question: 'rival', text: 'kilo-code' }] });
247
+ assert.deepEqual([ben.body.answers, ben.body.proposals, ben.body.pooled], [1, 0, 1]);
248
+ viewer = { id: 9, rank: 'metic', status: 'active' };
249
+ const cyCard = (await call('GET', '/market-watch/ask')).body.card;
250
+ await call('POST', '/market-watch/ask/answer', { week_of: card.week_of, names: [{ question: 'rival', text: 'The Kilo Code' }] });
251
+ assert.equal(store.state.proposals.filter((p) => p.payload.pool_key === 'rival|kilo code').length, 1, 'similar answers are ONE proposal');
252
+
253
+ // 7. The team rows the next member sees: "Add Kilo Code as a competitor · ada · 3 agree".
254
+ const row = cyCard.team.find((r) => r.name === 'Kilo Code');
255
+ assert.deepEqual([row.verb, row.tail, row.who, row.agree], ['Add', 'as a competitor', 'ada', 2], 'ada and ben had agreed when cy opened the card');
256
+ viewer = { id: 10, rank: 'xenos', status: 'active' };
257
+ const team = (await call('GET', '/market-watch/ask')).body.card.team;
258
+ assert.equal(team[0].name, 'Kilo Code');
259
+ assert.equal(team[0].agree, 3);
260
+ assert.match(widget.cardHtml({ ...card, team }), /Add <b>Kilo Code<\/b> as a competitor<\/span><span class="mwask__who">ada<\/span><span class="mwask__pill mwask__pill--hot">3 agree<\/span>/);
261
+ });
262
+
263
+ test('a second tap on the same chip is a no-op, and "nothing new" files no proposal', async () => {
264
+ await freshWeek();
265
+ viewer = { id: 7, rank: 'xenos', status: 'active' };
266
+ const b = { week_of: '2026-10-26', picks: [{ question: 'rival', choice: 'AI app builder' }, { question: 'rival', choice: 'ai APP builder' }] };
267
+ const r = await call('POST', '/market-watch/ask/answer', b);
268
+ assert.deepEqual([r.body.answers, r.body.proposals], [1, 1]);
269
+ viewer = { id: 8, rank: 'xenos', status: 'active' };
270
+ const n = await call('POST', '/market-watch/ask/answer', { week_of: '2026-10-26', nothing: true });
271
+ assert.equal(n.status, 200);
272
+ assert.deepEqual([n.body.answers, n.body.proposals], [1, 0]);
273
+ assert.equal(store.state.proposals.length, 1);
274
+ assert.equal((await call('GET', '/market-watch/ask')).body.card, null, 'nothing new still answers the week');
275
+ });
276
+
277
+ test('with no rules in use a competitor chip is kept as an answer and files no proposal', async () => {
278
+ await freshWeek();
279
+ store.state.activeRules = {};
280
+ viewer = { id: 7, rank: 'xenos', status: 'active' };
281
+ const r = await call('POST', '/market-watch/ask/answer', { picks: [{ question: 'rival', choice: 'AI app builder' }] });
282
+ assert.deepEqual([r.status, r.body.answers, r.body.proposals], [200, 1, 0]);
283
+ });
284
+
285
+ test('MEMBERS ONLY: a non-member is refused 403 on both routes, a signed-out caller 401', async () => {
286
+ await freshWeek();
287
+ for (const v of [{ id: 66, rank: 'helot', status: 'active' }, { id: 67, rank: 'xenos', status: 'inactive' }, { id: 68, rank: null, status: 'active' }]) {
288
+ viewer = v;
289
+ const g = await call('GET', '/market-watch/ask');
290
+ const p = await call('POST', '/market-watch/ask/answer', { picks: [{ question: 'rival', choice: 'AI app builder' }] });
291
+ assert.equal(g.status, 403, `GET as ${JSON.stringify(v)}`);
292
+ assert.equal(p.status, 403, `POST as ${JSON.stringify(v)}`);
293
+ }
294
+ assert.equal(store.state.answers.length, 0, 'nothing was stored');
295
+ viewer = null;
296
+ assert.equal((await call('POST', '/market-watch/ask/answer', { nothing: true })).status, 401);
297
+ });
298
+
299
+ test('a member cannot answer for another member', async () => {
300
+ await freshWeek();
301
+ viewer = { id: 7, rank: 'metic', status: 'active' };
302
+ const r = await call('POST', '/market-watch/ask/answer', { builder_id: 8, picks: [{ question: 'rival', choice: 'AI app builder' }] });
303
+ assert.equal(r.status, 403);
304
+ assert.equal(r.body.error.code, 'not_your_answer');
305
+ assert.equal(store.state.answers.length, 0);
306
+ // Naming yourself is allowed, and is the same as naming nobody.
307
+ const self = await call('POST', '/market-watch/ask/answer', { builder_id: 7, picks: [{ question: 'rival', choice: 'AI app builder' }] });
308
+ assert.equal(self.status, 200);
309
+ assert.deepEqual(store.state.answers.map((a) => a.builder_id), [7]);
310
+ });
311
+
312
+ test('a chip must be one the card offered, and free text is capped', async () => {
313
+ await freshWeek();
314
+ viewer = { id: 7, rank: 'xenos', status: 'active' };
315
+ const forged = await call('POST', '/market-watch/ask/answer', { picks: [{ question: 'rival', choice: 'Anything I like' }] });
316
+ assert.deepEqual([forged.status, forged.body.error.code], [400, 'unknown_choice']);
317
+ const long = await call('POST', '/market-watch/ask/answer', { names: [{ question: 'watch', text: 'y'.repeat(200) }] });
318
+ assert.deepEqual([long.status, long.body.error.code], [400, 'text_too_long']);
319
+ const extra = await call('POST', '/market-watch/ask/answer', { nothing: true, reason: '<b>hi</b>' });
320
+ assert.equal(extra.status, 400, 'an unknown field is refused (strict body schema)');
321
+ assert.equal(store.state.answers.length, 0);
322
+ });
323
+
324
+ test('an unanswered card expires quietly: after the week closes there is no card and no answer', async () => {
325
+ await freshWeek();
326
+ viewer = { id: 7, rank: 'xenos', status: 'active' };
327
+ clock = new Date('2026-11-02T00:00:01Z'); // the next week, before its ask is out
328
+ assert.equal((await call('GET', '/market-watch/ask')).body.card, null);
329
+ const late = await call('POST', '/market-watch/ask/answer', { week_of: '2026-10-26', nothing: true });
330
+ assert.deepEqual([late.status, late.body.error.code], [409, 'ask_closed']);
331
+ // A body for last week, sent once the new week's card is out, is refused, not filed on the new one.
332
+ await ask.issueWeeklyAsk(store, { project: PROJECT, now: clock });
333
+ const stale = await call('POST', '/market-watch/ask/answer', { week_of: '2026-10-26', nothing: true });
334
+ assert.equal(stale.status, 409);
335
+ assert.equal(store.state.answers.length, 0);
336
+ });
337
+
338
+ test('an ask whose closes_at has passed is closed even inside its week', async () => {
339
+ await freshWeek();
340
+ viewer = { id: 7, rank: 'xenos', status: 'active' };
341
+ for (const a of store.state.asks) a.closes_at = new Date('2026-10-29T00:00:00Z'); // closed this morning
342
+ assert.equal((await call('GET', '/market-watch/ask')).body.card, null);
343
+ assert.equal((await call('POST', '/market-watch/ask/answer', { nothing: true })).status, 409);
344
+ assert.equal(store.state.answers.length, 0);
345
+ });
346
+
347
+ test('an answer can never land on another project\'s ask', async () => {
348
+ await freshWeek();
349
+ await ask.issueWeeklyAsk(store, { project: OTHER, now: NOW });
350
+ const otherAsk = store.state.asks.find((a) => a.project === OTHER);
351
+ const id = await store.tx((t) => t.insertAnswer({ project: PROJECT, askId: otherAsk.id, builderId: 7, choice: 'x', freeText: null }));
352
+ assert.equal(id, null);
353
+ });
354
+
355
+ // ---- untrusted text is escaped ------------------------------------------------
356
+
357
+ test('members\' words and item names are escaped wherever the card draws them', () => {
358
+ const evil = '<img src=x onerror=alert(1)>';
359
+ const html = widget.cardHtml({
360
+ week_of: '2026-10-26', week_no: 44, next_on: '2026-11-02', streak: 2,
361
+ questions: [{ key: 'rival', prompt: 'what counts as a competitor?', options: [{ label: evil, example: `"${evil}` }] }],
362
+ team: [{ verb: 'Add', name: evil, tail: 'as a competitor', who: evil, agree: 1, status: 'open' }],
363
+ });
364
+ assert.ok(!html.includes('<img'), 'no member text becomes markup');
365
+ assert.ok(html.includes('&lt;img src=x onerror=alert(1)&gt;'));
366
+ assert.ok(!/data-mwask-choice="[^"]*"[^ >]*onerror/.test(html), 'an attribute cannot be broken out of');
367
+ assert.equal(widget.cardHtml(null), '', 'no card, no slice');
368
+ assert.deepEqual(widget.answerBody('2026-10-26', [{ question: 'watch', text: 'z'.repeat(120) }]).names[0].text.length, 80);
369
+ });