@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
@@ -273,6 +273,8 @@ store_module_prices(id, module_key, price_cents, price_credits, set_by, set_at)
273
273
 
274
274
  Passkey foundation (migration `core_277`, task 1004750, [ADR 0382](adr/0382-a-hardware-card-is-a-passkey-step-up-an-archon-switches-on-per-builder.md) D1/D6 — storage only; the ceremonies and routes are their own tasks; code in `src/bongos/passkeys.js`):
275
275
 
276
+ Card step-up gate (migration `core_298`, task 1004747, ADR 0382 D2/D5; code in `src/bongos/step-up.js`): `builders.require_card` (default false) + `builder_sessions.card_verified_at`, stamped only by `POST /me/passkeys/authenticate/verify`. `requireCardStepUp` (also `api.requireCardStepUp` for modules) refuses 403 `card_required` when the builder's flag is on and the session has no card tap in the last 10 minutes; it fails closed. Mounted on rank set, admin session revoke (the own-session panic button stays open), render-deploy act + PUT app, treasury pots/borrow answers. The Archon switch that sets the flag is task 1004748.
277
+
276
278
  ```
277
279
  passkey_credentials(id, credential_id UNIQUE, builder_id FK, public_key bytea, sign_count,
278
280
  transports[], aaguid, nickname, created_at, last_used_at)
@@ -322,7 +324,7 @@ payout_tax_details(id, builder_id, status collected|incomplete, collected_by pro
322
324
  -- POST /payouts/ndas, POST /payouts/tax-details (payout.approve + web session).
323
325
  ```
324
326
 
325
- Builder payouts (migration `core_290`, task 1003183, [ADR 0397](adr/0397-the-payment-rail-is-stripe-connect-with-one-connected-account-per-project.md) D7 — a builder is paid for a task exactly once; `src/bongos/builder-payouts.js`, routes `GET`/`POST /payouts/builder-payouts` gated `payout.approve` plus the person's own web session for the POST; the `payout` movement is booked through the economy module's `economy.account` port in the same transaction. No processor adapter exists yet, so every payout is recorded owed and held `no_processor`):
327
+ Builder payouts (migration `core_298`, task 1003183, [ADR 0397](adr/0397-the-payment-rail-is-stripe-connect-with-one-connected-account-per-project.md) D7 — a builder is paid for a task exactly once; `src/bongos/builder-payouts.js`, routes `GET`/`POST /payouts/builder-payouts` gated `payout.approve` plus the person's own web session for the POST; the `payout` movement is booked through the economy module's `economy.account` port in the same transaction. No processor adapter exists yet, so every payout is recorded owed and held `no_processor`):
326
328
 
