@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
@@ -15,6 +15,8 @@ const crypto = require('node:crypto');
15
15
  const CHALLENGE_TTL_SECONDS = 300;
16
16
  const MAX_CHALLENGE_TTL_SECONDS = 600;
17
17
  const PURPOSES = Object.freeze(['register', 'authenticate']);
18
+ // How long one card tap keeps a session cleared for the protected actions (ADR 0382 D2).
19
+ const STEP_UP_WINDOW_SECONDS = 600;
18
20
 
19
21
  function defaultPool() { return require('./pool').pool; }
20
22
 
@@ -146,7 +148,35 @@ async function renameCredential(builderId, credentialId, nickname, { pool = defa
146
148
  return r.rowCount > 0;
147
149
  }
148
150
 
151
+ // Stamp the session that just passed a card check. Keyed on the builder as well, so a
152
+ // session id that is not the caller's matches no row. Returns true when stamped.
153
+ async function markSessionCardVerified(sessionId, builderId, { pool = defaultPool() } = {}) {
154
+ const r = await pool.query(
155
+ `UPDATE builder_sessions SET card_verified_at = now()
156
+ WHERE id = $1 AND builder_id = $2 AND revoked_at IS NULL`,
157
+ [sessionId, builderId],
158
+ );
159
+ return r.rowCount > 0;
160
+ }
161
+
162
+ // Where this session stands: is a card required of the builder, and is the session's
163
+ // last tap inside the window? Read fresh from the DB on every call, never cached.
164
+ async function stepUpStatus(sessionId, builderId, { pool = defaultPool(), windowSeconds = STEP_UP_WINDOW_SECONDS } = {}) {
165
+ const { rows } = await pool.query(
166
+ `SELECT b.require_card,
167
+ (s.card_verified_at IS NOT NULL
168
+ AND s.card_verified_at > now() - make_interval(secs => $3::int)) AS fresh
169
+ FROM builders b JOIN builder_sessions s ON s.builder_id = b.id
170
+ WHERE b.id = $1 AND s.id = $2`,
171
+ [builderId, sessionId, String(windowSeconds)],
172
+ );
173
+ const r = rows[0];
174
+ if (!r) return null;
175
+ return { required: r.require_card === true, fresh: r.fresh === true };
176
+ }
177
+
149
178
  module.exports = {
179
+ STEP_UP_WINDOW_SECONDS, markSessionCardVerified, stepUpStatus,
150
180
  CHALLENGE_TTL_SECONDS, MAX_CHALLENGE_TTL_SECONDS, PURPOSES, clampTtlSeconds, counterAdvances,
151
181
  createChallenge, consumeChallenge, sweepExpiredChallenges,
152
182
  getOrCreateUserHandle, addCredential, getCredentialById, listCredentials, recordUse, removeCredential, renameCredential,
@@ -1418,7 +1418,7 @@ module.exports = function buildAuthRouter() {
1418
1418
  // rank: archon — kills an arbitrary session; can lock other builders out. Gate:
1419
1419
  // requirePermission('auth.session.revoke') — BV1.R104; the seeded rank-role makes
1420
1420
  // the permission and the old rank floor identical (ADR 0151).
1421
- router.post('/auth/revoke', auth.requireBuilder, auth.requirePermission('auth.session.revoke'), async (req, res) => {
1421
+ router.post('/auth/revoke', auth.requireBuilder, auth.requirePermission('auth.session.revoke'), require('../step-up').requireCardStepUp, async (req, res) => {
1422
1422
  if (validateOrRespond(req, res, { session_id: {} })) return; // bigint as number or string; validated below
1423
1423
  const sessionId = req.body && req.body.session_id;
1424
1424
  if (sessionId === undefined || sessionId === null) {
@@ -1446,7 +1446,7 @@ module.exports = function buildAuthRouter() {
1446
1446
  // rank: archon — same authority as PATCH /builders/:id/rank. Gate:
1447
1447
  // requirePermission('auth.session.revoke') — BV1.R104; the same atom /auth/revoke
1448
1448
  // gates on (ADR 0151).
1449
- router.post('/auth/revoke-all', auth.requireBuilder, auth.requirePermission('auth.session.revoke'), async (req, res) => {
1449
+ router.post('/auth/revoke-all', auth.requireBuilder, auth.requirePermission('auth.session.revoke'), require('../step-up').requireCardStepUp, async (req, res) => {
1450
1450
  if (validateOrRespond(req, res, { builder_id: {} })) return; // bigint as number or string; validated below
1451
1451
  const builderId = req.body && req.body.builder_id;
1452
1452
  if (builderId === undefined || builderId === null) {
@@ -24,6 +24,7 @@
24
24
 
25
25
  const express = require('express');
26
26
  const auth = require('../auth');
27
+ const { requireCardStepUp } = require('../step-up');
27
28
  // WHO DECIDES AT TREASURY (task 1004657; ADR 0363 D5): the budget write refuses a
28
29
  // lone act while Treasury's decision-rules row names a sitting. A passed Treasury
29
30
  // matter sets the limit through setMonthlyBudget directly (the governor.matterAct
@@ -271,7 +272,7 @@ module.exports = function buildBuildersRouter() {
271
272
  // the permission and the old rank floor identical (ADR 0151). The atom is
272
273
  // `system: true`, so no CUSTOM role may ever be granted it — only the seeded archon
273
274
  // role holds it, which is exactly the meta-escalation wall ADR 0016 wants.
274
- router.patch('/builders/:id/rank', auth.requireBuilder, auth.requirePermission('builder.rank.set'), async (req, res) => {
275
+ router.patch('/builders/:id/rank', auth.requireBuilder, auth.requirePermission('builder.rank.set'), requireCardStepUp, async (req, res) => {
275
276
  if (validateOrRespond(req, res, {
276
277
  rank: { required: true, type: 'string', maxLength: 32 },
277
278
  })) return;
@@ -28,6 +28,8 @@ module.exports = function buildModulePayoutsRouter({
28
28
  requirePermission = auth.requirePermission,
29
29
  } = {}) {
30
30
  const router = express.Router();
31
+ // the processor adapter (modules/economy/processor-stripe.js, task 1005040) is a port, resolved per call
32
+ const proc = () => processor || require('../../module-seams').resolveOptional('economy.processor');
31
33
 
32
34
  function personal(req, res) {
33
35
  const p = store.personalSession(req);
@@ -74,7 +76,7 @@ module.exports = function buildModulePayoutsRouter({
74
76
  if (!actor) return;
75
77
  if (validateOrRespond(req, res, { processor_sale_ref: { required: true } })) return;
76
78
  try {
77
- const out = await payouts.recordSale({ processorSaleRef: req.body.processor_sale_ref, actorId: actor.id }, { processor });
79
+ const out = await payouts.recordSale({ processorSaleRef: req.body.processor_sale_ref, actorId: actor.id }, { processor: proc() });
78
80
  res.status(out.already ? 200 : 201).json(out);
79
81
  } catch (err) { fail(res, err, 'POST /payouts/module-sales'); }
80
82
  });
@@ -104,7 +106,7 @@ module.exports = function buildModulePayoutsRouter({
104
106
  if (!actor) return;
105
107
  if (validateOrRespond(req, res, { approval_id: { required: true } })) return;
106
108
  try {
107
- const out = await payouts.payShare({ shareId: req.params.id, approvalId: req.body.approval_id, actorId: actor.id }, { processor });
109
+ const out = await payouts.payShare({ shareId: req.params.id, approvalId: req.body.approval_id, actorId: actor.id }, { processor: proc() });
108
110
  res.set('Cache-Control', 'no-store');
109
111
  res.json(out);
110
112
  } catch (err) { fail(res, err, 'POST /payouts/module-shares/:id/pay'); }
@@ -30,6 +30,8 @@ function buildMyCashOutsRouter({
30
30
  requireBuilder = auth.requireBuilder,
31
31
  } = {}) {
32
32
  const router = express.Router();
33
+ // the processor adapter (modules/economy/processor-stripe.js, task 1005040) is a port, resolved per call
34
+ const proc = () => processor || require('../../module-seams').resolveOptional('economy.processor');
33
35
 
34
36
  function personal(req, res) {
35
37
  const p = approvals.personalSession(req);
@@ -62,7 +64,7 @@ function buildMyCashOutsRouter({
62
64
  // GET /api/bongos/me/cash-outs — my withdrawals and where each stands.
63
65
  router.get('/me/cash-outs', requireBuilder, async (req, res) => {
64
66
  try {
65
- const out = await cashOuts.listMyCashOuts(req.builder.id, { processor, limit: req.query.limit, log });
67
+ const out = await cashOuts.listMyCashOuts(req.builder.id, { processor: proc(), limit: req.query.limit, log });
66
68
  res.set('Cache-Control', 'no-store');
67
69
  res.json(out);
68
70
  } catch (err) { fault(res, err, 'GET /me/cash-outs'); }
@@ -76,7 +78,7 @@ function buildMyCashOutsRouter({
76
78
  try {
77
79
  const out = await cashOuts.requestCashOut({
78
80
  builderId: String(req.builder.id), actor, amountCents: req.body.amount_cents, instructionId: req.body.instruction_id,
79
- }, { processor, log });
81
+ }, { processor: proc(), log });
80
82
  answer(res, out);
81
83
  } catch (err) { fault(res, err, 'POST /me/cash-outs'); }
82
84
  });
@@ -85,7 +87,7 @@ function buildMyCashOutsRouter({
85
87
  router.post('/me/cash-outs/:id/retry', requireBuilder, async (req, res) => {
86
88
  if (!personal(req, res)) return;
87
89
  try {
88
- answer(res, await cashOuts.retryCashOut({ builderId: String(req.builder.id), cashOutId: req.params.id }, { processor }));
90
+ answer(res, await cashOuts.retryCashOut({ builderId: String(req.builder.id), cashOutId: req.params.id }, { processor: proc() }));
89
91
  } catch (err) { fault(res, err, 'POST /me/cash-outs/:id/retry'); }
90
92
  });
91
93
 
@@ -206,6 +206,76 @@ module.exports = function buildMyPasskeysRouter({
206
206
  }
207
207
  });
208
208
 
209
+ // POST /api/bongos/me/passkeys/authenticate/options — start a card check.
210
+ // rank: any-builder (own identity only). The challenge is bound to the caller.
211
+ router.post('/me/passkeys/authenticate/options', requireBuilder, async (req, res) => {
212
+ if (validateOrRespond(req, res, {})) return;
213
+ try {
214
+ const rp = relyingParty(req);
215
+ if (!rp) return res.fail('passkeys_not_configured', { status: 503, message: 'security cards are not configured on this site yet' });
216
+ const cards = await store.listCredentials(req.builder.id);
217
+ if (!cards.length) return res.fail('no_cards', { status: 409, message: 'register a security card first' });
218
+ const { challenge } = await store.createChallenge({ purpose: 'authenticate', builderId: req.builder.id });
219
+ const options = await lib().generateAuthenticationOptions({
220
+ rpID: rp.rpID,
221
+ challenge: Buffer.from(challenge, 'base64url'),
222
+ allowCredentials: cards.map((c) => ({ id: c.credential_id, transports: c.transports })),
223
+ userVerification: 'required',
224
+ });
225
+ res.json({ options });
226
+ } catch (err) {
227
+ log.error({ err: err && err.message }, 'authenticate/options failed');
228
+ res.fail('options_failed', { status: 500, message: 'internal error' });
229
+ }
230
+ });
231
+
232
+ // POST /api/bongos/me/passkeys/authenticate/verify — finish a card check.
233
+ // Body: { response } the browser's assertion. On success the CALLER'S session row
234
+ // is stamped; that stamp (never anything the client sends) is the step-up proof.
235
+ // rank: any-builder (own identity only).
236
+ router.post('/me/passkeys/authenticate/verify', requireBuilder, async (req, res) => {
237
+ if (validateOrRespond(req, res, { response: {} })) return;
238
+ const response = req.body && req.body.response;
239
+ if (!response || typeof response !== 'object' || typeof response.id !== 'string') {
240
+ return res.fail('bad_response', { status: 400, message: 'response is required' });
241
+ }
242
+ const refuse = () => res.fail('verification_failed', { status: 400, message: 'the card could not be verified' });
243
+ try {
244
+ const rp = relyingParty(req);
245
+ if (!rp) return res.fail('passkeys_not_configured', { status: 503, message: 'security cards are not configured on this site yet' });
246
+ const sessionId = req.gdsSession && req.gdsSession.session_id;
247
+ if (sessionId === undefined || sessionId === null) return res.fail('unauthenticated', 401);
248
+ const cred = await store.getCredentialById(response.id);
249
+ // One answer whether the card is unknown or somebody else's.
250
+ if (!cred || String(cred.builder_id) !== String(req.builder.id)) return refuse();
251
+ let verification;
252
+ try {
253
+ verification = await lib().verifyAuthenticationResponse({
254
+ response,
255
+ expectedChallenge: async (c) => !!(await store.consumeChallenge(c, { expectPurpose: 'authenticate', expectBuilderId: req.builder.id })),
256
+ expectedOrigin: rp.origin,
257
+ expectedRPID: rp.rpID,
258
+ credential: { id: cred.credential_id, publicKey: new Uint8Array(cred.public_key), counter: Number(cred.sign_count), transports: cred.transports || [] },
259
+ requireUserVerification: true,
260
+ });
261
+ } catch (err) {
262
+ log.info({ err: err && err.message }, 'authenticate/verify refused');
263
+ return refuse();
264
+ }
265
+ if (!verification.verified || !verification.authenticationInfo) return refuse();
266
+ const use = await store.recordUse(cred.credential_id, verification.authenticationInfo.newCounter);
267
+ if (!use.ok) {
268
+ log.warn({ reason: use.reason, builder_id: String(req.builder.id) }, 'card counter did not advance: possible clone');
269
+ return refuse();
270
+ }
271
+ if (!(await store.markSessionCardVerified(sessionId, req.builder.id))) return refuse();
272
+ res.json({ ok: true, valid_for_seconds: store.STEP_UP_WINDOW_SECONDS });
273
+ } catch (err) {
274
+ log.error({ err: err && err.message }, 'authenticate/verify failed');
275
+ res.fail('authenticate_failed', { status: 500, message: 'internal error' });
276
+ }
277
+ });
278
+
209
279
  // POST /api/bongos/me/passkeys/rename — Body: { credential_id, nickname }.
210
280
  // rank: any-builder (own identity only) — the WHERE clause is the boundary.
211
281
  router.post('/me/passkeys/rename', requireBuilder, async (req, res) => {
@@ -59,13 +59,15 @@ module.exports = function buildPayoutApprovalsRouter({
59
59
  store = require('../payout-approvals'),
60
60
  gates = require('../payout-gates'),
61
61
  payouts = builderPayouts,
62
- // The processor adapter (ADR 0397: the only code that may name the rail) does not exist
63
- // yet, so nothing is sent: every builder payout is recorded as owed and held.
62
+ // The processor adapter (ADR 0397: the only code that may name the rail) is the optional
63
+ // `economy.processor` port; with none registered nothing is sent and every payout is held.
64
64
  processor = null,
65
65
  requireBuilder = auth.requireBuilder,
66
66
  requirePermission = auth.requirePermission,
67
67
  } = {}) {
68
68
  const router = express.Router();
69
+ // the processor adapter (modules/economy/processor-stripe.js, task 1005040) is a port, resolved per call
70
+ const proc = () => processor || require('../../module-seams').resolveOptional('economy.processor');
69
71
 
70
72
  function personal(req, res) {
71
73
  const p = store.personalSession(req);
@@ -338,7 +340,7 @@ module.exports = function buildPayoutApprovalsRouter({
338
340
  if (validateOrRespond(req, res, { approval_id: { required: true }, task_id: { required: true } })) return;
339
341
  const body = req.body || {};
340
342
  try {
341
- const out = await payouts.payForTask({ approvalId: body.approval_id, taskId: body.task_id, actorId: actor.id }, { processor });
343
+ const out = await payouts.payForTask({ approvalId: body.approval_id, taskId: body.task_id, actorId: actor.id }, { processor: proc() });
342
344
  res.set('Cache-Control', 'no-store');
343
345
  res.json(out);
344
346
  } catch (err) {
@@ -0,0 +1,42 @@
1
+ // src/bongos/step-up.js — the card step-up gate (task 1004747, ADR 0382 D2/D5).
2
+ //
3
+ // requireCardStepUp is mounted AFTER requireBuilder (and after any rank/permission
4
+ // gate) on the routes where a stolen session does the most damage. A builder an
5
+ // Archon has NOT switched the requirement on for passes straight through, so landing
6
+ // this changes nothing for anybody. For a builder with the requirement on, the
7
+ // request is refused 403 `card_required` unless THIS session passed a card check in
8
+ // the last 10 minutes — a timestamp on the server-side session row, set only by
9
+ // POST /me/passkeys/authenticate/verify.
10
+ //
11
+ // It fails CLOSED: if the state cannot be read, the request is refused. The flag and
12
+ // the timestamp are read from the DB per request, never from the token or the client.
13
+ 'use strict';
14
+
15
+ const passkeys = require('./passkeys');
16
+ const log = require('./logger').logger.child({ src: 'step-up' });
17
+
18
+ const CARD_REQUIRED = Object.freeze({
19
+ status: 403,
20
+ message: 'This action needs your hardware card. Tap it on the card page, then try again.',
21
+ });
22
+
23
+ function buildRequireCardStepUp({ store = passkeys } = {}) {
24
+ return async function requireCardStepUp(req, res, next) {
25
+ try {
26
+ const sessionId = req.gdsSession && req.gdsSession.session_id;
27
+ const builderId = req.builder && req.builder.id;
28
+ if (sessionId === undefined || sessionId === null || builderId === undefined || builderId === null) {
29
+ return res.fail('unauthenticated', 401);
30
+ }
31
+ const st = await store.stepUpStatus(sessionId, builderId);
32
+ if (!st) return res.fail('card_required', CARD_REQUIRED);
33
+ if (!st.required || st.fresh) return next();
34
+ return res.fail('card_required', CARD_REQUIRED);
35
+ } catch (err) {
36
+ log.error({ err: err && err.message }, 'step-up check failed; refusing');
37
+ return res.fail('card_check_failed', { status: 503, message: 'could not check your hardware card; try again' });
38
+ }
39
+ };
40
+ }
41
+
42
+ module.exports = { requireCardStepUp: buildRequireCardStepUp(), buildRequireCardStepUp };
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.24.95'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.25.1'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -157,6 +157,10 @@ module.exports = {
157
157
  // sitting, one builder acting alone is refused (409 decided_by_sitting,
158
158
  // pointing at the matter route). No government module → as today.
159
159
  get requireActAlone() { return require('./bongos/act-alone').requireActAlone; },
160
+ // requireCardStepUp — the hardware-card step-up gate (task 1004747; ADR 0382 D2/D5),
161
+ // mounted AFTER the route's permission gate. A builder with no card requirement
162
+ // passes untouched; one with it on needs a card tap in the last 10 minutes.
163
+ get requireCardStepUp() { return require('./bongos/step-up').requireCardStepUp; },
160
164
  get rankMeetsThreshold() { return require('./bongos/auth').rankMeetsThreshold; },
161
165
  get requireBfgPrincipal() { return require('./bongos/auth').requireBfgPrincipal; },
162
166
  // allowBoxScope — DEPRECATED no-op, kept so the doorway loses no export inside
@@ -0,0 +1,92 @@
1
+ // tests/autonomy_runner_goal_stats.mjs — one goal, seen from the caller's runner
2
+ // (task 1005243): the numbers the game's runner card on Home shows.
3
+ //
4
+ // The route is driven as a REAL request through the assembled router with only the
5
+ // auth gates stubbed, and the SQL against a stubbed pool (the
6
+ // autonomy_builder_scope precedent). DB-free, no network.
7
+ // Run: node --test tests/autonomy_runner_goal_stats.mjs
8
+
9
+ import { test } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { createRequire } from 'node:module';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
16
+ const req = createRequire(import.meta.url);
17
+ const express = req('express');
18
+ const { attachFail } = req(path.join(ROOT, 'src/bongos/middleware/error-envelope.js'));
19
+ const api = req(path.join(ROOT, 'src/module-api.js'));
20
+ const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
21
+
22
+ const permStub = (key) => { const mw = (_q, _s, next) => next(); mw.permKey = key; return mw; };
23
+ const passBuilder = (q, _s, next) => { q.builder = { id: 7, rank: 'metic' }; next(); };
24
+
25
+ function router() {
26
+ const saved = { b: api.requireBuilder, p: api.requirePermission };
27
+ api.requireBuilder = passBuilder;
28
+ api.requirePermission = permStub;
29
+ const p = req.resolve(path.join(ROOT, 'modules/autonomy/routes/autonomy.js'));
30
+ delete req.cache[p];
31
+ try { return req(p)(); } finally { api.requireBuilder = saved.b; api.requirePermission = saved.p; }
32
+ }
33
+ async function serve(r) {
34
+ const app = express();
35
+ app.use(express.json()); app.use(attachFail); app.use(r);
36
+ const server = app.listen(0);
37
+ await new Promise((ok) => server.once('listening', ok));
38
+ return { base: `http://127.0.0.1:${server.address().port}`, close: () => server.close() };
39
+ }
40
+ async function withPool(answer, fn) {
41
+ const saved = api.pool.query;
42
+ const calls = [];
43
+ api.pool.query = async (sql, params) => { calls.push({ sql, params }); return answer(sql, params); };
44
+ try { return await fn(calls); } finally { api.pool.query = saved; }
45
+ }
46
+
47
+ test('the read counts only the caller\'s RUNNER claims that shipped in that goal, and their sessions\' tokens', async () => {
48
+ await withPool(() => ({ rows: [{ tasks_done: 7, tokens: '4820000', avg_s: '2280.4', tasks_left: 5 }] }), async (calls) => {
49
+ const out = await db.readRunnerGoalStats(7, 1000090);
50
+ assert.deepEqual(out, { tasks_done: 7, tokens: 4820000, avg_task_seconds: 2280, tasks_left: 5 });
51
+ const { sql, params } = calls[0];
52
+ assert.deepEqual(params, [7, 1000090], 'scoped to the caller and the goal');
53
+ assert.match(sql, /c\.autonomous/, 'a runner\'s claim, not a person\'s');
54
+ assert.match(sql, /c\.outcome = 'shipped'/, 'shipped, by the ledger');
55
+ assert.match(sql, /sr\.builder_id = \$1/, 'only the caller\'s sessions');
56
+ assert.match(sql, /status IN \('ready', 'active'\)/, 'what is left is what a runner could still take');
57
+ });
58
+ });
59
+
60
+ test('a goal not opened to runners tells no count of what is left', async () => {
61
+ await withPool(() => ({ rows: [{ tasks_done: 0, tokens: '0', avg_s: null, tasks_left: null }] }), async () => {
62
+ assert.equal((await db.readRunnerGoalStats(7, 9)).tasks_left, null);
63
+ });
64
+ });
65
+
66
+ test('nothing done yet reads as zeros and no average, never a made-up pace', async () => {
67
+ await withPool(() => ({ rows: [{ tasks_done: 0, tokens: '0', avg_s: null, tasks_left: 3 }] }), async () => {
68
+ assert.deepEqual(await db.readRunnerGoalStats(7, 1), { tasks_done: 0, tokens: 0, avg_task_seconds: null, tasks_left: 3 });
69
+ });
70
+ });
71
+
72
+ test('GET /autonomy/me/goals/:id/stats: behind autonomy.run, the caller\'s own, a bad id refused', async () => {
73
+ const r = router();
74
+ const layer = r.stack.find((l) => l.route && l.route.path === '/autonomy/me/goals/:goalId/stats' && l.route.methods.get);
75
+ assert.ok(layer, 'the route is registered');
76
+ assert.deepEqual(layer.route.stack.map((s) => s.handle.permKey).filter(Boolean), ['autonomy.run']);
77
+ const s = await serve(r);
78
+ try {
79
+ await withPool(() => ({ rows: [{ tasks_done: 2, tokens: 1000, avg_s: 600, tasks_left: 1 }] }), async (calls) => {
80
+ const ok = await fetch(`${s.base}/autonomy/me/goals/1000090/stats`);
81
+ assert.equal(ok.status, 200);
82
+ assert.deepEqual(await ok.json(), { goal_id: 1000090, tasks_done: 2, tokens: 1000, avg_task_seconds: 600, tasks_left: 1 });
83
+ assert.deepEqual(calls[0].params, [7, 1000090], 'keyed by the session\'s builder, never the request');
84
+ const bad = await fetch(`${s.base}/autonomy/me/goals/abc/stats`);
85
+ assert.equal(bad.status, 400);
86
+ });
87
+ await withPool(() => { throw new Error('db down'); }, async () => {
88
+ const down = await fetch(`${s.base}/autonomy/me/goals/5/stats`);
89
+ assert.equal(down.status, 503);
90
+ });
91
+ } finally { s.close(); }
92
+ });
@@ -197,7 +197,8 @@ test('the source → the record: every mounted gate is listed under the building
197
197
 
198
198
  // ── 3. every building, through its real router ──────────────────────────────
199
199
 
200
- const fakeBuilder = (req, res, next) => { req.builder = { id: 42, rank: 'archon', github_login: 'someone' }; next(); };
200
+ const fakeBuilder = (req, res, next) => { req.builder = { id: 42, rank: 'archon', github_login: 'someone' }; req.gdsSession = { session_id: '1' }; next(); };
201
+ require('../src/bongos/passkeys').stepUpStatus = async () => ({ required: false, fresh: false }); // task 1004747: no DB in this test
201
202
  const realApiRequireBuilder = api.requireBuilder;
202
203
  const realAuthRequireBuilder = authMod.requireBuilder;
203
204
  api.requireBuilder = fakeBuilder;
@@ -288,7 +288,8 @@ const realArchons = db.listActiveArchons;
288
288
  let caller = 3;
289
289
  let held = ['board.vote.cast'];
290
290
  let leads = [];
291
- api.requireBuilder = (req, res, next) => { req.builder = { id: caller, rank: caller === 3 ? 'archon' : 'metic' }; next(); };
291
+ api.requireBuilder = (req, res, next) => { req.builder = { id: caller, rank: caller === 3 ? 'archon' : 'metic' }; req.gdsSession = { session_id: '1' }; next(); };
292
+ require('../src/bongos/passkeys').stepUpStatus = async () => ({ required: false, fresh: false }); // task 1004747: no DB in this test
292
293
  resolver.resolveBuilderPermissions = async () => held;
293
294
  db.listLeads = async () => leads;
294
295
  db.listActiveArchons = async () => [{ id: '3', github_login: 'ada', display_name: 'Ada' }];
@@ -271,6 +271,26 @@ test('Escape closes one layer, top first: the sheet, then the scene\'s pick, the
271
271
  await ctx.close();
272
272
  });
273
273
 
274
+ test('a scene\'s pick: the card is on the glass, its action takes the accent button, Escape closes it first (task 1005243)', { skip }, async () => {
275
+ const { ctx, page, errors } = await open();
276
+ await page.evaluate(() => OTBVisor.focus({ key: 'runner:2', title: 'Area 2', status: { text: 'Running', tone: 'ok' }, lines: ['7 tasks done here'],
277
+ lead: { label: 'Recall runner', run() { window.led = (window.led || 0) + 1; } }, onClose() { window.unpicked = true; } }));
278
+ const g = await page.evaluate(() => ({
279
+ title: document.querySelector('.visor__focus-title').textContent,
280
+ status: document.querySelector('.visor__focus-status').textContent,
281
+ main: document.querySelector('.visor__main').textContent,
282
+ takes: getComputedStyle(document.querySelector('.visor__focus')).pointerEvents,
283
+ }));
284
+ assert.deepEqual(g, { title: 'Area 2', status: 'Running', main: 'Recall runner', takes: 'auto' });
285
+ await page.click('[data-visor-lead]');
286
+ assert.equal(await page.evaluate(() => window.led), 1, 'the accent button runs the scene\'s action');
287
+ await page.keyboard.press('Escape');
288
+ const s = await page.evaluate(() => ({ focus: OTBVisor.focused(), unpicked: window.unpicked, picked: window.picked, menu: window.optionsOpened, main: document.querySelector('.visor__main').textContent }));
289
+ assert.deepEqual(s, { focus: null, unpicked: true, picked: true, menu: 0, main: 'Goal skyG' }, 'the card took the press; the character\'s own button is back');
290
+ assert.deepEqual(errors, []);
291
+ await ctx.close();
292
+ });
293
+
274
294
  test('a lead scene action is the first key hint, and its click and its key run it (task 1005209)', { skip }, async () => {
275
295
  const { ctx, page, errors } = await open();
276
296
  await page.evaluate(() => OTBVisor.scene({ name: 'test', place: 'Test city', actions: [{ label: 'Decide 2 in a row', key: 'd', bind: true, lead: true, run() { window.decided = (window.decided || 0) + 1; } }] }));
@@ -0,0 +1,115 @@
1
+ // tests/home_runner.mjs — your Autobongos runner on Home's city (task 1005243).
2
+ //
3
+ // The decision is pure (home-runner.js cardFor / stateOf), so it runs here for
4
+ // real against the same runner-button.js the Settings card draws from. The page
5
+ // wiring is pinned by its source: the scene paints the pick, every button is the
6
+ // visor's (ADR 0403), and only a viewer the server says may run gets any of it.
7
+ // Run: node --test tests/home_runner.mjs
8
+
9
+ import { test } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { createRequire } from 'node:module';
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
+ const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
18
+ const require = createRequire(import.meta.url);
19
+ const R = require(path.join(HALL, 'home-runner.js'));
20
+ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
21
+
22
+ const NOW = Date.parse('2026-11-12T10:00:00Z');
23
+ const seen = new Date(NOW - 20000).toISOString();
24
+ const fenceOf = (picks, allowed = [1, 2, 3], extra = {}) => ({ enabled: true, goals: picks, scope: { may_run: true, goals: picks }, project: { enabled: true, goals: allowed }, ...extra });
25
+ const dev = (o = {}) => ({ host: 'pc', run_requested: true, run_changed_at: new Date(NOW - 3600e3).toISOString(), last_seen_at: seen, health: { state: 'alive', workingTaskId: null }, ...o });
26
+ const STATS = { tasks_done: 7, tokens: 4820000, avg_task_seconds: 2280, tasks_left: 5 };
27
+ const card = (o) => R.cardFor({ goal: { id: 2, title: 'Area 2' }, nowMs: NOW, titleOf: (g) => `Area ${g}`, stats: STATS, ...o });
28
+
29
+ test('no permission to run, or no fence read: no card, the tower stays a link', () => {
30
+ assert.equal(card({ runners: [dev()], fence: null }), null);
31
+ assert.equal(card({ runners: [dev()], fence: { ...fenceOf([]), scope: { may_run: false, goals: [] } } }), null);
32
+ });
33
+
34
+ test('no runner set up yet: the card offers setting one up', () => {
35
+ const c = card({ runners: [], fence: fenceOf([]) });
36
+ assert.equal(c.lead.act, 'setup');
37
+ assert.equal(c.status.text, 'No runner yet');
38
+ });
39
+
40
+ test('a goal the project has not opened to runners: no send, only the goal', () => {
41
+ const c = card({ runners: [dev()], fence: fenceOf([], [1, 3]) });
42
+ assert.equal(c.lead.act, 'open');
43
+ assert.match(c.lines[0], /not opened this goal to runners/);
44
+ });
45
+
46
+ test('your runner is elsewhere or resting: Send runner here, with what it did here and what is left', () => {
47
+ const away = card({ runners: [dev()], fence: fenceOf([1]) });
48
+ assert.deepEqual(away.lead, { label: 'Send runner here', act: 'send' });
49
+ assert.equal(away.status.text, 'Your runner is at Area 1');
50
+ assert.deepEqual(away.lines, ['7 tasks done here · 4.8M tokens', '5 tasks ready · about 3 h for your runner']);
51
+ const off = card({ runners: [dev({ run_requested: false })], fence: fenceOf([]) });
52
+ assert.equal(off.status.text, 'Your runner is resting');
53
+ assert.equal(off.lead.act, 'send');
54
+ });
55
+
56
+ test('your runner is here and on: its device face, the numbers, and Recall runner', () => {
57
+ const work = card({ runners: [dev({ health: { state: 'alive', workingTaskId: '9' } })], fence: fenceOf([2]) });
58
+ assert.deepEqual(work.lead, { label: 'Recall runner', act: 'recall' });
59
+ assert.deepEqual([work.status.text, work.status.tone], ['Running', 'ok']);
60
+ assert.equal(work.lines[0], 'Working on task 9.');
61
+ const rest = card({ runners: [dev({ health: { state: 'alive', workingTaskId: null, holdUntil: new Date(NOW + 42 * 60e3).toISOString() } })], fence: fenceOf([2]) });
62
+ assert.match(rest.status.text, /^Resting until .+ · 42 min$/);
63
+ assert.equal(rest.lead.act, 'recall');
64
+ });
65
+
66
+ test('recalled here: it says so and offers to send it back', () => {
67
+ const c = card({ runners: [dev({ run_requested: false })], fence: fenceOf([2]) });
68
+ assert.equal(c.status.text, 'Recalled');
69
+ assert.equal(c.lead.act, 'send');
70
+ });
71
+
72
+ test('nothing done yet: no invented numbers; nothing ready: says so', () => {
73
+ const c = card({ runners: [dev()], fence: fenceOf([1]), stats: { tasks_done: 0, tokens: 0, avg_task_seconds: null, tasks_left: 0 } });
74
+ assert.deepEqual(c.lines, ['Nothing ready to build here right now.']);
75
+ const noPace = card({ runners: [dev()], fence: fenceOf([1]), stats: { tasks_done: 0, tokens: 0, avg_task_seconds: null, tasks_left: 4 } });
76
+ assert.deepEqual(noPace.lines, ['4 tasks ready'], 'no time is promised without a pace to measure it by');
77
+ });
78
+
79
+ test('the marker\'s state follows the device; several picks are not one tower', () => {
80
+ assert.equal(R.stateOf({ runners: [dev({ health: { state: 'alive', workingTaskId: '9' } })], fence: fenceOf([2]), nowMs: NOW }), 'working');
81
+ assert.equal(R.stateOf({ runners: [dev({ run_requested: false })], fence: fenceOf([2]), nowMs: NOW }), 'off');
82
+ assert.equal(R.stateOf({ runners: [dev({ health: { state: 'alive', holdUntil: new Date(NOW + 60e3).toISOString() } })], fence: fenceOf([2]), nowMs: NOW }), 'resting');
83
+ assert.equal(R.assignedGoal(fenceOf([2])), '2');
84
+ assert.equal(R.assignedGoal(fenceOf([1, 2])), null);
85
+ });
86
+
87
+ test('numbers read like a person would say them', () => {
88
+ assert.equal(R.tokensText(4820000), '4.8M tokens');
89
+ assert.equal(R.tokensText(340400), '340k tokens');
90
+ assert.equal(R.aboutText(5 * 2280), 'about 3 h');
91
+ assert.equal(R.aboutText(1200), 'about 20 min');
92
+ assert.equal(R.aboutText(3 * 86400), 'about 3 days');
93
+ assert.equal(R.aboutText(0), '');
94
+ });
95
+
96
+ test('source: the scene paints the pick, the visor holds every button (ADR 0403), the writes are Settings\' routes', () => {
97
+ const src = read('home-runner.js');
98
+ assert.match(src, /OTBVisor\.focus/, 'the card and its button are the visor\'s');
99
+ assert.doesNotMatch(src, /createElement\(['"]button/, 'no button is drawn in the scene');
100
+ assert.match(src, /scope\.may_run === true/, 'only a viewer the server says may run');
101
+ assert.match(src, /dataset\.game === 'on'/, 'game mode only: in classic a tower stays a link');
102
+ for (const route of ["'PUT', '/autonomy/me/goals'", '/autonomy/runners/${encodeURIComponent(r.host)}/run', '/autonomy/me/goals/${encodeURIComponent(picked)}/stats']) assert.ok(src.includes(route), route);
103
+ const draw = read('home-city-draw.js');
104
+ assert.match(draw, /'data-goal': a\.id/, 'every tower names its goal');
105
+ assert.match(draw, /hc-tower--picked/);
106
+ const html = read('index.html');
107
+ const at = (f) => html.indexOf(`/builders/${f}`);
108
+ assert.ok(at('runner-button.js') > 0 && at('runner-button.js') < at('home-runner.js') && at('home-runner.js') < at('home-city.js'), 'runner-button.js, then home-runner.js, before home-city.js');
109
+ });
110
+
111
+ test('source: the marker moves only in CSS, and the hall\'s reduced-motion rule stills it', () => {
112
+ const css = read('home-city.css');
113
+ assert.match(css, /\.hc-runner--working \.hc-runner__ring \{[^}]*animation: hc-turn/);
114
+ assert.doesNotMatch(read('home-runner.js'), /requestAnimationFrame|setInterval/, 'no motion in script');
115
+ });
@@ -23,6 +23,7 @@ const PUBLISHED_SURFACE = [
23
23
  'escapeHtml', // task 1004876: THE server-side HTML escaper (1.22.0) — modules render user text through it, never a private copy
24
24
  'requireBuilder', 'requireRank', 'rankMeetsThreshold', 'requireBfgPrincipal', 'allowBoxScope', 'resolveSession',
25
25
  'requirePermission', // BV1.R105: the authority-atom gate module routes migrate onto (1.19.0; ADR 0151)
26
+ 'requireCardStepUp', // added by task 1004747 (ADR 0382): the hardware-card step-up gate a module mounts after its permission gate
26
27
  'requireActAlone', // added by task 1004657 (ADR 0363 D5): a building's own page asks its decision-rules row before one builder acts alone. On the doorway because eight buildings' pages live in six modules and core, and each must refuse the same way People does
27
28
  'pool', 'getPool', 'instanceDbName', 'withTx', 'getBuilderById',
28
29
  // added by task 1003575 (ADR 0241 §3): the instance's PROJECT OWNER — its first