@fuzdev/fuz_app 0.107.0 → 0.108.0

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 (58) hide show
  1. package/dist/auth/account_route_schema.d.ts +10 -1
  2. package/dist/auth/account_route_schema.d.ts.map +1 -1
  3. package/dist/auth/account_route_schema.js +10 -1
  4. package/dist/auth/account_routes.d.ts +18 -4
  5. package/dist/auth/account_routes.d.ts.map +1 -1
  6. package/dist/auth/account_routes.js +32 -29
  7. package/dist/auth/api_token_queries.d.ts +4 -0
  8. package/dist/auth/api_token_queries.d.ts.map +1 -1
  9. package/dist/auth/api_token_queries.js +5 -1
  10. package/dist/auth/bearer_auth.d.ts +9 -6
  11. package/dist/auth/bearer_auth.d.ts.map +1 -1
  12. package/dist/auth/bearer_auth.js +12 -21
  13. package/dist/auth/bootstrap_routes.d.ts +12 -2
  14. package/dist/auth/bootstrap_routes.d.ts.map +1 -1
  15. package/dist/auth/bootstrap_routes.js +15 -9
  16. package/dist/auth/middleware.d.ts +0 -3
  17. package/dist/auth/middleware.d.ts.map +1 -1
  18. package/dist/auth/middleware.js +11 -10
  19. package/dist/auth/migrations.d.ts.map +1 -1
  20. package/dist/auth/migrations.js +24 -0
  21. package/dist/auth/session_queries.d.ts +13 -2
  22. package/dist/auth/session_queries.d.ts.map +1 -1
  23. package/dist/auth/session_queries.js +14 -3
  24. package/dist/auth/signup_routes.d.ts +10 -0
  25. package/dist/auth/signup_routes.d.ts.map +1 -1
  26. package/dist/auth/signup_routes.js +14 -11
  27. package/dist/http/error_schemas.d.ts +1 -1
  28. package/dist/http/error_schemas.js +1 -1
  29. package/dist/rate_limiter.d.ts +36 -3
  30. package/dist/rate_limiter.d.ts.map +1 -1
  31. package/dist/rate_limiter.js +36 -3
  32. package/dist/server/app_server.d.ts +27 -10
  33. package/dist/server/app_server.d.ts.map +1 -1
  34. package/dist/server/app_server.js +30 -20
  35. package/dist/server/app_server_context.d.ts +10 -2
  36. package/dist/server/app_server_context.d.ts.map +1 -1
  37. package/dist/testing/CLAUDE.md +21 -8
  38. package/dist/testing/app_server.d.ts.map +1 -1
  39. package/dist/testing/app_server.js +6 -4
  40. package/dist/testing/cross_backend/default_spine_surface.d.ts.map +1 -1
  41. package/dist/testing/cross_backend/default_spine_surface.js +9 -8
  42. package/dist/testing/cross_backend/login_security.d.ts.map +1 -1
  43. package/dist/testing/cross_backend/login_security.js +59 -0
  44. package/dist/testing/cross_backend/session_cap.d.ts +19 -0
  45. package/dist/testing/cross_backend/session_cap.d.ts.map +1 -0
  46. package/dist/testing/cross_backend/session_cap.js +93 -0
  47. package/dist/testing/middleware.d.ts +2 -5
  48. package/dist/testing/middleware.d.ts.map +1 -1
  49. package/dist/testing/middleware.js +5 -5
  50. package/dist/testing/rate_limiting.d.ts +10 -16
  51. package/dist/testing/rate_limiting.d.ts.map +1 -1
  52. package/dist/testing/rate_limiting.js +11 -95
  53. package/dist/testing/stubs.d.ts.map +1 -1
  54. package/dist/testing/stubs.js +3 -1
  55. package/dist/testing/testing_rate_limiter.d.ts +7 -1
  56. package/dist/testing/testing_rate_limiter.d.ts.map +1 -1
  57. package/dist/testing/testing_rate_limiter.js +7 -1
  58. package/package.json +1 -1
