@fuzdev/fuz_app 0.96.0 → 0.98.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 (165) hide show
  1. package/dist/actions/CLAUDE.md +57 -23
  2. package/dist/actions/action_bridge.d.ts.map +1 -1
  3. package/dist/actions/action_codegen.d.ts +1 -1
  4. package/dist/actions/action_codegen.d.ts.map +1 -1
  5. package/dist/actions/action_codegen.js +1 -1
  6. package/dist/actions/{action_peer.d.ts → action_dispatcher.d.ts} +12 -12
  7. package/dist/actions/action_dispatcher.d.ts.map +1 -0
  8. package/dist/actions/{action_peer.js → action_dispatcher.js} +2 -2
  9. package/dist/actions/action_event.d.ts.map +1 -1
  10. package/dist/actions/action_rpc.d.ts +9 -0
  11. package/dist/actions/action_rpc.d.ts.map +1 -1
  12. package/dist/actions/broadcast_api.d.ts +2 -2
  13. package/dist/actions/broadcast_api.d.ts.map +1 -1
  14. package/dist/actions/compile_action_registry.d.ts.map +1 -1
  15. package/dist/actions/frontend_rpc_client.d.ts +3 -3
  16. package/dist/actions/frontend_rpc_client.d.ts.map +1 -1
  17. package/dist/actions/frontend_rpc_client.js +3 -3
  18. package/dist/actions/peer_ping.d.ts +147 -0
  19. package/dist/actions/peer_ping.d.ts.map +1 -0
  20. package/dist/actions/peer_ping.js +189 -0
  21. package/dist/actions/peer_request.d.ts +158 -0
  22. package/dist/actions/peer_request.d.ts.map +1 -0
  23. package/dist/actions/peer_request.js +167 -0
  24. package/dist/actions/perform_action.d.ts +8 -0
  25. package/dist/actions/perform_action.d.ts.map +1 -1
  26. package/dist/actions/perform_action.js +2 -1
  27. package/dist/actions/protocol.d.ts +3 -3
  28. package/dist/actions/protocol.d.ts.map +1 -1
  29. package/dist/actions/protocol.js +9 -4
  30. package/dist/actions/register_action_ws.d.ts.map +1 -1
  31. package/dist/actions/register_action_ws.js +28 -1
  32. package/dist/actions/rpc_client.d.ts +4 -4
  33. package/dist/actions/rpc_client.d.ts.map +1 -1
  34. package/dist/actions/socket.svelte.d.ts.map +1 -1
  35. package/dist/actions/transports.d.ts +1 -1
  36. package/dist/actions/transports_http.d.ts.map +1 -1
  37. package/dist/actions/transports_ws.d.ts +12 -5
  38. package/dist/actions/transports_ws.d.ts.map +1 -1
  39. package/dist/actions/transports_ws.js +36 -6
  40. package/dist/actions/transports_ws_backend.d.ts +34 -1
  41. package/dist/actions/transports_ws_backend.d.ts.map +1 -1
  42. package/dist/actions/transports_ws_backend.js +62 -5
  43. package/dist/auth/CLAUDE.md +13 -13
  44. package/dist/auth/actor_lookup_action_specs.d.ts.map +1 -1
  45. package/dist/auth/actor_search_action_specs.d.ts.map +1 -1
  46. package/dist/auth/admin_action_specs.d.ts.map +1 -1
  47. package/dist/auth/admin_actions.d.ts.map +1 -1
  48. package/dist/auth/audit_log_queries.d.ts.map +1 -1
  49. package/dist/auth/audit_log_schema.d.ts.map +1 -1
  50. package/dist/auth/bearer_auth.d.ts.map +1 -1
  51. package/dist/auth/bearer_auth.js +6 -7
  52. package/dist/auth/bootstrap_routes.d.ts.map +1 -1
  53. package/dist/auth/daemon_token_middleware.d.ts +21 -4
  54. package/dist/auth/daemon_token_middleware.d.ts.map +1 -1
  55. package/dist/auth/daemon_token_middleware.js +61 -10
  56. package/dist/auth/middleware.d.ts.map +1 -1
  57. package/dist/auth/middleware.js +6 -2
  58. package/dist/auth/role_grant_queries.d.ts +31 -12
  59. package/dist/auth/role_grant_queries.d.ts.map +1 -1
  60. package/dist/auth/role_grant_queries.js +36 -12
  61. package/dist/auth/role_schema.d.ts.map +1 -1
  62. package/dist/auth/self_service_role_actions.d.ts.map +1 -1
  63. package/dist/db/create_db.d.ts.map +1 -1
  64. package/dist/db/fact_store.d.ts.map +1 -1
  65. package/dist/db/status.d.ts.map +1 -1
  66. package/dist/http/CLAUDE.md +3 -2
  67. package/dist/http/auth_shape.d.ts.map +1 -1
  68. package/dist/http/db_routes.d.ts.map +1 -1
  69. package/dist/http/error_schemas.d.ts +0 -6
  70. package/dist/http/error_schemas.d.ts.map +1 -1
  71. package/dist/http/error_schemas.js +0 -6
  72. package/dist/http/jsonrpc_helpers.d.ts +1 -1
  73. package/dist/http/jsonrpc_helpers.js +1 -1
  74. package/dist/http/origin.d.ts +18 -1
  75. package/dist/http/origin.d.ts.map +1 -1
  76. package/dist/http/origin.js +17 -0
  77. package/dist/rate_limiter.d.ts.map +1 -1
  78. package/dist/rate_limiter.js +11 -0
  79. package/dist/server/app_backend.d.ts.map +1 -1
  80. package/dist/server/app_server.d.ts.map +1 -1
  81. package/dist/server/serve_fact_route.d.ts.map +1 -1
  82. package/dist/server/serve_fact_route.js +7 -4
  83. package/dist/testing/CLAUDE.md +41 -18
  84. package/dist/testing/admin_integration.d.ts.map +1 -1
  85. package/dist/testing/adversarial_input.d.ts.map +1 -1
  86. package/dist/testing/adversarial_input.js +3 -1
  87. package/dist/testing/attack_surface.d.ts.map +1 -1
  88. package/dist/testing/audit_completeness.d.ts.map +1 -1
  89. package/dist/testing/audit_completeness.js +4 -1
  90. package/dist/testing/cross_backend/action_manifest.d.ts +7 -7
  91. package/dist/testing/cross_backend/action_manifest.js +7 -7
  92. package/dist/testing/cross_backend/action_manifest_parity.d.ts.map +1 -1
  93. package/dist/testing/cross_backend/bootstrap_backend.d.ts.map +1 -1
  94. package/dist/testing/cross_backend/bootstrap_backend.js +8 -0
  95. package/dist/testing/cross_backend/capabilities.d.ts +15 -12
  96. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
  97. package/dist/testing/cross_backend/capabilities.js +4 -4
  98. package/dist/testing/cross_backend/cell_cross_helpers.d.ts.map +1 -1
  99. package/dist/testing/cross_backend/conformance_case.d.ts +42 -0
  100. package/dist/testing/cross_backend/conformance_case.d.ts.map +1 -1
  101. package/dist/testing/cross_backend/conformance_case.js +47 -0
  102. package/dist/testing/cross_backend/conformance_table.d.ts.map +1 -1
  103. package/dist/testing/cross_backend/conformance_table.js +90 -3
  104. package/dist/testing/cross_backend/cookie_attributes.d.ts +20 -0
  105. package/dist/testing/cross_backend/cookie_attributes.d.ts.map +1 -0
  106. package/dist/testing/cross_backend/cookie_attributes.js +158 -0
  107. package/dist/testing/cross_backend/credential_header_robustness.d.ts +12 -0
  108. package/dist/testing/cross_backend/credential_header_robustness.d.ts.map +1 -0
  109. package/dist/testing/cross_backend/credential_header_robustness.js +179 -0
  110. package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
  111. package/dist/testing/cross_backend/default_backend_configs.js +5 -3
  112. package/dist/testing/cross_backend/fact_serving.d.ts.map +1 -1
  113. package/dist/testing/cross_backend/fact_serving.js +5 -1
  114. package/dist/testing/cross_backend/full_spine_mount.d.ts.map +1 -1
  115. package/dist/testing/cross_backend/full_spine_mount.js +7 -0
  116. package/dist/testing/cross_backend/identity_parity.d.ts.map +1 -1
  117. package/dist/testing/cross_backend/login_security.d.ts.map +1 -1
  118. package/dist/testing/cross_backend/method_coverage.d.ts +3 -7
  119. package/dist/testing/cross_backend/method_coverage.d.ts.map +1 -1
  120. package/dist/testing/cross_backend/method_coverage.js +1 -2
  121. package/dist/testing/cross_backend/peer_ping_ws.js +1 -1
  122. package/dist/testing/cross_backend/role_grant_offer_enumeration.d.ts +26 -0
  123. package/dist/testing/cross_backend/role_grant_offer_enumeration.d.ts.map +1 -0
  124. package/dist/testing/cross_backend/role_grant_offer_enumeration.js +108 -0
  125. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
  126. package/dist/testing/cross_backend/setup.d.ts +17 -0
  127. package/dist/testing/cross_backend/setup.d.ts.map +1 -1
  128. package/dist/testing/cross_backend/setup.js +31 -8
  129. package/dist/testing/cross_backend/testing_reset_actions.d.ts +0 -13
  130. package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
  131. package/dist/testing/cross_backend/testing_reset_actions.js +12 -4
  132. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
  133. package/dist/testing/cross_backend/ts_spine_backend_config.js +9 -3
  134. package/dist/testing/data_exposure.d.ts.map +1 -1
  135. package/dist/testing/db.d.ts.map +1 -1
  136. package/dist/testing/db_entities.d.ts +15 -0
  137. package/dist/testing/db_entities.d.ts.map +1 -1
  138. package/dist/testing/db_entities.js +16 -1
  139. package/dist/testing/error_coverage.d.ts.map +1 -1
  140. package/dist/testing/error_coverage.js +1 -2
  141. package/dist/testing/integration_helpers.d.ts.map +1 -1
  142. package/dist/testing/rate_limiting.d.ts.map +1 -1
  143. package/dist/testing/round_trip.d.ts.map +1 -1
  144. package/dist/testing/rpc_attack_surface.d.ts.map +1 -1
  145. package/dist/testing/rpc_helpers.d.ts.map +1 -1
  146. package/dist/testing/rpc_round_trip.d.ts.map +1 -1
  147. package/dist/testing/schema_generators.d.ts.map +1 -1
  148. package/dist/testing/schema_generators.js +3 -1
  149. package/dist/testing/schema_parity.d.ts.map +1 -1
  150. package/dist/testing/sse_round_trip.d.ts.map +1 -1
  151. package/dist/testing/surface_invariants.d.ts.map +1 -1
  152. package/dist/testing/transports/bootstrap.d.ts.map +1 -1
  153. package/dist/testing/transports/ws_client.d.ts +1 -1
  154. package/dist/testing/transports/ws_transport.d.ts +1 -1
  155. package/dist/testing/ws_round_trip.d.ts +1 -1
  156. package/dist/testing/ws_round_trip.d.ts.map +1 -1
  157. package/dist/testing/ws_round_trip.js +3 -3
  158. package/dist/ui/CLAUDE.md +6 -3
  159. package/dist/ui/auth_state.svelte.d.ts.map +1 -1
  160. package/dist/ui/form_state.svelte.d.ts.map +1 -1
  161. package/dist/ui/role_grant_offers_state.svelte.d.ts +1 -1
  162. package/dist/ui/role_grant_offers_state.svelte.js +1 -1
  163. package/dist/ui/ui_format.d.ts.map +1 -1
  164. package/package.json +2 -2
  165. package/dist/actions/action_peer.d.ts.map +0 -1
