@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
@@ -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
+ });
@@ -0,0 +1,122 @@
1
+ // tests/market_watch_ask_db.mjs — the weekly ask's SQL store against a REAL Postgres (task
2
+ // 1004940, goal 1000137).
3
+ //
4
+ // What only a real engine can say: the week's ask is issued once per project per week (the
5
+ // UNIQUE constraint, not the caller's care); an answer cannot land on another project's ask
6
+ // (the guard is in the INSERT ... SELECT); similar answers from three members file ONE
7
+ // proposal whose agree count is three; a second tap is a no-op; and the rows satisfy the
8
+ // proposals table's own CHECKs (a weekly_ask proposal carries its answer, a rule_change its
9
+ // rules version).
10
+ //
11
+ // The module is default:false, so CI's migrate.sh does not apply its migrations; this file
12
+ // applies market_watch_001, 002 and 003 itself. Its rows live under a project origin no real
13
+ // instance has and are deleted before and after. Self-skips without a reachable Postgres;
14
+ // in the INTEGRATION set (scripts/bongos/run-unit-tests.js). DB-free sibling:
15
+ // tests/market_watch_ask.mjs.
16
+ //
17
+ // Run: DATABASE_URL=... node tests/market_watch_ask_db.mjs
18
+
19
+ import { createRequire } from 'node:module';
20
+ import { readFileSync } from 'node:fs';
21
+ import { strict as assert } from 'node:assert';
22
+ import { insertTestBuilders } from './helpers.mjs';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const { Pool } = require('pg');
26
+ const ask = require('../modules/market-watch/ask.js');
27
+
28
+ const M1 = readFileSync(new URL('../modules/market-watch/migrations/market_watch_001_tables.sql', import.meta.url), 'utf8');
29
+ const M2 = readFileSync(new URL('../modules/market-watch/migrations/market_watch_002_run.sql', import.meta.url), 'utf8');
30
+ const M3 = readFileSync(new URL('../modules/market-watch/migrations/market_watch_003_ask_pool.sql', import.meta.url), 'utf8');
31
+
32
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
33
+ try {
34
+ await pool.query('SELECT 1');
35
+ } catch (e) {
36
+ console.log(`market_watch_ask_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
37
+ await pool.end().catch(() => {});
38
+ process.exit(0);
39
+ }
40
+
41
+ const P = 'https://t1004940-ask.example';
42
+ const OTHER = 'https://t1004940-other.example';
43
+ const ADA = 91004940, BEN = 91004941, CY = 91004942;
44
+ const NOW = new Date('2026-10-29T12:00:00Z');
45
+ const q = (sql, params) => pool.query(sql, params);
46
+
47
+ async function cleanup() {
48
+ for (const project of [P, OTHER]) {
49
+ await q(`DELETE FROM market_watch_proposals WHERE project = $1`, [project]).catch(() => {});
50
+ await q(`DELETE FROM market_watch_ask_answers WHERE ask_id IN (SELECT id FROM market_watch_weekly_asks WHERE project = $1)`, [project]).catch(() => {});
51
+ await q(`DELETE FROM market_watch_weekly_asks WHERE project = $1`, [project]).catch(() => {});
52
+ await q(`DELETE FROM market_watch_items WHERE project = $1`, [project]).catch(() => {});
53
+ await q(`DELETE FROM market_watch_rule_versions WHERE project = $1`, [project]).catch(() => {});
54
+ }
55
+ await q(`DELETE FROM builders WHERE id = ANY($1::bigint[])`, [[ADA, BEN, CY]]).catch(() => {});
56
+ }
57
+
58
+ let failed = 0;
59
+ async function check(name, fn) {
60
+ try { await fn(); console.log(`ok - ${name}`); } catch (e) { failed += 1; console.log(`not ok - ${name}\n ${e && e.stack}`); }
61
+ }
62
+
63
+ try {
64
+ await q(M1);
65
+ await q(M2);
66
+ await q(M3);
67
+ await q(M3);
68
+ await cleanup();
69
+ await insertTestBuilders(pool, [{ id: ADA, login: 't1004940-ada' }, { id: BEN, login: 't1004940-ben' }, { id: CY, login: 't1004940-cy' }]);
70
+ await q(`INSERT INTO market_watch_rule_versions (project, version, status, reason) VALUES ($1, 1, 'active', 'test rules')`, [P]);
71
+ for (const [kind, name, category] of [
72
+ ['competitor', 'Lovable', 'AI app builder'], ['competitor', 'Bolt.new', 'AI app builder'],
73
+ ['competitor', 'Factory', 'AI software-engineering agent'], ['partner', 'Colony', 'DAO tooling'],
74
+ ]) await q(`INSERT INTO market_watch_items (project, kind, name, url, category) VALUES ($1, $2, $3, '', $4)`, [P, kind, name, category]);
75
+ const store = ask.createAskStore(pool);
76
+
77
+ await check('the week is issued once per project, with chips from the project\'s own items', async () => {
78
+ assert.equal((await ask.issueWeeklyAsk(store, { project: P, now: NOW })).created, 2);
79
+ assert.equal((await ask.issueWeeklyAsk(store, { project: P, now: NOW })).created, 0);
80
+ // Even a direct second insert is refused by the constraint, not by the caller.
81
+ assert.equal(await store.insertAsk(P, { weekOf: '2026-10-26', questionKey: 'rival', prompt: 'x', options: [], closesAt: NOW }), false);
82
+ const asks = await store.asksForWeek(P, '2026-10-26');
83
+ assert.deepEqual(asks.map((a) => a.question_key), ['rival', 'watch']);
84
+ assert.deepEqual(asks[0].options.map((o) => o.label), ['AI app builder', 'AI software-engineering agent']);
85
+ assert.deepEqual(asks[1].options.map((o) => o.label), ['DAO tooling']);
86
+ });
87
+
88
+ await check('three members, one pool: one proposal, three agree, and the card is gone for each', async () => {
89
+ const r1 = await ask.answer(store, { project: P, builderId: ADA, now: NOW, body: { picks: [{ question: 'rival', choice: 'AI app builder' }], names: [{ question: 'rival', text: 'Kilo Code' }] } });
90
+ assert.equal(r1.status, 200, JSON.stringify(r1.body));
91
+ assert.deepEqual([r1.body.answers, r1.body.proposals], [2, 2]);
92
+ const r2 = await ask.answer(store, { project: P, builderId: BEN, now: NOW, body: { names: [{ question: 'rival', text: 'kilo-code' }] } });
93
+ assert.deepEqual([r2.body.proposals, r2.body.pooled], [0, 1]);
94
+ const r3 = await ask.answer(store, { project: P, builderId: CY, now: NOW, body: { names: [{ question: 'rival', text: 'The Kilo Code' }] } });
95
+ assert.deepEqual([r3.body.proposals, r3.body.pooled], [0, 1]);
96
+ const again = await ask.answer(store, { project: P, builderId: ADA, now: NOW, body: { picks: [{ question: 'rival', choice: 'AI app builder' }] } });
97
+ assert.equal(again.body.answers, 0, 'a second tap is a no-op');
98
+ const team = await store.teamProposals(P, '2026-10-26');
99
+ assert.equal(team.length, 2);
100
+ assert.deepEqual([team[0].payload.name, team[0].agree, team[0].who], ['Kilo Code', 3, 't1004940-ada']);
101
+ const { rows } = await q(`SELECT kind, origin, answer_id, rule_version_id FROM market_watch_proposals WHERE project = $1 ORDER BY id`, [P]);
102
+ assert.equal(rows[0].kind, 'rule_change');
103
+ assert.ok(rows[0].rule_version_id && rows.every((r) => r.origin === 'weekly_ask' && r.answer_id));
104
+ for (const b of [ADA, BEN, CY]) assert.equal(await ask.cardFor(store, { project: P, builderId: b, now: NOW }), null);
105
+ });
106
+
107
+ await check('an answer cannot land on another project\'s ask', async () => {
108
+ await ask.issueWeeklyAsk(store, { project: OTHER, now: NOW });
109
+ const [other] = await store.asksForWeek(OTHER, '2026-10-26');
110
+ const id = await store.tx((t) => t.insertAnswer({ project: P, askId: other.id, builderId: ADA, choice: 'x', freeText: null }));
111
+ assert.equal(id, null);
112
+ });
113
+ } finally {
114
+ await cleanup();
115
+ await pool.end();
116
+ }
117
+
118
+ if (failed) {
119
+ console.log(`market_watch_ask_db.mjs: ${failed} failed`);
120
+ process.exit(1);
121
+ }
122
+ console.log('market_watch_ask_db.mjs: all passed');
@@ -28,6 +28,7 @@ const db = require('../modules/market-watch/db.js');
28
28
 
29
29
  const MIGRATION = readFileSync(new URL('../modules/market-watch/migrations/market_watch_001_tables.sql', import.meta.url), 'utf8');
30
30
  const MIGRATION_002 = readFileSync(new URL('../modules/market-watch/migrations/market_watch_002_run.sql', import.meta.url), 'utf8');
31
+ const MIGRATION_003 = readFileSync(new URL('../modules/market-watch/migrations/market_watch_003_ask_pool.sql', import.meta.url), 'utf8');
31
32
  const run = require('../modules/market-watch/run.js');
32
33
  const store = require('../modules/market-watch/store.js');
33
34
 
@@ -80,6 +81,9 @@ try {
80
81
  // 'run' proposal origin. A new migration here joins this file (CI never applies it).
81
82
  await q(MIGRATION_002);
82
83
  await q(MIGRATION_002);
84
+ // market_watch_003 (task 1004940): the weekly ask's pool-lookup index.
85
+ await q(MIGRATION_003);
86
+ await q(MIGRATION_003);
83
87
  const { rows } = await q(`SELECT count(*)::int AS n FROM information_schema.tables WHERE table_name LIKE 'market\\_watch\\_%'`);
84
88
  assert.ok(rows[0].n >= 10, `tables: ${rows[0].n}`);
85
89
  });
@@ -2,7 +2,7 @@
2
2
  // the loader, and an instance boots with it present and DISABLED (task 1004935, goal
3
3
  // 1000137). DB-free, so it runs in the unit lane.
4
4
  //
5
- // The module mounts no routes yet; task 1004936 gave it its tables and a one-shot hub seed
5
+ // Task 1004940 mounted its first routes (the weekly ask); task 1004936 gave it its tables and a one-shot hub seed
6
6
  // poller (tests/market_watch_data.mjs covers those). What this file
7
7
  // pins is everything the later Market Watch tasks will stand on:
8
8
  // 1. the manifest validates, ships default:false, and declares spend.requiresPayer;
@@ -26,7 +26,7 @@ const mods = require('../src/modules.js');
26
26
  const bundles = require('../modules/provisioning/starter-bundles.js');
27
27
  const scopeMap = require('../src/bongos/module-scope-map.js');
28
28
 
29
- test('the manifest validates, ships disabled, declares a payer, and contributes only its data', () => {
29
+ test('the manifest validates, ships disabled, declares a payer, and contributes what the goal has built', () => {
30
30
  const v = validateManifest(manifest);
31
31
  assert.equal(v.valid, true, `manifest errors: ${v.errors.join('; ')}`);
32
32
  assert.equal(manifest.key, KEY);
@@ -34,9 +34,13 @@ test('the manifest validates, ships disabled, declares a payer, and contributes
34
34
  assert.equal(manifest.spend && manifest.spend.requiresPayer, true,
35
35
  'every run spends the project AI budget, so the payer guard is declared before the first route');
36
36
  // Task 1004936 added its tables and the one-shot hub seed; task 1004937 the weekly-run
37
- // poller. There are still no routes.
38
- assert.deepEqual(manifest.contributes, { migrations: true, pollers: ['hub-seed', 'weekly-run'] },
39
- 'its tables, the hub seed and the weekly run, nothing to mount');
37
+ // poller; task 1004940 the weekly ask (its poller, its routes and its hall slice). Each
38
+ // screen task adds its own entries, so this pins what must be there, not the whole list.
39
+ const c = manifest.contributes;
40
+ assert.equal(c.migrations, true);
41
+ for (const p of ['hub-seed', 'weekly-run', 'weekly-ask']) assert.ok(c.pollers.includes(p), `poller ${p}`);
42
+ assert.ok(c.routes.includes('ask'), 'the weekly ask routes');
43
+ assert.ok(c.uiSections.includes('weekly-ask'), 'the weekly ask hall slice (hallWidgetScripts needs a uiSection)');
40
44
  });
41
45
 
42
46
  test('the loader discovers it from the live tree — loaded, not an error, not skipped', () => {
@@ -57,7 +57,7 @@ const fakeRes = () => ({
57
57
  // The router is built against the REAL module-api and the REAL goal-category; only
58
58
  // the two calls that would reach Postgres are swapped. Stubbing the row builders
59
59
  // instead would prove nothing about the shape the page actually receives.
60
- const WORKING_REWARD = { rewardConfig: () => ({ marginPct: 20, usdPeg: 1, mode: 'cost-plus-only', perTaskCredit: false, ideatorCredit: true }) };
60
+ const WORKING_REWARD = { rewardConfig: () => ({ marginPct: 20, costScale: 1, mode: 'cost-plus-only', perTaskCredit: false, ideatorCredit: true }) };
61
61
  function buildRouterWithStubs({ timer, categories, reward = WORKING_REWARD }) {
62
62
  const saved = {
63
63
  resolveRotDays: api.resolveRotDays,