327
329
  ```
328
330
  builder_payouts_owed(id, builder_id FK, task_id FK, approval_id FK payout_approvals,
@@ -316,7 +316,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
316
316
  | `4df46e2be815` | certain | Clear the key? | `modules/hall-ui/public/market-watch-settings.js:218` |
317
317
  | `593dda3178eb` | certain | Click to copy | `modules/hall-ui/public/copy-on-click.js:137` |
318
318
  | `34624d39ff29` | certain | Clone the project to your machine and run | `modules/hall-ui/public/setup-gate.js:34` |
319
- | `3a426bad2c49` | certain | Close actions | `modules/hall-ui/public/visor.js:293` |
319
+ | `3a426bad2c49` | certain | Close actions | `modules/hall-ui/public/visor.js:336` |
320
320
  | `70f6a704d504` | certain | Close it | `modules/hall-ui/public/copy-desk.html:140` |
321
321
  | `053e576ba9a3` | certain | Close options | `modules/hall-ui/public/shell-game.js:454` |
322
322
  | `d12a2fb5730d` | certain | Close this flag | `modules/hall-ui/public/copy-desk.html:116` |
@@ -1486,7 +1486,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
1486
1486
  | `9a685b01e2c6` | certain | This page opens at a higher rank. | `modules/hall-ui/public/watch.html:237` |
1487
1487
  | `e7a289134dc3` | certain | This page shows no words the reader could find. | `modules/hall-ui/public/tweak-editor.js:246` |
1488
1488
  | `d4b3b5556986` | certain | This page's reference docs | `modules/hall-ui/public/page-notes.js:166` |
1489
- | `79e4a934b434` | certain | This place | `modules/hall-ui/public/visor.js:295` |
1489
+ | `79e4a934b434` | certain | This place | `modules/hall-ui/public/visor.js:338` |
1490
1490
  | `5bdcbec770dd` | certain | This project has no slash commands yet. | `modules/hall-ui/public/commands.js:37` |
1491
1491
  | `f53b83bfbe54` | certain | This project has no watch rules yet. | `modules/hall-ui/public/market-watch-rules.js:62` |
1492
1492
  | `e97b2d549f10` | certain | This project is hosted on {…}, and that is where it is taken offline or removed. Both links below open this pr | `modules/hall-ui/public/project-settings.js:412` |
@@ -5208,7 +5208,7 @@
5208
5208
  "surface": "builders-hall",
5209
5209
  "text": "Close actions",
5210
5210
  "file": "modules/hall-ui/public/visor.js",
5211
- "line": 293,
5211
+ "line": 336,
5212
5212
  "origin": "js-markup",
5213
5213
  "confidence": "certain"
5214
5214
  },
@@ -15738,7 +15738,7 @@
15738
15738
  "surface": "builders-hall",
15739
15739
  "text": "This place",
15740
15740
  "file": "modules/hall-ui/public/visor.js",
15741
- "line": 295,
15741
+ "line": 338,
15742
15742
  "origin": "js-markup",
15743
15743
  "confidence": "certain"
15744
15744
  },
@@ -3440,5 +3440,11 @@ is load-bearing: the script throws rather than guess if it is missing, and
3440
3440
  landed since 1.24.94 with no explicit bump. run 43304959846. (task 1002620)
3441
3441
  1.24.96 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3442
3442
  landed since 1.24.95 with no explicit bump. run 43307125735. (task 1002620)
3443
+ 1.25.0 — adds `requireCardStepUp`, the hardware-card step-up gate a module mounts
3444
+ after its permission gate (task 1004747, ADR 0382). Additive, so MINOR.
3445
+ 1.25.1 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3446
+ landed since 1.25.0 with no explicit bump. run 43313162829. (task 1002620)
3447
+ 1.25.2 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3448
+ landed since 1.25.1 with no explicit bump. run 43325008692. (task 1002620)
3443
3449
  ---------------------------------------------------------------------------
3444
3450
  ```
@@ -283,7 +283,7 @@
283
283
  "surface": "builders",
284
284
  "title": "The city",
285
285
  "states_read": ["city","fullscreen","focus","pins","living","card","card-ask"],
286
- "files_hash": "0367a0e57f2fcfb1",
286
+ "files_hash": "573bb0fbcdcafa9a",
287
287
  "reading_hash": "277096d903879a57",
288
288
  "counts": {"lines":26,"placed":9,"shared":6,"unplaced":11},
289
289
  "omitted": {"data":7},
@@ -613,7 +613,7 @@
613
613
  "surface": "builders",
614
614
  "title": "The sky",
615
615
  "states_read": ["tour","sky","wells","ledger","full","full-back","out"],
616
- "files_hash": "9de2854d844d990f",
616
+ "files_hash": "fe5e504a7e12bca9",
617
617
  "reading_hash": "4a879397a84631e3",
618
618
  "counts": {"lines":50,"placed":25,"shared":6,"unplaced":19},
619
619
  "omitted": {"data":10},
@@ -954,8 +954,8 @@
954
954
  "surface": "builders",
955
955
  "title": "Builders' Hall",
956
956
  "states_read": ["home","out","founding","genesis","decide","demo"],
957
- "files_hash": "36c7410d9c8aeae0",
958
- "reading_hash": "45468116976bf674",
957
+ "files_hash": "b3b116e114d183f4",
958
+ "reading_hash": "59797028e6124c9c",
959
959
  "counts": {"lines":32,"placed":12,"shared":8,"unplaced":12},
960
960
  "omitted": {"data":5},
961
961
  "notes": ["founding: an action failed (page.focus: Timeout 6000ms exceeded.); read as it stood"],
@@ -977,7 +977,7 @@
977
977
  {"key":"L0015","section":"Needs your attention13","text":"Ideas awaiting triage","placement":"placed","file":"modules/hall-ui/public/builders.js","line":320,"string_id":"e181637c5a5e"},
978
978
  {"key":"L0016","section":"Needs your attention13","text":"Requires project {…} fix","placement":"unplaced"},
979
979
  {"key":"L0017","section":"footer","text":"Sign out","placement":"shared","file":"modules/hall-ui/public/shell.js","line":514,"string_id":"1883ad7f07fc"},
