@fuzdev/fuz_app 0.140.0 → 0.141.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/dist/actions/CLAUDE.md +62 -17
  2. package/dist/actions/action_rpc.d.ts +5 -1
  3. package/dist/actions/action_rpc.d.ts.map +1 -1
  4. package/dist/actions/action_rpc.js +9 -1
  5. package/dist/actions/connection_closer.d.ts +25 -2
  6. package/dist/actions/connection_closer.d.ts.map +1 -1
  7. package/dist/actions/connection_closer.js +8 -1
  8. package/dist/actions/register_action_ws.d.ts +42 -6
  9. package/dist/actions/register_action_ws.d.ts.map +1 -1
  10. package/dist/actions/register_action_ws.js +87 -19
  11. package/dist/actions/register_ws_endpoint.d.ts +15 -6
  12. package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
  13. package/dist/actions/register_ws_endpoint.js +5 -2
  14. package/dist/actions/socket.svelte.d.ts +19 -4
  15. package/dist/actions/socket.svelte.d.ts.map +1 -1
  16. package/dist/actions/socket.svelte.js +31 -4
  17. package/dist/actions/transports.d.ts +30 -1
  18. package/dist/actions/transports.d.ts.map +1 -1
  19. package/dist/actions/transports.js +30 -1
  20. package/dist/actions/transports_ws_auth_guard.d.ts +13 -6
  21. package/dist/actions/transports_ws_auth_guard.d.ts.map +1 -1
  22. package/dist/actions/transports_ws_auth_guard.js +24 -10
  23. package/dist/actions/transports_ws_backend.d.ts +51 -5
  24. package/dist/actions/transports_ws_backend.d.ts.map +1 -1
  25. package/dist/actions/transports_ws_backend.js +62 -9
  26. package/dist/actions/ws_endpoint_spec.d.ts +11 -10
  27. package/dist/actions/ws_endpoint_spec.d.ts.map +1 -1
  28. package/dist/auth/CLAUDE.md +11 -9
  29. package/dist/auth/account_route_schema.d.ts +1 -0
  30. package/dist/auth/account_route_schema.d.ts.map +1 -1
  31. package/dist/auth/account_route_schema.js +4 -0
  32. package/dist/auth/api_token.d.ts +20 -0
  33. package/dist/auth/api_token.d.ts.map +1 -1
  34. package/dist/auth/api_token.js +21 -1
  35. package/dist/auth/api_token_queries.d.ts +22 -4
  36. package/dist/auth/api_token_queries.d.ts.map +1 -1
  37. package/dist/auth/api_token_queries.js +11 -6
  38. package/dist/auth/audit_log_routes.d.ts +10 -0
  39. package/dist/auth/audit_log_routes.d.ts.map +1 -1
  40. package/dist/auth/audit_log_routes.js +20 -0
  41. package/dist/auth/audit_log_schema.d.ts +30 -5
  42. package/dist/auth/audit_log_schema.d.ts.map +1 -1
  43. package/dist/auth/audit_log_schema.js +30 -0
  44. package/dist/auth/bearer_auth.d.ts +4 -2
  45. package/dist/auth/bearer_auth.d.ts.map +1 -1
  46. package/dist/auth/bearer_auth.js +19 -8
  47. package/dist/auth/bootstrap_route_schema.d.ts +1 -0
  48. package/dist/auth/bootstrap_route_schema.d.ts.map +1 -1
  49. package/dist/auth/bootstrap_route_schema.js +1 -0
  50. package/dist/auth/cleanup.d.ts +9 -9
  51. package/dist/auth/cleanup.js +9 -9
  52. package/dist/auth/credential_deadline.d.ts +95 -0
  53. package/dist/auth/credential_deadline.d.ts.map +1 -0
  54. package/dist/auth/credential_deadline.js +139 -0
  55. package/dist/auth/daemon_token_middleware.d.ts.map +1 -1
  56. package/dist/auth/daemon_token_middleware.js +4 -1
  57. package/dist/auth/middleware.d.ts +90 -12
  58. package/dist/auth/middleware.d.ts.map +1 -1
  59. package/dist/auth/middleware.js +115 -16
  60. package/dist/auth/request_context.d.ts +4 -2
  61. package/dist/auth/request_context.d.ts.map +1 -1
  62. package/dist/auth/request_context.js +14 -3
  63. package/dist/auth/resolved_auth.d.ts +13 -2
  64. package/dist/auth/resolved_auth.d.ts.map +1 -1
  65. package/dist/auth/resolved_auth.js +5 -4
  66. package/dist/auth/session_cookie.d.ts +4 -0
  67. package/dist/auth/session_cookie.d.ts.map +1 -1
  68. package/dist/auth/session_cookie.js +3 -2
  69. package/dist/auth/session_middleware.d.ts +1 -1
  70. package/dist/auth/session_middleware.d.ts.map +1 -1
  71. package/dist/auth/session_middleware.js +4 -1
  72. package/dist/auth/session_queries.d.ts +19 -2
  73. package/dist/auth/session_queries.d.ts.map +1 -1
  74. package/dist/auth/session_queries.js +8 -2
  75. package/dist/auth/signup_route_schema.d.ts.map +1 -1
  76. package/dist/auth/signup_route_schema.js +1 -0
  77. package/dist/hono_context.d.ts +23 -0
  78. package/dist/hono_context.d.ts.map +1 -1
  79. package/dist/hono_context.js +19 -0
  80. package/dist/http/CLAUDE.md +41 -15
  81. package/dist/http/middleware_spec.d.ts +16 -0
  82. package/dist/http/middleware_spec.d.ts.map +1 -1
  83. package/dist/http/origin.d.ts +113 -18
  84. package/dist/http/origin.d.ts.map +1 -1
  85. package/dist/http/origin.js +247 -127
  86. package/dist/http/route_spec.d.ts +21 -0
  87. package/dist/http/route_spec.d.ts.map +1 -1
  88. package/dist/http/schema_helpers.d.ts +22 -0
  89. package/dist/http/schema_helpers.d.ts.map +1 -1
  90. package/dist/http/schema_helpers.js +24 -0
  91. package/dist/http/surface.d.ts +6 -1
  92. package/dist/http/surface.d.ts.map +1 -1
  93. package/dist/http/surface.js +12 -5
  94. package/dist/realtime/sse_auth_guard.d.ts +22 -6
  95. package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
  96. package/dist/realtime/sse_auth_guard.js +31 -12
  97. package/dist/realtime/subscriber_registry.d.ts +10 -1
  98. package/dist/realtime/subscriber_registry.d.ts.map +1 -1
  99. package/dist/realtime/subscriber_registry.js +10 -1
  100. package/dist/server/app_server.d.ts +20 -7
  101. package/dist/server/app_server.d.ts.map +1 -1
  102. package/dist/server/app_server.js +8 -3
  103. package/dist/server/env.d.ts +2 -1
  104. package/dist/server/env.d.ts.map +1 -1
  105. package/dist/server/env.js +7 -17
  106. package/dist/server/serve_fact_route.d.ts.map +1 -1
  107. package/dist/server/serve_fact_route.js +1 -0
  108. package/dist/testing/CLAUDE.md +154 -22
  109. package/dist/testing/adversarial_headers.d.ts.map +1 -1
  110. package/dist/testing/adversarial_headers.js +13 -6
  111. package/dist/testing/connection_closer_helpers.d.ts +6 -1
  112. package/dist/testing/connection_closer_helpers.d.ts.map +1 -1
  113. package/dist/testing/connection_closer_helpers.js +4 -0
  114. package/dist/testing/cross_backend/body_size.d.ts.map +1 -1
  115. package/dist/testing/cross_backend/body_size.js +14 -65
  116. package/dist/testing/cross_backend/capabilities.d.ts +26 -2
  117. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
  118. package/dist/testing/cross_backend/capabilities.js +9 -3
  119. package/dist/testing/cross_backend/conformance_case.d.ts +11 -3
  120. package/dist/testing/cross_backend/conformance_case.d.ts.map +1 -1
  121. package/dist/testing/cross_backend/conformance_case.js +6 -0
  122. package/dist/testing/cross_backend/conformance_table.d.ts.map +1 -1
  123. package/dist/testing/cross_backend/conformance_table.js +20 -19
  124. package/dist/testing/cross_backend/credential_deadline.d.ts +25 -0
  125. package/dist/testing/cross_backend/credential_deadline.d.ts.map +1 -0
  126. package/dist/testing/cross_backend/credential_deadline.js +185 -0
  127. package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
  128. package/dist/testing/cross_backend/default_backend_configs.js +13 -2
  129. package/dist/testing/cross_backend/full_spine_mount.d.ts +3 -0
  130. package/dist/testing/cross_backend/full_spine_mount.d.ts.map +1 -1
  131. package/dist/testing/cross_backend/full_spine_mount.js +7 -1
  132. package/dist/testing/cross_backend/narrowed_api_token.d.ts +23 -0
  133. package/dist/testing/cross_backend/narrowed_api_token.d.ts.map +1 -0
  134. package/dist/testing/cross_backend/narrowed_api_token.js +40 -0
  135. package/dist/testing/cross_backend/origin.d.ts.map +1 -1
  136. package/dist/testing/cross_backend/origin.js +6 -26
  137. package/dist/testing/cross_backend/origin_conformance.d.ts +152 -0
  138. package/dist/testing/cross_backend/origin_conformance.d.ts.map +1 -0
  139. package/dist/testing/cross_backend/origin_conformance.js +136 -0
  140. package/dist/testing/cross_backend/origin_conformance.json +1679 -0
  141. package/dist/testing/cross_backend/origin_scope.d.ts +16 -0
  142. package/dist/testing/cross_backend/origin_scope.d.ts.map +1 -0
  143. package/dist/testing/cross_backend/origin_scope.js +140 -0
  144. package/dist/testing/cross_backend/role_grant_cross_helpers.d.ts +35 -0
  145. package/dist/testing/cross_backend/role_grant_cross_helpers.d.ts.map +1 -0
  146. package/dist/testing/cross_backend/role_grant_cross_helpers.js +64 -0
  147. package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts +8 -0
  148. package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts.map +1 -1
  149. package/dist/testing/cross_backend/role_grant_offer_notification_ws.js +44 -33
  150. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
  151. package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +6 -4
  152. package/dist/testing/cross_backend/spine_surface_constants.d.ts +19 -0
  153. package/dist/testing/cross_backend/spine_surface_constants.d.ts.map +1 -1
  154. package/dist/testing/cross_backend/spine_surface_constants.js +22 -0
  155. package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
  156. package/dist/testing/cross_backend/testing_backdoor.js +20 -7
  157. package/dist/testing/cross_backend/testing_reset_actions.d.ts +180 -5
  158. package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
  159. package/dist/testing/cross_backend/testing_reset_actions.js +154 -5
  160. package/dist/testing/cross_backend/token_scope_surface.d.ts.map +1 -1
  161. package/dist/testing/cross_backend/token_scope_surface.js +25 -21
  162. package/dist/testing/cross_backend/transport_composition.d.ts +60 -0
  163. package/dist/testing/cross_backend/transport_composition.d.ts.map +1 -0
  164. package/dist/testing/cross_backend/transport_composition.js +364 -0
  165. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
  166. package/dist/testing/cross_backend/ts_spine_backend_config.js +21 -6
  167. package/dist/testing/cross_backend/ws_role_gated.d.ts +38 -0
  168. package/dist/testing/cross_backend/ws_role_gated.d.ts.map +1 -0
  169. package/dist/testing/cross_backend/ws_role_gated.js +142 -0
  170. package/dist/testing/entities.d.ts +23 -0
  171. package/dist/testing/entities.d.ts.map +1 -1
  172. package/dist/testing/entities.js +37 -0
  173. package/dist/testing/integration.d.ts.map +1 -1
  174. package/dist/testing/integration.js +3 -1
  175. package/dist/testing/middleware.d.ts +10 -0
  176. package/dist/testing/middleware.d.ts.map +1 -1
  177. package/dist/testing/middleware.js +31 -7
  178. package/dist/testing/stubs.d.ts +16 -3
  179. package/dist/testing/stubs.d.ts.map +1 -1
  180. package/dist/testing/stubs.js +20 -7
  181. package/dist/testing/surface_invariants.js +2 -2
  182. package/package.json +1 -1