@@ -72,20 +72,21 @@ export const create_spine_route_specs = (ctx) => [
72
72
  ...create_account_route_specs(ctx.deps, {
73
73
  session_options: spine_session_options,
74
74
  // Honor the limiters the server was assembled with (on `AppServerContext`
75
- // per the `ip_rate_limiter` / `login_account_rate_limiter` TSDoc) rather
76
- // than hardcoding `null`. The standard spine binary passes `null` (the
77
- // cross suites fire many loopback logins and must not trip a limiter), so
78
- // this is behavior-preserving there; the dedicated login-security cross
79
- // backend passes real limiters to exercise the 429 + `Retry-After` path
80
- // and XFF-keyed bucketing over the wire (see `login_security.ts`).
81
- ip_rate_limiter: ctx.ip_rate_limiter,
75
+ // per the `login_ip_rate_limiter` / `login_account_rate_limiter` TSDoc)
76
+ // rather than hardcoding `null`. The standard spine binary passes `null`
77
+ // (the cross suites fire many loopback logins and must not trip a
78
+ // limiter), so this is behavior-preserving there; the dedicated
79
+ // login-security cross backend passes real limiters to exercise the 429 +
80
+ // `Retry-After` path and XFF-keyed bucketing over the wire (see
81
+ // `login_security.ts`).
82
+ login_ip_rate_limiter: ctx.login_ip_rate_limiter,
82
83
  login_account_rate_limiter: ctx.login_account_rate_limiter,
83
84
  login_fail_floor_ms: 0,
84
85
  bootstrap_status: ctx.bootstrap_status
85
86
  }),
86
87
  ...create_signup_route_specs(ctx.deps, {
87
88
  session_options: spine_session_options,
88
- ip_rate_limiter: null,
89
+ signup_ip_rate_limiter: null,
89
90
  signup_account_rate_limiter: null,
90
91
  // Disable the denial floor so the identity-parity suite's accepted
91
92
  // cases (valid username/email, no invite → 403 no-matching-invite)
@@ -1 +1 @@
1
- {"version":3,"file":"login_security.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/login_security.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAuD9B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE5C,mDAAmD;AACnD,MAAM,WAAW,6BAA6B;IAC7C,2EAA2E;IAC3E,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,kFAAkF;IAClF,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC7B;AAyBD,eAAO,MAAM,mCAAmC,GAC/C,SAAS,6BAA6B,KACpC,IAqGF,CAAC"}
1
+ {"version":3,"file":"login_security.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/login_security.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA8D9B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE5C,mDAAmD;AACnD,MAAM,WAAW,6BAA6B;IAC7C,2EAA2E;IAC3E,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,kFAAkF;IAClF,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC7B;AA0BD,eAAO,MAAM,mCAAmC,GAC/C,SAAS,6BAA6B,KACpC,IA2KF,CAAC"}
@@ -24,6 +24,13 @@ import '../assert_dev_env.js';
24
24
  * not `429`). A backend that ignored XFF and keyed on the (loopback) TCP
25
25
  * peer would 429 the fresh-IP request too — so the `401` proves the limiter
26
26
  * keys on the resolved `X-Forwarded-For` client IP.
27
+ * - **a success does not refund the per-IP budget** — the distributed-spray
28
+ * backstop. Failed guesses against *distinct* usernames from one forwarded
29
+ * IP, interleaved with the attacker's own successful logins, must still 429
30
+ * within a bounded total-request budget. Both spines used to clear the IP
31
+ * bucket on success, which made that budget unbounded: one credential (or,
32
+ * under open signup, none) bought unlimited guesses against arbitrary victims
33
+ * from a single address. Only the account-grain bucket is forgiven now.
27
34
  *
28
35
  * **Determinism without a limiter reset.** Limiter state is in-memory and the
29
36
  * per-test `_testing_reset` wipes only the DB, never the buckets — so each
@@ -58,6 +65,7 @@ import { default_login_account_rate_limit, default_login_ip_rate_limit } from '.
58
65
  const XFF_IP_LIMIT = '203.0.113.1';
59
66
  const XFF_SEGREGATION_EXHAUST = '203.0.113.2';
60
67
  const XFF_SEGREGATION_FRESH = '203.0.113.3';
68
+ const XFF_SPRAY = '203.0.113.4';
61
69
  /** A non-routable, non-existent identifier — login resolves to the not-found path (records the bucket, returns 401). */
62
70
  const PROBE_PASSWORD = 'login_security_wrong_password';
63
71
  export const describe_login_security_cross_tests = (options) => {
@@ -93,6 +101,20 @@ export const describe_login_security_cross_tests = (options) => {
93
101
  retry_after_header: res.headers.get('retry-after')
94
102
  };
95
103
  };
104
+ /**
105
+ * POST a login expected to *succeed*, on a fresh transport with the given
106
+ * `X-Forwarded-For`. The mirror of `attempt` — used to prove that a success
107
+ * does not refund the resolved IP's bucket.
108
+ */
109
+ const succeed = async (fixture, forwarded_for, username, password) => {
110
+ const res = await fixture.fresh_transport()(login_path, {
111
+ method: 'POST',
112
+ headers: { 'content-type': 'application/json', 'x-forwarded-for': forwarded_for },
113
+ body: JSON.stringify({ username, password })
114
+ });
115
+ await res.text();
116
+ return res.status;
117
+ };
96
118
  describe('login rate limiting + trusted-proxy parity', () => {
97
119
  test(`per-IP login limit fires 429 + Retry-After after ${ip_limit} attempts from one forwarded IP`, async () => {
98
120
  const fixture = await setup_test();
@@ -126,5 +148,42 @@ export const describe_login_security_cross_tests = (options) => {
126
148
  const fresh = await attempt(fixture, XFF_SEGREGATION_FRESH, 'probe_xff');
127
149
  assert.strictEqual(fresh.status, 401, "a fresh forwarded IP must not inherit another IP's limit");
128
150
  });
151
+ test('a success does not refund the per-IP budget (distributed-spray backstop)', async () => {
152
+ const fixture = await setup_test();
153
+ // The attacker's own account. Minting rides the setup path from
154
+ // loopback with no XFF, so it lands on a different bucket than
155
+ // XFF_SPRAY and can't confound the count.
156
+ const attacker = await fixture.create_account({
157
+ username: 'spray_attacker',
158
+ password_value: 'spray_attacker_password'
159
+ });
160
+ let budget = 0;
161
+ let limited = false;
162
+ for (let i = 0; i < ip_limit * 3; i++) {
163
+ // Their own successful login — the move that used to zero the
164
+ // whole IP window — then one guess against a fresh victim
165
+ // username, so no per-account bucket accumulates and only the
166
+ // IP aggregate can stop this.
167
+ const own = await succeed(fixture, XFF_SPRAY, attacker.account.username, 'spray_attacker_password');
168
+ budget++;
169
+ // Either leg may trip: the aggregate governs the *address*, so
170
+ // once it fills even the attacker's own login is refused. That
171
+ // is the property — no move from this IP buys more attempts.
172
+ if (own === 429) {
173
+ limited = true;
174
+ break;
175
+ }
176
+ assert.strictEqual(own, 200, "the attacker's own login must succeed");
177
+ const guess = await attempt(fixture, XFF_SPRAY, `spray_victim_${i}`);
178
+ budget++;
179
+ if (guess.status === 429) {
180
+ limited = true;
181
+ break;
182
+ }
183
+ assert.strictEqual(guess.status, 401, 'a guess against an unknown user must 401');
184
+ }
185
+ assert(limited, 'the per-IP aggregate must eventually 429 the spray');
186
+ assert(budget <= ip_limit * 2 + 2, `interleaved successes must not extend the per-IP budget (spent ${budget})`);
187
+ });
129
188
  });
130
189
  };
@@ -0,0 +1,19 @@
1
+ import '../assert_dev_env.js';
2
+ import type { SetupTest } from './setup.js';
3
+ /** Options for the session-cap parity suite. */
4
+ export interface SessionCapCrossTestOptions {
5
+ /** Per-test fixture producer (cross-process only — see the module doc). */
6
+ readonly setup_test: SetupTest;
7
+ /**
8
+ * The cap both spines enforce. Defaults to `DEFAULT_MAX_SESSIONS` — the TS
9
+ * route default and the Rust `const`. A consumer that overrides
10
+ * `max_sessions` on the TS side passes its value here.
11
+ */
12
+ readonly max_sessions?: number;
13
+ /** REST login route path. Default `/api/account/login`. */
14
+ readonly login_path?: string;
15
+ /** REST account-status route path. Default `/api/account/status`. */
16
+ readonly status_path?: string;
17
+ }
18
+ export declare const describe_session_cap_cross_tests: (options: SessionCapCrossTestOptions) => void;
19
+ //# sourceMappingURL=session_cap.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session_cap.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/session_cap.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAwD9B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE5C,gDAAgD;AAChD,MAAM,WAAW,0BAA0B;IAC1C,2EAA2E;IAC3E,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,2DAA2D;IAC3D,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,qEAAqE;IACrE,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED,eAAO,MAAM,gCAAgC,GAAI,SAAS,0BAA0B,KAAG,IA6DtF,CAAC"}
@@ -0,0 +1,93 @@
1
+ import '../assert_dev_env.js';
2
+ /**
3
+ * Cross-backend parity suite for the **per-account concurrent-session cap**
4
+ * over real HTTP.
5
+ *
6
+ * The cap is a shipped control on the TS spine (`query_session_enforce_limit`,
7
+ * `DEFAULT_MAX_SESSIONS`) that the Rust spine went without — its session INSERT
8
+ * carried no eviction, so every Rust-backed deployment served unlimited
9
+ * concurrent sessions per account. That divergence survived because nothing crossed the
10
+ * wire on it — the TS half is pinned by in-process tests
11
+ * (`session_middleware.lifecycle.db.test.ts`, `session_token_limits.integration.db.test.ts`)
12
+ * whose Rust counterparts simply didn't exist to fail. This suite is the pin
13
+ * that runs on *both*, so the next spine to lose the cap fails here rather than
14
+ * in production. Two properties:
15
+ *
16
+ * - **the cap bounds concurrent sessions, by eviction not refusal** — logging in
17
+ * `max_sessions + 1` times succeeds every time (a login is never denied for
18
+ * being the one over the line), and the newest cookie resolves afterward.
19
+ * - **the evicted session is the oldest** — the first login's cookie no longer
20
+ * authenticates once the cap is exceeded. A spine with no cap authenticates it
21
+ * fine, which is exactly the defect this catches.
22
+ *
23
+ * **Why `max_sessions + 1` logins and not `max_sessions`.** The starting session
24
+ * count isn't zero and isn't guaranteed equal across impls — `create_account`
25
+ * seeds its own session on the Rust cradle. Overshooting by one guarantees at
26
+ * least one eviction whatever the baseline is, and the *oldest* login is the
27
+ * first to go either way. Asserting on the first and last sessions rather than
28
+ * on a row count keeps the case wire-observable (no DB channel) and
29
+ * baseline-independent.
30
+ *
31
+ * Both surfaces are flat REST (`POST /api/account/login`, `GET /api/account/status`)
32
+ * on every spine, so this is an imperative suite (not a `conformance_table` row)
33
+ * — the sibling of `cookie_attributes.ts` / `origin.ts` / `login_security.ts`.
34
+ * **Cross-process only**, and load-bearingly so: each login is held on its own
35
+ * `fresh_transport` and identified by that transport's cookie jar, which
36
+ * in-process is a jar-less passthrough. That also keeps the suite free of a
37
+ * fourth `Set-Cookie` parser — reading the raw header is `cookie_attributes.ts`'s
38
+ * job, and this case only needs "does this login still authenticate".
39
+ *
40
+ * What it does **not** pin: concurrent creators. Two simultaneous logins can
41
+ * each evict against a stale count under Read Committed and both commit above
42
+ * the cap — see `query_session_enforce_limit`'s race note. That needs a
43
+ * barrier-based test against one backend, not a parity case.
44
+ *
45
+ * `$lib`-free by contract (relative specifiers only), like the sibling
46
+ * cross-backend suites.
47
+ *
48
+ * @module
49
+ */
50
+ import { describe, test, assert } from 'vitest';
51
+ import { DEFAULT_MAX_SESSIONS } from '../../auth/account_route_schema.js';
52
+ import { DEFAULT_TEST_PASSWORD } from '../test_credentials.js';
53
+ export const describe_session_cap_cross_tests = (options) => {
54
+ const { setup_test } = options;
55
+ const max_sessions = options.max_sessions ?? DEFAULT_MAX_SESSIONS;
56
+ const login_path = options.login_path ?? '/api/account/login';
57
+ const status_path = options.status_path ?? '/api/account/status';
58
+ // Fresh-keeper-per-test wipes the DB between tests, so a literal username
59
+ // never collides (see `setup.ts`).
60
+ const username = 'session_cap_user';
61
+ describe('per-account session cap parity', () => {
62
+ test(`logging in ${max_sessions + 1} times evicts the oldest session (newest cookie resolves, first does not)`, async () => {
63
+ const fixture = await setup_test();
64
+ await fixture.create_account({ username, password_value: DEFAULT_TEST_PASSWORD });
65
+ // One transport per login, each with its own empty jar, so each ends up
66
+ // holding exactly that login's session cookie and re-sends it on any
67
+ // later request. That's why this suite is cross-process only: in-process
68
+ // `fresh_transport` is a jar-less passthrough and every session would
69
+ // look alike. Held oldest-first.
70
+ const sessions = [];
71
+ for (let i = 0; i < max_sessions + 1; i++) {
72
+ const transport = fixture.fresh_transport();
73
+ const res = await transport(login_path, {
74
+ method: 'POST',
75
+ headers: { 'content-type': 'application/json' },
76
+ body: JSON.stringify({ username, password: DEFAULT_TEST_PASSWORD })
77
+ });
78
+ assert.strictEqual(res.status, 200, `login ${i + 1}/${max_sessions + 1} must succeed — the cap evicts, it never refuses`);
79
+ assert.ok(transport.cookies().length > 0, `login ${i + 1} must set a session cookie on its jar`);
80
+ sessions.push(transport);
81
+ }
82
+ /** `GET /status` on one login's transport: 200 authenticated, 401 not. */
83
+ const status_on = async (transport) => {
84
+ const res = await transport(status_path, { method: 'GET' });
85
+ await res.text();
86
+ return res.status;
87
+ };
88
+ assert.strictEqual(await status_on(sessions[sessions.length - 1]), 200, 'the newest session must still resolve — the cap must evict, not refuse');
89
+ assert.strictEqual(await status_on(sessions[0]), 401, `the oldest session must be evicted once the account exceeds ${max_sessions} sessions ` +
90
+ '(a spine with no cap authenticates this cookie)');
91
+ });
92
+ });
93
+ };
@@ -11,7 +11,6 @@ import './assert_dev_env.js';
11
11
  import { vi } from 'vitest';
12
12
  import { Hono } from 'hono';
13
13
  import type { z } from 'zod';
14
- import type { RateLimiter } from '../rate_limiter.js';
15
14
  import { type RequestContext } from '../auth/request_context.js';
16
15
  /** Mock configuration for bearer auth middleware test setup. */
17
16
  export interface BearerAuthTestOptions {
@@ -80,7 +79,7 @@ export declare const TEST_CLIENT_IP = "127.0.0.1";
80
79
  * The route handler at `/api/test` returns the resolved context in the response body,
81
80
  * enabling assertions on `REQUEST_CONTEXT_KEY` and `CREDENTIAL_TYPE_KEY`.
82
81
  */
83
- export declare const create_bearer_auth_test_app: (tc: BearerAuthTestOptions, ip_rate_limiter?: RateLimiter | null) => {
82
+ export declare const create_bearer_auth_test_app: (tc: BearerAuthTestOptions) => {
84
83
  app: Hono;
85
84
  mocks: BearerAuthMocks;
86
85
  };
@@ -89,7 +88,7 @@ export declare const create_bearer_auth_test_app: (tc: BearerAuthTestOptions, ip
89
88
  *
90
89
  * Generates one `test()` per case inside a `describe()` block.
91
90
  */
92
- export declare const describe_bearer_auth_cases: (suite_name: string, cases: Array<BearerAuthTestCase>, ip_rate_limiter?: RateLimiter | null) => void;
91
+ export declare const describe_bearer_auth_cases: (suite_name: string, cases: Array<BearerAuthTestCase>) => void;
93
92
  /** Path used by the echo route in `create_test_middleware_stack_app`. */
94
93
  export declare const TEST_MIDDLEWARE_PATH = "/api/test";
95
94
  /** Options for `create_test_middleware_stack_app`. */
@@ -100,8 +99,6 @@ export interface TestMiddlewareStackOptions {
100
99
  allowed_origins?: string;
101
100
  /** Connection IP or factory. @default first trusted proxy */
102
101
  connection_ip?: string | (() => string | undefined);
103
- /** Rate limiter for bearer auth. @default `null` */
104
- ip_rate_limiter?: RateLimiter | null;
105
102
  }
106
103
  /** Return type of `create_test_middleware_stack_app`. */
107
104
  export interface TestMiddlewareStackApp {
@@ -1 +1 @@
1
- {"version":3,"file":"middleware.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/middleware.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"middleware.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/middleware.ts"],"names":[],"mappings":"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"}
@@ -91,9 +91,9 @@ export const TEST_CLIENT_IP = '127.0.0.1';
91
91
  * The route handler at `/api/test` returns the resolved context in the response body,
92
92
  * enabling assertions on `REQUEST_CONTEXT_KEY` and `CREDENTIAL_TYPE_KEY`.
93
93
  */
94
- export const create_bearer_auth_test_app = (tc, ip_rate_limiter = null) => {
94
+ export const create_bearer_auth_test_app = (tc) => {
95
95
  const mocks = create_bearer_auth_mocks(tc);
96
- const bearer_middleware = create_bearer_auth_middleware(STUB_DEPS, ip_rate_limiter, new Logger('test', { level: 'off' }));
96
+ const bearer_middleware = create_bearer_auth_middleware(STUB_DEPS, new Logger('test', { level: 'off' }));
97
97
  const app = new Hono();
98
98
  // inject pre-existing session identity if the test case specifies one.
99
99
  // `pre_context` simulates the session middleware having authenticated
@@ -144,11 +144,11 @@ export const create_bearer_auth_test_app = (tc, ip_rate_limiter = null) => {
144
144
  *
145
145
  * Generates one `test()` per case inside a `describe()` block.
146
146
  */
147
- export const describe_bearer_auth_cases = (suite_name, cases, ip_rate_limiter = null) => {
147
+ export const describe_bearer_auth_cases = (suite_name, cases) => {
148
148
  describe(suite_name, () => {
149
149
  for (const tc of cases) {
150
150
  test(tc.name, async () => {
151
- const { app, mocks } = create_bearer_auth_test_app(tc, ip_rate_limiter);
151
+ const { app, mocks } = create_bearer_auth_test_app(tc);
152
152
  const res = await app.request('/api/test', {
153
153
  method: 'GET',
154
154
  headers: tc.headers
@@ -227,7 +227,7 @@ export const create_test_middleware_stack_app = (options) => {
227
227
  });
228
228
  const allowed_patterns = parse_allowed_origins(allowed_origins_str);
229
229
  const origin_mw = verify_request_source(allowed_patterns);
230
- const bearer_mw = create_bearer_auth_middleware(STUB_DEPS, options?.ip_rate_limiter ?? null, new Logger('test', { level: 'off' }));
230
+ const bearer_mw = create_bearer_auth_middleware(STUB_DEPS, new Logger('test', { level: 'off' }));
231
231
  const app = new Hono();
232
232
  app.use('*', proxy_mw);
233
233
  app.use('/api/*', origin_mw);
@@ -4,7 +4,7 @@ import type { AppServerContext } from '../server/app_server_context.js';
4
4
  import type { RouteSpec } from '../http/route_spec.js';
5
5
  import { type SuiteAppOptions } from './app_server.js';
6
6
  import { type DbFactory } from './db.js';
7
- import { type RpcEndpointsSuiteOption } from './rpc_helpers.js';
7
+ import type { RpcEndpointsSuiteOption } from './rpc_helpers.js';
8
8
  /**
9
9
  * Configuration for `describe_rate_limiting_tests`.
10
10
  */
@@ -25,37 +25,31 @@ export interface RateLimitingTestOptions {
25
25
  */
26
26
  max_attempts?: number;
27
27
  /**
28
- * RPC endpoint specs — required so the bearer-auth rate limiting test
29
- * can probe an authenticated method via the `account_verify` RPC
30
- * action. Hard-fails via `require_rpc_endpoint_path` on setup.
28
+ * RPC endpoint specs mounted on the test app. Optional — both suites below
29
+ * exercise REST auth routes, so nothing here needs an RPC path.
31
30
  *
32
31
  * Accepts either an array (eager) or a factory
33
32
  * `(ctx: AppServerContext) => Array<RpcEndpointSpec>` — the factory form
34
33
  * is required when action handlers must close over the per-test
35
- * `ctx.deps`. The factory must return the same
36
- * endpoint `path` regardless of ctx — it is invoked once at setup with
37
- * a stub ctx for path lookup and again per-test by `create_app_server`
38
- * for live dispatch.
34
+ * `ctx.deps`.
39
35
  */
40
- rpc_endpoints: RpcEndpointsSuiteOption;
36
+ rpc_endpoints?: RpcEndpointsSuiteOption;
41
37
  }
42
38
  /**
43
39
  * Standard rate limiting integration test suite.
44
40
  *
45
- * Creates 3 test groups:
41
+ * Creates 2 test groups:
46
42
  * 1. IP rate limiting on login — fires `max_attempts + 1` login requests,
47
43
  * verifies the last returns 429 with a valid `RateLimitError` body.
48
44
  * 2. Per-account rate limiting on login — fires `max_attempts + 1` login
49
45
  * requests with the same username, verifies the last returns 429.
50
- * 3. Bearer auth IP rate limiting — fires `max_attempts + 1` bearer requests
51
- * with an invalid token, verifies the last returns 429.
46
+ *
47
+ * There is deliberately no bearer-auth group: the bearer path carries no rate
48
+ * limiter on either spine, because an API token's entropy — not throttling —
49
+ * is what bounds guessing it. See `auth/bearer_auth.ts`.
52
50
  *
53
51
  * Each test group asserts that required routes exist, failing with a descriptive
54
52
  * message if the consumer's route specs are misconfigured.
55
- *
56
- * @throws Error at setup time when `options.rpc_endpoints` is empty — the
57
- * bearer-auth rate-limit test probes via the `account_verify` RPC action,
58
- * so the suite hard-fails via `require_rpc_endpoint_path`.
59
53
  */
60
54
  export declare const describe_rate_limiting_tests: (options: RateLimitingTestOptions) => void;
61
55
  //# sourceMappingURL=rate_limiting.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"rate_limiting.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/rate_limiting.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AA0B7B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAChE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,iCAAiC,CAAC;AACxE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAIvD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,iBAAiB,CAAC;AACxE,OAAO,EAIN,KAAK,SAAS,EACd,MAAM,SAAS,CAAC;AAEjB,OAAO,EAIN,KAAK,uBAAuB,EAC5B,MAAM,kBAAkB,CAAC;AAK1B;;GAEG;AACH,MAAM,WAAW,uBAAuB;IACvC,4CAA4C;IAC5C,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,wDAAwD;IACxD,kBAAkB,EAAE,CAAC,GAAG,EAAE,gBAAgB,KAAK,KAAK,CAAC,SAAS,CAAC,CAAC;IAChE,iDAAiD;IACjD,WAAW,CAAC,EAAE,eAAe,CAAC;IAC9B;;OAEG;IACH,YAAY,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC;IAChC;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;;;;;;;;OAYG;IACH,aAAa,EAAE,uBAAuB,CAAC;CACvC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,4BAA4B,GAAI,SAAS,uBAAuB,KAAG,IAgQ/E,CAAC"}
1
+ {"version":3,"file":"rate_limiting.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/rate_limiting.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AA0B7B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAChE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,iCAAiC,CAAC;AACxE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAIvD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,iBAAiB,CAAC;AACxE,OAAO,EAIN,KAAK,SAAS,EACd,MAAM,SAAS,CAAC;AAEjB,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,kBAAkB,CAAC;AAIhE;;GAEG;AACH,MAAM,WAAW,uBAAuB;IACvC,4CAA4C;IAC5C,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,wDAAwD;IACxD,kBAAkB,EAAE,CAAC,GAAG,EAAE,gBAAgB,KAAK,KAAK,CAAC,SAAS,CAAC,CAAC;IAChE,iDAAiD;IACjD,WAAW,CAAC,EAAE,eAAe,CAAC;IAC9B;;OAEG;IACH,YAAY,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC;IAChC;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;;;;OAQG;IACH,aAAa,CAAC,EAAE,uBAAuB,CAAC;CACxC;AAED;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,4BAA4B,GAAI,SAAS,uBAAuB,KAAG,IAqK/E,CAAC"}
@@ -27,35 +27,25 @@ import { auth_migration_ns } from '../auth/migrations.js';
27
27
  import { create_test_app } from './app_server.js';
28
28
  import { create_pglite_factory, create_describe_db, auth_integration_truncate_tables } from './db.js';
29
29
  import { find_auth_route, assert_rate_limit_retry_after_header } from './integration_helpers.js';
30
- import { rpc_call_non_browser, require_rpc_endpoint_path, resolve_rpc_endpoints_for_setup } from './rpc_helpers.js';
31
30
  import { run_migrations } from '../db/migrate.js';
32
- import { account_verify_action_spec } from '../auth/account_action_specs.js';
33
31
  /**
34
32
  * Standard rate limiting integration test suite.
35
33
  *
36
- * Creates 3 test groups:
34
+ * Creates 2 test groups:
37
35
  * 1. IP rate limiting on login — fires `max_attempts + 1` login requests,
38
36
  * verifies the last returns 429 with a valid `RateLimitError` body.
39
37
  * 2. Per-account rate limiting on login — fires `max_attempts + 1` login
40
38
  * requests with the same username, verifies the last returns 429.
41
- * 3. Bearer auth IP rate limiting — fires `max_attempts + 1` bearer requests
42
- * with an invalid token, verifies the last returns 429.
39
+ *
40
+ * There is deliberately no bearer-auth group: the bearer path carries no rate
41
+ * limiter on either spine, because an API token's entropy — not throttling —
42
+ * is what bounds guessing it. See `auth/bearer_auth.ts`.
43
43
  *
44
44
  * Each test group asserts that required routes exist, failing with a descriptive
45
45
  * message if the consumer's route specs are misconfigured.
46
- *
47
- * @throws Error at setup time when `options.rpc_endpoints` is empty — the
48
- * bearer-auth rate-limit test probes via the `account_verify` RPC action,
49
- * so the suite hard-fails via `require_rpc_endpoint_path`.
50
46
  */
51
47
  export const describe_rate_limiting_tests = (options) => {
52
48
  const max_attempts = options.max_attempts ?? 2;
53
- // Hard-fail early so consumers see a clear setup error instead of a
54
- // confusing test failure when `rpc_endpoints` is missing. Factory-form
55
- // callers are resolved with a stub ctx purely to extract the endpoint
56
- // path; real handlers run per-test via the top-level `rpc_endpoints` slot on `CreateTestAppOptions`.
57
- const rpc_endpoints_for_setup = resolve_rpc_endpoints_for_setup(options.rpc_endpoints, options.session_options);
58
- const rpc_path = require_rpc_endpoint_path(rpc_endpoints_for_setup);
59
49
  const init_schema = async (db) => {
60
50
  await run_migrations(db, [auth_migration_ns]);
61
51
  };
@@ -67,7 +57,7 @@ export const describe_rate_limiting_tests = (options) => {
67
57
  // --- 1. IP rate limiting on login ---
68
58
  describe('IP rate limiting on login', () => {
69
59
  test(`login is blocked after ${max_attempts} failed attempts`, async () => {
70
- const ip_rate_limiter = create_test_rate_limiter();
60
+ const login_ip_rate_limiter = create_test_rate_limiter();
71
61
  try {
72
62
  const test_app = await create_test_app({
73
63
  session_options: options.session_options,
@@ -76,9 +66,8 @@ export const describe_rate_limiting_tests = (options) => {
76
66
  rpc_endpoints: options.rpc_endpoints,
77
67
  app_options: {
78
68
  ...options.app_options,
79
- ip_rate_limiter,
80
- login_account_rate_limiter: null,
81
- bearer_ip_rate_limiter: null
69
+ login_ip_rate_limiter,
70
+ login_account_rate_limiter: null
82
71
  }
83
72
  });
84
73
  const login_route = find_auth_route(test_app.route_specs, '/login', 'POST');
@@ -113,7 +102,7 @@ export const describe_rate_limiting_tests = (options) => {
113
102
  assert_rate_limit_retry_after_header(blocked_res, body);
114
103
  }
115
104
  finally {
116
- ip_rate_limiter.dispose();
105
+ login_ip_rate_limiter.dispose();
117
106
  }
118
107
  });
119
108
  });
@@ -129,9 +118,8 @@ export const describe_rate_limiting_tests = (options) => {
129
118
  rpc_endpoints: options.rpc_endpoints,
130
119
  app_options: {
131
120
  ...options.app_options,
132
- ip_rate_limiter: null,
133
- login_account_rate_limiter,
134
- bearer_ip_rate_limiter: null
121
+ login_ip_rate_limiter: null,
122
+ login_account_rate_limiter
135
123
  }
136
124
  });
137
125
  const login_route = find_auth_route(test_app.route_specs, '/login', 'POST');
@@ -182,77 +170,5 @@ export const describe_rate_limiting_tests = (options) => {
182
170
  }
183
171
  });
184
172
  });
185
- // --- 3. Bearer auth IP rate limiting ---
186
- describe('bearer auth IP rate limiting', () => {
187
- test(`bearer auth is blocked after ${max_attempts} invalid token attempts`, async () => {
188
- const bearer_ip_rate_limiter = create_test_rate_limiter();
189
- try {
190
- const test_app = await create_test_app({
191
- session_options: options.session_options,
192
- create_route_specs: options.create_route_specs,
193
- db: get_db(),
194
- rpc_endpoints: options.rpc_endpoints,
195
- app_options: {
196
- ...options.app_options,
197
- ip_rate_limiter: null,
198
- login_account_rate_limiter: null,
199
- bearer_ip_rate_limiter
200
- }
201
- });
202
- // Probe `account_verify` via RPC with an invalid bearer token.
203
- // The REST `/api/account/verify` shim is status-only (empty body
204
- // for nginx `auth_request`), so we use the RPC surface to exercise
205
- // a typed authenticated method. The bearer_auth rate limiter
206
- // increments per attempt regardless of the route's own auth outcome.
207
- // Use `rpc_call_non_browser` so the default `origin` header is
208
- // suppressed — bearer_auth discards the token when Origin or
209
- // Referer is present (browser context), which would short-circuit
210
- // before the rate limiter records the attempt.
211
- //
212
- // Note: the rate limiter short-circuits before the RPC dispatcher,
213
- // so the 429 response is a REST-shaped `RateLimitError`, not a
214
- // JSON-RPC envelope. We use the underlying `app.request` for the
215
- // blocked probe so `rpc_call_non_browser` doesn't throw on the
216
- // non-envelope body.
217
- const bearer_probe_headers = {
218
- authorization: 'Bearer secret_fuz_token_invalid'
219
- };
220
- // Fire max_attempts invalid bearer requests (sequential — must exhaust the window)
221
- for (let i = 0; i < max_attempts; i++) {
222
- const res = await rpc_call_non_browser({
223
- app: test_app.app,
224
- path: rpc_path,
225
- method: account_verify_action_spec.method,
226
- id: 'rl-probe',
227
- headers: bearer_probe_headers
228
- });
229
- assert.notStrictEqual(res.status, 429, `Request ${i + 1}/${max_attempts} should not be rate limited`);
230
- }
231
- // The next request should be rate limited. The 429 body is REST-shape
232
- // (middleware short-circuits before the RPC dispatcher), so go
233
- // direct — `rpc_call_non_browser` would throw on the non-envelope body.
234
- const blocked_res = await test_app.app.request(rpc_path, {
235
- method: 'POST',
236
- headers: {
237
- host: 'localhost',
238
- 'content-type': 'application/json',
239
- ...bearer_probe_headers
240
- },
241
- body: JSON.stringify({
242
- jsonrpc: '2.0',
243
- method: account_verify_action_spec.method,
244
- id: 'rl-probe-blocked'
245
- })
246
- });
247
- assert.strictEqual(blocked_res.status, 429);
248
- const body = await blocked_res.json();
249
- RateLimitError.parse(body);
250
- assert_rate_limit_retry_after_header(blocked_res, body);
251
- }
252
- finally {
253
- bearer_ip_rate_limiter.dispose();
254
- }
255
- });
256
- });
257
173
  });
258
174
  };
@@ -1 +1 @@
1
- {"version":3,"file":"stubs.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/stubs.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"stubs.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/stubs.ts"],"names":[],"mappings":"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"}
@@ -199,7 +199,9 @@ export const create_stub_app_server_context = (session_options) => {
199
199
  },
200
200
  bootstrap_status: { available: false, token_path: null },
201
201
  session_options,
202
- ip_rate_limiter: null,
202
+ login_ip_rate_limiter: null,
203
+ signup_ip_rate_limiter: null,
204
+ bootstrap_ip_rate_limiter: null,
203
205
  login_account_rate_limiter: null,
204
206
  signup_account_rate_limiter: null,
205
207
  action_ip_rate_limiter: null,
@@ -20,7 +20,7 @@ import './assert_dev_env.js';
20
20
  * import {TestingRateLimiter} from '@fuzdev/fuz_app/testing/testing_rate_limiter.ts';
21
21
  *
22
22
  * const limiter = new TestingRateLimiter(default_login_ip_rate_limit);
23
- * await create_app_server({backend, ip_rate_limiter: limiter, ...});
23
+ * await create_app_server({backend, login_ip_rate_limiter: limiter, ...});
24
24
  *
25
25
  * // Inside the `_testing_reset` handler's `reset_state`:
26
26
  * limiter.reset_all();
@@ -45,6 +45,12 @@ export declare class TestingRateLimiter extends RateLimiter {
45
45
  * safe to call before any check/record activity. Designed to be invoked
46
46
  * from a `_testing_reset` handler's `reset_state` callback so the test
47
47
  * binary's rate-limit buckets don't leak across test cases.
48
+ *
49
+ * This is the **one sanctioned caller that clears IP-aggregate keys** —
50
+ * `RateLimiter.reset` forbids that for production code, because refunding
51
+ * the IP bucket on a successful auth reopens the distributed-spray path.
52
+ * Here it is test-state teardown between cases, not success-forgiveness, so
53
+ * the rule doesn't apply.
48
54
  */
49
55
  reset_all(): void;
50
56
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"testing_rate_limiter.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/testing_rate_limiter.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAE7B;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,EAAE,WAAW,EAAE,KAAK,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAEvE;;;;;GAKG;AACH,qBAAa,kBAAmB,SAAQ,WAAW;;IAGzC,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,eAAe;IAKjD,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,eAAe;IAKlD,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAKjC;;;;;OAKG;IACH,SAAS,IAAI,IAAI;IAOjB;;;;;;OAMG;IACH,IAAI,YAAY,IAAI,WAAW,CAAC,MAAM,CAAC,CAEtC;CACD"}
1
+ {"version":3,"file":"testing_rate_limiter.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/testing_rate_limiter.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAE7B;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,EAAE,WAAW,EAAE,KAAK,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAEvE;;;;;GAKG;AACH,qBAAa,kBAAmB,SAAQ,WAAW;;IAGzC,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,eAAe;IAKjD,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,eAAe;IAKlD,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAKjC;;;;;;;;;;;OAWG;IACH,SAAS,IAAI,IAAI;IAOjB;;;;;;OAMG;IACH,IAAI,YAAY,IAAI,WAAW,CAAC,MAAM,CAAC,CAEtC;CACD"}
@@ -20,7 +20,7 @@ import './assert_dev_env.js';
20
20
  * import {TestingRateLimiter} from '@fuzdev/fuz_app/testing/testing_rate_limiter.ts';
21
21
  *
22
22
  * const limiter = new TestingRateLimiter(default_login_ip_rate_limit);
23
- * await create_app_server({backend, ip_rate_limiter: limiter, ...});
23
+ * await create_app_server({backend, login_ip_rate_limiter: limiter, ...});
24
24
  *
25
25
  * // Inside the `_testing_reset` handler's `reset_state`:
26
26
  * limiter.reset_all();
@@ -54,6 +54,12 @@ export class TestingRateLimiter extends RateLimiter {
54
54
  * safe to call before any check/record activity. Designed to be invoked
55
55
  * from a `_testing_reset` handler's `reset_state` callback so the test
56
56
  * binary's rate-limit buckets don't leak across test cases.
57
+ *
58
+ * This is the **one sanctioned caller that clears IP-aggregate keys** —
59
+ * `RateLimiter.reset` forbids that for production code, because refunding
60
+ * the IP bucket on a successful auth reopens the distributed-spray path.
61
+ * Here it is test-state teardown between cases, not success-forgiveness, so
62
+ * the rule doesn't apply.
57
63
  */
58
64
  reset_all() {
59
65
  for (const key of this.#seen_keys) {