980
- {"key":"L0018","section":"Needs your attention13","text":"core 1.24.94","placement":"unplaced"},
980
+ {"key":"L0018","section":"Needs your attention13","text":"core 1.25.1","placement":"unplaced"},
981
981
  {"key":"L0019","section":"Needs your attention13","text":"Skip","placement":"unplaced"},
982
982
  {"key":"L0020","section":"Needs your attention13","text":"Built on","placement":"placed","file":"modules/hall-ui/public/welcome-boot.js","line":183,"string_id":"fef59346ffee"},
983
983
  {"key":"L0021","section":"Needs your attention13","text":"cloudbongos.com","placement":"unplaced"},
@@ -3159,7 +3159,7 @@
3159
3159
  "surface": "builders",
3160
3160
  "title": "Your thinking",
3161
3161
  "states_read": ["tour","sky","list","simple","full-idea","full","full-list","full-back","out"],
3162
- "files_hash": "96c460b71d47125f",
3162
+ "files_hash": "cd0f2d8f522becc8",
3163
3163
  "reading_hash": "b73f848b46b0f9b8",
3164
3164
  "counts": {"lines":50,"placed":25,"shared":6,"unplaced":19},
3165
3165
  "omitted": {"data":10},
@@ -0,0 +1,16 @@
1
+ -- core_298 card step-up: the per-builder requirement flag and the per-session proof
2
+ -- Applied under task 1004747 (ADR 0382 D2, D4).
3
+ --
4
+ -- WHY. A builder who has a registered hardware card can be required to tap it before
5
+ -- the sensitive actions (rank changes, deploy, session revoke, payouts). The server
6
+ -- needs two things to keep: whether the requirement is on for a builder, and when a
7
+ -- given session last proved a card.
8
+ --
9
+ -- * builders.require_card default false: nothing changes for anybody until an
10
+ -- Archon switches it on (that switch is its own task). Read fresh per request.
11
+ -- * builder_sessions.card_verified_at set ONLY by the card verify route, on the
12
+ -- server-side session row. Nothing the client sends counts as proof, and a new
13
+ -- session starts with none.
14
+
15
+ ALTER TABLE builders ADD COLUMN IF NOT EXISTS require_card boolean NOT NULL DEFAULT false;
16
+ ALTER TABLE builder_sessions ADD COLUMN IF NOT EXISTS card_verified_at timestamptz;
@@ -8,6 +8,6 @@
8
8
  "default": true,
9
9
  "maintenance": { "status": "core-maintained" },
10
10
  "contributes": { "migrations": true, "routes": ["cost", "credits", "earnings", "leaderboard", "project-account", "balances", "reward"], "pollers": ["ledger-check"] },
11
- "provides": ["reward", "economy.account"],
11
+ "provides": ["reward", "economy.account", "economy.processor"],
12
12
  "consumes": []
13
13
  }
