@fuzdev/fuz_app 0.29.0 → 0.31.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 (210) hide show
  1. package/dist/actions/CLAUDE.md +630 -0
  2. package/dist/actions/action_rpc.d.ts +29 -0
  3. package/dist/actions/action_rpc.d.ts.map +1 -1
  4. package/dist/actions/action_rpc.js +42 -6
  5. package/dist/actions/action_types.d.ts +2 -2
  6. package/dist/actions/cancel.d.ts +12 -13
  7. package/dist/actions/cancel.d.ts.map +1 -1
  8. package/dist/actions/cancel.js +10 -13
  9. package/dist/actions/heartbeat.d.ts +8 -13
  10. package/dist/actions/heartbeat.d.ts.map +1 -1
  11. package/dist/actions/heartbeat.js +5 -8
  12. package/dist/actions/register_action_ws.d.ts +3 -3
  13. package/dist/actions/register_action_ws.js +2 -2
  14. package/dist/actions/register_ws_endpoint.d.ts +4 -4
  15. package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
  16. package/dist/actions/register_ws_endpoint.js +3 -3
  17. package/dist/actions/socket.svelte.d.ts +16 -16
  18. package/dist/actions/socket.svelte.d.ts.map +1 -1
  19. package/dist/actions/socket.svelte.js +15 -15
  20. package/dist/actions/transports_ws_auth_guard.d.ts.map +1 -1
  21. package/dist/actions/transports_ws_backend.d.ts +15 -0
  22. package/dist/actions/transports_ws_backend.d.ts.map +1 -1
  23. package/dist/actions/transports_ws_backend.js +17 -0
  24. package/dist/auth/CLAUDE.md +923 -0
  25. package/dist/auth/account_action_specs.d.ts +216 -0
  26. package/dist/auth/account_action_specs.d.ts.map +1 -0
  27. package/dist/auth/account_action_specs.js +159 -0
  28. package/dist/auth/account_actions.d.ts +51 -0
  29. package/dist/auth/account_actions.d.ts.map +1 -0
  30. package/dist/auth/account_actions.js +119 -0
  31. package/dist/auth/account_queries.d.ts +6 -2
  32. package/dist/auth/account_queries.d.ts.map +1 -1
  33. package/dist/auth/account_queries.js +40 -4
  34. package/dist/auth/account_routes.d.ts +94 -16
  35. package/dist/auth/account_routes.d.ts.map +1 -1
  36. package/dist/auth/account_routes.js +108 -180
  37. package/dist/auth/account_schema.d.ts +85 -30
  38. package/dist/auth/account_schema.d.ts.map +1 -1
  39. package/dist/auth/account_schema.js +40 -8
  40. package/dist/auth/admin_action_specs.d.ts +674 -0
  41. package/dist/auth/admin_action_specs.d.ts.map +1 -0
  42. package/dist/auth/admin_action_specs.js +287 -0
  43. package/dist/auth/admin_actions.d.ts +69 -0
  44. package/dist/auth/admin_actions.d.ts.map +1 -0
  45. package/dist/auth/admin_actions.js +256 -0
  46. package/dist/auth/api_token.d.ts +10 -0
  47. package/dist/auth/api_token.d.ts.map +1 -1
  48. package/dist/auth/api_token.js +9 -0
  49. package/dist/auth/api_token_queries.d.ts +3 -3
  50. package/dist/auth/api_token_queries.js +3 -3
  51. package/dist/auth/app_settings_schema.d.ts +4 -3
  52. package/dist/auth/app_settings_schema.d.ts.map +1 -1
  53. package/dist/auth/app_settings_schema.js +2 -1
  54. package/dist/auth/audit_log_routes.d.ts +14 -6
  55. package/dist/auth/audit_log_routes.d.ts.map +1 -1
  56. package/dist/auth/audit_log_routes.js +22 -79
  57. package/dist/auth/audit_log_schema.d.ts +100 -29
  58. package/dist/auth/audit_log_schema.d.ts.map +1 -1
  59. package/dist/auth/audit_log_schema.js +83 -11
  60. package/dist/auth/bootstrap_routes.d.ts +14 -0
  61. package/dist/auth/bootstrap_routes.d.ts.map +1 -1
  62. package/dist/auth/bootstrap_routes.js +10 -3
  63. package/dist/auth/cleanup.d.ts +63 -0
  64. package/dist/auth/cleanup.d.ts.map +1 -0
  65. package/dist/auth/cleanup.js +80 -0
  66. package/dist/auth/invite_schema.d.ts +11 -10
  67. package/dist/auth/invite_schema.d.ts.map +1 -1
  68. package/dist/auth/invite_schema.js +4 -3
  69. package/dist/auth/migrations.d.ts +6 -0
  70. package/dist/auth/migrations.d.ts.map +1 -1
  71. package/dist/auth/migrations.js +28 -0
  72. package/dist/auth/permit_offer_action_specs.d.ts +364 -0
  73. package/dist/auth/permit_offer_action_specs.d.ts.map +1 -0
  74. package/dist/auth/permit_offer_action_specs.js +216 -0
  75. package/dist/auth/permit_offer_actions.d.ts +96 -0
  76. package/dist/auth/permit_offer_actions.d.ts.map +1 -0
  77. package/dist/auth/permit_offer_actions.js +428 -0
  78. package/dist/auth/permit_offer_notifications.d.ts +361 -0
  79. package/dist/auth/permit_offer_notifications.d.ts.map +1 -0
  80. package/dist/auth/permit_offer_notifications.js +179 -0
  81. package/dist/auth/permit_offer_queries.d.ts +165 -0
  82. package/dist/auth/permit_offer_queries.d.ts.map +1 -0
  83. package/dist/auth/permit_offer_queries.js +390 -0
  84. package/dist/auth/permit_offer_schema.d.ts +103 -0
  85. package/dist/auth/permit_offer_schema.d.ts.map +1 -0
  86. package/dist/auth/permit_offer_schema.js +142 -0
  87. package/dist/auth/permit_queries.d.ts +77 -14
  88. package/dist/auth/permit_queries.d.ts.map +1 -1
  89. package/dist/auth/permit_queries.js +119 -24
  90. package/dist/auth/session_queries.d.ts +4 -2
  91. package/dist/auth/session_queries.d.ts.map +1 -1
  92. package/dist/auth/session_queries.js +4 -2
  93. package/dist/auth/signup_routes.d.ts +13 -0
  94. package/dist/auth/signup_routes.d.ts.map +1 -1
  95. package/dist/auth/signup_routes.js +14 -7
  96. package/dist/http/CLAUDE.md +584 -0
  97. package/dist/http/pending_effects.d.ts +29 -0
  98. package/dist/http/pending_effects.d.ts.map +1 -0
  99. package/dist/http/pending_effects.js +31 -0
  100. package/dist/http/route_spec.d.ts.map +1 -1
  101. package/dist/http/route_spec.js +4 -3
  102. package/dist/rate_limiter.d.ts +30 -0
  103. package/dist/rate_limiter.d.ts.map +1 -1
  104. package/dist/rate_limiter.js +25 -2
  105. package/dist/realtime/sse_auth_guard.d.ts +2 -0
  106. package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
  107. package/dist/realtime/sse_auth_guard.js +5 -3
  108. package/dist/testing/CLAUDE.md +668 -1
  109. package/dist/testing/admin_integration.d.ts +10 -7
  110. package/dist/testing/admin_integration.d.ts.map +1 -1
  111. package/dist/testing/admin_integration.js +382 -482
  112. package/dist/testing/app_server.d.ts +7 -6
  113. package/dist/testing/app_server.d.ts.map +1 -1
  114. package/dist/testing/attack_surface.d.ts +9 -3
  115. package/dist/testing/attack_surface.d.ts.map +1 -1
  116. package/dist/testing/attack_surface.js +4 -4
  117. package/dist/testing/audit_completeness.d.ts +6 -0
  118. package/dist/testing/audit_completeness.d.ts.map +1 -1
  119. package/dist/testing/audit_completeness.js +158 -134
  120. package/dist/testing/auth_apps.d.ts.map +1 -1
  121. package/dist/testing/auth_apps.js +4 -33
  122. package/dist/testing/db.d.ts +1 -1
  123. package/dist/testing/db.d.ts.map +1 -1
  124. package/dist/testing/db.js +2 -0
  125. package/dist/testing/entities.d.ts +35 -13
  126. package/dist/testing/entities.d.ts.map +1 -1
  127. package/dist/testing/entities.js +17 -0
  128. package/dist/testing/integration.d.ts +10 -0
  129. package/dist/testing/integration.d.ts.map +1 -1
  130. package/dist/testing/integration.js +352 -340
  131. package/dist/testing/integration_helpers.d.ts +16 -5
  132. package/dist/testing/integration_helpers.d.ts.map +1 -1
  133. package/dist/testing/integration_helpers.js +24 -4
  134. package/dist/testing/rate_limiting.d.ts +7 -0
  135. package/dist/testing/rate_limiting.d.ts.map +1 -1
  136. package/dist/testing/rate_limiting.js +41 -10
  137. package/dist/testing/rpc_helpers.d.ts +153 -1
  138. package/dist/testing/rpc_helpers.d.ts.map +1 -1
  139. package/dist/testing/rpc_helpers.js +184 -8
  140. package/dist/testing/sse_round_trip.d.ts +8 -0
  141. package/dist/testing/sse_round_trip.d.ts.map +1 -1
  142. package/dist/testing/sse_round_trip.js +10 -3
  143. package/dist/testing/standard.d.ts +9 -1
  144. package/dist/testing/standard.d.ts.map +1 -1
  145. package/dist/testing/standard.js +6 -2
  146. package/dist/testing/surface_invariants.d.ts +7 -3
  147. package/dist/testing/surface_invariants.d.ts.map +1 -1
  148. package/dist/testing/surface_invariants.js +5 -4
  149. package/dist/testing/ws_round_trip.d.ts.map +1 -1
  150. package/dist/testing/ws_round_trip.js +9 -38
  151. package/dist/ui/AccountSessions.svelte +8 -4
  152. package/dist/ui/AccountSessions.svelte.d.ts.map +1 -1
  153. package/dist/ui/AdminAccounts.svelte +61 -33
  154. package/dist/ui/AdminAccounts.svelte.d.ts.map +1 -1
  155. package/dist/ui/AdminAuditLog.svelte +3 -2
  156. package/dist/ui/AdminAuditLog.svelte.d.ts.map +1 -1
  157. package/dist/ui/AdminInvites.svelte +3 -2
  158. package/dist/ui/AdminInvites.svelte.d.ts.map +1 -1
  159. package/dist/ui/AdminOverview.svelte +14 -9
  160. package/dist/ui/AdminOverview.svelte.d.ts.map +1 -1
  161. package/dist/ui/AdminPermitHistory.svelte +3 -2
  162. package/dist/ui/AdminPermitHistory.svelte.d.ts.map +1 -1
  163. package/dist/ui/AdminSessions.svelte +29 -25
  164. package/dist/ui/AdminSessions.svelte.d.ts.map +1 -1
  165. package/dist/ui/CLAUDE.md +351 -0
  166. package/dist/ui/OpenSignupToggle.svelte +6 -3
  167. package/dist/ui/OpenSignupToggle.svelte.d.ts.map +1 -1
  168. package/dist/ui/PermitOfferForm.svelte +141 -0
  169. package/dist/ui/PermitOfferForm.svelte.d.ts +14 -0
  170. package/dist/ui/PermitOfferForm.svelte.d.ts.map +1 -0
  171. package/dist/ui/PermitOfferHistory.svelte +109 -0
  172. package/dist/ui/PermitOfferHistory.svelte.d.ts +11 -0
  173. package/dist/ui/PermitOfferHistory.svelte.d.ts.map +1 -0
  174. package/dist/ui/PermitOfferInbox.svelte +121 -0
  175. package/dist/ui/PermitOfferInbox.svelte.d.ts +12 -0
  176. package/dist/ui/PermitOfferInbox.svelte.d.ts.map +1 -0
  177. package/dist/ui/account_sessions_state.svelte.d.ts +53 -3
  178. package/dist/ui/account_sessions_state.svelte.d.ts.map +1 -1
  179. package/dist/ui/account_sessions_state.svelte.js +39 -16
  180. package/dist/ui/admin_accounts_state.svelte.d.ts +118 -2
  181. package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
  182. package/dist/ui/admin_accounts_state.svelte.js +99 -23
  183. package/dist/ui/admin_invites_state.svelte.d.ts +47 -1
  184. package/dist/ui/admin_invites_state.svelte.d.ts.map +1 -1
  185. package/dist/ui/admin_invites_state.svelte.js +38 -26
  186. package/dist/ui/admin_sessions_state.svelte.d.ts +26 -0
  187. package/dist/ui/admin_sessions_state.svelte.d.ts.map +1 -1
  188. package/dist/ui/admin_sessions_state.svelte.js +35 -21
  189. package/dist/ui/app_settings_state.svelte.d.ts +39 -0
  190. package/dist/ui/app_settings_state.svelte.d.ts.map +1 -1
  191. package/dist/ui/app_settings_state.svelte.js +34 -18
  192. package/dist/ui/audit_log_state.svelte.d.ts +40 -3
  193. package/dist/ui/audit_log_state.svelte.d.ts.map +1 -1
  194. package/dist/ui/audit_log_state.svelte.js +36 -42
  195. package/dist/ui/auth_state.svelte.d.ts +4 -3
  196. package/dist/ui/auth_state.svelte.d.ts.map +1 -1
  197. package/dist/ui/auth_state.svelte.js +4 -1
  198. package/dist/ui/permit_offers_state.svelte.d.ts +125 -0
  199. package/dist/ui/permit_offers_state.svelte.d.ts.map +1 -0
  200. package/dist/ui/permit_offers_state.svelte.js +197 -0
  201. package/package.json +3 -3
  202. package/dist/auth/admin_routes.d.ts +0 -29
  203. package/dist/auth/admin_routes.d.ts.map +0 -1
  204. package/dist/auth/admin_routes.js +0 -226
  205. package/dist/auth/app_settings_routes.d.ts +0 -27
  206. package/dist/auth/app_settings_routes.d.ts.map +0 -1
  207. package/dist/auth/app_settings_routes.js +0 -66
  208. package/dist/auth/invite_routes.d.ts +0 -18
  209. package/dist/auth/invite_routes.d.ts.map +0 -1
  210. package/dist/auth/invite_routes.js +0 -129
