@bongos/core 1.24.96 → 1.25.2

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 (44) hide show
  1. package/.bongos-core.json +66 -46
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +7 -0
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/api/openapi.json +131 -3
  8. package/docs/api-reference.md +4 -2
  9. package/docs/architecture.md +3 -1
  10. package/docs/copy-inventory.md +2 -2
  11. package/docs/copy-registry.json +2 -2
  12. package/docs/module-api-changelog.md +6 -0
  13. package/docs/page-readings.json +6 -6
  14. package/migrations/core_298_card_step_up.sql +16 -0
  15. package/modules/economy/module.json +1 -1
  16. package/modules/economy/processor-stripe.js +292 -0
  17. package/modules/economy/routes/balances.js +4 -3
  18. package/modules/economy/routes/project-account.js +12 -1
  19. package/modules/economy/tests/processor_stripe.mjs +135 -0
  20. package/modules/government/routes/government.js +2 -2
  21. package/modules/hall-ui/public/visor.js +67 -20
  22. package/modules/hall-ui/records/visor.md +7 -0
  23. package/modules/render-deploy/routes/act.js +1 -1
  24. package/modules/render-deploy/routes/app.js +1 -1
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/release-notes.json +17 -0
  28. package/scripts/bongos/fitness.js +1 -1
  29. package/scripts/bongos/government-walk.js +2 -2
  30. package/src/bongos/passkeys.js +30 -0
  31. package/src/bongos/routes/auth.js +2 -2
  32. package/src/bongos/routes/builders.js +2 -1
  33. package/src/bongos/routes/module-payouts.js +4 -2
  34. package/src/bongos/routes/my-cash-outs.js +5 -3
  35. package/src/bongos/routes/my-passkeys.js +70 -0
  36. package/src/bongos/routes/payout-approvals.js +5 -3
  37. package/src/bongos/step-up.js +42 -0
  38. package/src/module-api.js +5 -1
  39. package/tests/government_act_alone_buildings.mjs +2 -1
  40. package/tests/government_treasury.mjs +2 -1
  41. package/tests/hall_visor.mjs +63 -3
  42. package/tests/module_api.mjs +1 -0
  43. package/tests/my_passkeys.mjs +47 -1
  44. package/tests/rank_change_broadcast_port.mjs +2 -0
