@bongos/core 1.24.96 → 1.25.1
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.
- package/.bongos-core.json +60 -40
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +7 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +131 -3
- package/docs/api-reference.md +4 -2
- package/docs/architecture.md +3 -1
- package/docs/module-api-changelog.md +4 -0
- package/migrations/core_298_card_step_up.sql +16 -0
- package/modules/economy/module.json +1 -1
- package/modules/economy/processor-stripe.js +292 -0
- package/modules/economy/routes/balances.js +4 -3
- package/modules/economy/routes/project-account.js +12 -1
- package/modules/economy/tests/processor_stripe.mjs +135 -0
- package/modules/government/routes/government.js +2 -2
- package/modules/render-deploy/routes/act.js +1 -1
- package/modules/render-deploy/routes/app.js +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +11 -0
- package/scripts/bongos/fitness.js +1 -1
- package/scripts/bongos/government-walk.js +2 -2
- package/src/bongos/passkeys.js +30 -0
- package/src/bongos/routes/auth.js +2 -2
- package/src/bongos/routes/builders.js +2 -1
- package/src/bongos/routes/module-payouts.js +4 -2
- package/src/bongos/routes/my-cash-outs.js +5 -3
- package/src/bongos/routes/my-passkeys.js +70 -0
- package/src/bongos/routes/payout-approvals.js +5 -3
- package/src/bongos/step-up.js +42 -0
- package/src/module-api.js +5 -1
- package/tests/government_act_alone_buildings.mjs +2 -1
- package/tests/government_treasury.mjs +2 -1
- package/tests/module_api.mjs +1 -0
- package/tests/my_passkeys.mjs +47 -1
- package/tests/rank_change_broadcast_port.mjs +2 -0
|
@@ -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) {
|
|
@@ -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;
|
|
@@ -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.
|
|
3
|
+
"version": "1.25.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.
|
|
9
|
+
"version": "1.25.1",
|
|
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.
|
|
3
|
+
"version": "1.25.1",
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -11689,5 +11689,16 @@
|
|
|
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
|
+
}
|
|
11692
11703
|
]
|
|
11693
11704
|
}
|
|
@@ -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;
|