@bongos/core 1.24.95 → 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.
Files changed (58) hide show
  1. package/.bongos-core.json +97 -57
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +9 -0
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/api/openapi.json +179 -3
  8. package/docs/api-reference.md +6 -3
  9. package/docs/architecture.md +3 -1
  10. package/docs/copy-inventory.md +7 -7
  11. package/docs/copy-registry.json +7 -7
  12. package/docs/module-api-changelog.md +6 -0
  13. package/docs/page-inventory.json +2 -0
  14. package/docs/page-readings.json +6 -6
  15. package/migrations/core_298_card_step_up.sql +16 -0
  16. package/modules/autonomy/db.js +48 -0
  17. package/modules/autonomy/routes/autonomy.js +15 -0
  18. package/modules/economy/module.json +1 -1
  19. package/modules/economy/processor-stripe.js +292 -0
  20. package/modules/economy/routes/balances.js +4 -3
  21. package/modules/economy/routes/project-account.js +12 -1
  22. package/modules/economy/tests/processor_stripe.mjs +135 -0
  23. package/modules/government/catalog.js +1 -1
  24. package/modules/government/routes/government.js +2 -2
  25. package/modules/hall-ui/public/home-city-draw.js +24 -2
  26. package/modules/hall-ui/public/home-city.css +15 -0
  27. package/modules/hall-ui/public/home-city.js +3 -1
  28. package/modules/hall-ui/public/home-runner.js +248 -0
  29. package/modules/hall-ui/public/index.html +8 -5
  30. package/modules/hall-ui/public/visor.css +27 -0
  31. package/modules/hall-ui/public/visor.js +65 -2
  32. package/modules/hall-ui/records/home-runner.md +19 -0
  33. package/modules/hall-ui/records/visor.md +4 -0
  34. package/modules/render-deploy/routes/act.js +1 -1
  35. package/modules/render-deploy/routes/app.js +1 -1
  36. package/package-lock.json +2 -2
  37. package/package.json +1 -1
  38. package/release-notes.json +17 -0
  39. package/scripts/bongos/fitness.js +1 -1
  40. package/scripts/bongos/government-walk.js +2 -2
  41. package/scripts/hall-preview/server.js +4 -0
  42. package/src/bongos/passkeys.js +30 -0
  43. package/src/bongos/routes/auth.js +2 -2
  44. package/src/bongos/routes/builders.js +2 -1
  45. package/src/bongos/routes/module-payouts.js +4 -2
  46. package/src/bongos/routes/my-cash-outs.js +5 -3
  47. package/src/bongos/routes/my-passkeys.js +70 -0
  48. package/src/bongos/routes/payout-approvals.js +5 -3
  49. package/src/bongos/step-up.js +42 -0
  50. package/src/module-api.js +5 -1
  51. package/tests/autonomy_runner_goal_stats.mjs +92 -0
  52. package/tests/government_act_alone_buildings.mjs +2 -1
  53. package/tests/government_treasury.mjs +2 -1
  54. package/tests/hall_visor.mjs +20 -0
  55. package/tests/home_runner.mjs +115 -0
  56. package/tests/module_api.mjs +1 -0
  57. package/tests/my_passkeys.mjs +47 -1
  58. 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`);
@@ -128,7 +128,7 @@ const PERMISSIONS = [
128
128
  // "runner operators" should be able to compose one. It can never widen the fence
129
129
  // — the project allowlist and the kill switch stay behind autonomy.fence.manage —
130
130
  // so delegating it delegates only "whose machine may work the allowed goals".
131
- { key: 'autonomy.run', system: false, floor: 'metic', guards: 'PUT /autonomy/me/goals (a builder\'s own runner scope); also decides whether GET /autonomy/fence answers enabled for the caller' },
131
+ { key: 'autonomy.run', system: false, floor: 'metic', guards: 'PUT /autonomy/me/goals (a builder\'s own runner scope) and GET /autonomy/me/goals/:goalId/stats (its numbers on one goal); also decides whether GET /autonomy/fence answers enabled for the caller' },
132
132
  // BV1.R21 (task 1003608, goal 1000086). ADR 0250 §6 amends ADR 0157 for exactly
133
133
  // this key: `version.create` returns to the Archon floor.
134
134
  //
@@ -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;
@@ -297,6 +297,8 @@
297
297
  const a = it.a, x0 = a.x, y0 = a.y, x1 = x0 + FOOT, y1 = y0 + FOOT, base = 4;
298
298
  const d = doneAt(a, t), built = Math.max(2, (d.build + d.art) * m.px), clad = d.art * m.px;
299
299
  const site = sites[a.id], who = site ? m.people[site.builder] : null, agent = !!(site && (site.agent || (who && who.agent)));
300
+ // your runner sent here (task 1005243): its marker stands in for the agent's face
301
+ const mine = latest && o.runner && o.runner.goal === a.id ? o.runner.state : null;
300
302
  const wait = latest ? (m.waiting[a.id] || 0) : 0;
301
303
  const g = [];
302
304
  g.push(...box(x0 - 0.12, y0 - 0.12, x1 + 0.12, y1 + 0.12, 0, base, I.plinth));
@@ -330,12 +332,13 @@
330
332
  const hx = mx + 1.8;
331
333
  g.push(line(P(hx, my, ct), P(hx, my, top - 6), I.steel, 1));
332
334
  g.push(node('g', { class: 'hc-slab' }, box(hx - 0.3, my - 0.2, hx + 0.3, my + 0.2, top - 38, 3, I.slab)));
333
- if (who) over.push({ k: 'face', p: P(mx + 0.9, my, ct + 30), who, agent, a });
335
+ if (who && !mine) over.push({ k: 'face', p: P(mx + 0.9, my, ct + 30), who, agent, a });
334
336
  }
337
+ if (mine) over.push({ k: 'runner', p: site ? P(x0 + 1.4, y0 + 0.5, top + 86) : P((x0 + x1) / 2, (y0 + y1) / 2, top + 40), state: mine, a });
335
338
  if (wait) over.push({ k: 'dot', p: P((x0 + x1) / 2, (y0 + y1) / 2, top + (site ? 12 : 22)), a, n: wait });
336
339
  const shipped = Math.round(d.build + d.art), byArt = Math.round(d.art);
337
340
  const tip = `${shipped} shipped${byArt ? ` · ${byArt} by artists` : ''}${who ? ` · ${agent ? `${who.name}'s agent` : who.name} on site` : ''} · click to open the goal`;
338
- out.push(node('a', { href: `/goals#/goal/${encodeURIComponent(a.id)}`, class: 'hc-tower', 'aria-label': `${a.title}: ${shipped} shipped. Open the goal.`, 'data-tip': a.title, 'data-tip-body': tip }, g));
341
+ out.push(node('a', { href: `/goals#/goal/${encodeURIComponent(a.id)}`, class: 'hc-tower' + (o.picked === a.id ? ' hc-tower--picked' : ''), 'data-goal': a.id, 'aria-label': `${a.title}: ${shipped} shipped. Open the goal.`, 'data-tip': a.title, 'data-tip-body': tip }, g));
339
342
  // the cloud at its foot, over this tower and under the next one forward
340
343
  if (cloudy) out.push(puff(x1 - 0.5, y1 - 0.5, 62));
341
344
  });
