@bongos/core 1.19.419 → 1.19.420
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 +58 -43
- package/.claude/skills/new-project/SKILL.md +22 -11
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +5 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0209-one-budget-for-the-account-existence-oracle.md +79 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +103 -5
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +186 -174
- package/docs/copy-registry.json +348 -238
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +2 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/onboarding/routes/access-requests.js +16 -3
- package/modules/platform-identity/platform-identity.js +5 -5
- package/modules/platform-identity/tests/platform-identity.mjs +20 -0
- package/modules/provisioning/migrations/provisioning_013_project_detail.sql +54 -0
- package/modules/provisioning/onboard-plan.js +13 -0
- package/modules/provisioning/provisioning.js +113 -5
- package/modules/provisioning/routes/provisioning.js +105 -0
- package/modules/provisioning/tests/provisioning.mjs +84 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/projects.html +161 -42
- package/scripts/gds/fitness.js +9 -0
- package/scripts/gds/onboard.js +41 -1
- package/scripts/gds/provision-net.js +7 -3
- package/src/bongos/middleware/rate-limit.js +68 -2
- package/src/bongos/routes/auth.js +19 -12
- package/src/module-api.js +13 -1
- package/tests/account_existence_rate_limit.mjs +375 -0
- package/tests/module_api.mjs +1 -0
- package/tests/onboard.mjs +56 -0
- package/tests/projects_hub.mjs +69 -0
- package/tests/projects_hub_pre_uat.mjs +27 -5
- package/tests/provision.mjs +20 -0
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// src/bongos/middleware/rate-limit.js — V3.R34 (#255).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// /api/gds
|
|
3
|
+
// Four sliding-window in-memory limiters. The first two are the GLOBAL pair and
|
|
4
|
+
// protect write methods on /api/gds/* only — read methods (GET, HEAD, OPTIONS)
|
|
5
|
+
// bypass them entirely; #3 and #4 are mounted per-surface to close exactly that
|
|
6
|
+
// gap where an unauthenticated READ is itself the abuse.
|
|
5
7
|
//
|
|
6
8
|
// 1. Per-IP limiter — 30 writes / 60s. Anchors on req.ip (which honors
|
|
7
9
|
// 'trust proxy' in server.js so Cloudflare's
|
|
@@ -32,6 +34,22 @@
|
|
|
32
34
|
// server.js sets 'trust proxy', so req.ip is the end
|
|
33
35
|
// user, not Cloudflare's single origin IP.
|
|
34
36
|
//
|
|
37
|
+
// 4. Account-existence limiter — 120 reads / 60s per IP, spent from ONE bucket
|
|
38
|
+
// by BOTH routes that answer "does this GitHub login
|
|
39
|
+
// have an account here?": the onboarding module's
|
|
40
|
+
// GET /access-requests/status (the richer
|
|
41
|
+
// member|invited|pending|dismissed|none) and core's
|
|
42
|
+
// GET /auth/web/admission-status (its boolean twin).
|
|
43
|
+
// Task 1003339 / ADR 0209. They are ONE oracle read
|
|
44
|
+
// two ways, so they share one budget — give each its
|
|
45
|
+
// own and an enumerator just alternates between them
|
|
46
|
+
// for double the rate. Both are unauthenticated by
|
|
47
|
+
// design (a would-be builder is outside the system),
|
|
48
|
+
// so the per-IP budget is the only ceiling there is.
|
|
49
|
+
// 120/min clears the honest pollers with wide margin —
|
|
50
|
+
// the access-pending page polls every 3s (20/min) —
|
|
51
|
+
// while capping a scraper at ~2 logins/s.
|
|
52
|
+
//
|
|
35
53
|
// Any limiter tripping → 429 with Retry-After (seconds rounded up to the
|
|
36
54
|
// nearest whole second). Audit middleware still fires on a write 429 — the row
|
|
37
55
|
// in audit_log is forensic value for "who tried too hard." (Reads are never
|
|
@@ -54,11 +72,16 @@ const PER_SESSION_WINDOW_MS = 60 * 1000;
|
|
|
54
72
|
const PER_SESSION_LIMIT = 60;
|
|
55
73
|
const PUBLIC_READ_WINDOW_MS = 60 * 1000;
|
|
56
74
|
const PUBLIC_READ_LIMIT = 120;
|
|
75
|
+
const ACCOUNT_EXISTENCE_WINDOW_MS = 60 * 1000;
|
|
76
|
+
const ACCOUNT_EXISTENCE_LIMIT = 120;
|
|
57
77
|
const SWEEP_MS = 5 * 60 * 1000;
|
|
58
78
|
|
|
59
79
|
const ipBuckets = new Map();
|
|
60
80
|
const sessionBuckets = new Map();
|
|
61
81
|
const publicReadBuckets = new Map();
|
|
82
|
+
// ONE bucket map for limiter #4 — both account-existence probes key into it, so
|
|
83
|
+
// the budget is genuinely shared rather than merely identically configured.
|
|
84
|
+
const accountExistenceBuckets = new Map();
|
|
62
85
|
|
|
63
86
|
const WRITE_METHODS = new Set(['POST', 'PATCH', 'PUT', 'DELETE']);
|
|
64
87
|
// HEAD is metered too: Express runs the GET handler (incl. the DB query) for a
|
|
@@ -105,6 +128,10 @@ function sweep() {
|
|
|
105
128
|
pruneAndCount(arr, now, PUBLIC_READ_WINDOW_MS);
|
|
106
129
|
if (arr.length === 0) publicReadBuckets.delete(k);
|
|
107
130
|
}
|
|
131
|
+
for (const [k, arr] of accountExistenceBuckets) {
|
|
132
|
+
pruneAndCount(arr, now, ACCOUNT_EXISTENCE_WINDOW_MS);
|
|
133
|
+
if (arr.length === 0) accountExistenceBuckets.delete(k);
|
|
134
|
+
}
|
|
108
135
|
}
|
|
109
136
|
|
|
110
137
|
// Schedule the sweep. unref() so it doesn't keep the process alive during
|
|
@@ -182,9 +209,46 @@ function publicReadRateLimitMiddleware(req, res, next) {
|
|
|
182
209
|
next();
|
|
183
210
|
}
|
|
184
211
|
|
|
212
|
+
// perIpSlidingWindow(buckets, opts) — build a per-IP sliding-window middleware
|
|
213
|
+
// over the given bucket map. Exists so a second route can spend from the SAME
|
|
214
|
+
// budget as the first simply by mounting the same returned function; `scope`
|
|
215
|
+
// names that budget in the 429 body + headers so a caller can tell which ceiling
|
|
216
|
+
// it hit. Keyed on req.ip — the same real-client-IP resolution the write limiter
|
|
217
|
+
// uses (Caddy forwards the client IP, server sets 'trust proxy').
|
|
218
|
+
function perIpSlidingWindow(buckets, { limit, windowMs, scope }) {
|
|
219
|
+
return function slidingWindowRateLimit(req, res, next) {
|
|
220
|
+
const now = Date.now();
|
|
221
|
+
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
|
|
222
|
+
let bucket = buckets.get(ip);
|
|
223
|
+
if (!bucket) { bucket = []; buckets.set(ip, bucket); }
|
|
224
|
+
if (pruneAndCount(bucket, now, windowMs) >= limit) {
|
|
225
|
+
const retryAfter = Math.max(1, Math.ceil((bucket[0] + windowMs - now) / 1000));
|
|
226
|
+
res.set('Retry-After', String(retryAfter));
|
|
227
|
+
res.set('X-RateLimit-Scope', scope);
|
|
228
|
+
res.set('X-RateLimit-Limit', String(limit));
|
|
229
|
+
res.set('X-RateLimit-Window-Seconds', String(windowMs / 1000));
|
|
230
|
+
return res.fail('rate_limited', 429, { scope, retry_after_seconds: retryAfter });
|
|
231
|
+
}
|
|
232
|
+
bucket.push(now);
|
|
233
|
+
return next();
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// accountExistenceReadRateLimit — limiter #4 in the header. ONE instance, mounted
|
|
238
|
+
// on both account-existence probes (core /auth/web/admission-status and the
|
|
239
|
+
// onboarding module's /access-requests/status, which reaches it through the
|
|
240
|
+
// doorway). Mounting the same function on both is what makes the budget shared:
|
|
241
|
+
// exhausting it on either route closes the other for the rest of the window.
|
|
242
|
+
const accountExistenceReadRateLimit = perIpSlidingWindow(accountExistenceBuckets, {
|
|
243
|
+
limit: ACCOUNT_EXISTENCE_LIMIT,
|
|
244
|
+
windowMs: ACCOUNT_EXISTENCE_WINDOW_MS,
|
|
245
|
+
scope: 'account-existence',
|
|
246
|
+
});
|
|
247
|
+
|
|
185
248
|
module.exports = {
|
|
186
249
|
rateLimitMiddleware,
|
|
187
250
|
publicReadRateLimitMiddleware,
|
|
251
|
+
accountExistenceReadRateLimit,
|
|
188
252
|
// exported for tests + the security-baseline-check script
|
|
189
253
|
PER_IP_WINDOW_MS,
|
|
190
254
|
PER_IP_LIMIT,
|
|
@@ -192,4 +256,6 @@ module.exports = {
|
|
|
192
256
|
PER_SESSION_LIMIT,
|
|
193
257
|
PUBLIC_READ_WINDOW_MS,
|
|
194
258
|
PUBLIC_READ_LIMIT,
|
|
259
|
+
ACCOUNT_EXISTENCE_WINDOW_MS,
|
|
260
|
+
ACCOUNT_EXISTENCE_LIMIT,
|
|
195
261
|
};
|
|
@@ -34,6 +34,7 @@ const { reportActivityRollupForShippedTask } = require('../auth-admission.js');
|
|
|
34
34
|
const db = require('../db');
|
|
35
35
|
const { pairStore } = require('../app-pair');
|
|
36
36
|
const { publicOrigin, parseCookie, validateOrRespond } = require('./_helpers');
|
|
37
|
+
const { accountExistenceReadRateLimit } = require('../middleware/rate-limit');
|
|
37
38
|
const { branding } = require('../../branding');
|
|
38
39
|
const seams = require('../../module-seams');
|
|
39
40
|
// task 1003208: structured logging — was console.*. A core file logs through
|
|
@@ -564,23 +565,29 @@ module.exports = function buildAuthRouter() {
|
|
|
564
565
|
// NOT make it non-enumerable: with open enrollment off, and for any login
|
|
565
566
|
// that is not the configured firstAdmin, `admitted:true` means that login is
|
|
566
567
|
// a member or has been invited. That reveals account existence to an
|
|
567
|
-
// unauthenticated caller.
|
|
568
|
-
// returns early for non-write methods, so GETs bypass it entirely; the page's
|
|
569
|
-
// 3s poll bounds the honest client, not an attacker.
|
|
568
|
+
// unauthenticated caller.
|
|
570
569
|
//
|
|
571
|
-
//
|
|
572
|
-
//
|
|
573
|
-
//
|
|
574
|
-
//
|
|
575
|
-
//
|
|
576
|
-
//
|
|
577
|
-
//
|
|
578
|
-
//
|
|
570
|
+
// IT IS NOW THROTTLED (task 1003339 / ADR 0209). It used to say "NOT
|
|
571
|
+
// throttled" here, and that was true: rateLimitMiddleware returns early for
|
|
572
|
+
// non-write methods, so GETs bypassed it entirely and the page's 3s poll
|
|
573
|
+
// bounded the honest client, not an attacker. accountExistenceReadRateLimit
|
|
574
|
+
// below is the ceiling — 120 reads/60s per IP, and the SAME instance is
|
|
575
|
+
// mounted on the onboarding module's GET /access-requests/status, which is
|
|
576
|
+
// this route's strictly richer twin (member|invited|pending|dismissed|none
|
|
577
|
+
// plus its own `admitted`). One budget across both, because they are one
|
|
578
|
+
// oracle: hardening either alone would accomplish nothing, and giving each its
|
|
579
|
+
// own budget would just let an enumerator alternate for double the rate.
|
|
580
|
+
//
|
|
581
|
+
// WHAT IS STILL OPEN and is deliberately NOT decided here: whether the answer
|
|
582
|
+
// itself should collapse, so a caller who is not the subject cannot tell
|
|
583
|
+
// pending/dismissed/none apart from a bare "not admitted". That changes what a
|
|
584
|
+
// real invited user sees during onboarding, so it is the owner's call — filed
|
|
585
|
+
// as a blocker against task 1003339, not settled by a comment in this file.
|
|
579
586
|
//
|
|
580
587
|
// That endpoint is also why this one exists: it models neither the firstAdmin
|
|
581
588
|
// bypass nor open enrollment, so it reports the OWNER as 'none' forever —
|
|
582
589
|
// the exact case this page has to resolve.
|
|
583
|
-
router.get('/auth/web/admission-status', async (req, res) => {
|
|
590
|
+
router.get('/auth/web/admission-status', accountExistenceReadRateLimit, async (req, res) => {
|
|
584
591
|
const login = typeof req.query.login === 'string' ? req.query.login.slice(0, 100) : '';
|
|
585
592
|
if (!login) return res.json({ admitted: false });
|
|
586
593
|
try {
|
package/src/module-api.js
CHANGED
|
@@ -33,7 +33,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
33
33
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
34
34
|
// the entry to that file. Look for a version's history there, not here.
|
|
35
35
|
// ---------------------------------------------------------------------------
|
|
36
|
-
const CORE_VERSION = '1.19.
|
|
36
|
+
const CORE_VERSION = '1.19.420'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
37
37
|
|
|
38
38
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
39
39
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -131,6 +131,18 @@ module.exports = {
|
|
|
131
131
|
// The ladder as the canonical 1-based rank → tier map (BV1.R108) — modules
|
|
132
132
|
// derive tier comparisons from this instead of hand-copying the map.
|
|
133
133
|
get RANK_TIER() { return require('./bongos/db-kernel').RANK_TIER; },
|
|
134
|
+
// accountExistenceReadRateLimit — the SHARED per-IP budget (120 reads/60s)
|
|
135
|
+
// spent by every route that answers "does this login have an account
|
|
136
|
+
// here?". A kernel trust primitive in the same sense the authz gates are:
|
|
137
|
+
// the abuse ceiling on an unauthenticated read. It is one MIDDLEWARE
|
|
138
|
+
// INSTANCE, not a factory, on purpose — core's /auth/web/admission-status
|
|
139
|
+
// and the onboarding module's richer /access-requests/status are one
|
|
140
|
+
// oracle read two ways, so they must spend from one bucket or an
|
|
141
|
+
// enumerator alternates between them for double the rate (task 1003339,
|
|
142
|
+
// ADR 0209). Vendoring a second copy in a module is the failure this
|
|
143
|
+
// export exists to prevent.
|
|
144
|
+
accountExistenceReadRateLimit: require('./bongos/middleware/rate-limit').accountExistenceReadRateLimit,
|
|
145
|
+
|
|
134
146
|
// --- per-builder secrets at rest (AES-256-GCM box) — a kernel trust
|
|
135
147
|
// primitive; the full secretBox object is exposed so a module can call
|
|
136
148
|
// isConfigured/encrypt/decrypt/provisionMasterKey without reaching core.
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// tests/account_existence_rate_limit.mjs — the account-existence oracle has a
|
|
2
|
+
// ceiling, and it is ONE ceiling (task 1003339, goal 1000045; ADR 0209).
|
|
3
|
+
//
|
|
4
|
+
// The hole this pins shut: two PUBLIC routes answer "does this GitHub login have
|
|
5
|
+
// an account here?" — the onboarding module's GET /access-requests/status (the
|
|
6
|
+
// rich member|invited|pending|dismissed|none) and core's
|
|
7
|
+
// GET /auth/web/admission-status (its boolean twin) — and neither was throttled.
|
|
8
|
+
// The global limiter returns early for non-write methods, so every GET bypassed
|
|
9
|
+
// it. Scripted at line rate, either route turns a list of GitHub usernames into a
|
|
10
|
+
// membership roster: who builds here, and confirmation that a named person is
|
|
11
|
+
// present. That is targeting data, and it is worth having for a phishing run.
|
|
12
|
+
//
|
|
13
|
+
// What is executed here, not asserted about:
|
|
14
|
+
//
|
|
15
|
+
// 1. COUNTING — the first ACCOUNT_EXISTENCE_LIMIT probes from one address pass
|
|
16
|
+
// and the next is refused, on BOTH routes, over real HTTP through the real
|
|
17
|
+
// routers.
|
|
18
|
+
// 2. SHARING — the two routes spend from ONE budget. Exhaust it on the rich
|
|
19
|
+
// route and the boolean twin is closed too, and vice versa. This is the
|
|
20
|
+
// property that matters most and the easiest one to fake: two limiters
|
|
21
|
+
// configured 120/60s each look identical in a diff and hand an enumerator
|
|
22
|
+
// 240/min. So it is proved by behaviour AND by object identity (§4).
|
|
23
|
+
// 3. SLIDING — still refused 30s in, open again at 61s. Proved on the real
|
|
24
|
+
// mounted route by moving Date.now, not by testing a private helper.
|
|
25
|
+
// 4. ISOLATION — the ceiling is per-IP, so one abuser cannot close the door on
|
|
26
|
+
// everybody else.
|
|
27
|
+
// 5. CHEAPNESS — a refused probe issues no database query. The limiter runs
|
|
28
|
+
// ahead of the lookups, so the oracle is not also a free amplifier.
|
|
29
|
+
//
|
|
30
|
+
// MUTATION-CHECKED (the tests/own_session_revoke.mjs standard): remove
|
|
31
|
+
// `accountExistenceReadRateLimit` from either route's mount and this file goes
|
|
32
|
+
// red — §1's counting fails for that route and §2's sharing fails in both
|
|
33
|
+
// directions. Replace the shared instance with a per-route copy and §2 fails
|
|
34
|
+
// while §1 still passes, which is exactly the regression worth catching.
|
|
35
|
+
//
|
|
36
|
+
// DB-FREE. The fake pool below is a small in-memory builders/access_requests
|
|
37
|
+
// pair that answers from the statement's OWN shape, so the 200s are real answers
|
|
38
|
+
// and the query counter in §5 counts real intent. Everything above it is real:
|
|
39
|
+
// the real doorway (src/module-api.js — the module gets the limiter through it,
|
|
40
|
+
// which is the point), the real auth.webAdmissionStatus, the real routers, real
|
|
41
|
+
// HTTP with the production 'trust proxy' setting.
|
|
42
|
+
//
|
|
43
|
+
// Run: node tests/account_existence_rate_limit.mjs
|
|
44
|
+
|
|
45
|
+
import { strict as assert } from 'node:assert';
|
|
46
|
+
import http from 'node:http';
|
|
47
|
+
import { createRequire } from 'node:module';
|
|
48
|
+
import { readFileSync } from 'node:fs';
|
|
49
|
+
import { fileURLToPath } from 'node:url';
|
|
50
|
+
import { dirname, join } from 'node:path';
|
|
51
|
+
// ESM `import`, not createRequire, ON PURPOSE: the knip dead-code ratchet does
|
|
52
|
+
// not credit a CJS export reached through createRequire, so these three would
|
|
53
|
+
// read as unused and regress the baseline. Safe to hoist above the pool pin
|
|
54
|
+
// below — rate-limit.js has no requires of its own — and an ESM import of a CJS
|
|
55
|
+
// module resolves through the same cache, so SHARED_LIMITER is the very object
|
|
56
|
+
// auth.js and the doorway hold (which §4 asserts).
|
|
57
|
+
import {
|
|
58
|
+
accountExistenceReadRateLimit as SHARED_LIMITER,
|
|
59
|
+
ACCOUNT_EXISTENCE_LIMIT as LIMIT,
|
|
60
|
+
ACCOUNT_EXISTENCE_WINDOW_MS as WINDOW_MS,
|
|
61
|
+
} from '../src/bongos/middleware/rate-limit.js';
|
|
62
|
+
|
|
63
|
+
const require = createRequire(import.meta.url);
|
|
64
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
65
|
+
|
|
66
|
+
let passed = 0;
|
|
67
|
+
let failed = 0;
|
|
68
|
+
async function t(name, fn) {
|
|
69
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
70
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ===========================================================================
|
|
74
|
+
// The in-memory tables + their statement interpreter
|
|
75
|
+
// ===========================================================================
|
|
76
|
+
|
|
77
|
+
// 'ada' is a member; 'bo' has an invite; 'cy' is still pending; everyone else is
|
|
78
|
+
// a stranger. The 200-level answers below are therefore real route output, which
|
|
79
|
+
// is what makes "the limiter refused before the lookup" (§5) meaningful.
|
|
80
|
+
const builders = new Set(['ada']);
|
|
81
|
+
const accessRequests = [
|
|
82
|
+
{ github_login: 'bo', status: 'invited' },
|
|
83
|
+
{ github_login: 'cy', status: 'pending' },
|
|
84
|
+
{ github_login: 'dez', status: 'dismissed' },
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
let queries = [];
|
|
88
|
+
const pool = {
|
|
89
|
+
query: async (text, params = []) => {
|
|
90
|
+
const sql = String(text).replace(/\s+/g, ' ').trim();
|
|
91
|
+
queries.push({ sql, params });
|
|
92
|
+
const login = String(params[0] ?? '').toLowerCase();
|
|
93
|
+
const req = accessRequests.filter((r) => r.github_login.toLowerCase() === login);
|
|
94
|
+
|
|
95
|
+
// db-kernel accessStateByLogin — the three-EXISTS probe behind
|
|
96
|
+
// auth.webAdmissionStatus. Recognised by its own projection.
|
|
97
|
+
if (/AS member/.test(sql)) {
|
|
98
|
+
return { rows: [{
|
|
99
|
+
member: builders.has(login),
|
|
100
|
+
invited: req.some((r) => r.status === 'invited'),
|
|
101
|
+
pending: req.some((r) => r.status === 'pending'),
|
|
102
|
+
}], rowCount: 1 };
|
|
103
|
+
}
|
|
104
|
+
if (/^SELECT 1 FROM builders/.test(sql)) {
|
|
105
|
+
return builders.has(login) ? { rows: [{ '?column?': 1 }], rowCount: 1 } : { rows: [], rowCount: 0 };
|
|
106
|
+
}
|
|
107
|
+
if (/FROM access_requests/.test(sql)) {
|
|
108
|
+
// The route prefers an 'invited' row, else the most recent.
|
|
109
|
+
const ordered = [...req].sort((a, b) => Number(b.status === 'invited') - Number(a.status === 'invited'));
|
|
110
|
+
return ordered.length ? { rows: [{ status: ordered[0].status }], rowCount: 1 } : { rows: [], rowCount: 0 };
|
|
111
|
+
}
|
|
112
|
+
throw new Error(`fake pool: unhandled statement — ${sql.slice(0, 90)}`);
|
|
113
|
+
},
|
|
114
|
+
on: () => {},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Pin the fake pool BEFORE anything that reaches for a connection is loaded, so
|
|
118
|
+
// the doorway, db-kernel and the routers all resolve to it and no real pg Pool
|
|
119
|
+
// is ever built.
|
|
120
|
+
const poolPath = require.resolve('../src/bongos/pool.js');
|
|
121
|
+
require.cache[poolPath] = {
|
|
122
|
+
id: poolPath, filename: poolPath, loaded: true,
|
|
123
|
+
exports: { pool, instanceDbName: () => 'test' },
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const express = require('express');
|
|
127
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
128
|
+
const api = require('../src/module-api.js');
|
|
129
|
+
const buildAuthRouter = require('../src/bongos/routes/auth.js');
|
|
130
|
+
const buildAccessRequestsRouter = require('../modules/onboarding/routes/access-requests.js');
|
|
131
|
+
|
|
132
|
+
const RICH = (login) => `/api/gds/access-requests/status?github_login=${encodeURIComponent(login)}`;
|
|
133
|
+
const BOOL = (login) => `/api/gds/auth/web/admission-status?login=${encodeURIComponent(login)}`;
|
|
134
|
+
|
|
135
|
+
const authRouter = buildAuthRouter();
|
|
136
|
+
const accessRouter = buildAccessRequestsRouter();
|
|
137
|
+
|
|
138
|
+
const app = express();
|
|
139
|
+
// Exactly what src/platform-server.js sets, so req.ip resolves from
|
|
140
|
+
// X-Forwarded-For the same way it does behind the real proxy.
|
|
141
|
+
app.set('trust proxy', 1);
|
|
142
|
+
app.use(express.json());
|
|
143
|
+
app.use(attachFail);
|
|
144
|
+
app.use('/api/gds', accessRouter);
|
|
145
|
+
app.use('/api/gds', authRouter);
|
|
146
|
+
|
|
147
|
+
const server = app.listen(0, '127.0.0.1');
|
|
148
|
+
await new Promise((r) => server.once('listening', r));
|
|
149
|
+
const PORT = server.address().port;
|
|
150
|
+
|
|
151
|
+
// node:http rather than fetch — §3 moves Date.now, and undici's connection
|
|
152
|
+
// bookkeeping should not be dragged along for the ride.
|
|
153
|
+
function call(path, ip) {
|
|
154
|
+
return new Promise((resolve, reject) => {
|
|
155
|
+
const req = http.request(
|
|
156
|
+
{ host: '127.0.0.1', port: PORT, path, method: 'GET', headers: { 'x-forwarded-for': ip } },
|
|
157
|
+
(res) => {
|
|
158
|
+
let data = '';
|
|
159
|
+
res.on('data', (c) => { data += c; });
|
|
160
|
+
res.on('end', () => resolve({
|
|
161
|
+
status: res.statusCode,
|
|
162
|
+
headers: res.headers,
|
|
163
|
+
body: data ? JSON.parse(data) : null,
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
);
|
|
167
|
+
req.on('error', reject);
|
|
168
|
+
req.end();
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Spend the whole budget for `ip`, asserting every probe was actually served.
|
|
173
|
+
async function exhaust(ip, pathFor = RICH, login = 'ada') {
|
|
174
|
+
for (let i = 0; i < LIMIT; i++) {
|
|
175
|
+
const r = await call(pathFor(login), ip);
|
|
176
|
+
assert.equal(r.status, 200, `probe ${i + 1} of ${LIMIT} should still be inside the budget (got ${r.status})`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ===========================================================================
|
|
181
|
+
// 1 — the ceiling exists, on both routes
|
|
182
|
+
// ===========================================================================
|
|
183
|
+
|
|
184
|
+
console.log('the ceiling exists — both probes stop answering:');
|
|
185
|
+
|
|
186
|
+
await t(`the rich probe answers ${LIMIT} times from one address, then refuses`, async () => {
|
|
187
|
+
const ip = '10.0.0.1';
|
|
188
|
+
await exhaust(ip);
|
|
189
|
+
const over = await call(RICH('ada'), ip);
|
|
190
|
+
assert.equal(over.status, 429, `probe ${LIMIT + 1} must be refused`);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await t('the rich probe\'s refusal is the ADR 0116 envelope, and says which budget ran out', async () => {
|
|
194
|
+
const ip = '10.0.0.2';
|
|
195
|
+
await exhaust(ip);
|
|
196
|
+
const over = await call(RICH('ada'), ip);
|
|
197
|
+
assert.equal(over.body.error.code, 'rate_limited');
|
|
198
|
+
assert.equal(over.body.error.details.scope, 'account-existence',
|
|
199
|
+
'the two routes must name ONE budget — a per-route scope name would mean per-route buckets');
|
|
200
|
+
assert.equal(over.headers['x-ratelimit-scope'], 'account-existence');
|
|
201
|
+
assert.equal(over.headers['x-ratelimit-limit'], String(LIMIT));
|
|
202
|
+
const retry = Number(over.headers['retry-after']);
|
|
203
|
+
assert.ok(Number.isInteger(retry) && retry >= 1 && retry <= WINDOW_MS / 1000,
|
|
204
|
+
`Retry-After must be a whole number of seconds inside the window (got ${over.headers['retry-after']})`);
|
|
205
|
+
assert.equal(over.body.error.details.retry_after_seconds, retry, 'the body agrees with the header');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
await t(`the boolean probe answers ${LIMIT} times from one address, then refuses`, async () => {
|
|
209
|
+
const ip = '10.0.0.3';
|
|
210
|
+
await exhaust(ip, BOOL);
|
|
211
|
+
const over = await call(BOOL('ada'), ip);
|
|
212
|
+
assert.equal(over.status, 429, `probe ${LIMIT + 1} must be refused`);
|
|
213
|
+
assert.equal(over.body.error.code, 'rate_limited');
|
|
214
|
+
assert.equal(over.body.error.details.scope, 'account-existence');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
await t('under the ceiling both probes still answer truthfully — this is a cap, not a mute', async () => {
|
|
218
|
+
const ip = '10.0.0.4';
|
|
219
|
+
const member = await call(RICH('ada'), ip);
|
|
220
|
+
assert.deepEqual(member.body, { github_login: 'ada', status: 'member', admitted: true });
|
|
221
|
+
const invited = await call(RICH('bo'), ip);
|
|
222
|
+
assert.equal(invited.body.status, 'invited');
|
|
223
|
+
const stranger = await call(RICH('zzz'), ip);
|
|
224
|
+
assert.equal(stranger.body.status, 'none');
|
|
225
|
+
const bool = await call(BOOL('ada'), ip);
|
|
226
|
+
assert.deepEqual(bool.body, { admitted: true });
|
|
227
|
+
assert.equal((await call(BOOL('zzz'), ip)).body.admitted, false);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// ===========================================================================
|
|
231
|
+
// 2 — ONE budget, not two. The property a copy-pasted limiter would lose.
|
|
232
|
+
// ===========================================================================
|
|
233
|
+
|
|
234
|
+
console.log('\none oracle, one budget — the routes cannot be alternated for double the rate:');
|
|
235
|
+
|
|
236
|
+
await t('spending the budget on the rich probe closes the boolean twin as well', async () => {
|
|
237
|
+
const ip = '10.0.0.5';
|
|
238
|
+
await exhaust(ip, RICH);
|
|
239
|
+
const twin = await call(BOOL('ada'), ip);
|
|
240
|
+
assert.equal(twin.status, 429,
|
|
241
|
+
'the twin answers the same question — two separate 120/60s budgets would hand an enumerator 240/min');
|
|
242
|
+
assert.equal(twin.body.error.details.scope, 'account-existence');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
await t('and in the other direction — spending it on the boolean twin closes the rich probe', async () => {
|
|
246
|
+
const ip = '10.0.0.6';
|
|
247
|
+
await exhaust(ip, BOOL);
|
|
248
|
+
const rich = await call(RICH('ada'), ip);
|
|
249
|
+
assert.equal(rich.status, 429);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
await t('a budget spent half on each route still totals one budget', async () => {
|
|
253
|
+
const ip = '10.0.0.7';
|
|
254
|
+
for (let i = 0; i < LIMIT; i++) {
|
|
255
|
+
const r = await call(i % 2 === 0 ? RICH('ada') : BOOL('ada'), ip);
|
|
256
|
+
assert.equal(r.status, 200, `alternating probe ${i + 1} should still be inside the shared budget`);
|
|
257
|
+
}
|
|
258
|
+
assert.equal((await call(RICH('ada'), ip)).status, 429, 'alternating must not buy a single extra probe');
|
|
259
|
+
assert.equal((await call(BOOL('ada'), ip)).status, 429);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// ===========================================================================
|
|
263
|
+
// 3 — the window slides, so the cap is a cap and not a permanent ban
|
|
264
|
+
// ===========================================================================
|
|
265
|
+
|
|
266
|
+
console.log('\nthe window slides — an exhausted address recovers, and not a moment early:');
|
|
267
|
+
|
|
268
|
+
await t(`still refused part-way through the window, open again after ${WINDOW_MS / 1000}s`, async () => {
|
|
269
|
+
const ip = '10.0.0.8';
|
|
270
|
+
const realNow = Date.now;
|
|
271
|
+
try {
|
|
272
|
+
await exhaust(ip);
|
|
273
|
+
assert.equal((await call(RICH('ada'), ip)).status, 429, 'exhausted to begin with');
|
|
274
|
+
|
|
275
|
+
// Move the clock, not the limiter: this is the route that ships, with its
|
|
276
|
+
// real 60s window, being asked again later.
|
|
277
|
+
const t0 = realNow();
|
|
278
|
+
Date.now = () => t0 + WINDOW_MS / 2;
|
|
279
|
+
assert.equal((await call(RICH('ada'), ip)).status, 429,
|
|
280
|
+
'half a window later the timestamps are still inside it — a fixed-bucket reset would wrongly open here');
|
|
281
|
+
|
|
282
|
+
Date.now = () => t0 + WINDOW_MS + 1000;
|
|
283
|
+
const after = await call(RICH('ada'), ip);
|
|
284
|
+
assert.equal(after.status, 200, 'once the whole window has passed the budget is back');
|
|
285
|
+
assert.equal(after.body.status, 'member', 'and it is serving real answers again, not a cached 200');
|
|
286
|
+
} finally {
|
|
287
|
+
Date.now = realNow;
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// ===========================================================================
|
|
292
|
+
// 4 — per-IP, and structurally shared
|
|
293
|
+
// ===========================================================================
|
|
294
|
+
|
|
295
|
+
console.log('\nthe ceiling is per-address, and the sharing is structural:');
|
|
296
|
+
|
|
297
|
+
await t('one exhausted address does not close the door on anybody else', async () => {
|
|
298
|
+
const noisy = '10.0.0.9';
|
|
299
|
+
const bystander = '10.0.0.10';
|
|
300
|
+
await exhaust(noisy);
|
|
301
|
+
assert.equal((await call(RICH('ada'), noisy)).status, 429);
|
|
302
|
+
const other = await call(RICH('ada'), bystander);
|
|
303
|
+
assert.equal(other.status, 200, 'a per-IP cap must not degenerate into a global kill switch');
|
|
304
|
+
assert.equal((await call(BOOL('ada'), bystander)).status, 200);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
await t('both routes mount the very same middleware INSTANCE — sharing that cannot drift', () => {
|
|
308
|
+
const shared = SHARED_LIMITER;
|
|
309
|
+
assert.equal(typeof shared, 'function');
|
|
310
|
+
assert.equal(require('../src/bongos/middleware/rate-limit.js').accountExistenceReadRateLimit, shared,
|
|
311
|
+
'the ESM and CJS views of the middleware must be the same object, or this whole test proves nothing');
|
|
312
|
+
assert.equal(api.accountExistenceReadRateLimit, shared,
|
|
313
|
+
'the doorway must hand modules the same instance core uses, not a fresh one');
|
|
314
|
+
|
|
315
|
+
const handlers = (router, path) => {
|
|
316
|
+
const layer = router.stack.find((l) => l.route && l.route.path === path && l.route.methods.get);
|
|
317
|
+
assert.ok(layer, `${path} must be a declared GET route`);
|
|
318
|
+
return layer.route.stack.map((s) => s.handle);
|
|
319
|
+
};
|
|
320
|
+
assert.ok(handlers(accessRouter, '/access-requests/status').includes(shared),
|
|
321
|
+
'the onboarding probe must mount the shared limiter');
|
|
322
|
+
assert.ok(handlers(authRouter, '/auth/web/admission-status').includes(shared),
|
|
323
|
+
'the core probe must mount the shared limiter');
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
await t('neither route vendors its own sliding window — the copy-paste regression stays out', () => {
|
|
327
|
+
for (const rel of ['modules/onboarding/routes/access-requests.js', 'src/bongos/routes/auth.js']) {
|
|
328
|
+
const src = readFileSync(join(ROOT, rel), 'utf8');
|
|
329
|
+
assert.ok(!/RATE_(WINDOW|LIMIT)|readRateLimit\s*=|new Map\(\); \/\/ ip/.test(src),
|
|
330
|
+
`${rel} must reach the shared limiter, never vendor a second one`);
|
|
331
|
+
}
|
|
332
|
+
const onboarding = readFileSync(join(ROOT, 'modules/onboarding/routes/access-requests.js'), 'utf8');
|
|
333
|
+
assert.match(onboarding, /accountExistenceReadRateLimit\s*\}?\s*=\s*api/,
|
|
334
|
+
'the module reaches it through the published doorway (ADR 0083), not a deep core require');
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// ===========================================================================
|
|
338
|
+
// 5 — a refused probe is cheap, so the oracle is not also an amplifier
|
|
339
|
+
// ===========================================================================
|
|
340
|
+
|
|
341
|
+
console.log('\na refused probe costs nothing:');
|
|
342
|
+
|
|
343
|
+
await t('the limiter runs ahead of the lookups — a 429 issues no query', async () => {
|
|
344
|
+
const ip = '10.0.0.11';
|
|
345
|
+
await exhaust(ip);
|
|
346
|
+
queries = [];
|
|
347
|
+
assert.equal((await call(RICH('ada'), ip)).status, 429);
|
|
348
|
+
assert.equal((await call(BOOL('ada'), ip)).status, 429);
|
|
349
|
+
assert.deepEqual(queries, [], 'a refused probe must not reach the database at all');
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
await t('an allowed probe DOES query — so the previous test is not vacuous', async () => {
|
|
353
|
+
queries = [];
|
|
354
|
+
await call(RICH('ada'), '10.0.0.12');
|
|
355
|
+
assert.ok(queries.length > 0, 'the 200 path really does hit the pool');
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// ===========================================================================
|
|
359
|
+
// 6 — the budget is sized for the honest callers it has to clear
|
|
360
|
+
// ===========================================================================
|
|
361
|
+
|
|
362
|
+
await t('the ceiling clears both honest pollers with room to spare', () => {
|
|
363
|
+
// renderAccessPendingPage polls the boolean probe every 3s; `bongos login`
|
|
364
|
+
// polls the rich one every 5s. A future tightening that cannot fit both,
|
|
365
|
+
// several times over, would break sign-in for a shared/NAT address.
|
|
366
|
+
const honestPerMinute = 60 / 3 + 60 / 5;
|
|
367
|
+
assert.ok(LIMIT >= honestPerMinute * 3,
|
|
368
|
+
`${LIMIT}/min must leave real margin over the ${honestPerMinute}/min the pollers need`);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// ===========================================================================
|
|
372
|
+
|
|
373
|
+
server.close();
|
|
374
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
375
|
+
process.exit(failed === 0 ? 0 : 1);
|
package/tests/module_api.mjs
CHANGED
|
@@ -24,6 +24,7 @@ const PUBLISHED_SURFACE = [
|
|
|
24
24
|
'requirePermission', // BV1.R105: the authority-atom gate module routes migrate onto (1.19.0; ADR 0151)
|
|
25
25
|
'pool', 'getPool', 'instanceDbName', 'withTx', 'getBuilderById',
|
|
26
26
|
'secretBox', // Gemini-key storage relocated to modules/art-pipeline + its 'art.getBuilderGeminiKeyRow' port (ADR 0102)
|
|
27
|
+
'accountExistenceReadRateLimit', // task 1003339 (ADR 0209): the shared per-IP budget both account-existence probes spend from — one instance, not a factory, so the budget is genuinely shared
|
|
27
28
|
'routeRankCheck', 'permissionPathCheck', // BV1.R73: kernel trust classifiers (1.6.0)
|
|
28
29
|
'repoInfo', 'moduleScopeMap', 'pathMatch', // BV1.R86: kernel repo-identity + scope-wall + touches-overlap for the carved lifecycle (1.12.0)
|
|
29
30
|
'goalScopeCheck', // BV1.R60: kernel scope wall for the goal-authored-task create gate (1.13.0; ADR 0086 §3)
|
package/tests/onboard.mjs
CHANGED
|
@@ -31,6 +31,62 @@ test('onboardPlan is the canonical sequence: name+address first, the builders ha
|
|
|
31
31
|
assert.ok(keys.indexOf('device-flow') < keys.indexOf('first-sign-in'), 'checkbox prompted before sign-in');
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
+
test('the OPTIONAL project-detail step sits in the canonical sequence, and optional means skippable (task 1002334)', () => {
|
|
35
|
+
const plan = ob.onboardPlan();
|
|
36
|
+
const keys = plan.map((s) => s.key);
|
|
37
|
+
const step = plan.find((s) => s.key === 'project-detail');
|
|
38
|
+
assert.ok(step, 'the interview is a real step in the sequence, not a wizard-only flourish');
|
|
39
|
+
assert.equal(keys.indexOf('project-detail'), keys.indexOf('name-address') + 1,
|
|
40
|
+
'it follows the name interview — the project is described before its repo and address are decided');
|
|
41
|
+
assert.equal(step.optional, true, 'the flag is what tells a form it may be walked past');
|
|
42
|
+
assert.equal(step.kind, 'auto',
|
|
43
|
+
"'auto' like name-address: the FORM collects it, so a done panel of outstanding human steps must not list it");
|
|
44
|
+
// one optional step keeps the flag meaningful; a second would need its own rule
|
|
45
|
+
assert.deepEqual(plan.filter((s) => s.optional).map((s) => s.key), ['project-detail']);
|
|
46
|
+
// and the adopt branch carries it identically — the sequence branches only at scaffold
|
|
47
|
+
assert.ok(ob.onboardPlan('adopt').some((s) => s.key === 'project-detail' && s.optional === true));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('every form OFFERS the optional step and names what skipping costs — skill / CLI / wizard / route (task 1002334)', () => {
|
|
51
|
+
const { readFileSync } = require('node:fs');
|
|
52
|
+
const path = require('node:path');
|
|
53
|
+
const read = (rel) => readFileSync(path.join(import.meta.dirname, '..', rel), 'utf8');
|
|
54
|
+
|
|
55
|
+
// the skill — the source of truth for the sequence
|
|
56
|
+
const skill = read('.claude/skills/new-project/SKILL.md');
|
|
57
|
+
assert.match(skill, /OPTIONAL/, 'the canonical runbook marks the step optional');
|
|
58
|
+
assert.match(skill, /team_shape/, 'and names the answers that ride the request');
|
|
59
|
+
assert.match(skill, /dark matter/i, 'and states what skipping costs');
|
|
60
|
+
|
|
61
|
+
// the CLI form
|
|
62
|
+
const cli = read('scripts/gds/onboard.js');
|
|
63
|
+
assert.match(cli, /Press Enter to skip/, 'the terminal form offers the skip in the prompt itself');
|
|
64
|
+
assert.match(cli, /off the public map/, 'and says what a missing description means');
|
|
65
|
+
|
|
66
|
+
// the web form
|
|
67
|
+
const hub = read('public-landing/projects.html');
|
|
68
|
+
assert.match(hub, /id="panel-detail"/, 'the wizard ships the panel');
|
|
69
|
+
assert.match(hub, /id="skipDetail"/, 'with a skip control of its own');
|
|
70
|
+
|
|
71
|
+
// the route that receives it
|
|
72
|
+
const route = read('modules/provisioning/routes/provisioning.js');
|
|
73
|
+
assert.match(route, /description: \{ type: 'string', maxLength: provisioning\.DESCRIPTION_MAX \}/,
|
|
74
|
+
'the create route accepts the description');
|
|
75
|
+
assert.match(route, /\/provisioning\/instances\/:id\/detail/,
|
|
76
|
+
'and there is a way back, or "optional" would mean permanently unpublishable');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('the answers the CLI collects are the same body the web form files (buildProjectDetail)', () => {
|
|
80
|
+
assert.deepEqual(ob.buildProjectDetail({ description: ' a b ', teamShape: 'Community' }),
|
|
81
|
+
{ description: 'a b', detail: { team_shape: 'community' } });
|
|
82
|
+
// a skip files nothing — absent means skipped, and an empty string would read
|
|
83
|
+
// to the publish gate as an answered-but-blank description
|
|
84
|
+
assert.deepEqual(ob.buildProjectDetail({}), {});
|
|
85
|
+
assert.deepEqual(ob.buildProjectDetail({ description: ' ', teamShape: '' }), {});
|
|
86
|
+
// an off-vocabulary answer is dropped, never guessed into a recommendation
|
|
87
|
+
assert.deepEqual(ob.buildProjectDetail({ teamShape: 'a big guild' }), {});
|
|
88
|
+
});
|
|
89
|
+
|
|
34
90
|
test('onboardPlan(adopt): same sequence + human steps, but the scaffold-leg labels branch (task 2046)', () => {
|
|
35
91
|
const green = ob.onboardPlan();
|
|
36
92
|
const adopt = ob.onboardPlan('adopt');
|