@fuzdev/fuz_app 0.111.0 → 0.113.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 (212) hide show
  1. package/dist/actions/CLAUDE.md +15 -11
  2. package/dist/actions/action_rpc.d.ts +1 -1
  3. package/dist/actions/action_rpc.d.ts.map +1 -1
  4. package/dist/actions/action_rpc.js +4 -6
  5. package/dist/actions/peer_ping.d.ts +8 -0
  6. package/dist/actions/peer_ping.d.ts.map +1 -1
  7. package/dist/actions/peer_ping.js +8 -0
  8. package/dist/actions/peer_request.d.ts +1 -1
  9. package/dist/actions/peer_request.js +1 -1
  10. package/dist/actions/perform_action.d.ts +16 -9
  11. package/dist/actions/perform_action.d.ts.map +1 -1
  12. package/dist/actions/perform_action.js +30 -21
  13. package/dist/actions/register_action_ws.d.ts +1 -1
  14. package/dist/actions/register_action_ws.d.ts.map +1 -1
  15. package/dist/actions/register_action_ws.js +88 -21
  16. package/dist/auth/CLAUDE.md +43 -8
  17. package/dist/auth/account_actions.d.ts.map +1 -1
  18. package/dist/auth/account_actions.js +10 -1
  19. package/dist/auth/account_queries.d.ts.map +1 -1
  20. package/dist/auth/account_queries.js +36 -16
  21. package/dist/auth/account_schema.d.ts +20 -0
  22. package/dist/auth/account_schema.d.ts.map +1 -1
  23. package/dist/auth/account_schema.js +22 -1
  24. package/dist/auth/actor_search_actions.d.ts +3 -3
  25. package/dist/auth/actor_search_actions.d.ts.map +1 -1
  26. package/dist/auth/actor_search_actions.js +10 -6
  27. package/dist/auth/api_token_queries.d.ts.map +1 -1
  28. package/dist/auth/api_token_queries.js +11 -5
  29. package/dist/auth/app_settings_queries.d.ts.map +1 -1
  30. package/dist/auth/app_settings_queries.js +6 -4
  31. package/dist/auth/audit_log_queries.d.ts +6 -0
  32. package/dist/auth/audit_log_queries.d.ts.map +1 -1
  33. package/dist/auth/audit_log_queries.js +11 -5
  34. package/dist/auth/audit_log_route_schema.d.ts.map +1 -1
  35. package/dist/auth/audit_log_route_schema.js +12 -1
  36. package/dist/auth/cell_actions.d.ts.map +1 -1
  37. package/dist/auth/cell_actions.js +3 -8
  38. package/dist/auth/cell_audit_actions.d.ts.map +1 -1
  39. package/dist/auth/cell_audit_actions.js +1 -1
  40. package/dist/auth/cell_field_actions.js +1 -1
  41. package/dist/auth/cell_grant_actions.js +1 -1
  42. package/dist/auth/cell_item_actions.js +1 -1
  43. package/dist/auth/invite_queries.d.ts.map +1 -1
  44. package/dist/auth/invite_queries.js +10 -8
  45. package/dist/auth/role_grant_offer_action_specs.d.ts +2 -1
  46. package/dist/auth/role_grant_offer_action_specs.d.ts.map +1 -1
  47. package/dist/auth/role_grant_offer_action_specs.js +2 -1
  48. package/dist/auth/role_grant_offer_ddl.d.ts +11 -0
  49. package/dist/auth/role_grant_offer_ddl.d.ts.map +1 -1
  50. package/dist/auth/role_grant_offer_ddl.js +20 -2
  51. package/dist/auth/role_grant_offer_queries.d.ts.map +1 -1
  52. package/dist/auth/role_grant_offer_queries.js +29 -15
  53. package/dist/auth/role_grant_queries.d.ts.map +1 -1
  54. package/dist/auth/role_grant_queries.js +21 -7
  55. package/dist/auth/session_queries.d.ts.map +1 -1
  56. package/dist/auth/session_queries.js +9 -4
  57. package/dist/db/CLAUDE.md +21 -2
  58. package/dist/db/cell_audit_queries.js +2 -2
  59. package/dist/db/cell_field_queries.d.ts +1 -1
  60. package/dist/db/cell_field_queries.d.ts.map +1 -1
  61. package/dist/db/cell_field_queries.js +8 -6
  62. package/dist/db/cell_grant_queries.d.ts +1 -1
  63. package/dist/db/cell_grant_queries.d.ts.map +1 -1
  64. package/dist/db/cell_grant_queries.js +12 -10
  65. package/dist/db/cell_item_queries.d.ts +1 -1
  66. package/dist/db/cell_item_queries.d.ts.map +1 -1
  67. package/dist/db/cell_item_queries.js +9 -7
  68. package/dist/db/cell_queries.d.ts +3 -3
  69. package/dist/db/cell_queries.d.ts.map +1 -1
  70. package/dist/db/cell_queries.js +6 -4
  71. package/dist/db/fact_disk_storage.js +1 -1
  72. package/dist/db/fact_queries.d.ts.map +1 -1
  73. package/dist/db/fact_queries.js +9 -3
  74. package/dist/db/sql_columns.d.ts +80 -3
  75. package/dist/db/sql_columns.d.ts.map +1 -1
  76. package/dist/db/sql_columns.js +91 -4
  77. package/dist/http/CLAUDE.md +6 -4
  78. package/dist/http/auth_shape.d.ts +19 -6
  79. package/dist/http/auth_shape.d.ts.map +1 -1
  80. package/dist/http/auth_shape.js +19 -6
  81. package/dist/http/jsonrpc_helpers.d.ts +13 -0
  82. package/dist/http/jsonrpc_helpers.d.ts.map +1 -1
  83. package/dist/http/jsonrpc_helpers.js +13 -0
  84. package/dist/http/surface_query.d.ts +29 -4
  85. package/dist/http/surface_query.d.ts.map +1 -1
  86. package/dist/http/surface_query.js +30 -5
  87. package/dist/realtime/sse_auth_guard.d.ts +9 -3
  88. package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
  89. package/dist/realtime/sse_auth_guard.js +9 -3
  90. package/dist/server/serve_fact_route.d.ts.map +1 -1
  91. package/dist/server/serve_fact_route.js +7 -2
  92. package/dist/testing/CLAUDE.md +54 -28
  93. package/dist/testing/admin_integration.d.ts +0 -3
  94. package/dist/testing/admin_integration.d.ts.map +1 -1
  95. package/dist/testing/admin_integration.js +0 -1
  96. package/dist/testing/adversarial_404.d.ts.map +1 -1
  97. package/dist/testing/adversarial_404.js +4 -1
  98. package/dist/testing/adversarial_input.d.ts.map +1 -1
  99. package/dist/testing/adversarial_input.js +5 -4
  100. package/dist/testing/app_server.d.ts +4 -3
  101. package/dist/testing/app_server.d.ts.map +1 -1
  102. package/dist/testing/app_server.js +11 -5
  103. package/dist/testing/attack_surface.d.ts +21 -2
  104. package/dist/testing/attack_surface.d.ts.map +1 -1
  105. package/dist/testing/attack_surface.js +78 -27
  106. package/dist/testing/audit_completeness.d.ts +0 -3
  107. package/dist/testing/audit_completeness.d.ts.map +1 -1
  108. package/dist/testing/audit_completeness.js +0 -1
  109. package/dist/testing/auth_apps.d.ts +34 -2
  110. package/dist/testing/auth_apps.d.ts.map +1 -1
  111. package/dist/testing/auth_apps.js +43 -7
  112. package/dist/testing/cross_backend/account_lifecycle.d.ts +5 -4
  113. package/dist/testing/cross_backend/account_lifecycle.d.ts.map +1 -1
  114. package/dist/testing/cross_backend/account_lifecycle.js +7 -0
  115. package/dist/testing/cross_backend/action_manifest.d.ts +19 -3
  116. package/dist/testing/cross_backend/action_manifest.d.ts.map +1 -1
  117. package/dist/testing/cross_backend/action_manifest.js +10 -4
  118. package/dist/testing/cross_backend/action_manifest_parity.d.ts +9 -3
  119. package/dist/testing/cross_backend/action_manifest_parity.d.ts.map +1 -1
  120. package/dist/testing/cross_backend/action_manifest_parity.js +6 -0
  121. package/dist/testing/cross_backend/actor_lookup.d.ts +3 -2
  122. package/dist/testing/cross_backend/actor_lookup.d.ts.map +1 -1
  123. package/dist/testing/cross_backend/body_size.d.ts +4 -2
  124. package/dist/testing/cross_backend/body_size.d.ts.map +1 -1
  125. package/dist/testing/cross_backend/body_size.js +1 -1
  126. package/dist/testing/cross_backend/cell_crud.d.ts +2 -2
  127. package/dist/testing/cross_backend/cell_crud.d.ts.map +1 -1
  128. package/dist/testing/cross_backend/cell_crud.js +10 -0
  129. package/dist/testing/cross_backend/cell_gated_create.d.ts +3 -3
  130. package/dist/testing/cross_backend/cell_gated_create.d.ts.map +1 -1
  131. package/dist/testing/cross_backend/cell_grant_role.d.ts +2 -2
  132. package/dist/testing/cross_backend/cell_grant_role.d.ts.map +1 -1
  133. package/dist/testing/cross_backend/cell_relations.d.ts +2 -2
  134. package/dist/testing/cross_backend/cell_relations.d.ts.map +1 -1
  135. package/dist/testing/cross_backend/fact_serving.d.ts +2 -2
  136. package/dist/testing/cross_backend/fact_serving.d.ts.map +1 -1
  137. package/dist/testing/cross_backend/make_cross_backend_project.d.ts +17 -2
  138. package/dist/testing/cross_backend/make_cross_backend_project.d.ts.map +1 -1
  139. package/dist/testing/cross_backend/make_cross_backend_project.js +23 -3
  140. package/dist/testing/cross_backend/origin.d.ts +4 -2
  141. package/dist/testing/cross_backend/origin.d.ts.map +1 -1
  142. package/dist/testing/cross_backend/origin.js +1 -1
  143. package/dist/testing/cross_backend/peer_ping_ws.d.ts.map +1 -1
  144. package/dist/testing/cross_backend/peer_ping_ws.js +11 -7
  145. package/dist/testing/cross_backend/query_shape.d.ts +6 -5
  146. package/dist/testing/cross_backend/query_shape.d.ts.map +1 -1
  147. package/dist/testing/cross_backend/ready.d.ts +2 -2
  148. package/dist/testing/cross_backend/ready.d.ts.map +1 -1
  149. package/dist/testing/cross_backend/role_grant_offer_enumeration.d.ts.map +1 -1
  150. package/dist/testing/cross_backend/role_grant_offer_enumeration.js +7 -1
  151. package/dist/testing/cross_backend/setup.d.ts +26 -6
  152. package/dist/testing/cross_backend/setup.d.ts.map +1 -1
  153. package/dist/testing/cross_backend/standard.d.ts +5 -1
  154. package/dist/testing/cross_backend/standard.d.ts.map +1 -1
  155. package/dist/testing/cross_backend/standard.js +1 -5
  156. package/dist/testing/cross_backend/testing_backdoor.js +1 -1
  157. package/dist/testing/cross_backend/testing_reset_actions.d.ts +5 -0
  158. package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
  159. package/dist/testing/cross_backend/testing_reset_actions.js +3 -2
  160. package/dist/testing/cross_backend/token_lifetime.d.ts.map +1 -1
  161. package/dist/testing/cross_backend/token_lifetime.js +6 -0
  162. package/dist/testing/cross_backend/token_scope_surface.d.ts +2 -2
  163. package/dist/testing/cross_backend/token_scope_surface.d.ts.map +1 -1
  164. package/dist/testing/cross_backend/token_scope_surface.js +36 -7
  165. package/dist/testing/cross_backend/wire_shapes.d.ts +22 -0
  166. package/dist/testing/cross_backend/wire_shapes.d.ts.map +1 -0
  167. package/dist/testing/cross_backend/wire_shapes.js +41 -0
  168. package/dist/testing/data_exposure.d.ts +0 -3
  169. package/dist/testing/data_exposure.d.ts.map +1 -1
  170. package/dist/testing/data_exposure.js +0 -1
  171. package/dist/testing/db.d.ts +44 -7
  172. package/dist/testing/db.d.ts.map +1 -1
  173. package/dist/testing/db.js +62 -27
  174. package/dist/testing/integration.d.ts.map +1 -1
  175. package/dist/testing/integration.js +15 -2
  176. package/dist/testing/round_trip.d.ts +0 -3
  177. package/dist/testing/round_trip.d.ts.map +1 -1
  178. package/dist/testing/round_trip.js +0 -4
  179. package/dist/testing/rpc_attack_surface.js +2 -2
  180. package/dist/testing/rpc_round_trip.d.ts +0 -3
  181. package/dist/testing/rpc_round_trip.d.ts.map +1 -1
  182. package/dist/testing/rpc_round_trip.js +0 -1
  183. package/dist/testing/schema_generators.d.ts +15 -0
  184. package/dist/testing/schema_generators.d.ts.map +1 -1
  185. package/dist/testing/schema_generators.js +45 -11
  186. package/dist/testing/standard.d.ts +5 -1
  187. package/dist/testing/standard.d.ts.map +1 -1
  188. package/dist/testing/standard.js +2 -7
  189. package/dist/testing/surface_invariants.d.ts +7 -1
  190. package/dist/testing/surface_invariants.d.ts.map +1 -1
  191. package/dist/testing/surface_invariants.js +11 -5
  192. package/dist/timestamp.d.ts +58 -0
  193. package/dist/timestamp.d.ts.map +1 -0
  194. package/dist/timestamp.js +57 -0
  195. package/dist/ui/AdminAccounts.svelte +6 -4
  196. package/dist/ui/AdminAccounts.svelte.d.ts.map +1 -1
  197. package/dist/ui/AdminInvites.svelte +3 -3
  198. package/dist/ui/AdminInvites.svelte.d.ts.map +1 -1
  199. package/dist/ui/AdminOverview.svelte +10 -10
  200. package/dist/ui/AdminOverview.svelte.d.ts.map +1 -1
  201. package/dist/ui/AdminRoleGrantHistory.svelte +7 -7
  202. package/dist/ui/AdminRoleGrantHistory.svelte.d.ts.map +1 -1
  203. package/dist/ui/AdminSettings.svelte +1 -1
  204. package/dist/ui/RoleGrantOfferHistory.svelte +3 -1
  205. package/dist/ui/RoleGrantOfferHistory.svelte.d.ts.map +1 -1
  206. package/dist/ui/RoleGrantOfferInbox.svelte +4 -1
  207. package/dist/ui/RoleGrantOfferInbox.svelte.d.ts.map +1 -1
  208. package/dist/ui/SurfaceExplorer.svelte +33 -17
  209. package/dist/ui/SurfaceExplorer.svelte.d.ts.map +1 -1
  210. package/dist/ui/role_grant_offers_state.svelte.d.ts.map +1 -1
  211. package/dist/ui/role_grant_offers_state.svelte.js +6 -2
  212. package/package.json +10 -10
