@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.
Files changed (39) hide show
  1. package/.bongos-core.json +58 -43
  2. package/.claude/skills/new-project/SKILL.md +22 -11
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +2 -0
  5. package/clients/bongos-client/index.cjs +2 -0
  6. package/clients/bongos-client/index.d.ts +5 -1
  7. package/clients/bongos-client/index.mjs +2 -0
  8. package/docs/adr/0209-one-budget-for-the-account-existence-oracle.md +79 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +103 -5
  11. package/docs/api-reference.md +4 -3
  12. package/docs/copy-inventory.md +186 -174
  13. package/docs/copy-registry.json +348 -238
  14. package/docs/file-map.md +1 -0
  15. package/docs/module-api-changelog.md +2 -0
  16. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  17. package/modules/onboarding/routes/access-requests.js +16 -3
  18. package/modules/platform-identity/platform-identity.js +5 -5
  19. package/modules/platform-identity/tests/platform-identity.mjs +20 -0
  20. package/modules/provisioning/migrations/provisioning_013_project_detail.sql +54 -0
  21. package/modules/provisioning/onboard-plan.js +13 -0
  22. package/modules/provisioning/provisioning.js +113 -5
  23. package/modules/provisioning/routes/provisioning.js +105 -0
  24. package/modules/provisioning/tests/provisioning.mjs +84 -0
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/public-landing/projects.html +161 -42
  28. package/scripts/gds/fitness.js +9 -0
  29. package/scripts/gds/onboard.js +41 -1
  30. package/scripts/gds/provision-net.js +7 -3
  31. package/src/bongos/middleware/rate-limit.js +68 -2
  32. package/src/bongos/routes/auth.js +19 -12
  33. package/src/module-api.js +13 -1
  34. package/tests/account_existence_rate_limit.mjs +375 -0
  35. package/tests/module_api.mjs +1 -0
  36. package/tests/onboard.mjs +56 -0
  37. package/tests/projects_hub.mjs +69 -0
  38. package/tests/projects_hub_pre_uat.mjs +27 -5
  39. package/tests/provision.mjs +20 -0
@@ -1,7 +1,9 @@
1
1
  // src/bongos/middleware/rate-limit.js — V3.R34 (#255).
2
2
  //
3
- // Two sliding-window in-memory limiters that protect write methods on
4
- // /api/gds/*. Read methods (GET, HEAD, OPTIONS) bypass entirely.
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. It is also NOT throttled — rateLimitMiddleware
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
- // Why it is nonetheless not a new exposure: the onboarding module's
572
- // GET /access-requests/status?github_login=X is ALREADY public and returns
573
- // the strictly richer member|invited|pending|dismissed|none plus its own
574
- // `admitted`. Anything derivable here is already derivable there, so this
575
- // adds no net-new surface — and hardening only this route would accomplish
576
- // nothing while that one stays open. Whether unauthenticated login-existence
577
- // probing is acceptable at all is a real question, but it belongs to both
578
- // routes together, not to a comment here.
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.419'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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);
@@ -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');