@bongos/core 1.24.14 → 1.24.16

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 (165) hide show
  1. package/.bongos-core.json +308 -138
  2. package/.claude/skills/blocker-review/SKILL.md +2 -0
  3. package/.claude/skills/session-board/SKILL.md +29 -0
  4. package/.claude/skills/strand-fix/SKILL.md +1 -1
  5. package/clients/bongos-client/README.md +1 -1
  6. package/clients/bongos-client/bongos-client.global.js +14 -0
  7. package/clients/bongos-client/index.cjs +14 -0
  8. package/clients/bongos-client/index.d.ts +23 -2
  9. package/clients/bongos-client/index.mjs +14 -0
  10. package/docs/adr/0385-money-lives-with-a-processor-credits-stay-a-project-meter.md +4 -0
  11. package/docs/api/openapi.json +369 -6
  12. package/docs/api-reference.md +19 -4
  13. package/docs/architecture.md +6 -0
  14. package/docs/copy-inventory.md +231 -204
  15. package/docs/copy-registry.json +488 -255
  16. package/docs/file-map.md +1 -0
  17. package/docs/module-api-changelog.md +4 -2
  18. package/docs/page-inventory.json +7 -1
  19. package/docs/page-readings.json +1132 -1103
  20. package/migrations/core_289_task_short_name.sql +34 -0
  21. package/modules/ai-studio/db.js +10 -0
  22. package/modules/ai-studio/gemini.js +73 -5
  23. package/modules/ai-studio/openrouter.js +17 -2
  24. package/modules/ai-studio/routes/ai-studio.js +7 -1
  25. package/modules/ai-studio/studio.js +8 -0
  26. package/modules/builder-settings/checkout-freshness.js +71 -0
  27. package/modules/builder-settings/routes/settings.js +26 -0
  28. package/modules/builder-settings/settings.js +2 -0
  29. package/modules/economy/migrations/economy_002_project_account.sql +83 -0
  30. package/modules/economy/module.json +1 -1
  31. package/modules/economy/project-account.js +166 -0
  32. package/modules/economy/routes/project-account.js +40 -0
  33. package/modules/hall-ui/public/ai-studio.css +3 -0
  34. package/modules/hall-ui/public/ai-studio.html +2 -1
  35. package/modules/hall-ui/public/ai-studio.js +72 -1
  36. package/modules/hall-ui/public/atlas.html +1 -1
  37. package/modules/hall-ui/public/blockers.html +1 -1
  38. package/modules/hall-ui/public/board-room.html +1 -1
  39. package/modules/hall-ui/public/city.html +1 -1
  40. package/modules/hall-ui/public/collab.html +1 -1
  41. package/modules/hall-ui/public/commands.html +1 -1
  42. package/modules/hall-ui/public/copy-desk.html +1 -1
  43. package/modules/hall-ui/public/deploy.html +1 -1
  44. package/modules/hall-ui/public/diagrams.html +1 -1
  45. package/modules/hall-ui/public/drachmae.html +1 -1
  46. package/modules/hall-ui/public/fleet.html +1 -1
  47. package/modules/hall-ui/public/gate.html +1 -1
  48. package/modules/hall-ui/public/github-chip.js +85 -0
  49. package/modules/hall-ui/public/goal-map.html +1 -1
  50. package/modules/hall-ui/public/goals.html +1 -1
  51. package/modules/hall-ui/public/government.html +1 -1
  52. package/modules/hall-ui/public/idea.html +1 -1
  53. package/modules/hall-ui/public/ideas.html +1 -1
  54. package/modules/hall-ui/public/index.html +1 -1
  55. package/modules/hall-ui/public/market-watch-item.html +2 -2
  56. package/modules/hall-ui/public/market-watch-item.js +10 -3
  57. package/modules/hall-ui/public/market-watch-item.states.json +41 -0
  58. package/modules/hall-ui/public/market-watch-lib.js +36 -5
  59. package/modules/hall-ui/public/market-watch-rules.html +1 -1
  60. package/modules/hall-ui/public/market-watch-settings.html +1 -1
  61. package/modules/hall-ui/public/market-watch.css +5 -0
  62. package/modules/hall-ui/public/market-watch.html +2 -2
  63. package/modules/hall-ui/public/modules.html +1 -1
  64. package/modules/hall-ui/public/my-agents.html +1 -1
  65. package/modules/hall-ui/public/my-agents.js +3 -0
  66. package/modules/hall-ui/public/panel.css +5 -0
  67. package/modules/hall-ui/public/primer.html +1 -1
  68. package/modules/hall-ui/public/profile.html +1 -1
  69. package/modules/hall-ui/public/project-settings.html +1 -1
  70. package/modules/hall-ui/public/ranks.html +1 -1
  71. package/modules/hall-ui/public/roadmap.html +1 -1
  72. package/modules/hall-ui/public/roster.html +1 -1
  73. package/modules/hall-ui/public/sessions.html +1 -1
  74. package/modules/hall-ui/public/settings-age.js +65 -0
  75. package/modules/hall-ui/public/settings.html +27 -1
  76. package/modules/hall-ui/public/settings.js +1 -0
  77. package/modules/hall-ui/public/shell.js +22 -22
  78. package/modules/hall-ui/public/street.html +1 -1
  79. package/modules/hall-ui/public/studio.html +1 -1
  80. package/modules/hall-ui/public/style.css +28 -0
  81. package/modules/hall-ui/public/task.html +1 -1
  82. package/modules/hall-ui/public/thinking.html +1 -1
  83. package/modules/hall-ui/public/tweak-editor.html +1 -1
  84. package/modules/hall-ui/public/watch.html +1 -1
  85. package/modules/hall-ui/public/work.html +1 -1
  86. package/modules/ideas/blockers.js +13 -7
  87. package/modules/lifecycle/db-claim-reads.js +1 -1
  88. package/modules/lifecycle/db-ship.js +2 -2
  89. package/modules/lifecycle/db-tasks.js +4 -4
  90. package/modules/lifecycle/migrations/lifecycle_019_pay_type_gates.sql +50 -0
  91. package/modules/lifecycle/module.json +1 -0
  92. package/modules/lifecycle/pay-type-gates.js +180 -0
  93. package/modules/lifecycle/publish-reconciler.js +31 -1
  94. package/modules/lifecycle/rebase-gate.js +3 -2
  95. package/modules/lifecycle/routes/claims.js +3 -2
  96. package/modules/lifecycle/routes/pay-type-gates.js +59 -0
  97. package/modules/lifecycle/routes/project-settings.js +4 -0
  98. package/modules/lifecycle/routes/task-write-routes.js +43 -0
  99. package/modules/lifecycle/ship-card.js +4 -1
  100. package/modules/lifecycle/task-name-db.js +55 -0
  101. package/modules/lifecycle/task-name.js +140 -0
  102. package/modules/market-watch/db.js +10 -1
  103. package/modules/market-watch/migrations/market_watch_004_entry_corrections.sql +20 -0
  104. package/modules/market-watch/pages.js +20 -1
  105. package/modules/market-watch/proposals.js +57 -7
  106. package/modules/market-watch/routes/pages.js +2 -0
  107. package/modules/market-watch/routes/rules.js +4 -0
  108. package/modules/platform-identity/terms-acceptance.js +9 -9
  109. package/modules/public-landing/public/account-google.states.json +14 -0
  110. package/modules/public-landing/public/assets/cosmos.css +12 -0
  111. package/modules/public-landing/public/assets/hub-bar.js +44 -1
  112. package/modules/public-landing/public/privacy.html +5 -1
  113. package/modules/public-landing/public/projects-google.states.json +14 -0
  114. package/modules/public-landing/public/projects.html +39 -0
  115. package/modules/public-landing/public/terms.html +12 -2
  116. package/modules/ui-design/kit/fixtures/me-google.json +10 -0
  117. package/modules/ui-design/kit/fixtures/me-sign-in-methods-google.json +4 -0
  118. package/modules/ui-design/kit/serve.js +7 -1
  119. package/package-lock.json +2 -2
  120. package/package.json +1 -1
  121. package/release-notes.json +55 -1
  122. package/scripts/bongos/claim.js +4 -2
  123. package/scripts/bongos/land-watch.js +1 -0
  124. package/scripts/bongos/report-checkout.js +30 -0
  125. package/scripts/bongos/run-unit-tests.js +11 -0
  126. package/scripts/bongos/sessions-board.js +264 -0
  127. package/scripts/bongos/ship-finish.js +1 -0
  128. package/scripts/bongos/ship-flow.js +5 -2
  129. package/scripts/bongos/strand-lease.js +5 -3
  130. package/scripts/bongos/strand-watch.js +38 -7
  131. package/scripts/gds/report-checkout.js +7 -0
  132. package/scripts/gds/sessions-board.js +7 -0
  133. package/src/bongos/agent-standing-rules.js +13 -3
  134. package/src/bongos/routes/me.js +7 -1
  135. package/src/module-api.js +1 -1
  136. package/tests/agent_standing_rules.mjs +18 -1
  137. package/tests/ai_studio_billing.mjs +13 -0
  138. package/tests/ai_studio_gemini_models.mjs +81 -0
  139. package/tests/ai_studio_requests.mjs +24 -0
  140. package/tests/blocker_detail_hydration.mjs +5 -3
  141. package/tests/blocker_tasks_named_db.mjs +74 -0
  142. package/tests/claim_gate_ci_unblock.mjs +1 -1
  143. package/tests/github_chip.mjs +217 -0
  144. package/tests/hall_ai_studio.mjs +129 -5
  145. package/tests/host_topology_skips.mjs +1 -0
  146. package/tests/hub_account_page.mjs +3 -2
  147. package/tests/landing_legal_pages.mjs +3 -2
  148. package/tests/market_watch_data_db.mjs +6 -0
  149. package/tests/market_watch_pages.mjs +87 -2
  150. package/tests/market_watch_rules.mjs +101 -5
  151. package/tests/market_watch_rules_db.mjs +42 -0
  152. package/tests/nav_permission_atoms.mjs +1 -1
  153. package/tests/pay_terms.mjs +8 -0
  154. package/tests/pay_type_gates.mjs +234 -0
  155. package/tests/pay_type_gates_db.mjs +84 -0
  156. package/tests/project_account.mjs +125 -0
  157. package/tests/project_account_db.mjs +97 -0
  158. package/tests/self_healing_start.mjs +162 -0
  159. package/tests/sessions_board.mjs +153 -0
  160. package/tests/settings_age.mjs +110 -0
  161. package/tests/stop_refcheck.mjs +40 -0
  162. package/tests/task_name.mjs +237 -0
  163. package/tests/task_name_db.mjs +79 -0
  164. package/tests/terms_acceptance.mjs +25 -3
  165. package/tests/terms_grandfather_db.mjs +11 -4