@@ -33,8 +33,39 @@ import { z } from 'zod';
33
33
  * - `keeper` — the per-test bootstrapped keeper (holds `ROLE_KEEPER` +
34
34
  * `ROLE_ADMIN`), session credential.
35
35
  * - `daemon` — the keeper authenticated via the daemon-token header.
36
+ * - `invalid_daemon` — a *malformed/invalid* `X-Daemon-Token` carried
37
+ * alongside the keeper's session cookie, over a non-browser (no-Origin)
38
+ * transport. The middleware soft-fail-discards the invalid daemon token
39
+ * (matching the Rust spine's `None`), so auth falls through to the session
40
+ * leg: the request authenticates as the keeper-via-session and a
41
+ * daemon-gated action then refuses the session credential with
42
+ * `credential_type_required` — not a hard `invalid_daemon_token` 401. The
43
+ * no-Origin transport keeps the daemon token on the invalid-token path
44
+ * rather than the browser-context discard; the session base credential is
45
+ * what makes the credential-type gate (not the auth gate) the refusing
46
+ * layer (without it the discard would 401 anonymous).
47
+ * - `daemon_browser` — a *valid* `X-Daemon-Token` carried in a browser
48
+ * context (default `Origin` present) alongside the keeper's session cookie.
49
+ * Browsers attach `Origin` automatically; the daemon-token middleware
50
+ * discards a header-bearing daemon token as browser context (mirroring the
51
+ * bearer guard and the Rust spine's `is_browser_context`), so the *valid*
52
+ * token is dropped and auth falls through to the session leg → a daemon-gated
53
+ * action then refuses the session credential with `credential_type_required`.
54
+ * Distinct from `invalid_daemon`: here the token is well-formed and current,
55
+ * so a 403 (not a 400 confirm-guard hit like `daemon`) proves the
56
+ * browser-context discard fired — a valid daemon token does NOT authenticate
57
+ * when an `Origin` is present. `Origin` is deliberately NOT suppressed; its
58
+ * presence is the signal under test.
36
59
  * - `token` — the keeper authenticated via a bearer api-token (non-browser
37
60
  * context; the runner suppresses `Origin` so the token isn't discarded).
61
+ * - `bearer_browser` — a *valid* bearer api-token carried in a browser context
62
+ * (default `Origin` present), fresh jar so NO session rides alongside. The
63
+ * bearer middleware discards the token as browser context (mirroring the
64
+ * daemon guard + the Rust spine's `is_browser_context`), so the request
65
+ * arrives anonymous and an authed action 401s. Proves a stolen bearer cannot
66
+ * be replayed from a browser — wire-indistinguishable from sending no
67
+ * credential (the `token` principal is the honored counterpart: it suppresses
68
+ * `Origin`, so the same token authenticates).
38
69
  * - `anonymous` — no credential, fresh cookie jar.
39
70
  * - `fresh_non_admin` — a freshly minted account with no roles, session
40
71
  * credential (via the production invite → signup → login flow).
@@ -52,7 +83,10 @@ import { z } from 'zod';
52
83
  export const ConformancePrincipal = z.enum([
53
84
  'keeper',
54
85
  'daemon',
86
+ 'invalid_daemon',
87
+ 'daemon_browser',
55
88
  'token',
89
+ 'bearer_browser',
56
90
  'anonymous',
57
91
  'fresh_non_admin',
58
92
  'role_holder',
@@ -96,6 +130,19 @@ export const ConformanceCaseExpectation = z.strictObject({
96
130
  description: 'Specific field-value assertions on the success `result` (2xx) or the error ' +
97
131
  '`error.data` (non-2xx). Each key must deep-equal the corresponding response field.',
98
132
  }),
133
+ absent_fields: z
134
+ .array(z.string())
135
+ .optional()
136
+ .meta({
137
+ description: 'Sensitive field names that must NOT appear ANYWHERE (at any depth) in the ' +
138
+ 'normalized response body — the negative-space twin of `fields`. Each name is ' +
139
+ 'searched recursively through the success `result` / error envelope, so a ' +
140
+ 'secret column (`password_hash`, `token_hash`) that leaks at any nesting level ' +
141
+ '(top-level, inside a list element) fails the case. Pins "this secret never ' +
142
+ 'serializes" on BOTH spines — a serialization regression would otherwise leak ' +
143
+ 'identically and silently on each. Non-vacuous only when the body is known ' +
144
+ 'non-empty (e.g. a list that always contains the caller); note that in the case.',
145
+ }),
99
146
  headers: z
100
147
  .record(z.string(), z.string().nullable())
101
148
  .optional()
@@ -1 +1 @@
1
- {"version":3,"file":"conformance_table.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/conformance_table.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA8B9B,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,uBAAuB,CAAC;AAE1D,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,8BAA8B,CAAC;AAMjE,OAAO,EAKN,KAAK,uBAAuB,EAC5B,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EAAC,KAAK,eAAe,EAA4B,MAAM,uBAAuB,CAAC;AACtF,OAAO,KAAK,EAAC,mBAAmB,EAAC,MAAM,mBAAmB,CAAC;AAC3D,OAAO,KAAK,EAAC,SAAS,EAAc,MAAM,YAAY,CAAC;AAGvD;;;;;GAKG;AACH,MAAM,WAAW,0BAA0B;IAC1C,iEAAiE;IACjE,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,gEAAgE;IAChE,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,sDAAsD;AACtD,MAAM,WAAW,uBAAuB;IACvC,8CAA8C;IAC9C,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAC,eAAe,CAAC,CAAC;IAC/C,+DAA+D;IAC/D,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,oEAAoE;IACpE,QAAQ,CAAC,cAAc,EAAE,cAAc,CAAC;IACxC,0EAA0E;IAC1E,QAAQ,CAAC,YAAY,EAAE,mBAAmB,CAAC;IAC3C,kEAAkE;IAClE,QAAQ,CAAC,aAAa,EAAE,uBAAuB,CAAC;IAChD,4EAA4E;IAC5E,QAAQ,CAAC,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACjD,4EAA4E;IAC5E,QAAQ,CAAC,UAAU,CAAC,EAAE,0BAA0B,CAAC;IACjD,iEAAiE;IACjE,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC7B;AA+FD;;;;;;;GAOG;AACH,eAAO,MAAM,mBAAmB,EAAE,aAAa,CAAC,MAAM,CAIrD,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,6BAA6B,GACzC,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC/B,OAAO,MAAM,KACX,IAQF,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,GACnC,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC/B,UAAU,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC,EACvC,OAAO,MAAM,KACX,IAYF,CAAC;AAgKF;;;;;GAKG;AACH,eAAO,MAAM,gCAAgC,GAAI,SAAS,uBAAuB,KAAG,IAkEnF,CAAC"}
1
+ {"version":3,"file":"conformance_table.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/conformance_table.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA8B9B,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,uBAAuB,CAAC;AAE1D,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,8BAA8B,CAAC;AAOjE,OAAO,EAKN,KAAK,uBAAuB,EAC5B,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EAAC,KAAK,eAAe,EAA4B,MAAM,uBAAuB,CAAC;AACtF,OAAO,KAAK,EAAC,mBAAmB,EAAC,MAAM,mBAAmB,CAAC;AAC3D,OAAO,KAAK,EAAC,SAAS,EAAc,MAAM,YAAY,CAAC;AAGvD;;;;;GAKG;AACH,MAAM,WAAW,0BAA0B;IAC1C,iEAAiE;IACjE,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,gEAAgE;IAChE,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,sDAAsD;AACtD,MAAM,WAAW,uBAAuB;IACvC,8CAA8C;IAC9C,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAC,eAAe,CAAC,CAAC;IAC/C,+DAA+D;IAC/D,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,oEAAoE;IACpE,QAAQ,CAAC,cAAc,EAAE,cAAc,CAAC;IACxC,0EAA0E;IAC1E,QAAQ,CAAC,YAAY,EAAE,mBAAmB,CAAC;IAC3C,kEAAkE;IAClE,QAAQ,CAAC,aAAa,EAAE,uBAAuB,CAAC;IAChD,4EAA4E;IAC5E,QAAQ,CAAC,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACjD,4EAA4E;IAC5E,QAAQ,CAAC,UAAU,CAAC,EAAE,0BAA0B,CAAC;IACjD,iEAAiE;IACjE,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC7B;AAuLD;;;;;;;GAOG;AACH,eAAO,MAAM,mBAAmB,EAAE,aAAa,CAAC,MAAM,CAIrD,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,6BAA6B,GACzC,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC/B,OAAO,MAAM,KACX,IAQF,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,GACnC,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC/B,UAAU,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC,EACvC,OAAO,MAAM,KACX,IAYF,CAAC;AAuKF;;;;;GAKG;AACH,eAAO,MAAM,gCAAgC,GAAI,SAAS,uBAAuB,KAAG,IAkEnF,CAAC"}
@@ -25,10 +25,21 @@ import '../assert_dev_env.js';
25
25
  * @module
26
26
  */
27
27
  import { assert, describe, test } from 'vitest';
28
+ import { DAEMON_TOKEN_HEADER } from '../../auth/daemon_token.js';
28
29
  import { find_auth_route, rest_auth_route_suffixes, } from '../integration_helpers.js';
29
30
  import { find_rpc_action, headers_to_record, rpc_call, resolve_rpc_endpoints_for_setup, } from '../rpc_helpers.js';
30
31
  import {} from './conformance_case.js';
31
32
  import { xfail_until } from './xfail.js';
33
+ /**
34
+ * A deliberately malformed `X-Daemon-Token` value for the `invalid_daemon`
35
+ * principal. Fails the `DaemonToken` Zod schema (not 43 base64url chars), so
36
+ * the daemon-token middleware soft-fail-discards it rather than authenticating
37
+ * — the request falls through with no identity, surfacing
38
+ * `credential_type_required` on a daemon-gated action (matching the Rust
39
+ * spine's `None`). Sent over a no-Origin transport so it lands on the
40
+ * invalid-token path, not the browser-context discard.
41
+ */
42
+ const INVALID_DAEMON_TOKEN_VALUE = 'not-a-valid-daemon-token';
32
43
  /**
33
44
  * Map a `ConformancePrincipal` onto the transport + headers it
34
45
  * authenticates with, reading exclusively from the per-test `TestFixture`.
@@ -51,6 +62,39 @@ const resolve_principal = async (fixture, as, principals) => {
51
62
  headers: fixture.create_daemon_token_headers(),
52
63
  suppress_default_origin: true,
53
64
  };
65
+ case 'invalid_daemon':
66
+ // A malformed `X-Daemon-Token` carried ALONGSIDE the keeper's session
67
+ // cookie, over a non-browser (no-Origin) transport. The invalid daemon
68
+ // token is soft-fail-discarded (matching the Rust spine's `None`), so
69
+ // auth falls through to the session leg: the request authenticates as
70
+ // the keeper-via-session (clearing `require_auth`), then the daemon-gated
71
+ // action's credential-type gate refuses the session credential →
72
+ // `403 credential_type_required`. No-Origin keeps the daemon token on
73
+ // the invalid-token path, NOT the browser-context discard. Without the
74
+ // base credential this would 401 (anonymous) — the session is what makes
75
+ // the *credential-type* gate, not the auth gate, the refusing layer.
76
+ return {
77
+ transport: fixture.fresh_transport({ origin: null }),
78
+ headers: fixture.create_session_headers({
79
+ [DAEMON_TOKEN_HEADER]: INVALID_DAEMON_TOKEN_VALUE,
80
+ }),
81
+ suppress_default_origin: true,
82
+ };
83
+ case 'daemon_browser':
84
+ // A VALID `X-Daemon-Token` carried in a browser context (default Origin
85
+ // present) ALONGSIDE the keeper's session cookie. The daemon-token
86
+ // middleware discards a header-bearing daemon token as browser context
87
+ // (mirroring the bearer guard + the Rust spine's `is_browser_context`),
88
+ // so the valid token is dropped and auth falls through to the session
89
+ // leg → keeper-via-session → a daemon-gated action refuses the session
90
+ // credential with `credential_type_required`. Origin is NOT suppressed —
91
+ // its presence is the browser-context signal under test. Unlike the
92
+ // `daemon` principal (no Origin → token honored → reaches the 400 confirm
93
+ // guard), the 403 here proves the valid token was discarded, not honored.
94
+ return {
95
+ transport: fixture.transport,
96
+ headers: fixture.create_session_headers(fixture.create_daemon_token_headers()),
97
+ };
54
98
  case 'token':
55
99
  // Bearer is discarded in a browser context — empty jar + no Origin.
56
100
  return {
@@ -58,6 +102,20 @@ const resolve_principal = async (fixture, as, principals) => {
58
102
  headers: fixture.create_bearer_headers(),
59
103
  suppress_default_origin: true,
60
104
  };
105
+ case 'bearer_browser':
106
+ // A VALID bearer api-token in a browser context (default Origin
107
+ // present), fresh jar so no session rides alongside. The bearer
108
+ // middleware discards a header-bearing token as browser context
109
+ // (mirroring the daemon guard + the Rust spine's `is_browser_context`),
110
+ // so the request arrives anonymous → an authed action 401s. Origin is
111
+ // NOT suppressed (its presence is the browser-context signal) — the
112
+ // inverse of the `token` principal, which suppresses it so the same
113
+ // token is honored. The 401 proves the valid token was discarded, not
114
+ // honored (a non-discarding leg would authenticate → 200).
115
+ return {
116
+ transport: fixture.fresh_transport(),
117
+ headers: fixture.create_bearer_headers(),
118
+ };
61
119
  case 'anonymous':
62
120
  // Fresh jar so the keeper cookie (cross-process) can't leak in.
63
121
  return { transport: fixture.fresh_transport(), headers: {} };
@@ -97,6 +155,29 @@ const assert_fields = (actual, fields, label) => {
97
155
  assert.deepEqual(record[key], expected, `${label}: field '${key}'`);
98
156
  }
99
157
  };
158
+ /**
159
+ * Whether `key` is present as an own property of `value` or any object nested
160
+ * within it (arrays and objects walked recursively). The shape-agnostic test
161
+ * behind `expect.absent_fields` — a secret column leaking at any depth (inside
162
+ * a list element, a nested record) is caught, not just a top-level field.
163
+ */
164
+ const has_key_anywhere = (value, key) => {
165
+ if (Array.isArray(value))
166
+ return value.some((v) => has_key_anywhere(v, key));
167
+ if (value !== null && typeof value === 'object') {
168
+ if (Object.hasOwn(value, key))
169
+ return true;
170
+ return Object.values(value).some((v) => has_key_anywhere(v, key));
171
+ }
172
+ return false;
173
+ };
174
+ /** Assert none of the sensitive field names appear anywhere in the response body. */
175
+ const assert_absent_fields = (body, absent, label) => {
176
+ for (const key of absent) {
177
+ assert.ok(!has_key_anywhere(body, key), `${label}: response must not serialize the sensitive field '${key}' anywhere in its ` +
178
+ `body — a secret column leaked into the wire shape.`);
179
+ }
180
+ };
100
181
  /**
101
182
  * Response headers that fingerprint the backend implementation or framework.
102
183
  * Neither spine emits these; the runner asserts they stay absent on EVERY
@@ -157,10 +238,16 @@ const is_success_status = (status) => status >= 200 && status < 300;
157
238
  const run_case = async (c, options, resolved_rpc_endpoints) => {
158
239
  const fixture = await options.setup_test();
159
240
  const { transport, headers, suppress_default_origin } = await resolve_principal(fixture, c.request.as, options.principals);
160
- if (c.request.method.startsWith('/')) {
161
- return run_rest_case(c, options, transport, headers);
241
+ const normalized = c.request.method.startsWith('/')
242
+ ? await run_rest_case(c, options, transport, headers)
243
+ : await run_rpc_case(c, transport, headers, suppress_default_origin, resolved_rpc_endpoints);
244
+ // Negative-space field check, uniform across REST + RPC and success + error
245
+ // bodies: a sensitive column (`password_hash`, `token_hash`) must not appear
246
+ // anywhere in what the prober sees.
247
+ if (c.expect.absent_fields) {
248
+ assert_absent_fields(normalized.body, c.expect.absent_fields, c.name);
162
249
  }
163
- return run_rpc_case(c, transport, headers, suppress_default_origin, resolved_rpc_endpoints);
250
+ return normalized;
164
251
  };
165
252
  /** Dispatch + assert a case targeting an RPC method. */
166
253
  const run_rpc_case = async (c, transport, headers, suppress_default_origin, resolved_rpc_endpoints) => {
@@ -0,0 +1,20 @@
1
+ import '../assert_dev_env.js';
2
+ import type { SetupTest } from './setup.js';
3
+ /** Options for the session-cookie-attribute parity suite. */
4
+ export interface CookieAttributesCrossTestOptions {
5
+ /** Per-test fixture producer (cross-process only — see the module doc). */
6
+ readonly setup_test: SetupTest;
7
+ /**
8
+ * The spine's session cookie name (`handle.config.cookie_name`, e.g.
9
+ * `fuz_session`). The `Set-Cookie` under test is matched by this name so a
10
+ * future cookie-name change surfaces here as "no session cookie set" rather
11
+ * than a silent miss.
12
+ */
13
+ readonly cookie_name: string;
14
+ /** REST login route path. Default `/api/account/login`. */
15
+ readonly login_path?: string;
16
+ /** REST logout route path. Default `/api/account/logout`. */
17
+ readonly logout_path?: string;
18
+ }
19
+ export declare const describe_cookie_attributes_cross_tests: (options: CookieAttributesCrossTestOptions) => void;
20
+ //# sourceMappingURL=cookie_attributes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cookie_attributes.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/cookie_attributes.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA+C9B,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,YAAY,CAAC;AAE1C,6DAA6D;AAC7D,MAAM,WAAW,gCAAgC;IAChD,2EAA2E;IAC3E,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,2DAA2D;IAC3D,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,6DAA6D;IAC7D,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AAoED,eAAO,MAAM,sCAAsC,GAClD,SAAS,gCAAgC,KACvC,IAmFF,CAAC"}
@@ -0,0 +1,158 @@
1
+ import '../assert_dev_env.js';
2
+ /**
3
+ * Cross-backend parity suite for **session cookie attributes** over real HTTP.
4
+ *
5
+ * The session cookie's `Set-Cookie` attributes are the load-bearing browser
6
+ * security boundary: `HttpOnly` keeps the token out of JS (XSS can't read it),
7
+ * `Secure` keeps it off plaintext HTTP, `SameSite=Strict` is the primary CSRF
8
+ * defense (the Origin allowlist is only defense-in-depth), and `Path=/` scopes
9
+ * it to the whole app. A regression dropping any one of these is a real
10
+ * downgrade — and it's wire-observable, so both spines must emit the same
11
+ * hardened set. The cross-backend conformance table deliberately keeps
12
+ * `Set-Cookie` *out* of its byte-identity comparison (the signed value
13
+ * legitimately differs per request and per impl), so the attribute contract
14
+ * had no cross-backend pin: the TS spine's attributes were covered by
15
+ * `session_cookie.ts`'s own tests, but the Rust spine's `sign_session_cookie`
16
+ * / `clear_session_cookie` strings were asserted nowhere. This suite closes
17
+ * that gap on both impls by parsing the raw `Set-Cookie` and asserting the
18
+ * attributes directly. Three properties:
19
+ *
20
+ * - **successful login sets a hardened cookie** — `HttpOnly; Secure;
21
+ * SameSite=Strict; Path=/` plus a positive integer `Max-Age` (the session
22
+ * lifetime). The signed value is opaque here; only the attributes are pinned.
23
+ * - **a failed login sets no session cookie** — a denial mints no credential,
24
+ * so there is no `Set-Cookie` for the session name at all. A spine that
25
+ * leaked a (signed-but-unauthenticated) cookie on the 401 would fail here.
26
+ * - **logout clears the cookie with `Max-Age=0` and the same hardened flags** —
27
+ * the clear must not silently drop `Secure` / `HttpOnly` / `SameSite=Strict`
28
+ * (a cleared-but-unhardened `Set-Cookie` is a downgrade window).
29
+ *
30
+ * Both surfaces are flat REST (`POST /api/account/{login,logout}`) on every
31
+ * spine, so this is an imperative suite (not a `conformance_table` row) — the
32
+ * sibling of `origin.ts` / `login_security.ts`. Cross-process only: reading the
33
+ * raw `Set-Cookie` attributes needs the wire response (the in-process
34
+ * `parse_session` / `session_cookie.rs` unit tests cover each impl's cookie
35
+ * codec directly). Cited property: `docs/security.md` §"Session Security"
36
+ * (the "Cookie attributes" bullet — `HttpOnly; Secure; SameSite=Strict; Path=/`).
37
+ *
38
+ * `$lib`-free by contract (relative specifiers only), like the sibling
39
+ * cross-backend suites.
40
+ *
41
+ * @module
42
+ */
43
+ import { describe, test, assert } from 'vitest';
44
+ import { DEFAULT_TEST_PASSWORD } from '../test_credentials.js';
45
+ /**
46
+ * Parse a single `Set-Cookie` header value into `{name, value, attributes}`.
47
+ * Returns `null` for a malformed head (no `=`, empty name). Unlike the
48
+ * transport jar's `parse_set_cookie` (which drops everything after the first
49
+ * `;`), this retains the attributes — they are exactly what the suite asserts.
50
+ */
51
+ const parse_set_cookie = (raw) => {
52
+ const parts = raw.split(';');
53
+ const head = (parts[0] ?? '').trim();
54
+ const eq = head.indexOf('=');
55
+ if (eq <= 0)
56
+ return null;
57
+ const name = head.slice(0, eq).trim();
58
+ if (!name)
59
+ return null;
60
+ const value = head.slice(eq + 1);
61
+ const attributes = new Map();
62
+ for (let i = 1; i < parts.length; i++) {
63
+ const attr = parts[i].trim();
64
+ if (!attr)
65
+ continue;
66
+ const attr_eq = attr.indexOf('=');
67
+ if (attr_eq === -1) {
68
+ attributes.set(attr.toLowerCase(), '');
69
+ }
70
+ else {
71
+ attributes.set(attr.slice(0, attr_eq).trim().toLowerCase(), attr.slice(attr_eq + 1).trim());
72
+ }
73
+ }
74
+ return { name, value, attributes };
75
+ };
76
+ /**
77
+ * Find the `Set-Cookie` for the session cookie among a response's headers.
78
+ * `getSetCookie()` returns each `Set-Cookie` as its own string (the only way
79
+ * to read attributes — `Headers.get()` collapses multiple cookies to one
80
+ * comma-joined string). Returns `undefined` when no session cookie was set.
81
+ */
82
+ const find_session_cookie = (res, cookie_name) => {
83
+ for (const raw of res.headers.getSetCookie()) {
84
+ const parsed = parse_set_cookie(raw);
85
+ if (parsed && parsed.name === cookie_name)
86
+ return parsed;
87
+ }
88
+ return undefined;
89
+ };
90
+ /** Assert the hardened attribute set shared by the set + cleared cookies. */
91
+ const assert_hardened_flags = (cookie, label) => {
92
+ assert.ok(cookie.attributes.has('httponly'), `${label}: cookie must be HttpOnly (no JS read)`);
93
+ assert.ok(cookie.attributes.has('secure'), `${label}: cookie must be Secure (HTTPS-only)`);
94
+ assert.strictEqual(cookie.attributes.get('samesite')?.toLowerCase(), 'strict', `${label}: cookie must be SameSite=Strict (CSRF backstop)`);
95
+ assert.strictEqual(cookie.attributes.get('path'), '/', `${label}: cookie Path must be '/'`);
96
+ };
97
+ export const describe_cookie_attributes_cross_tests = (options) => {
98
+ const { setup_test, cookie_name } = options;
99
+ const login_path = options.login_path ?? '/api/account/login';
100
+ const logout_path = options.logout_path ?? '/api/account/logout';
101
+ // Fresh-keeper-per-test wipes the DB between tests, so a literal username
102
+ // never collides (see `setup.ts`). Each test mints it fresh.
103
+ const username = 'cookie_attr_user';
104
+ describe('session cookie attribute parity', () => {
105
+ test('successful login sets a hardened session cookie (HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age>0)', async () => {
106
+ const fixture = await setup_test();
107
+ await fixture.create_account({ username, password_value: DEFAULT_TEST_PASSWORD });
108
+ // Fresh transport so no pre-existing cookie rides along; the default
109
+ // Origin (= base_url) is allowlisted, so the login clears the Origin gate.
110
+ const res = await fixture.fresh_transport()(login_path, {
111
+ method: 'POST',
112
+ headers: { 'content-type': 'application/json' },
113
+ body: JSON.stringify({ username, password: DEFAULT_TEST_PASSWORD }),
114
+ });
115
+ assert.strictEqual(res.status, 200, 'valid credentials must log in');
116
+ const cookie = find_session_cookie(res, cookie_name);
117
+ assert.ok(cookie, `login must Set-Cookie the '${cookie_name}' session cookie`);
118
+ assert.ok(cookie.value.length > 0, 'the session cookie must carry a signed value');
119
+ assert_hardened_flags(cookie, 'login Set-Cookie');
120
+ const max_age = Number(cookie.attributes.get('max-age'));
121
+ assert.ok(Number.isInteger(max_age) && max_age > 0, `login cookie must carry a positive integer Max-Age (got ${cookie.attributes.get('max-age')})`);
122
+ });
123
+ test('failed login (wrong password) sets no session cookie', async () => {
124
+ const fixture = await setup_test();
125
+ await fixture.create_account({ username, password_value: DEFAULT_TEST_PASSWORD });
126
+ const res = await fixture.fresh_transport()(login_path, {
127
+ method: 'POST',
128
+ headers: { 'content-type': 'application/json' },
129
+ body: JSON.stringify({ username, password: `wrong-${DEFAULT_TEST_PASSWORD}` }),
130
+ });
131
+ assert.strictEqual(res.status, 401, 'wrong password must be rejected');
132
+ assert.strictEqual(find_session_cookie(res, cookie_name), undefined, 'a failed login must not Set-Cookie a session — no credential is minted on denial');
133
+ });
134
+ test('logout clears the session cookie with Max-Age=0 and hardened flags', async () => {
135
+ const fixture = await setup_test();
136
+ await fixture.create_account({ username, password_value: DEFAULT_TEST_PASSWORD });
137
+ // Log in on a fresh transport so its jar carries the session cookie into
138
+ // the logout request (logout is session-authenticated).
139
+ const transport = fixture.fresh_transport();
140
+ const login_res = await transport(login_path, {
141
+ method: 'POST',
142
+ headers: { 'content-type': 'application/json' },
143
+ body: JSON.stringify({ username, password: DEFAULT_TEST_PASSWORD }),
144
+ });
145
+ assert.strictEqual(login_res.status, 200, 'precondition: login must succeed');
146
+ const res = await transport(logout_path, {
147
+ method: 'POST',
148
+ headers: { 'content-type': 'application/json' },
149
+ body: JSON.stringify({}),
150
+ });
151
+ assert.strictEqual(res.status, 200, 'logout must succeed for an authenticated session');
152
+ const cookie = find_session_cookie(res, cookie_name);
153
+ assert.ok(cookie, `logout must Set-Cookie a cleared '${cookie_name}'`);
154
+ assert.strictEqual(cookie.attributes.get('max-age'), '0', 'logout must clear the cookie with Max-Age=0');
155
+ assert_hardened_flags(cookie, 'logout Set-Cookie');
156
+ });
157
+ });
158
+ };
@@ -0,0 +1,12 @@
1
+ import '../assert_dev_env.js';
2
+ /** Options for the credential-header robustness probe — needs the raw URL + a valid daemon token. */
3
+ export interface CredentialHeaderRobustnessCrossTestOptions {
4
+ /** Base URL the spawned backend is reachable at (e.g. `http://localhost:1178`). */
5
+ readonly base_url: string;
6
+ /** RPC endpoint path to target. Default `/api/rpc`. */
7
+ readonly rpc_path?: string;
8
+ /** A valid daemon token (keeper) — `handle.daemon_token`. */
9
+ readonly daemon_token: string;
10
+ }
11
+ export declare const describe_credential_header_robustness_cross_tests: (options: CredentialHeaderRobustnessCrossTestOptions) => void;
12
+ //# sourceMappingURL=credential_header_robustness.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"credential_header_robustness.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/credential_header_robustness.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA2D9B,qGAAqG;AACrG,MAAM,WAAW,0CAA0C;IAC1D,mFAAmF;IACnF,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,uDAAuD;IACvD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,6DAA6D;IAC7D,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;CAC9B;AAwFD,eAAO,MAAM,iDAAiD,GAC7D,SAAS,0CAA0C,KACjD,IAkHF,CAAC"}
@@ -0,0 +1,179 @@
1
+ import '../assert_dev_env.js';
2
+ /**
3
+ * Cross-backend **credential-header robustness** probe — the auth sibling of
4
+ * `body_size_smuggling.ts`.
5
+ *
6
+ * The auth middleware reads a single credential per header (`Authorization`,
7
+ * `X-Daemon-Token`). What a *duplicated*, *oversized*, or *control-char-injected*
8
+ * credential header does is framework-territory — Hono reads its headers via the
9
+ * Web `Headers` API, axum via `http::HeaderMap` — and the two could resolve a
10
+ * duplicate differently (first-vs-last), cap header size differently, or parse a
11
+ * malformed value differently. None of that is exercised over `fetch` (the
12
+ * `FetchTransport` can't emit a duplicate or malformed header), so it had no
13
+ * cross-impl pin. This raw-socket suite sends hand-framed requests and asserts
14
+ * the **security invariants that must hold regardless of how each framework
15
+ * resolves the ambiguity** — so it pins a real property without blessing one
16
+ * resolution over the other:
17
+ *
18
+ * - **a duplicated credential header never escalates** — a request carrying a
19
+ * valid keeper `X-Daemon-Token` *and* a second, conflicting one must not
20
+ * perform a keeper operation. The target omits `confirm`, so the documented
21
+ * confirm guard (`purge_not_confirmed`) makes the honored-token branch a
22
+ * guaranteed non-2xx with **zero side effect**, and the discarded-token
23
+ * branch is a non-2xx auth/credential refusal — so whichever copy the
24
+ * framework picks, the outcome is non-2xx. The same shape with two garbage
25
+ * `Authorization` headers pins the bearer path can't be escalated either.
26
+ * - **a control-char-injected credential header can't smuggle or authenticate**
27
+ * — embedded `CRLF` / bare `CR` in the value never frames a second request
28
+ * (no smuggle) and never authenticates (the truncated / rejected value is no
29
+ * token).
30
+ * - **no response desync** — each crafted request yields exactly one HTTP
31
+ * response (a duplicate header that reframed the request would surface as a
32
+ * second status line, like the body-size smuggling probe).
33
+ * - **an oversized credential header can't wedge the server** — after a request
34
+ * with a 64 KiB `X-Daemon-Token` (well past both frameworks' header caps), a
35
+ * subsequent normal request on a fresh connection still gets a response. The
36
+ * oversized request's own outcome (4xx / 431 / connection close) is
37
+ * don't-care; surviving it is the property.
38
+ *
39
+ * Raw-socket by necessity (`fetch` can't emit duplicate or malformed headers),
40
+ * so — like `body_size_smuggling.ts` — this is **cross-process only** and
41
+ * fixture-free: it needs only the base URL, the RPC path, and a valid daemon
42
+ * token (`handle.daemon_token`, kept current for the run by the same rotation
43
+ * the `_testing_reset` channel relies on). Cited property: `docs/security.md`
44
+ * §"Credential Type Hierarchy" (a leaked / conflicting credential header can't
45
+ * escalate the credential ceiling) + §"API Token Security" (bearer handling).
46
+ *
47
+ * `$lib`-free by contract (relative + `node:` specifiers only).
48
+ *
49
+ * @module
50
+ */
51
+ import { connect } from 'node:net';
52
+ import { describe, test, assert } from 'vitest';
53
+ import { DAEMON_TOKEN_HEADER } from '../../auth/daemon_token.js';
54
+ import { SPINE_RPC_PATH } from './default_spine_surface.js';
55
+ /** A well-formed UUID that never names a real row. */
56
+ const NIL_UUID = '00000000-0000-0000-0000-000000000000';
57
+ /** A value that is never a real credential — used for the conflicting / garbage copies. */
58
+ const GARBAGE_TOKEN = 'not-a-real-credential-value';
59
+ /**
60
+ * Open a raw TCP socket to `base_url`, write `request_bytes` once, and collect
61
+ * everything the server sends back until it closes or `read_timeout_ms`
62
+ * elapses. Write errors are swallowed (a server closing mid-write on a rejected
63
+ * request is expected behavior); what matters is what we read back. Mirrors
64
+ * `body_size_smuggling.ts`'s `send_raw`.
65
+ */
66
+ const send_raw = (base_url, request_bytes, read_timeout_ms) => new Promise((resolve) => {
67
+ const url = new URL(base_url);
68
+ const port = Number(url.port) || (url.protocol === 'https:' ? 443 : 80);
69
+ const socket = connect({ host: url.hostname, port });
70
+ let received = '';
71
+ let settled = false;
72
+ const finish = () => {
73
+ if (settled)
74
+ return;
75
+ settled = true;
76
+ clearTimeout(timer);
77
+ socket.destroy();
78
+ resolve(received);
79
+ };
80
+ const timer = setTimeout(finish, read_timeout_ms);
81
+ socket.setEncoding('latin1');
82
+ socket.on('connect', () => socket.write(request_bytes));
83
+ socket.on('data', (chunk) => {
84
+ received += chunk;
85
+ });
86
+ socket.on('error', () => { }); // EPIPE/ECONNRESET on mid-write close is expected
87
+ socket.on('close', finish);
88
+ });
89
+ /**
90
+ * Count HTTP response status lines in a raw byte stream. Unanchored so a
91
+ * second (smuggled) response concatenated after the first body — which carries
92
+ * no trailing newline — is still counted; see `body_size_smuggling.ts`.
93
+ */
94
+ const count_responses = (raw) => (raw.match(/HTTP\/1\.[01] \d{3}/g) ?? []).length;
95
+ /** The status code of the first HTTP response in a raw stream, or `null` if none. */
96
+ const first_status = (raw) => {
97
+ const match = /HTTP\/1\.[01] (\d{3})/.exec(raw);
98
+ return match ? Number(match[1]) : null;
99
+ };
100
+ const is_success_status = (status) => status >= 200 && status < 300;
101
+ /**
102
+ * Frame a raw HTTP/1.1 POST with the given extra header lines + JSON body.
103
+ * `Connection: close` so the server closes after one response (the response
104
+ * count is then unambiguous). The body is ASCII JSON, so `body.length` is the
105
+ * byte length — no `Buffer` needed.
106
+ */
107
+ const rpc_post = (host, path, header_lines, body) => `POST ${path} HTTP/1.1\r\n` +
108
+ `Host: ${host}\r\n` +
109
+ `Content-Type: application/json\r\n` +
110
+ header_lines.map((h) => `${h}\r\n`).join('') +
111
+ `Content-Length: ${body.length}\r\n` +
112
+ `Connection: close\r\n` +
113
+ `\r\n` +
114
+ body;
115
+ /**
116
+ * A keeper-gated `account_purge` body with `confirm` OMITTED — the confirm
117
+ * guard (`purge_not_confirmed`, before any deletion) guarantees the
118
+ * valid-credential branch is a non-2xx with zero side effect, so the
119
+ * no-escalation assertion holds regardless of which duplicate header wins.
120
+ */
121
+ const purge_body = (id) => JSON.stringify({ jsonrpc: '2.0', method: 'account_purge', id, params: { account_id: NIL_UUID } });
122
+ export const describe_credential_header_robustness_cross_tests = (options) => {
123
+ const { base_url, daemon_token } = options;
124
+ const rpc_path = options.rpc_path ?? SPINE_RPC_PATH;
125
+ const host = new URL(base_url).host;
126
+ describe('credential-header robustness', () => {
127
+ test('duplicate X-Daemon-Token (valid + conflicting) never escalates → one non-2xx response', async () => {
128
+ const req = rpc_post(host, rpc_path, [`${DAEMON_TOKEN_HEADER}: ${daemon_token}`, `${DAEMON_TOKEN_HEADER}: ${GARBAGE_TOKEN}`], purge_body('dup-daemon'));
129
+ const raw = await send_raw(base_url, req, 2000);
130
+ assert.strictEqual(count_responses(raw), 1, `a duplicated X-Daemon-Token must yield exactly one response (no desync). Raw head: ${raw.slice(0, 120)}`);
131
+ const status = first_status(raw);
132
+ assert.ok(status !== null, `expected an HTTP response. Raw head: ${raw.slice(0, 120)}`);
133
+ assert.ok(!is_success_status(status), `a duplicated daemon token must not perform a keeper operation; whichever copy wins, ` +
134
+ `the result must be non-2xx (got ${status})`);
135
+ });
136
+ test('duplicate Authorization bearer headers (both garbage) do not authenticate → one non-2xx response', async () => {
137
+ const req = rpc_post(host, rpc_path, [`Authorization: Bearer ${GARBAGE_TOKEN}`, `Authorization: Bearer ${GARBAGE_TOKEN}-2`], purge_body('dup-bearer'));
138
+ const raw = await send_raw(base_url, req, 2000);
139
+ assert.strictEqual(count_responses(raw), 1, `duplicate Authorization headers must yield exactly one response (no desync). Raw head: ${raw.slice(0, 120)}`);
140
+ const status = first_status(raw);
141
+ assert.ok(status !== null, `expected an HTTP response. Raw head: ${raw.slice(0, 120)}`);
142
+ assert.ok(!is_success_status(status), `garbage bearer tokens (even duplicated) must not authenticate (got ${status})`);
143
+ });
144
+ test('control-char-injected credential header (CR/LF) cannot smuggle or authenticate', async () => {
145
+ // Embedded CRLF and a bare CR (header-splitting attempts) in a garbage
146
+ // `X-Daemon-Token` value. A strict HTTP parser rejects the request
147
+ // (400 / connection close); a lenient one truncates the value to a
148
+ // non-token or treats the injected line as an extra (harmless) header.
149
+ // Either way the framework-agnostic invariants hold: the injected bytes
150
+ // never frame a SECOND request (no smuggle), and the garbage credential
151
+ // never authenticates (no 2xx). `<= 1` (not `=== 1`) because a strict
152
+ // parser may close without a readable response — that's also "no smuggle".
153
+ const malformed = [
154
+ { label: 'CRLF header-split', value: `${GARBAGE_TOKEN}\r\nX-Injected: 1` },
155
+ { label: 'bare CR', value: `${GARBAGE_TOKEN}\rX-Injected: 1` },
156
+ ];
157
+ for (const { label, value } of malformed) {
158
+ const req = rpc_post(host, rpc_path, [`${DAEMON_TOKEN_HEADER}: ${value}`], purge_body('ctrl-char'));
159
+ const raw = await send_raw(base_url, req, 2000);
160
+ assert.ok(count_responses(raw) <= 1, `${label}: a control-char-injected credential header must not frame a second ` +
161
+ `request (no smuggle). Raw head: ${raw.slice(0, 120)}`);
162
+ const status = first_status(raw);
163
+ assert.ok(status === null || !is_success_status(status), `${label}: a malformed credential header must never authenticate (got ${status})`);
164
+ }
165
+ });
166
+ test('oversized credential header does not crash or wedge the server', async () => {
167
+ // 64 KiB — well past both frameworks' header-size caps, so the request is
168
+ // rejected (4xx / 431 / connection close). Its own outcome is don't-care.
169
+ const oversized = 'a'.repeat(64 * 1024);
170
+ const req = rpc_post(host, rpc_path, [`${DAEMON_TOKEN_HEADER}: ${oversized}`], purge_body('oversized'));
171
+ await send_raw(base_url, req, 2000);
172
+ // The property: the server survives it. A normal request on a fresh
173
+ // connection must still get a response (no crash, no wedged accept loop).
174
+ const probe = `GET ${rpc_path} HTTP/1.1\r\nHost: ${host}\r\nConnection: close\r\n\r\n`;
175
+ const raw = await send_raw(base_url, probe, 2000);
176
+ assert.ok(first_status(raw) !== null, `server must still respond after an oversized-header request (no crash / wedge). Raw head: ${raw.slice(0, 120)}`);
177
+ });
178
+ });
179
+ };
@@ -1 +1 @@
1
- {"version":3,"file":"default_backend_configs.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/default_backend_configs.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,KAAK,EAAC,sBAAsB,EAAE,aAAa,EAAC,MAAM,qBAAqB,CAAC;AAC/E,OAAO,KAAK,EAAC,mBAAmB,EAAE,iBAAiB,EAAC,MAAM,mBAAmB,CAAC;AAC9E,OAAO,EAA2B,KAAK,gBAAgB,EAAC,MAAM,+BAA+B,CAAC;AAQ9F;;;;;;;;;;GAUG;AACH,eAAO,MAAM,4BAA4B,iCAAiC,CAAC;AAE3E;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,EAAE,mBAqBpC,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,EAAE,mBAmBtC,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,EAAE,iBAInC,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,EAAE,iBAIrC,CAAC;AAeH,MAAM,WAAW,iCAAiC;IACjD,gFAAgF;IAChF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,sCAAsC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,4DAA4D;IAC5D,QAAQ,CAAC,aAAa,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC9C,oDAAoD;IACpD,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,iEAAiE;IACjE,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACtD,6CAA6C;IAC7C,QAAQ,CAAC,YAAY,CAAC,EAAE,mBAAmB,CAAC;IAC5C,wEAAwE;IACxE,QAAQ,CAAC,KAAK,CAAC,EAAE,gBAAgB,CAAC;IAClC,sEAAsE;IACtE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAC/D;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;;;OAMG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;GAKG;AACH,eAAO,MAAM,8BAA8B,GAC1C,MAAM,iCAAiC,KACrC,aAoCF,CAAC;AAEF,MAAM,WAAW,mCAAmC;IACnD,gFAAgF;IAChF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,sCAAsC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,4DAA4D;IAC5D,QAAQ,CAAC,aAAa,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC9C;;;;OAIG;IACH,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,iEAAiE;IACjE,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACtD,+CAA+C;IAC/C,QAAQ,CAAC,YAAY,CAAC,EAAE,mBAAmB,CAAC;IAC5C,wEAAwE;IACxE,QAAQ,CAAC,KAAK,CAAC,EAAE,gBAAgB,CAAC;IAClC,sEAAsE;IACtE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAC/D;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;OAGG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;GAKG;AACH,eAAO,MAAM,gCAAgC,GAC5C,MAAM,mCAAmC,KACvC,aA4CF,CAAC"}
1
+ {"version":3,"file":"default_backend_configs.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/default_backend_configs.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,KAAK,EAAC,sBAAsB,EAAE,aAAa,EAAC,MAAM,qBAAqB,CAAC;AAC/E,OAAO,KAAK,EAAC,mBAAmB,EAAE,iBAAiB,EAAC,MAAM,mBAAmB,CAAC;AAC9E,OAAO,EAA2B,KAAK,gBAAgB,EAAC,MAAM,+BAA+B,CAAC;AAQ9F;;;;;;;;;;GAUG;AACH,eAAO,MAAM,4BAA4B,iCAAiC,CAAC;AAE3E;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,EAAE,mBAuBpC,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,EAAE,mBAmBtC,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,EAAE,iBAInC,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,EAAE,iBAIrC,CAAC;AAeH,MAAM,WAAW,iCAAiC;IACjD,gFAAgF;IAChF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,sCAAsC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,4DAA4D;IAC5D,QAAQ,CAAC,aAAa,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC9C,oDAAoD;IACpD,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,iEAAiE;IACjE,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACtD,6CAA6C;IAC7C,QAAQ,CAAC,YAAY,CAAC,EAAE,mBAAmB,CAAC;IAC5C,wEAAwE;IACxE,QAAQ,CAAC,KAAK,CAAC,EAAE,gBAAgB,CAAC;IAClC,sEAAsE;IACtE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAC/D;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;;;OAMG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;GAKG;AACH,eAAO,MAAM,8BAA8B,GAC1C,MAAM,iCAAiC,KACrC,aAoCF,CAAC;AAEF,MAAM,WAAW,mCAAmC;IACnD,gFAAgF;IAChF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,sCAAsC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,4DAA4D;IAC5D,QAAQ,CAAC,aAAa,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC9C;;;;OAIG;IACH,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,iEAAiE;IACjE,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACtD,+CAA+C;IAC/C,QAAQ,CAAC,YAAY,CAAC,EAAE,mBAAmB,CAAC;IAC5C,wEAAwE;IACxE,QAAQ,CAAC,KAAK,CAAC,EAAE,gBAAgB,CAAC;IAClC,sEAAsE;IACtE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAC/D;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;OAGG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;GAKG;AACH,eAAO,MAAM,gCAAgC,GAC5C,MAAM,mCAAmC,KACvC,aA4CF,CAAC"}
@@ -35,9 +35,11 @@ export const ts_default_capabilities = Object.freeze({
35
35
  // posture). A Bun-served consumer overrides to `false` (see the bun spine
36
36
  // config) — fail-loud rather than silently skipping the smuggle detector.
37
37
  oversized_reject_closes_connection: true,
38
- // The TS server can't yet initiate server→client requests (the
39
- // `BackendWebsocketTransport.send()` request path is the deferred
40
- // twin-impl convergence), so the TS family skips the peer/ping suite.
38
+ // Off by default like `sse`/`ready`: the WS backend transport *can* initiate
39
+ // server→client requests (`BackendWebsocketTransport.request_connection`),
40
+ // but driving the full `peer/ping` suite also needs `peer/ping` mounted on
41
+ // the HTTP RPC endpoint (the no-transport case). The TS spine config opts in;
42
+ // a generic consumer enables it once it wires that mount.
41
43
  peer_request: false,
42
44
  });
43
45
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"fact_serving.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/fact_serving.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAiD9B,OAAO,KAAK,EAAC,wBAAwB,EAAE,SAAS,EAAC,MAAM,YAAY,CAAC;AAEpE;;;;;GAKG;AACH,MAAM,WAAW,2BAA4B,SAAQ,wBAAwB;IAC5E,QAAQ,CAAC,sBAAsB,CAAC,EAAE,SAAS,CAAC;CAC5C;AA8BD,eAAO,MAAM,iCAAiC,GAAI,SAAS,2BAA2B,KAAG,IA8NxF,CAAC"}
1
+ {"version":3,"file":"fact_serving.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/fact_serving.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAiD9B,OAAO,KAAK,EAAC,wBAAwB,EAAE,SAAS,EAAC,MAAM,YAAY,CAAC;AAEpE;;;;;GAKG;AACH,MAAM,WAAW,2BAA4B,SAAQ,wBAAwB;IAC5E,QAAQ,CAAC,sBAAsB,CAAC,EAAE,SAAS,CAAC;CAC5C;AA8BD,eAAO,MAAM,iCAAiC,GAAI,SAAS,2BAA2B,KAAG,IAiOxF,CAAC"}