@fuzdev/fuz_app 0.140.0 → 0.142.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.
- package/dist/actions/CLAUDE.md +72 -22
- package/dist/actions/action_rpc.d.ts +5 -1
- package/dist/actions/action_rpc.d.ts.map +1 -1
- package/dist/actions/action_rpc.js +9 -1
- package/dist/actions/connection_closer.d.ts +44 -4
- package/dist/actions/connection_closer.d.ts.map +1 -1
- package/dist/actions/connection_closer.js +33 -3
- package/dist/actions/perform_action.d.ts +10 -0
- package/dist/actions/perform_action.d.ts.map +1 -1
- package/dist/actions/perform_action.js +23 -9
- package/dist/actions/register_action_ws.d.ts +42 -6
- package/dist/actions/register_action_ws.d.ts.map +1 -1
- package/dist/actions/register_action_ws.js +87 -19
- package/dist/actions/register_ws_endpoint.d.ts +15 -6
- package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
- package/dist/actions/register_ws_endpoint.js +10 -4
- package/dist/actions/socket.svelte.d.ts +19 -4
- package/dist/actions/socket.svelte.d.ts.map +1 -1
- package/dist/actions/socket.svelte.js +31 -4
- package/dist/actions/transports.d.ts +30 -1
- package/dist/actions/transports.d.ts.map +1 -1
- package/dist/actions/transports.js +30 -1
- package/dist/actions/transports_ws_auth_guard.d.ts +13 -6
- package/dist/actions/transports_ws_auth_guard.d.ts.map +1 -1
- package/dist/actions/transports_ws_auth_guard.js +24 -10
- package/dist/actions/transports_ws_backend.d.ts +51 -5
- package/dist/actions/transports_ws_backend.d.ts.map +1 -1
- package/dist/actions/transports_ws_backend.js +62 -9
- package/dist/actions/ws_endpoint_spec.d.ts +11 -10
- package/dist/actions/ws_endpoint_spec.d.ts.map +1 -1
- package/dist/auth/CLAUDE.md +44 -22
- package/dist/auth/account_actions.d.ts.map +1 -1
- package/dist/auth/account_actions.js +12 -1
- package/dist/auth/account_route_schema.d.ts +1 -0
- package/dist/auth/account_route_schema.d.ts.map +1 -1
- package/dist/auth/account_route_schema.js +12 -0
- package/dist/auth/account_routes.d.ts +7 -2
- package/dist/auth/account_routes.d.ts.map +1 -1
- package/dist/auth/account_routes.js +240 -187
- package/dist/auth/admin_action_specs.d.ts +6 -1
- package/dist/auth/admin_action_specs.d.ts.map +1 -1
- package/dist/auth/admin_action_specs.js +6 -1
- package/dist/auth/admin_actions.d.ts.map +1 -1
- package/dist/auth/admin_actions.js +14 -2
- package/dist/auth/api_token.d.ts +20 -0
- package/dist/auth/api_token.d.ts.map +1 -1
- package/dist/auth/api_token.js +21 -1
- package/dist/auth/api_token_queries.d.ts +22 -4
- package/dist/auth/api_token_queries.d.ts.map +1 -1
- package/dist/auth/api_token_queries.js +11 -6
- package/dist/auth/audit_log_routes.d.ts +10 -0
- package/dist/auth/audit_log_routes.d.ts.map +1 -1
- package/dist/auth/audit_log_routes.js +20 -0
- package/dist/auth/audit_log_schema.d.ts +30 -5
- package/dist/auth/audit_log_schema.d.ts.map +1 -1
- package/dist/auth/audit_log_schema.js +30 -0
- package/dist/auth/bearer_auth.d.ts +4 -2
- package/dist/auth/bearer_auth.d.ts.map +1 -1
- package/dist/auth/bearer_auth.js +19 -8
- package/dist/auth/bootstrap_route_schema.d.ts +1 -0
- package/dist/auth/bootstrap_route_schema.d.ts.map +1 -1
- package/dist/auth/bootstrap_route_schema.js +1 -0
- package/dist/auth/bootstrap_routes.d.ts.map +1 -1
- package/dist/auth/bootstrap_routes.js +85 -76
- package/dist/auth/cell_action_specs.d.ts +7 -3
- package/dist/auth/cell_action_specs.d.ts.map +1 -1
- package/dist/auth/cell_action_specs.js +9 -5
- package/dist/auth/cell_actions.d.ts +3 -3
- package/dist/auth/cell_actions.d.ts.map +1 -1
- package/dist/auth/cell_actions.js +67 -38
- package/dist/auth/cell_authorize.d.ts +69 -8
- package/dist/auth/cell_authorize.d.ts.map +1 -1
- package/dist/auth/cell_authorize.js +89 -9
- package/dist/auth/cell_field_action_specs.d.ts +5 -4
- package/dist/auth/cell_field_action_specs.d.ts.map +1 -1
- package/dist/auth/cell_field_action_specs.js +7 -7
- package/dist/auth/cell_field_actions.d.ts +7 -5
- package/dist/auth/cell_field_actions.d.ts.map +1 -1
- package/dist/auth/cell_field_actions.js +24 -22
- package/dist/auth/cell_grant_action_specs.d.ts +6 -0
- package/dist/auth/cell_grant_action_specs.d.ts.map +1 -1
- package/dist/auth/cell_grant_action_specs.js +6 -0
- package/dist/auth/cell_grant_actions.d.ts +3 -1
- package/dist/auth/cell_grant_actions.d.ts.map +1 -1
- package/dist/auth/cell_grant_actions.js +16 -2
- package/dist/auth/cell_item_action_specs.d.ts +5 -4
- package/dist/auth/cell_item_action_specs.d.ts.map +1 -1
- package/dist/auth/cell_item_action_specs.js +7 -7
- package/dist/auth/cell_item_actions.d.ts +6 -5
- package/dist/auth/cell_item_actions.d.ts.map +1 -1
- package/dist/auth/cell_item_actions.js +23 -22
- package/dist/auth/cell_relation_list_limit.d.ts +20 -0
- package/dist/auth/cell_relation_list_limit.d.ts.map +1 -0
- package/dist/auth/cell_relation_list_limit.js +27 -0
- package/dist/auth/cell_relation_visibility.d.ts +4 -3
- package/dist/auth/cell_relation_visibility.d.ts.map +1 -1
- package/dist/auth/cell_relation_visibility.js +6 -5
- package/dist/auth/cell_steward.d.ts +45 -0
- package/dist/auth/cell_steward.d.ts.map +1 -0
- package/dist/auth/cell_steward.js +53 -0
- package/dist/auth/cleanup.d.ts +9 -9
- package/dist/auth/cleanup.js +9 -9
- package/dist/auth/credential_deadline.d.ts +95 -0
- package/dist/auth/credential_deadline.d.ts.map +1 -0
- package/dist/auth/credential_deadline.js +139 -0
- package/dist/auth/daemon_token_middleware.d.ts.map +1 -1
- package/dist/auth/daemon_token_middleware.js +4 -1
- package/dist/auth/middleware.d.ts +90 -12
- package/dist/auth/middleware.d.ts.map +1 -1
- package/dist/auth/middleware.js +115 -16
- package/dist/auth/request_context.d.ts +8 -2
- package/dist/auth/request_context.d.ts.map +1 -1
- package/dist/auth/request_context.js +20 -4
- package/dist/auth/resolved_auth.d.ts +13 -2
- package/dist/auth/resolved_auth.d.ts.map +1 -1
- package/dist/auth/resolved_auth.js +5 -4
- package/dist/auth/role_grant_offer_action_specs.d.ts +5 -0
- package/dist/auth/role_grant_offer_action_specs.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_action_specs.js +6 -1
- package/dist/auth/role_grant_offer_actions.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_actions.js +23 -16
- package/dist/auth/role_grant_offer_queries.d.ts +8 -6
- package/dist/auth/role_grant_offer_queries.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_queries.js +16 -8
- package/dist/auth/session_cookie.d.ts +4 -0
- package/dist/auth/session_cookie.d.ts.map +1 -1
- package/dist/auth/session_cookie.js +3 -2
- package/dist/auth/session_middleware.d.ts +1 -1
- package/dist/auth/session_middleware.d.ts.map +1 -1
- package/dist/auth/session_middleware.js +4 -1
- package/dist/auth/session_queries.d.ts +19 -2
- package/dist/auth/session_queries.d.ts.map +1 -1
- package/dist/auth/session_queries.js +8 -2
- package/dist/auth/signup_route_schema.d.ts.map +1 -1
- package/dist/auth/signup_route_schema.js +1 -0
- package/dist/auth/signup_routes.d.ts +4 -3
- package/dist/auth/signup_routes.d.ts.map +1 -1
- package/dist/auth/signup_routes.js +157 -134
- package/dist/auth/token_scope.d.ts +7 -1
- package/dist/auth/token_scope.d.ts.map +1 -1
- package/dist/auth/token_scope.js +8 -2
- package/dist/db/CLAUDE.md +32 -19
- package/dist/db/cell_ddl.d.ts +1 -2
- package/dist/db/cell_ddl.d.ts.map +1 -1
- package/dist/db/cell_ddl.js +1 -2
- package/dist/db/cell_field_queries.d.ts +20 -18
- package/dist/db/cell_field_queries.d.ts.map +1 -1
- package/dist/db/cell_field_queries.js +28 -22
- package/dist/db/cell_item_queries.d.ts +20 -14
- package/dist/db/cell_item_queries.d.ts.map +1 -1
- package/dist/db/cell_item_queries.js +28 -18
- package/dist/db/cell_moderation.d.ts +67 -0
- package/dist/db/cell_moderation.d.ts.map +1 -0
- package/dist/db/cell_moderation.js +66 -0
- package/dist/db/cell_queries.d.ts +44 -14
- package/dist/db/cell_queries.d.ts.map +1 -1
- package/dist/db/cell_queries.js +68 -45
- package/dist/db/cell_view_sql.d.ts +102 -0
- package/dist/db/cell_view_sql.d.ts.map +1 -0
- package/dist/db/cell_view_sql.js +112 -0
- package/dist/db/db_pg.d.ts +1 -1
- package/dist/db/db_pg.js +1 -1
- package/dist/hono_context.d.ts +23 -0
- package/dist/hono_context.d.ts.map +1 -1
- package/dist/hono_context.js +19 -0
- package/dist/http/CLAUDE.md +41 -15
- package/dist/http/auth_shape.d.ts +5 -2
- package/dist/http/auth_shape.d.ts.map +1 -1
- package/dist/http/auth_shape.js +5 -2
- package/dist/http/ip_canonical.d.ts +30 -3
- package/dist/http/ip_canonical.d.ts.map +1 -1
- package/dist/http/ip_canonical.js +50 -3
- package/dist/http/middleware_spec.d.ts +16 -0
- package/dist/http/middleware_spec.d.ts.map +1 -1
- package/dist/http/origin.d.ts +113 -18
- package/dist/http/origin.d.ts.map +1 -1
- package/dist/http/origin.js +247 -127
- package/dist/http/route_spec.d.ts +21 -0
- package/dist/http/route_spec.d.ts.map +1 -1
- package/dist/http/schema_helpers.d.ts +22 -0
- package/dist/http/schema_helpers.d.ts.map +1 -1
- package/dist/http/schema_helpers.js +24 -0
- package/dist/http/surface.d.ts +6 -1
- package/dist/http/surface.d.ts.map +1 -1
- package/dist/http/surface.js +12 -5
- package/dist/primitive_schemas.d.ts +10 -1
- package/dist/primitive_schemas.d.ts.map +1 -1
- package/dist/primitive_schemas.js +11 -2
- package/dist/rate_limiter.d.ts +179 -12
- package/dist/rate_limiter.d.ts.map +1 -1
- package/dist/rate_limiter.js +352 -42
- package/dist/realtime/sse_auth_guard.d.ts +22 -6
- package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
- package/dist/realtime/sse_auth_guard.js +31 -12
- package/dist/realtime/subscriber_registry.d.ts +10 -1
- package/dist/realtime/subscriber_registry.d.ts.map +1 -1
- package/dist/realtime/subscriber_registry.js +10 -1
- package/dist/server/app_server.d.ts +20 -7
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/server/app_server.js +8 -3
- package/dist/server/env.d.ts +2 -1
- package/dist/server/env.d.ts.map +1 -1
- package/dist/server/env.js +7 -17
- package/dist/server/serve_fact_route.d.ts +8 -4
- package/dist/server/serve_fact_route.d.ts.map +1 -1
- package/dist/server/serve_fact_route.js +16 -6
- package/dist/testing/CLAUDE.md +164 -25
- package/dist/testing/adversarial_headers.d.ts.map +1 -1
- package/dist/testing/adversarial_headers.js +13 -6
- package/dist/testing/connection_closer_helpers.d.ts +6 -1
- package/dist/testing/connection_closer_helpers.d.ts.map +1 -1
- package/dist/testing/connection_closer_helpers.js +4 -0
- package/dist/testing/cross_backend/account_lifecycle.d.ts.map +1 -1
- package/dist/testing/cross_backend/account_lifecycle.js +37 -2
- package/dist/testing/cross_backend/body_size.d.ts.map +1 -1
- package/dist/testing/cross_backend/body_size.js +14 -65
- package/dist/testing/cross_backend/capabilities.d.ts +26 -2
- package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
- package/dist/testing/cross_backend/capabilities.js +9 -3
- package/dist/testing/cross_backend/cell_gated_create.d.ts +38 -0
- package/dist/testing/cross_backend/cell_gated_create.d.ts.map +1 -1
- package/dist/testing/cross_backend/cell_gated_create.js +401 -5
- package/dist/testing/cross_backend/cell_relations.d.ts.map +1 -1
- package/dist/testing/cross_backend/cell_relations.js +145 -2
- package/dist/testing/cross_backend/conformance_case.d.ts +11 -3
- package/dist/testing/cross_backend/conformance_case.d.ts.map +1 -1
- package/dist/testing/cross_backend/conformance_case.js +6 -0
- package/dist/testing/cross_backend/conformance_table.d.ts.map +1 -1
- package/dist/testing/cross_backend/conformance_table.js +20 -19
- package/dist/testing/cross_backend/credential_deadline.d.ts +25 -0
- package/dist/testing/cross_backend/credential_deadline.d.ts.map +1 -0
- package/dist/testing/cross_backend/credential_deadline.js +185 -0
- package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_backend_configs.js +13 -2
- package/dist/testing/cross_backend/expected_schema.json +0 -1
- package/dist/testing/cross_backend/full_spine_mount.d.ts +3 -0
- package/dist/testing/cross_backend/full_spine_mount.d.ts.map +1 -1
- package/dist/testing/cross_backend/full_spine_mount.js +7 -1
- package/dist/testing/cross_backend/login_security.d.ts +16 -0
- package/dist/testing/cross_backend/login_security.d.ts.map +1 -1
- package/dist/testing/cross_backend/login_security.js +154 -0
- package/dist/testing/cross_backend/narrowed_api_token.d.ts +23 -0
- package/dist/testing/cross_backend/narrowed_api_token.d.ts.map +1 -0
- package/dist/testing/cross_backend/narrowed_api_token.js +40 -0
- package/dist/testing/cross_backend/origin.d.ts.map +1 -1
- package/dist/testing/cross_backend/origin.js +6 -26
- package/dist/testing/cross_backend/origin_conformance.d.ts +152 -0
- package/dist/testing/cross_backend/origin_conformance.d.ts.map +1 -0
- package/dist/testing/cross_backend/origin_conformance.js +136 -0
- package/dist/testing/cross_backend/origin_conformance.json +1679 -0
- package/dist/testing/cross_backend/origin_scope.d.ts +16 -0
- package/dist/testing/cross_backend/origin_scope.d.ts.map +1 -0
- package/dist/testing/cross_backend/origin_scope.js +140 -0
- package/dist/testing/cross_backend/query_shape.d.ts.map +1 -1
- package/dist/testing/cross_backend/query_shape.js +24 -2
- package/dist/testing/cross_backend/role_grant_cross_helpers.d.ts +35 -0
- package/dist/testing/cross_backend/role_grant_cross_helpers.d.ts.map +1 -0
- package/dist/testing/cross_backend/role_grant_cross_helpers.js +64 -0
- package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts +8 -0
- package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts.map +1 -1
- package/dist/testing/cross_backend/role_grant_offer_notification_ws.js +44 -33
- package/dist/testing/cross_backend/role_grant_participation.d.ts.map +1 -1
- package/dist/testing/cross_backend/role_grant_participation.js +64 -0
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +6 -4
- package/dist/testing/cross_backend/spine_surface_constants.d.ts +20 -1
- package/dist/testing/cross_backend/spine_surface_constants.d.ts.map +1 -1
- package/dist/testing/cross_backend/spine_surface_constants.js +23 -1
- package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_backdoor.js +20 -7
- package/dist/testing/cross_backend/testing_reset_actions.d.ts +180 -5
- package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_reset_actions.js +154 -5
- package/dist/testing/cross_backend/token_scope_surface.d.ts.map +1 -1
- package/dist/testing/cross_backend/token_scope_surface.js +25 -21
- package/dist/testing/cross_backend/transport_composition.d.ts +60 -0
- package/dist/testing/cross_backend/transport_composition.d.ts.map +1 -0
- package/dist/testing/cross_backend/transport_composition.js +364 -0
- package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
- package/dist/testing/cross_backend/ts_spine_backend_config.js +21 -6
- package/dist/testing/cross_backend/ws_action_rate_limit.d.ts.map +1 -1
- package/dist/testing/cross_backend/ws_action_rate_limit.js +21 -1
- package/dist/testing/cross_backend/ws_role_gated.d.ts +38 -0
- package/dist/testing/cross_backend/ws_role_gated.d.ts.map +1 -0
- package/dist/testing/cross_backend/ws_role_gated.js +142 -0
- package/dist/testing/entities.d.ts +23 -0
- package/dist/testing/entities.d.ts.map +1 -1
- package/dist/testing/entities.js +37 -0
- package/dist/testing/integration.d.ts.map +1 -1
- package/dist/testing/integration.js +3 -1
- package/dist/testing/middleware.d.ts +10 -0
- package/dist/testing/middleware.d.ts.map +1 -1
- package/dist/testing/middleware.js +31 -7
- package/dist/testing/schema_introspect.js +1 -1
- package/dist/testing/schema_parity.d.ts +4 -3
- package/dist/testing/schema_parity.d.ts.map +1 -1
- package/dist/testing/schema_parity.js +4 -3
- package/dist/testing/schema_ready_fixture.d.ts +2 -4
- package/dist/testing/schema_ready_fixture.d.ts.map +1 -1
- package/dist/testing/schema_ready_fixture.js +2 -4
- package/dist/testing/stubs.d.ts +16 -3
- package/dist/testing/stubs.d.ts.map +1 -1
- package/dist/testing/stubs.js +20 -7
- package/dist/testing/surface_invariants.js +2 -2
- package/dist/ui/auth_state.svelte.d.ts +10 -0
- package/dist/ui/auth_state.svelte.d.ts.map +1 -1
- package/dist/ui/auth_state.svelte.js +22 -4
- package/package.json +1 -1
- package/dist/db/cell_history_ddl.d.ts +0 -38
- package/dist/db/cell_history_ddl.d.ts.map +0 -1
- package/dist/db/cell_history_ddl.js +0 -61
package/dist/rate_limiter.js
CHANGED
|
@@ -4,10 +4,28 @@
|
|
|
4
4
|
* Tracks failed attempts per key (typically IP address) using a sliding
|
|
5
5
|
* time window. No external dependencies — state resets on server restart.
|
|
6
6
|
*
|
|
7
|
+
* **Reserve, then settle.** A handler that checks the limiter, awaits work,
|
|
8
|
+
* and records afterwards lets every request of a concurrent burst pass the
|
|
9
|
+
* check before any of them records — a burst of any size gets through.
|
|
10
|
+
* `RateLimiter.try_reserve` closes that: it admits a request and counts it in
|
|
11
|
+
* flight in one synchronous step, and the attempt counts against the cap
|
|
12
|
+
* until its `RateLimitReservation` is settled — `record` keeps it as an
|
|
13
|
+
* attempt, `release` gives it back. Every handler whose outcome decides
|
|
14
|
+
* whether the attempt counts reserves, and releases in a `finally` so an
|
|
15
|
+
* early return or a throw never leaks an in-flight slot; `check` + `record`
|
|
16
|
+
* stays for callers that record in the same synchronous step they check.
|
|
17
|
+
*
|
|
18
|
+
* **Attempts count while in flight, whatever their outcome** — so a burst of
|
|
19
|
+
* *correct* concurrent attempts from one key is capped too. The password
|
|
20
|
+
* surfaces therefore reserve with `RateLimiter.reserve`, which waits up to
|
|
21
|
+
* `RESERVATION_WAIT_MAX_MS` for a slot when the cap is reached only by
|
|
22
|
+
* attempts in flight, and refuses at once when it is reached by recorded ones.
|
|
23
|
+
*
|
|
7
24
|
* @module
|
|
8
25
|
*/
|
|
9
26
|
import { LruMap } from '@fuzdev/fuz_util/lru_map.ts';
|
|
10
27
|
import { ERROR_RATE_LIMIT_EXCEEDED } from './http/error_schemas.js';
|
|
28
|
+
import { PERIODIC_INTERVAL_MS_MAX } from './periodic.js';
|
|
11
29
|
/**
|
|
12
30
|
* Default tracked-key cap: bounds worst-case memory under key-enumeration
|
|
13
31
|
* attacks (an attacker rotating source IPs cannot grow the backing map
|
|
@@ -90,10 +108,36 @@ export const announce_rate_limiters_disabled = () => {
|
|
|
90
108
|
rate_limiters_disabled_announced = true;
|
|
91
109
|
console.error(RATE_LIMITERS_DISABLED_BANNER);
|
|
92
110
|
};
|
|
111
|
+
/**
|
|
112
|
+
* `retry_after` reported while a key is at its cap only because of attempts
|
|
113
|
+
* still in flight. Those settle within a request's lifetime, so the earliest
|
|
114
|
+
* moment worth retrying is the next second, not the oldest recorded attempt's
|
|
115
|
+
* expiry. Mirrors the Rust spine's `IN_FLIGHT_RETRY_AFTER_SECONDS`.
|
|
116
|
+
*/
|
|
117
|
+
export const IN_FLIGHT_RETRY_AFTER_SECONDS = 1;
|
|
118
|
+
/**
|
|
119
|
+
* How long a password-surface reservation (`RateLimiter.reserve`) waits for a
|
|
120
|
+
* slot when the key is at its cap only because of attempts still in flight,
|
|
121
|
+
* before answering `429`. Mirrors the Rust spine's `RESERVATION_WAIT_MAX`.
|
|
122
|
+
*
|
|
123
|
+
* Counting attempts while they are in flight caps *successful* concurrent
|
|
124
|
+
* attempts from one key too — a class behind one NAT logging in together.
|
|
125
|
+
* Those attempts settle within a request's lifetime, so a short wait turns
|
|
126
|
+
* most of those refusals into a late success, while a key at its cap on
|
|
127
|
+
* *recorded* attempts is still refused at once.
|
|
128
|
+
*/
|
|
129
|
+
export const RESERVATION_WAIT_MAX_MS = 5_000;
|
|
130
|
+
/** The reservation a disabled limiter hands out: settling it does nothing. */
|
|
131
|
+
const INERT_RESERVATION = Object.freeze({
|
|
132
|
+
record: () => { },
|
|
133
|
+
release: () => { }
|
|
134
|
+
});
|
|
135
|
+
const entry_is_idle = (entry) => entry.timestamps.length === 0 && entry.in_flight === 0;
|
|
93
136
|
/**
|
|
94
137
|
* In-memory sliding window rate limiter.
|
|
95
138
|
*
|
|
96
|
-
* Stores an array of timestamps per key
|
|
139
|
+
* Stores an array of timestamps per key, plus a count of attempts reserved
|
|
140
|
+
* (`try_reserve`) and not yet settled. On `check`/`record`, timestamps
|
|
97
141
|
* outside the window are pruned. `retry_after` reports seconds until the
|
|
98
142
|
* oldest active timestamp expires.
|
|
99
143
|
*
|
|
@@ -110,10 +154,37 @@ export const announce_rate_limiters_disabled = () => {
|
|
|
110
154
|
*/
|
|
111
155
|
export class RateLimiter {
|
|
112
156
|
options;
|
|
113
|
-
/** Key →
|
|
157
|
+
/** Key → recorded attempt timestamps + attempts in flight. */
|
|
114
158
|
#attempts;
|
|
115
159
|
#cleanup_timer = null;
|
|
160
|
+
/**
|
|
161
|
+
* `reserve` calls parked on a key at its in-flight cap, per key, with the
|
|
162
|
+
* wakers that key's settles, records, and resets run so each re-decides.
|
|
163
|
+
* Held apart from the attempts map, so evicting a key's entry strands none
|
|
164
|
+
* of its waiters; a key's slot is removed when its last waiter leaves. Per
|
|
165
|
+
* key, so a settle wakes only the waiters it can admit or refuse — never
|
|
166
|
+
* every waiter on the limiter.
|
|
167
|
+
*/
|
|
168
|
+
#waiters = new Map();
|
|
169
|
+
/**
|
|
170
|
+
* @param options - the limiter's bounds
|
|
171
|
+
* @throws {RangeError} when `max_attempts`, `window_ms`, or `cleanup_interval_ms` is not a safe integer ≥ 0, `cleanup_interval_ms` exceeds `PERIODIC_INTERVAL_MS_MAX`, or `max_keys` is neither `null` nor a positive safe integer
|
|
172
|
+
*/
|
|
116
173
|
constructor(options) {
|
|
174
|
+
for (const field of ['max_attempts', 'window_ms', 'cleanup_interval_ms']) {
|
|
175
|
+
const value = options[field];
|
|
176
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
177
|
+
throw new RangeError(`RateLimiterOptions.${field} must be a safe integer ≥ 0: ${value}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (options.cleanup_interval_ms > PERIODIC_INTERVAL_MS_MAX) {
|
|
181
|
+
throw new RangeError(`RateLimiterOptions.cleanup_interval_ms must be at most ${PERIODIC_INTERVAL_MS_MAX}: ${options.cleanup_interval_ms}`);
|
|
182
|
+
}
|
|
183
|
+
if (options.max_keys !== undefined &&
|
|
184
|
+
options.max_keys !== null &&
|
|
185
|
+
!(Number.isSafeInteger(options.max_keys) && options.max_keys > 0)) {
|
|
186
|
+
throw new RangeError(`RateLimiterOptions.max_keys must be null or a positive safe integer: ${options.max_keys}`);
|
|
187
|
+
}
|
|
117
188
|
this.options = options;
|
|
118
189
|
const max_keys = options.max_keys === undefined ? DEFAULT_RATE_LIMITER_MAX_KEYS : options.max_keys;
|
|
119
190
|
this.#attempts = max_keys === null ? new Map() : new LruMap(max_keys);
|
|
@@ -129,6 +200,17 @@ export class RateLimiter {
|
|
|
129
200
|
get size() {
|
|
130
201
|
return this.#attempts.size;
|
|
131
202
|
}
|
|
203
|
+
/**
|
|
204
|
+
* Keys holding a parked-waiter slot right now (see `reserve`): each slot is
|
|
205
|
+
* removed with its key's last waiter. For tests pinning that a waiter
|
|
206
|
+
* leaves nothing behind; the twin of the Rust spine's test-only
|
|
207
|
+
* `waiter_keys`.
|
|
208
|
+
*
|
|
209
|
+
* @internal
|
|
210
|
+
*/
|
|
211
|
+
get waiter_keys() {
|
|
212
|
+
return this.#waiters.size;
|
|
213
|
+
}
|
|
132
214
|
/**
|
|
133
215
|
* Custom inspect output that exposes only `options` and `size`, never the
|
|
134
216
|
* tracked keys. The key set is sensitive (usernames / IP addresses) and
|
|
@@ -141,38 +223,59 @@ export class RateLimiter {
|
|
|
141
223
|
return { options: this.options, size: this.size };
|
|
142
224
|
}
|
|
143
225
|
/**
|
|
144
|
-
* Check whether `key` is allowed without recording an attempt
|
|
226
|
+
* Check whether `key` is allowed without recording an attempt, counting
|
|
227
|
+
* recorded attempts and reserved ones still in flight.
|
|
145
228
|
*
|
|
146
229
|
* Prunes timestamps that fell outside the window as a side effect (and
|
|
147
|
-
* removes the key entirely when none remain
|
|
148
|
-
* bounded even under read-only traffic.
|
|
230
|
+
* removes the key entirely when none remain and none are in flight), so
|
|
231
|
+
* the backing map stays bounded even under read-only traffic.
|
|
232
|
+
*
|
|
233
|
+
* A check followed by an awaited operation and then a `record` lets a
|
|
234
|
+
* concurrent burst past the cap; reserve with `try_reserve` instead.
|
|
149
235
|
*
|
|
150
236
|
* @param key - rate limit key (e.g. IP address)
|
|
151
237
|
* @param now - current timestamp in ms (defaults to `Date.now()`)
|
|
152
238
|
* @mutates internal map - prunes expired timestamps for `key`
|
|
153
239
|
*/
|
|
154
240
|
check(key, now = Date.now()) {
|
|
241
|
+
const load = this.#load(key, now);
|
|
242
|
+
switch (load.kind) {
|
|
243
|
+
case 'free':
|
|
244
|
+
return { allowed: true, remaining: this.options.max_attempts - load.used, retry_after: 0 };
|
|
245
|
+
case 'recorded':
|
|
246
|
+
return { allowed: false, remaining: 0, retry_after: load.retry_after };
|
|
247
|
+
case 'in_flight':
|
|
248
|
+
return { allowed: false, remaining: 0, retry_after: IN_FLIGHT_RETRY_AFTER_SECONDS };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/** Where `key` stands against the cap, pruning expired timestamps first. */
|
|
252
|
+
#load(key, now) {
|
|
155
253
|
const { max_attempts, window_ms } = this.options;
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
254
|
+
// A cap of zero refuses before reading the key: no entry is created or
|
|
255
|
+
// promoted, and no reservation parks. The wait it names is the whole
|
|
256
|
+
// window, at least a second.
|
|
257
|
+
if (max_attempts === 0) {
|
|
258
|
+
return { kind: 'recorded', retry_after: Math.max(1, Math.ceil(window_ms / 1000)) };
|
|
160
259
|
}
|
|
161
|
-
const
|
|
162
|
-
if (
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
260
|
+
const entry = this.#attempts.get(key);
|
|
261
|
+
if (!entry)
|
|
262
|
+
return { kind: 'free', used: 0 };
|
|
263
|
+
const cutoff = now - window_ms;
|
|
264
|
+
const active = entry.timestamps.filter((t) => t > cutoff);
|
|
265
|
+
entry.timestamps = active;
|
|
266
|
+
// A key with attempts in flight stays even with no live timestamp:
|
|
267
|
+
// deleting it would drop the count it carries.
|
|
268
|
+
if (entry_is_idle(entry)) {
|
|
269
|
+
this.#attempts.delete(key);
|
|
270
|
+
return { kind: 'free', used: 0 };
|
|
169
271
|
}
|
|
170
|
-
|
|
171
|
-
|
|
272
|
+
// At the cap on recorded attempts alone, wait for the oldest; at the
|
|
273
|
+
// cap only with the in-flight attempts counted, a settle frees a slot.
|
|
274
|
+
if (active.length >= max_attempts) {
|
|
275
|
+
return { kind: 'recorded', retry_after: Math.ceil((active[0] + window_ms - now) / 1000) };
|
|
172
276
|
}
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
return { allowed: false, remaining: 0, retry_after };
|
|
277
|
+
const used = active.length + entry.in_flight;
|
|
278
|
+
return used < max_attempts ? { kind: 'free', used } : { kind: 'in_flight' };
|
|
176
279
|
}
|
|
177
280
|
/**
|
|
178
281
|
* Record a failed attempt for `key` and return the updated result.
|
|
@@ -184,19 +287,21 @@ export class RateLimiter {
|
|
|
184
287
|
record(key, now = Date.now()) {
|
|
185
288
|
const { max_attempts, window_ms } = this.options;
|
|
186
289
|
const cutoff = now - window_ms;
|
|
187
|
-
let
|
|
188
|
-
if (
|
|
290
|
+
let entry = this.#attempts.get(key);
|
|
291
|
+
if (entry) {
|
|
189
292
|
// Prune expired entries in place.
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
this.#attempts.set(key, active);
|
|
193
|
-
timestamps = active;
|
|
293
|
+
entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
|
|
294
|
+
entry.timestamps.push(now);
|
|
194
295
|
}
|
|
195
296
|
else {
|
|
196
|
-
|
|
197
|
-
this.#attempts.set(key,
|
|
297
|
+
entry = { timestamps: [now], in_flight: 0 };
|
|
298
|
+
this.#attempts.set(key, entry);
|
|
198
299
|
}
|
|
199
|
-
|
|
300
|
+
// A parked reservation re-decides: the key may now be at its cap on
|
|
301
|
+
// recorded attempts, which refuses it at once.
|
|
302
|
+
this.#wake(key);
|
|
303
|
+
const timestamps = entry.timestamps;
|
|
304
|
+
const count = timestamps.length + entry.in_flight;
|
|
200
305
|
if (count <= max_attempts) {
|
|
201
306
|
return { allowed: true, remaining: max_attempts - count, retry_after: 0 };
|
|
202
307
|
}
|
|
@@ -204,6 +309,171 @@ export class RateLimiter {
|
|
|
204
309
|
const retry_after = Math.ceil((oldest + window_ms - now) / 1000);
|
|
205
310
|
return { allowed: false, remaining: 0, retry_after };
|
|
206
311
|
}
|
|
312
|
+
/**
|
|
313
|
+
* Admit an attempt for `key` and count it in flight, in one synchronous
|
|
314
|
+
* step.
|
|
315
|
+
*
|
|
316
|
+
* The attempt counts against the cap — for this request and every
|
|
317
|
+
* concurrent one — until the returned reservation is settled: recorded as
|
|
318
|
+
* an attempt, or released. So no burst of concurrent requests gets more
|
|
319
|
+
* than `max_attempts` past the limiter between them. Release in a
|
|
320
|
+
* `finally`: nothing settles a reservation for you, so one left unsettled
|
|
321
|
+
* holds its slot for the life of the process.
|
|
322
|
+
*
|
|
323
|
+
* @param key - rate limit key (e.g. IP address)
|
|
324
|
+
* @param now - current timestamp in ms (defaults to `Date.now()`)
|
|
325
|
+
* @returns the reservation, or `{ok: false, retry_after}` when recorded plus in-flight attempts already reach the cap (nothing is reserved then)
|
|
326
|
+
* @mutates internal map - increments `key`'s in-flight count, creating its entry when absent
|
|
327
|
+
*/
|
|
328
|
+
try_reserve(key, now = Date.now()) {
|
|
329
|
+
const checked = this.check(key, now);
|
|
330
|
+
if (!checked.allowed)
|
|
331
|
+
return { ok: false, retry_after: checked.retry_after };
|
|
332
|
+
return { ok: true, reservation: this.#admit(key) };
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* `try_reserve`, waiting up to `max_wait_ms` for a slot when the key is at
|
|
336
|
+
* its cap only because of attempts still in flight.
|
|
337
|
+
*
|
|
338
|
+
* Each settle (and record and reset) on the key wakes its waiters to
|
|
339
|
+
* re-decide — that key's only, so a settle costs nothing for waiters parked
|
|
340
|
+
* elsewhere: a freed slot is reserved; recorded attempts reaching the cap
|
|
341
|
+
* refuse at once with their real `retry_after`; reaching `max_wait_ms` (or
|
|
342
|
+
* `signal` aborting) refuses with `IN_FLIGHT_RETRY_AFTER_SECONDS`. At most
|
|
343
|
+
* `max_attempts` reservations wait on one key — one more is refused at
|
|
344
|
+
* once — so a flood can't park requests without bound. A waiter that
|
|
345
|
+
* leaves, by any path, leaves the key's waiter count as it found it. A
|
|
346
|
+
* waiter holds no place in a queue: each wake, every waiter re-decides, so
|
|
347
|
+
* one can lose every race for a freed slot until `max_wait_ms` refuses it.
|
|
348
|
+
*
|
|
349
|
+
* @param key - rate limit key (e.g. IP address)
|
|
350
|
+
* @param options - the wait bound and an abort signal
|
|
351
|
+
* @returns the reservation, or `{ok: false, retry_after}` as described (nothing is reserved then)
|
|
352
|
+
* @mutates internal map - increments `key`'s in-flight count on admission; counts the call in `key`'s waiters while it waits
|
|
353
|
+
*/
|
|
354
|
+
async reserve(key, options = {}) {
|
|
355
|
+
const { max_wait_ms = RESERVATION_WAIT_MAX_MS, signal } = options;
|
|
356
|
+
const deadline = Date.now() + max_wait_ms;
|
|
357
|
+
let parked = false;
|
|
358
|
+
try {
|
|
359
|
+
for (;;) {
|
|
360
|
+
const load = this.#load(key, Date.now());
|
|
361
|
+
if (load.kind === 'free') {
|
|
362
|
+
this.#leave(key, parked);
|
|
363
|
+
parked = false;
|
|
364
|
+
return { ok: true, reservation: this.#admit(key) };
|
|
365
|
+
}
|
|
366
|
+
if (load.kind === 'recorded')
|
|
367
|
+
return { ok: false, retry_after: load.retry_after };
|
|
368
|
+
if (!parked) {
|
|
369
|
+
let waiters = this.#waiters.get(key);
|
|
370
|
+
if (waiters && waiters.count >= this.options.max_attempts) {
|
|
371
|
+
return { ok: false, retry_after: IN_FLIGHT_RETRY_AFTER_SECONDS };
|
|
372
|
+
}
|
|
373
|
+
if (!waiters) {
|
|
374
|
+
waiters = { count: 0, wakers: new Set() };
|
|
375
|
+
this.#waiters.set(key, waiters);
|
|
376
|
+
}
|
|
377
|
+
waiters.count++;
|
|
378
|
+
parked = true;
|
|
379
|
+
}
|
|
380
|
+
const remaining_ms = deadline - Date.now();
|
|
381
|
+
if (remaining_ms <= 0 || !(await this.#wait_for_wake(key, remaining_ms, signal))) {
|
|
382
|
+
return { ok: false, retry_after: IN_FLIGHT_RETRY_AFTER_SECONDS };
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
finally {
|
|
387
|
+
this.#leave(key, parked);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
/** Leave `key`'s waiters, if parked there; the last one out removes the key's slot. */
|
|
391
|
+
#leave(key, parked) {
|
|
392
|
+
if (!parked)
|
|
393
|
+
return;
|
|
394
|
+
const waiters = this.#waiters.get(key);
|
|
395
|
+
if (!waiters)
|
|
396
|
+
return;
|
|
397
|
+
waiters.count--;
|
|
398
|
+
if (waiters.count <= 0)
|
|
399
|
+
this.#waiters.delete(key);
|
|
400
|
+
}
|
|
401
|
+
/** Resolves `true` on `key`'s next wake, `false` on timeout or abort; cleans up either way. */
|
|
402
|
+
#wait_for_wake(key, timeout_ms, signal) {
|
|
403
|
+
return new Promise((resolve) => {
|
|
404
|
+
const wakers = this.#waiters.get(key)?.wakers;
|
|
405
|
+
if (signal?.aborted || !wakers) {
|
|
406
|
+
resolve(false);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const done = (woke) => {
|
|
410
|
+
wakers.delete(waker);
|
|
411
|
+
clearTimeout(timer);
|
|
412
|
+
signal?.removeEventListener('abort', on_abort);
|
|
413
|
+
resolve(woke);
|
|
414
|
+
};
|
|
415
|
+
const waker = () => done(true);
|
|
416
|
+
const on_abort = () => done(false);
|
|
417
|
+
const timer = setTimeout(() => done(false), timeout_ms);
|
|
418
|
+
wakers.add(waker);
|
|
419
|
+
signal?.addEventListener('abort', on_abort, { once: true });
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
/** Run the wakers of the reservations parked on `key`. */
|
|
423
|
+
#wake(key) {
|
|
424
|
+
const wakers = this.#waiters.get(key)?.wakers;
|
|
425
|
+
if (!wakers)
|
|
426
|
+
return;
|
|
427
|
+
for (const waker of [...wakers])
|
|
428
|
+
waker();
|
|
429
|
+
}
|
|
430
|
+
/** Count one more attempt in flight on `key`, already found free. */
|
|
431
|
+
#admit(key) {
|
|
432
|
+
let entry = this.#attempts.get(key);
|
|
433
|
+
if (!entry) {
|
|
434
|
+
// A fresh key past `max_keys` evicts the least-recently-used entry,
|
|
435
|
+
// in-flight attempts and all; their reservations settle as no-ops
|
|
436
|
+
// (release) or into a fresh entry (record).
|
|
437
|
+
entry = { timestamps: [], in_flight: 0 };
|
|
438
|
+
this.#attempts.set(key, entry);
|
|
439
|
+
}
|
|
440
|
+
entry.in_flight++;
|
|
441
|
+
const reserved = entry;
|
|
442
|
+
let settled = false;
|
|
443
|
+
const settle = (record, settle_now) => {
|
|
444
|
+
if (settled)
|
|
445
|
+
return;
|
|
446
|
+
settled = true;
|
|
447
|
+
this.#settle(key, reserved, record, settle_now);
|
|
448
|
+
};
|
|
449
|
+
return {
|
|
450
|
+
record: (record_now = Date.now()) => settle(true, record_now),
|
|
451
|
+
release: () => settle(false, Date.now())
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
#settle(key, reserved, record, now) {
|
|
455
|
+
const current = this.#attempts.get(key);
|
|
456
|
+
if (current !== reserved) {
|
|
457
|
+
// The reserved entry is gone — evicted, or reset with nothing in
|
|
458
|
+
// flight but this — so there is no in-flight count to give back. A
|
|
459
|
+
// record still counts, against whatever the key holds now.
|
|
460
|
+
if (record)
|
|
461
|
+
this.record(key, now);
|
|
462
|
+
else
|
|
463
|
+
this.#wake(key);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
reserved.in_flight = Math.max(0, reserved.in_flight - 1);
|
|
467
|
+
if (record) {
|
|
468
|
+
const cutoff = now - this.options.window_ms;
|
|
469
|
+
reserved.timestamps = reserved.timestamps.filter((t) => t > cutoff);
|
|
470
|
+
reserved.timestamps.push(now);
|
|
471
|
+
}
|
|
472
|
+
else if (entry_is_idle(reserved)) {
|
|
473
|
+
this.#attempts.delete(key);
|
|
474
|
+
}
|
|
475
|
+
this.#wake(key);
|
|
476
|
+
}
|
|
207
477
|
/**
|
|
208
478
|
* Clear all attempts for `key`.
|
|
209
479
|
*
|
|
@@ -218,8 +488,9 @@ export class RateLimiter {
|
|
|
218
488
|
* otherwise an attacker interleaves their own logins (or, under open signup,
|
|
219
489
|
* throwaway account creations needing no credential at all) with guesses
|
|
220
490
|
* against arbitrary victims and sprays indefinitely from one address.
|
|
221
|
-
*
|
|
222
|
-
*
|
|
491
|
+
* The only success that records on an IP bucket is an open signup (charged
|
|
492
|
+
* like a failure); no success clears one, so with no reset each is a
|
|
493
|
+
* monotone-within-window counter that only time clears.
|
|
223
494
|
*
|
|
224
495
|
* Because they are monotone, the IP buckets are **one instance per auth
|
|
225
496
|
* surface** (`login_ip_rate_limiter`, `signup_ip_rate_limiter`,
|
|
@@ -229,13 +500,24 @@ export class RateLimiter {
|
|
|
229
500
|
* four routes at once — a cost that used to be masked by the reset this
|
|
230
501
|
* doc forbids.
|
|
231
502
|
*
|
|
232
|
-
*
|
|
503
|
+
* Attempts still in flight on `key` (reserved by concurrent requests) are
|
|
504
|
+
* kept: a reset forgives what was recorded, not what is still running.
|
|
505
|
+
*
|
|
506
|
+
* @mutates internal map - removes the entry for `key`, or clears its timestamps while attempts are in flight
|
|
233
507
|
*/
|
|
234
508
|
reset(key) {
|
|
235
|
-
this.#attempts.
|
|
509
|
+
const entry = this.#attempts.get(key);
|
|
510
|
+
if (entry && entry.in_flight > 0) {
|
|
511
|
+
entry.timestamps = [];
|
|
512
|
+
}
|
|
513
|
+
else {
|
|
514
|
+
this.#attempts.delete(key);
|
|
515
|
+
}
|
|
516
|
+
this.#wake(key);
|
|
236
517
|
}
|
|
237
518
|
/**
|
|
238
|
-
* Remove entries whose timestamps are all outside the window
|
|
519
|
+
* Remove entries whose timestamps are all outside the window and that
|
|
520
|
+
* have no attempt in flight.
|
|
239
521
|
*
|
|
240
522
|
* @param now - current timestamp in ms (defaults to `Date.now()`)
|
|
241
523
|
* @mutates internal map - prunes expired timestamps and deletes empty keys
|
|
@@ -246,14 +528,11 @@ export class RateLimiter {
|
|
|
246
528
|
// to the MRU end during iteration and causes re-visit in the same pass.
|
|
247
529
|
// The `Map` path is unaffected but the snapshot is cheap on both.
|
|
248
530
|
const entries = [...this.#attempts];
|
|
249
|
-
for (const [key,
|
|
250
|
-
|
|
251
|
-
if (
|
|
531
|
+
for (const [key, entry] of entries) {
|
|
532
|
+
entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
|
|
533
|
+
if (entry_is_idle(entry)) {
|
|
252
534
|
this.#attempts.delete(key);
|
|
253
535
|
}
|
|
254
|
-
else {
|
|
255
|
-
this.#attempts.set(key, active);
|
|
256
|
-
}
|
|
257
536
|
}
|
|
258
537
|
}
|
|
259
538
|
/**
|
|
@@ -268,6 +547,37 @@ export class RateLimiter {
|
|
|
268
547
|
}
|
|
269
548
|
}
|
|
270
549
|
}
|
|
550
|
+
/**
|
|
551
|
+
* `RateLimiter.try_reserve` against a limiter that may be absent.
|
|
552
|
+
*
|
|
553
|
+
* An inert reservation when `limiter` is `null` (disabled) or `key` is `null`
|
|
554
|
+
* (no resolvable key — e.g. no client IP), so a handler reserves and settles
|
|
555
|
+
* the same way whether or not the surface is limited. Twin of the Rust
|
|
556
|
+
* spine's `try_reserve_optional`.
|
|
557
|
+
*
|
|
558
|
+
* @param limiter - the limiter, or `null` when disabled
|
|
559
|
+
* @param key - rate limit key, or `null` to skip
|
|
560
|
+
* @param now - current timestamp in ms (defaults to `Date.now()`)
|
|
561
|
+
*/
|
|
562
|
+
export const try_reserve_optional = (limiter, key, now = Date.now()) => {
|
|
563
|
+
if (!limiter || key === null)
|
|
564
|
+
return { ok: true, reservation: INERT_RESERVATION };
|
|
565
|
+
return limiter.try_reserve(key, now);
|
|
566
|
+
};
|
|
567
|
+
/**
|
|
568
|
+
* `RateLimiter.reserve` against a limiter that may be absent — an inert
|
|
569
|
+
* reservation when `limiter` or `key` is `null`. Twin of the Rust spine's
|
|
570
|
+
* `reserve_optional`.
|
|
571
|
+
*
|
|
572
|
+
* @param limiter - the limiter, or `null` when disabled
|
|
573
|
+
* @param key - rate limit key, or `null` to skip
|
|
574
|
+
* @param options - the wait bound and an abort signal
|
|
575
|
+
*/
|
|
576
|
+
export const reserve_optional = async (limiter, key, options) => {
|
|
577
|
+
if (!limiter || key === null)
|
|
578
|
+
return { ok: true, reservation: INERT_RESERVATION };
|
|
579
|
+
return limiter.reserve(key, options);
|
|
580
|
+
};
|
|
271
581
|
/**
|
|
272
582
|
* Create a `RateLimiter` with sensible defaults for per-IP login protection.
|
|
273
583
|
*
|
|
@@ -35,12 +35,20 @@ export declare const AUDIT_LOG_CHANNEL = "audit_log";
|
|
|
35
35
|
* their `groups` — pending registrations included. The audit stream route
|
|
36
36
|
* registers `scope = session hash` and `groups = [account id]`; a route that
|
|
37
37
|
* admits bearer tokens registers the API token id too, and
|
|
38
|
-
* `close_sockets_for_token` then reaches it. `
|
|
39
|
-
* `
|
|
38
|
+
* `close_sockets_for_token` then reaches it. `close_sockets_for_role` closes
|
|
39
|
+
* the account's streams when `role` is the one the registry's streams are
|
|
40
|
+
* gated on — the registry keeps one gating role for all its streams, not one
|
|
41
|
+
* per stream. `close_all_sockets` is `SubscriberRegistry.close_all` — every
|
|
42
|
+
* stream, for shutdown.
|
|
40
43
|
*
|
|
41
44
|
* Twin of the Rust spine's `SocketRevoker` impl on `SseRegistry`.
|
|
45
|
+
*
|
|
46
|
+
* @param registry - the registry to close streams on
|
|
47
|
+
* @param required_role - the role every stream of `registry` was gated on,
|
|
48
|
+
* or `null` when they are not role-gated (`close_sockets_for_role` then
|
|
49
|
+
* closes nothing)
|
|
42
50
|
*/
|
|
43
|
-
export declare const create_sse_connection_closer: <T>(registry: SubscriberRegistry<T
|
|
51
|
+
export declare const create_sse_connection_closer: <T>(registry: SubscriberRegistry<T>, required_role: string | null) => ConnectionCloser;
|
|
44
52
|
/**
|
|
45
53
|
* Create an audit event handler that closes SSE streams on a successful
|
|
46
54
|
* revocation row, dispatching on the event's `RevocationScope`
|
|
@@ -56,9 +64,12 @@ export declare const create_sse_connection_closer: <T>(registry: SubscriberRegis
|
|
|
56
64
|
* registers the token id and is closed here.
|
|
57
65
|
* - `account` → every stream of `target_account_id ?? account_id`.
|
|
58
66
|
* - `role` → every stream of the target account, when the revoked role
|
|
59
|
-
* (`metadata.role`) is `required_role
|
|
60
|
-
*
|
|
61
|
-
*
|
|
67
|
+
* (`metadata.role`) is `required_role` and the revoked grant was global
|
|
68
|
+
* (`to_global_role_revocation`): the stream's role gate admits on global
|
|
69
|
+
* grants only, so revoking a scoped grant of the same role takes away
|
|
70
|
+
* nothing it admitted on. A stream is authorized once at open and has no
|
|
71
|
+
* later check; the WebSocket guard closes its role-gated sockets on the
|
|
72
|
+
* same rows.
|
|
62
73
|
* - `none` → nothing.
|
|
63
74
|
*
|
|
64
75
|
* Over-closing a one-way feed is cheap: the client reconnects if it is still
|
|
@@ -94,6 +105,11 @@ export interface AuditLogSse {
|
|
|
94
105
|
log: Logger;
|
|
95
106
|
/** Combined broadcast + guard callback. Wired by `create_app_server`'s `audit_log_sse` option, or compose inside the consumer's `audit_factory` body. */
|
|
96
107
|
on_audit_event: (event: AuditLogEvent) => void;
|
|
108
|
+
/**
|
|
109
|
+
* The role the stream requires — what a caller adapting `registry` passes
|
|
110
|
+
* `create_sse_connection_closer`, so its role close matches the guard's.
|
|
111
|
+
*/
|
|
112
|
+
required_role: string;
|
|
97
113
|
}
|
|
98
114
|
/**
|
|
99
115
|
* SSE event specs for audit log events.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sse_auth_guard.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/realtime/sse_auth_guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAGtD,OAAO,
|
|
1
|
+
{"version":3,"file":"sse_auth_guard.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/realtime/sse_auth_guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAGtD,OAAO,EAKN,KAAK,aAAa,EAClB,MAAM,6BAA6B,CAAC;AACrC,OAAO,KAAK,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,iCAAiC,CAAC;AACxF,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAC9D,OAAO,KAAK,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAE3D,2DAA2D;AAC3D,eAAO,MAAM,iBAAiB,cAAc,CAAC;AAE7C;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,4BAA4B,GAAI,CAAC,EAC7C,UAAU,kBAAkB,CAAC,CAAC,CAAC,EAC/B,eAAe,MAAM,GAAG,IAAI,KAC1B,gBAOD,CAAC;AAEH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,eAAO,MAAM,qBAAqB,GAAI,CAAC,EACtC,UAAU,kBAAkB,CAAC,CAAC,CAAC,EAC/B,eAAe,MAAM,GAAG,IAAI,EAC5B,KAAK,MAAM,KACT,CAAC,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAoEjC,CAAC;AAEF;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC3B;;;;;OAKG;IACH,QAAQ,EAAE,kBAAkB,CAAC,eAAe,CAAC,CAAC;IAC9C,kFAAkF;IAClF,GAAG,EAAE,MAAM,CAAC;IACZ,yJAAyJ;IACzJ,cAAc,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;IAC/C;;;OAGG;IACH,aAAa,EAAE,MAAM,CAAC;CACtB;AAED;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,EAAE,KAAK,CAAC,SAAS,CAOlD,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,KAAK,CAAC;AAE9C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AACH,eAAO,MAAM,oBAAoB,GAAI,SAAS;IAC7C,mEAAmE;IACnE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ;;;;;;;;;OASG;IACH,iBAAiB,EAAE,cAAc,GAAG,IAAI,CAAC;IACzC;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B,KAAG,WAiBH,CAAC"}
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* @module
|
|
21
21
|
*/
|
|
22
22
|
import { UnreachableError } from '@fuzdev/fuz_util/error.ts';
|
|
23
|
-
import { AUDIT_EVENT_TYPES, AuditLogEventJson, to_revocation_scope } from '../auth/audit_log_schema.js';
|
|
23
|
+
import { AUDIT_EVENT_TYPES, AuditLogEventJson, to_global_role_revocation, to_revocation_scope } from '../auth/audit_log_schema.js';
|
|
24
24
|
import { SubscriberRegistry } from './subscriber_registry.js';
|
|
25
25
|
/** SSE channel the audit-log stream route publishes on. */
|
|
26
26
|
export const AUDIT_LOG_CHANNEL = 'audit_log';
|
|
@@ -33,15 +33,24 @@ export const AUDIT_LOG_CHANNEL = 'audit_log';
|
|
|
33
33
|
* their `groups` — pending registrations included. The audit stream route
|
|
34
34
|
* registers `scope = session hash` and `groups = [account id]`; a route that
|
|
35
35
|
* admits bearer tokens registers the API token id too, and
|
|
36
|
-
* `close_sockets_for_token` then reaches it. `
|
|
37
|
-
* `
|
|
36
|
+
* `close_sockets_for_token` then reaches it. `close_sockets_for_role` closes
|
|
37
|
+
* the account's streams when `role` is the one the registry's streams are
|
|
38
|
+
* gated on — the registry keeps one gating role for all its streams, not one
|
|
39
|
+
* per stream. `close_all_sockets` is `SubscriberRegistry.close_all` — every
|
|
40
|
+
* stream, for shutdown.
|
|
38
41
|
*
|
|
39
42
|
* Twin of the Rust spine's `SocketRevoker` impl on `SseRegistry`.
|
|
43
|
+
*
|
|
44
|
+
* @param registry - the registry to close streams on
|
|
45
|
+
* @param required_role - the role every stream of `registry` was gated on,
|
|
46
|
+
* or `null` when they are not role-gated (`close_sockets_for_role` then
|
|
47
|
+
* closes nothing)
|
|
40
48
|
*/
|
|
41
|
-
export const create_sse_connection_closer = (registry) => ({
|
|
49
|
+
export const create_sse_connection_closer = (registry, required_role) => ({
|
|
42
50
|
close_sockets_for_session: (session_token_hash) => registry.close_by_identity(session_token_hash),
|
|
43
51
|
close_sockets_for_token: (api_token_id) => registry.close_by_identity(api_token_id),
|
|
44
52
|
close_sockets_for_account: (account_id) => registry.close_by_identity(account_id),
|
|
53
|
+
close_sockets_for_role: (account_id, role) => required_role !== null && role === required_role ? registry.close_by_identity(account_id) : 0,
|
|
45
54
|
close_all_sockets: () => registry.close_all()
|
|
46
55
|
});
|
|
47
56
|
/**
|
|
@@ -59,9 +68,12 @@ export const create_sse_connection_closer = (registry) => ({
|
|
|
59
68
|
* registers the token id and is closed here.
|
|
60
69
|
* - `account` → every stream of `target_account_id ?? account_id`.
|
|
61
70
|
* - `role` → every stream of the target account, when the revoked role
|
|
62
|
-
* (`metadata.role`) is `required_role
|
|
63
|
-
*
|
|
64
|
-
*
|
|
71
|
+
* (`metadata.role`) is `required_role` and the revoked grant was global
|
|
72
|
+
* (`to_global_role_revocation`): the stream's role gate admits on global
|
|
73
|
+
* grants only, so revoking a scoped grant of the same role takes away
|
|
74
|
+
* nothing it admitted on. A stream is authorized once at open and has no
|
|
75
|
+
* later check; the WebSocket guard closes its role-gated sockets on the
|
|
76
|
+
* same rows.
|
|
65
77
|
* - `none` → nothing.
|
|
66
78
|
*
|
|
67
79
|
* Over-closing a one-way feed is cheap: the client reconnects if it is still
|
|
@@ -111,20 +123,26 @@ export const create_sse_auth_guard = (registry, required_role, log) => {
|
|
|
111
123
|
}
|
|
112
124
|
return;
|
|
113
125
|
}
|
|
114
|
-
case 'role':
|
|
126
|
+
case 'role': {
|
|
115
127
|
// `null` means the stream isn't gated by a specific role_grant, so
|
|
116
128
|
// a role revocation closes nothing.
|
|
117
129
|
if (required_role === null)
|
|
118
130
|
return;
|
|
119
|
-
|
|
131
|
+
// global grants only — the gate never admitted on a scoped one
|
|
132
|
+
const revoked = to_global_role_revocation(event);
|
|
133
|
+
if (!revoked || revoked.role !== required_role)
|
|
120
134
|
return;
|
|
121
|
-
|
|
135
|
+
const closed = registry.close_by_identity(revoked.account_id);
|
|
136
|
+
if (closed > 0) {
|
|
137
|
+
log.info(`SSE auth guard: closed ${closed} stream(s) for account ${revoked.account_id} (${event.event_type})`);
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
122
141
|
case 'account':
|
|
123
142
|
break;
|
|
124
143
|
default:
|
|
125
144
|
throw new UnreachableError(scope);
|
|
126
145
|
}
|
|
127
|
-
// `role` and `account` both close the affected account's streams —
|
|
128
146
|
// admin actions set target_account_id, self-service actions only set
|
|
129
147
|
// account_id
|
|
130
148
|
const target = event.target_account_id ?? event.account_id;
|
|
@@ -207,9 +225,10 @@ export const create_audit_log_sse = (options) => {
|
|
|
207
225
|
const max_per_scope = options.max_per_scope === undefined ? AUDIT_LOG_SSE_MAX_PER_SCOPE : options.max_per_scope;
|
|
208
226
|
const registry = new SubscriberRegistry({ max_per_scope, log: options.log });
|
|
209
227
|
const guard = create_sse_auth_guard(registry, role, options.log);
|
|
210
|
-
options.connection_closer?.add(create_sse_connection_closer(registry));
|
|
228
|
+
options.connection_closer?.add(create_sse_connection_closer(registry, role));
|
|
211
229
|
return {
|
|
212
230
|
log: options.log,
|
|
231
|
+
required_role: role,
|
|
213
232
|
on_audit_event: (event) => {
|
|
214
233
|
registry.broadcast(AUDIT_LOG_CHANNEL, { method: event.event_type, params: event });
|
|
215
234
|
guard(event);
|