@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
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
/**
|
|
3
|
+
* Cross-backend front-door contract: what every transport answers before its
|
|
4
|
+
* request reaches per-action dispatch.
|
|
5
|
+
*
|
|
6
|
+
* Each spine composes its front door differently. The TS spine mounts one
|
|
7
|
+
* stack at `/api/*` — body limit, then origin, then session and bearer — and
|
|
8
|
+
* each surface adds its own gates behind it. The Rust spine stacks global
|
|
9
|
+
* layers (body limit, then origin) and has each handler resolve its credential
|
|
10
|
+
* and run its own gates. Two compositions can agree on every per-surface suite
|
|
11
|
+
* and still disagree on which refusal answers when a request trips several, so
|
|
12
|
+
* this suite pins the answer per transport as a table:
|
|
13
|
+
*
|
|
14
|
+
* | surface | foreign `Origin` | unreadable `Origin` | duplicated `Origin` | over cap + foreign `Origin` | no credential | narrowed token |
|
|
15
|
+
* | --- | --- | --- | --- | --- | --- | --- |
|
|
16
|
+
* | REST `POST` (`/logout`) | 403 `forbidden_origin` | 403 `forbidden_origin` | 403 `forbidden_origin` | 413 `payload_too_large` | 401 `authentication_required` | 403 `credential_type_required` |
|
|
17
|
+
* | REST `POST` (`/password`) | — | — | — | — | 401 `authentication_required` | — |
|
|
18
|
+
* | RPC `POST` | 403 `forbidden_origin` | 403 `forbidden_origin` | 403 `forbidden_origin` | 413 `payload_too_large` | 401 `authentication_required` | 403 `token_scope_required` (`rpc:<method>`) |
|
|
19
|
+
* | RPC `GET` | 403 `forbidden_origin` | 403 `forbidden_origin` | 403 `forbidden_origin` | — | 401 `authentication_required` | 403 `token_scope_required` (`rpc:<method>`) |
|
|
20
|
+
* | WS upgrade | 403 `forbidden_origin` | 403 `forbidden_origin` | 403 `forbidden_origin` | — | 401 `authentication_required` | 403 `token_scope_required` (`surface:ws_upgrade`) |
|
|
21
|
+
* | SSE (audit stream) | 403 `forbidden_origin` | 403 `forbidden_origin` | 403 `forbidden_origin` | — | 401 `authentication_required` | 403 `credential_type_required` |
|
|
22
|
+
*
|
|
23
|
+
* - **foreign `Origin`** carries the keeper's session cookie, so only the
|
|
24
|
+
* origin check can refuse it — a front door missing that check answers the
|
|
25
|
+
* surface's success instead.
|
|
26
|
+
* - **unreadable `Origin`** is the allowed origin with a byte past ASCII
|
|
27
|
+
* (`0xE9`), with the keeper's session — a check that reads such a value as
|
|
28
|
+
* absent lets it through.
|
|
29
|
+
* - **duplicated `Origin`** sends the allowed origin on two header lines, with
|
|
30
|
+
* the keeper's session — refused whether a backend sees the two lines (the
|
|
31
|
+
* Rust spine) or one value joined from them (the TS runtimes), so a check that
|
|
32
|
+
* reads only the first lets it through.
|
|
33
|
+
* - **over cap + foreign `Origin`** declares a `Content-Length` one byte over
|
|
34
|
+
* the shared 1 MiB cap and carries no credential: the cap answers on the
|
|
35
|
+
* header, ahead of both the origin check and auth. Only the surfaces that
|
|
36
|
+
* take a body have the column.
|
|
37
|
+
* - **no credential** sends an allowed `Origin` and nothing else.
|
|
38
|
+
* - **narrowed token** sends a bearer minted with a method-scoped scope, and no
|
|
39
|
+
* `Origin` (a bearer in a browser context is discarded, not refused). The RPC
|
|
40
|
+
* probe calls a method the scope doesn't name. A narrowed token reaches no
|
|
41
|
+
* non-RPC surface, but on a session-only surface the credential gate answers
|
|
42
|
+
* first — the REST probe is session-only `/logout`, and the audit stream is
|
|
43
|
+
* session-only on both spines.
|
|
44
|
+
*
|
|
45
|
+
* Each case asserts the status and the refusal body: a flat `{error}` (plus
|
|
46
|
+
* its named fields) or, past the RPC front door, the JSON-RPC error envelope
|
|
47
|
+
* with the reason on `error.data.reason`. A WebSocket refusal's body is
|
|
48
|
+
* required where the backend declares `capabilities.ws_refusal_body`; where it
|
|
49
|
+
* doesn't (Node's `@hono/node-ws` drops a refused upgrade's body), an empty
|
|
50
|
+
* body passes on its status and a body that is sent is still asserted.
|
|
51
|
+
*
|
|
52
|
+
* **Transport.** Given a `base_url`, every probe is written to the socket
|
|
53
|
+
* (`raw_http_exchange`): an upgrade has to be, and an over-cap request sends
|
|
54
|
+
* its head alone, as `body_size.ts` explains — the 413 must arrive without a
|
|
55
|
+
* body byte. With `base_url: null` the probes go through the fixture's
|
|
56
|
+
* in-process transport, whole bodies included, and the WebSocket rows skip
|
|
57
|
+
* (there is no socket to upgrade).
|
|
58
|
+
*
|
|
59
|
+
* Wire it for any backend that serves the spine's account routes, RPC
|
|
60
|
+
* endpoint, WebSocket endpoint and audit stream; the WS and SSE rows gate on
|
|
61
|
+
* `capabilities.ws` and `capabilities.sse`.
|
|
62
|
+
*
|
|
63
|
+
* @module
|
|
64
|
+
*/
|
|
65
|
+
import { assert, describe } from 'vitest';
|
|
66
|
+
import { randomBytes } from 'node:crypto';
|
|
67
|
+
import { account_verify_action_spec } from '../../auth/account_action_specs.js';
|
|
68
|
+
import { ERROR_AUTHENTICATION_REQUIRED, ERROR_CREDENTIAL_TYPE_REQUIRED, ERROR_FORBIDDEN_ORIGIN, ERROR_PAYLOAD_TOO_LARGE, ERROR_TOKEN_SCOPE_REQUIRED } from '../../http/error_schemas.js';
|
|
69
|
+
import { create_rpc_get_url } from '../rpc_helpers.js';
|
|
70
|
+
import { raw_http_exchange, raw_http_parse_response } from '../transports/raw_http.js';
|
|
71
|
+
import { test_if } from './capabilities.js';
|
|
72
|
+
import { mint_narrowed_api_token } from './narrowed_api_token.js';
|
|
73
|
+
import { SPINE_RPC_PATH, SPINE_SSE_PATH } from './spine_surface_constants.js';
|
|
74
|
+
/** The RPC method the narrowed token admits. */
|
|
75
|
+
const NARROWED_ADMITTED_METHOD = account_verify_action_spec.method;
|
|
76
|
+
/**
|
|
77
|
+
* The RPC method the narrowed-token probe calls — one its scope doesn't name.
|
|
78
|
+
* A read with no credential-type or role gate, so the scope gate is the only
|
|
79
|
+
* one that can refuse it, over `GET` as over `POST`.
|
|
80
|
+
*/
|
|
81
|
+
const NARROWED_REFUSED_METHOD = 'account_session_list';
|
|
82
|
+
const FORBIDDEN_ORIGIN = { status: 403, error: ERROR_FORBIDDEN_ORIGIN };
|
|
83
|
+
const PAYLOAD_TOO_LARGE = {
|
|
84
|
+
status: 413,
|
|
85
|
+
error: ERROR_PAYLOAD_TOO_LARGE
|
|
86
|
+
};
|
|
87
|
+
const SESSION_ONLY = {
|
|
88
|
+
status: 403,
|
|
89
|
+
error: ERROR_CREDENTIAL_TYPE_REQUIRED,
|
|
90
|
+
fields: { required_credential_types: ['session'] }
|
|
91
|
+
};
|
|
92
|
+
const RPC_UNAUTHENTICATED = {
|
|
93
|
+
status: 401,
|
|
94
|
+
error: ERROR_AUTHENTICATION_REQUIRED,
|
|
95
|
+
envelope: true
|
|
96
|
+
};
|
|
97
|
+
const RPC_SCOPE_REFUSED = {
|
|
98
|
+
status: 403,
|
|
99
|
+
error: ERROR_TOKEN_SCOPE_REQUIRED,
|
|
100
|
+
fields: { required_scope: `rpc:${NARROWED_REFUSED_METHOD}` },
|
|
101
|
+
envelope: true
|
|
102
|
+
};
|
|
103
|
+
const UNAUTHENTICATED = {
|
|
104
|
+
status: 401,
|
|
105
|
+
error: ERROR_AUTHENTICATION_REQUIRED
|
|
106
|
+
};
|
|
107
|
+
/** The front-door table — one case per cell of the module doc's matrix. */
|
|
108
|
+
export const transport_composition_cases = [
|
|
109
|
+
{ surface: 'rest_post', probe: 'foreign_origin', expect: FORBIDDEN_ORIGIN },
|
|
110
|
+
{ surface: 'rest_post', probe: 'unreadable_origin', expect: FORBIDDEN_ORIGIN },
|
|
111
|
+
{ surface: 'rest_post', probe: 'duplicated_origin', expect: FORBIDDEN_ORIGIN },
|
|
112
|
+
{ surface: 'rest_post', probe: 'over_cap_foreign_origin', expect: PAYLOAD_TOO_LARGE },
|
|
113
|
+
{ surface: 'rest_post', probe: 'no_credential', expect: UNAUTHENTICATED },
|
|
114
|
+
{ surface: 'rest_post', probe: 'narrowed_token', expect: SESSION_ONLY },
|
|
115
|
+
// the other session-only REST route that resolves its own credential on the
|
|
116
|
+
// Rust spine, so its 401 is pinned beside `/logout`'s
|
|
117
|
+
{ surface: 'rest_password', probe: 'no_credential', expect: UNAUTHENTICATED },
|
|
118
|
+
{ surface: 'rpc_post', probe: 'foreign_origin', expect: FORBIDDEN_ORIGIN },
|
|
119
|
+
{ surface: 'rpc_post', probe: 'unreadable_origin', expect: FORBIDDEN_ORIGIN },
|
|
120
|
+
{ surface: 'rpc_post', probe: 'duplicated_origin', expect: FORBIDDEN_ORIGIN },
|
|
121
|
+
{ surface: 'rpc_post', probe: 'over_cap_foreign_origin', expect: PAYLOAD_TOO_LARGE },
|
|
122
|
+
{ surface: 'rpc_post', probe: 'no_credential', expect: RPC_UNAUTHENTICATED },
|
|
123
|
+
{ surface: 'rpc_post', probe: 'narrowed_token', expect: RPC_SCOPE_REFUSED },
|
|
124
|
+
{ surface: 'rpc_get', probe: 'foreign_origin', expect: FORBIDDEN_ORIGIN },
|
|
125
|
+
{ surface: 'rpc_get', probe: 'unreadable_origin', expect: FORBIDDEN_ORIGIN },
|
|
126
|
+
{ surface: 'rpc_get', probe: 'duplicated_origin', expect: FORBIDDEN_ORIGIN },
|
|
127
|
+
{ surface: 'rpc_get', probe: 'no_credential', expect: RPC_UNAUTHENTICATED },
|
|
128
|
+
{ surface: 'rpc_get', probe: 'narrowed_token', expect: RPC_SCOPE_REFUSED },
|
|
129
|
+
{ surface: 'ws_upgrade', probe: 'foreign_origin', expect: FORBIDDEN_ORIGIN, gate: 'ws' },
|
|
130
|
+
{ surface: 'ws_upgrade', probe: 'unreadable_origin', expect: FORBIDDEN_ORIGIN, gate: 'ws' },
|
|
131
|
+
{ surface: 'ws_upgrade', probe: 'duplicated_origin', expect: FORBIDDEN_ORIGIN, gate: 'ws' },
|
|
132
|
+
{ surface: 'ws_upgrade', probe: 'no_credential', expect: UNAUTHENTICATED, gate: 'ws' },
|
|
133
|
+
{
|
|
134
|
+
surface: 'ws_upgrade',
|
|
135
|
+
probe: 'narrowed_token',
|
|
136
|
+
expect: {
|
|
137
|
+
status: 403,
|
|
138
|
+
error: ERROR_TOKEN_SCOPE_REQUIRED,
|
|
139
|
+
fields: { required_scope: 'surface:ws_upgrade' }
|
|
140
|
+
},
|
|
141
|
+
gate: 'ws'
|
|
142
|
+
},
|
|
143
|
+
{ surface: 'sse', probe: 'foreign_origin', expect: FORBIDDEN_ORIGIN, gate: 'sse' },
|
|
144
|
+
{ surface: 'sse', probe: 'unreadable_origin', expect: FORBIDDEN_ORIGIN, gate: 'sse' },
|
|
145
|
+
{ surface: 'sse', probe: 'duplicated_origin', expect: FORBIDDEN_ORIGIN, gate: 'sse' },
|
|
146
|
+
{ surface: 'sse', probe: 'no_credential', expect: UNAUTHENTICATED, gate: 'sse' },
|
|
147
|
+
{ surface: 'sse', probe: 'narrowed_token', expect: SESSION_ONLY, gate: 'sse' }
|
|
148
|
+
];
|
|
149
|
+
/** The shared 1 MiB default cap both spines ship, as in `body_size.ts`. */
|
|
150
|
+
const BODY_LIMIT_DEFAULT_BYTES = 1024 * 1024;
|
|
151
|
+
/** An `Origin` no backend's allowlist admits. */
|
|
152
|
+
const DISALLOWED_ORIGIN = 'http://evil.com';
|
|
153
|
+
/** How long a raw exchange waits for the refusal. */
|
|
154
|
+
const RAW_READ_TIMEOUT_MS = 2000;
|
|
155
|
+
const rpc_envelope = (method, id) => JSON.stringify({ jsonrpc: '2.0', method, id });
|
|
156
|
+
/** A JSON object body of exactly `bytes` ASCII bytes. */
|
|
157
|
+
const sized_json = (bytes) => {
|
|
158
|
+
const base = JSON.stringify({ pad: '' });
|
|
159
|
+
return JSON.stringify({ pad: 'x'.repeat(Math.max(0, bytes - base.length)) });
|
|
160
|
+
};
|
|
161
|
+
const to_header_lines = (headers) => Object.entries(headers)
|
|
162
|
+
.map(([name, value]) => `${name}: ${value}\r\n`)
|
|
163
|
+
.join('');
|
|
164
|
+
const parse_json = (text) => {
|
|
165
|
+
try {
|
|
166
|
+
return JSON.parse(text);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
const case_name = (c) => `${c.surface} × ${c.probe} → ${c.expect.status} ${c.expect.error}`;
|
|
173
|
+
/**
|
|
174
|
+
* Read the refusal reason and fields out of a body, by the shape the case
|
|
175
|
+
* expects. An envelope expectation fails on a flat body and the other way
|
|
176
|
+
* round, so a spine answering from the wrong layer fails here.
|
|
177
|
+
*/
|
|
178
|
+
const assert_refusal_body = (c, body, text) => {
|
|
179
|
+
const label = case_name(c);
|
|
180
|
+
assert.ok(body !== null && typeof body === 'object', `${label}: expected a JSON body, got ${JSON.stringify(text.slice(0, 200))}`);
|
|
181
|
+
let refusal;
|
|
182
|
+
if (c.expect.envelope) {
|
|
183
|
+
const envelope = body;
|
|
184
|
+
assert.strictEqual(envelope.jsonrpc, '2.0', `${label}: expected a JSON-RPC envelope: ${text}`);
|
|
185
|
+
const data = envelope.error?.data;
|
|
186
|
+
assert.ok(data !== null && typeof data === 'object', `${label}: expected error.data on the envelope: ${text}`);
|
|
187
|
+
refusal = data;
|
|
188
|
+
assert.strictEqual(refusal.reason, c.expect.error, `${label}: error.data.reason`);
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
refusal = body;
|
|
192
|
+
assert.ok(!('jsonrpc' in refusal), `${label}: expected a flat body, got an envelope: ${text}`);
|
|
193
|
+
assert.strictEqual(refusal.error, c.expect.error, `${label}: body.error`);
|
|
194
|
+
}
|
|
195
|
+
for (const [key, expected] of Object.entries(c.expect.fields ?? {})) {
|
|
196
|
+
assert.deepStrictEqual(refusal[key], expected, `${label}: ${key}`);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
/**
|
|
200
|
+
* Register the front-door table's cases — one test per cell, each on a fresh
|
|
201
|
+
* fixture.
|
|
202
|
+
*
|
|
203
|
+
* This is the front-door contract: a backend that wires it answers each
|
|
204
|
+
* transport's probes as the table says — when one request trips several
|
|
205
|
+
* refusals, the one the table names answers.
|
|
206
|
+
*/
|
|
207
|
+
export const describe_transport_composition_cross_tests = (options) => {
|
|
208
|
+
const { setup_test, capabilities, base_url } = options;
|
|
209
|
+
const rpc_path = options.rpc_path ?? SPINE_RPC_PATH;
|
|
210
|
+
const rest_path = options.rest_path ?? '/api/account/logout';
|
|
211
|
+
const password_path = options.password_path ?? '/api/account/password';
|
|
212
|
+
const ws_path = options.ws_path ?? '/api/ws';
|
|
213
|
+
const sse_path = options.sse_path ?? SPINE_SSE_PATH;
|
|
214
|
+
const allowed_origin = options.origin ?? (base_url === null ? 'http://localhost:5173' : new URL(base_url).origin);
|
|
215
|
+
/** The request line, headers and body a case's probe sends. */
|
|
216
|
+
const build_probe = async (c, fixture) => {
|
|
217
|
+
const over_cap = c.probe === 'over_cap_foreign_origin';
|
|
218
|
+
let credential;
|
|
219
|
+
let repeated_headers;
|
|
220
|
+
switch (c.probe) {
|
|
221
|
+
case 'foreign_origin':
|
|
222
|
+
credential = { ...fixture.create_session_headers(), origin: DISALLOWED_ORIGIN };
|
|
223
|
+
break;
|
|
224
|
+
case 'unreadable_origin':
|
|
225
|
+
// latin1 on the socket — the byte 0xE9
|
|
226
|
+
credential = { ...fixture.create_session_headers(), origin: `${allowed_origin}\u00e9` };
|
|
227
|
+
break;
|
|
228
|
+
case 'duplicated_origin':
|
|
229
|
+
credential = { ...fixture.create_session_headers(), origin: allowed_origin };
|
|
230
|
+
repeated_headers = [['origin', allowed_origin]];
|
|
231
|
+
break;
|
|
232
|
+
case 'over_cap_foreign_origin':
|
|
233
|
+
// no credential either: the cap answers ahead of the origin check and auth
|
|
234
|
+
credential = { origin: DISALLOWED_ORIGIN };
|
|
235
|
+
break;
|
|
236
|
+
case 'no_credential':
|
|
237
|
+
credential = { origin: allowed_origin };
|
|
238
|
+
break;
|
|
239
|
+
case 'narrowed_token': {
|
|
240
|
+
const token = await mint_narrowed_api_token({
|
|
241
|
+
app: fixture.transport,
|
|
242
|
+
rpc_path,
|
|
243
|
+
session_headers: fixture.create_session_headers(),
|
|
244
|
+
methods: [NARROWED_ADMITTED_METHOD]
|
|
245
|
+
});
|
|
246
|
+
// no `Origin` — a bearer in a browser context is discarded
|
|
247
|
+
credential = { authorization: `Bearer ${token}` };
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const rpc_method = c.probe === 'narrowed_token' ? NARROWED_REFUSED_METHOD : account_verify_action_spec.method;
|
|
252
|
+
const id = `${c.surface}-${c.probe}`;
|
|
253
|
+
switch (c.surface) {
|
|
254
|
+
case 'rest_post':
|
|
255
|
+
case 'rest_password':
|
|
256
|
+
return {
|
|
257
|
+
method: 'POST',
|
|
258
|
+
path: c.surface === 'rest_post' ? rest_path : password_path,
|
|
259
|
+
headers: { ...credential, 'content-type': 'application/json' },
|
|
260
|
+
body: over_cap ? sized_json(BODY_LIMIT_DEFAULT_BYTES + 1) : '{}',
|
|
261
|
+
over_cap,
|
|
262
|
+
repeated_headers
|
|
263
|
+
};
|
|
264
|
+
case 'rpc_post': {
|
|
265
|
+
return {
|
|
266
|
+
method: 'POST',
|
|
267
|
+
path: rpc_path,
|
|
268
|
+
headers: { ...credential, 'content-type': 'application/json' },
|
|
269
|
+
body: over_cap ? sized_json(BODY_LIMIT_DEFAULT_BYTES + 1) : rpc_envelope(rpc_method, id),
|
|
270
|
+
over_cap,
|
|
271
|
+
repeated_headers
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
case 'rpc_get':
|
|
275
|
+
return {
|
|
276
|
+
method: 'GET',
|
|
277
|
+
path: create_rpc_get_url(rpc_path, rpc_method, undefined, id),
|
|
278
|
+
headers: credential,
|
|
279
|
+
body: null,
|
|
280
|
+
over_cap,
|
|
281
|
+
repeated_headers
|
|
282
|
+
};
|
|
283
|
+
case 'ws_upgrade':
|
|
284
|
+
return {
|
|
285
|
+
method: 'GET',
|
|
286
|
+
path: ws_path,
|
|
287
|
+
headers: {
|
|
288
|
+
...credential,
|
|
289
|
+
upgrade: 'websocket',
|
|
290
|
+
connection: 'Upgrade',
|
|
291
|
+
'sec-websocket-key': randomBytes(16).toString('base64'),
|
|
292
|
+
'sec-websocket-version': '13'
|
|
293
|
+
},
|
|
294
|
+
body: null,
|
|
295
|
+
over_cap,
|
|
296
|
+
repeated_headers
|
|
297
|
+
};
|
|
298
|
+
case 'sse':
|
|
299
|
+
return {
|
|
300
|
+
method: 'GET',
|
|
301
|
+
path: sse_path,
|
|
302
|
+
headers: { ...credential, accept: 'text/event-stream' },
|
|
303
|
+
body: null,
|
|
304
|
+
over_cap,
|
|
305
|
+
repeated_headers
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
/** Put a probe on the socket, or through the in-process transport. */
|
|
310
|
+
const send_probe = async (fixture, probe) => {
|
|
311
|
+
if (base_url === null) {
|
|
312
|
+
const headers = new Headers(probe.headers);
|
|
313
|
+
// in-process there is no wire: a repeated header joins, as the runtimes join it
|
|
314
|
+
for (const [name, value] of probe.repeated_headers ?? [])
|
|
315
|
+
headers.append(name, value);
|
|
316
|
+
const res = await fixture.fresh_transport()(probe.path, {
|
|
317
|
+
method: probe.method,
|
|
318
|
+
headers,
|
|
319
|
+
...(probe.body === null ? {} : { body: probe.body })
|
|
320
|
+
});
|
|
321
|
+
return { status: res.status, text: await res.text() };
|
|
322
|
+
}
|
|
323
|
+
const url = new URL(base_url);
|
|
324
|
+
const body = probe.over_cap ? '' : (probe.body ?? '');
|
|
325
|
+
const length_header = probe.over_cap
|
|
326
|
+
? { 'content-length': String(BODY_LIMIT_DEFAULT_BYTES + 1) }
|
|
327
|
+
: probe.body === null
|
|
328
|
+
? {}
|
|
329
|
+
: { 'content-length': String(body.length) };
|
|
330
|
+
const request = `${probe.method} ${probe.path} HTTP/1.1\r\n` +
|
|
331
|
+
to_header_lines({ host: url.host, ...probe.headers, ...length_header }) +
|
|
332
|
+
(probe.repeated_headers ?? []).map(([name, value]) => `${name}: ${value}\r\n`).join('') +
|
|
333
|
+
`\r\n` +
|
|
334
|
+
body;
|
|
335
|
+
const { received, closed_by_server } = await raw_http_exchange(base_url, request, {
|
|
336
|
+
read_timeout_ms: RAW_READ_TIMEOUT_MS,
|
|
337
|
+
until: 'response'
|
|
338
|
+
});
|
|
339
|
+
const response = raw_http_parse_response(received);
|
|
340
|
+
assert.ok(response, `${probe.method} ${probe.path}: expected a whole response; ` +
|
|
341
|
+
(closed_by_server
|
|
342
|
+
? 'the server closed the connection first'
|
|
343
|
+
: `none arrived within ${RAW_READ_TIMEOUT_MS}ms`) +
|
|
344
|
+
`. Received: ${received ? received.slice(0, 200) : 'nothing'}`);
|
|
345
|
+
return { status: response.status, text: response.body };
|
|
346
|
+
};
|
|
347
|
+
describe('transport composition (front door)', () => {
|
|
348
|
+
for (const c of transport_composition_cases) {
|
|
349
|
+
const gated = c.gate === undefined || capabilities[c.gate];
|
|
350
|
+
// an upgrade needs a socket; in-process there is none
|
|
351
|
+
const reachable = c.surface !== 'ws_upgrade' || base_url !== null;
|
|
352
|
+
test_if(gated && reachable, case_name(c), async () => {
|
|
353
|
+
const fixture = await setup_test();
|
|
354
|
+
const res = await send_probe(fixture, await build_probe(c, fixture));
|
|
355
|
+
assert.strictEqual(res.status, c.expect.status, `${case_name(c)}: status (body: ${res.text.slice(0, 200)})`);
|
|
356
|
+
// a backend that declares no WS refusal body (Node's `@hono/node-ws`)
|
|
357
|
+
// may answer with an empty one; a body it does send is still asserted
|
|
358
|
+
if (c.surface === 'ws_upgrade' && !capabilities.ws_refusal_body && res.text === '')
|
|
359
|
+
return;
|
|
360
|
+
assert_refusal_body(c, parse_json(res.text), res.text);
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ts_spine_backend_config.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/ts_spine_backend_config.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"ts_spine_backend_config.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/ts_spine_backend_config.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAiEzD,6FAA6F;AAC7F,eAAO,MAAM,0BAA0B,OAAO,CAAC;AAE/C,iDAAiD;AACjD,eAAO,MAAM,0BAA0B,OAAO,CAAC;AAE/C,gDAAgD;AAChD,eAAO,MAAM,yBAAyB,OAAO,CAAC;AAE9C,yDAAyD;AACzD,eAAO,MAAM,mBAAmB,wDAAwD,CAAC;AAEzF,yDAAyD;AACzD,eAAO,MAAM,mBAAmB,wDAAwD,CAAC;AAEzF,wDAAwD;AACxD,eAAO,MAAM,kBAAkB,uDAAuD,CAAC;AAEvF,MAAM,WAAW,2BAA2B;IAC3C,mFAAmF;IACnF,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,6DAA6D;IAC7D,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;;;;;;OASG;IACH,QAAQ,CAAC,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAC3C;;;;;;OAMG;IACH,QAAQ,CAAC,wBAAwB,CAAC,EAAE,OAAO,CAAC;IAC5C;;;;;OAKG;IACH,QAAQ,CAAC,8BAA8B,CAAC,EAAE,MAAM,CAAC;CACjD;AAWD;;;GAGG;AACH,eAAO,MAAM,4BAA4B,GACxC,UAAS,2BAAgC,KACvC,aAoBF,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,2BAA2B,GACvC,UAAS,2BAAgC,KACvC,aAoBF,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,4BAA4B,GACxC,UAAS,2BAAgC,KACvC,aAiCF,CAAC"}
|
|
@@ -9,8 +9,11 @@ import { LOGIN_RATE_LIMIT_ENABLED_ENV, SPINE_SSE_PATH, TS_SPINE_DIR_ENV } from '
|
|
|
9
9
|
* WS transport's `request_connection` path drives server→client `peer/ping`),
|
|
10
10
|
* `ws_account_actions` (the binary mounts its full action set on `/api/ws`,
|
|
11
11
|
* the account actions among them),
|
|
12
|
-
*
|
|
13
|
-
* `test_cell_gated_create_authorize` policy)
|
|
12
|
+
* `cell_gated_create` (the binary's `full_spine_mount` mounts the
|
|
13
|
+
* `test_cell_gated_create_authorize` policy), and `ws_role_gated` (the binary
|
|
14
|
+
* mounts the role-gated WS endpoint at `SPINE_ROLE_GATED_WS_PATH`). The Node
|
|
15
|
+
* binary's preset; `ws_refusal_body` stays `false`, as `@hono/node-ws` drops a
|
|
16
|
+
* refused upgrade's body.
|
|
14
17
|
*/
|
|
15
18
|
const ts_spine_capabilities = Object.freeze({
|
|
16
19
|
...ts_default_capabilities,
|
|
@@ -18,11 +21,22 @@ const ts_spine_capabilities = Object.freeze({
|
|
|
18
21
|
ready: true,
|
|
19
22
|
peer_request: true,
|
|
20
23
|
ws_account_actions: true,
|
|
21
|
-
cell_gated_create: true
|
|
24
|
+
cell_gated_create: true,
|
|
25
|
+
ws_role_gated: true
|
|
26
|
+
});
|
|
27
|
+
/**
|
|
28
|
+
* Capabilities for the **Deno** spine binary — `ts_spine_capabilities` with
|
|
29
|
+
* `ws_refusal_body: true` (`Deno.upgradeWebSocket` leaves a refused upgrade's
|
|
30
|
+
* response to Hono, body included).
|
|
31
|
+
*/
|
|
32
|
+
const ts_spine_deno_capabilities = Object.freeze({
|
|
33
|
+
...ts_spine_capabilities,
|
|
34
|
+
ws_refusal_body: true
|
|
22
35
|
});
|
|
23
36
|
/**
|
|
24
37
|
* Capabilities for the **Bun** spine binary — `ts_spine_capabilities` with
|
|
25
|
-
* `ws_handshake_pipelining: false
|
|
38
|
+
* `ws_handshake_pipelining: false` and `ws_refusal_body: true` (`Bun.serve`
|
|
39
|
+
* sends a refused upgrade's response as built).
|
|
26
40
|
*
|
|
27
41
|
* `Bun.serve` answers `400` to an upgrade request that carries WebSocket
|
|
28
42
|
* frames in the same TCP write when the handler upgrades after an await that
|
|
@@ -34,7 +48,8 @@ const ts_spine_capabilities = Object.freeze({
|
|
|
34
48
|
*/
|
|
35
49
|
const ts_spine_bun_capabilities = Object.freeze({
|
|
36
50
|
...ts_spine_capabilities,
|
|
37
|
-
ws_handshake_pipelining: false
|
|
51
|
+
ws_handshake_pipelining: false,
|
|
52
|
+
ws_refusal_body: true
|
|
38
53
|
});
|
|
39
54
|
/** Default port for the Node TS spine binary — slots beside the Rust `spine_stub` (1177). */
|
|
40
55
|
export const TS_SPINE_NODE_DEFAULT_PORT = 1178;
|
|
@@ -148,7 +163,7 @@ export const ts_spine_deno_backend_config = (options = {}) => {
|
|
|
148
163
|
...login_rate_limit_env(enable_login_rate_limit),
|
|
149
164
|
...action_rate_limit_env(options)
|
|
150
165
|
},
|
|
151
|
-
capabilities:
|
|
166
|
+
capabilities: ts_spine_deno_capabilities
|
|
152
167
|
}),
|
|
153
168
|
sse_path: SPINE_SSE_PATH
|
|
154
169
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ws_action_rate_limit.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/ws_action_rate_limit.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"ws_action_rate_limit.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/ws_action_rate_limit.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAkE9B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAgD5C,+DAA+D;AAC/D,MAAM,WAAW,iCAAiC;IACjD,2EAA2E;IAC3E,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,2EAA2E;IAC3E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,gDAAgD;IAChD,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,yEAAyE;IACzE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;CAC9B;AAkBD;;;;GAIG;AACH,eAAO,MAAM,yCAAyC,GACrD,SAAS,iCAAiC,KACxC,IAwSF,CAAC"}
|
|
@@ -23,7 +23,9 @@ import '../assert_dev_env.js';
|
|
|
23
23
|
* 'ip'`, public) with malformed params, from one `X-Forwarded-For` address
|
|
24
24
|
* over anonymous HTTP and over a WS whose upgrade carried the same header:
|
|
25
25
|
* the call past the cap is refused on both, while a second address keeps its
|
|
26
|
-
* own budget on both.
|
|
26
|
+
* own budget on both. Over HTTP, IPv6 addresses bucket by their `/64`:
|
|
27
|
+
* another address in the same `/64` is refused once one has spent the
|
|
28
|
+
* budget, while one in the next `/64` keeps its own.
|
|
27
29
|
* - **account axis at the dispatchers** — `cell_create` (`rate_limit:
|
|
28
30
|
* 'account'`, on both spines' RPC and WS mounts) with malformed params: the
|
|
29
31
|
* call past the cap answers `429` over HTTP and `rate_limited` over WS, calls
|
|
@@ -84,6 +86,13 @@ const IP_LIMITED_BAD_PARAMS = { nonce: 'not_a_number' };
|
|
|
84
86
|
*/
|
|
85
87
|
const XFF_IP_EXHAUSTED = '198.51.100.1';
|
|
86
88
|
const XFF_IP_FRESH = '198.51.100.2';
|
|
89
|
+
/**
|
|
90
|
+
* RFC 3849 documentation addresses: two in one `/64` — the second differs past
|
|
91
|
+
* bit 64 only — and one in the next `/64`. Distinct from `login_security.ts`'s.
|
|
92
|
+
*/
|
|
93
|
+
const XFF_V6_EXHAUSTED = '2001:db8:e3a:1::1';
|
|
94
|
+
const XFF_V6_SAME_64 = '2001:db8:e3a:1:8000::2';
|
|
95
|
+
const XFF_V6_OTHER_64 = '2001:db8:e3a:2::1';
|
|
87
96
|
/** Generous per-response wait — the cases assert the server's answer, not a timeout. */
|
|
88
97
|
const RESPONSE_TIMEOUT_MS = 5000;
|
|
89
98
|
const outcome_from_frame = (status, frame) => ({
|
|
@@ -254,5 +263,16 @@ export const describe_ws_action_rate_limit_cross_tests = (options) => {
|
|
|
254
263
|
await ws_fresh.close();
|
|
255
264
|
}
|
|
256
265
|
});
|
|
266
|
+
test('an IP-limited action buckets IPv6 clients by /64', async () => {
|
|
267
|
+
const fixture = await setup_test();
|
|
268
|
+
const call = (forwarded_for) => call_http(fixture, IP_LIMITED_METHOD, IP_LIMITED_BAD_PARAMS, {
|
|
269
|
+
'x-forwarded-for': forwarded_for
|
|
270
|
+
});
|
|
271
|
+
for (let i = 1; i <= max_attempts; i++) {
|
|
272
|
+
assert_charged_not_limited(await call(XFF_V6_EXHAUSTED), `call ${i} from ${XFF_V6_EXHAUSTED}`);
|
|
273
|
+
}
|
|
274
|
+
assert_rate_limited(await call(XFF_V6_SAME_64), `a call from ${XFF_V6_SAME_64}, same /64`);
|
|
275
|
+
assert_charged_not_limited(await call(XFF_V6_OTHER_64), `a call from ${XFF_V6_OTHER_64}, the next /64`);
|
|
276
|
+
});
|
|
257
277
|
});
|
|
258
278
|
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
import { type BackendCapabilities } from './capabilities.js';
|
|
3
|
+
import type { SetupTest } from './setup.js';
|
|
4
|
+
/** Configuration for `describe_ws_role_gated_cross_tests`. */
|
|
5
|
+
export interface WsRoleGatedCrossTestOptions {
|
|
6
|
+
/** Per-test fixture producer (`default_cross_process_setup(handle)`). */
|
|
7
|
+
readonly setup_test: SetupTest;
|
|
8
|
+
/** Backend capability flags; every case gates on `ws` and `ws_role_gated`. */
|
|
9
|
+
readonly capabilities: BackendCapabilities;
|
|
10
|
+
/** Base URL the backend is reachable at. */
|
|
11
|
+
readonly base_url: string;
|
|
12
|
+
/** The ungated WebSocket endpoint path (e.g. `/api/ws`). */
|
|
13
|
+
readonly ws_path: string;
|
|
14
|
+
/** The role-gated WebSocket endpoint path. */
|
|
15
|
+
readonly gated_ws_path: string;
|
|
16
|
+
/**
|
|
17
|
+
* The roles `gated_ws_path` requires: `ROLE_ADMIN` and `scoped_role`
|
|
18
|
+
* among them.
|
|
19
|
+
*/
|
|
20
|
+
readonly gated_roles: ReadonlyArray<string>;
|
|
21
|
+
/** A role in `gated_roles` an admin can grant both globally and scoped. */
|
|
22
|
+
readonly scoped_role: string;
|
|
23
|
+
/** JSON-RPC endpoint path. Default `SPINE_RPC_PATH`. */
|
|
24
|
+
readonly rpc_path?: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Register the role-gated WebSocket suite: a revoked global grant of a gating
|
|
28
|
+
* role closes the account's gated socket with `WS_CLOSE_PERMISSION_REVOKED`,
|
|
29
|
+
* its ungated socket stays open and still receives the account's pushes, and
|
|
30
|
+
* a re-upgrade is refused at the gate; a scoped revoke of a gating role
|
|
31
|
+
* closes nothing.
|
|
32
|
+
*
|
|
33
|
+
* That the closed registration is sent nothing more is beyond what a client
|
|
34
|
+
* can observe once it has read the close frame, so the suite does not claim
|
|
35
|
+
* it: each spine's own transport tests pin it.
|
|
36
|
+
*/
|
|
37
|
+
export declare const describe_ws_role_gated_cross_tests: (options: WsRoleGatedCrossTestOptions) => void;
|
|
38
|
+
//# sourceMappingURL=ws_role_gated.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ws_role_gated.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/ws_role_gated.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAwC9B,OAAO,EAAE,KAAK,mBAAmB,EAAW,MAAM,mBAAmB,CAAC;AAKtE,OAAO,KAAK,EAAE,SAAS,EAAmC,MAAM,YAAY,CAAC;AAM7E,8DAA8D;AAC9D,MAAM,WAAW,2BAA2B;IAC3C,yEAAyE;IACzE,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,8EAA8E;IAC9E,QAAQ,CAAC,YAAY,EAAE,mBAAmB,CAAC;IAC3C,4CAA4C;IAC5C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,4DAA4D;IAC5D,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,8CAA8C;IAC9C,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B;;;OAGG;IACH,QAAQ,CAAC,WAAW,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC5C,2EAA2E;IAC3E,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,wDAAwD;IACxD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,kCAAkC,GAAI,SAAS,2BAA2B,KAAG,IA2IzF,CAAC"}
|