@bongos/core 1.24.23 → 1.24.25

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 (82) hide show
  1. package/.bongos-core.json +212 -57
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +42 -0
  4. package/clients/bongos-client/index.cjs +42 -0
  5. package/clients/bongos-client/index.d.ts +55 -1
  6. package/clients/bongos-client/index.mjs +42 -0
  7. package/docs/api/openapi.json +1181 -207
  8. package/docs/api-reference.md +30 -5
  9. package/docs/architecture.md +42 -0
  10. package/docs/copy-inventory.md +18 -8
  11. package/docs/copy-registry.json +99 -9
  12. package/docs/module-api-changelog.md +4 -0
  13. package/docs/page-readings.json +64 -25
  14. package/migrations/core_291_payout_nda_tax.sql +75 -0
  15. package/migrations/core_292_module_sale_payouts.sql +170 -0
  16. package/migrations/core_293_cash_outs.sql +184 -0
  17. package/migrations/core_294_cash_out_attempts_sent_by_time.sql +21 -0
  18. package/modules/economy/ledger-check.js +360 -0
  19. package/modules/economy/migrations/economy_003_user_balances.sql +116 -0
  20. package/modules/economy/migrations/economy_004_module_share_payouts.sql +22 -0
  21. package/modules/economy/migrations/economy_005_project_account_holds_index.sql +20 -0
  22. package/modules/economy/migrations/economy_006_ledger_checks.sql +68 -0
  23. package/modules/economy/module.json +1 -1
  24. package/modules/economy/outflow.js +209 -0
  25. package/modules/economy/pollers/ledger-check.js +87 -0
  26. package/modules/economy/project-account.js +92 -7
  27. package/modules/economy/routes/balances.js +79 -0
  28. package/modules/economy/routes/project-account.js +40 -2
  29. package/modules/economy/user-balances.js +308 -0
  30. package/modules/government/decision-rules.js +1 -0
  31. package/modules/hall-ui/public/project-settings.css +18 -0
  32. package/modules/hall-ui/public/project-settings.html +15 -2
  33. package/modules/hall-ui/public/project-settings.js +142 -0
  34. package/modules/ideas/module.json +2 -1
  35. package/modules/ideas/routes/blockers.js +26 -0
  36. package/modules/lifecycle/db-claims.js +22 -0
  37. package/modules/lifecycle/db-ship.js +5 -5
  38. package/modules/lifecycle/funding-wall.js +253 -0
  39. package/modules/lifecycle/module.json +2 -1
  40. package/modules/lifecycle/routes/claims.js +9 -0
  41. package/modules/lifecycle/routes/pay-type-gates.js +7 -0
  42. package/modules/lifecycle/routes/project-settings.js +59 -2
  43. package/package-lock.json +2 -2
  44. package/package.json +1 -1
  45. package/release-notes.json +44 -0
  46. package/scripts/bongos/government-walk.js +1 -0
  47. package/scripts/bongos/plain-cards.js +18 -0
  48. package/scripts/bongos/run-unit-tests.js +28 -0
  49. package/src/bongos/builder-payouts.js +6 -1
  50. package/src/bongos/cash-outs.js +399 -0
  51. package/src/bongos/module-payouts.js +432 -0
  52. package/src/bongos/package-registry.js +3 -2
  53. package/src/bongos/payout-approvals.js +5 -2
  54. package/src/bongos/payout-gates.js +44 -7
  55. package/src/bongos/project-settings.js +2 -0
  56. package/src/bongos/routes/core-update.js +11 -3
  57. package/src/bongos/routes/module-payouts.js +114 -0
  58. package/src/bongos/routes/my-cash-outs.js +103 -0
  59. package/src/bongos/routes/payout-gates.js +42 -1
  60. package/src/bongos/routes.js +6 -0
  61. package/src/bongos/software-update.js +36 -16
  62. package/src/module-api.js +1 -1
  63. package/tests/builder_payouts_db.mjs +1 -0
  64. package/tests/cash_outs.mjs +96 -0
  65. package/tests/cash_outs_db.mjs +205 -0
  66. package/tests/funded_project_pays_proof_db.mjs +181 -0
  67. package/tests/funding_wall.mjs +205 -0
  68. package/tests/funding_wall_db.mjs +174 -0
  69. package/tests/government_act_alone_buildings.mjs +1 -0
  70. package/tests/ledger_check.mjs +189 -0
  71. package/tests/ledger_check_db.mjs +119 -0
  72. package/tests/module_payouts.mjs +295 -0
  73. package/tests/module_payouts_db.mjs +140 -0
  74. package/tests/own_scope_guard.mjs +5 -0
  75. package/tests/payout_gates.mjs +67 -6
  76. package/tests/payout_gates_db.mjs +23 -1
  77. package/tests/project_account_db.mjs +5 -2
  78. package/tests/project_outflow.mjs +103 -0
  79. package/tests/project_outflow_db.mjs +100 -0
  80. package/tests/software_update.mjs +61 -3
  81. package/tests/user_balances.mjs +241 -0
  82. package/tests/user_balances_db.mjs +137 -0
@@ -31,6 +31,7 @@ const { asyncHandler } = require('./_helpers');
31
31
  const auth = require('../auth');
32
32
  const { readCoreUpdate, readCoreUpgrades } = require('../core-update');
33
33
  const { readSoftwareUpdate } = require('../software-update');