@@ -18,11 +18,14 @@ import { test, assert, describe } from 'vitest';
18
18
  import { assert_surface_invariants, assert_rpc_ws_surface_invariants, assert_surface_security_policy, audit_error_schema_tightness, assert_error_schema_tightness, default_error_schema_tightness, fuz_app_stock_route_tightness_allowlist } from './surface_invariants.js';
19
19
  import { describe_adversarial_input } from './adversarial_input.js';
20
20
  import { describe_adversarial_404 } from './adversarial_404.js';
21
- import { create_test_app_from_specs, create_test_request_context, create_auth_test_apps, select_auth_app, resolve_test_path } from './auth_apps.js';
21
+ import { create_auth_test_apps, create_route_skip_filter, select_auth_app } from './auth_apps.js';
22
+ import { resolve_valid_path } from './schema_generators.js';
22
23
  import { assert_surface_matches_snapshot, assert_surface_deterministic, assert_only_expected_public_routes, assert_full_middleware_stack, assert_error_schema_valid } from './assertions.js';
23
24
  import { merge_error_schemas } from '../http/schema_helpers.js';
24
25
  import { collect_middleware_errors } from '../http/surface.js';
25
- import { filter_protected_routes, filter_role_routes, filter_keeper_routes } from '../http/surface_query.js';
26
+ import { filter_protected_routes, filter_role_routes, filter_credential_gated_routes } from '../http/surface_query.js';
27
+ import { CREDENTIAL_TYPES } from '../hono_context.js';
28
+ import { CREDENTIAL_TYPE_SESSION } from '../auth/credential_type_schema.js';
26
29
  import { ERROR_AUTHENTICATION_REQUIRED, ERROR_INSUFFICIENT_PERMISSIONS, ERROR_CREDENTIAL_TYPE_REQUIRED } from '../http/error_schemas.js';
