@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.
Files changed (38) hide show
  1. package/.bongos-core.json +60 -40
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +7 -0
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/api/openapi.json +131 -3
  8. package/docs/api-reference.md +4 -2
  9. package/docs/architecture.md +3 -1
  10. package/docs/module-api-changelog.md +4 -0
  11. package/migrations/core_298_card_step_up.sql +16 -0
  12. package/modules/economy/module.json +1 -1
  13. package/modules/economy/processor-stripe.js +292 -0
  14. package/modules/economy/routes/balances.js +4 -3
  15. package/modules/economy/routes/project-account.js +12 -1
  16. package/modules/economy/tests/processor_stripe.mjs +135 -0
  17. package/modules/government/routes/government.js +2 -2
  18. package/modules/render-deploy/routes/act.js +1 -1
  19. package/modules/render-deploy/routes/app.js +1 -1
  20. package/package-lock.json +2 -2
  21. package/package.json +1 -1
  22. package/release-notes.json +11 -0
  23. package/scripts/bongos/fitness.js +1 -1
  24. package/scripts/bongos/government-walk.js +2 -2
  25. package/src/bongos/passkeys.js +30 -0
  26. package/src/bongos/routes/auth.js +2 -2
  27. package/src/bongos/routes/builders.js +2 -1
  28. package/src/bongos/routes/module-payouts.js +4 -2
  29. package/src/bongos/routes/my-cash-outs.js +5 -3
  30. package/src/bongos/routes/my-passkeys.js +70 -0
  31. package/src/bongos/routes/payout-approvals.js +5 -3
  32. package/src/bongos/step-up.js +42 -0
  33. package/src/module-api.js +5 -1
  34. package/tests/government_act_alone_buildings.mjs +2 -1
  35. package/tests/government_treasury.mjs +2 -1
  36. package/tests/module_api.mjs +1 -0
  37. package/tests/my_passkeys.mjs +47 -1
  38. 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.96'; // 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
@@ -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' }];
@@ -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
@@ -14,7 +14,12 @@ const build = require('../src/bongos/routes/my-passkeys');
14
14
 
15
15
  const creds = [];
16
16
  const consumed = [];