@@ -344,6 +347,7 @@
344
347
 
345
348
  over.forEach((v, n) => {
346
349
  if (v.k === 'face') { out.push(avatarNode(v.p, v.who, v.agent, v.a, I, n, node, line, circle)); return; }
350
+ if (v.k === 'runner') { out.push(runnerNode(v.p, v.state, v.a, I, node, line, circle)); return; }
347
351
  const cx = v.p[0] - 30, cy = v.p[1];
348
352
  out.push(node('g', { class: 'hc-wait', 'data-tip': v.a.title, 'data-tip-body': `${v.n} waiting on you` }, [
349
353
  circle(cx, cy, 11, { fill: 'var(--accent)', stroke: 'var(--bg-card)', 'stroke-width': 2.5 }),
@@ -607,6 +611,24 @@
607
611
  return node('g', { class: 'hc-face-pin', 'data-tip': agent ? `${who.name}'s agent` : who.name, 'data-tip-body': a.title }, g);
608
612
  }
609
613
 
614
+ // Your Autobongos runner on the tower it was sent to (task 1005243): the agent's robot face,
615
+ // ringed in the state it is in. Working wears the accent ring that turns (CSS, under the
616
+ // Kill Switch); resting a dashed ring; starting the info ring; ready a soft ring; off no ring
617
+ // and a faded face. The words for each are the visor's card, never this mark alone.
618
+ // The same words the visor's card puts on the device's face (runner-button.js buttonState).
619
+ const RUNNER_WORDS = { working: 'Running', resting: 'Resting', starting: 'Starting…', ready: 'Ready', off: 'Recalled' };
620
+ function runnerNode(p, state, a, I, node, line, circle) {
621
+ const x = p[0], y = p[1];
622
+ const st = RUNNER_WORDS[state] ? state : 'off';
623
+ return node('g', { class: `hc-runner hc-runner--${st}`, 'data-goal': a.id, 'data-tip': 'Your runner', 'data-tip-body': `${RUNNER_WORDS[st]} · ${a.title}` }, [
624
+ line([x, y + 18], [x, y + 32], I.steel, 1),
625
+ circle(x, y, 21, { class: 'hc-runner__ring', fill: 'none', 'stroke-width': 2.5 }),
626
+ circle(x, y, 16, { class: 'hc-runner__face', fill: I.avAgent, stroke: 'var(--bg-card)', 'stroke-width': 3 }),
627
+ node('rect', { class: 'hc-runner__ink', x: r1(x - 7), y: r1(y - 5), width: 14, height: 11, rx: 3, fill: 'none', stroke: I.avInk, 'stroke-width': 1.5 }),
628
+ circle(x - 3, y + 0.5, 1.5, { class: 'hc-runner__eye', fill: I.avInk }), circle(x + 3, y + 0.5, 1.5, { class: 'hc-runner__eye', fill: I.avInk }),
629
+ ]);
630
+ }
631
+
610
632
  function initial(name) { const s = String(name || '').trim(); return s ? s[0].toUpperCase() : '?'; }
611
633
  // a stable spread for an idea's place in the sky (FNV-1a over its id)
612
634
  function hash(s) { let h = 2166136261; for (const c of String(s)) { h ^= c.charCodeAt(0); h = Math.imul(h, 16777619); } return Math.abs(h); }
@@ -38,6 +38,20 @@ html[data-game="on"] body[data-page="home"] .page-head--hall .page-head__h { wid
38
38
  .hc-svg a:focus { outline: none; }
39
39
  .hc-svg a:focus-visible .hc-face { filter: brightness(1.35); }
40
40
  .hc-svg .hc-tower:hover .hc-face { filter: brightness(1.3); }
41
+ /* game mode, your runner (task 1005243): the tower picked for the visor's card is lit and edged
42
+ in the accent; your runner's marker is the agent's face in a ring that says its state */
43
+ .hc-svg .hc-tower--picked .hc-face { filter: brightness(1.2); stroke: var(--accent); stroke-width: 2px; }
44
+ .hc-svg .hc-runner { cursor: pointer; }
45
+ .hc-svg .hc-runner__ring { stroke: var(--ink-soft); stroke-opacity: .5; stroke-width: 3.5px; }
46
+ /* yours, not anyone's agent: the accent face, the dark ink on it */
47
+ .hc-svg .hc-runner__face { fill: var(--accent); }
48
+ .hc-svg .hc-runner__ink { stroke: var(--accent-on); }
49
+ .hc-svg .hc-runner__eye { fill: var(--accent-on); }
50
+ .hc-svg .hc-runner--working .hc-runner__ring { stroke: var(--accent); stroke-opacity: 1; stroke-dasharray: 22 10; transform-box: fill-box; transform-origin: center; animation: hc-turn 6s linear infinite; }
51
+ .hc-svg .hc-runner--starting .hc-runner__ring { stroke: var(--info); stroke-opacity: 1; stroke-dasharray: 4 6; }
52
+ .hc-svg .hc-runner--resting .hc-runner__ring { stroke: var(--info); stroke-opacity: .9; stroke-dasharray: 2 5; }
53
+ .hc-svg .hc-runner--off { opacity: .55; }
54
+ .hc-svg .hc-runner--off .hc-runner__ring { stroke-opacity: 0; }
41
55
  /* the core and the museum stay quiet on the builder's Home so the new buildings lead;
42
56
  each comes back to full colour under the pointer or the keyboard */
43
57
  .hc-svg .hc-quiet { opacity: .62; filter: saturate(.3); transition: opacity .3s, filter .3s; }
@@ -110,6 +124,7 @@ html[data-theme="dark"] .home-city.hc-is-sky { background: linear-gradient(color
110
124
 
111
125
  /* live, and nothing more: a crane's slab climbs, this week's ideas glow (20s+) */
112
126
  @keyframes hc-lift { 0%, 12% { transform: translateY(0); } 55%, 70% { transform: translateY(-24px); } 100% { transform: translateY(0); } }
127
+ @keyframes hc-turn { to { transform: rotate(360deg); } }
113
128
  @keyframes hc-glow { 0%, 100% { opacity: .55; } 50% { opacity: 1; } }
114
129
  @keyframes hc-live { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--ok) 55%, transparent); } 100% { box-shadow: 0 0 0 8px transparent; } }
115
130
  .hc-slab { animation: hc-lift 20s ease-in-out infinite; }