27
30
  // --- Adversarial test runner ---
28
31
  /**
@@ -45,31 +48,54 @@ const build_error_schema_lookup = (route_specs, middleware_specs) => {
45
48
  }
46
49
  return lookup;
47
50
  };
51
+ /**
52
+ * Build a lookup from `"METHOD /path"` to the route's own `RouteSpec` — the
53
+ * companion of `build_error_schema_lookup`, so a generated request can reach
54
+ * the route's `params` schema and synthesize path segments the route will
55
+ * actually accept.
56
+ */
57
+ const build_route_spec_lookup = (route_specs) => {
58
+ const lookup = new Map();
59
+ for (const spec of route_specs) {
60
+ lookup.set(`${spec.method} ${spec.path}`, spec);
61
+ }
62
+ return lookup;
63
+ };
48
64
  /**
49
65
  * Generate adversarial HTTP auth enforcement test suites.
50
66
  *
51
67
  * Describe blocks:
52
68
  * - unauthenticated → 401 — every protected route
53
- * - wrong role → 403 — every role route, tested with all non-matching roles
54
- * - authenticated without role → 403 — every role route, no-role context
69
+ * - wrong role → 403 — every role route the role apps' session credential
70
+ * reaches, tested with all non-matching roles
71
+ * - authenticated without role → 403 — the same routes, no-role context
72
+ * - credential-gated routes reject a disallowed credential → 403 — every
73
+ * route declaring `auth.credential_types`, probed on a channel it refuses
55
74
  * - correct auth passes guard — every protected route, assert not 401/403
56
75
  */