@@ -0,0 +1,292 @@
1
+ 'use strict';
2
+
3
+ // modules/economy/processor-stripe.js — THE PAYMENT-RAIL ADAPTER: Stripe Connect, TEST MODE
4
+ // ONLY (task 1005040, goal 1000092; ADR 0397 D1-D3, D5, D7).
5
+ //
6
+ // This is the one file that may name Stripe (ADR 0397: "a processor adapter module is the
7
+ // only place that may name it"). Everything else calls the `economy.processor` port; a rail
8
+ // change (the written J.P. Morgan fallback) replaces this file, not the product.
9
+ //
10
+ // TEST MODE ONLY. The factory refuses any key that is not `sk_test_…`, so no live key can be
11
+ // stored or used by this build. Opening a live account is the owner's act and counsel's
12
+ // items 1-4 (ADR 0385/0397) still gate it; lifting this guard is a separate, deliberate task.
13
+ //
14
+ // THE PORT (every method the callers already name; each is optional to the callers, all are
15
+ // provided here):
16
+ // balanceMicro() the project's connected account balance
17
+ // memberBalanceMicro(accountRef) one person's connected account balance
18
+ // confirmMemberAccount({accountRef, builderId}) true only for an account THIS project's
19
+ // connection opened for that person (metadata we set)
20
+ // memberTransfer({idempotencyKey, amountCents, currency, fromAccount, toAccount, toProject})
21
+ // transfer({idempotencyKey, amountCents, currency, builderId, payTo, taskId|moduleShareId})
22
+ // project account → the payee (self, or the guardian)
23
+ // memberPayout({idempotencyKey, amountCents, currency, account, payTo})
24
+ // connected account → the person's bank
25
+ // readSale({ref}) a charge's own figures, never a screen's
26
+ // listMovements({since, until}) the project account's movements, by object id
27
+ // Every money-moving call carries the caller's idempotency key as Stripe's `Idempotency-Key`,
28
+ // so a retry gets back the object already made, never a second.
29
+ //
30
+ // ERROR MAPPING (ADR 0397 D7). An error carries `refused: true` ONLY when Stripe answered
31
+ // 4xx (other than 408/409/429): it understood and created nothing. A timeout, a network
32
+ // failure, a 5xx, a 409 or a 429 may have created the object, so they are not `refused` and
33
+ // the caller retries on the same key.
34
+ //
35
+ // OPEN, UNVERIFIED AGAINST A LIVE TEST ACCOUNT (no Stripe account exists yet; the owner opens
36
+ // it): account-to-account transfers (`memberTransfer`, `transfer`) are sent as a transfer made
37
+ // "on behalf of" the paying connected account (the Stripe-Account header). If Stripe's account
38
+ // type does not allow that, the call is refused and recorded `failed`; it never silently
39
+ // moves money by another route.
40
+
41
+ const crypto = require('node:crypto');
42
+
43
+ const API = 'https://api.stripe.com';
44
+ const MICRO_PER_CENT = 10_000;
45
+ const CURRENCY = 'usd';
46
+ const ACCOUNT_RE = /^acct_[A-Za-z0-9]{6,64}$/;
47
+ const WEBHOOK_TOLERANCE_S = 300;
48
+
49
+ class StripeError extends Error {
50
+ constructor(message, { code = 'stripe_error', status = null, refused = false } = {}) {
51
+ super(message);
52
+ this.name = 'StripeError';
53
+ this.code = code;
54
+ this.status = status;
55
+ this.refused = refused;
56
+ }
57
+ }
58
+
59
+ // Stripe takes form-encoded bodies, nested as a[b]=c.
60
+ function encode(params, prefix = '') {
61
+ const out = [];
62
+ for (const [k, v] of Object.entries(params || {})) {
63
+ if (v === undefined || v === null) continue;
64
+ const key = prefix ? `${prefix}[${k}]` : k;
65
+ if (typeof v === 'object') out.push(...encode(v, key));
66
+ else out.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(v))}`);
67
+ }
68
+ return out;
69
+ }
70
+
71
+ function createStripeProcessor({
72
+ secretKey, projectAccount = null, projectKey = projectAccount || 'project', webhookSecret = null,
73
+ allowedOrigin = null,
74
+ fetchImpl = globalThis.fetch, memberAccountFor = null, timeoutMs = 12_000, now = () => Date.now(),
75
+ } = {}) {
76
+ if (typeof secretKey !== 'string' || !/^sk_test_[A-Za-z0-9]{8,}$/.test(secretKey)) {
77
+ throw new StripeError('The Stripe key must be a test-mode secret key (sk_test_…); live keys are not accepted by this build.', { code: 'test_key_only' });
78
+ }
79
+ if (projectAccount !== null && !ACCOUNT_RE.test(projectAccount)) {
80
+ throw new StripeError('The project connected account must look like acct_….', { code: 'bad_project_account' });
81
+ }
82
+ if (typeof fetchImpl !== 'function') throw new StripeError('No fetch available.', { code: 'no_fetch' });
83
+
84
+ async function call(method, path, { params = null, account = null, idempotencyKey = null } = {}) {
85
+ const query = method === 'GET' && params ? `?${encode(params).join('&')}` : '';
86
+ const headers = { Authorization: `Bearer ${secretKey}` };
87
+ if (account) headers['Stripe-Account'] = account;
88
+ if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
89
+ let body;
90
+ if (method !== 'GET') { headers['Content-Type'] = 'application/x-www-form-urlencoded'; body = encode(params).join('&'); }
91
+ const ctl = new AbortController();
92
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
93
+ let res;
94
+ try {
95
+ res = await fetchImpl(`${API}${path}${query}`, { method, headers, body, signal: ctl.signal });
96
+ } catch (e) {
97
+ // may have reached Stripe: not refused
98
+ throw new StripeError(e && e.name === 'AbortError' ? 'Stripe did not answer in time' : `could not reach Stripe: ${e && e.message}`, { code: e && e.name === 'AbortError' ? 'timeout' : 'network' });
99
+ } finally { clearTimeout(timer); }
100
+ let json = null;
101
+ try { json = await res.json(); } catch { /* handled below */ }
102
+ if (res.ok && json) return json;
103
+ // a 2xx we cannot read may have created the object: not refused, retried on the same key
104
+ if (res.ok) throw new StripeError('Stripe answered but the reply could not be read', { code: 'bad_response', status: res.status });
105
+ const e = (json && json.error) || {};
106
+ const status = res.status;
107
+ const refused = status >= 400 && status < 500 && ![408, 409, 429].includes(status);
108
+ throw new StripeError(e.message || `Stripe answered ${status}`, { code: e.code || e.type || `http_${status}`, status, refused });
109
+ }
110
+
111
+ const microOf = (cents) => cents * MICRO_PER_CENT;
112
+ const checkMoney = ({ amountCents, currency }) => {
113
+ if (!Number.isSafeInteger(amountCents) || amountCents <= 0) throw new StripeError('amountCents must be a positive whole number.', { code: 'bad_amount', refused: true });
114
+ if (String(currency).toLowerCase() !== CURRENCY) throw new StripeError('Only US dollars are supported.', { code: 'currency_not_supported', refused: true });
115
+ };
116
+ const needProject = () => {
117
+ if (!projectAccount) throw new StripeError('No project connected account is configured (STRIPE_PROJECT_ACCOUNT).', { code: 'no_project_account', refused: true });
118
+ return projectAccount;
119
+ };
120
+
121
+ // Balance reads sit behind any-builder pages, so identical reads inside a few seconds share one
122
+ // Stripe call (and one in-flight request). A stale figure is harmless: Stripe refuses an overdraw.
123
+ const BALANCE_TTL_MS = 5000;
124
+ const balanceCache = new Map();
125
+ function balanceOf(account) {
126
+ const hit = balanceCache.get(account);
127
+ if (hit && now() - hit.at < BALANCE_TTL_MS) return hit.p;
128
+ const p = readBalance(account);
129
+ balanceCache.set(account, { at: now(), p });
130
+ p.catch(() => { if (balanceCache.get(account) && balanceCache.get(account).p === p) balanceCache.delete(account); });
131
+ return p;
132
+ }
133
+ // The figure counts available plus pending, so it matches what our record books on funding.
134
+ async function readBalance(account) {
135
+ const b = await call('GET', '/v1/balance', { account });
136
+ const avail = (b.available || []).filter((x) => x.currency === CURRENCY).reduce((s, x) => s + Number(x.amount), 0);
137
+ const pending = (b.pending || []).filter((x) => x.currency === CURRENCY).reduce((s, x) => s + Number(x.amount), 0);
138
+ return microOf(avail + pending);
139
+ }
140
+
141
+ // Resolve the account a payout/transfer lands in: the person's own, or the guardian's for a
142
+ // 16-17 recipient (payout-gates `pay_to`). Never taken from the request.
143
+ async function payeeAccount({ builderId, payTo }) {
144
+ if (payTo && typeof payTo === 'object') {
145
+ if (!ACCOUNT_RE.test(String(payTo.guardian_account || ''))) throw new StripeError('The guardian has no usable account.', { code: 'no_guardian_account', refused: true });
146
+ return payTo.guardian_account;
147
+ }
148
+ const acct = typeof memberAccountFor === 'function' ? await memberAccountFor(String(builderId)) : null;
149
+ if (!acct || !ACCOUNT_RE.test(acct)) throw new StripeError('The person has no connected account in this project.', { code: 'no_member_account', refused: true });
150
+ return acct;
151
+ }
152
+
153
+ const adapter = {
154
+ name: 'stripe-connect-test',
155
+ testMode: true,
156
+
157
+ balanceMicro: () => balanceOf(needProject()),
158
+ memberBalanceMicro: (accountRef) => {
159
+ if (!ACCOUNT_RE.test(String(accountRef))) throw new StripeError('bad account reference', { code: 'bad_account', refused: true });
160
+ return balanceOf(accountRef);
161
+ },
162
+
163
+ // Opened by us for that person: the metadata is written only by createMemberAccount below.
164
+ async confirmMemberAccount({ accountRef, builderId }) {
165
+ if (!ACCOUNT_RE.test(String(accountRef))) return false;
166
+ const a = await call('GET', `/v1/accounts/${accountRef}`);
167
+ const m = a.metadata || {};
168
+ return m.bongos_builder_id === String(builderId) && m.bongos_project === projectKey;
169
+ },
170
+
171
+ // The processor's hosted onboarding starts here: an Express account stamped with whose it is.
172
+ async createMemberAccount({ builderId, idempotencyKey }) {
173
+ const a = await call('POST', '/v1/accounts', {
174
+ idempotencyKey: idempotencyKey || `bongos-account-${projectKey}-${builderId}`,
175
+ params: { type: 'express', capabilities: { transfers: { requested: true } }, metadata: { bongos_builder_id: String(builderId), bongos_project: projectKey } },
176
+ });
177
+ return { ref: a.id };
178
+ },
179
+ async onboardingLink({ accountRef, returnUrl, refreshUrl }) {
180
+ for (const u of [returnUrl, refreshUrl]) {
181
+ const okUrl = allowedOrigin ? String(u).startsWith(`${allowedOrigin}/`) : /^https:\/\//.test(String(u));
182
+ if (!okUrl) throw new StripeError('Onboarding return addresses must be on the project own site.', { code: 'bad_return_url', refused: true });
183
+ }
184
+ const l = await call('POST', '/v1/account_links', { params: { account: accountRef, type: 'account_onboarding', return_url: returnUrl, refresh_url: refreshUrl } });
185
+ return { url: l.url };
186
+ },
187
+
188
+ async memberTransfer({ idempotencyKey, amountCents, currency, fromAccount, toAccount, toProject }) {
189
+ checkMoney({ amountCents, currency });
190
+ const dest = toProject ? needProject() : toAccount;
191
+ if (!ACCOUNT_RE.test(String(fromAccount)) || !ACCOUNT_RE.test(String(dest))) throw new StripeError('bad account reference', { code: 'bad_account', refused: true });
192
+ const t = await call('POST', '/v1/transfers', {
193
+ idempotencyKey, account: fromAccount,
194
+ params: { amount: amountCents, currency: CURRENCY, destination: dest, metadata: { bongos_key: idempotencyKey } },
195
+ });
196
+ return { ref: t.id };
197
+ },
198
+
199
+ async transfer({ idempotencyKey, amountCents, currency, builderId, payTo }) {
200
+ checkMoney({ amountCents, currency });
201
+ const dest = await payeeAccount({ builderId, payTo });
202
+ const t = await call('POST', '/v1/transfers', {
203
+ idempotencyKey, account: needProject(),
204
+ params: { amount: amountCents, currency: CURRENCY, destination: dest, metadata: { bongos_key: idempotencyKey } },
205
+ });
206
+ return { ref: t.id };
207
+ },
208
+
209
+ async memberPayout({ idempotencyKey, amountCents, currency, account, payTo = 'self' }) {
210
+ checkMoney({ amountCents, currency });
211
+ // A Stripe payout goes to the bank on the connected account itself. A 16-17 recipient's
212
+ // money must go to the guardian (payout-gates `pay_to`), which this call cannot do, so it
213
+ // is refused rather than paid to the wrong person. Nothing is sent.
214
+ if (payTo !== 'self') throw new StripeError('This payout must go to a guardian account, which a bank payout cannot do.', { code: 'guardian_payout_unsupported', refused: true });
215
+ if (!ACCOUNT_RE.test(String(account))) throw new StripeError('bad account reference', { code: 'bad_account', refused: true });
216
+ const p = await call('POST', '/v1/payouts', {
217
+ idempotencyKey, account,
218
+ params: { amount: amountCents, currency: CURRENCY, metadata: { bongos_key: idempotencyKey } },
219
+ });
220
+ return { ref: p.id, paid: p.status === 'paid' };
221
+ },
222
+
223
+ // ref is the PaymentIntent id of the buyer's charge. Figures come from Stripe, not the caller.
224
+ async readSale({ ref }) {
225
+ const p = await call('GET', `/v1/payment_intents/${encodeURIComponent(ref)}`);
226
+ if (p.status !== 'succeeded') throw new StripeError(`The sale ${ref} has not been paid.`, { code: 'sale_not_paid', refused: true });
227
+ return {
228
+ moduleKey: (p.metadata || {}).module_key,
229
+ priceCents: Number(p.amount),
230
+ platformFeeCents: p.application_fee_amount == null ? 0 : Number(p.application_fee_amount),
231
+ currency: String(p.currency || '').toUpperCase(),
232
+ };
233
+ },
234
+
235
+ // Compared with our record by object id (tr_, po_, py_…); the size of the movement, unsigned.
236
+ async listMovements({ since, until }) {
237
+ const account = needProject();
238
+ const out = [];
239
+ let after = null;
240
+ for (let page = 0; page < 50; page++) {
241
+ const r = await call('GET', '/v1/balance_transactions', {
242
+ account,
243
+ params: { limit: 100, 'created[gte]': Math.floor(new Date(since).getTime() / 1000), 'created[lt]': Math.ceil(new Date(until).getTime() / 1000), starting_after: after },
244
+ });
245
+ for (const t of r.data || []) {
246
+ if (t.currency !== CURRENCY || !t.source) continue;
247
+ out.push({ ref: typeof t.source === 'string' ? t.source : t.source.id, amountMicro: microOf(Math.abs(Number(t.amount))), createdAt: new Date(t.created * 1000).toISOString() });
248
+ }
249
+ if (!r.has_more || !(r.data || []).length) return out;
250
+ after = r.data[r.data.length - 1].id;
251
+ }
252
+ throw new StripeError('Too many movements to list in one check.', { code: 'too_many' });
253
+ },
254
+
255
+ // Webhook: verify Stripe's signature over the RAW body, then translate the payout news into
256
+ // the cash-out call. Returns { handled, outcome?, ref?, reason? }; throws on a bad signature.
257
+ verifyWebhook(rawBody, signatureHeader) {
258
+ if (!webhookSecret) throw new StripeError('No webhook secret is configured.', { code: 'no_webhook_secret' });
259
+ const parts = Object.fromEntries(String(signatureHeader || '').split(',').map((p) => p.split('=')).filter((p) => p.length === 2));
260
+ const t = Number(parts.t);
261
+ // a timestamp from the future is as suspect as an old one; replays inside the window are
262
+ // harmless only because recordProcessorOutcome repeats as a no-op (the route must keep that).
263
+ const age = now() / 1000 - t;
264
+ if (!Number.isFinite(t) || age > WEBHOOK_TOLERANCE_S || age < -60) throw new StripeError('Stale or missing webhook timestamp.', { code: 'bad_signature' });
265
+ const want = crypto.createHmac('sha256', webhookSecret).update(`${t}.${rawBody}`).digest('hex');
266
+ const ok = String(signatureHeader).split(',').some((p) => p.startsWith('v1=') && p.length === 3 + want.length
267
+ && crypto.timingSafeEqual(Buffer.from(p.slice(3)), Buffer.from(want)));
268
+ if (!ok) throw new StripeError('Webhook signature does not match.', { code: 'bad_signature' });
269
+ return JSON.parse(rawBody);
270
+ },
271
+ payoutNews(event) {
272
+ const o = event && event.data && event.data.object;
273
+ if (!o || !o.id) return { handled: false };
274
+ if (event.type === 'payout.paid') return { handled: true, outcome: 'paid', ref: o.id };
275
+ if (event.type === 'payout.failed') return { handled: true, outcome: 'returned', ref: o.id, reason: o.failure_message || o.failure_code || 'the bank returned this payout' };
276
+ return { handled: false };
277
+ },
278
+ };
279
+ return adapter;
280
+ }
281
+
282
+ // Built from the environment; null when no key is set (every flow then holds `no_processor`).
283
+ function fromEnv(env = process.env, extra = {}) {
284
+ if (!env.STRIPE_SECRET_KEY) return null;
285
+ return createStripeProcessor({
286
+ secretKey: env.STRIPE_SECRET_KEY, projectAccount: env.STRIPE_PROJECT_ACCOUNT || null,
287
+ projectKey: env.STRIPE_PROJECT_KEY || env.STRIPE_PROJECT_ACCOUNT || 'project', allowedOrigin: env.STRIPE_ONBOARDING_ORIGIN || null,
288
+ webhookSecret: env.STRIPE_WEBHOOK_SECRET || null, ...extra,
289
+ });
290
+ }
291
+
292
+ module.exports = { createStripeProcessor, fromEnv, StripeError, encode };
@@ -31,6 +31,7 @@ function personalWebSession(req) {
31
31
 
32
32
  module.exports = function buildBalancesRouter({ processor = null } = {}) {
33
33
  const router = express.Router();
34
+ const proc = () => processor || api.resolveOptional('economy.processor');
34
35
 
35
36
  const fail = (res, err, what) => {
36
37
  if (err instanceof TransferRefusal) return res.fail(err.code, { status: err.status, message: err.message });
@@ -41,7 +42,7 @@ module.exports = function buildBalancesRouter({ processor = null } = {}) {
41
42
  // rank: any-builder — the signed-in person's own balance and their own payments.
42
43
  router.get('/balances/me', api.requireBuilder, async (req, res) => {
43
44
  try {
44
- const out = await readMyBalance(api.pool, req.builder.id, { processor, limit: req.query.limit, log });
45
+ const out = await readMyBalance(api.pool, req.builder.id, { processor: proc(), limit: req.query.limit, log });
45
46
  res.set('Cache-Control', 'no-store');
46
47
  res.json(out);
47
48
  } catch (err) { fail(res, err, 'GET /balances/me'); }
@@ -50,7 +51,7 @@ module.exports = function buildBalancesRouter({ processor = null } = {}) {
50
51
  // rank: any-builder — the project's balance figure, so a member can see whether it needs funding.
51
52
  router.get('/balances/project', api.requireBuilder, async (req, res) => {
52
53
  try {
53
- const out = await readProjectBalance({ processor, log });
54
+ const out = await readProjectBalance({ processor: proc(), log });
54
55
  res.set('Cache-Control', 'no-store');
55
56
  res.json(out);
56
57
  } catch (err) { fail(res, err, 'GET /balances/project'); }
@@ -65,7 +66,7 @@ module.exports = function buildBalancesRouter({ processor = null } = {}) {
65
66
  const out = await transfer({
66
67
  kind, fromBuilderId: String(req.builder.id), toBuilderId: b.to_builder_id,
67
68
  amountCents: b.amount_cents, instructionId: b.instruction_id, note: b.note,
68
- }, { pool: api.pool, processor });
69
+ }, { pool: api.pool, processor: proc() });
69
70
  res.status(out.status === 'sent' ? 200 : 202).json(out);
70
71
  } catch (err) { fail(res, err, `POST /balances/${kind}`); }
71
72
  };
@@ -18,6 +18,7 @@ const api = require('../../../src/module-api');
18
18
  const { readAccount, recordMovement, lockAccount, releaseSettledHolds, readAvailable } = require('../project-account');
19
19
  const { readOutflow } = require('../outflow');
20
20
  const { readLedgerChecks } = require('../ledger-check');
21
+ const { fromEnv: stripeFromEnv } = require('../processor-stripe');
21
22
  const { memberAccount } = require('../user-balances');
22
23
 
23
24
  const log = api.logger('economy');
@@ -33,18 +34,28 @@ function registerSeams() {
33
34
  if (!api.hasProvider('economy.account')) {
34
35
  api.registerProvider('economy.account', { recordMovement, lockAccount, releaseSettledHolds, readAvailable, memberAccount });
35
36
  }
37
+ // The `economy.processor` PORT (task 1005040, ADR 0397): the Stripe Connect TEST-mode adapter,
38
+ // registered only when STRIPE_SECRET_KEY (an sk_test_ key) is set. With none, every flow holds
39
+ // `no_processor`, exactly as before. A bad key is logged and leaves the port unregistered.
40
+ if (!api.hasProvider('economy.processor')) {
41
+ try {
42
+ const adapter = stripeFromEnv(process.env, { memberAccountFor: (builderId) => memberAccount(api.pool, builderId) });
43
+ if (adapter) api.registerProvider('economy.processor', adapter);
44
+ } catch (e) { log.error({ err: e.message }, 'economy.processor not registered'); }
45
+ }
36
46
  }
37
47
 
38
48
  module.exports = function buildProjectAccountRouter({ processor = null } = {}) {
39
49
  registerSeams();
40
50
  const router = express.Router();
51
+ const proc = () => processor || api.resolveOptional('economy.processor');
41
52
 
42
53
  // rank: payout.approve (archon floor) — the owner. The account's balance and who was paid what
43
54
  // is the project's own money, so only the payout authority reads it (ADR 0016: enforced here, server-side).
44
55
  router.get('/project-account', api.requireBuilder, api.requirePermission('payout.approve'), async (req, res) => {
45
56
  try {
46
57
  const { limit, offset } = api.parsePagination(req, { defaultLimit: 50, maxLimit: 200 });
47
- const out = await readAccount(api.pool, { limit, offset, processor, log });
58
+ const out = await readAccount(api.pool, { limit, offset, processor: proc(), log });
48
59
  res.set('Cache-Control', 'no-store');
49
60
  res.json({ ...out, page: api.pageMeta(limit, offset, out.movement_count) });
50
61
  } catch (err) {