@@ -0,0 +1,135 @@
1
+ // modules/economy/tests/processor_stripe.mjs — task 1005040. DB-free, no network: the Stripe
2
+ // Connect test-mode adapter against a fake fetch. Run: node modules/economy/tests/processor_stripe.mjs
3
+ import { createRequire } from 'node:module';
4
+ import { strict as assert } from 'node:assert';
5
+ import crypto from 'node:crypto';
6
+ const require = createRequire(import.meta.url);
7
+ const { createStripeProcessor, fromEnv } = require('../processor-stripe.js');
8
+
9
+ let pass = 0;
10
+ const ok = (c, m) => { assert.ok(c, m); pass++; console.log(` ok - ${m}`); };
11
+ // Fake keys are assembled at run time so no key-shaped literal sits in the repo.
12
+ const FAKE = 'placeholder12345678';
13
+ const KEY = ['sk', 'test', FAKE].join('_');
14
+ const LIVE = ['sk', 'live', FAKE].join('_');
15
+ const RESTRICTED = ['rk', 'test', FAKE].join('_');
16
+ const PROJ = 'acct_project0001';
17
+ const ME = 'acct_member00001';
18
+
19
+ const calls = [];
20
+ const fake = (answer) => async (url, init) => {
21
+ calls.push({ url, ...init });
22
+ const r = await answer(url, init);
23
+ return { ok: (r.status || 200) < 400, status: r.status || 200, json: async () => r.body };
24
+ };
25
+ const make = (answer, extra = {}) => createStripeProcessor({ secretKey: KEY, projectAccount: PROJ, projectKey: 'p1', fetchImpl: fake(answer), memberAccountFor: async (id) => (id === '5' ? ME : null), ...extra });
26
+ const err = async (fn) => { try { await fn(); } catch (e) { return e; } return null; };
27
+
28
+ // ── test mode only ───────────────────────────────────────────────────────
29
+ ok((await err(() => createStripeProcessor({ secretKey: LIVE }))).code === 'test_key_only', 'a live key is refused');
30
+ ok((await err(() => createStripeProcessor({ secretKey: RESTRICTED }))).code === 'test_key_only', 'only test secret keys are accepted');
31
+ ok(fromEnv({}) === null, 'no key in the environment: no adapter (flows hold no_processor)');
32
+ ok(fromEnv({ STRIPE_SECRET_KEY: KEY, STRIPE_PROJECT_ACCOUNT: PROJ }).testMode === true, 'built from the environment, flagged test mode');
33
+
34
+ // ── payout: key, account header, paid flag, refused mapping ──────────────
35
+ let p = make(() => ({ body: { id: 'po_1', status: 'in_transit' } }));
36
+ let out = await p.memberPayout({ idempotencyKey: 'bongos-cashout-7-1', amountCents: 2500, currency: 'USD', account: ME });
37
+ ok(out.ref === 'po_1' && out.paid === false, 'payout returns the reference, not yet paid');
38
+ const c = calls.at(-1);
39
+ ok(c.url.endsWith('/v1/payouts') && c.headers['Idempotency-Key'] === 'bongos-cashout-7-1', 'payout sends the caller idempotency key');
40
+ ok(c.headers['Stripe-Account'] === ME && c.body.includes('amount=2500') && c.body.includes('currency=usd'), "payout acts on the person's account for the exact amount");
41
+ p = make(() => ({ body: { id: 'po_2', status: 'paid' } }));
42
+ ok((await p.memberPayout({ idempotencyKey: 'k', amountCents: 1000, currency: 'USD', account: ME })).paid === true, 'a payout Stripe reports paid is paid');
43
+ p = make(() => ({ status: 400, body: { error: { code: 'balance_insufficient', message: 'no funds' } } }));
44
+ let e = await err(() => p.memberPayout({ idempotencyKey: 'k', amountCents: 1000, currency: 'USD', account: ME }));
45
+ ok(e.refused === true && e.code === 'balance_insufficient', 'a 4xx that created nothing is refused');
46
+ for (const status of [500, 409, 429, 408]) {
47
+ p = make(() => ({ status, body: { error: { message: 'x' } } }));
48
+ e = await err(() => p.memberPayout({ idempotencyKey: 'k', amountCents: 1000, currency: 'USD', account: ME }));
49
+ ok(e && e.refused === false, `a ${status} may have paid, so it is not refused (retry on the same key)`);
50
+ }
51
+ p = make(() => { throw new Error('socket hang up'); });
52
+ e = await err(() => p.memberPayout({ idempotencyKey: 'k', amountCents: 1000, currency: 'USD', account: ME }));
53
+ ok(e.refused === false && e.code === 'network', 'a network failure is not refused');
54
+ p = make(() => ({ body: {} }));
55
+ ok((await err(() => p.memberPayout({ idempotencyKey: 'k', amountCents: 10.5, currency: 'USD', account: ME }))).refused === true, 'a fractional amount is refused before any call');
56
+ ok((await err(() => p.memberPayout({ idempotencyKey: 'k', amountCents: 1000, currency: 'EUR', account: ME }))).code === 'currency_not_supported', 'only dollars');
57
+ ok((await err(() => p.memberPayout({ idempotencyKey: 'k', amountCents: 1000, currency: 'USD', account: 'nope' }))).code === 'bad_account', 'a malformed account is refused');
58
+
59
+ // guardian routing, unreadable replies, project key, cache, urls
60
+ p = make(() => ({ body: { id: 'po_x' } }));
61
+ const n0 = calls.length;
62
+ e = await err(() => p.memberPayout({ idempotencyKey: 'k', amountCents: 1000, currency: 'USD', account: ME, payTo: { guardian_id: '2', guardian_account: 'acct_guardian001' } }));
63
+ ok(e.code === 'guardian_payout_unsupported' && e.refused === true && calls.length === n0, 'a guardian-routed payout is refused and nothing is sent');
64
+ p = make(() => ({ status: 200, body: null }));
65
+ e = await err(() => p.memberPayout({ idempotencyKey: 'k', amountCents: 1000, currency: 'USD', account: ME }));
66
+ ok(e.code === 'bad_response' && e.refused === false, 'a 2xx with an unreadable body is not refused and says so');
67
+ ok(fromEnv({ STRIPE_SECRET_KEY: KEY, STRIPE_PROJECT_ACCOUNT: PROJ }).confirmMemberAccount.length >= 0, 'built without a project key falls back to the project account');
68
+ let reads = 0;
69
+ p = make(() => { reads++; return { body: { available: [{ amount: 100, currency: 'usd' }], pending: [] } }; });
70
+ await Promise.all([p.balanceMicro(), p.balanceMicro(), p.memberBalanceMicro(ME), p.memberBalanceMicro(ME)]);
71
+ ok(reads === 2, 'identical balance reads at once share one Stripe call per account');
72
+ p = make(() => ({ body: { url: 'https://x' } }), { allowedOrigin: 'https://proj.example' });
73
+ ok((await err(() => p.onboardingLink({ accountRef: ME, returnUrl: 'https://evil.example/x', refreshUrl: 'https://proj.example/y' }))).code === 'bad_return_url', 'onboarding addresses outside the project origin are refused');
74
+ ok((await p.onboardingLink({ accountRef: ME, returnUrl: 'https://proj.example/a', refreshUrl: 'https://proj.example/b' })).url === 'https://x', 'addresses on the project origin pass');
75
+
76
+ // ── balances ─────────────────────────────────────────────────────────────
77
+ p = make(() => ({ body: { available: [{ amount: 1000, currency: 'usd' }, { amount: 5, currency: 'eur' }], pending: [{ amount: 250, currency: 'usd' }] } }));
78
+ ok(await p.balanceMicro() === 1250 * 10000, 'project balance is the dollar total in micro-dollars (other currencies ignored)');
79
+ ok(calls.at(-1).headers['Stripe-Account'] === PROJ, 'read from the project account');
80
+ ok(await p.memberBalanceMicro(ME) === 1250 * 10000 && calls.at(-1).headers['Stripe-Account'] === ME, 'member balance reads the member account');
81
+
82
+ // ── account confirmation ─────────────────────────────────────────────────
83
+ p = make(() => ({ body: { id: ME, metadata: { bongos_builder_id: '5', bongos_project: 'p1' } } }));
84
+ ok(await p.confirmMemberAccount({ accountRef: ME, builderId: '5' }) === true, 'an account we opened for that person is confirmed');
85
+ ok(await p.confirmMemberAccount({ accountRef: ME, builderId: '6' }) === false, 'not for someone else');
86
+ p = make(() => ({ body: { id: ME, metadata: { bongos_builder_id: '5', bongos_project: 'other' } } }));
87
+ ok(await p.confirmMemberAccount({ accountRef: ME, builderId: '5' }) === false, 'not an account another project opened');
88
+
89
+ // ── transfers ────────────────────────────────────────────────────────────
90
+ p = make(() => ({ body: { id: 'tr_1' } }));
91
+ out = await p.transfer({ idempotencyKey: 'bongos-payout-9', amountCents: 700, currency: 'USD', builderId: '5', payTo: 'self' });
92
+ ok(out.ref === 'tr_1' && calls.at(-1).headers['Stripe-Account'] === PROJ && calls.at(-1).body.includes(`destination=${ME}`), "a payout transfer goes project → the builder's own account");
93
+ await p.transfer({ idempotencyKey: 'k2', amountCents: 700, currency: 'USD', builderId: '5', payTo: { guardian_id: '2', guardian_account: 'acct_guardian001' } });
94
+ ok(calls.at(-1).body.includes('destination=acct_guardian001'), 'a 16-17 recipient is paid to the guardian');
95
+ e = await err(() => p.transfer({ idempotencyKey: 'k3', amountCents: 700, currency: 'USD', builderId: '99', payTo: 'self' }));
96
+ ok(e.code === 'no_member_account' && e.refused, 'no connected account: refused, nothing sent');
97
+ const before = calls.length;
98
+ await p.memberTransfer({ idempotencyKey: 'k4', amountCents: 300, currency: 'USD', fromAccount: ME, toAccount: null, toProject: true });
99
+ ok(calls.length === before + 1 && calls.at(-1).body.includes(`destination=${PROJ}`) && calls.at(-1).headers['Stripe-Account'] === ME, 'funding the project: member account → project account');
100
+ const bare = createStripeProcessor({ secretKey: KEY, fetchImpl: fake(() => ({ body: { id: 'x' } })) });
101
+ ok((await err(() => bare.balanceMicro())).code === 'no_project_account', 'no project account configured: refused');
102
+
103
+ // ── sale ─────────────────────────────────────────────────────────────────
104
+ p = make(() => ({ body: { id: 'pi_1', status: 'succeeded', amount: 1000, currency: 'usd', application_fee_amount: 100, metadata: { module_key: 'cool-mod' } } }));
105
+ const sale = await p.readSale({ ref: 'pi_1' });
106
+ ok(sale.moduleKey === 'cool-mod' && sale.priceCents === 1000 && sale.platformFeeCents === 100 && sale.currency === 'USD', 'a sale is read from Stripe, in the shape the module-share code checks');
107
+ p = make(() => ({ body: { id: 'pi_2', status: 'requires_payment_method', amount: 1000, currency: 'usd' } }));
108
+ ok((await err(() => p.readSale({ ref: 'pi_2' }))).code === 'sale_not_paid', 'an unpaid sale is refused');
109
+
110
+ // ── movements, with paging ───────────────────────────────────────────────
111
+ let page = 0;
112
+ p = make(() => (++page === 1
113
+ ? { body: { has_more: true, data: [{ id: 'txn_1', source: 'tr_1', amount: -700, currency: 'usd', created: 1_700_000_000 }] } }
114
+ : { body: { has_more: false, data: [{ id: 'txn_2', source: 'po_1', amount: 2500, currency: 'usd', created: 1_700_000_100 }] } }));
115
+ const mv = await p.listMovements({ since: new Date(1_699_000_000_000), until: new Date(1_701_000_000_000) });
116
+ ok(mv.length === 2 && mv[0].ref === 'tr_1' && mv[0].amountMicro === 700 * 10000 && mv[1].ref === 'po_1', 'movements are paged and listed by object id, unsigned, in micro-dollars');
117
+ ok(calls.at(-1).url.includes('starting_after=txn_1'), 'the second page continues after the first');
118
+
119
+ // ── webhook ──────────────────────────────────────────────────────────────
120
+ const SECRET = 'webhook-test-secret';
121
+ const hook = createStripeProcessor({ secretKey: KEY, webhookSecret: SECRET, fetchImpl: fake(() => ({})), now: () => 1_700_000_000_000 });
122
+ const body = JSON.stringify({ type: 'payout.failed', data: { object: { id: 'po_9', failure_message: 'account closed' } } });
123
+ const sign = (t, b) => `t=${t},v1=${crypto.createHmac('sha256', SECRET).update(`${t}.${b}`).digest('hex')}`;
124
+ const ev = hook.verifyWebhook(body, sign(1_700_000_000, body));
125
+ ok(ev.type === 'payout.failed', 'a correctly signed webhook is accepted');
126
+ const news = hook.payoutNews(ev);
127
+ ok(news.outcome === 'returned' && news.ref === 'po_9' && news.reason === 'account closed', "payout.failed becomes a return with the bank's reason");
128
+ ok(hook.payoutNews({ type: 'payout.paid', data: { object: { id: 'po_9' } } }).outcome === 'paid', 'payout.paid becomes paid');
129
+ ok(hook.payoutNews({ type: 'charge.succeeded', data: { object: { id: 'ch_1' } } }).handled === false, 'other events are ignored');
130
+ ok((await err(() => hook.verifyWebhook(`${body} `, sign(1_700_000_000, body)))).code === 'bad_signature', 'a tampered body is refused');
131
+ ok((await err(() => hook.verifyWebhook(body, sign(1_699_990_000, body)))).code === 'bad_signature', 'a stale timestamp is refused');
132
+ ok((await err(() => hook.verifyWebhook(body, sign(1_700_000_200, body)))).code === 'bad_signature', 'a timestamp from the future is refused');
133
+ ok((await err(() => hook.verifyWebhook(body, 't=1700000000,v1=abcd'))).code === 'bad_signature', 'a wrong signature is refused');
134
+
135
+ console.log(`\n${pass} passed`);
@@ -1344,7 +1344,7 @@ module.exports = function buildGovernmentRouter() {
1344
1344
  // seat while it is unseated — may (403 not_the_holder). Only an open goal.
1345
1345
  //
1346
1346
  // Body: { budget_usd: number | null } → 200 { ok, pot | removed }
1347
- router.put('/government/treasury/pots/:goalId', api.requireBuilder, api.requirePermission('board.vote.cast'), async (req, res) => {
1347
+ router.put('/government/treasury/pots/:goalId', api.requireBuilder, api.requirePermission('board.vote.cast'), api.requireCardStepUp, async (req, res) => {
1348
1348
  const goalId = parseId(req, res, { param: 'goalId', code: 'bad_goal_id', positiveInt: true });
1349
1349
  if (goalId === null) return;
1350
1350
  if (validateOrRespond(req, res, { budget_usd: {} })) return; // a number of dollars, or null to take the pot away; parsed below
@@ -1375,7 +1375,7 @@ module.exports = function buildGovernmentRouter() {
1375
1375
  // stops nothing: with no spend limit, work runs on and the overspend is debt.
1376
1376
  //
1377
1377
  // Body: { key: 'lend' | 'half' | 'no' } → 200 { ok, borrow }
1378
- router.post('/government/treasury/borrows/:borrowId/answer', api.requireBuilder, api.requirePermission('board.vote.cast'), async (req, res) => {
1378
+ router.post('/government/treasury/borrows/:borrowId/answer', api.requireBuilder, api.requirePermission('board.vote.cast'), api.requireCardStepUp, async (req, res) => {
1379
1379
  const borrowId = parseId(req, res, { param: 'borrowId', code: 'bad_borrow_id', positiveInt: true });
1380
1380
  if (borrowId === null) return;
1381
1381
  if (validateOrRespond(req, res, { key: { required: true, type: 'string', maxLength: 16 } })) return;
@@ -11,9 +11,10 @@
11
11
  // the character's colour; task 1005218), your character's name and your credits
12
12
  // (builders.total_credits — credits only, never a payout; the spec's "does not exist").
13
13
  // Centre: your character's main action, the one accent button, with its key. Right: the
14
- // quiet key hints, no boxes: your other actions, "Actions" (key A), which opens the
15
- // sheet (your character's actions, then the keys of the place you are in, then Options
16
- // on Escape), and Options itself.
14
+ // quiet key hints, no boxes: your other actions and "Actions" (key A), which opens the
15
+ // sheet (your character's actions, then the keys of the place you are in, then Options on
16
+ // a key of its own; Escape closes it). Options is only in the sheet (task 1005248).
17
+ // - every key comes from resolveKeys: one owner per key, never two (task 1005248).
17
18
  //
18
19
  // The rules it keeps (ADR 0399):
19
20
  // - one overlay, #visor, with pointer-events: none; only its controls take a pointer, so a
@@ -59,6 +60,11 @@
59
60
  ],
60
61
  };
61
62
  var SHEET_KEY = 'a';
63
+ var CLOSE_KEY = 'esc'; // shown once: the sheet's Close (Escape)
64
+ var OPTIONS = { id: 'options', label: 'Options', key: 'o', href: '/settings' };
65
+ // Keys a scene binds on its own page (the sky's L 0 / ?, the gallery's F, the palette's K).
66
+ // A moved key never lands on one of them; a scene that declares one keeps it.
67
+ var AVOID = ['l', '0', '/', '?', 'f', 'k', '+', '-', '='];
62
68
  var NAMES = { artisan: 'Artisan', artist: 'Artist', patron: 'Patron' };
63
69
 
64
70
  function esc(v) { return window.OTB && window.OTB.escapeHtml ? window.OTB.escapeHtml(v) : String(v == null ? '' : v).replace(/[^\w .,:'-]/g, ''); }
@@ -151,6 +157,42 @@
151
157
  render();
152
158
  }
153
159
 
160
+ // ---------- the keys: one resolver, so no key ever means two things (task 1005248) ----------
161
+ // Every key the visor shows or binds comes from here, and here hands each key out once.
162
+ // A key in ACTIONS or a scene's registration is a RECOMMENDATION: when it is already taken
163
+ // the action moves to the next free letter of its own name, then of the alphabet, and with
164
+ // none free it shows and binds no key at all. Never two owners. In order of who keeps a key:
165
+ // 1. the visor's own: A (Actions) and Escape (Close, while the sheet is open);
166
+ // 2. the keys a scene binds itself (an action without `bind`, and the `keys` lines): the
167
+ // visor cannot move a key it does not bind, so a second claim on one shows no key;
168
+ // 3. the character's actions, in order; 4. the scene's `bind` actions; 5. Options.
169
+ function norm(k) { return k ? String(k).toLowerCase() : ''; }
170
+ function resolveKeys(id) {
171
+ var taken = {};
172
+ function claim(k, who) { if (!k || taken[k]) return ''; taken[k] = who; return k; }
173
+ function free(pref, label, who) {
174
+ var got = claim(norm(pref), who);
175
+ if (got || !pref) return got;
176
+ var pool = String(label || '').toLowerCase().replace(/[^a-z]/g, '') + 'abcdefghijklmnopqrstuvwxyz';
177
+ for (var i = 0; i < pool.length; i++) if (AVOID.indexOf(pool[i]) < 0 && claim(pool[i], who)) return pool[i];
178
+ return '';
179
+ }
180
+ claim(SHEET_KEY, 'sheet');
181
+ claim(CLOSE_KEY, 'close');
182
+ var sa = scene ? scene.actions : [], sk = scene ? scene.keys : [];
183
+ var place = sa.map(function () { return ''; });
184
+ sa.forEach(function (a, i) { if (!a.bind) place[i] = claim(norm(a.key), 'place:' + i); });
185
+ var lines = sk.map(function (k, i) { return claim(norm(k.key), 'line:' + i); });
186
+ var acts = actionsFor(id).map(function (a) {
187
+ var c = {}; for (var p in a) c[p] = a[p];
188
+ c.key = free(a.key, a.label, a.id);
189
+ return c;
190
+ });
191
+ sa.forEach(function (a, i) { if (a.bind) place[i] = free(a.key, a.label, 'place:' + i); });
192
+ var opts = { id: OPTIONS.id, label: OPTIONS.label, href: OPTIONS.href, key: free(OPTIONS.key, OPTIONS.label, OPTIONS.id) };
193
+ return { actions: acts, place: place, lines: lines, options: opts, taken: taken };
194
+ }
195
+
154
196
  // ---------- what the scene has picked (task 1005243) ----------
155
197
  // A scene that picks something (Home's tower) hands the visor a small card about it:
156
198
  // a title, one status, a few lines, and at most one action. The action becomes the
@@ -227,7 +269,7 @@
227
269
  svgOpen('visor__notch', '0 0 12 9') + twice('<path class="visor__line" d="M0 0l6 9l6 -9"/>') + '</svg>' +
228
270
  svgOpen('visor__sight', '0 0 24 24') + twice('<circle class="visor__line" cx="12" cy="12" r="11"/>') + '<circle class="visor__dot" cx="12" cy="12" r="2"/></svg>';
229
271
  }
230
- function keyHtml(k) { k = String(k); return '<kbd class="visor__key">' + esc(k.length === 1 ? k.toUpperCase() : k) + '</kbd>'; }
272
+ function keyHtml(k) { k = String(k || ''); if (!k) return ''; if (k === CLOSE_KEY) k = 'Esc'; return '<kbd class="visor__key">' + esc(k.length === 1 ? k.toUpperCase() : k) + '</kbd>'; }
231
273
  // The count and its unit are two spans, so the number can be white and the word ice; the
232
274
  // text between them is still creditsText's, word for word.
233
275
  function creditsHtml(n) {
@@ -240,14 +282,14 @@
240
282
  return '<a class="visor__hint" data-visor-act="' + esc(a.id) + '" ' + actionAttrs(a) + '>' + keyHtml(a.key) + '<span>' + esc(a.label) + '</span></a>';
241
283
  }
242
284
  // The scene's lead action, as the first quiet hint (task 1005209).
243
- function placeHintHtml(a, i) {
244
- return '<button class="visor__hint visor__hint--place" type="button" data-visor-scene="' + i + '">' + (a.key ? keyHtml(a.key) : '') + '<span>' + esc(a.label) + '</span></button>';
285
+ function placeHintHtml(a, i, key) {
286
+ return '<button class="visor__hint visor__hint--place" type="button" data-visor-scene="' + i + '">' + keyHtml(key) + '<span>' + esc(a.label) + '</span></button>';
245
287
  }
246
288
  function actionAttrs(a) {
247
289
  return a.href ? 'href="' + esc(hallHref(a.href)) + '"' : 'href="#" role="button"';
248
290
  }
249
291
  function glassHtml(id) {
250
- var acts = actionsFor(id), main = acts[0], C = craft();
292
+ var keys = resolveKeys(id), acts = keys.actions, main = acts[0], C = craft();
251
293
  // Your picture on the glass is you wearing the visor (task 1005218); an older craft-mode
252
294
  // without that picture still hands over the whole figure.
253
295
  var sprite = C && typeof C.portraitSrc === 'function' ? C.portraitSrc(id) : (C && typeof C.spriteSrc === 'function' ? C.spriteSrc(id) : '');
@@ -263,10 +305,9 @@
263
305
  '<span class="visor__credits" data-visor-credits>' + creditsHtml(n) + '</span></span>' +
264
306
  '</div>' +
265
307
  mainHtml(main) +
266
- '<div class="visor__hints">' + scene.actions.map(function (a, i) { return a.lead ? placeHintHtml(a, i) : ''; }).join('') + acts.slice(1).map(hintHtml).join('') +
308
+ '<div class="visor__hints">' + scene.actions.map(function (a, i) { return a.lead ? placeHintHtml(a, i, keys.place[i]) : ''; }).join('') + acts.slice(1).map(hintHtml).join('') +
267
309
  '<button class="visor__hint visor__hint--sheet" type="button" data-visor-sheet aria-expanded="false" aria-controls="visor-sheet">' +
268
310
  '<span class="visor__dots" aria-hidden="true"></span>' + keyHtml(SHEET_KEY) + '<span>Actions</span></button>' +
269
- '<a class="visor__hint visor__hint--options" href="' + esc(hallHref('/settings')) + '">' + keyHtml('Esc') + '<span>Options</span></a>' +
270
311
  '</div>' +
271
312
  '</div>';
272
313
  }
@@ -280,20 +321,22 @@
280
321
  '<p class="visor__progress-words"><span class="sr-only">' + esc(said) + '</span>' +
281
322
  '<span aria-hidden="true">' + esc(progress.id) + ' · ' + progress.pct + '%</span></p></div>';
282
323
  }
324
+ // Options lives only in here (the owner, 2026-11-12), on a key of its own: Escape is Close.
283
325
  function sheetHtml(id) {
284
- var rows = actionsFor(id).map(function (a) {
326
+ var keys = resolveKeys(id);
327
+ var rows = keys.actions.map(function (a) {
285
328
  return '<li><a class="visor__row" data-visor-act="' + esc(a.id) + '" ' + actionAttrs(a) + '><span>' + esc(a.label) + '</span>' + keyHtml(a.key) + '</a></li>';
286
329
  }).join('');
287
330
  var place = scene.actions.map(function (a, i) {
288
- return '<li><button class="visor__row" type="button" data-visor-scene="' + i + '"><span>' + esc(a.label) + '</span>' + (a.key ? keyHtml(a.key) : '') + '</button></li>';
289
- }).concat(scene.keys.map(function (k) {
290
- return '<li><span class="visor__row visor__row--hint"><span>' + esc(k.label) + '</span>' + keyHtml(k.key) + '</span></li>';
331
+ return '<li><button class="visor__row" type="button" data-visor-scene="' + i + '"><span>' + esc(a.label) + '</span>' + keyHtml(keys.place[i]) + '</button></li>';
332
+ }).concat(scene.keys.map(function (k, i) {
333
+ return '<li><span class="visor__row visor__row--hint"><span>' + esc(k.label) + '</span>' + keyHtml(keys.lines[i]) + '</span></li>';
291
334
  })).join('');
292
335
  return '<div class="visor__sheet-head"><h2 class="visor__sheet-title" id="visor-sheet-title">Actions</h2>' +
293
- '<button class="visor__close" type="button" data-visor-close aria-label="Close actions">' + keyHtml('Esc') + '</button></div>' +
336
+ '<button class="visor__close" type="button" data-visor-close aria-label="Close actions">' + keyHtml(CLOSE_KEY) + '</button></div>' +
294
337
  '<p class="visor__cap">' + esc(NAMES[id]) + '</p><ul class="visor__rows">' + rows + '</ul>' +
295
338
  (place ? '<p class="visor__cap">This place</p><ul class="visor__rows">' + place + '</ul>' : '') +
296
- '<ul class="visor__rows"><li><a class="visor__row" href="' + esc(hallHref('/settings')) + '"><span>Options</span>' + keyHtml('Esc') + '</a></li></ul>';
339
+ '<ul class="visor__rows"><li><a class="visor__row" data-visor-act="options" href="' + esc(hallHref(keys.options.href)) + '"><span>' + esc(keys.options.label) + '</span>' + keyHtml(keys.options.key) + '</a></li></ul>';
297
340
  }
298
341
  function shown() { return !!(root && !root.hidden); }
299
342
  function render() {
@@ -375,10 +418,12 @@
375
418
  if (C && typeof C.openTool === 'function') C.openTool(a.tool);
376
419
  return;
377
420
  }
421
+ // Options opens the game's options window over the place when it can (shell-game.js)
422
+ if (a.id === OPTIONS.id && window.OTBOptionsOverlay && typeof window.OTBOptionsOverlay.open === 'function') { window.OTBOptionsOverlay.open(); return; }
378
423
  if (a.href) window.location.assign(hallHref(a.href));
379
424
  }
380
425
  function find(id) {
381
- var acts = actionsFor(character());
426
+ var acts = resolveKeys(character()).actions;
382
427
  for (var i = 0; i < acts.length; i++) if (acts[i].id === id) return acts[i];
383
428
  return null;
384
429
  }
@@ -415,11 +460,12 @@
415
460
  if (!e || e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey || typeof e.key !== 'string' || e.key.length !== 1) return null;
416
461
  var k = e.key.toLowerCase();
417
462
  if (k === SHEET_KEY) return 'sheet';
418
- var acts = actionsFor(id);
419
- for (var i = 0; i < acts.length; i++) if (acts[i].key === k) return acts[i];
420
- // then the keys the visor binds for the place (task 1005209); the character's win a clash
463
+ // the resolver gave each key one owner, so the first match is the only one
464
+ var keys = resolveKeys(id);
465
+ for (var i = 0; i < keys.actions.length; i++) if (keys.actions[i].key === k) return keys.actions[i];
421
466
  var own = scene ? scene.actions : [];
422
- for (var j = 0; j < own.length; j++) if (own[j].bind && own[j].key === k) return own[j];
467
+ for (var j = 0; j < own.length; j++) if (own[j].bind && keys.place[j] === k) return own[j];
468
+ if (keys.options.key === k) return keys.options;
423
469
  return null;
424
470
  }
425
471
  // Bubble, on window: a scene or pop-up that used the key has called preventDefault by now.
@@ -460,6 +506,7 @@
460
506
  ACTIONS: ACTIONS,
461
507
  actionsFor: actionsFor,
462
508
  keyFor: keyFor,
509
+ resolveKeys: resolveKeys,
463
510
  creditsText: creditsText,
464
511
  character: character,
465
512
  pickVersion: pickVersion,
@@ -6,6 +6,13 @@ Covers: `visor.js` + `visor.css`, loaded on Home, `/city`, `/goal-map` and `/thi
6
6
 
7
7
  The visor is the control and the scene is the view: every main call to action in game mode is on the glass (the accent button, the key hints, the Actions sheet), and a scene carries none of its own. A scene that has something to do registers it with `OTBVisor.scene({ actions, keys })`. ADR 0403 §3 lists the scenes that do not keep it yet; the Governor City keeps it since task 1005209 the sky since task 1005210, and Home's gallery since task 1005211 (below).
8
8
 
9
+ ## One owner per key (task 1005248, owner 2026-11-12)
10
+
11
+ - **The bug.** The Artist saw Esc twice: "Options" on the glass, then in the Actions sheet both Close and the Options row said Esc.
12
+ - **The rule.** A key in `ACTIONS` or a scene's registration is a recommendation. `resolveKeys(id)` in `visor.js` hands every key out once, and the glass, the sheet and `keyFor` all read it; nothing else names a key. Who keeps a key, in order: the visor's own (A Actions, Escape Close); the keys a scene binds itself (an action without `bind`, the `keys` lines), because the visor cannot move a key it does not bind, so a second claim on one shows no key; the character's actions; the scene's `bind` actions; Options. A later claimant moves to the next free letter of its own name, then of the alphabet, never onto a key a scene binds on its page (`AVOID`: L 0 / ? F K + - =); with none free it has no key.
13
+ - **Options is only in the sheet** (the owner likes it there), on O, or its next free letter (the Patron's O is the Board room, so P). Pressing it opens the options window over the place, like a click. Escape on the glass is the sheet's Close only. The shell's "last Escape opens the options menu" (`shell-game.js`, ADR 0399 §2.4) is not a visor key and is unchanged.
14
+ - **Tests:** `tests/hall_visor.mjs` proves no shared key for every character over the sky, the city and Home, and over a hostile scene that claims A, Escape, a character key and one key twice.
15
+
9
16
  ## A place's own buttons on the glass (task 1005209, the Governor City first)
10
17
 
11
18
  - **The scene contract grew two flags.** A scene action's `key` is still a key the scene binds itself, shown beside it in the sheet. With `bind: true` the visor binds that key instead, after the character's keys (a clash goes to the character), for a scene button that had no key of its own. With `lead: true` the action is also the first quiet key hint on the glass (`.visor__hint--place`). Every scene action is still a row under "This place" in the sheet.
@@ -26,7 +26,7 @@ const log = api.logger('render-deploy');
26
26
  module.exports = function buildRenderDeployActRouter({ makeClient = defaultMakeClient, pool } = {}) {
27
27
  const router = express.Router();
28
28
 
29
- router.post('/render-deploy/act', api.requireBuilder, api.requirePermission('core.pin.move'), api.requireActAlone('release'), async (req, res) => {
29
+ router.post('/render-deploy/act', api.requireBuilder, api.requirePermission('core.pin.move'), api.requireCardStepUp, api.requireActAlone('release'), async (req, res) => {
30
30
  const db = pool || api.pool;
31
31
  // Shape only, and strict: an undeclared field is refused. `confirm` and `expectLiveDeployId`
32
32
  // are left to the checks below so each gets its own plain refusal rather than a schema one.
@@ -35,7 +35,7 @@ module.exports = function buildRenderDeployAppRouter({ makeClient = defaultMakeC
35
35
  }
36
36
  });
37
37
 
38
- router.put('/render-deploy/app', api.requireBuilder, api.requirePermission('core.pin.move'), async (req, res) => {
38
+ router.put('/render-deploy/app', api.requireBuilder, api.requirePermission('core.pin.move'), api.requireCardStepUp, async (req, res) => {
39
39
  if (api.validateOrRespond(req, res, {
40
40
  ownerId: { type: 'string', maxLength: 80 },
41
41
  serviceId: { type: 'string', maxLength: 80 },
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.96",
3
+ "version": "1.25.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.24.96",
9
+ "version": "1.25.2",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.96",
3
+ "version": "1.25.2",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -11689,5 +11689,22 @@
11689
11689
  "id": "1005243",
11690
11690
  "text": "In game mode, tap a goal tower on Home to send your Autobongos runner there with one visor button; the tower then shows your runner at work, and tapping it shows tasks done, tokens used, time left and a Recall button."
11691
11691
  }
11692
+ ],
11693
+ "1.25.0": [],
11694
+ "1.25.1": [
11695
+ {
11696
+ "id": "1004747",
11697
+ "text": "Sensitive actions (rank changes, deploys, signing sessions out, treasury payouts) can now require a tap of your hardware card within the last 10 minutes. Nobody is required to yet; an Archon switch comes next."
11698
+ },
11699
+ {
11700
+ "id": "1005040",
11701
+ "text": "Added the Stripe Connect test-mode payment adapter (economy.processor port). Every payout, cash-out, balance and ledger-check call now has a processor to talk to once a test key is set; nothing is live."
11702
+ }
11703
+ ],
11704
+ "1.25.2": [
11705
+ {
11706
+ "id": "1005248",
11707
+ "text": "In game mode, no two buttons on the visor share a hotkey any more, and Options now sits only inside the Actions menu with its own key."
11708
+ }
11692
11709
  ]
11693
11710
  }
@@ -619,7 +619,7 @@ const KERNEL_FILES = [
619
619
  // by the doorway, so leaving it off reads as a kernel->non-kernel leak. It imports
620
620
  // only module-seams.js and logger.js (both on this roster) and reaches the
621
621
  // government module through its port, never by require.
622
- 'src/bongos/act-alone.js',
622
+ 'src/bongos/act-alone.js', 'src/bongos/step-up.js', 'src/bongos/passkeys.js', // trust gate + its store (task 1004747, ADR 0382)
623
623
  ];
624
624
 
625
625
  // Kernel files exempt from the no-module-keys scan (Check 10) because naming a
@@ -687,13 +687,13 @@ async function runWalk() {
687
687
 
688
688
  // The session: whoever the request says it is, from this project's people.
689
689
  const asHeader = 'x-walk-builder';
690
- const sessionHeader = 'x-walk-session';
690
+ const sessionHeader = 'x-walk-session'; req('src/bongos/passkeys').stepUpStatus = async () => ({ required: false, fresh: false }); // task 1004747: no card is required in the walk
691
691
  const fakeBuilder = (r, res, nextFn) => {
692
692
  const b = store.builders.get(String(r.headers[asHeader] || ''));
693
693
  if (!b || b.status !== 'active') return res.status(401).json({ error: { code: 'not_signed_in' } });
694
694
  r.builder = { id: Number(b.id), rank: b.rank, github_login: b.github_login, display_name: b.display_name };
695
695
  // an AI session's CLI token, or a person in the hall (task 1004704's line)
696
- r.gdsSession = { source: r.headers[sessionHeader] === 'cli' ? 'cli' : 'web' };
696
+ r.gdsSession = { source: r.headers[sessionHeader] === 'cli' ? 'cli' : 'web', session_id: '1' }; // session_id: the step-up gate (task 1004747) needs one
697
697
  return nextFn();
698
698
  };
699
699
  api.requireBuilder = fakeBuilder;
@@ -15,6 +15,8 @@ const crypto = require('node:crypto');
15
15
  const CHALLENGE_TTL_SECONDS = 300;
16
16
  const MAX_CHALLENGE_TTL_SECONDS = 600;
17
17
  const PURPOSES = Object.freeze(['register', 'authenticate']);
18
+ // How long one card tap keeps a session cleared for the protected actions (ADR 0382 D2).
19
+ const STEP_UP_WINDOW_SECONDS = 600;
18
20
 
19
21
  function defaultPool() { return require('./pool').pool; }
20
22
 
@@ -146,7 +148,35 @@ async function renameCredential(builderId, credentialId, nickname, { pool = defa
146
148
  return r.rowCount > 0;
147
149
  }
148
150
 
151
+ // Stamp the session that just passed a card check. Keyed on the builder as well, so a
152
+ // session id that is not the caller's matches no row. Returns true when stamped.
153
+ async function markSessionCardVerified(sessionId, builderId, { pool = defaultPool() } = {}) {
154
+ const r = await pool.query(
155
+ `UPDATE builder_sessions SET card_verified_at = now()
156
+ WHERE id = $1 AND builder_id = $2 AND revoked_at IS NULL`,
157
+ [sessionId, builderId],
158
+ );
159
+ return r.rowCount > 0;
160
+ }
161
+
162
+ // Where this session stands: is a card required of the builder, and is the session's
163
+ // last tap inside the window? Read fresh from the DB on every call, never cached.
164
+ async function stepUpStatus(sessionId, builderId, { pool = defaultPool(), windowSeconds = STEP_UP_WINDOW_SECONDS } = {}) {
165
+ const { rows } = await pool.query(
166
+ `SELECT b.require_card,
167
+ (s.card_verified_at IS NOT NULL
168
+ AND s.card_verified_at > now() - make_interval(secs => $3::int)) AS fresh
169
+ FROM builders b JOIN builder_sessions s ON s.builder_id = b.id
170
+ WHERE b.id = $1 AND s.id = $2`,
171
+ [builderId, sessionId, String(windowSeconds)],
172
+ );
173
+ const r = rows[0];
174
+ if (!r) return null;
175
+ return { required: r.require_card === true, fresh: r.fresh === true };
176
+ }
177
+
149
178
  module.exports = {
179
+ STEP_UP_WINDOW_SECONDS, markSessionCardVerified, stepUpStatus,
150
180
  CHALLENGE_TTL_SECONDS, MAX_CHALLENGE_TTL_SECONDS, PURPOSES, clampTtlSeconds, counterAdvances,
151
181
  createChallenge, consumeChallenge, sweepExpiredChallenges,
152
182
  getOrCreateUserHandle, addCredential, getCredentialById, listCredentials, recordUse, removeCredential, renameCredential,
@@ -1418,7 +1418,7 @@ module.exports = function buildAuthRouter() {
1418
1418
  // rank: archon — kills an arbitrary session; can lock other builders out. Gate:
1419
1419
  // requirePermission('auth.session.revoke') — BV1.R104; the seeded rank-role makes
1420
1420
  // the permission and the old rank floor identical (ADR 0151).
1421
- router.post('/auth/revoke', auth.requireBuilder, auth.requirePermission('auth.session.revoke'), async (req, res) => {
1421
+ router.post('/auth/revoke', auth.requireBuilder, auth.requirePermission('auth.session.revoke'), require('../step-up').requireCardStepUp, async (req, res) => {
1422
1422
  if (validateOrRespond(req, res, { session_id: {} })) return; // bigint as number or string; validated below
1423
1423
  const sessionId = req.body && req.body.session_id;
1424
1424
  if (sessionId === undefined || sessionId === null) {
@@ -1446,7 +1446,7 @@ module.exports = function buildAuthRouter() {
1446
1446
  // rank: archon — same authority as PATCH /builders/:id/rank. Gate:
1447
1447
  // requirePermission('auth.session.revoke') — BV1.R104; the same atom /auth/revoke
1448
1448
  // gates on (ADR 0151).
1449
- router.post('/auth/revoke-all', auth.requireBuilder, auth.requirePermission('auth.session.revoke'), async (req, res) => {
1449
+ router.post('/auth/revoke-all', auth.requireBuilder, auth.requirePermission('auth.session.revoke'), require('../step-up').requireCardStepUp, async (req, res) => {
1450
1450
  if (validateOrRespond(req, res, { builder_id: {} })) return; // bigint as number or string; validated below
1451
1451
  const builderId = req.body && req.body.builder_id;
1452
1452
  if (builderId === undefined || builderId === null) {
@@ -24,6 +24,7 @@
24
24
 
25
25
  const express = require('express');
26
26
  const auth = require('../auth');
27
+ const { requireCardStepUp } = require('../step-up');
27
28
  // WHO DECIDES AT TREASURY (task 1004657; ADR 0363 D5): the budget write refuses a
28
29
  // lone act while Treasury's decision-rules row names a sitting. A passed Treasury
29
30
  // matter sets the limit through setMonthlyBudget directly (the governor.matterAct
@@ -271,7 +272,7 @@ module.exports = function buildBuildersRouter() {
271
272
  // the permission and the old rank floor identical (ADR 0151). The atom is
272
273
  // `system: true`, so no CUSTOM role may ever be granted it — only the seeded archon
273
274
  // role holds it, which is exactly the meta-escalation wall ADR 0016 wants.
274
- router.patch('/builders/:id/rank', auth.requireBuilder, auth.requirePermission('builder.rank.set'), async (req, res) => {
275
+ router.patch('/builders/:id/rank', auth.requireBuilder, auth.requirePermission('builder.rank.set'), requireCardStepUp, async (req, res) => {
275
276
  if (validateOrRespond(req, res, {
276
277
  rank: { required: true, type: 'string', maxLength: 32 },
277
278
  })) return;
@@ -28,6 +28,8 @@ module.exports = function buildModulePayoutsRouter({
28
28
  requirePermission = auth.requirePermission,
29
29
  } = {}) {
30
30
  const router = express.Router();
31
+ // the processor adapter (modules/economy/processor-stripe.js, task 1005040) is a port, resolved per call
32
+ const proc = () => processor || require('../../module-seams').resolveOptional('economy.processor');
31
33
 
32
34
  function personal(req, res) {
33
35
  const p = store.personalSession(req);
@@ -74,7 +76,7 @@ module.exports = function buildModulePayoutsRouter({
74
76
  if (!actor) return;
75
77
  if (validateOrRespond(req, res, { processor_sale_ref: { required: true } })) return;
76
78
  try {
77
- const out = await payouts.recordSale({ processorSaleRef: req.body.processor_sale_ref, actorId: actor.id }, { processor });
79
+ const out = await payouts.recordSale({ processorSaleRef: req.body.processor_sale_ref, actorId: actor.id }, { processor: proc() });
78
80
  res.status(out.already ? 200 : 201).json(out);
79
81
  } catch (err) { fail(res, err, 'POST /payouts/module-sales'); }
80
82
  });
@@ -104,7 +106,7 @@ module.exports = function buildModulePayoutsRouter({
104
106
  if (!actor) return;
105
107
  if (validateOrRespond(req, res, { approval_id: { required: true } })) return;
106
108
  try {
107
- const out = await payouts.payShare({ shareId: req.params.id, approvalId: req.body.approval_id, actorId: actor.id }, { processor });
109
+ const out = await payouts.payShare({ shareId: req.params.id, approvalId: req.body.approval_id, actorId: actor.id }, { processor: proc() });
108
110
  res.set('Cache-Control', 'no-store');
109
111
  res.json(out);
110
112
  } catch (err) { fail(res, err, 'POST /payouts/module-shares/:id/pay'); }
@@ -30,6 +30,8 @@ function buildMyCashOutsRouter({
30
30
  requireBuilder = auth.requireBuilder,
31
31
  } = {}) {
32
32
  const router = express.Router();
33
+ // the processor adapter (modules/economy/processor-stripe.js, task 1005040) is a port, resolved per call
34
+ const proc = () => processor || require('../../module-seams').resolveOptional('economy.processor');
33
35
 
34
36
  function personal(req, res) {
35
37
  const p = approvals.personalSession(req);
@@ -62,7 +64,7 @@ function buildMyCashOutsRouter({
62
64
  // GET /api/bongos/me/cash-outs — my withdrawals and where each stands.
63
65
  router.get('/me/cash-outs', requireBuilder, async (req, res) => {
64
66
  try {
65
- const out = await cashOuts.listMyCashOuts(req.builder.id, { processor, limit: req.query.limit, log });
67
+ const out = await cashOuts.listMyCashOuts(req.builder.id, { processor: proc(), limit: req.query.limit, log });
66
68
  res.set('Cache-Control', 'no-store');
67
69
  res.json(out);
68
70
  } catch (err) { fault(res, err, 'GET /me/cash-outs'); }
@@ -76,7 +78,7 @@ function buildMyCashOutsRouter({
76
78
  try {
77
79
  const out = await cashOuts.requestCashOut({
78
80
  builderId: String(req.builder.id), actor, amountCents: req.body.amount_cents, instructionId: req.body.instruction_id,
79
- }, { processor, log });
81
+ }, { processor: proc(), log });
80
82
  answer(res, out);
81
83
  } catch (err) { fault(res, err, 'POST /me/cash-outs'); }
82
84
  });
@@ -85,7 +87,7 @@ function buildMyCashOutsRouter({
85
87
  router.post('/me/cash-outs/:id/retry', requireBuilder, async (req, res) => {
86
88
  if (!personal(req, res)) return;
87
89
  try {
88
- answer(res, await cashOuts.retryCashOut({ builderId: String(req.builder.id), cashOutId: req.params.id }, { processor }));
90
+ answer(res, await cashOuts.retryCashOut({ builderId: String(req.builder.id), cashOutId: req.params.id }, { processor: proc() }));
89
91
  } catch (err) { fault(res, err, 'POST /me/cash-outs/:id/retry'); }
90
92
  });
91
93