34
+ const { followForShape } = require('../../../scripts/bongos/update-channel.js');
34
35
  const seams = require('../../module-seams');
35
36
  const { loadIdpConfig } = require('../auth-config');
36
37
  const upgradeRequest = require('../upgrade-request');
@@ -88,6 +89,13 @@ function updateDoor({ provisioning, idp, hubUpgradeUrl }) {
88
89
  return url ? { kind: 'hub', url } : { kind: 'cli', url: null };
89
90
  }
90
91
 
92
+ // WHICH PUBLISHED VERSIONS THIS HALL MAY BE OFFERED (task 1004593, ADR 0361 D5): the hall that
93
+ // runs the door is the platform's own, the one project that takes every candidate; every other
94
+ // project follows releases, as the runner and /deploy offer them. followForShape is the runner's.
95
+ function followFor(door) {
96
+ return followForShape(door && door.kind === 'local' ? 'control-plane' : null);
97
+ }
98
+
91
99
  function safeHubUpgradeUrl() {
92
100
  try { return require('../../instance-config').resolveEnv('HUB_UPGRADE_URL') || null; } catch { return null; }
93
101
  }
@@ -327,12 +335,12 @@ function buildCoreUpdateRouter({
327
335
  asyncHandler('GET /software-update', async (req, res) => {
328
336
  let running = null;
329
337
  try { running = require('../../module-api').CORE_VERSION; } catch { /* version unknown */ }
338
+ const door = updateDoor(doorInputs());
330
339
  // canUpdate does not depend on the rule, so it runs beside the rule read, not after it.
331
340
  const [update, can] = await Promise.all([
332
- resolveChannel(selfChannel).then(async ({ channel, source }) => ({ ...(await softwareUpdate({ running, channel })), channel_source: source })),
341
+ resolveChannel(selfChannel).then(async ({ channel, source }) => ({ ...(await softwareUpdate({ running, channel, follow: followFor(door) })), channel_source: source })),
333
342
  canUpdate(req),
334
343
  ]);
335
- const door = updateDoor(doorInputs());
336
344
  const ask = await readAsk({ update, door, deps: askDeps() });
337
345
  const held = await safeHold(upgradeHold, { running, offered: update && update.reading === 'ok' ? update.offered : null });
338
346
  // The history only for a viewer who holds core.pin.move (see safeMoved's gate note).
@@ -357,8 +365,8 @@ function buildCoreUpdateRouter({
357
365
  let running = null;
358
366
  try { running = require('../../module-api').CORE_VERSION; } catch { /* version unknown */ }
359
367
  const { channel } = await resolveChannel(selfChannel);
360
- const update = await softwareUpdate({ running, channel });
361
368
  const door = updateDoor(doorInputs());
369
+ const update = await softwareUpdate({ running, channel, follow: followFor(door) });
362
370
  const deps = askDeps();
363
371
  return upgradeRequest.requestUpgrade({
364
372
  host: deps.host,
@@ -0,0 +1,114 @@
1
+ 'use strict';
2
+
3
+ // src/bongos/routes/module-payouts.js — a module sale's split rule, the sale, and paying
4
+ // each share (task 1004910, goal 1000092 N6; ADR 0388). The rules live in
5
+ // ../module-payouts.js; the database repeats the ones that matter (core_291).
6
+ //
7
+ // rank: every route needs `payout.approve` (read fresh per request, ADR 0016), except
8
+ // GET /payouts/module-shares/mine, where any signed-in builder sees their own shares and
9
+ // the weight behind each (Art. VIII). Every write is a PERSONAL act: the person's own web
10
+ // session, never a CLI token or a machine principal (payout-approvals' personalSession).
11
+ //
12
+ // No processor adapter exists yet (ADR 0397), so `processor` is null: a sale cannot be
13
+ // read and a share is recorded as owed and held. Dependencies are injectable for tests.
14
+
15
+ const express = require('express');
16
+ const auth = require('../auth');
17
+ const { validateOrRespond, parsePagination } = require('./_helpers');
18
+ const modulePayouts = require('../module-payouts');
19
+ const log = require('../logger').logger.child({ src: 'module-payouts' });
20
+
21
+ const PERSONAL_REFUSAL = 'Moving module money must be done by you, signed in on the website. A command-line or AI session cannot do it.';
22
+
23
+ module.exports = function buildModulePayoutsRouter({
24
+ payouts = modulePayouts,
25
+ store = require('../payout-approvals'),
26
+ processor = null,
27
+ requireBuilder = auth.requireBuilder,
28
+ requirePermission = auth.requirePermission,
29
+ } = {}) {
30
+ const router = express.Router();
31
+
32
+ function personal(req, res) {
33
+ const p = store.personalSession(req);
34
+ if (!p.ok) {
35
+ res.fail('personal_approval_required', { status: 403, message: PERSONAL_REFUSAL });
36
+ return null;
37
+ }
38
+ return p.actor;
39
+ }
40
+
41
+ function fail(res, err, what) {
42
+ if (err instanceof payouts.PayoutRefusal) return res.fail(err.code, { status: err.status, message: err.message });
43
+ log.error({ err: err && err.message }, `${what} failed`);
44
+ if (!res.headersSent) res.fail('module_payout_failed', { status: 500, message: 'internal error' });
45
+ }
46
+
47
+ // GET /api/bongos/payouts/module-splits/:moduleKey — the module's split rule and the
48
+ // contributors' weights it would use now.
49
+ router.get('/payouts/module-splits/:moduleKey', requireBuilder, requirePermission('payout.approve'), async (req, res) => {
50
+ try {
51
+ res.json(await payouts.previewSplit(req.params.moduleKey));
52
+ } catch (err) { fail(res, err, 'GET /payouts/module-splits'); }
53
+ });
54
+
55
+ // POST /api/bongos/payouts/module-splits/:moduleKey — record the project's rule.
56
+ // Body: { seller_id, seller_bps, contributors_bps, credit_row_kinds? }.
57
+ router.post('/payouts/module-splits/:moduleKey', requireBuilder, requirePermission('payout.approve'), async (req, res) => {
58
+ const actor = personal(req, res);
59
+ if (!actor) return;
60
+ if (validateOrRespond(req, res, {
61
+ seller_id: { required: true }, seller_bps: { required: true }, contributors_bps: { required: true }, credit_row_kinds: {},
62
+ })) return;
63
+ try {
64
+ const rule = await payouts.setSplitRule({ moduleKey: req.params.moduleKey, body: req.body, actorId: actor.id });
65
+ res.status(201).json({ rule });
66
+ } catch (err) { fail(res, err, 'POST /payouts/module-splits'); }
67
+ });
68
+
69
+ // POST /api/bongos/payouts/module-sales — record ONE sale the processor reports, and
70
+ // write every share as owed. Body: { processor_sale_ref }. The price is read from the
71
+ // processor, never the body. Recording the same sale again answers with the one there.
72
+ router.post('/payouts/module-sales', requireBuilder, requirePermission('payout.approve'), async (req, res) => {
73
+ const actor = personal(req, res);
74
+ if (!actor) return;
75
+ if (validateOrRespond(req, res, { processor_sale_ref: { required: true } })) return;
76
+ try {
77
+ const out = await payouts.recordSale({ processorSaleRef: req.body.processor_sale_ref, actorId: actor.id }, { processor });
78
+ res.status(out.already ? 200 : 201).json(out);
79
+ } catch (err) { fail(res, err, 'POST /payouts/module-sales'); }
80
+ });
81
+
82
+ // GET /api/bongos/payouts/module-shares — every share and where it stands.
83
+ router.get('/payouts/module-shares', requireBuilder, requirePermission('payout.approve'), async (req, res) => {
84
+ try {
85
+ const { limit, offset } = parsePagination(req, { defaultLimit: 50, maxLimit: 200 });
86
+ res.json({ shares: await payouts.listShares(undefined, { limit, offset }) });
87
+ } catch (err) { fail(res, err, 'GET /payouts/module-shares'); }
88
+ });
89
+
90
+ // GET /api/bongos/payouts/module-shares/mine — the signed-in builder's own shares.
91
+ router.get('/payouts/module-shares/mine', requireBuilder, async (req, res) => {
92
+ try {
93
+ const { limit, offset } = parsePagination(req, { defaultLimit: 50, maxLimit: 200 });
94
+ res.set('Cache-Control', 'no-store');
95
+ res.json({ shares: await payouts.listShares(undefined, { recipientId: req.builder.id, limit, offset }) });
96
+ } catch (err) { fail(res, err, 'GET /payouts/module-shares/mine'); }
97
+ });
98
+
99
+ // POST /api/bongos/payouts/module-shares/:id/pay — send ONE share under ONE approval.
100
+ // Body: { approval_id }. The person and the amount come from the share; the approval
101
+ // must name exactly them. Pressing again is safe, as for a task payout.
102
+ router.post('/payouts/module-shares/:id/pay', requireBuilder, requirePermission('payout.approve'), async (req, res) => {
103
+ const actor = personal(req, res);
104
+ if (!actor) return;
105
+ if (validateOrRespond(req, res, { approval_id: { required: true } })) return;
106
+ try {
107
+ const out = await payouts.payShare({ shareId: req.params.id, approvalId: req.body.approval_id, actorId: actor.id }, { processor });
108
+ res.set('Cache-Control', 'no-store');
109
+ res.json(out);
110
+ } catch (err) { fail(res, err, 'POST /payouts/module-shares/:id/pay'); }
111
+ });
112
+
113
+ return router;
114
+ };
@@ -0,0 +1,103 @@
1
+ 'use strict';
2
+
3
+ // src/bongos/routes/my-cash-outs.js — a person takes their own balance out to their bank
4
+ // (task 1004911, goal 1000092 N7; ADR 0385 D1/D8, ADR 0397 D2/D7).
5
+ //
6
+ // GET /me/cash-outs my withdrawals, each in plain words, the minimum, my balance
7
+ // POST /me/cash-outs { amount_cents, instruction_id } → ask for one withdrawal
8
+ // POST /me/cash-outs/:id/retry try an unfinished one again (never pays twice)
9
+ // POST /me/cash-outs/:id/cancel withdraw a request that has not been sent
10
+ //
11
+ // Own-scoped (tests/own_scope_guard.mjs): every route acts on req.builder.id and nothing
12
+ // else; someone else's withdrawal answers 404. Every write needs the person's own web
13
+ // session: pressing it IS their logged personal approval (N9), so a CLI token, an agent or a
14
+ // machine principal can never take a person's money out. ../cash-outs.js owns the order.
15
+ //
16
+ // The processor adapter (ADR 0397: the only code that may name the rail) does not exist
17
+ // yet, so every withdrawal is recorded and held `no_processor` until it lands.
18
+
19
+ const express = require('express');
20
+ const auth = require('../auth');
21
+ const { validateOrRespond } = require('./_helpers');
22
+ const log = require('../logger').logger.child({ src: 'my-cash-outs' });
23
+
24
+ const PERSONAL_REFUSAL = 'A cash-out is made by you, signed in on the website. A command-line or AI session cannot take money out.';
25
+
26
+ function buildMyCashOutsRouter({
27
+ cashOuts = require('../cash-outs'),
28
+ approvals = require('../payout-approvals'),
29
+ processor = null,
30
+ requireBuilder = auth.requireBuilder,
31
+ } = {}) {
32
+ const router = express.Router();
33
+
34
+ function personal(req, res) {
35
+ const p = approvals.personalSession(req);
36
+ if (!p.ok) {
37
+ res.fail('personal_approval_required', { status: 403, message: PERSONAL_REFUSAL });
38
+ return null;
39
+ }
40
+ return p.actor;
41
+ }
42
+
43
+ function fault(res, err, what) {
44
+ if (err instanceof cashOuts.CashOutRefusal) {
45
+ return res.fail(err.code, { status: err.status, message: err.message, ...(err.details ? { details: err.details } : {}) });
46
+ }
47
+ // a database rule refusing (core_293's triggers): say no in words, keep its text in the log
48
+ if (err && (err.code === '23514' || err.code === '42501' || err.code === '23505')) {
49
+ log.warn({ err: err.message }, `${what} refused by the database`);
50
+ return res.fail('cash_out_refused', { status: 409, message: 'The cash-out rules refused this. Nothing was sent.' });
51
+ }
52
+ log.error({ err: err && err.message }, `${what} failed`);
53
+ if (!res.headersSent) res.fail('cash_out_failed', { status: 500, message: 'internal error' });
54
+ return undefined;
55
+ }
56
+
57
+ const answer = (res, out) => {
58
+ res.set('Cache-Control', 'no-store');
59
+ res.status(out.status === 'sent' || out.status === 'paid' || out.status === 'cancelled' ? 200 : 202).json(out);
60
+ };
61
+
62
+ // GET /api/bongos/me/cash-outs — my withdrawals and where each stands.
63
+ router.get('/me/cash-outs', requireBuilder, async (req, res) => {
64
+ try {
65
+ const out = await cashOuts.listMyCashOuts(req.builder.id, { processor, limit: req.query.limit, log });
66
+ res.set('Cache-Control', 'no-store');
67
+ res.json(out);
68
+ } catch (err) { fault(res, err, 'GET /me/cash-outs'); }
69
+ });
70
+
71
+ // POST /api/bongos/me/cash-outs — ask to take amount_cents of my balance to my bank.
72
+ router.post('/me/cash-outs', requireBuilder, async (req, res) => {
73
+ const actor = personal(req, res);
74
+ if (!actor) return;
75
+ if (validateOrRespond(req, res, { amount_cents: { required: true }, instruction_id: { required: true } })) return;
76
+ try {
77
+ const out = await cashOuts.requestCashOut({
78
+ builderId: String(req.builder.id), actor, amountCents: req.body.amount_cents, instructionId: req.body.instruction_id,
79
+ }, { processor, log });
80
+ answer(res, out);
81
+ } catch (err) { fault(res, err, 'POST /me/cash-outs'); }
82
+ });
83
+
84
+ // POST /api/bongos/me/cash-outs/:id/retry — try one of my unfinished withdrawals again.
85
+ router.post('/me/cash-outs/:id/retry', requireBuilder, async (req, res) => {
86
+ if (!personal(req, res)) return;
87
+ try {
88
+ answer(res, await cashOuts.retryCashOut({ builderId: String(req.builder.id), cashOutId: req.params.id }, { processor }));
89
+ } catch (err) { fault(res, err, 'POST /me/cash-outs/:id/retry'); }
90
+ });
91
+
92
+ // POST /api/bongos/me/cash-outs/:id/cancel — withdraw one of my requests that has not been sent.
93
+ router.post('/me/cash-outs/:id/cancel', requireBuilder, async (req, res) => {
94
+ if (!personal(req, res)) return;
95
+ try {
96
+ answer(res, await cashOuts.cancelCashOut({ builderId: String(req.builder.id), cashOutId: req.params.id }));
97
+ } catch (err) { fault(res, err, 'POST /me/cash-outs/:id/cancel'); }
98
+ });
99
+
100
+ return router;
101
+ }
102
+
103
+ module.exports = buildMyCashOutsRouter;
@@ -6,7 +6,8 @@
6
6
  // WHY. The gates themselves run inside every money route (../payout-gates.js,
7
7
  // called from routes/payout-approvals.js and from checkRun / checkApproval). This
8
8
  // file is where their facts are recorded — an age tier, a guardian link, a
9
- // sanctions screening, a hand-made hold — and where an appeal is decided. The
9
+ // sanctions screening, a hand-made hold, and the contractor papers (an NDA on
10
+ // file, tax details collected; task 1004908) — and where an appeal is decided. The
10
11
  // person whose pay is held reads why and appeals in routes/my-payouts.js (the
11
12
  // framework, cited by Article only, forbids withholding earned pay silently).
12
13
  //
@@ -137,6 +138,46 @@ function buildPayoutGatesRouter({
137
138
  } catch (err) { fault(res, err, 'POST /payouts/screenings'); }
138
139
  });
139
140
 
141
+ // POST /api/bongos/payouts/ndas — record that a builder's signed NDA is on file
142
+ // (or withdrawn), and where the signed copy lives. Never the NDA's text.
143
+ // Body: { builder_id, status: on_file|withdrawn, nda_version, reference? }.
144
+ router.post('/payouts/ndas', requireBuilder, requirePermission('payout.approve'), async (req, res) => {
145
+ const actor = personal(req, res);
146
+ if (!actor) return;
147
+ if (validateOrRespond(req, res, { builder_id: { required: true }, status: { required: true }, nda_version: { required: true }, reference: {} })) return;
148
+ const b = req.body;
149
+ if (!approvals.isId(b.builder_id)) return res.fail('bad_builder_id', { status: 400, message: 'builder_id must be a number' });
150
+ if (String(b.builder_id) === String(actor.id)) return res.fail('own_papers', { status: 403, message: 'Someone else must record your own NDA.' });
151
+ if (!gates.NDA_STATUSES.includes(b.status)) return res.fail('bad_status', { status: 400, message: `status must be one of ${gates.NDA_STATUSES.join(', ')}` });
152
+ if (typeof b.nda_version !== 'string' || !/^[A-Za-z0-9._-]{1,40}$/.test(b.nda_version)) return res.fail('bad_nda_version', { status: 400, message: 'nda_version must be a short label (letters, digits, dot, dash, underscore), such as beta-v1.' });
153
+ const reference = b.reference == null ? null : statement(b.reference, 200);
154
+ if (reference !== null && reference.length > 200) return res.fail('bad_reference', { status: 400, message: 'reference must be up to 200 characters' });
155
+ try {
156
+ res.status(201).json({ record: await gates.recordNda({ builderId: b.builder_id, status: b.status, ndaVersion: b.nda_version, reference, recordedBy: actor.id }) });
157
+ } catch (err) { fault(res, err, 'POST /payouts/ndas'); }
158
+ });
159
+
160
+ // POST /api/bongos/payouts/tax-details — record that the processor's onboarding has
161
+ // (or has not) collected a builder's identity and tax details, as checked by a
162
+ // person. The tax id itself never comes here: a reference that looks like one is
163
+ // refused. Body: { builder_id, status: collected|incomplete, reference? }.
164
+ router.post('/payouts/tax-details', requireBuilder, requirePermission('payout.approve'), async (req, res) => {
165
+ const actor = personal(req, res);
166
+ if (!actor) return;
167
+ if (validateOrRespond(req, res, { builder_id: { required: true }, status: { required: true }, reference: {} })) return;
168
+ const b = req.body;
169
+ if (!approvals.isId(b.builder_id)) return res.fail('bad_builder_id', { status: 400, message: 'builder_id must be a number' });
170
+ if (String(b.builder_id) === String(actor.id)) return res.fail('own_papers', { status: 403, message: 'Someone else must record your own tax details.' });
171
+ if (!gates.TAX_STATUSES.includes(b.status)) return res.fail('bad_status', { status: 400, message: `status must be one of ${gates.TAX_STATUSES.join(', ')}` });
172
+ const reference = b.reference == null ? null : statement(b.reference, 200);
173
+ if (reference !== null && reference.length > 200) return res.fail('bad_reference', { status: 400, message: 'reference must be up to 200 characters' });
174
+ if (reference !== null && gates.looksLikeTaxId(reference)) return res.fail('tax_id_refused', { status: 400, message: 'That looks like a tax id. Record the processor\'s onboarding reference instead; Bongos never stores tax ids.' });
175
+ const login = String(req.builder.github_login || actor.id).replace(/[^A-Za-z0-9._-]/g, '').slice(0, 60) || String(actor.id);
176
+ try {
177
+ res.status(201).json({ record: await gates.recordTaxDetails({ builderId: b.builder_id, status: b.status, collectedBy: `person:${login}`, reference, recordedBy: actor.id }) });
178
+ } catch (err) { fault(res, err, 'POST /payouts/tax-details'); }
179
+ });
180
+
140
181
  // POST /api/bongos/payouts/freezes — a person freezes a builder's payouts by hand.
141
182
  // The notice is required: there is no silent freeze. Body: { builder_id, notice_text, owed_cents? }.
142
183
  router.post('/payouts/freezes', requireBuilder, requirePermission('payout.approve'), async (req, res) => {
@@ -37,6 +37,10 @@ const buildPayoutApprovalsRouter = require('./routes/payout-approvals');
37
37
  // Who may be paid at all: age tier, sanctions, a hold that tells the person (task 1004914).
38
38
  const buildPayoutGatesRouter = require('./routes/payout-gates');
39
39
  const buildMyPayoutsRouter = require('./routes/my-payouts');
40
+ // A person takes their own balance out to their bank (task 1004911, N7).
41
+ const buildMyCashOutsRouter = require('./routes/my-cash-outs');
42
+ // A module sale's split rule, the sale and each share's payout (task 1004910, ADR 0388).
43
+ const buildModulePayoutsRouter = require('./routes/module-payouts');
40
44
  const buildBuildersRouter = require('./routes/builders');
41
45
  // The LIFECYCLE routes (tasks · claims · versions · done-when · goals ·
42
46
  // dependencies · analytics) carved into modules/lifecycle/
@@ -179,6 +183,8 @@ function buildGdsRouter() {
179
183
  router.use(buildPayoutApprovalsRouter());
180
184
  router.use(buildPayoutGatesRouter());
181
185
  router.use(buildMyPayoutsRouter());
186
+ router.use(buildMyCashOutsRouter());
187
+ router.use(buildModulePayoutsRouter());
182
188
  router.use(buildBuildersRouter());
183
189
  // tasks · claims · versions · done-when · goals · dependencies · analytics now
184
190
  // mount via moduleLoader.mountModuleRoutes below (lifecycle module, BV1.R86) —
@@ -22,6 +22,12 @@
22
22
  // /deploy, via the provisioning.selfUpdateChannel seam — task 1004535), else the runner's
23
23
  // default. The banner (core-update.js) is handed the same rule, so the two cannot disagree.
24
24
  //
25
+ // ONLY WHAT THIS PROJECT'S FOLLOW ADMITS IS OFFERED (task 1004593, ADR 0361 D5). Merges publish
26
+ // as candidates above the registry's `latest` label; a project that follows releases is offered
27
+ // nothing above it, through `versionsForFollow`, the function the runner and the banner use.
28
+ // Before this the panel offered a candidate that /deploy then said did not exist. The route
29
+ // names the follow: candidates only on the platform's own hall.
30
+ //
25
31
  // A READING, NEVER A GUESS. `reading` is 'ok' only when the registry answered; "up to date"
26
32
  // is `offered: null` on an ok reading and nothing else. Notes that could not be read say so,
27
33
  // and a version whose notes are missing is shown as missing rather than as empty.
@@ -30,6 +36,8 @@ const {
30
36
  CORE_PKG,
31
37
  DEFAULT_CHANNEL,
32
38
  normalizeChannel,
39
+ normalizeFollow,
40
+ versionsForFollow,
33
41
  resolveChannelTarget,
34
42
  compareSemver,
35
43
  } = require('../../scripts/bongos/update-channel.js');
@@ -62,22 +70,29 @@ function entriesFor(notes, version) {
62
70
  return out;
63
71
  }
64
72
 
73
+ /** The published versions `follow` admits, oldest first; [] when a release follower has no label. */
74
+ function admittedVersions(follow, registry) {
75
+ return versionsForFollow({ follow, available: registry.versions, released: registry.latest });
76
+ }
77
+
65
78
  /**
66
79
  * The panel's whole answer, from one registry reading. PURE — no clock, no I/O.
67
80
  *
68
81
  * `registry` and `notes` are package-registry.js's `readRegistry` result. Versions in the
69
- * update are the published ones above `running` and at or below `offered`, newest first.
82
+ * update are the admitted ones above `running` and at or below `offered`, newest first, and
83
+ * `newest` is the newest admitted version, so a candidate is never named to a release follower.
70
84
  */
71
- function softwareUpdateVerdict({ running, channel, registry, notes, checkedAt }) {
85
+ function softwareUpdateVerdict({ running, channel, follow, registry, notes, checkedAt }) {
72
86
  const ch = normalizeChannel(channel == null ? DEFAULT_CHANNEL : channel);
73
- const base = { checked_at: checkedAt, running: running || null, channel: ch };
74
- if (!running || !VERSION_RE.test(String(running))) {
75
- return { ...base, reading: 'no_answer', error: 'this project could not name the version it runs', offered: null, newest: null, whats_new: null };
76
- }
77
- if (!registry || !registry.ok) {
78
- return { ...base, reading: 'no_answer', error: (registry && registry.error) || 'the registry could not be read', offered: null, newest: null, whats_new: null };
79
- }
80
- const offered = resolveChannelTarget({ installed: running, channel: ch, available: registry.versions }) || null;
87
+ const fol = normalizeFollow(follow);
88
+ const base = { checked_at: checkedAt, running: running || null, channel: ch, follow: fol };
89
+ const noAnswer = (error) => ({ ...base, reading: 'no_answer', error, offered: null, newest: null, whats_new: null });
90
+ if (!running || !VERSION_RE.test(String(running))) return noAnswer('this project could not name the version it runs');
91
+ if (!registry || !registry.ok) return noAnswer((registry && registry.error) || 'the registry could not be read');
92
+ const admitted = admittedVersions(fol, registry);
93
+ // No label, no answer: the runner moves a release follower on nothing it cannot call released.
94
+ if (!admitted.length) return noAnswer('the registry does not say which version is released');
95
+ const offered = resolveChannelTarget({ installed: running, channel: ch, available: admitted }) || null;
81
96
  const published = registry.published || {};
82
97
  const answer = {
83
98
  ...base,
@@ -85,12 +100,12 @@ function softwareUpdateVerdict({ running, channel, registry, notes, checkedAt })
85
100
  error: null,
86
101
  offered,
87
102
  offered_published_at: offered ? published[offered] || null : null,
88
- newest: registry.newest || null,
103
+ newest: admitted.reduce((a, v) => (compareSemver(v, a) > 0 ? v : a)),
89
104
  whats_new: null,
90
105
  };
91
106
  if (!offered) return answer;
92
107
 
93
- const inUpdate = registry.versions
108
+ const inUpdate = admitted
94
109
  .filter((v) => compareSemver(v, running) > 0 && compareSemver(v, offered) <= 0)
95
110
  .sort((a, b) => compareSemver(b, a));
96
111
  const shown = inUpdate.slice(0, VERSIONS_MAX);
@@ -115,14 +130,19 @@ function softwareUpdateVerdict({ running, channel, registry, notes, checkedAt })
115
130
  * `get`/`now`/`store` pass through to the registry reader, so a test drives the real
116
131
  * packument parsing, the real tarball stream and the real cache rather than a stub.
117
132
  */
118
- async function readSoftwareUpdate({ running, channel, get, now = Date.now, store } = {}) {
133
+ async function readSoftwareUpdate({ running, channel, follow, get, now = Date.now, store } = {}) {
119
134
  const t = now();
135
+ const fol = normalizeFollow(follow);
120
136
  const { registry, notes } = await readRegistry({
121
137
  pkg: CORE_PKG, get, now, store,
122
- // Nothing to describe when nothing newer is published: skip the download.
123
- haveNotesFor: (newest) => !VERSION_RE.test(String(running || '')) || compareSemver(newest, running) <= 0,
138
+ // Nothing to describe when nothing this project could take is newer: skip the download.
139
+ haveNotesFor: (_newest, facts) => {
140
+ if (!VERSION_RE.test(String(running || ''))) return true;
141
+ const admitted = admittedVersions(fol, facts);
142
+ return !admitted.some((v) => compareSemver(v, running) > 0);
143
+ },
124
144
  });
125
- return softwareUpdateVerdict({ running, channel, registry, notes, checkedAt: new Date(t).toISOString() });
145
+ return softwareUpdateVerdict({ running, channel, follow: fol, registry, notes, checkedAt: new Date(t).toISOString() });
126
146
  }
127
147
 
128
148
  module.exports = { readSoftwareUpdate };
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.24.23'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.25'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -57,6 +57,7 @@ try {
57
57
  await client.query(`INSERT INTO payout_approvals (kind, recipient_id, amount_cents, currency)
58
58
  VALUES ('payout_approved', 1, 12500, 'USD'), ('payout_approved', 1, 300, 'USD'), ('payout_approved', 1, 400, 'USD')`);
59
59
  await client.query(mig('modules', 'economy', 'migrations', 'economy_002_project_account.sql'));
60
+ await client.query(mig('modules', 'economy', 'migrations', 'economy_004_module_share_payouts.sql'));
60
61
  await client.query(mig('migrations', 'core_290_builder_payouts.sql'));
61
62
  await client.query('BEGIN');
62
63
 
@@ -0,0 +1,96 @@
1
+ // tests/cash_outs.mjs — task 1004911 (goal 1000092 N7). DB-free.
2
+ //
3
+ // The pure half of cash-outs: how a withdrawal's attempts fold into where it stands (and
4
+ // which try, so which idempotency key, comes next), the refusals made before anything is
5
+ // recorded, and the routes' own gate: only the person, signed in on the web, can take their
6
+ // money out. The real tables and the real flow are tests/cash_outs_db.mjs.
7
+ //
8
+ // Run: node tests/cash_outs.mjs
9
+ import { createRequire } from 'node:module';
10
+ import { strict as assert } from 'node:assert';
11
+ import http from 'node:http';
12
+ const require = createRequire(import.meta.url);
13
+ const express = require('express');
14
+ const C = require('../src/bongos/cash-outs.js');
15
+ const buildMyCashOuts = require('../src/bongos/routes/my-cash-outs.js');
16
+
17
+ let pass = 0;
18
+ const ok = (c, m) => { assert.ok(c, m); pass++; console.log(` ok - ${m}`); };
19
+ const refusal = async (fn) => { try { await fn(); } catch (e) { return e; } return null; };
20
+ const at = (outcome, tryNo, ref = null) => ({ outcome, try_no: tryNo, processor_ref: ref, reason: ref ? null : 'x' });
21
+
22
+ // ── standing ─────────────────────────────────────────────────────────────
23
+ ok(C.standing([]).status === 'held' && C.standing([]).try_no === 1, 'nothing tried yet: waiting, try 1');
24
+ ok(C.standing([at('failed', 1)]).try_no === 1, 'a failure we cannot trust keeps try 1 (the same key)');
25
+ ok(C.standing([at('refused', 1)]).try_no === 2, 'a processor refusal moves on to try 2');
26
+ ok(C.standing([at('sent', 1, 'po_1')]).status === 'sent', 'sent and not yet arrived');
27
+ ok(C.standing([at('sent', 1, 'po_1'), at('paid', 1, 'po_1')]).status === 'paid', 'arrived');
28
+ const back = C.standing([at('sent', 1, 'po_1'), at('paid', 1, 'po_1'), { outcome: 'returned', try_no: 1, processor_ref: 'po_1', reason: 'closed' }]);
29
+ ok(back.status === 'returned' && back.try_no === 2, 'a bank return after arrival reopens it on try 2');
30
+ ok(C.standing([at('held', 1), at('cancelled', 1)]).status === 'cancelled', 'cancelled');
31
+ ok(C.idempotencyKey('7', 2) === 'bongos-cashout-7-2', 'the key names the withdrawal and the try');
32
+
33
+ // ── refusals before anything is recorded ─────────────────────────────────
34
+ const noDb = { query: async () => { throw new Error('the database must not be reached'); } };
35
+ const me = { id: '5', session_id: '1', session_source: 'web' };
36
+ let e = await refusal(() => C.requestCashOut({ builderId: '5', actor: me, amountCents: 999, instructionId: 'press-000001' }, { pool: noDb, account: { memberAccount: async () => 'acct_test' } }));
37
+ ok(e && e.code === 'below_minimum', 'below the minimum is refused without touching the database');
38
+ e = await refusal(() => C.requestCashOut({ builderId: '5', actor: me, amountCents: 1.5, instructionId: 'press-000001' }, { pool: noDb }));
39
+ ok(e && e.code === 'bad_amount', 'a fractional amount is refused');
40
+ e = await refusal(() => C.requestCashOut({ builderId: '5', actor: me, amountCents: 2000, instructionId: 'x' }, { pool: noDb }));
41
+ ok(e && e.code === 'bad_instruction_id', 'a malformed instruction id is refused');
42
+ e = await refusal(() => C.requestCashOut({ builderId: '5', actor: { ...me, id: '6' }, amountCents: 2000, instructionId: 'press-000001' }, { pool: noDb }));
43
+ ok(e && e.code === 'personal_session_required', "nobody cashes out someone else's money");
44
+ e = await refusal(() => C.requestCashOut({ builderId: '5', actor: me, amountCents: 2000, instructionId: 'press-000001' }, { pool: noDb, account: null }));
45
+ ok(e && e.code === 'no_account_record', 'with balances switched off there is nothing to cash out');
46
+ e = await refusal(() => C.requestCashOut({ builderId: '5', actor: me, amountCents: 2000, instructionId: 'press-000001' }, { pool: noDb, account: { memberAccount: async () => null } }));
47
+ ok(e && e.code === 'no_account', 'no processor account here: nothing to cash out');
48
+ e = await refusal(() => C.recordProcessorOutcome({ processorRef: 'po_1', outcome: 'returned' }, { pool: noDb }));
49
+ ok(e && e.code === 'no_reason', 'a returned payout must say why');
50
+
51
+ // ── the routes' gate ─────────────────────────────────────────────────────
52
+ const seen = [];
53
+ const stub = {
54
+ CashOutRefusal: C.CashOutRefusal,
55
+ listMyCashOuts: async (id) => { seen.push(['list', id]); return { cash_outs: [] }; },
56
+ requestCashOut: async (i) => { seen.push(['request', i.builderId, i.actor.id]); return { status: 'held' }; },
57
+ retryCashOut: async (i) => { seen.push(['retry', i.builderId, i.cashOutId]); throw new C.CashOutRefusal('no_such_cash_out', 'none', 404); },
58
+ cancelCashOut: async (i) => { seen.push(['cancel', i.builderId]); return { status: 'cancelled' }; },
59
+ };
60
+ function serve(as) {
61
+ const a = express();
62
+ a.use(express.json());
63
+ a.use((req, res, next) => { res.fail = (code, o = {}) => res.status(o.status || 400).json({ error: code, message: o.message }); req.builder = as.builder; req.gdsSession = as.gdsSession; next(); });
64
+ a.use('/', buildMyCashOuts({ cashOuts: stub, requireBuilder: (q, r, nx) => nx() }));
65
+ return http.createServer(a).listen(0);
66
+ }
67
+ async function call(srv, method, path, body) {
68
+ const r = await fetch(`http://127.0.0.1:${srv.address().port}${path}`, { method, headers: { 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });
69
+ return { status: r.status, body: await r.json() };
70
+ }
71
+ const web = serve({ builder: { id: '5' }, gdsSession: { source: 'web', session_id: '3' } });
72
+ const cli = serve({ builder: { id: '5' }, gdsSession: { source: 'cli', session_id: '4' } });
73
+ const bot = serve({ builder: { id: '9', system_role: 'agent' }, gdsSession: { source: 'web', session_id: '5' } });
74
+ try {
75
+ for (const [srv, who] of [[cli, 'a CLI session'], [bot, 'a machine principal']]) {
76
+ for (const [act, p] of [['request', '/me/cash-outs'], ['retry', '/me/cash-outs/1/retry'], ['cancel', '/me/cash-outs/1/cancel']]) {
77
+ const r = await call(srv, 'POST', p, { amount_cents: 2000, instruction_id: 'press-000001' });
78
+ ok(r.status === 403 && r.body.error === 'personal_approval_required', `${who} cannot ${act} a cash-out`);
79
+ }
80
+ }
81
+ ok(!seen.some(([k]) => k !== 'list'), 'nothing reached the cash-out code from a refused session');
82
+ let r = await call(cli, 'GET', '/me/cash-outs');
83
+ ok(r.status === 200 && seen.at(-1)[1] === '5', 'reading my own list works from any session, keyed on me');
84
+ r = await call(web, 'POST', '/me/cash-outs', { amount_cents: 2000, instruction_id: 'press-000001', builder_id: '6' });
85
+ ok(r.status === 400 && seen.at(-1)[0] === 'list', 'a body naming someone else is refused outright');
86
+ r = await call(web, 'POST', '/me/cash-outs', { amount_cents: 2000, instruction_id: 'press-000001' });
87
+ ok(r.status === 202 && seen.at(-1)[1] === '5' && seen.at(-1)[2] === '5', 'a request is mine: the signed-in person, never the body');
88
+ r = await call(web, 'POST', '/me/cash-outs/77/retry');
89
+ ok(r.status === 404 && r.body.error === 'no_such_cash_out', 'a refusal is passed on with its status');
90
+ r = await call(web, 'POST', '/me/cash-outs', {});
91
+ ok(r.status === 400, 'a request with no amount is refused');
92
+ } finally {
93
+ for (const s of [web, cli, bot]) s.close();
94
+ }
95
+
96
+ console.log(`\ncash_outs.mjs: ${pass} passed`);