@@ -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;AAiDzD,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"}
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
- * and `cell_gated_create` (the binary's `full_spine_mount` mounts the
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: ts_spine_capabilities
166
+ capabilities: ts_spine_deno_capabilities
152
167
  }),
153
168
  sse_path: SPINE_SSE_PATH
154
169
  };
@@ -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"}
@@ -0,0 +1,142 @@
1
+ import '../assert_dev_env.js';
2
+ /**
3
+ * Cross-process **role-gated WebSocket** suite — a socket admitted by an
4
+ * endpoint's role gate (`register_ws_endpoint`'s `required_roles`) loses it
5
+ * with the role.
6
+ *
7
+ * A role gate decides who may hold a socket, and so who receives the
8
+ * endpoint's server pushes, which reach every admitted connection with no
9
+ * per-recipient filter. So both spines record the gating roles on each
10
+ * registration and close the account's gated sockets when a global grant of a
11
+ * listed role is revoked — with `WS_CLOSE_PERMISSION_REVOKED`, not the
12
+ * session-revoked code, since the credential is still good — and send nothing
13
+ * after the close. The ungated sockets of the same account, on the same
14
+ * transport, stay open: the gate is per endpoint, not per account. A scoped
15
+ * grant never admits at a role gate, so revoking one closes nothing.
16
+ *
17
+ * Runs against a backend that mounts, beside its ungated `ws_path`, a second
18
+ * endpoint at `gated_ws_path` gated on `gated_roles` — which must include
19
+ * `ROLE_ADMIN` (the revoked global grant) and a role that can be granted both
20
+ * globally and scoped (the scoped revoke). fuz_app's spine binaries mount
21
+ * `SPINE_ROLE_GATED_WS_PATH` gated on `SPINE_ROLE_GATED_WS_ROLES`. Gated on
22
+ * `capabilities.ws` and `capabilities.ws_role_gated`; cross-process only.
23
+ *
24
+ * @module
25
+ */
26
+ import { assert, describe } from 'vitest';
27
+ import { assert_rejects } from '@fuzdev/fuz_util/testing.ts';
28
+ import { heartbeat_action_spec } from '../../actions/heartbeat.js';
29
+ import { WS_CLOSE_PERMISSION_REVOKED, WS_CLOSE_PERMISSION_REVOKED_REASON } from '../../actions/transports.js';
30
+ import { ROLE_ADMIN } from '../../auth/role_schema.js';
31
+ import { ROLE_GRANT_OFFER_RECEIVED_NOTIFICATION_METHOD } from '../../auth/role_grant_offer_notifications.js';
32
+ import { rpc_call } from '../rpc_helpers.js';
33
+ import { is_notification_with } from '../transports/ws_client.js';
34
+ import { create_admitted_ws_transport, create_ws_transport } from '../transports/ws_transport.js';
35
+ import { test_if } from './capabilities.js';
36
+ import { create_active_role_grant, create_pending_role_grant_offer } from './role_grant_cross_helpers.js';
37
+ import { SPINE_RPC_PATH } from './spine_surface_constants.js';
38
+ /** How long a socket that must stay open is watched for a close it must not get. */
39
+ const STAYS_OPEN_MS = 1500;
40
+ /**
41
+ * Register the role-gated WebSocket suite: a revoked global grant of a gating
42
+ * role closes the account's gated socket with `WS_CLOSE_PERMISSION_REVOKED`,
43
+ * its ungated socket stays open and still receives the account's pushes, and
44
+ * a re-upgrade is refused at the gate; a scoped revoke of a gating role
45
+ * closes nothing.
46
+ *
47
+ * That the closed registration is sent nothing more is beyond what a client
48
+ * can observe once it has read the close frame, so the suite does not claim
49
+ * it: each spine's own transport tests pin it.
50
+ */
51
+ export const describe_ws_role_gated_cross_tests = (options) => {
52
+ const { setup_test, capabilities, base_url, ws_path, gated_ws_path, gated_roles, scoped_role, rpc_path = SPINE_RPC_PATH } = options;
53
+ const enabled = capabilities.ws && capabilities.ws_role_gated;
54
+ if (enabled && (!gated_roles.includes(ROLE_ADMIN) || !gated_roles.includes(scoped_role))) {
55
+ throw new Error(`describe_ws_role_gated_cross_tests: gated_roles must include ${ROLE_ADMIN} and ${scoped_role}`);
56
+ }
57
+ const rpc = (fixture, method, params, headers = fixture.create_session_headers()) => rpc_call({ app: fixture.transport, path: rpc_path, method, params, headers });
58
+ const cookie_of = (account) => {
59
+ const { cookie } = account.create_session_headers();
60
+ assert.ok(cookie, 'expected a session cookie for the WS upgrade');
61
+ return cookie;
62
+ };
63
+ const open = (path, account) => create_admitted_ws_transport({
64
+ base_url,
65
+ ws_path: path,
66
+ cookies: [cookie_of(account)],
67
+ origin: base_url
68
+ });
69
+ describe('role-gated websocket (cross-process)', () => {
70
+ test_if(enabled, 'revoking a global grant of a gating role closes the gated socket with WS_CLOSE_PERMISSION_REVOKED', async () => {
71
+ const fixture = await setup_test();
72
+ const target = await fixture.create_account({ roles: [ROLE_ADMIN] });
73
+ const role_grant_id = await create_active_role_grant(fixture, target, { rpc_path });
74
+ const gated = await open(gated_ws_path, target);
75
+ const ungated = await open(ws_path, target);
76
+ try {
77
+ const revoked = await rpc(fixture, 'role_grant_revoke', {
78
+ actor_id: target.actor.id,
79
+ role_grant_id
80
+ });
81
+ assert.isTrue(revoked.ok, `revoke failed: ${JSON.stringify(revoked)}`);
82
+ assert.ok(await gated.wait_for_close(5000), 'the gated socket did not close');
83
+ assert.strictEqual(gated.close_code, WS_CLOSE_PERMISSION_REVOKED);
84
+ assert.strictEqual(gated.close_reason, WS_CLOSE_PERMISSION_REVOKED_REASON);
85
+ // a push to the account still reaches its ungated socket, and the
86
+ // closed client records nothing more
87
+ const received_before = gated.messages.length;
88
+ await create_pending_role_grant_offer(fixture, target.account.id, fixture.create_session_headers(), { rpc_path });
89
+ await ungated.wait_for(is_notification_with(ROLE_GRANT_OFFER_RECEIVED_NOTIFICATION_METHOD, (p) => p.offer.to_account_id === target.account.id), 5000);
90
+ assert.strictEqual(gated.messages.length, received_before);
91
+ // the account's ungated socket on the same backend stays open
92
+ assert.strictEqual(ungated.close_code, null);
93
+ assert.deepStrictEqual(await ungated.request(2, heartbeat_action_spec.method, {}), {});
94
+ // and the gate refuses the account a new gated socket
95
+ await assert_rejects(() => create_ws_transport({
96
+ base_url,
97
+ ws_path: gated_ws_path,
98
+ cookies: [cookie_of(target)],
99
+ origin: base_url
100
+ }), /403/);
101
+ }
102
+ finally {
103
+ await gated.close();
104
+ await ungated.close();
105
+ }
106
+ });
107
+ test_if(enabled, 'a scoped revoke of a gating role closes nothing', async () => {
108
+ const fixture = await setup_test();
109
+ // a global grant of `scoped_role` admits the socket — which proves the
110
+ // endpoint lists it, so the scoped revoke below is one of a gating role
111
+ const target = await fixture.create_account();
112
+ const assign = async (scope) => {
113
+ const assigned = await rpc(fixture, 'role_grant_assign', {
114
+ to_account_id: target.account.id,
115
+ role: scoped_role,
116
+ ...scope
117
+ });
118
+ if (!assigned.ok)
119
+ assert.fail(`assign failed: ${JSON.stringify(assigned)}`);
120
+ return assigned.result.role_grant_id;
121
+ };
122
+ await assign({});
123
+ const role_grant_id = await assign({
124
+ scope_kind: 'space',
125
+ scope_id: '33333333-3333-4333-8333-333333333333'
126
+ });
127
+ const gated = await open(gated_ws_path, target);
128
+ try {
129
+ const revoked = await rpc(fixture, 'role_grant_revoke', {
130
+ actor_id: target.actor.id,
131
+ role_grant_id
132
+ });
133
+ assert.isTrue(revoked.ok, `revoke failed: ${JSON.stringify(revoked)}`);
134
+ assert.isFalse(await gated.wait_for_close(STAYS_OPEN_MS), `closed with ${gated.close_code} on a scoped revoke`);
135
+ assert.deepStrictEqual(await gated.request(2, heartbeat_action_spec.method, {}), {});
136
+ }
137
+ finally {
138
+ await gated.close();
139
+ }
140
+ });
141
+ });
142
+ };
@@ -2,6 +2,7 @@ import './assert_dev_env.js';
2
2
  import type { Account, Actor, RoleGrant } from '../auth/account_schema.js';
