@bongos/core 1.24.96 → 1.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +60 -40
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +7 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +131 -3
- package/docs/api-reference.md +4 -2
- package/docs/architecture.md +3 -1
- package/docs/module-api-changelog.md +4 -0
- package/migrations/core_298_card_step_up.sql +16 -0
- package/modules/economy/module.json +1 -1
- package/modules/economy/processor-stripe.js +292 -0
- package/modules/economy/routes/balances.js +4 -3
- package/modules/economy/routes/project-account.js +12 -1
- package/modules/economy/tests/processor_stripe.mjs +135 -0
- package/modules/government/routes/government.js +2 -2
- package/modules/render-deploy/routes/act.js +1 -1
- package/modules/render-deploy/routes/app.js +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +11 -0
- package/scripts/bongos/fitness.js +1 -1
- package/scripts/bongos/government-walk.js +2 -2
- package/src/bongos/passkeys.js +30 -0
- package/src/bongos/routes/auth.js +2 -2
- package/src/bongos/routes/builders.js +2 -1
- package/src/bongos/routes/module-payouts.js +4 -2
- package/src/bongos/routes/my-cash-outs.js +5 -3
- package/src/bongos/routes/my-passkeys.js +70 -0
- package/src/bongos/routes/payout-approvals.js +5 -3
- package/src/bongos/step-up.js +42 -0
- package/src/module-api.js +5 -1
- package/tests/government_act_alone_buildings.mjs +2 -1
- package/tests/government_treasury.mjs +2 -1
- package/tests/module_api.mjs +1 -0
- package/tests/my_passkeys.mjs +47 -1
- package/tests/rank_change_broadcast_port.mjs +2 -0
package/src/bongos/passkeys.js
CHANGED
|
@@ -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)
|
|
63
|
-
//
|
|
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.
|
|
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' }];
|
package/tests/module_api.mjs
CHANGED
|
@@ -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
|
package/tests/my_passkeys.mjs
CHANGED
|
@@ -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(),
|