@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.
- package/.bongos-core.json +97 -57
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +9 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/api/openapi.json +179 -3
- package/docs/api-reference.md +6 -3
- package/docs/architecture.md +3 -1
- package/docs/copy-inventory.md +7 -7
- package/docs/copy-registry.json +7 -7
- package/docs/module-api-changelog.md +6 -0
- package/docs/page-inventory.json +2 -0
- package/docs/page-readings.json +6 -6
- package/migrations/core_298_card_step_up.sql +16 -0
- package/modules/autonomy/db.js +48 -0
- package/modules/autonomy/routes/autonomy.js +15 -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/catalog.js +1 -1
- package/modules/government/routes/government.js +2 -2
- package/modules/hall-ui/public/home-city-draw.js +24 -2
- package/modules/hall-ui/public/home-city.css +15 -0
- package/modules/hall-ui/public/home-city.js +3 -1
- package/modules/hall-ui/public/home-runner.js +248 -0
- package/modules/hall-ui/public/index.html +8 -5
- package/modules/hall-ui/public/visor.css +27 -0
- package/modules/hall-ui/public/visor.js +65 -2
- package/modules/hall-ui/records/home-runner.md +19 -0
- package/modules/hall-ui/records/visor.md +4 -0
- 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 +17 -0
- package/scripts/bongos/fitness.js +1 -1
- package/scripts/bongos/government-walk.js +2 -2
- package/scripts/hall-preview/server.js +4 -0
- 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/autonomy_runner_goal_stats.mjs +92 -0
- package/tests/government_act_alone_buildings.mjs +2 -1
- package/tests/government_treasury.mjs +2 -1
- package/tests/hall_visor.mjs +20 -0
- package/tests/home_runner.mjs +115 -0
- 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
|
|
@@ -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' }];
|
package/tests/hall_visor.mjs
CHANGED
|
@@ -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
|
+
});
|
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
|