@@ -16,14 +16,18 @@ import './assert_dev_env.js';
16
16
  *
17
17
  * @module
18
18
  */
19
- import { describe, test, assert, afterAll } from 'vitest';
19
+ import { describe, test, assert, beforeAll, afterAll } from 'vitest';
20
20
  import { AUTH_MIGRATION_NS } from '../auth/migrations.js';
21
21
  import { create_test_app } from './app_server.js';
22
22
  import { create_pglite_factory, create_describe_db, AUTH_INTEGRATION_TRUNCATE_TABLES, } from './db.js';
23
23
  import { find_auth_route, assert_response_matches_spec, create_expired_test_cookie, assert_no_error_info_leakage, } from './integration_helpers.js';
24
+ import { find_rpc_action, rpc_call, rpc_call_non_browser, require_rpc_endpoint_path, } from './rpc_helpers.js';
24
25
  import { RateLimiter } from '../rate_limiter.js';
25
26
  import { run_migrations } from '../db/migrate.js';
26
27
  import { ErrorCoverageCollector, assert_error_coverage, DEFAULT_INTEGRATION_ERROR_COVERAGE, } from './error_coverage.js';
28
+ import { ApiError, ERROR_FORBIDDEN_ORIGIN } from '../http/error_schemas.js';
29
+ import { account_verify_action_spec, account_session_list_action_spec, account_session_revoke_action_spec, account_session_revoke_all_action_spec, account_token_create_action_spec, account_token_list_action_spec, account_token_revoke_action_spec, } from '../auth/account_action_specs.js';
30
+ import { invite_create_action_spec } from '../auth/admin_action_specs.js';
27
31
  /**
28
32
  * Build `CreateTestAppOptions` from standard options plus a database.
29
33
  */