17
+ const stamped = [];
18
+ let counterOk = true;
17
19
  const store = {
20
+ STEP_UP_WINDOW_SECONDS: 600,
21
+ recordUse: async () => (counterOk ? { ok: true } : { ok: false, reason: 'sign_count_regressed' }),
22
+ markSessionCardVerified: async (sid, b) => { stamped.push([sid, b]); return true; },
18
23
  listCredentials: async (b) => creds.filter((c) => c.builder_id === b),
19
24
  getOrCreateUserHandle: async () => 'aGFuZGxl',
20
25
  createChallenge: async () => ({ challenge: 'Y2hhbGw' }),
@@ -26,6 +31,12 @@ const store = {
26
31
  };
27
32
  let verifyOk = true;
28
33
  const webauthn = {
34
+ generateAuthenticationOptions: async (o) => ({ rpId: o.rpID, allowCredentials: o.allowCredentials, userVerification: o.userVerification }),
35
+ verifyAuthenticationResponse: async (o) => {
36
+ assert.equal(o.requireUserVerification, true);
37
+ if (!(await o.expectedChallenge('Y2hhbGw'))) throw new Error('challenge');
38
+ return { verified: true, authenticationInfo: { newCounter: 5 } };
39
+ },
29
40
  generateRegistrationOptions: async (o) => ({ rp: { id: o.rpID }, excludeCredentials: o.excludeCredentials }),
30
41
  verifyRegistrationResponse: async (o) => {
31
42
  assert.equal(o.requireUserVerification, true);
@@ -37,7 +48,7 @@ const webauthn = {
37
48
  const app = express();
38
49
  app.use(express.json());
39
50
  app.use((req, res, next) => { res.fail = (code, { status = 400, message } = {}) => res.status(status).json({ error: code, message }); next(); });
40
- app.use((req, res, next) => { req.builder = { id: '7', github_login: 'me' }; next(); });
51
+ app.use((req, res, next) => { req.builder = { id: '7', github_login: 'me' }; req.gdsSession = { session_id: '42' }; next(); });
41
52
  app.use('/', build({ store, webauthn, requireBuilder: (q, r, n) => n() }));
42
53
  const server = http.createServer(app).listen(0);
43
54
  const port = server.address().port;
@@ -104,5 +115,40 @@ r = await new Promise((resolve) => {
104
115
  });
105
116
  assert.equal(r.status, 503); ok('unconfigured non-local host refused');
106
117
 
118
+ // --- card check (task 1004747) ---
119
+ creds.length = 0;
120
+ r = await call('POST', '/me/passkeys/authenticate/options', {});
121
+ assert.equal(r.status, 409); ok('card check refused when no card is registered');
122
+ creds.push({ credential_id: 'cardA', builder_id: '7', public_key: Buffer.from([1]), sign_count: 1, transports: ['nfc'] });
123
+ creds.push({ credential_id: 'cardB', builder_id: '8', public_key: Buffer.from([1]), sign_count: 1, transports: [] });
124
+ r = await call('POST', '/me/passkeys/authenticate/options', {});
125
+ assert.equal(r.status, 200); assert.equal(r.body.options.userVerification, 'required');
126
+ assert.deepEqual(r.body.options.allowCredentials.map((c) => c.id), ['cardA']); ok('authenticate options list only the caller cards');
127
+ consumed.length = 0;
128
+ r = await call('POST', '/me/passkeys/authenticate/verify', { response: { id: 'cardA' } });
129
+ assert.equal(r.status, 200); assert.deepEqual(stamped[0], ['42', '7']);
130
+ assert.equal(consumed[0][1].expectPurpose, 'authenticate'); assert.equal(consumed[0][1].expectBuilderId, '7'); ok('verify stamps the caller session with an authenticate-bound challenge');
131
+ stamped.length = 0;
132
+ r = await call('POST', '/me/passkeys/authenticate/verify', { response: { id: 'cardB' } });
133
+ assert.equal(r.status, 400); assert.equal(stamped.length, 0); ok('another builder card stamps nothing');
134
+ r = await call('POST', '/me/passkeys/authenticate/verify', { response: { id: 'nope' } });
135
+ assert.equal(r.status, 400); assert.equal(stamped.length, 0); ok('unknown card stamps nothing');
136
+ counterOk = false;
137
+ r = await call('POST', '/me/passkeys/authenticate/verify', { response: { id: 'cardA' } });
138
+ assert.equal(r.status, 400); assert.equal(stamped.length, 0); ok('a counter that did not advance stamps nothing');
139
+ counterOk = true;
140
+
141
+ // --- the gate itself ---
142
+ const { buildRequireCardStepUp } = require('../src/bongos/step-up');
143
+ const gate = (st) => buildRequireCardStepUp({ store: { stepUpStatus: async () => { if (st instanceof Error) throw st; return st; } } });
144
+ const run = async (mw, req) => { let out = 'next'; const res = { fail: (c, o) => { out = typeof o === 'number' ? [c, o] : [c, o.status]; } }; await mw(req, res, () => {}); return out; };
145
+ const sess = { gdsSession: { session_id: '1' }, builder: { id: '7' } };
146
+ assert.equal(await run(gate({ required: false, fresh: false }), sess), 'next'); ok('gate: requirement off passes');
147
+ assert.equal(await run(gate({ required: true, fresh: true }), sess), 'next'); ok('gate: fresh tap passes');
148
+ assert.deepEqual(await run(gate({ required: true, fresh: false }), sess), ['card_required', 403]); ok('gate: required and stale refused');
149
+ assert.deepEqual(await run(gate(null), sess), ['card_required', 403]); ok('gate: unknown session refused');
150
+ assert.deepEqual(await run(gate(new Error('db down')), sess), ['card_check_failed', 503]); ok('gate: fails closed on error');
151
+ assert.deepEqual(await run(gate({ required: false, fresh: false }), {}), ['unauthenticated', 401]); ok('gate: no session refused');
152
+
107
153
  server.close();
108
154
  console.log(`${n} passed`);
@@ -32,12 +32,14 @@ for (const fn of ['execFile', 'spawn', 'exec', 'fork']) {
32
32
  cp[fn] = (...args) => { spawned.push([fn, args[0], args[1]]); throw new Error(`test: ${fn} must not be called`); };
33
33
  }
34
34
 
35
+ require('../src/bongos/passkeys').stepUpStatus = async () => ({ required: false, fresh: false }); // task 1004747: the step-up gate reads the DB; these stubs have none
35
36
  const authPath = require.resolve('../src/bongos/auth.js');
36
37
  require.cache[authPath] = {
37
38
  id: authPath, filename: authPath, loaded: true,
38
39
  exports: {
39
40
  requireBuilder: (req, _res, next) => {
40
41
  req.builder = { id: '1', rank: 'archon', github_login: 'archon1', display_name: 'Archon One' };
42
+ req.gdsSession = { session_id: '1' };
41
43
  next();
42
44
  },
43
45
  requireRank: () => (_req, _res, next) => next(),