3
3
  import type { AuditLogEvent } from '../auth/audit_log_schema.js';
4
4
  import type { RequestActorContext } from '../auth/request_context.js';
5
+ import type { ValidatedApiToken } from '../auth/api_token_queries.js';
5
6
  /** Override type for `create_test_account` — id-like fields accept plain `string`. */
6
7
  export type TestAccountOverrides = Partial<Omit<Account, 'id' | 'created_by' | 'updated_by'>> & {
7
8
  id?: string;
@@ -45,4 +46,26 @@ export type TestAuditEventOverrides = Partial<Omit<AuditLogEvent, 'id' | 'actor_
45
46
  };
46
47
  /** Create a test `AuditLogEvent` with sensible defaults. */
47
48
  export declare const create_test_audit_event: (overrides?: TestAuditEventOverrides) => AuditLogEvent;
49
+ /**
50
+ * Create a well-formed raw API token for tests that need a bearer value to
51
+ * pass the `is_api_token_shaped` precheck and reach token validation. The
52
+ * body is `label` padded with `A` to the generated length, so distinct labels
53
+ * give distinct tokens and a token in a failure message reads back to its
54
+ * test. Not a real credential — it authenticates only where a test mocks the
55
+ * lookup.
56
+ *
57
+ * @param label - base64url characters (`A-Z a-z 0-9 - _`) that start the token body
58
+ * @returns `API_TOKEN_PREFIX` plus an `API_TOKEN_BODY_LENGTH`-char body
59
+ * @throws Error when `label` is longer than the body or not base64url
60
+ */
61
+ export declare const create_test_raw_api_token: (label?: string) => string;
62
+ /** Override type for `create_test_validated_api_token` — `account_id` accepts plain `string`. */
63
+ export type TestValidatedApiTokenOverrides = Partial<Omit<ValidatedApiToken, 'account_id'>> & {
64
+ account_id?: string;
65
+ };
66
+ /**
67
+ * Create a test `ValidatedApiToken` — what `query_validate_api_token` returns
68
+ * for a live token — with a `full` scope by default.
69
+ */
70
+ export declare const create_test_validated_api_token: (overrides?: TestValidatedApiTokenOverrides) => ValidatedApiToken;
48
71
  //# sourceMappingURL=entities.d.ts.map