@@ -48,6 +52,9 @@ const build_test_app_options = (options, db) => ({
48
52
  * @param options - session config and route factory
49
53
  */
50
54
  export const describe_standard_integration_tests = (options) => {
55
+ // Hard-fail early so consumers see a clear setup error instead of a
56
+ // confusing test failure when `rpc_endpoints` is missing.
57
+ const rpc_path = require_rpc_endpoint_path(options.rpc_endpoints);
51
58
  const init_schema = async (db) => {
52
59
  await run_migrations(db, [AUTH_MIGRATION_NS]);
53
60
  };
@@ -58,27 +65,28 @@ export const describe_standard_integration_tests = (options) => {
58
65
  // Error coverage tracking across test groups
59
66
  const error_collector = new ErrorCoverageCollector();
60
67
  let captured_route_specs = null;
68
+ beforeAll(async () => {
69
+ // Capture route specs once up front so coverage assertion runs even if
70
+ // individual tests are skipped or fail early. Route specs are immutable
71
+ // config; the transient test app is discarded.
72
+ const test_app = await create_test_app(build_test_app_options(options, get_db()));
73
+ captured_route_specs = test_app.route_specs;
74
+ });
61
75
  afterAll(() => {
62
76
  if (captured_route_specs) {
63
- // Scope coverage to auth-related routes that this suite exercises.
64
- // Consumer-specific routes (tx runs, state, etc.) are not exercised
65
- // by the standard suite and would dilute the coverage percentage.
66
- const auth_suffixes = [
67
- '/login',
68
- '/logout',
69
- '/verify',
70
- '/sessions',
71
- '/sessions/revoke-all',
72
- '/tokens',
73
- '/tokens/create',
74
- '/password',
75
- '/signup',
76
- '/bootstrap',
77
- ];
78
- const auth_routes = captured_route_specs.filter((s) => (auth_suffixes.some((suffix) => s.path.endsWith(suffix)) ||
79
- s.path.includes('/sessions/:') ||
80
- s.path.includes('/tokens/:')) &&
81
- !(s.auth.type === 'role' && s.auth.role === 'admin'));
77
+ // Scope coverage to auth routes this suite actually exercises:
78
+ // the REST auth surface (login/logout/password/signup/bootstrap)
79
+ // plus the shared RPC endpoint. Consumer-specific routes would
80
+ // dilute the coverage percentage; admin-role routes are scoped
81
+ // to the admin suite instead.
82
+ const auth_routes = captured_route_specs.filter((s) => {
83
+ if (s.auth.type === 'role' && s.auth.role === 'admin')
84
+ return false;
85
+ const rest_suffixes = ['/login', '/logout', '/password', '/signup', '/bootstrap'];
86
+ if (rest_suffixes.some((suffix) => s.path.endsWith(suffix)))
87
+ return true;
88
+ return s.path === rpc_path;
89
+ });
82
90
  assert_error_coverage(error_collector, auth_routes.length > 0 ? auth_routes : captured_route_specs, {
83
91
  min_coverage: DEFAULT_INTEGRATION_ERROR_COVERAGE,
84
92
  });
@@ -111,7 +119,6 @@ export const describe_standard_integration_tests = (options) => {
111
119
  });
112
120
  test('login with wrong password returns 401', async () => {
113
121
  const test_app = await create_test_app(build_test_app_options(options, get_db()));
114
- captured_route_specs ??= test_app.route_specs;
115
122
  const login_route = find_auth_route(test_app.route_specs, '/login', 'POST');
116
123
  assert.ok(login_route, 'Expected POST /login route — ensure create_route_specs includes account routes');
117
124
  const res = await test_app.app.request(login_route.path, {
@@ -175,10 +182,8 @@ export const describe_standard_integration_tests = (options) => {
175
182
  test('full cycle: login → verify → logout → verify fails', async () => {
176
183
  const test_app = await create_test_app(build_test_app_options(options, get_db()));
177
184
  const login_route = find_auth_route(test_app.route_specs, '/login', 'POST');
178
- const verify_route = find_auth_route(test_app.route_specs, '/verify', 'GET');
179
185
  const logout_route = find_auth_route(test_app.route_specs, '/logout', 'POST');
180
186
  assert.ok(login_route, 'Expected POST /login route — ensure create_route_specs includes account routes');
181
- assert.ok(verify_route, 'Expected GET /verify route — ensure create_route_specs includes account routes');
182
187
  assert.ok(logout_route, 'Expected POST /logout route — ensure create_route_specs includes account routes');
183
188
  // Login
184
189
  const login_res = await test_app.app.request(login_route.path, {
@@ -206,7 +211,10 @@ export const describe_standard_integration_tests = (options) => {
206
211
  cookie: `${cookie_name}=${login_cookie}`,
207
212
  });
208
213
  // Verify works
209
- const verify_res = await test_app.app.request(verify_route.path, {
214
+ const verify_res = await rpc_call({
215
+ app: test_app.app,
216
+ path: rpc_path,
217
+ method: account_verify_action_spec.method,
210
218
  headers: create_headers(),
211
219
  });
212
220
  assert.strictEqual(verify_res.status, 200);
@@ -220,7 +228,10 @@ export const describe_standard_integration_tests = (options) => {
220
228
  assert.strictEqual(logout_body.ok, true);
221
229
  assert.strictEqual(logout_body.username, test_app.backend.account.username, 'Logout response should include the username');
222
230
  // Verify fails after logout (session revoked)
223
- const verify_after = await test_app.app.request(verify_route.path, {
231
+ const verify_after = await rpc_call({
232
+ app: test_app.app,
233
+ path: rpc_path,
234
+ method: account_verify_action_spec.method,
224
235
  headers: create_headers(),
225
236
  });
226
237
  assert.strictEqual(verify_after.status, 401);
@@ -288,93 +299,99 @@ export const describe_standard_integration_tests = (options) => {
288
299
  describe('session security', () => {
289
300
  test('no cookie on protected route returns 401', async () => {
290
301
  const test_app = await create_test_app(build_test_app_options(options, get_db()));
291
- const verify_route = find_auth_route(test_app.route_specs, '/verify', 'GET');
292
- assert.ok(verify_route, 'Expected GET /verify route — ensure create_route_specs includes account routes');
293
- const res = await test_app.app.request(verify_route.path, {
302
+ const res = await rpc_call({
303
+ app: test_app.app,
304
+ path: rpc_path,
305
+ method: account_verify_action_spec.method,
294
306
  headers: { host: 'localhost' },
295
307
  });
296
308
  assert.strictEqual(res.status, 401);
297
- error_collector.record(test_app.route_specs, 'GET', verify_route.path, 401);
298
309
  });
299
310
  test('corrupted cookie returns 401', async () => {
300
311
  const test_app = await create_test_app(build_test_app_options(options, get_db()));
301
- const verify_route = find_auth_route(test_app.route_specs, '/verify', 'GET');
302
- assert.ok(verify_route, 'Expected GET /verify route — ensure create_route_specs includes account routes');
303
- const res = await test_app.app.request(verify_route.path, {
304
- headers: {
305
- host: 'localhost',
306
- cookie: `${cookie_name}=random_garbage_value`,
307
- },
312
+ const res = await rpc_call({
313
+ app: test_app.app,
314
+ path: rpc_path,
315
+ method: account_verify_action_spec.method,
316
+ headers: { cookie: `${cookie_name}=random_garbage_value` },
308
317
  });
309
318
  assert.strictEqual(res.status, 401);
310
- error_collector.record(test_app.route_specs, 'GET', verify_route.path, 401);
311
319
  });
312
320
  test('expired cookie returns 401', async () => {
313
321
  const test_app = await create_test_app(build_test_app_options(options, get_db()));
314
- const verify_route = find_auth_route(test_app.route_specs, '/verify', 'GET');
315
- assert.ok(verify_route, 'Expected GET /verify route — ensure create_route_specs includes account routes');
316
322
  const expired_cookie = await create_expired_test_cookie(test_app.backend.keyring, options.session_options);
317
- const res = await test_app.app.request(verify_route.path, {
318
- headers: {
319
- host: 'localhost',
320
- cookie: `${cookie_name}=${expired_cookie}`,
321
- },
323
+ const res = await rpc_call({
324
+ app: test_app.app,
325
+ path: rpc_path,
326
+ method: account_verify_action_spec.method,
327
+ headers: { cookie: `${cookie_name}=${expired_cookie}` },
322
328
  });
323
329
  assert.strictEqual(res.status, 401);
324
- error_collector.record(test_app.route_specs, 'GET', verify_route.path, 401);
325
330
  });
326
331
  });
327
332
  // --- 4. Session revocation ---
328
333
  describe('session revocation', () => {
329
334
  test('revoke single session by ID invalidates that session', async () => {
330
335
  const test_app = await create_test_app(build_test_app_options(options, get_db()));
331
- const sessions_route = find_auth_route(test_app.route_specs, '/sessions', 'GET');
332
- const revoke_route = test_app.route_specs.find((s) => s.method === 'POST' &&
333
- s.path.endsWith('/sessions/:id/revoke') &&
334
- s.auth.type === 'authenticated');
335
- const verify_route = find_auth_route(test_app.route_specs, '/verify', 'GET');
336
- assert.ok(sessions_route, 'Expected GET /sessions route — ensure create_route_specs includes account routes');
337
- assert.ok(revoke_route, 'Expected POST /sessions/:id/revoke route — ensure create_route_specs includes account routes');
338
- assert.ok(verify_route, 'Expected GET /verify route — ensure create_route_specs includes account routes');
339
336
  const headers = test_app.create_session_headers();
340
337
  // List own sessions to get the session ID
341
- const list_res = await test_app.app.request(sessions_route.path, { headers });
342
- assert.strictEqual(list_res.status, 200);
343
- const list_body = await list_res.json();
338
+ const list_res = await rpc_call({
339
+ app: test_app.app,
340
+ path: rpc_path,
341
+ method: account_session_list_action_spec.method,
342
+ headers,
343
+ });
344
+ assert.ok(list_res.ok, 'account_session_list should succeed');
345
+ const list_body = list_res.result;
344
346
  assert.ok(list_body.sessions.length >= 1);
345
347
  const session_id = list_body.sessions[0].id;
346
348
  // Revoke that session by ID
347
- const revoke_path = revoke_route.path.replace(':id', session_id);
348
- const revoke_res = await test_app.app.request(revoke_path, {
349
- method: 'POST',
349
+ const revoke_res = await rpc_call({
350
+ app: test_app.app,
351
+ path: rpc_path,
352
+ method: account_session_revoke_action_spec.method,
353
+ params: { session_id },
350
354
  headers,
351
355
  });
352
- assert.strictEqual(revoke_res.status, 200);
353
- const revoke_body = await revoke_res.json();
356
+ assert.ok(revoke_res.ok, 'account_session_revoke should succeed');
357
+ const revoke_body = revoke_res.result;
354
358
  assert.strictEqual(revoke_body.ok, true);
355
359
  assert.strictEqual(revoke_body.revoked, true);
356
360
  // Session should no longer work
357
- const after = await test_app.app.request(verify_route.path, { headers });
361
+ const after = await rpc_call({
362
+ app: test_app.app,
363
+ path: rpc_path,
364
+ method: account_verify_action_spec.method,
365
+ headers,
366
+ });
358
367
  assert.strictEqual(after.status, 401);
359
368
  });
360
369
  test('revoke-all invalidates existing session', async () => {
361
370
  const test_app = await create_test_app(build_test_app_options(options, get_db()));
362
- const verify_route = find_auth_route(test_app.route_specs, '/verify', 'GET');
363
- const revoke_route = find_auth_route(test_app.route_specs, '/sessions/revoke-all', 'POST');
364
- assert.ok(verify_route, 'Expected GET /verify route — ensure create_route_specs includes account routes');
365
- assert.ok(revoke_route, 'Expected POST /sessions/revoke-all route — ensure create_route_specs includes account routes');
366
371
  const headers = test_app.create_session_headers();
367
372
  // Verify works
368
- const before = await test_app.app.request(verify_route.path, { headers });
373
+ const before = await rpc_call({
374
+ app: test_app.app,
375
+ path: rpc_path,
376
+ method: account_verify_action_spec.method,
377
+ headers,
378
+ });
369
379
  assert.strictEqual(before.status, 200);
370
380
  // Revoke all sessions
371
- const revoke_res = await test_app.app.request(revoke_route.path, {
372
- method: 'POST',
381
+ const revoke_res = await rpc_call({
382
+ app: test_app.app,
383
+ path: rpc_path,
384
+ method: account_session_revoke_all_action_spec.method,
373
385
  headers,
374
386
  });
375
- assert.strictEqual(revoke_res.status, 200);
387
+ assert.ok(revoke_res.ok, 'account_session_revoke_all should succeed');
376
388
  // Verify fails after revocation
377
- const after = await test_app.app.request(verify_route.path, { headers });
389
+ const after = await rpc_call({
390
+ app: test_app.app,
391
+ path: rpc_path,
392
+ method: account_verify_action_spec.method,
393
+ headers,
394
+ });
378
395
  assert.strictEqual(after.status, 401);
379
396
  });
380
397
  });
@@ -384,10 +401,8 @@ export const describe_standard_integration_tests = (options) => {
384
401
  const test_app = await create_test_app(build_test_app_options(options, get_db()));
385
402
  const login_route = find_auth_route(test_app.route_specs, '/login', 'POST');
386
403
  const password_route = find_auth_route(test_app.route_specs, '/password', 'POST');
387
- const verify_route = find_auth_route(test_app.route_specs, '/verify', 'GET');
388
404
  assert.ok(login_route, 'Expected POST /login route — ensure create_route_specs includes account routes');
389
405
  assert.ok(password_route, 'Expected POST /password route — ensure create_route_specs includes account routes');
390
- assert.ok(verify_route, 'Expected GET /verify route — ensure create_route_specs includes account routes');
391
406
  const headers = test_app.create_session_headers({
392
407
  'content-type': 'application/json',
393
408
  });
@@ -406,7 +421,10 @@ export const describe_standard_integration_tests = (options) => {
406
421
  assert.ok(typeof change_body.sessions_revoked === 'number', 'Expected sessions_revoked count');
407
422
  assert.ok(change_body.sessions_revoked >= 1, 'Expected at least 1 session revoked');
408
423
  // Old session should be invalid
409
- const verify_after = await test_app.app.request(verify_route.path, {
424
+ const verify_after = await rpc_call({
425
+ app: test_app.app,
426
+ path: rpc_path,
427
+ method: account_verify_action_spec.method,
410
428
  headers: test_app.create_session_headers(),
411
429
  });
412
430
  assert.strictEqual(verify_after.status, 401);
@@ -430,9 +448,7 @@ export const describe_standard_integration_tests = (options) => {
430
448
  test('password change with wrong current password returns 401', async () => {
431
449
  const test_app = await create_test_app(build_test_app_options(options, get_db()));
432
450
  const password_route = find_auth_route(test_app.route_specs, '/password', 'POST');
433
- const verify_route = find_auth_route(test_app.route_specs, '/verify', 'GET');
434
451
  assert.ok(password_route, 'Expected POST /password route — ensure create_route_specs includes account routes');
435
- assert.ok(verify_route, 'Expected GET /verify route — ensure create_route_specs includes account routes');
436
452
  const res = await test_app.app.request(password_route.path, {
437
453
  method: 'POST',
438
454
  headers: test_app.create_session_headers({
@@ -446,7 +462,10 @@ export const describe_standard_integration_tests = (options) => {
446
462
  assert.strictEqual(res.status, 401);
447
463
  error_collector.record(test_app.route_specs, 'POST', password_route.path, 401);
448
464
  // Session should still be valid (password didn't change)
449
- const verify_res = await test_app.app.request(verify_route.path, {
465
+ const verify_res = await rpc_call({
466
+ app: test_app.app,
467
+ path: rpc_path,
468
+ method: account_verify_action_spec.method,
450
469
  headers: test_app.create_session_headers(),
451
470
  });
452
471
  assert.strictEqual(verify_res.status, 200);
@@ -456,37 +475,45 @@ export const describe_standard_integration_tests = (options) => {
456
475
  describe('origin verification', () => {
457
476
  test('evil origin is rejected with 403', async () => {
458
477
  const test_app = await create_test_app(build_test_app_options(options, get_db()));
459
- const verify_route = find_auth_route(test_app.route_specs, '/verify', 'GET');
460
- assert.ok(verify_route, 'Expected GET /verify routeensure create_route_specs includes account routes');
461
- const res = await test_app.app.request(verify_route.path, {
478
+ // `verify_request_source` runs before the RPC dispatcher and returns a
479
+ // plain REST `{error}` bodynot a JSON-RPC envelope. Skip `rpc_call`.
480
+ const res = await test_app.app.request(rpc_path, {
481
+ method: 'POST',
462
482
  headers: {
463
483
  host: 'localhost',
464
484
  origin: 'http://evil.com',
485
+ 'content-type': 'application/json',
465
486
  cookie: `${cookie_name}=${test_app.backend.session_cookie}`,
466
487
  },
488
+ body: JSON.stringify({
489
+ jsonrpc: '2.0',
490
+ method: account_verify_action_spec.method,
491
+ id: 'evil-origin',
492
+ }),
467
493
  });
468
494
  assert.strictEqual(res.status, 403);
469
- const body = await res.json();
470
- assert.strictEqual(body.error, 'forbidden_origin');
495
+ const body = ApiError.parse(await res.json());
496
+ assert.strictEqual(body.error, ERROR_FORBIDDEN_ORIGIN);
471
497
  });
472
498
  test('valid origin is accepted', async () => {
473
499
  const test_app = await create_test_app(build_test_app_options(options, get_db()));
474
- const verify_route = find_auth_route(test_app.route_specs, '/verify', 'GET');
475
- assert.ok(verify_route, 'Expected GET /verify route — ensure create_route_specs includes account routes');
476
- const res = await test_app.app.request(verify_route.path, {
500
+ const res = await rpc_call({
501
+ app: test_app.app,
502
+ path: rpc_path,
503
+ method: account_verify_action_spec.method,
477
504
  headers: test_app.create_session_headers(),
478
505
  });
479
506
  assert.strictEqual(res.status, 200);
480
507
  });
481
508
  test('no origin header is allowed (direct access)', async () => {
482
509
  const test_app = await create_test_app(build_test_app_options(options, get_db()));
483
- const verify_route = find_auth_route(test_app.route_specs, '/verify', 'GET');
484
- assert.ok(verify_route, 'Expected GET /verify route — ensure create_route_specs includes account routes');
485
- const res = await test_app.app.request(verify_route.path, {
486
- headers: {
487
- host: 'localhost',
488
- cookie: `${cookie_name}=${test_app.backend.session_cookie}`,
489
- },
510
+ // Probe the "no Origin / no Referer" path; `rpc_call_non_browser`
511
+ // suppresses the default `origin` header.
512
+ const res = await rpc_call_non_browser({
513
+ app: test_app.app,
514
+ path: rpc_path,
515
+ method: account_verify_action_spec.method,
516
+ headers: { cookie: `${cookie_name}=${test_app.backend.session_cookie}` },
490
517
  });
491
518
  assert.notStrictEqual(res.status, 403);
492
519
  });
@@ -495,86 +522,84 @@ export const describe_standard_integration_tests = (options) => {
495
522
  describe('bearer auth', () => {
496
523
  test('valid bearer token authenticates', async () => {
497
524
  const test_app = await create_test_app(build_test_app_options(options, get_db()));
498
- const verify_route = find_auth_route(test_app.route_specs, '/verify', 'GET');
499
- assert.ok(verify_route, 'Expected GET /verify route — ensure create_route_specs includes account routes');
500
- const res = await test_app.app.request(verify_route.path, {
525
+ const res = await rpc_call_non_browser({
526
+ app: test_app.app,
527
+ path: rpc_path,
528
+ method: account_verify_action_spec.method,
501
529
  headers: test_app.create_bearer_headers(),
502
530
  });
503
531
  assert.strictEqual(res.status, 200);
504
532
  });
505
533
  test('invalid bearer token returns 401', async () => {
506
534
  const test_app = await create_test_app(build_test_app_options(options, get_db()));
507
- const verify_route = find_auth_route(test_app.route_specs, '/verify', 'GET');
508
- assert.ok(verify_route, 'Expected GET /verify route — ensure create_route_specs includes account routes');
509
- const res = await test_app.app.request(verify_route.path, {
510
- headers: {
511
- host: 'localhost',
512
- authorization: 'Bearer secret_fuz_token_invalid',
513
- },
535
+ const res = await rpc_call_non_browser({
536
+ app: test_app.app,
537
+ path: rpc_path,
538
+ method: account_verify_action_spec.method,
539
+ headers: { authorization: 'Bearer secret_fuz_token_invalid' },
514
540
  });
515
541
  assert.strictEqual(res.status, 401);
516
- error_collector.record(test_app.route_specs, 'GET', verify_route.path, 401);
517
542
  });
518
543
  test('bearer token with Origin header is rejected', async () => {
519
544
  const test_app = await create_test_app(build_test_app_options(options, get_db()));
520
- const verify_route = find_auth_route(test_app.route_specs, '/verify', 'GET');
521
- assert.ok(verify_route, 'Expected GET /verify route — ensure create_route_specs includes account routes');
522
545
  const bearer_headers = test_app.create_bearer_headers();
523
- // Without Origin — works
524
- const ok_res = await test_app.app.request(verify_route.path, {
546
+ // Without Origin — works.
547
+ const ok_res = await rpc_call_non_browser({
548
+ app: test_app.app,
549
+ path: rpc_path,
550
+ method: account_verify_action_spec.method,
525
551
  headers: bearer_headers,
526
552
  });
527
553
  assert.strictEqual(ok_res.status, 200);
528
- // With Origin — bearer silently discarded (browser context), falls through to no auth
529
- const res = await test_app.app.request(verify_route.path, {
530
- headers: {
531
- ...bearer_headers,
532
- origin: 'http://localhost:5173',
533
- },
554
+ // With Origin — bearer silently discarded (browser context), falls through to no auth.
555
+ const res = await rpc_call({
556
+ app: test_app.app,
557
+ path: rpc_path,
558
+ method: account_verify_action_spec.method,
559
+ headers: { ...bearer_headers, origin: 'http://localhost:5173' },
534
560
  });
535
561
  assert.strictEqual(res.status, 401);
536
- error_collector.record(test_app.route_specs, 'GET', verify_route.path, 401);
537
562
  });
538
563
  });
539
564
  // --- 7. Token revocation ---
540
565
  describe('token revocation', () => {
541
566
  test('revoked API token returns 401', async () => {
542
567
  const test_app = await create_test_app(build_test_app_options(options, get_db()));
543
- const verify_route = find_auth_route(test_app.route_specs, '/verify', 'GET');
544
- const create_token_route = find_auth_route(test_app.route_specs, '/tokens/create', 'POST');
545
- const revoke_token_route = test_app.route_specs.find((s) => s.method === 'POST' && s.path.endsWith('/tokens/:id/revoke'));
546
- assert.ok(verify_route, 'Expected GET /verify route — ensure create_route_specs includes account routes');
547
- assert.ok(create_token_route, 'Expected POST /tokens/create route — ensure create_route_specs includes account routes');
548
- assert.ok(revoke_token_route, 'Expected POST /tokens/:id/revoke route — ensure create_route_specs includes account routes');
549
- // Create a new token via the API
550
- const create_res = await test_app.app.request(create_token_route.path, {
551
- method: 'POST',
552
- headers: {
553
- ...test_app.create_session_headers(),
554
- 'content-type': 'application/json',
555
- },
556
- body: JSON.stringify({ name: 'test-revoke' }),
568
+ // Create a new token via RPC
569
+ const create_res = await rpc_call({
570
+ app: test_app.app,
571
+ path: rpc_path,
572
+ method: account_token_create_action_spec.method,
573
+ params: { name: 'test-revoke' },
574
+ headers: test_app.create_session_headers(),
557
575
  });
558
- assert.strictEqual(create_res.status, 200);
559
- const { token, id } = (await create_res.json());
576
+ assert.ok(create_res.ok, 'account_token_create should succeed');
577
+ const { token, id } = create_res.result;
560
578
  // Verify token works
561
- const use_res = await test_app.app.request(verify_route.path, {
562
- headers: { host: 'localhost', authorization: `Bearer ${token}` },
579
+ const use_res = await rpc_call_non_browser({
580
+ app: test_app.app,
581
+ path: rpc_path,
582
+ method: account_verify_action_spec.method,
583
+ headers: { authorization: `Bearer ${token}` },
563
584
  });
564
585
  assert.strictEqual(use_res.status, 200);
565
- // Revoke via HTTP
566
- const revoke_path = revoke_token_route.path.replace(':id', id);
567
- const revoke_res = await test_app.app.request(revoke_path, {
568
- method: 'POST',
586
+ // Revoke via RPC
587
+ const revoke_res = await rpc_call({
588
+ app: test_app.app,
589
+ path: rpc_path,
590
+ method: account_token_revoke_action_spec.method,
591
+ params: { token_id: id },
569
592
  headers: test_app.create_session_headers(),
570
593
  });
571
- assert.strictEqual(revoke_res.status, 200);
594
+ assert.ok(revoke_res.ok, 'account_token_revoke should succeed');
572
595
  // Token should no longer work
573
- const after_res = await test_app.app.request(verify_route.path, {
574
- headers: { host: 'localhost', authorization: `Bearer ${token}` },
596
+ const after_res = await rpc_call_non_browser({
597
+ app: test_app.app,
598
+ path: rpc_path,
599
+ method: account_verify_action_spec.method,
600
+ headers: { authorization: `Bearer ${token}` },
575
601
  });
576
602
  assert.strictEqual(after_res.status, 401);
577
- error_collector.record(test_app.route_specs, 'GET', verify_route.path, 401);
578
603
  });
579
604
  });
580
605
  // --- 8. Cross-account isolation ---
@@ -596,114 +621,107 @@ export const describe_standard_integration_tests = (options) => {
596
621
  });
597
622
  test("user A cannot revoke user B's sessions", async () => {
598
623
  const test_app = await create_test_app(build_test_app_options(options, get_db()));
599
- const revoke_all_route = find_auth_route(test_app.route_specs, '/sessions/revoke-all', 'POST');
600
- const verify_route = find_auth_route(test_app.route_specs, '/verify', 'GET');
601
- assert.ok(revoke_all_route, 'Expected POST /sessions/revoke-all route — ensure create_route_specs includes account routes');
602
- assert.ok(verify_route, 'Expected GET /verify route — ensure create_route_specs includes account routes');
603
624
  // Create a second account
604
625
  const user_b = await test_app.create_account({ username: 'user_b' });
605
626
  // User A revokes all their own sessions
606
- const revoke_res = await test_app.app.request(revoke_all_route.path, {
607
- method: 'POST',
627
+ const revoke_res = await rpc_call({
628
+ app: test_app.app,
629
+ path: rpc_path,
630
+ method: account_session_revoke_all_action_spec.method,
608
631
  headers: test_app.create_session_headers(),
609
632
  });
610
- assert.strictEqual(revoke_res.status, 200);
633
+ assert.ok(revoke_res.ok, 'account_session_revoke_all should succeed');
611
634
  // User B's session should still work
612
- const verify_b = await test_app.app.request(verify_route.path, {
613
- headers: {
614
- host: 'localhost',
615
- cookie: `${cookie_name}=${user_b.session_cookie}`,
616
- },
635
+ const verify_b = await rpc_call({
636
+ app: test_app.app,
637
+ path: rpc_path,
638
+ method: account_verify_action_spec.method,
639
+ headers: { cookie: `${cookie_name}=${user_b.session_cookie}` },
617
640
  });
618
641
  assert.strictEqual(verify_b.status, 200);
619
642
  });
620
643
  test("user A cannot revoke user B's session by ID", async () => {
621
644
  const test_app = await create_test_app(build_test_app_options(options, get_db()));
622
- const sessions_route = find_auth_route(test_app.route_specs, '/sessions', 'GET');
623
- const revoke_route = test_app.route_specs.find((s) => s.method === 'POST' &&
624
- s.path.endsWith('/sessions/:id/revoke') &&
625
- s.auth.type === 'authenticated');
626
- const verify_route = find_auth_route(test_app.route_specs, '/verify', 'GET');
627
- assert.ok(sessions_route, 'Expected GET /sessions route — ensure create_route_specs includes account routes');
628
- assert.ok(revoke_route, 'Expected POST /sessions/:id/revoke route — ensure create_route_specs includes account routes');
629
- assert.ok(verify_route, 'Expected GET /verify route — ensure create_route_specs includes account routes');
630
645
  const user_b = await test_app.create_account({ username: 'user_b' });
631
- const user_b_headers = {
632
- host: 'localhost',
633
- cookie: `${cookie_name}=${user_b.session_cookie}`,
634
- };
646
+ const user_b_headers = { cookie: `${cookie_name}=${user_b.session_cookie}` };
635
647
  // Get user B's session ID by listing as user B
636
- const list_res = await test_app.app.request(sessions_route.path, {
648
+ const list_res = await rpc_call({
649
+ app: test_app.app,
650
+ path: rpc_path,
651
+ method: account_session_list_action_spec.method,
637
652
  headers: user_b_headers,
638
653
  });
639
- assert.strictEqual(list_res.status, 200);
640
- const list_body = await list_res.json();
654
+ assert.ok(list_res.ok, 'account_session_list should succeed');
655
+ const list_body = list_res.result;
641
656
  assert.ok(list_body.sessions.length >= 1);
642
657
  const session_id_b = list_body.sessions[0].id;
643
658
  // User A tries to revoke user B's session by ID
644
- const revoke_path = revoke_route.path.replace(':id', session_id_b);
645
- const revoke_res = await test_app.app.request(revoke_path, {
646
- method: 'POST',
659
+ const revoke_res = await rpc_call({
660
+ app: test_app.app,
661
+ path: rpc_path,
662
+ method: account_session_revoke_action_spec.method,
663
+ params: { session_id: session_id_b },
647
664
  headers: test_app.create_session_headers(),
648
665
  });
649
- assert.strictEqual(revoke_res.status, 200);
650
- const revoke_body = await revoke_res.json();
666
+ assert.ok(revoke_res.ok, 'account_session_revoke should succeed');
667
+ const revoke_body = revoke_res.result;
651
668
  assert.strictEqual(revoke_body.revoked, false, 'Should not revoke another account session');
652
669
  // User B's session should still work
653
- const verify_b = await test_app.app.request(verify_route.path, {
670
+ const verify_b = await rpc_call({
671
+ app: test_app.app,
672
+ path: rpc_path,
673
+ method: account_verify_action_spec.method,
654
674
  headers: user_b_headers,
655
675
  });
656
676
  assert.strictEqual(verify_b.status, 200);
657
677
  });
658
678
  test("user A cannot revoke user B's token by ID", async () => {
659
679
  const test_app = await create_test_app(build_test_app_options(options, get_db()));
660
- const tokens_route = find_auth_route(test_app.route_specs, '/tokens', 'GET');
661
- const revoke_route = test_app.route_specs.find((s) => s.method === 'POST' &&
662
- s.path.endsWith('/tokens/:id/revoke') &&
663
- s.auth.type === 'authenticated');
664
- const verify_route = find_auth_route(test_app.route_specs, '/verify', 'GET');
665
- assert.ok(tokens_route, 'Expected GET /tokens route — ensure create_route_specs includes account routes');
666
- assert.ok(revoke_route, 'Expected POST /tokens/:id/revoke route — ensure create_route_specs includes account routes');
667
- assert.ok(verify_route, 'Expected GET /verify route — ensure create_route_specs includes account routes');
668
680
  const user_b = await test_app.create_account({ username: 'user_b' });
669
- const user_b_headers = {
670
- host: 'localhost',
671
- cookie: `${cookie_name}=${user_b.session_cookie}`,
672
- };
681
+ const user_b_headers = { cookie: `${cookie_name}=${user_b.session_cookie}` };
673
682
  // Get user B's token ID by listing as user B
674
- const list_res = await test_app.app.request(tokens_route.path, {
683
+ const list_res = await rpc_call({
684
+ app: test_app.app,
685
+ path: rpc_path,
686
+ method: account_token_list_action_spec.method,
675
687
  headers: user_b_headers,
676
688
  });
677
- assert.strictEqual(list_res.status, 200);
678
- const list_body = await list_res.json();
689
+ assert.ok(list_res.ok, 'account_token_list should succeed');
690
+ const list_body = list_res.result;
679
691
  assert.ok(list_body.tokens.length >= 1);
680
692
  const token_id_b = list_body.tokens[0].id;
681
693
  // User A tries to revoke user B's token by ID
682
- const revoke_path = revoke_route.path.replace(':id', token_id_b);
683
- const revoke_res = await test_app.app.request(revoke_path, {
684
- method: 'POST',
694
+ const revoke_res = await rpc_call({
695
+ app: test_app.app,
696
+ path: rpc_path,
697
+ method: account_token_revoke_action_spec.method,
698
+ params: { token_id: token_id_b },
685
699
  headers: test_app.create_session_headers(),
686
700
  });
687
- assert.strictEqual(revoke_res.status, 200);
688
- const revoke_body = await revoke_res.json();
701
+ assert.ok(revoke_res.ok, 'account_token_revoke should succeed');
702
+ const revoke_body = revoke_res.result;
689
703
  assert.strictEqual(revoke_body.revoked, false, 'Should not revoke another account token');
690
704
  // User B's bearer token should still work
691
- const verify_b = await test_app.app.request(verify_route.path, {
692
- headers: { host: 'localhost', authorization: `Bearer ${user_b.api_token}` },
705
+ const verify_b = await rpc_call_non_browser({
706
+ app: test_app.app,
707
+ path: rpc_path,
708
+ method: account_verify_action_spec.method,
709
+ headers: { authorization: `Bearer ${user_b.api_token}` },
693
710
  });
694
711
  assert.strictEqual(verify_b.status, 200);
695
712
  });
696
713
  test("user A's session list does not include user B's sessions", async () => {
697
714
  const test_app = await create_test_app(build_test_app_options(options, get_db()));
698
- const sessions_route = find_auth_route(test_app.route_specs, '/sessions', 'GET');
699
- assert.ok(sessions_route, 'Expected GET /sessions route — ensure create_route_specs includes account routes');
700
715
  const user_b = await test_app.create_account({ username: 'user_b' });
701
716
  // User A lists sessions
702
- const res = await test_app.app.request(sessions_route.path, {
717
+ const res = await rpc_call({
718
+ app: test_app.app,
719
+ path: rpc_path,
720
+ method: account_session_list_action_spec.method,
703
721
  headers: test_app.create_session_headers(),
704
722
  });
705
- assert.strictEqual(res.status, 200);
706
- const body = await res.json();
723
+ assert.ok(res.ok, 'account_session_list should succeed');
724
+ const body = res.result;
707
725
  // Sessions should only belong to user A's account
708
726
  for (const session of body.sessions) {
709
727
  assert.strictEqual(session.account_id, test_app.backend.account.id, `Session ${session.id} should belong to user A, not user B (${user_b.account.id})`);
@@ -711,15 +729,16 @@ export const describe_standard_integration_tests = (options) => {
711
729
  });
712
730
  test("user A's token list does not include user B's tokens", async () => {
713
731
  const test_app = await create_test_app(build_test_app_options(options, get_db()));
714
- const tokens_route = find_auth_route(test_app.route_specs, '/tokens', 'GET');
715
- assert.ok(tokens_route, 'Expected GET /tokens route — ensure create_route_specs includes account routes');
716
732
  const user_b = await test_app.create_account({ username: 'user_b' });
717
733
  // User A lists tokens
718
- const res = await test_app.app.request(tokens_route.path, {
734
+ const res = await rpc_call({
735
+ app: test_app.app,
736
+ path: rpc_path,
737
+ method: account_token_list_action_spec.method,
719
738
  headers: test_app.create_session_headers(),
720
739
  });
721
- assert.strictEqual(res.status, 200);
722
- const body = await res.json();
740
+ assert.ok(res.ok, 'account_token_list should succeed');
741
+ const body = res.result;
723
742
  // Tokens should only belong to user A's account
724
743
  for (const token of body.tokens) {
725
744
  assert.strictEqual(token.account_id, test_app.backend.account.id, `Token ${token.id} should belong to user A, not user B (${user_b.account.id})`);
@@ -728,58 +747,58 @@ export const describe_standard_integration_tests = (options) => {
728
747
  });
729
748
  // --- 9. Response body validation ---
730
749
  describe('response body validation', () => {
731
- test('401 response matches declared error schema', async () => {
750
+ // `assert_response_matches_spec` validates REST `RouteSpec` outputs.
751
+ // The account REST routes that used to cover this (/verify, /sessions,
752
+ // /tokens, /tokens/create) moved to RPC in the 2026-04-23 migration,
753
+ // so we exercise the remaining REST endpoints (/login, /logout,
754
+ // /password) against their declared schemas. RPC output validation is
755
+ // covered by `describe_rpc_round_trip_tests`.
756
+ test('POST /login 401 response matches declared error schema', async () => {
732
757
  const test_app = await create_test_app(build_test_app_options(options, get_db()));
733
- const verify_route = find_auth_route(test_app.route_specs, '/verify', 'GET');
734
- assert.ok(verify_route, 'Expected GET /verify route — ensure create_route_specs includes account routes');
735
- const res = await test_app.app.request(verify_route.path, {
736
- headers: { host: 'localhost' },
758
+ const login_route = find_auth_route(test_app.route_specs, '/login', 'POST');
759
+ assert.ok(login_route, 'Expected POST /login route — ensure create_route_specs includes account routes');
760
+ const res = await test_app.app.request(login_route.path, {
761
+ method: 'POST',
762
+ headers: {
763
+ host: 'localhost',
764
+ origin: 'http://localhost:5173',
765
+ 'content-type': 'application/json',
766
+ },
767
+ body: JSON.stringify({
768
+ username: 'nonexistent_user_xyz',
769
+ password: 'any-password',
770
+ }),
737
771
  });
738
772
  assert.strictEqual(res.status, 401);
739
- // Should not throw — body matches the declared error schema
740
- await assert_response_matches_spec(test_app.route_specs, 'GET', verify_route.path, res);
741
- });
742
- test('GET /verify 200 response matches output schema', async () => {
743
- const test_app = await create_test_app(build_test_app_options(options, get_db()));
744
- const verify_route = find_auth_route(test_app.route_specs, '/verify', 'GET');
745
- assert.ok(verify_route, 'Expected GET /verify route — ensure create_route_specs includes account routes');
746
- const res = await test_app.app.request(verify_route.path, {
747
- headers: test_app.create_session_headers(),
748
- });
749
- assert.strictEqual(res.status, 200);
750
- await assert_response_matches_spec(test_app.route_specs, 'GET', verify_route.path, res);
773
+ await assert_response_matches_spec(test_app.route_specs, 'POST', login_route.path, res);
751
774
  });
752
- test('GET /sessions 200 response matches output schema', async () => {
775
+ test('POST /logout 200 response matches output schema', async () => {
753
776
  const test_app = await create_test_app(build_test_app_options(options, get_db()));
754
- const sessions_route = find_auth_route(test_app.route_specs, '/sessions', 'GET');
755
- assert.ok(sessions_route, 'Expected GET /sessions route — ensure create_route_specs includes account routes');
756
- const res = await test_app.app.request(sessions_route.path, {
757
- headers: test_app.create_session_headers(),
758
- });
759
- assert.strictEqual(res.status, 200);
760
- await assert_response_matches_spec(test_app.route_specs, 'GET', sessions_route.path, res);
761
- });
762
- test('GET /tokens 200 response matches output schema', async () => {
763
- const test_app = await create_test_app(build_test_app_options(options, get_db()));
764
- const tokens_route = find_auth_route(test_app.route_specs, '/tokens', 'GET');
765
- assert.ok(tokens_route, 'Expected GET /tokens route — ensure create_route_specs includes account routes');
766
- const res = await test_app.app.request(tokens_route.path, {
767
- headers: test_app.create_session_headers(),
777
+ const logout_route = find_auth_route(test_app.route_specs, '/logout', 'POST');
778
+ assert.ok(logout_route, 'Expected POST /logout route — ensure create_route_specs includes account routes');
779
+ const res = await test_app.app.request(logout_route.path, {
780
+ method: 'POST',
781
+ headers: test_app.create_session_headers({ 'content-type': 'application/json' }),
782
+ body: JSON.stringify({}),
768
783
  });
769
784
  assert.strictEqual(res.status, 200);
770
- await assert_response_matches_spec(test_app.route_specs, 'GET', tokens_route.path, res);
785
+ await assert_response_matches_spec(test_app.route_specs, 'POST', logout_route.path, res);
771
786
  });
772
- test('POST /tokens/create 200 response matches output schema', async () => {
787
+ test('POST /logout 401 response matches declared error schema', async () => {
773
788
  const test_app = await create_test_app(build_test_app_options(options, get_db()));
774
- const create_token_route = find_auth_route(test_app.route_specs, '/tokens/create', 'POST');
775
- assert.ok(create_token_route, 'Expected POST /tokens/create route — ensure create_route_specs includes account routes');
776
- const res = await test_app.app.request(create_token_route.path, {
789
+ const logout_route = find_auth_route(test_app.route_specs, '/logout', 'POST');
790
+ assert.ok(logout_route, 'Expected POST /logout route — ensure create_route_specs includes account routes');
791
+ const res = await test_app.app.request(logout_route.path, {
777
792
  method: 'POST',
778
- headers: test_app.create_session_headers({ 'content-type': 'application/json' }),
779
- body: JSON.stringify({ name: 'schema-test' }),
793
+ headers: {
794
+ host: 'localhost',
795
+ origin: 'http://localhost:5173',
796
+ 'content-type': 'application/json',
797
+ },
798
+ body: JSON.stringify({}),
780
799
  });
781
- assert.strictEqual(res.status, 200);
782
- await assert_response_matches_spec(test_app.route_specs, 'POST', create_token_route.path, res);
800
+ assert.strictEqual(res.status, 401);
801
+ await assert_response_matches_spec(test_app.route_specs, 'POST', logout_route.path, res);
783
802
  });
784
803
  });
785
804
  // --- 10b. Rate limiting smoke test (full middleware stack) ---
@@ -833,31 +852,36 @@ export const describe_standard_integration_tests = (options) => {
833
852
  describe('error coverage breadth', () => {
834
853
  test('exercises 401 on multiple auth-required routes for error coverage', async () => {
835
854
  const test_app = await create_test_app(build_test_app_options(options, get_db()));
836
- // Hit several auth-required routes without credentials to broaden
837
- // error coverage beyond just /verify and /login
838
- const route_suffixes = ['/sessions', '/tokens', '/sessions/revoke-all', '/tokens/create'];
839
- for (const suffix of route_suffixes) {
840
- const route = find_auth_route(test_app.route_specs, suffix, suffix === '/tokens/create' || suffix === '/sessions/revoke-all' ? 'POST' : 'GET');
841
- if (!route)
842
- continue;
843
- const res = await test_app.app.request(route.path, {
844
- method: route.method,
855
+ // Hit several auth-required RPC methods without credentials to
856
+ // broaden error coverage beyond just /login. RPC 401s are tracked
857
+ // against the shared endpoint path.
858
+ const rpc_methods = [
859
+ account_session_list_action_spec.method,
860
+ account_session_revoke_all_action_spec.method,
861
+ account_token_list_action_spec.method,
862
+ account_token_create_action_spec.method,
863
+ account_verify_action_spec.method,
864
+ ];
865
+ for (const method of rpc_methods) {
866
+ const res = await rpc_call({
867
+ app: test_app.app,
868
+ path: rpc_path,
869
+ method,
845
870
  headers: { host: 'localhost' },
846
871
  });
847
- if (res.status === 401) {
848
- error_collector.record(test_app.route_specs, route.method, route.path, 401);
849
- }
872
+ assert.strictEqual(res.status, 401, `${method} without auth should return 401 (dispatcher runs auth before params)`);
873
+ error_collector.record(test_app.route_specs, 'POST', rpc_path, 401);
850
874
  }
851
- // Also exercise POST /logout without auth
875
+ // Also exercise POST /logout without auth (still REST)
852
876
  const logout_route = find_auth_route(test_app.route_specs, '/logout', 'POST');
853
877
  if (logout_route) {
854
878
  const res = await test_app.app.request(logout_route.path, {
855
879
  method: 'POST',
856
- headers: { host: 'localhost' },
880
+ headers: { host: 'localhost', 'content-type': 'application/json' },
881
+ body: JSON.stringify({}),
857
882
  });
858
- if (res.status === 401) {
859
- error_collector.record(test_app.route_specs, 'POST', logout_route.path, 401);
860
- }
883
+ assert.strictEqual(res.status, 401, 'POST /logout without auth should return 401');
884
+ error_collector.record(test_app.route_specs, 'POST', logout_route.path, 401);
861
885
  }
862
886
  });
863
887
  });
@@ -865,32 +889,32 @@ export const describe_standard_integration_tests = (options) => {
865
889
  describe('error response information leakage', () => {
866
890
  test('401 responses contain no leaky fields', async () => {
867
891
  const test_app = await create_test_app(build_test_app_options(options, get_db()));
868
- const verify_route = find_auth_route(test_app.route_specs, '/verify', 'GET');
869
- if (!verify_route)
870
- return;
871
- const res = await test_app.app.request(verify_route.path, {
892
+ const res = await rpc_call({
893
+ app: test_app.app,
894
+ path: rpc_path,
895
+ method: account_verify_action_spec.method,
872
896
  headers: { host: 'localhost' },
873
897
  });
874
898
  assert.strictEqual(res.status, 401);
875
- const body = await res.json();
876
- assert_no_error_info_leakage(body, `GET ${verify_route.path} 401`);
899
+ assert.ok(!res.ok);
900
+ // Check every field on the JSON-RPC `error` object `data` carries the
901
+ // handler-authored shape, but `message` and any sibling fields should
902
+ // equally be free of stack traces, file paths, or other internals.
903
+ assert_no_error_info_leakage(res.error, `RPC ${account_verify_action_spec.method} 401 error envelope`);
877
904
  });
878
905
  });
879
906
  // --- 11. Expired credential rejection ---
880
907
  describe('expired credential rejection', () => {
881
908
  test('expired session cookie returns 401', async () => {
882
909
  const test_app = await create_test_app(build_test_app_options(options, get_db()));
883
- const verify_route = find_auth_route(test_app.route_specs, '/verify', 'GET');
884
- assert.ok(verify_route, 'Expected GET /verify route — ensure create_route_specs includes account routes');
885
910
  const expired_cookie = await create_expired_test_cookie(test_app.backend.keyring, options.session_options);
886
- const res = await test_app.app.request(verify_route.path, {
887
- headers: {
888
- host: 'localhost',
889
- cookie: `${cookie_name}=${expired_cookie}`,
890
- },
911
+ const res = await rpc_call({
912
+ app: test_app.app,
913
+ path: rpc_path,
914
+ method: account_verify_action_spec.method,
915
+ headers: { cookie: `${cookie_name}=${expired_cookie}` },
891
916
  });
892
917
  assert.strictEqual(res.status, 401, 'Expired session cookie should be rejected');
893
- error_collector.record(test_app.route_specs, 'GET', verify_route.path, 401);
894
918
  });
895
919
  test('expired session cookie returns 401 on mutation route', async () => {
896
920
  const test_app = await create_test_app(build_test_app_options(options, get_db()));
@@ -943,27 +967,28 @@ export const describe_standard_integration_tests = (options) => {
943
967
  describe('password change revokes API tokens', () => {
944
968
  test('API tokens are invalidated after password change', async () => {
945
969
  const test_app = await create_test_app(build_test_app_options(options, get_db()));
946
- const token_create_route = find_auth_route(test_app.route_specs, '/tokens/create', 'POST');
947
970
  const password_route = find_auth_route(test_app.route_specs, '/password', 'POST');
948
- const verify_route = find_auth_route(test_app.route_specs, '/verify', 'GET');
949
- assert.ok(token_create_route, 'Expected POST /tokens/create route');
950
971
  assert.ok(password_route, 'Expected POST /password route');
951
- assert.ok(verify_route, 'Expected GET /verify route');
952
- // Create an API token
953
- const create_res = await test_app.app.request(token_create_route.path, {
954
- method: 'POST',
955
- headers: test_app.create_session_headers({ 'content-type': 'application/json' }),
956
- body: JSON.stringify({ name: 'test-token' }),
972
+ // Create an API token via RPC
973
+ const create_res = await rpc_call({
974
+ app: test_app.app,
975
+ path: rpc_path,
976
+ method: account_token_create_action_spec.method,
977
+ params: { name: 'test-token' },
978
+ headers: test_app.create_session_headers(),
957
979
  });
958
- assert.strictEqual(create_res.status, 200);
959
- const { token: raw_token } = await create_res.json();
980
+ assert.ok(create_res.ok, 'account_token_create should succeed');
981
+ const { token: raw_token } = create_res.result;
960
982
  assert.ok(raw_token, 'Expected raw token in create response');
961
983
  // Verify bearer token works
962
- const verify_before = await test_app.app.request(verify_route.path, {
963
- headers: { host: 'localhost', authorization: `Bearer ${raw_token}` },
984
+ const verify_before = await rpc_call_non_browser({
985
+ app: test_app.app,
986
+ path: rpc_path,
987
+ method: account_verify_action_spec.method,
988
+ headers: { authorization: `Bearer ${raw_token}` },
964
989
  });
965
990
  assert.strictEqual(verify_before.status, 200, 'Bearer token should work before password change');
966
- // Change password
991
+ // Change password (still REST)
967
992
  const change_res = await test_app.app.request(password_route.path, {
968
993
  method: 'POST',
969
994
  headers: test_app.create_session_headers({ 'content-type': 'application/json' }),
@@ -977,8 +1002,11 @@ export const describe_standard_integration_tests = (options) => {
977
1002
  assert.ok(typeof change_body.tokens_revoked === 'number', 'Expected tokens_revoked count');
978
1003
  assert.ok(change_body.tokens_revoked >= 1, 'Expected at least 1 token revoked');
979
1004
  // Bearer token should now be invalid
980
- const verify_after = await test_app.app.request(verify_route.path, {
981
- headers: { host: 'localhost', authorization: `Bearer ${raw_token}` },
1005
+ const verify_after = await rpc_call_non_browser({
1006
+ app: test_app.app,
1007
+ path: rpc_path,
1008
+ method: account_verify_action_spec.method,
1009
+ headers: { authorization: `Bearer ${raw_token}` },
982
1010
  });
983
1011
  assert.strictEqual(verify_after.status, 401, 'Bearer token should be rejected after password change');
984
1012
  });
@@ -990,30 +1018,25 @@ export const describe_standard_integration_tests = (options) => {
990
1018
  const signup_route = test_app.route_specs.find((s) => s.method === 'POST' && s.path.endsWith('/signup') && s.auth.type === 'none');
991
1019
  if (!signup_route)
992
1020
  return; // signup is optional
993
- const invite_route = test_app.route_specs.find((s) => s.method === 'POST' &&
994
- s.path.endsWith('/invites') &&
995
- s.auth.type === 'role' &&
996
- s.auth.role === 'admin');
997
- if (!invite_route)
998
- return; // invite routes are optional
1021
+ // `invite_create` became RPC-only in the 2026-04-23 migration.
1022
+ // Consumers that don't wire admin RPC actions can't exercise invites;
1023
+ // skip the test rather than fail.
1024
+ if (!find_rpc_action(options.rpc_endpoints, invite_create_action_spec.method))
1025
+ return;
999
1026
  // Create an admin to manage invites
1000
1027
  const admin = await test_app.create_account({
1001
1028
  username: 'invite_edge_admin',
1002
1029
  roles: ['admin'],
1003
1030
  });
1004
- const admin_headers = {
1005
- host: 'localhost',
1006
- origin: 'http://localhost:5173',
1007
- cookie: `${cookie_name}=${admin.session_cookie}`,
1008
- 'content-type': 'application/json',
1009
- };
1010
- // Create invite for alice@example.com
1011
- const invite_res = await test_app.app.request(invite_route.path, {
1012
- method: 'POST',
1013
- headers: admin_headers,
1014
- body: JSON.stringify({ email: 'alice@example.com' }),
1031
+ // Create invite for alice@example.com via RPC
1032
+ const invite_res = await rpc_call({
1033
+ app: test_app.app,
1034
+ path: rpc_path,
1035
+ method: invite_create_action_spec.method,
1036
+ params: { email: 'alice@example.com' },
1037
+ headers: { cookie: `${cookie_name}=${admin.session_cookie}` },
1015
1038
  });
1016
- assert.strictEqual(invite_res.status, 200);
1039
+ assert.ok(invite_res.ok, `invite_create failed: ${invite_res.ok ? '' : JSON.stringify(invite_res.error)}`);
1017
1040
  // Try to sign up with a different email — should fail (no matching invite)
1018
1041
  const signup_res = await test_app.app.request(signup_route.path, {
1019
1042
  method: 'POST',
@@ -1041,39 +1064,26 @@ export const describe_standard_integration_tests = (options) => {
1041
1064
  const signup_route = test_app.route_specs.find((s) => s.method === 'POST' && s.path.endsWith('/signup') && s.auth.type === 'none');
1042
1065
  if (!signup_route)
1043
1066
  return; // signup is optional
1044
- // Find admin invite creation route (POST ending in /invites, admin-gated)
1045
- const invite_route = test_app.route_specs.find((s) => s.method === 'POST' &&
1046
- s.path.endsWith('/invites') &&
1047
- s.auth.type === 'role' &&
1048
- s.auth.role === 'admin');
1049
- if (!invite_route)
1050
- return; // invite routes are optional
1051
- // Find admin accounts route to get admin's account ID
1052
- const accounts_route = test_app.route_specs.find((s) => s.method === 'GET' &&
1053
- s.path.endsWith('/accounts') &&
1054
- s.auth.type === 'role' &&
1055
- s.auth.role === 'admin');
1056
- if (!accounts_route)
1067
+ // `invite_create` became RPC-only in the 2026-04-23 migration.
1068
+ // Consumers that don't wire admin RPC actions can't exercise invites.
1069
+ if (!find_rpc_action(options.rpc_endpoints, invite_create_action_spec.method))
1057
1070
  return;
1058
1071
  // We need admin access — create an admin account
1059
1072
  const admin = await test_app.create_account({
1060
1073
  username: 'signup_test_admin',
1061
1074
  roles: ['admin'],
1062
1075
  });
1063
- const admin_headers = {
1064
- host: 'localhost',
1065
- origin: 'http://localhost:5173',
1066
- cookie: `${cookie_name}=${admin.session_cookie}`,
1067
- 'content-type': 'application/json',
1068
- };
1069
- // Create an invite for a specific test email
1076
+ const admin_headers = { cookie: `${cookie_name}=${admin.session_cookie}` };
1077
+ // Create an invite for a specific test email via RPC
1070
1078
  const test_email = 'signup-test@example.com';
1071
- const invite_res = await test_app.app.request(invite_route.path, {
1072
- method: 'POST',
1079
+ const invite_res = await rpc_call({
1080
+ app: test_app.app,
1081
+ path: rpc_path,
1082
+ method: invite_create_action_spec.method,
1083
+ params: { email: test_email },
1073
1084
  headers: admin_headers,
1074
- body: JSON.stringify({ email: test_email }),
1075
1085
  });
1076
- assert.strictEqual(invite_res.status, 200, 'Expected invite creation to succeed');
1086
+ assert.ok(invite_res.ok, `invite_create failed: ${invite_res.ok ? '' : JSON.stringify(invite_res.error)}`);
1077
1087
  // Attempt 1: signup with a non-matching email (no invite match) → 403
1078
1088
  const no_match_res = await test_app.app.request(signup_route.path, {
1079
1089
  method: 'POST',
@@ -1094,14 +1104,16 @@ export const describe_standard_integration_tests = (options) => {
1094
1104
  // then create an invite for a different email, then try signup with
1095
1105
  // the invited email but the colliding username
1096
1106
  const existing_user = await test_app.create_account({ username: 'existing_user' });
1097
- // Create invite for a different email
1107
+ // Create invite for a different email via RPC
1098
1108
  const conflict_email = 'conflict-test@example.com';
1099
- const invite2_res = await test_app.app.request(invite_route.path, {
1100
- method: 'POST',
1109
+ const invite2_res = await rpc_call({
1110
+ app: test_app.app,
1111
+ path: rpc_path,
1112
+ method: invite_create_action_spec.method,
1113
+ params: { email: conflict_email },
1101
1114
  headers: admin_headers,
1102
- body: JSON.stringify({ email: conflict_email }),
1103
1115
  });
1104
- assert.strictEqual(invite2_res.status, 200, 'Expected second invite creation to succeed');
1116
+ assert.ok(invite2_res.ok, `invite_create failed: ${invite2_res.ok ? '' : JSON.stringify(invite2_res.error)}`);
1105
1117
  // Attempt 2: signup with the invited email but a colliding username → 409
1106
1118
  const conflict_res = await test_app.app.request(signup_route.path, {
1107
1119
  method: 'POST',