@@ -0,0 +1,234 @@
1
+ // tests/pay_type_gates.mjs — a pay type can be put on a task only after its gate is
2
+ // cleared for the project (task 1004991, goal 1000092; ADR 0385 "Pay-type gates").
3
+ //
4
+ // What is real: modules/lifecycle/pay-type-gates.js (the list, the verdict, the checker,
5
+ // the writer), the pay-type-gates router, the lifecycle tasks router (PATCH /tasks/:id)
6
+ // and the project-settings router (the standing terms). What is faked: the caller
7
+ // (requireBuilder, requireRank, requirePermission) and Postgres, a recording stand-in
8
+ // holding the gate history and the pay-term sets in memory.
9
+ //
10
+ // Run: node tests/pay_type_gates.mjs
11
+
12
+ import { strict as assert } from 'node:assert';
13
+ import { createRequire } from 'node:module';
14
+ const require = createRequire(import.meta.url);
15
+ process.env.NODE_ENV = 'test';
16
+
17
+ let gateRows = [];
18
+ let sets = [];
19
+ let terms = [];
20
+ const query = async (sql, params = []) => {
21
+ if (/^\s*(BEGIN|COMMIT|ROLLBACK)/i.test(sql)) return { rows: [] };
22
+ if (/INSERT INTO lifecycle_pay_type_gates/.test(sql)) {
23
+ const [pay_type, requirement, state, evidence_ref, decided_by] = params;
24
+ gateRows.push({ id: gateRows.length + 1, pay_type, requirement, state, evidence_ref, decided_by, decided_at: new Date(Date.UTC(2026, 9, 30, 0, gateRows.length)) });
25
+ return { rows: [] };
26
+ }
27
+ if (/FROM lifecycle_pay_type_gates/.test(sql)) {
28
+ const newest = new Map();
29
+ for (const r of gateRows.filter((x) => params[0].includes(x.pay_type))) newest.set(`${r.pay_type}/${r.requirement}`, r);
30
+ return { rows: [...newest.values()] };
31
+ }
32
+ if (/INSERT INTO lifecycle_pay_term_sets/.test(sql)) {
33
+ const row = { id: sets.length + 1, task_id: params[0], set_by: params[1], set_at: new Date() };
34
+ sets.push(row);
35
+ return { rows: [row] };
36
+ }
37
+ if (/INSERT INTO lifecycle_pay_terms/.test(sql)) {
38
+ params[1].forEach((pay_type, i) => terms.push({ set_id: params[0], pay_type, position: i }));
39
+ return { rows: [] };
40
+ }
41
+ if (/FROM lifecycle_pay_term_sets s LEFT JOIN builders/.test(sql)) return { rows: [] };
42
+ return { rows: [] };
43
+ };
44
+ const pool = { query, connect: async () => ({ query, release() {} }) };
45
+ const poolPath = require.resolve('../src/bongos/pool.js');
46
+ require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool } };
47
+
48
+ let caller = { id: 7, rank: 'archon' };
49
+ let held = [];
50
+ const api = require('../src/module-api.js');
51
+ const set = (k, v) => Object.defineProperty(api, k, { value: v, configurable: true, writable: true });
52
+ set('requireBuilder', (req, _res, next) => { req.builder = { ...caller }; next(); });
53
+ set('requireRank', (rank) => (req, res, next) => (req.builder.rank === rank ? next() : res.fail('rank_forbidden', { status: 403, message: `needs ${rank}` })));
54
+ set('requirePermission', (atom) => (req, res, next) => (held.includes(atom) ? next() : res.fail('permission_denied', { status: 403, message: `needs ${atom}` })));
55
+ set('requireActAlone', () => (_req, _res, next) => next());
56
+ set('withTx', async (fn) => fn({ query }));
57
+
58
+ const db = require('../modules/lifecycle/db.js');
59
+ const TASK = { id: 5150, title: 'a task', status: 'ready', version_id: 'BONGOS-V2' };
60
+ db.getTask = async (id) => (Number(id) === TASK.id ? { ...TASK } : null);
61
+ db.getTaskDependencies = async () => [];
62
+ db.getTaskDependents = async () => [];
63
+
64
+ const lib = require('../modules/lifecycle/pay-type-gates.js');
65
+ const payTerms = require('../modules/lifecycle/pay-terms.js');
66
+
67
+ const express = require('express');
68
+ const app = express();
69
+ app.use(express.json());
70
+ app.use((req, res, next) => {
71
+ res.fail = (code, o) => {
72
+ const opts = typeof o === 'number' ? { status: o } : (o || {});
73
+ return res.status(opts.status || 400).json({ error: code, message: opts.message });
74
+ };
75
+ next();
76
+ });
77
+ app.use('/api', require('../modules/lifecycle/routes/tasks.js')());
78
+ app.use('/api', require('../modules/lifecycle/routes/project-settings.js')());
79
+ app.use('/api', require('../modules/lifecycle/routes/pay-type-gates.js')());
80
+ const server = app.listen(0);
81
+ await new Promise((r) => server.once('listening', r));
82
+ const base = `http://127.0.0.1:${server.address().port}/api`;
83
+ const call = async (method, path, body) => {
84
+ const r = await fetch(base + path, { method, headers: { 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });
85
+ return { status: r.status, body: await r.json().catch(() => null) };
86
+ };
87
+ const reset = () => { gateRows = []; sets = []; terms = []; held = ['task.edit', 'project.settings.manage', 'payout.approve']; caller = { id: 7, rank: 'archon' }; };
88
+ const clear = (payType) => Promise.all(lib.requirementIds(payType).map((requirement) => call('PUT', `/pay-type-gates/${payType}/${requirement}`,
89
+ requirement === lib.ACKNOWLEDGEMENT ? { state: 'cleared', acknowledged: true } : { state: 'cleared', evidence_ref: `doc for ${requirement}` })));
90
+
91
+ let passed = 0;
92
+ let failed = 0;
93
+ async function t(name, fn) {
94
+ reset();
95
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
96
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.stack}`); }
97
+ }
98
+
99
+ console.log('pay-type gates (task 1004991):');
100
+
101
+ await t('every pay type a term can carry has a requirement list, and nothing else does', () => {
102
+ assert.deepEqual(Object.keys(lib.REQUIREMENTS).sort(), [...payTerms.PAY_TYPES].sort());
103
+ for (const p of payTerms.PAY_TYPES) assert.ok(lib.requirementIds(p).length > 0, `${p} has requirements`);
104
+ assert.deepEqual(lib.requirementIds('self_managed'), ['bongos_moves_and_guarantees_nothing']);
105
+ assert.ok(!('eu_labour' in lib.REQUIREMENTS), 'eu_labour has no list to clear');
106
+ });
107
+
108
+ await t('a pay term whose gate is not cleared is refused, and nothing is written', async () => {
109
+ const r = await call('PATCH', `/tasks/${TASK.id}`, { pay_terms: [{ pay_type: 'cash', amount: '500', unit: 'USD' }] });
110
+ assert.equal(r.status, 409);
111
+ assert.equal(r.body.error, 'pay_type_gate_not_cleared');
112
+ assert.match(r.body.message, /processor_onboarding/);
113
+ assert.deepEqual([sets.length, terms.length], [0, 0]);
114
+ });
115
+
116
+ await t('the project\'s standing terms obey the same gate', async () => {
117
+ const r = await call('PATCH', '/project-settings/pay-terms', { terms: [{ pay_type: 'equity' }] });
118
+ assert.equal(r.status, 409);
119
+ assert.equal(r.body.error, 'pay_type_gate_not_cleared');
120
+ assert.equal(sets.length, 0);
121
+ });
122
+
123
+ await t('a half-cleared gate still refuses; the last requirement opens it', async () => {
124
+ const [first, ...rest] = lib.requirementIds('cash');
125
+ assert.equal((await call('PUT', `/pay-type-gates/cash/${first}`, { state: 'cleared', evidence_ref: 'processor verdict 41' })).status, 200);
126
+ let r = await call('PATCH', `/tasks/${TASK.id}`, { pay_terms: [{ pay_type: 'cash' }] });
127
+ assert.equal(r.status, 409);
128
+ for (const req of rest) await call('PUT', `/pay-type-gates/cash/${req}`, { state: 'cleared', evidence_ref: 'on file' });
129
+ r = await call('PATCH', `/tasks/${TASK.id}`, { pay_terms: [{ pay_type: 'cash', amount: '500', unit: 'USD' }] });
130
+ assert.equal(r.status, 200);
131
+ assert.equal(terms.length, 1);
132
+ });
133
+
134
+ await t('revoking a requirement closes the gate again', async () => {
135
+ await clear('cash');
136
+ const req = lib.requirementIds('cash')[0];
137
+ assert.equal((await call('PUT', `/pay-type-gates/cash/${req}`, { state: 'revoked', evidence_ref: 'processor paused us' })).status, 200);
138
+ const r = await call('PATCH', `/tasks/${TASK.id}`, { pay_terms: [{ pay_type: 'cash' }] });
139
+ assert.equal(r.status, 409);
140
+ });
141
+
142
+ await t('self_managed clears with only the acknowledgement', async () => {
143
+ const req = lib.ACKNOWLEDGEMENT;
144
+ let r = await call('PUT', `/pay-type-gates/self_managed/${req}`, { state: 'cleared', evidence_ref: 'anything' });
145
+ assert.equal(r.status, 400, 'evidence alone is not the acknowledgement');
146
+ r = await call('PUT', `/pay-type-gates/self_managed/${req}`, { state: 'cleared', acknowledged: true });
147
+ assert.equal(r.status, 200);
148
+ assert.equal(r.body.gate.cleared, true);
149
+ assert.equal(r.body.gate.requirements[0].evidence_ref, payTerms.SELF_MANAGED_LABEL, 'the label is what was acknowledged');
150
+ const t1 = await call('PATCH', `/tasks/${TASK.id}`, { pay_terms: [{ pay_type: 'self_managed' }] });
151
+ assert.equal(t1.status, 200);
152
+ });
153
+
154
+ await t('eu_labour can never be cleared, by any route, and never reaches a term', async () => {
155
+ const w = await call('PUT', '/pay-type-gates/eu_labour/anything', { state: 'cleared', evidence_ref: 'counsel says so' });
156
+ assert.equal(w.status, 409);
157
+ assert.equal(w.body.error, 'pay_type_never_clearable');
158
+ assert.equal(gateRows.length, 0, 'no row written');
159
+ const p = await call('PATCH', `/tasks/${TASK.id}`, { pay_terms: [{ pay_type: 'eu_labour' }] });
160
+ assert.equal(p.status, 400);
161
+ assert.equal(sets.length, 0);
162
+ const all = (await call('GET', '/pay-type-gates')).body.gates.find((g) => g.pay_type === 'eu_labour');
163
+ assert.deepEqual([all.cleared, all.never], [false, true]);
164
+ assert.deepEqual(lib.gateVerdict('eu_labour', [{ pay_type: 'eu_labour', requirement: 'x', state: 'cleared' }]).cleared, false);
165
+ });
166
+
167
+ await t('a forged client request that claims a cleared gate is refused', async () => {
168
+ for (const forged of [
169
+ { pay_type: 'cash', gate_cleared: true },
170
+ { pay_type: 'cash', cleared: true, cleared_by: 7 },
171
+ { pay_type: 'cash', gate: { state: 'cleared' } },
172
+ ]) {
173
+ const r = await call('PATCH', `/tasks/${TASK.id}`, { pay_terms: [forged] });
174
+ assert.equal(r.status, 400, JSON.stringify(forged));
175
+ }
176
+ const top = await call('PATCH', `/tasks/${TASK.id}`, { gate_cleared: true, pay_terms: [{ pay_type: 'cash' }] });
177
+ assert.ok(top.status >= 400, 'a top-level claim does not open it either');
178
+ assert.deepEqual([sets.length, terms.length, gateRows.length], [0, 0, 0]);
179
+ });
180
+
181
+ await t('only the owner records a requirement, and who recorded it is the caller, not the body', async () => {
182
+ const req = lib.requirementIds('cash')[0];
183
+ caller = { id: 9, rank: 'metic' }; held = held.filter((a) => a !== 'payout.approve');
184
+ let r = await call('PUT', `/pay-type-gates/cash/${req}`, { state: 'cleared', evidence_ref: 'x' });
185
+ assert.equal(r.status, 403);
186
+ assert.equal(gateRows.length, 0);
187
+ caller = { id: 7, rank: 'archon' }; held = [...held, 'payout.approve'];
188
+ r = await call('PUT', `/pay-type-gates/cash/${req}`, { state: 'cleared', evidence_ref: 'x', decided_by: 99 });
189
+ assert.equal(r.status, 400, 'a recorder in the body is refused');
190
+ r = await call('PUT', `/pay-type-gates/cash/${req}`, { state: 'cleared', evidence_ref: 'x' });
191
+ assert.equal(r.status, 200);
192
+ assert.equal(gateRows[0].decided_by, 7);
193
+ assert.equal(r.body.gate.requirements[0].cleared_by, 7);
194
+ });
195
+
196
+ await t('a non-owner sees met or missing, never the evidence or who cleared it', async () => {
197
+ await clear('cash');
198
+ const owner = (await call('GET', '/pay-type-gates')).body.gates.find((g) => g.pay_type === 'cash');
199
+ assert.ok(owner.requirements[0].evidence_ref && owner.requirements[0].cleared_by, 'the owner sees both');
200
+ caller = { id: 9, rank: 'metic' }; held = held.filter((a) => a !== 'payout.approve');
201
+ const other = (await call('GET', '/pay-type-gates')).body.gates.find((g) => g.pay_type === 'cash');
202
+ assert.equal(other.cleared, true);
203
+ for (const r of other.requirements) {
204
+ assert.equal(r.met, true);
205
+ assert.ok(!('evidence_ref' in r) && !('cleared_by' in r) && !('cleared_at' in r));
206
+ }
207
+ });
208
+
209
+ await t('a write needs a known requirement, a state and evidence', async () => {
210
+ const req = lib.requirementIds('cash')[0];
211
+ assert.equal((await call('PUT', '/pay-type-gates/wages/x', { state: 'cleared', evidence_ref: 'x' })).status, 404);
212
+ assert.equal((await call('PUT', '/pay-type-gates/cash/not_a_requirement', { state: 'cleared', evidence_ref: 'x' })).status, 404);
213
+ assert.equal((await call('PUT', `/pay-type-gates/cash/${req}`, { state: 'maybe', evidence_ref: 'x' })).status, 400);
214
+ assert.equal((await call('PUT', `/pay-type-gates/cash/${req}`, { state: 'cleared', evidence_ref: ' ' })).status, 400);
215
+ assert.equal((await call('PUT', `/pay-type-gates/cash/${req}`, { state: 'cleared', evidence_ref: 'x'.repeat(501) })).status, 400);
216
+ assert.equal(gateRows.length, 0);
217
+ });
218
+
219
+ await t('a stray row for a requirement not on the list cannot clear a gate', () => {
220
+ const rows = [{ pay_type: 'cash', requirement: 'made_up', state: 'cleared', evidence_ref: 'x', decided_by: 1, decided_at: new Date() }];
221
+ assert.equal(lib.gateVerdict('cash', rows).cleared, false);
222
+ });
223
+
224
+ await t('no terms means no gate read; clearing every type then lets a mixed list through', async () => {
225
+ const r0 = await call('PATCH', `/tasks/${TASK.id}`, { pay_terms: [] });
226
+ assert.equal(r0.status, 200);
227
+ for (const p of payTerms.PAY_TYPES) await clear(p);
228
+ const all = await call('PATCH', `/tasks/${TASK.id}`, { pay_terms: payTerms.PAY_TYPES.map((pay_type) => ({ pay_type })) });
229
+ assert.equal(all.status, 200);
230
+ });
231
+
232
+ console.log(`\n${passed} passed, ${failed} failed`);
233
+ server.close();
234
+ process.exit(failed ? 1 : 0);
@@ -0,0 +1,84 @@
1
+ // tests/pay_type_gates_db.mjs — task 1004991 (ADR 0385 "Pay-type gates"). Real DB.
2
+ // The gate table built from the real migration (lifecycle_019) in a throwaway schema,
3
+ // written and read through the real modules/lifecycle/pay-type-gates.js, proving the
4
+ // DATABASE holds the rules the DB-free tests/pay_type_gates.mjs assumes: the history
5
+ // cannot be edited or deleted, a row for eu_labour or an unknown type is refused, a
6
+ // state or evidence the writer would never send is refused, and the read SQL resolves
7
+ // the newest record per requirement into a verdict.
8
+ // Self-skips without a reachable Postgres.
9
+ // Run: DATABASE_URL=... node tests/pay_type_gates_db.mjs
10
+ import { createRequire } from 'node:module';
11
+ import { readFileSync } from 'node:fs';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { dirname, join } from 'node:path';
14
+ import { strict as assert } from 'node:assert';
15
+ const require = createRequire(import.meta.url);
16
+ const { Pool } = require('pg');
17
+ const here = dirname(fileURLToPath(import.meta.url));
18
+
19
+ const pgPool = new Pool({ connectionString: process.env.DATABASE_URL || undefined, max: 1 });
20
+ let client;
21
+ try { client = await pgPool.connect(); } catch (e) {
22
+ console.log(`pay_type_gates_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
23
+ await pgPool.end().catch(() => {});
24
+ process.exit(0);
25
+ }
26
+ const SCHEMA = `t1004991_${process.pid}`;
27
+ let pass = 0;
28
+ const ok = (c, m) => { assert.ok(c, m); pass++; console.log(` ok - ${m}`); };
29
+ const refused = async (sql, params, m) => {
30
+ await client.query('SAVEPOINT s');
31
+ let threw = false;
32
+ try { await client.query(sql, params); } catch { threw = true; }
33
+ await client.query('ROLLBACK TO SAVEPOINT s');
34
+ ok(threw, m);
35
+ };
36
+
37
+ try {
38
+ await client.query(`CREATE SCHEMA ${SCHEMA}`);
39
+ await client.query(`SET search_path TO ${SCHEMA}`);
40
+ await client.query('CREATE TABLE builders (id bigserial PRIMARY KEY, github_login text NOT NULL)');
41
+ await client.query(readFileSync(join(here, '..', 'modules', 'lifecycle', 'migrations', 'lifecycle_019_pay_type_gates.sql'), 'utf8'));
42
+ await client.query("INSERT INTO builders (github_login) VALUES ('owner'), ('lead')");
43
+ await client.query('BEGIN');
44
+
45
+ const lib = require('../modules/lifecycle/pay-type-gates.js');
46
+ const o = { db: client };
47
+ const rec = (payType, requirement, state, evidenceRef, builderId = 1) =>
48
+ lib.recordRequirement({ payType, requirement, state, evidenceRef, builderId }, o);
49
+
50
+ let v = await rec('self_managed', lib.ACKNOWLEDGEMENT, 'cleared', 'acknowledged');
51
+ ok(v.cleared === true && Number(v.requirements[0].cleared_by) === 1, 'the one self-managed requirement clears its gate, naming who cleared it');
52
+
53
+ for (const req of lib.requirementIds('cash')) v = await rec('cash', req, 'cleared', `ref ${req}`, 2);
54
+ ok(v.cleared === true, 'every cash requirement cleared opens the gate');
55
+ v = await rec('cash', 'tax_details', 'revoked', 'details withdrawn');
56
+ ok(v.cleared === false && v.missing.join() === 'tax_details', 'the newest record per requirement wins: a revoke closes it again');
57
+ v = await rec('cash', 'tax_details', 'cleared', 'collected again');
58
+ ok(v.cleared === true, 'a later clearing reopens it');
59
+
60
+ const refusal = await lib.refuseUnclearedGates([{ pay_type: 'equity' }, { pay_type: 'cash' }], o);
61
+ ok(refusal && refusal.pay_type === 'equity', 'the checker names the first type whose gate is open');
62
+ ok((await lib.refuseUnclearedGates([{ pay_type: 'cash' }, { pay_type: 'self_managed' }], o)) === null, 'cleared types pass');
63
+
64
+ const all = await lib.readGates(o);
65
+ ok(all.length === 7 && all.find((g) => g.pay_type === 'eu_labour').never === true, 'the owner view lists eu_labour as never clearable');
66
+
67
+ const { rows: [aRow] } = await client.query('SELECT id FROM lifecycle_pay_type_gates LIMIT 1');
68
+ await refused('UPDATE lifecycle_pay_type_gates SET state = $1 WHERE id = $2', ['revoked', aRow.id], 'a record cannot be edited');
69
+ await refused('DELETE FROM lifecycle_pay_type_gates WHERE id = $1', [aRow.id], 'a record cannot be deleted');
70
+ await refused("INSERT INTO lifecycle_pay_type_gates (pay_type, requirement, state, evidence_ref, decided_by) VALUES ('eu_labour', 'x', 'cleared', 'y', 1)", [], 'the database refuses any row for eu_labour');
71
+ await refused("INSERT INTO lifecycle_pay_type_gates (pay_type, requirement, state, evidence_ref, decided_by) VALUES ('wages', 'x', 'cleared', 'y', 1)", [], 'an unknown pay type is refused');
72
+ await refused("INSERT INTO lifecycle_pay_type_gates (pay_type, requirement, state, evidence_ref, decided_by) VALUES ('cash', 'x', 'pending', 'y', 1)", [], 'a state other than cleared or revoked is refused');
73
+ await refused("INSERT INTO lifecycle_pay_type_gates (pay_type, requirement, state, evidence_ref, decided_by) VALUES ('cash', 'x', 'cleared', '', 1)", [], 'a record with no evidence reference is refused');
74
+ await refused("INSERT INTO lifecycle_pay_type_gates (pay_type, requirement, state, evidence_ref, decided_by) VALUES ('cash', 'x', 'cleared', 'y', 999)", [], 'a recorder who is not a builder is refused');
75
+
76
+ await client.query('ROLLBACK');
77
+ console.log(`pay_type_gates_db: ${pass} passed`);
78
+ } finally {
79
+ await client.query('ROLLBACK').catch(() => {});
80
+ await client.query('SET search_path TO public').catch(() => {});
81
+ await client.query(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`).catch(() => {});
82
+ client.release();
83
+ await pgPool.end();
84
+ }
@@ -0,0 +1,125 @@
1
+ // tests/project_account.mjs — the project account's record and view (task 1003182,
2
+ // goal 1000092; ADR 0385, ADR 0397). Behaviour of modules/economy/project-account.js:
3
+ // - every movement names its cause, and a missing cause is refused before the INSERT;
4
+ // - the direction is fixed by the kind, so a row cannot disagree with itself;
5
+ // - a retried processor reference books nothing the second time;
6
+ // - the view sums the record, shows what is held, and names drift only when the
7
+ // processor's own figure is known; an unreadable processor never blocks the view.
8
+ // The SQL itself is proven against real Postgres in tests/project_account_db.mjs.
9
+
10
+ import { test } from 'node:test';
11
+ import { strict as assert } from 'node:assert';
12
+ import { createRequire } from 'node:module';
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const A = require('../modules/economy/project-account.js');
16
+
17
+ const refusal = (input) => { try { A.checkMovement(input); } catch (e) { return e; } return null; };
18
+
19
+ test('a payout must name the builder, the task and the processor transfer', () => {
20
+ const full = { kind: 'payout', amountMicro: 5_000_000, builderId: 7, taskId: 99, processorRef: 'tr_1' };
21
+ assert.equal(refusal(full), null);
22
+ for (const missing of ['builderId', 'taskId', 'processorRef']) {
23
+ const e = refusal({ ...full, [missing]: undefined });
24
+ assert.ok(e instanceof A.MovementRefusal && e.code === 'cause_missing', `no ${missing} must be refused`);
25
+ }
26
+ });
27
+
28
+ test('funding and fees need a processor reference; a return needs the builder too', () => {
29
+ assert.equal(refusal({ kind: 'funding', amountMicro: 1, processorRef: 'ch_1' }), null);
30
+ assert.equal(refusal({ kind: 'funding', amountMicro: 1 }).code, 'cause_missing');
31
+ assert.equal(refusal({ kind: 'fee', amountMicro: 1 }).code, 'cause_missing');
32
+ assert.equal(refusal({ kind: 'payout_return', amountMicro: 1, processorRef: 'tr_1' }).code, 'cause_missing');
33
+ assert.equal(refusal({ kind: 'payout_return', amountMicro: 1, processorRef: 'tr_1', builderId: 7 }), null);
34
+ });
35
+
36
+ test('a correction names the movement it corrects, who made it, and why', () => {
37
+ const adj = { kind: 'adjustment', direction: 'in', amountMicro: 10, correctsId: 4, actorId: 1, note: 'fee booked twice' };
38
+ assert.equal(refusal(adj), null);
39
+ assert.equal(refusal({ ...adj, correctsId: undefined }).code, 'cause_missing');
40
+ assert.equal(refusal({ ...adj, note: ' ' }).code, 'cause_missing');
41
+ assert.equal(refusal({ ...adj, direction: 'sideways' }).code, 'bad_direction');
42
+ });
43
+
44
+ test('a hold is for a task; the amount is a positive whole number', () => {
45
+ assert.equal(refusal({ kind: 'hold', amountMicro: 5 }).code, 'cause_missing');
46
+ assert.equal(refusal({ kind: 'hold', amountMicro: 5, taskId: 3 }), null);
47
+ for (const bad of [0, -1, 1.5, NaN, 'x']) assert.equal(refusal({ kind: 'hold', taskId: 3, amountMicro: bad }).code, 'bad_amount');
48
+ assert.equal(refusal({ kind: 'spend', amountMicro: 5 }).code, 'bad_kind');
49
+ });
50
+
51
+ test('the direction comes from the kind, never from the caller', () => {
52
+ const row = A.checkMovement({ kind: 'payout', amountMicro: 5, builderId: 1, taskId: 2, processorRef: 't', direction: 'in' });
53
+ assert.equal(row.direction, 'out');
54
+ assert.equal(A.checkMovement({ kind: 'funding', amountMicro: 5, processorRef: 'c' }).direction, 'in');
55
+ assert.equal(A.checkMovement({ kind: 'hold', amountMicro: 5, taskId: 1 }).direction, 'none');
56
+ });
57
+
58
+ // A stand-in for the table that enforces the one-per-(kind, reference) rule the way the
59
+ // unique index does, so the retry path through recordMovement is exercised for real.
60
+ function fakeTable() {
61
+ const rows = [];
62
+ return {
63
+ rows,
64
+ async query(sql, p) {
65
+ if (/^\s*INSERT/.test(sql)) {
66
+ const [kind, direction, amount_micro, builder_id, task_id, corrects_id, processor_ref] = p;
67
+ if (processor_ref && rows.some((r) => r.kind === kind && r.processor_ref === processor_ref)) return { rows: [] };
68
+ const row = { id: rows.length + 1, kind, direction, amount_micro, builder_id, task_id, corrects_id, processor_ref };
69
+ rows.push(row);
70
+ return { rows: [row] };
71
+ }
72
+ return { rows: rows.filter((r) => r.kind === p[0] && r.processor_ref === p[1]) };
73
+ },
74
+ };
75
+ }
76
+
77
+ test('a retried payout books nothing the second time and hands back the first row', async () => {
78
+ const db = fakeTable();
79
+ const input = { kind: 'payout', amountMicro: 5_000_000, builderId: 7, taskId: 99, processorRef: 'tr_1' };
80
+ const first = await A.recordMovement(db, input);
81
+ const again = await A.recordMovement(db, input);
82
+ assert.equal(first.recorded, true);
83
+ assert.equal(again.recorded, false);
84
+ assert.equal(again.row.id, first.row.id);
85
+ assert.equal(db.rows.length, 1);
86
+ });
87
+
88
+ test('a refused movement never reaches the database', async () => {
89
+ const db = fakeTable();
90
+ await assert.rejects(() => A.recordMovement(db, { kind: 'payout', amountMicro: 5 }), A.MovementRefusal);
91
+ assert.equal(db.rows.length, 0);
92
+ });
93
+
94
+ // ---- the view ---------------------------------------------------------------
95
+
96
+ const totals = { recorded_balance_micro: '7000000', in_micro: '10000000', out_micro: '3000000', held_micro: '2000000', movements: '4' };
97
+
98
+ test('available is the recorded balance less what is held, and the account holds no money', () => {
99
+ const v = A.shapeAccount(totals, [], null);
100
+ assert.equal(v.recorded_balance_micro, 7_000_000);
101
+ assert.equal(v.held_micro, 2_000_000);
102
+ assert.equal(v.available_micro, 5_000_000);
103
+ assert.equal(v.holds_money, false);
104
+ });
105
+
106
+ test('drift is named only when the processor figure is known', () => {
107
+ assert.equal(A.shapeAccount(totals, [], null).drift_micro, null);
108
+ assert.equal(A.shapeAccount(totals, [], null).processor_balance_micro, null);
109
+ const v = A.shapeAccount(totals, [], 6_500_000);
110
+ assert.equal(v.processor_balance_micro, 6_500_000);
111
+ assert.equal(v.drift_micro, -500_000);
112
+ assert.equal(A.shapeAccount(totals, [], 0).drift_micro, -7_000_000, 'a processor balance of zero is a real figure, not "unknown"');
113
+ });
114
+
115
+ test('readAccount reads the record and survives an unreadable processor', async () => {
116
+ const db = { async query(sql) { return /SUM\(/.test(sql) ? { rows: [totals] } : { rows: [{ id: 1, kind: 'funding', amount_micro: '10000000' }] }; } };
117
+ const ok = await A.readAccount(db, { processor: { balanceMicro: async () => 7_000_000 } });
118
+ assert.equal(ok.drift_micro, 0);
119
+ assert.equal(ok.movements[0].amount_micro, 10_000_000);
120
+ const down = await A.readAccount(db, { processor: { balanceMicro: async () => { throw new Error('processor down'); } } });
121
+ assert.equal(down.processor_balance_micro, null);
122
+ assert.match(down.processor_error, /could not be read/);
123
+ assert.doesNotMatch(down.processor_error, /processor down/, 'the raw processor error never reaches the client');
124
+ assert.equal(down.recorded_balance_micro, 7_000_000, 'the record still answers');
125
+ });
@@ -0,0 +1,97 @@
1
+ // tests/project_account_db.mjs — task 1003182 (goal 1000092). Real DB.
2
+ //
3
+ // Applies the REAL modules/economy/migrations/economy_002_project_account.sql and proves what
4
+ // Postgres itself refuses: a payout with no task, a funding with no processor reference, an
5
+ // adjustment that corrects nothing, a row whose direction contradicts its kind, an UPDATE or
6
+ // DELETE of any row, and a second movement for the same processor reference; then that the real
7
+ // readAccount sums the record. Two rules are probed by dropping them (the cause rule and the
8
+ // append-only trigger): with the rule gone the same write lands, so the refusal was the rule's.
9
+ //
10
+ // Self-skips without a reachable Postgres.
11
+ // Run: DATABASE_URL=... node tests/project_account_db.mjs
12
+ import { createRequire } from 'node:module';
13
+ import { readFileSync } from 'node:fs';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { dirname, join } from 'node:path';
16
+ import { strict as assert } from 'node:assert';
17
+ const require = createRequire(import.meta.url);
18
+ const { Pool } = require('pg');
19
+ const A = require('../modules/economy/project-account.js');
20
+ const here = dirname(fileURLToPath(import.meta.url));
21
+ const MIGRATION = readFileSync(join(here, '..', 'modules', 'economy', 'migrations', 'economy_002_project_account.sql'), 'utf8');
22
+
23
+ const pgPool = new Pool({ connectionString: process.env.DATABASE_URL || undefined, max: 1 });
24
+ let client;
25
+ try { client = await pgPool.connect(); } catch (e) {
26
+ console.log(`project_account_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
27
+ await pgPool.end().catch(() => {});
28
+ process.exit(0);
29
+ }
30
+ const SCHEMA = `t1003182_${process.pid}`;
31
+ let pass = 0;
32
+ const ok = (c, m) => { assert.ok(c, m); pass++; console.log(` ok - ${m}`); };
33
+ const T = 'economy_project_account_movements';
34
+ const rejects = async (sql, params) => {
35
+ await client.query('SAVEPOINT s');
36
+ let err = null;
37
+ try { await client.query(sql, params); } catch (e) { err = e; }
38
+ await client.query('ROLLBACK TO SAVEPOINT s');
39
+ return err;
40
+ };
41
+ const INS = `INSERT INTO ${T} (kind, direction, amount_micro, builder_id, task_id, corrects_id, processor_ref, actor_id, note)
42
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`;
43
+
44
+ try {
45
+ await client.query(`CREATE SCHEMA ${SCHEMA}`);
46
+ await client.query(`SET search_path TO ${SCHEMA}`);
47
+ await client.query('CREATE TABLE builders (id bigserial PRIMARY KEY, display_name text)');
48
+ await client.query('CREATE TABLE tasks (id bigserial PRIMARY KEY, title text)');
49
+ await client.query('CREATE TABLE schema_migrations (version text PRIMARY KEY)');
50
+ await client.query("INSERT INTO builders (display_name) VALUES ('Ada'), ('Owner')");
51
+ await client.query("INSERT INTO tasks (title) VALUES ('Build the thing')");
52
+ await client.query(MIGRATION);
53
+ await client.query('BEGIN');
54
+
55
+ const code = (e) => e && e.code;
56
+ ok(code(await rejects(INS, ['payout', 'out', 5, 1, null, null, 'tr_x', null, ''])) === '23514', 'a payout with no task is refused by the database');
57
+ ok(code(await rejects(INS, ['payout', 'out', 5, null, 1, null, 'tr_x', null, ''])) === '23514', 'a payout with no builder is refused');
58
+ ok(code(await rejects(INS, ['payout', 'out', 5, 1, 1, null, null, null, ''])) === '23514', 'a payout with no processor transfer is refused');
59
+ ok(code(await rejects(INS, ['funding', 'in', 5, null, null, null, null, null, ''])) === '23514', 'a funding with no processor reference is refused');
60
+ ok(code(await rejects(INS, ['adjustment', 'in', 5, null, null, null, null, 2, 'why'])) === '23514', 'an adjustment that corrects nothing is refused');
61
+ ok(code(await rejects(INS, ['payout', 'in', 5, 1, 1, null, 'tr_x', null, ''])) === '23514', 'a payout that says it came IN is refused');
62
+ ok(code(await rejects(INS, ['funding', 'in', 0, null, null, null, 'ch_x', null, ''])) === '23514', 'a zero amount is refused');
63
+ ok(code(await rejects(INS, ['spend', 'out', 5, null, null, null, 'x', null, ''])) === '23514', 'an unknown kind is refused');
64
+
65
+ // the real writer, through the real read
66
+ const q = { query: (s, p) => client.query(s, p) };
67
+ await A.recordMovement(q, { kind: 'funding', amountMicro: 10_000_000, processorRef: 'ch_1' });
68
+ const pay = await A.recordMovement(q, { kind: 'payout', amountMicro: 3_000_000, builderId: 1, taskId: 1, processorRef: 'tr_1' });
69
+ await A.recordMovement(q, { kind: 'hold', amountMicro: 2_000_000, taskId: 1 });
70
+ const dup = await A.recordMovement(q, { kind: 'payout', amountMicro: 3_000_000, builderId: 1, taskId: 1, processorRef: 'tr_1' });
71
+ ok(pay.recorded && !dup.recorded, 'the same processor transfer books once; the retry is a no-op');
72
+ const v = await A.readAccount(q, { processor: { balanceMicro: async () => 7_000_000 } });
73
+ ok(v.recorded_balance_micro === 7_000_000 && v.held_micro === 2_000_000 && v.available_micro === 5_000_000, 'the view sums the record and shows what is held');
74
+ ok(v.drift_micro === 0 && v.movement_count === 3, 'no drift when the processor agrees');
75
+ const named = v.movements.find((m) => m.kind === 'payout');
76
+ ok(named.builder_name === 'Ada' && named.task_title === 'Build the thing', 'a payout row shows who was paid and for which task');
77
+
78
+ await A.recordMovement(q, { kind: 'adjustment', direction: 'in', amountMicro: 3_000_000, correctsId: pay.row.id, actorId: 2, note: 'payout returned by the processor' });
79
+ const after = await A.readAccount(q);
80
+ ok(after.recorded_balance_micro === 10_000_000, 'a correction is a new row that moves the balance');
81
+
82
+ ok(/append-only/.test((await rejects(`UPDATE ${T} SET amount_micro = 1`)).message), 'an UPDATE is refused');
83
+ ok(/append-only/.test((await rejects(`DELETE FROM ${T}`)).message), 'a DELETE is refused');
84
+
85
+ await client.query(`DROP TRIGGER economy_project_account_movements_no_rewrite ON ${T}`);
86
+ ok((await rejects(`UPDATE ${T} SET amount_micro = 1`)) === null, 'without the trigger the UPDATE lands (the probe)');
87
+ // probe: with the cause rule dropped the same payout lands, so the refusals above were real
88
+ await client.query(`ALTER TABLE ${T} DROP CONSTRAINT economy_project_account_cause_chk`);
89
+ ok((await rejects(INS, ['payout', 'out', 5, null, null, null, 'tr_probe', null, ''])) === null, 'without the cause rule the causeless payout lands (the probe)');
90
+ await client.query('ROLLBACK');
91
+ console.log(`project_account_db.mjs: ${pass} passed`);
92
+ } finally {
93
+ await client.query('ROLLBACK').catch(() => {});
94
+ await client.query(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`).catch(() => {});
95
+ client.release();
96
+ await pgPool.end().catch(() => {});
97
+ }