57
76
  export const describe_adversarial_auth = (options) => {
58
77
  const { build, roles } = options;
59
78
  const { surface, route_specs, middleware_specs } = build();
60
- const protected_routes = filter_protected_routes(surface);
79
+ const is_skipped = create_route_skip_filter(options.skip_routes);
80
+ const protected_routes = filter_protected_routes(surface).filter((r) => !is_skipped(r));
61
81
  if (protected_routes.length === 0)
62
82
  return;
63
- const role_routes = filter_role_routes(surface);
64
- const keeper_routes = filter_keeper_routes(surface);
83
+ const role_routes = filter_role_routes(surface).filter((r) => !is_skipped(r));
84
+ const credential_gated_routes = filter_credential_gated_routes(surface).filter((r) => !is_skipped(r));
65
85
  // merged error schemas (auto-derived + middleware + handler-specific) for response validation
66
86
  const error_schema_lookup = build_error_schema_lookup(route_specs, middleware_specs);
87
+ // The route's own spec, so a path param with a declared format (a
88
+ // `blake3:…` fact hash, a `tok_…` id) is synthesized into a value params
89
+ // validation accepts. Otherwise the generated request 400s on the path
90
+ // before ever reaching the 401 / 403 gate the case is asserting.
91
+ const spec_lookup = build_route_spec_lookup(route_specs);
92
+ const test_path = (route) => resolve_valid_path(route.path, spec_lookup.get(`${route.method} ${route.path}`)?.params);
67
93
  const apps = create_auth_test_apps(route_specs, roles);
68
94
  describe('adversarial HTTP auth enforcement', () => {
69
95
  describe('unauthenticated → 401', () => {
70
96
  for (const route of protected_routes) {
71
97
  test(`${route.method} ${route.path}`, async () => {
72
- const res = await apps.public.request(resolve_test_path(route.path), {
98
+ const res = await apps.public.request(test_path(route), {
73
99
  method: route.method
74
100
  });
75
101
  assert.strictEqual(res.status, 401, `${route.method} ${route.path}`);
@@ -79,10 +105,17 @@ export const describe_adversarial_auth = (options) => {
79
105
  });
80
106
  }
81
107
  });
82
- // Role-only routes (no credential gate). Keeper routes have a credential
83
- // gate that fires before the role gate, so they get tested separately
84
- // in the keeper block below.
85
- const role_only_routes = role_routes.filter((r) => !(r.auth.credential_types?.length ?? 0));
108
+ // Role routes the role apps can actually reach. Those apps carry a
109
+ // session credential, so a route gated to another channel (keeper) is
110
+ // refused by the pre-authorization credential gate before its role gate
111
+ // ever runs — probing it here would assert `insufficient_permissions`
112
+ // and see `credential_type_required`. A route gated *to* the session
113
+ // channel (the audit SSE stream) stays in: its role gate is reachable,
114
+ // and dropping it would silently retire this coverage the day a route
115
+ // gained a session gate. Either way the credential block below probes
116
+ // the gate itself.
117
+ const role_only_routes = role_routes.filter((r) => !r.auth.credential_types?.length ||
118
+ r.auth.credential_types.includes(CREDENTIAL_TYPE_SESSION));
86
119
  if (role_only_routes.length > 0) {
87
120
  describe('wrong role → 403', () => {
88
121
  for (const route of role_only_routes) {
@@ -93,7 +126,7 @@ export const describe_adversarial_auth = (options) => {
93
126
  const app = apps.by_role.get(wrong_role);
94
127
  if (!app)
95
128
  throw new Error(`No test app for role '${wrong_role}'`);
96
- const res = await app.request(resolve_test_path(route.path), {
129
+ const res = await app.request(test_path(route), {
97
130
  method: route.method
98
131
  });
99
132
  assert.strictEqual(res.status, 403, `${route.method} ${route.path}`);
@@ -108,7 +141,7 @@ export const describe_adversarial_auth = (options) => {
108
141
  describe('authenticated without role → 403', () => {
109
142
  for (const route of role_only_routes) {
110
143
  test(`${route.method} ${route.path}`, async () => {
111
- const res = await apps.authed.request(resolve_test_path(route.path), {
144
+ const res = await apps.authed.request(test_path(route), {
112
145
  method: route.method
113
146
  });
114
147
  assert.strictEqual(res.status, 403, `${route.method} ${route.path}`);
@@ -120,19 +153,35 @@ export const describe_adversarial_auth = (options) => {
120
153
  }
121
154
  });
122
155
  }
123
- if (keeper_routes.length > 0) {
124
- describe('keeper routes reject session credential → 403', () => {
125
- // keeper role via session cookie should fail (wrong credential type)
126
- const app_session_keeper = create_test_app_from_specs(route_specs, create_test_request_context('keeper'), 'session');
127
- for (const route of keeper_routes) {
128
- test(`${route.method} ${route.path}`, async () => {
129
- const res = await app_session_keeper.request(resolve_test_path(route.path), {
156
+ if (credential_gated_routes.length > 0) {
157
+ describe('credential-gated routes reject a disallowed credential → 403', () => {
158
+ // Each route is probed on the first channel its own allowlist
159
+ // omits, in `CREDENTIAL_TYPES` order: a session for keeper's
160
+ // `daemon_token` gate, an api token for the session-only routes —
161
+ // which is the bearer channel those gates exist to refuse. The
162
+ // channel apps hold the keeper role, so no route's role gate can
163
+ // be what answers; it could not be anyway, the credential gate
164
+ // being pre-authorization.
165
+ for (const route of credential_gated_routes) {
166
+ const admitted = route.auth.credential_types ?? [];
167
+ const refused = CREDENTIAL_TYPES.find((t) => !admitted.includes(t));
168
+ if (!refused) {
169
+ // A gate listing every builtin channel refuses none of them, so
170
+ // there is no probe to make. Reported as a skip rather than
171
+ // dropped silently: a route that reads as gated but is not is
172
+ // worth seeing in the run, and a `continue` here would hide
173
+ // exactly the shape this block exists to surface.
174
+ test.skip(`${route.method} ${route.path} (gate admits every builtin channel — nothing to refuse)`, () => { });
175
+ continue;
176
+ }
177
+ test(`${route.method} ${route.path} (${refused})`, async () => {
178
+ const res = await apps.by_credential_type.get(refused).request(test_path(route), {
130
179
  method: route.method
131
180
  });
132
181
  assert.strictEqual(res.status, 403, `${route.method} ${route.path}`);
133
182
  const body = await res.json();
134
183
  assert.strictEqual(body.error, ERROR_CREDENTIAL_TYPE_REQUIRED);
135
- assert.deepStrictEqual(body.required_credential_types, route.auth.credential_types ?? []);
184
+ assert.deepStrictEqual(body.required_credential_types, admitted);
136
185
  assert_error_schema_valid(error_schema_lookup, route, 403, body);
137
186
  });
138
187
  }
@@ -141,7 +190,9 @@ export const describe_adversarial_auth = (options) => {
141
190
  describe('correct auth passes guard', () => {
142
191
  for (const route of protected_routes) {
143
192
  test(`${route.method} ${route.path}`, async () => {
144
- const res = await select_auth_app(apps, route.auth).request(resolve_test_path(route.path), { method: route.method });
193
+ const res = await select_auth_app(apps, route.auth).request(test_path(route), {
194
+ method: route.method
195
+ });
145
196
  // handler may error (500) or return 404 (stub deps) — that's fine, we only verify auth passed
146
197
  assert.notStrictEqual(res.status, 401, 'should not be 401 (auth rejected)');
147
198
  assert.notStrictEqual(res.status, 403, 'should not be 403 (role rejected)');
@@ -210,7 +261,7 @@ export const resolve_standard_error_schema_tightness = (consumer) => {
210
261
  * any project-specific assertions in additional `describe` blocks.
211
262
  */
212
263
  export const describe_standard_attack_surface_tests = (options) => {
213
- const { build, snapshot_path, expected_public_routes, expected_api_middleware, roles, api_path_prefix = '/api/', security_policy } = options;
264
+ const { build, snapshot_path, expected_public_routes, expected_api_middleware, roles, api_path_prefix = '/api/', security_policy, skip_routes } = options;
214
265
  const error_schema_tightness = resolve_standard_error_schema_tightness(options.error_schema_tightness);
215
266
  const built = build();
216
267
  const { surface } = built;
@@ -254,7 +305,7 @@ export const describe_standard_attack_surface_tests = (options) => {
254
305
  }
255
306
  });
256
307
  });
257
- describe_adversarial_auth({ build: () => built, roles });
258
- describe_adversarial_input({ build: () => built, roles });
259
- describe_adversarial_404({ build: () => built, roles });
308
+ describe_adversarial_auth({ build: () => built, roles, skip_routes });
309
+ describe_adversarial_input({ build: () => built, roles, skip_routes });
310
+ describe_adversarial_404({ build: () => built, roles, skip_routes });
260
311
  };
@@ -2,7 +2,6 @@ import './assert_dev_env.js';
2
2
  import type { SessionOptions } from '../auth/session_cookie.js';
3
3
  import { type RpcEndpointsSuiteOption } from './rpc_helpers.js';
4
4
  import type { AppSurfaceSpec } from '../http/surface.js';
5
- import type { BackendCapabilities } from './cross_backend/capabilities.js';
6
5
  import type { SetupTest } from './cross_backend/setup.js';
7
6
  /**
8
7
  * Configuration for `describe_audit_completeness_tests`.
@@ -23,8 +22,6 @@ export interface AuditCompletenessTestOptions {
23
22
  * options uniformity.
24
23
  */
25
24
  surface_source: AppSurfaceSpec;
26
- /** Backend capability declarations. */
27
- capabilities: BackendCapabilities;
28
25
  /** Session config — needed for factory-form rpc_endpoints resolution. */
29
26
  session_options: SessionOptions<string>;
30
27
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"audit_completeness.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/audit_completeness.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AA4B7B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAUhE,OAAO,EAKN,KAAK,uBAAuB,EAC5B,MAAM,kBAAkB,CAAC;AA2B1B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,iCAAiC,CAAC;AAC3E,OAAO,KAAK,EAAE,SAAS,EAAe,MAAM,0BAA0B,CAAC;AAEvE;;GAEG;AACH,MAAM,WAAW,4BAA4B;IAC5C;;;;;OAKG;IACH,UAAU,EAAE,SAAS,CAAC;IACtB;;;;;;OAMG;IACH,cAAc,EAAE,cAAc,CAAC;IAC/B,uCAAuC;IACvC,YAAY,EAAE,mBAAmB,CAAC;IAClC,yEAAyE;IACzE,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC;;;OAGG;IACH,aAAa,EAAE,uBAAuB,CAAC;CACvC;AAgGD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,iCAAiC,GAAI,SAAS,4BAA4B,KAAG,IAinBzF,CAAC"}
1
+ {"version":3,"file":"audit_completeness.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/audit_completeness.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AA4B7B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAUhE,OAAO,EAKN,KAAK,uBAAuB,EAC5B,MAAM,kBAAkB,CAAC;AA2B1B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,KAAK,EAAE,SAAS,EAAe,MAAM,0BAA0B,CAAC;AAEvE;;GAEG;AACH,MAAM,WAAW,4BAA4B;IAC5C;;;;;OAKG;IACH,UAAU,EAAE,SAAS,CAAC;IACtB;;;;;;OAMG;IACH,cAAc,EAAE,cAAc,CAAC;IAC/B,yEAAyE;IACzE,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC;;;OAGG;IACH,aAAa,EAAE,uBAAuB,CAAC;CACvC;AAgGD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,iCAAiC,GAAI,SAAS,4BAA4B,KAAG,IAgnBzF,CAAC"}
@@ -111,7 +111,6 @@ export const describe_audit_completeness_tests = (options) => {
111
111
  // confusing test failure when `rpc_endpoints` is missing.
112
112
  const rpc_endpoints_for_setup = resolve_rpc_endpoints_for_setup(options.rpc_endpoints, options.session_options);
113
113
  const rpc_path = require_rpc_endpoint_path(rpc_endpoints_for_setup);
114
- void options.capabilities;
115
114
  describe('audit_log_completeness', () => {
116
115
  // --- Account routes ---
117
116
  describe('account mutation audit events', () => {
@@ -29,6 +29,16 @@ export interface AuthTestApps {
29
29
  public: Hono;
30
30
  authed: Hono;
31
31
  keeper: Hono;
32
+ /**
33
+ * One authenticated app per builtin credential type, each holding the
34
+ * keeper role — the channel axis, where `by_role` is the authority axis.
35
+ *
36
+ * A credential gate is pre-authorization, so probing it needs an app on a
37
+ * channel the route refuses, and proving a gated route still works needs
38
+ * one on a channel it admits. `keeper` is the `'daemon_token'` entry of
39
+ * this map, not a second app.
40
+ */
41
+ by_credential_type: Map<CredentialType, Hono>;
32
42
  by_role: Map<string, Hono>;
33
43
  }
34
44
  /**
@@ -41,11 +51,33 @@ export declare const create_auth_test_apps: (route_specs: Array<RouteSpec>, role
41
51
  /**
42
52
  * Select the Hono test app with correct auth for a route.
43
53
  *
54
+ * The credential channel is decided first, because the channel gate is
55
+ * pre-authorization: an app whose credential the route refuses never reaches
56
+ * the role gate, so a role-correct app on the wrong channel answers
57
+ * `credential_type_required`, and a "correct auth passes guard" case reads it
58
+ * as a failure of the role gate that never ran.
59
+ *
60
+ * A gate admitting `'session'` falls through to the `by_role` /
61
+ * `authed` apps, which carry that channel. Any other gate leaves the
62
+ * `by_credential_type` app as the only candidate — those hold the keeper
63
+ * role, which is why a route pairing a non-session channel with some *other*
64
+ * role has no app and says so.
65
+ *
44
66
  * @throws Error if `auth.roles` names a role not present in `apps.by_role` —
45
67
  * surfaces a missing entry in the `roles` array passed to
46
68
  * `create_auth_test_apps`.
69
+ * @throws Error if the route pairs a non-session channel with a role the
70
+ * channel apps don't hold. Silently handing back a session app would turn
71
+ * every case on the route into a misattributed 403.
47
72
  */
48
73
  export declare const select_auth_app: (apps: AuthTestApps, auth: RouteAuth) => Hono;
49
- /** Replace Hono route params (`:foo`) with dummy values for HTTP testing. */
50
- export declare const resolve_test_path: (path: string) => string;
74
+ /**
75
+ * Build the skip predicate an adversarial suite applies from its
76
+ * `skip_routes` option — routes named in `'METHOD /path'` form, the surface
77
+ * key. `undefined` skips nothing.
78
+ */
79
+ export declare const create_route_skip_filter: (skip_routes: Array<string> | undefined) => ((route: {
80
+ method: string;
81
+ path: string;
82
+ }) => boolean);
51
83
  //# sourceMappingURL=auth_apps.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"auth_apps.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/auth_apps.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAE7B;;;;;;;GAOG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAG5B,OAAO,EAAqB,KAAK,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAC1E,OAAO,EAAkB,KAAK,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAEvE,OAAO,EAGN,KAAK,cAAc,EACnB,MAAM,4BAA4B,CAAC;AACpC,OAAO,EAIN,KAAK,cAAc,EACnB,MAAM,oBAAoB,CAAC;AAI5B;;GAEG;AACH,eAAO,MAAM,2BAA2B,GAAI,OAAO,MAAM,KAAG,cAI1D,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,GACtC,aAAa,KAAK,CAAC,SAAS,CAAC,EAC7B,WAAW,cAAc,EACzB,kBAAkB,cAAc,KAC9B,IAuBF,CAAC;AAEF,sFAAsF;AACtF,MAAM,WAAW,YAAY;IAC5B,MAAM,EAAE,IAAI,CAAC;IACb,MAAM,EAAE,IAAI,CAAC;IACb,MAAM,EAAE,IAAI,CAAC;IACb,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;CAC3B;AAED;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,GACjC,aAAa,KAAK,CAAC,SAAS,CAAC,EAC7B,OAAO,KAAK,CAAC,MAAM,CAAC,KAClB,YAeF,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,eAAe,GAAI,MAAM,YAAY,EAAE,MAAM,SAAS,KAAG,IAarE,CAAC;AAEF,6EAA6E;AAC7E,eAAO,MAAM,iBAAiB,GAAI,MAAM,MAAM,KAAG,MAA4C,CAAC"}
1
+ {"version":3,"file":"auth_apps.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/auth_apps.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAE7B;;;;;;;GAOG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAG5B,OAAO,EAAqB,KAAK,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAC1E,OAAO,EAAkB,KAAK,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAOvE,OAAO,EAGN,KAAK,cAAc,EACnB,MAAM,4BAA4B,CAAC;AACpC,OAAO,EAKN,KAAK,cAAc,EACnB,MAAM,oBAAoB,CAAC;AAI5B;;GAEG;AACH,eAAO,MAAM,2BAA2B,GAAI,OAAO,MAAM,KAAG,cAI1D,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,GACtC,aAAa,KAAK,CAAC,SAAS,CAAC,EAC7B,WAAW,cAAc,EACzB,kBAAkB,cAAc,KAC9B,IAuBF,CAAC;AAEF,sFAAsF;AACtF,MAAM,WAAW,YAAY;IAC5B,MAAM,EAAE,IAAI,CAAC;IACb,MAAM,EAAE,IAAI,CAAC;IACb,MAAM,EAAE,IAAI,CAAC;IACb;;;;;;;;OAQG;IACH,kBAAkB,EAAE,GAAG,CAAC,cAAc,EAAE,IAAI,CAAC,CAAC;IAC9C,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;CAC3B;AAED;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,GACjC,aAAa,KAAK,CAAC,SAAS,CAAC,EAC7B,OAAO,KAAK,CAAC,MAAM,CAAC,KAClB,YAsBF,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,eAAe,GAAI,MAAM,YAAY,EAAE,MAAM,SAAS,KAAG,IAwBrE,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,GACpC,aAAa,KAAK,CAAC,MAAM,CAAC,GAAG,SAAS,KACpC,CAAC,CAAC,KAAK,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,KAAK,OAAO,CAGvD,CAAC"}
@@ -11,9 +11,11 @@ import { Hono } from 'hono';
11
11
  import { Logger } from '@fuzdev/fuz_util/log.ts';
12
12
  import { apply_route_specs } from '../http/route_spec.js';
13
13
  import { is_public_auth } from '../http/auth_shape.js';
14
+ import { CREDENTIAL_TYPE_DAEMON_TOKEN, CREDENTIAL_TYPE_SESSION } from '../auth/credential_type_schema.js';
15
+ import { ROLE_KEEPER } from '../auth/role_schema.js';
14
16
  import { fuz_auth_guard_resolver } from '../auth/auth_guard_resolver.js';
15
17
  import { REQUEST_CONTEXT_KEY, create_fuz_authorization_handler } from '../auth/request_context.js';
16
- import { ACCOUNT_ID_KEY, CREDENTIAL_TYPE_KEY, TEST_CONTEXT_PRESET_KEY } from '../hono_context.js';
18
+ import { ACCOUNT_ID_KEY, CREDENTIAL_TYPES, CREDENTIAL_TYPE_KEY, TEST_CONTEXT_PRESET_KEY } from '../hono_context.js';
17
19
  import { create_stub_db } from './stubs.js';
18
20
  import { create_test_account, create_test_actor, create_test_role_grant } from './entities.js';
19
21
  /**
@@ -40,7 +42,7 @@ export const create_test_app_from_specs = (route_specs, auth_ctx, credential_typ
40
42
  if (auth_ctx) {
41
43
  c.set(ACCOUNT_ID_KEY, auth_ctx.account.id);
42
44
  c.set(REQUEST_CONTEXT_KEY, auth_ctx);
43
- c.set(CREDENTIAL_TYPE_KEY, credential_type ?? 'session');
45
+ c.set(CREDENTIAL_TYPE_KEY, credential_type ?? CREDENTIAL_TYPE_SESSION);
44
46
  c.set(TEST_CONTEXT_PRESET_KEY, true);
45
47
  }
46
48
  await next();
@@ -59,25 +61,52 @@ export const create_auth_test_apps = (route_specs, roles) => {
59
61
  for (const role of roles) {
60
62
  by_role.set(role, create_test_app_from_specs(route_specs, create_test_request_context(role)));
61
63
  }
64
+ const by_credential_type = new Map(CREDENTIAL_TYPES.map((credential_type) => [
65
+ credential_type,
66
+ create_test_app_from_specs(route_specs, create_test_request_context(ROLE_KEEPER), credential_type)
67
+ ]));
62
68
  return {
63
69
  public: create_test_app_from_specs(route_specs),
64
70
  authed: create_test_app_from_specs(route_specs, create_test_request_context()),
65
- keeper: create_test_app_from_specs(route_specs, create_test_request_context('keeper'), 'daemon_token'),
71
+ keeper: by_credential_type.get(CREDENTIAL_TYPE_DAEMON_TOKEN),
72
+ by_credential_type,
66
73
  by_role
67
74
  };
68
75
  };
69
76
  /**
70
77
  * Select the Hono test app with correct auth for a route.
71
78
  *
79
+ * The credential channel is decided first, because the channel gate is
80
+ * pre-authorization: an app whose credential the route refuses never reaches
81
+ * the role gate, so a role-correct app on the wrong channel answers
82
+ * `credential_type_required`, and a "correct auth passes guard" case reads it
83
+ * as a failure of the role gate that never ran.
84
+ *
85
+ * A gate admitting `'session'` falls through to the `by_role` /
86
+ * `authed` apps, which carry that channel. Any other gate leaves the
87
+ * `by_credential_type` app as the only candidate — those hold the keeper
88
+ * role, which is why a route pairing a non-session channel with some *other*
89
+ * role has no app and says so.
90
+ *
72
91
  * @throws Error if `auth.roles` names a role not present in `apps.by_role` —
73
92
  * surfaces a missing entry in the `roles` array passed to
74
93
  * `create_auth_test_apps`.
94
+ * @throws Error if the route pairs a non-session channel with a role the
95
+ * channel apps don't hold. Silently handing back a session app would turn
96
+ * every case on the route into a misattributed 403.
75
97
  */
76
98
  export const select_auth_app = (apps, auth) => {
77
99
  if (is_public_auth(auth))
78
100
  return apps.public;
79
- if (auth.credential_types?.includes('daemon_token'))
80
- return apps.keeper;
101
+ const credential_types = auth.credential_types;
102
+ if (credential_types?.length && !credential_types.includes(CREDENTIAL_TYPE_SESSION)) {
103
+ const channel = CREDENTIAL_TYPES.find((t) => credential_types.includes(t));
104
+ const app = channel === undefined ? undefined : apps.by_credential_type.get(channel);
105
+ const roles = auth.roles;
106
+ if (app && (!roles?.length || roles.includes(ROLE_KEEPER)))
107
+ return app;
108
+ throw new Error(`No test app for credential types '${credential_types.join("', '")}'${roles?.length ? ` with roles '${roles.join("', '")}'` : ''} — the channel apps hold the '${ROLE_KEEPER}' role, and only the session channel has per-role apps`);
109
+ }
81
110
  if (auth.roles?.length) {
82
111
  // Multi-role disjunction: any of the named roles admits the caller.
83
112
  // Tests pick the first role's app; consumers wanting per-role coverage
@@ -90,5 +119,12 @@ export const select_auth_app = (apps, auth) => {
90
119
  }
91
120
  return apps.authed;
92
121
  };
93
- /** Replace Hono route params (`:foo`) with dummy values for HTTP testing. */
94
- export const resolve_test_path = (path) => path.replace(/:(\w+)/g, 'test_$1');
122
+ /**
123
+ * Build the skip predicate an adversarial suite applies from its
124
+ * `skip_routes` option — routes named in `'METHOD /path'` form, the surface
125
+ * key. `undefined` skips nothing.
126
+ */
127
+ export const create_route_skip_filter = (skip_routes) => {
128
+ const skip_set = new Set(skip_routes);
129
+ return (route) => skip_set.has(`${route.method} ${route.path}`);
130
+ };
@@ -1,10 +1,11 @@
1
1
  import '../assert_dev_env.js';
2
- import type { RpcPathCrossSuiteOptions } from './setup.js';
2
+ import type { RpcPathCapabilityGatedCrossSuiteOptions } from './setup.js';
3
3
  /**
4
4
  * Options for the account-lifecycle parity suite. The standard
5
- * RPC-dispatched cross-suite shape (`setup_test` / `capabilities` /
6
- * `rpc_path`); aliases `RpcPathCrossSuiteOptions` rather than duplicating.
5
+ * capability-gating RPC-dispatched cross-suite shape (`setup_test` /
6
+ * `capabilities` / `rpc_path`); aliases
7
+ * `RpcPathCapabilityGatedCrossSuiteOptions` rather than duplicating.
7
8
  */
8
- export type AccountLifecycleCrossTestOptions = RpcPathCrossSuiteOptions;
9
+ export type AccountLifecycleCrossTestOptions = RpcPathCapabilityGatedCrossSuiteOptions;
9
10
  export declare const describe_account_lifecycle_cross_tests: (options: AccountLifecycleCrossTestOptions) => void;
10
11
  //# sourceMappingURL=account_lifecycle.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"account_lifecycle.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/account_lifecycle.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAqC9B,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,YAAY,CAAC;AAG3D;;;;GAIG;AACH,MAAM,MAAM,gCAAgC,GAAG,wBAAwB,CAAC;AAExE,eAAO,MAAM,sCAAsC,GAClD,SAAS,gCAAgC,KACvC,IA+TF,CAAC"}
1
+ {"version":3,"file":"account_lifecycle.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/account_lifecycle.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAsC9B,OAAO,KAAK,EAAE,uCAAuC,EAAE,MAAM,YAAY,CAAC;AAG1E;;;;;GAKG;AACH,MAAM,MAAM,gCAAgC,GAAG,uCAAuC,CAAC;AAEvF,eAAO,MAAM,sCAAsC,GAClD,SAAS,gCAAgC,KACvC,IAqUF,CAAC"}
@@ -22,6 +22,7 @@ import { AccountDeleteOutput, AccountUndeleteOutput, AccountPurgeOutput, AdminAc
22
22
  import { ERROR_ACCOUNT_NOT_FOUND, ERROR_AUTHENTICATION_REQUIRED } from '../../http/error_schemas.js';
23
23
  import { test_if } from './capabilities.js';
24
24
  import { cross_rpc_call, error_reason, expect_output } from './cell_cross_helpers.js';
25
+ import { assert_iso8601_seconds } from './wire_shapes.js';
25
26
  import { SPINE_RPC_PATH } from './spine_surface_constants.js';
26
27
  export const describe_account_lifecycle_cross_tests = (options) => {
27
28
  const { setup_test, capabilities } = options;
@@ -99,6 +100,9 @@ export const describe_account_lifecycle_cross_tests = (options) => {
99
100
  const failure = listed.events.find((e) => e.outcome === 'failure' &&
100
101
  e.metadata?.reason === ERROR_CANNOT_DELETE_KEEPER);
101
102
  assert.ok(failure, 'keeper-removal guard must emit an account_delete outcome=failure audit row with reason cannot_delete_keeper');
103
+ // Byte-shape gate on the audit row's timestamp — `z.string()` in
104
+ // `AuditLogEventWithUsernamesJson` accepts a millisecond stamp too.
105
+ assert_iso8601_seconds(failure.created_at, 'audit_log_list.created_at');
102
106
  });
103
107
  test_if(capabilities.account_lifecycle, 'deterministic: double-undelete → second call is not_found', async () => {
104
108
  const fixture = await setup_test();
@@ -139,6 +143,9 @@ export const describe_account_lifecycle_cross_tests = (options) => {
139
143
  const row = with_deleted.accounts.find((a) => a.account.id === victim.account.id);
140
144
  assert.ok(row, 'include_deleted surfaces the tombstoned row');
141
145
  assert.ok(row.account.deleted_at !== null, 'tombstoned row carries a non-null deleted_at on both spines');
146
+ assert_iso8601_seconds(row.account.created_at, 'admin_account_list.created_at');
147
+ assert_iso8601_seconds(row.account.updated_at, 'admin_account_list.updated_at');
148
+ assert_iso8601_seconds(row.account.deleted_at, 'admin_account_list.deleted_at');
142
149
  });
143
150
  });
144
151
  };
@@ -2,7 +2,7 @@ import '../assert_dev_env.js';
2
2
  /**
3
3
  * Cross-impl RPC **action-manifest** introspection — a normalized,
4
4
  * JSON-serializable dump of a backend's live RPC method set, one entry per
5
- * method carrying its auth shape + side-effect flag.
5
+ * method carrying its auth shape, side-effect flag, and rate-limit class.
6
6
  *
7
7
  * The sibling of `schema_introspect.ts`'s `SchemaSnapshot`: where that
8
8
  * captures the live *database* shape for the schema-parity gate, this
@@ -35,13 +35,16 @@ import '../assert_dev_env.js';
35
35
  */
36
36
  import { z } from 'zod';
37
37
  import { type RouteAuth } from '../../http/auth_shape.js';
38
+ import { RateLimitKey } from '../../http/error_schemas.js';
38
39
  import type { RequestResponseActionSpec } from '../../actions/action_spec.js';
39
40
  /**
40
41
  * One method's normalized RPC metadata — the cross-impl-comparable unit.
41
42
  * `roles` / `credential_types` are always present + sorted (an absent gate
42
43
  * and an empty list both serialize to `[]`) so the diff never trips on a
43
44
  * `undefined`-vs-`[]` or declaration-order difference between impls; the
44
- * auth axes reuse the canonical `AuthAxisState` enum.
45
+ * auth axes reuse the canonical `AuthAxisState` enum. `rate_limit` is
46
+ * nullable rather than optional for the same reason — an unthrottled action
47
+ * serializes an explicit `null`, so a missing key is drift, not a default.
45
48
  */
46
49
  export declare const ActionManifestEntry: z.ZodObject<{
47
50
  /** RPC method name (`spec.method`). */
@@ -64,6 +67,12 @@ export declare const ActionManifestEntry: z.ZodObject<{
64
67
  roles: z.ZodArray<z.ZodString>;
65
68
  /** Permitted credential channels, sorted; `[]` when ungated (any credential). */
66
69
  credential_types: z.ZodArray<z.ZodString>;
70
+ /** Per-action rate-limit class, or `null` when the action declares none. */
71
+ rate_limit: z.ZodNullable<z.ZodEnum<{
72
+ account: "account";
73
+ ip: "ip";
74
+ both: "both";
75
+ }>>;
67
76
  }, z.core.$strict>;
68
77
  export type ActionManifestEntry = z.infer<typeof ActionManifestEntry>;
69
78
  /** The full action manifest — every entry, sorted by `method`. */
@@ -89,6 +98,12 @@ export declare const ActionManifest: z.ZodObject<{
89
98
  roles: z.ZodArray<z.ZodString>;
90
99
  /** Permitted credential channels, sorted; `[]` when ungated (any credential). */
91
100
  credential_types: z.ZodArray<z.ZodString>;
101
+ /** Per-action rate-limit class, or `null` when the action declares none. */
102
+ rate_limit: z.ZodNullable<z.ZodEnum<{
103
+ account: "account";
104
+ ip: "ip";
105
+ both: "both";
106
+ }>>;
92
107
  }, z.core.$strict>>;
93
108
  }, z.core.$strict>;
94
109
  export type ActionManifest = z.infer<typeof ActionManifest>;
@@ -101,6 +116,7 @@ export declare const action_manifest_entry: (spec: {
101
116
  readonly method: string;
102
117
  readonly auth: RouteAuth;
103
118
  readonly side_effects: boolean;
119
+ readonly rate_limit?: RateLimitKey;
104
120
  }) => ActionManifestEntry;
105
121
  /**
106
122
  * Build the normalized `ActionManifest` from a list of request-response
@@ -109,5 +125,5 @@ export declare const action_manifest_entry: (spec: {
109
125
  * it passes (the TS spine passes its full mount; the Rust stub filters
110
126
  * `PROTOCOL_ACTION_SPECS` first) — see the module doc.
111
127
  */
112
- export declare const build_action_manifest: (specs: ReadonlyArray<Pick<RequestResponseActionSpec, "method" | "auth" | "side_effects">>) => ActionManifest;
128
+ export declare const build_action_manifest: (specs: ReadonlyArray<Pick<RequestResponseActionSpec, "method" | "auth" | "side_effects" | "rate_limit">>) => ActionManifest;
113
129
  //# sourceMappingURL=action_manifest.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"action_manifest.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/action_manifest.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAiB,KAAK,SAAS,EAAE,MAAM,0BAA0B,CAAC;AACzE,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,8BAA8B,CAAC;AAE9E;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB;IAC/B,uCAAuC;;IAEvC,iGAAiG;;IAEjG,+BAA+B;;;;;;IAE/B,6BAA6B;;;;;;IAE7B,mFAAmF;;IAEnF,iFAAiF;;kBAEhF,CAAC;AACH,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEtE,kEAAkE;AAClE,eAAO,MAAM,cAAc;;QAhB1B,uCAAuC;;QAEvC,iGAAiG;;QAEjG,+BAA+B;;;;;;QAE/B,6BAA6B;;;;;;QAE7B,mFAAmF;;QAEnF,iFAAiF;;;kBAQhF,CAAC;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAM5D;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,GAAI,MAAM;IAC3C,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;CAC/B,KAAG,mBAOF,CAAC;AAMH;;;;;;GAMG;AACH,eAAO,MAAM,qBAAqB,GACjC,OAAO,aAAa,CAAC,IAAI,CAAC,yBAAyB,EAAE,QAAQ,GAAG,MAAM,GAAG,cAAc,CAAC,CAAC,KACvF,cAED,CAAC"}
1
+ {"version":3,"file":"action_manifest.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/action_manifest.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAiB,KAAK,SAAS,EAAE,MAAM,0BAA0B,CAAC;AACzE,OAAO,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAC3D,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,8BAA8B,CAAC;AAE9E;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB;IAC/B,uCAAuC;;IAEvC,iGAAiG;;IAEjG,+BAA+B;;;;;;IAE/B,6BAA6B;;;;;;IAE7B,mFAAmF;;IAEnF,iFAAiF;;IAEjF,4EAA4E;;;;;;kBAE3E,CAAC;AACH,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEtE,kEAAkE;AAClE,eAAO,MAAM,cAAc;;QAlB1B,uCAAuC;;QAEvC,iGAAiG;;QAEjG,+BAA+B;;;;;;QAE/B,6BAA6B;;;;;;QAE7B,mFAAmF;;QAEnF,iFAAiF;;QAEjF,4EAA4E;;;;;;;kBAQ3E,CAAC;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAM5D;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,GAAI,MAAM;IAC3C,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;IAC/B,QAAQ,CAAC,UAAU,CAAC,EAAE,YAAY,CAAC;CACnC,KAAG,mBAQF,CAAC;AAMH;;;;;;GAMG;AACH,eAAO,MAAM,qBAAqB,GACjC,OAAO,aAAa,CACnB,IAAI,CAAC,yBAAyB,EAAE,QAAQ,GAAG,MAAM,GAAG,cAAc,GAAG,YAAY,CAAC,CAClF,KACC,cAED,CAAC"}
@@ -2,7 +2,7 @@ import '../assert_dev_env.js';
2
2
  /**
3
3
  * Cross-impl RPC **action-manifest** introspection — a normalized,
4
4
  * JSON-serializable dump of a backend's live RPC method set, one entry per
5
- * method carrying its auth shape + side-effect flag.
5
+ * method carrying its auth shape, side-effect flag, and rate-limit class.
6
6
  *
7
7
  * The sibling of `schema_introspect.ts`'s `SchemaSnapshot`: where that
8
8
  * captures the live *database* shape for the schema-parity gate, this
@@ -35,12 +35,15 @@ import '../assert_dev_env.js';
35
35
  */
36
36
  import { z } from 'zod';
37
37
  import { AuthAxisState } from '../../http/auth_shape.js';
38
+ import { RateLimitKey } from '../../http/error_schemas.js';
38
39
  /**
39
40
  * One method's normalized RPC metadata — the cross-impl-comparable unit.
40
41
  * `roles` / `credential_types` are always present + sorted (an absent gate
41
42
  * and an empty list both serialize to `[]`) so the diff never trips on a
42
43
  * `undefined`-vs-`[]` or declaration-order difference between impls; the
43
- * auth axes reuse the canonical `AuthAxisState` enum.
44
+ * auth axes reuse the canonical `AuthAxisState` enum. `rate_limit` is
45
+ * nullable rather than optional for the same reason — an unthrottled action
46
+ * serializes an explicit `null`, so a missing key is drift, not a default.
44
47
  */
45
48
  export const ActionManifestEntry = z.strictObject({
46
49
  /** RPC method name (`spec.method`). */
@@ -54,7 +57,9 @@ export const ActionManifestEntry = z.strictObject({
54
57
  /** Required roles (any-of), sorted; `[]` when the action declares no role gate. */
55
58
  roles: z.array(z.string()),
56
59
  /** Permitted credential channels, sorted; `[]` when ungated (any credential). */
57
- credential_types: z.array(z.string())
60
+ credential_types: z.array(z.string()),
61
+ /** Per-action rate-limit class, or `null` when the action declares none. */
62
+ rate_limit: RateLimitKey.nullable()
58
63
  });
59
64
  /** The full action manifest — every entry, sorted by `method`. */
60
65
  export const ActionManifest = z.strictObject({
@@ -73,7 +78,8 @@ export const action_manifest_entry = (spec) => ({
73
78
  account: spec.auth.account,
74
79
  actor: spec.auth.actor,
75
80
  roles: sorted_strings(spec.auth.roles),
76
- credential_types: sorted_strings(spec.auth.credential_types)
81
+ credential_types: sorted_strings(spec.auth.credential_types),
82
+ rate_limit: spec.rate_limit ?? null
77
83
  });
78
84
  /** Deterministic byte-lexicographic method order — matches the Rust `str::cmp` the stub sorts by. */
79
85
  const compare_method = (a, b) => a.method < b.method ? -1 : a.method > b.method ? 1 : 0;
@@ -7,8 +7,8 @@ import '../assert_dev_env.js';
7
7
  * fuz_app spine and the Rust `testing_spine_stub`) are each other's parity
8
8
  * reference. After both bootstrap, dump each one's live RPC method set, diff,
9
9
  * fail loudly on drift. The diff entries name the specific divergence (a
10
- * method only one impl mounts, a per-method auth-axis or side-effect
11
- * mismatch) so the error message points at the source.
10
+ * method only one impl mounts, a per-method auth-axis, side-effect, or
11
+ * rate-limit-class mismatch) so the error message points at the source.
12
12
  *
13
13
  * ```ts
14
14
  * const manifest_a = await capture_action_manifest(ts_handle);
@@ -17,13 +17,14 @@ import '../assert_dev_env.js';
17
17
  * ```
18
18
  *
19
19
  * Non-coverage: the manifest captures `{method, side_effects, account, actor,
20
- * roles, credential_types}` — the wire-relevant auth shape. It does **not**
20
+ * roles, credential_types, rate_limit}` — the wire-relevant auth shape. It does **not**
21
21
  * capture input/output schemas (the declared-surface wire shape is gated by
22
22
  * `rpc_round_trip`) nor the protocol actions `heartbeat` / `cancel` (excluded
23
23
  * by construction — see `action_manifest.ts`).
24
24
  *
25
25
  * @module
26
26
  */
27
+ import type { RateLimitKey } from '../../http/error_schemas.js';
27
28
  import type { ActionManifest } from './action_manifest.js';
28
29
  /** Structured drift entry. `where` is the named source impl ('a' or 'b'). */
29
30
  export type ActionManifestDiff = {
@@ -35,6 +36,11 @@ export type ActionManifestDiff = {
35
36
  readonly method: string;
36
37
  readonly a: boolean;
37
38
  readonly b: boolean;
39
+ } | {
40
+ readonly kind: 'rate_limit_differs';
41
+ readonly method: string;
42
+ readonly a: RateLimitKey | null;
43
+ readonly b: RateLimitKey | null;
38
44
  } | {
39
45
  readonly kind: 'auth_field_differs';
40
46
  readonly method: string;
@@ -1 +1 @@
1
- {"version":3,"file":"action_manifest_parity.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/action_manifest_parity.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAuB,MAAM,sBAAsB,CAAC;AAWhF,6EAA6E;AAC7E,MAAM,MAAM,kBAAkB,GAC3B;IAAE,QAAQ,CAAC,IAAI,EAAE,gBAAgB,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,GAAG,GAAG,GAAG,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACvF;IACA,QAAQ,CAAC,IAAI,EAAE,qBAAqB,CAAC;IACrC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,CAAC,EAAE,OAAO,CAAC;CACnB,GACD;IACA,QAAQ,CAAC,IAAI,EAAE,oBAAoB,CAAC;IACpC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,SAAS,GAAG,OAAO,GAAG,OAAO,GAAG,kBAAkB,CAAC;IACnE,QAAQ,CAAC,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,CAAC,EAAE,OAAO,CAAC;CACnB,CAAC;AAML;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,GACjC,GAAG,cAAc,EACjB,GAAG,cAAc,KACf,KAAK,CAAC,kBAAkB,CA+B1B,CAAC;AAEF,qEAAqE;AACrE,MAAM,WAAW,wBAAwB;IACxC,QAAQ,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;GAGG;AACH,eAAO,MAAM,4BAA4B,GACxC,OAAO,aAAa,CAAC,kBAAkB,CAAC,EACxC,SAAQ,wBAA6B,KACnC,MA8BF,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,6BAA6B,GACzC,GAAG,cAAc,EACjB,GAAG,cAAc,EACjB,SAAQ,wBAA6B,KACnC,IAUF,CAAC"}
1
+ {"version":3,"file":"action_manifest_parity.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/action_manifest_parity.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAChE,OAAO,KAAK,EAAE,cAAc,EAAuB,MAAM,sBAAsB,CAAC;AAWhF,6EAA6E;AAC7E,MAAM,MAAM,kBAAkB,GAC3B;IAAE,QAAQ,CAAC,IAAI,EAAE,gBAAgB,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,GAAG,GAAG,GAAG,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACvF;IACA,QAAQ,CAAC,IAAI,EAAE,qBAAqB,CAAC;IACrC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,CAAC,EAAE,OAAO,CAAC;CACnB,GACD;IACA,QAAQ,CAAC,IAAI,EAAE,oBAAoB,CAAC;IACpC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,CAAC,EAAE,YAAY,GAAG,IAAI,CAAC;IAChC,QAAQ,CAAC,CAAC,EAAE,YAAY,GAAG,IAAI,CAAC;CAC/B,GACD;IACA,QAAQ,CAAC,IAAI,EAAE,oBAAoB,CAAC;IACpC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,SAAS,GAAG,OAAO,GAAG,OAAO,GAAG,kBAAkB,CAAC;IACnE,QAAQ,CAAC,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,CAAC,EAAE,OAAO,CAAC;CACnB,CAAC;AAML;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,GACjC,GAAG,cAAc,EACjB,GAAG,cAAc,KACf,KAAK,CAAC,kBAAkB,CAkC1B,CAAC;AAEF,qEAAqE;AACrE,MAAM,WAAW,wBAAwB;IACxC,QAAQ,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;GAGG;AACH,eAAO,MAAM,4BAA4B,GACxC,OAAO,aAAa,CAAC,kBAAkB,CAAC,EACxC,SAAQ,wBAA6B,KACnC,MAqCF,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,6BAA6B,GACzC,GAAG,cAAc,EACjB,GAAG,cAAc,EACjB,SAAQ,wBAA6B,KACnC,IAUF,CAAC"}
@@ -30,6 +30,9 @@ export const diff_action_manifests = (a, b) => {
30
30
  if (ea.side_effects !== eb.side_effects) {
31
31
  diffs.push({ kind: 'side_effects_differ', method, a: ea.side_effects, b: eb.side_effects });
32
32
  }
33
+ if (ea.rate_limit !== eb.rate_limit) {
34
+ diffs.push({ kind: 'rate_limit_differs', method, a: ea.rate_limit, b: eb.rate_limit });
35
+ }
33
36
  for (const field of AUTH_SCALAR_FIELDS) {
34
37
  if (ea[field] !== eb[field]) {
35
38
  diffs.push({ kind: 'auth_field_differs', method, field, a: ea[field], b: eb[field] });
@@ -62,6 +65,9 @@ export const format_action_manifest_diffs = (diffs, labels = {}) => {
62
65
  case 'side_effects_differ':
63
66
  lines.push(` ${d.method} side_effects differs: ${label_a}=${d.a}, ${label_b}=${d.b}`);
64
67
  break;
68
+ case 'rate_limit_differs':
69
+ lines.push(` ${d.method} rate_limit differs: ${label_a}=${JSON.stringify(d.a)}, ${label_b}=${JSON.stringify(d.b)}`);
70
+ break;
65
71
  case 'auth_field_differs':
66
72
  lines.push(` ${d.method} auth.${d.field} differs: ${label_a}=${JSON.stringify(d.a)}, ${label_b}=${JSON.stringify(d.b)}`);
67
73
  break;