@fuzdev/fuz_app 0.127.0 → 0.129.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 (106) hide show
  1. package/dist/actions/CLAUDE.md +35 -18
  2. package/dist/actions/connection_closer.d.ts +16 -4
  3. package/dist/actions/connection_closer.d.ts.map +1 -1
  4. package/dist/actions/connection_closer.js +20 -4
  5. package/dist/actions/register_action_ws.d.ts +10 -3
  6. package/dist/actions/register_action_ws.d.ts.map +1 -1
  7. package/dist/actions/register_action_ws.js +6 -3
  8. package/dist/actions/register_ws_endpoint.d.ts +3 -1
  9. package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
  10. package/dist/actions/register_ws_endpoint.js +3 -1
  11. package/dist/auth/CLAUDE.md +14 -10
  12. package/dist/auth/actor_lookup_action_specs.d.ts +2 -2
  13. package/dist/auth/actor_lookup_action_specs.js +2 -2
  14. package/dist/auth/audit_emitter.d.ts +73 -19
  15. package/dist/auth/audit_emitter.d.ts.map +1 -1
  16. package/dist/auth/audit_emitter.js +57 -21
  17. package/dist/auth/bearer_auth.d.ts +3 -2
  18. package/dist/auth/bearer_auth.d.ts.map +1 -1
  19. package/dist/auth/bearer_auth.js +3 -2
  20. package/dist/auth/middleware.d.ts +30 -0
  21. package/dist/auth/middleware.d.ts.map +1 -1
  22. package/dist/auth/middleware.js +59 -0
  23. package/dist/auth/request_context.d.ts +1 -1
  24. package/dist/auth/request_context.js +1 -1
  25. package/dist/auth/token_scope.d.ts +4 -2
  26. package/dist/auth/token_scope.d.ts.map +1 -1
  27. package/dist/auth/token_scope.js +10 -2
  28. package/dist/http/CLAUDE.md +3 -4
  29. package/dist/http/auth_shape.d.ts +3 -2
  30. package/dist/http/auth_shape.d.ts.map +1 -1
  31. package/dist/http/auth_shape.js +3 -2
  32. package/dist/http/common_routes.d.ts +1 -14
  33. package/dist/http/common_routes.d.ts.map +1 -1
  34. package/dist/http/common_routes.js +1 -19
  35. package/dist/http/ip_canonical.d.ts +1 -1
  36. package/dist/http/ip_canonical.js +1 -1
  37. package/dist/realtime/sse_auth_guard.d.ts +6 -2
  38. package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
  39. package/dist/realtime/sse_auth_guard.js +3 -1
  40. package/dist/server/app_server.d.ts +157 -19
  41. package/dist/server/app_server.d.ts.map +1 -1
  42. package/dist/server/app_server.js +226 -128
  43. package/dist/server/surface_route.d.ts +32 -0
  44. package/dist/server/surface_route.d.ts.map +1 -0
  45. package/dist/server/surface_route.js +46 -0
  46. package/dist/testing/CLAUDE.md +50 -32
  47. package/dist/testing/app_server.d.ts +40 -5
  48. package/dist/testing/app_server.d.ts.map +1 -1
  49. package/dist/testing/app_server.js +84 -68
  50. package/dist/testing/attack_surface.d.ts +1 -1
  51. package/dist/testing/attack_surface.d.ts.map +1 -1
  52. package/dist/testing/audit_completeness.js +1 -1
  53. package/dist/testing/audit_drift_guard.d.ts +5 -3
  54. package/dist/testing/audit_drift_guard.d.ts.map +1 -1
  55. package/dist/testing/audit_drift_guard.js +14 -4
  56. package/dist/testing/cross_backend/account_lifecycle.d.ts.map +1 -1
  57. package/dist/testing/cross_backend/account_lifecycle.js +1 -2
  58. package/dist/testing/cross_backend/capabilities.d.ts +3 -1
  59. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
  60. package/dist/testing/cross_backend/default_spine_surface.d.ts +2 -2
  61. package/dist/testing/cross_backend/default_spine_surface.d.ts.map +1 -1
  62. package/dist/testing/cross_backend/default_spine_surface.js +5 -2
  63. package/dist/testing/cross_backend/identity_parity.js +1 -1
  64. package/dist/testing/cross_backend/in_process_setup.d.ts.map +1 -1
  65. package/dist/testing/cross_backend/in_process_setup.js +4 -1
  66. package/dist/testing/cross_backend/method_coverage.d.ts +1 -1
  67. package/dist/testing/cross_backend/method_coverage.d.ts.map +1 -1
  68. package/dist/testing/cross_backend/method_coverage.js +2 -2
  69. package/dist/testing/cross_backend/peer_ping_ws.d.ts +1 -1
  70. package/dist/testing/cross_backend/peer_ping_ws.d.ts.map +1 -1
  71. package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts +1 -1
  72. package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts.map +1 -1
  73. package/dist/testing/cross_backend/setup.d.ts +4 -4
  74. package/dist/testing/cross_backend/setup.js +1 -1
  75. package/dist/testing/cross_backend/sse_round_trip.d.ts +1 -1
  76. package/dist/testing/cross_backend/sse_round_trip.d.ts.map +1 -1
  77. package/dist/testing/cross_backend/testing_reset_actions.d.ts +51 -32
  78. package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
  79. package/dist/testing/cross_backend/testing_reset_actions.js +70 -51
  80. package/dist/testing/cross_backend/testing_server_bun.d.ts +1 -1
  81. package/dist/testing/cross_backend/testing_server_bun.d.ts.map +1 -1
  82. package/dist/testing/cross_backend/testing_server_bun.js +80 -64
  83. package/dist/testing/cross_backend/testing_server_core.d.ts +10 -4
  84. package/dist/testing/cross_backend/testing_server_core.d.ts.map +1 -1
  85. package/dist/testing/cross_backend/testing_server_deno.d.ts +1 -1
  86. package/dist/testing/cross_backend/testing_server_deno.d.ts.map +1 -1
  87. package/dist/testing/cross_backend/testing_server_deno.js +1 -1
  88. package/dist/testing/cross_backend/testing_server_node.d.ts +1 -1
  89. package/dist/testing/cross_backend/testing_server_node.d.ts.map +1 -1
  90. package/dist/testing/cross_backend/testing_server_node.js +2 -2
  91. package/dist/testing/cross_backend/ws_round_trip.d.ts +3 -2
  92. package/dist/testing/cross_backend/ws_round_trip.d.ts.map +1 -1
  93. package/dist/testing/cross_backend/ws_round_trip.js +51 -13
  94. package/dist/testing/stubs.d.ts +65 -10
  95. package/dist/testing/stubs.d.ts.map +1 -1
  96. package/dist/testing/stubs.js +95 -41
  97. package/dist/testing/ws_round_trip.d.ts +1 -3
  98. package/dist/testing/ws_round_trip.d.ts.map +1 -1
  99. package/dist/ui/AdminSurface.svelte +3 -1
  100. package/dist/ui/AdminSurface.svelte.d.ts.map +1 -1
  101. package/dist/ui/CLAUDE.md +3 -1
  102. package/dist/ui/async_slot.svelte.d.ts +4 -4
  103. package/dist/ui/async_slot.svelte.js +4 -4
  104. package/dist/ui/keyed_async_slot.svelte.d.ts +4 -4
  105. package/dist/ui/keyed_async_slot.svelte.js +3 -3
  106. package/package.json +1 -1
@@ -12,7 +12,7 @@ import { logger } from 'hono/logger';
12
12
  import { bodyLimit } from 'hono/body-limit';
13
13
  import { z } from 'zod';
14
14
  import { session_cookie_options } from '../auth/session_cookie.js';
15
- import { create_audit_log_sse, audit_log_event_specs } from '../realtime/sse_auth_guard.js';
15
+ import { create_audit_log_sse, create_sse_connection_closer, audit_log_event_specs } from '../realtime/sse_auth_guard.js';
16
16
  import { BaseServerEnv } from './env.js';
17
17
  import { RateLimiter, announce_rate_limiters_disabled, default_login_ip_rate_limit, default_login_account_rate_limit, default_action_account_rate_limit, default_action_ip_rate_limit } from '../rate_limiter.js';
18
18
  // Side-effect import: augments Hono's ContextVariableMap so consumers
@@ -24,9 +24,9 @@ import { log_startup_summary } from './startup.js';
24
24
  import { create_app_surface_spec } from '../http/surface.js';
25
25
  import { apply_middleware_specs, apply_route_specs, prefix_route_specs } from '../http/route_spec.js';
26
26
  import { check_bootstrap_status, create_bootstrap_route_specs } from '../auth/bootstrap_routes.js';
27
- import { create_surface_route_spec } from '../http/common_routes.js';
27
+ import { create_surface_route_spec } from './surface_route.js';
28
28
  import { flush_pending_effects, flush_post_commit_effects } from '../http/pending_effects.js';
29
- import { assert_endpoint_in_auth_scope, create_auth_middleware_specs } from '../auth/middleware.js';
29
+ import { assert_endpoint_in_auth_scope, assert_middleware_stack_mounted, create_auth_middleware_specs } from '../auth/middleware.js';
30
30
  import { fuz_auth_guard_resolver } from '../auth/auth_guard_resolver.js';
31
31
  import { create_fuz_authorization_handler } from '../auth/request_context.js';
32
32
  import { ERROR_PAYLOAD_TOO_LARGE } from '../http/error_schemas.js';
@@ -60,6 +60,125 @@ export const require_audit_sse = (source) => {
60
60
  };
61
61
  /** Default maximum request body size: 1 MiB. */
62
62
  export const DEFAULT_MAX_BODY_SIZE = 1024 * 1024;
63
+ /**
64
+ * Each `AppServerOptions` rate-limiter field, with the defaults an omitted one
65
+ * is built from under `rate_limiters: 'enforced'` and the name its
66
+ * explicitly-null diagnostic uses. Insertion order is the order limiters are
67
+ * built and their diagnostics are listed.
68
+ */
69
+ const rate_limiter_slots = {
70
+ login_ip_rate_limiter: { label: 'login IP', defaults: default_login_ip_rate_limit },
71
+ signup_ip_rate_limiter: { label: 'signup IP', defaults: default_login_ip_rate_limit },
72
+ bootstrap_ip_rate_limiter: { label: 'bootstrap IP', defaults: default_login_ip_rate_limit },
73
+ login_account_rate_limiter: {
74
+ label: 'login account',
75
+ defaults: default_login_account_rate_limit
76
+ },
77
+ signup_account_rate_limiter: {
78
+ label: 'signup account',
79
+ defaults: default_login_account_rate_limit
80
+ },
81
+ action_ip_rate_limiter: { label: 'action IP', defaults: default_action_ip_rate_limit },
82
+ action_account_rate_limiter: {
83
+ label: 'action account',
84
+ defaults: default_action_account_rate_limit
85
+ }
86
+ };
87
+ /**
88
+ * Every `AppServerOptions` rate-limiter field, in build order.
89
+ *
90
+ * @internal
91
+ */
92
+ export const APP_SERVER_RATE_LIMITER_KEYS = Object.keys(rate_limiter_slots);
93
+ /**
94
+ * Resolve each rate-limiter option: an explicit option (instance or `null`)
95
+ * wins; an omitted one is a default instance under `'enforced'` and `null`
96
+ * under `'disabled_for_testing'`, which also announces the mode on stderr
97
+ * (`announce_rate_limiters_disabled`). One instance per surface — see
98
+ * `AppServerOptions.login_ip_rate_limiter` for why these are not a single
99
+ * shared bucket.
100
+ *
101
+ * @param options - the rate-limiter fields and `rate_limiters` mode of `AppServerOptions`
102
+ * @internal
103
+ */
104
+ export const resolve_rate_limiters = (options) => {
105
+ const mode = options.rate_limiters ?? 'enforced';
106
+ const limiters = {};
107
+ const built = [];
108
+ for (const key of APP_SERVER_RATE_LIMITER_KEYS) {
109
+ const option = options[key];
110
+ if (option !== undefined) {
111
+ limiters[key] = option;
112
+ }
113
+ else if (mode === 'disabled_for_testing') {
114
+ announce_rate_limiters_disabled();
115
+ limiters[key] = null;
116
+ }
117
+ else {
118
+ const limiter = new RateLimiter({ ...rate_limiter_slots[key].defaults });
119
+ built.push(limiter);
120
+ limiters[key] = limiter;
121
+ }
122
+ }
123
+ return { mode, limiters, built };
124
+ };
125
+ /**
126
+ * The config-level diagnostics `create_app_server` appends to the surface's
127
+ * spec-level ones: a weakened session cookie, and an open rate-limited
128
+ * surface — one warning for `'disabled_for_testing'`, otherwise one per
129
+ * explicitly-null limiter, so a deployment that disables login's limiter but
130
+ * keeps signup's reads differently from one that disables both.
131
+ *
132
+ * @internal
133
+ */
134
+ export const collect_config_diagnostics = (input) => {
135
+ const diagnostics = [];
136
+ const { cookie_options } = input;
137
+ if (cookie_options) {
138
+ if (cookie_options.secure === false) {
139
+ diagnostics.push({
140
+ level: 'warning',
141
+ category: 'security',
142
+ message: 'Session cookie secure=false — cookies sent over HTTP'
143
+ });
144
+ }
145
+ if (cookie_options.sameSite && cookie_options.sameSite !== session_cookie_options.sameSite) {
146
+ diagnostics.push({
147
+ level: 'warning',
148
+ category: 'security',
149
+ message: `Session cookie sameSite='${cookie_options.sameSite}' — weakened from default '${session_cookie_options.sameSite}'`
150
+ });
151
+ }
152
+ if (cookie_options.httpOnly === false) {
153
+ diagnostics.push({
154
+ level: 'warning',
155
+ category: 'security',
156
+ message: 'Session cookie httpOnly=false — cookie accessible to JS'
157
+ });
158
+ }
159
+ }
160
+ if (input.rate_limiter_mode === 'disabled_for_testing') {
161
+ // one warning for the mode rather than one per limiter it nulled
162
+ diagnostics.push({
163
+ level: 'warning',
164
+ category: 'security',
165
+ message: "rate limiters disabled for testing (rate_limiters: 'disabled_for_testing') — " +
166
+ 'every limiter not passed explicitly is off; never use in production'
167
+ });
168
+ }
169
+ else {
170
+ for (const key of APP_SERVER_RATE_LIMITER_KEYS) {
171
+ if (input.rate_limiters[key] === null) {
172
+ diagnostics.push({
173
+ level: 'warning',
174
+ category: 'security',
175
+ message: `${rate_limiter_slots[key].label} rate limiter explicitly disabled (null)`
176
+ });
177
+ }
178
+ }
179
+ }
180
+ return diagnostics;
181
+ };
63
182
  /**
64
183
  * Create a fully assembled Hono app with auth, middleware, and routes.
65
184
  *
@@ -80,57 +199,80 @@ export const DEFAULT_MAX_BODY_SIZE = 1024 * 1024;
80
199
  * case; consumers can call `require_audit_sse(ctx)` / `require_audit_sse(server)`
81
200
  * to assert the invariant.
82
201
  *
83
- * When `auth_cleanup` is set, the auth cleanup schedule starts as the last
84
- * step of assembly — so an assembly that throws leaves no schedule running.
202
+ * Assembly acquires a few things that outlive it — the rate limiters it
203
+ * builds, the listeners it registers on `deps.audit`, the members it adds to
204
+ * `deps.connection_closer` — and records a release for each as it goes. When
205
+ * assembly throws, every release recorded so far runs, last acquired first
206
+ * (one that throws is logged and the rest still run), and the original error
207
+ * is rethrown: a failed assembly leaves the backend's emitter and closer as
208
+ * it found them and no limiter timer running. When `auth_cleanup` is set, the
209
+ * auth cleanup schedule starts as the last step of assembly — nothing after
210
+ * it can throw, so an assembly that throws leaves no schedule running.
85
211
  *
86
212
  * The returned `close` owns shutdown: it stops that schedule, closes every
87
- * live connection on the backend's closer, disposes the rate limiters built
88
- * here, and closes the database (`AppServer.close`).
213
+ * live connection on the backend's closer, runs the same releases, and
214
+ * closes the database (`AppServer.close`).
89
215
  *
90
- * @returns assembled Hono app, backend, surface build, and bootstrap status
216
+ * @returns the assembled Hono app with its surface, bootstrap status, migration results,
217
+ * audit SSE, mounted WS transports, and `close`
218
+ * @throws Error from any assembly step, after releasing what assembly acquired
91
219
  */
92
220
  export const create_app_server = async (options) => {
221
+ const releases = [];
222
+ try {
223
+ return await assemble_app_server(options, releases);
224
+ }
225
+ catch (error) {
226
+ run_releases(releases, options.backend.deps.log, 'a failed assembly');
227
+ throw error;
228
+ }
229
+ };
230
+ /**
231
+ * Run `releases` last-first and empty it. Each runs in its own `try`, so one
232
+ * that throws is logged and the rest still run.
233
+ */
234
+ const run_releases = (releases, log, during) => {
235
+ for (const release of releases.splice(0).reverse()) {
236
+ try {
237
+ release();
238
+ }
239
+ catch (error) {
240
+ log.error(`create_app_server: a release during ${during} failed:`, error);
241
+ }
242
+ }
243
+ };
244
+ /**
245
+ * The body of `create_app_server`. Every acquisition that outlives assembly
246
+ * pushes its release onto `releases` as it happens, so a throw at any later
247
+ * step unwinds exactly what was acquired.
248
+ */
249
+ const assemble_app_server = async (options, releases) => {
93
250
  const { backend } = options;
94
251
  const { deps } = backend;
95
252
  const { log } = deps;
96
- // Rate limiters: an explicit option (instance or `null`) wins; an omitted
97
- // one is a default instance under 'enforced', `null` under
98
- // 'disabled_for_testing'. One instance per surface — see
99
- // `AppServerOptions.login_ip_rate_limiter` for why these are not a single
100
- // shared bucket. The ones built here are the server's to dispose on close.
101
- const rate_limiter_mode = options.rate_limiters ?? 'enforced';
102
- const built_rate_limiters = [];
103
- const resolve_rate_limiter = (option, defaults) => {
104
- if (option !== undefined)
105
- return option;
106
- if (rate_limiter_mode === 'disabled_for_testing') {
107
- announce_rate_limiters_disabled();
108
- return null;
109
- }
110
- const limiter = new RateLimiter({ ...defaults });
111
- built_rate_limiters.push(limiter);
112
- return limiter;
113
- };
114
- const login_ip_rate_limiter = resolve_rate_limiter(options.login_ip_rate_limiter, default_login_ip_rate_limit);
115
- const signup_ip_rate_limiter = resolve_rate_limiter(options.signup_ip_rate_limiter, default_login_ip_rate_limit);
116
- const bootstrap_ip_rate_limiter = resolve_rate_limiter(options.bootstrap_ip_rate_limiter, default_login_ip_rate_limit);
117
- const login_account_rate_limiter = resolve_rate_limiter(options.login_account_rate_limiter, default_login_account_rate_limit);
118
- const signup_account_rate_limiter = resolve_rate_limiter(options.signup_account_rate_limiter, default_login_account_rate_limit);
119
- const action_ip_rate_limiter = resolve_rate_limiter(options.action_ip_rate_limiter, default_action_ip_rate_limit);
120
- const action_account_rate_limiter = resolve_rate_limiter(options.action_account_rate_limiter, default_action_account_rate_limit);
253
+ // Rate limiters — the ones built here are the server's to dispose, on
254
+ // close or when assembly fails.
255
+ const { mode: rate_limiter_mode, limiters: rate_limiters, built: built_rate_limiters } = resolve_rate_limiters(options);
256
+ for (const limiter of built_rate_limiters) {
257
+ releases.push(() => limiter.dispose());
258
+ }
259
+ const { bootstrap_ip_rate_limiter, action_ip_rate_limiter, action_account_rate_limiter } = rate_limiters;
121
260
  // Factory-managed audit SSE — registers a listener on the bound emitter
122
261
  // so SSE fan-out runs alongside the consumer's `on_audit_event`
123
262
  // without rebuilding `AppDeps`, and adds the registry to the backend's
124
- // closer so revocations close its streams.
263
+ // closer so revocations close its streams. The closer membership is added
264
+ // here rather than through `create_audit_log_sse`'s option, so its remover
265
+ // is the server's.
125
266
  const audit_sse = options.audit_log_sse
126
267
  ? create_audit_log_sse({
127
268
  log,
128
269
  role: typeof options.audit_log_sse === 'object' ? options.audit_log_sse.role : undefined,
129
- connection_closer: deps.connection_closer
270
+ connection_closer: null
130
271
  })
131
272
  : null;
132
273
  if (audit_sse) {
133
- deps.audit.add_listener(audit_sse.on_audit_event);
274
+ releases.push(deps.connection_closer.add(create_sse_connection_closer(audit_sse.registry)));
275
+ releases.push(deps.audit.add_listener(audit_sse.on_audit_event));
134
276
  }
135
277
  // Proxy middleware
136
278
  const proxy_spec = create_proxy_middleware_spec({ ...options.proxy, log });
@@ -140,9 +282,13 @@ export const create_app_server = async (options) => {
140
282
  session_options: options.session_options,
141
283
  daemon_token_state: options.daemon_token_state
142
284
  });
143
- let middleware_specs = [proxy_spec, ...auth_middleware];
285
+ const stack_specs = [proxy_spec, ...auth_middleware];
286
+ let middleware_specs = stack_specs;
144
287
  if (options.transform_middleware) {
145
- middleware_specs = options.transform_middleware(middleware_specs);
288
+ // the transform gets copies in a fresh array, so mutating what it was
289
+ // handed can't rewrite the originals the check matches against
290
+ middleware_specs = options.transform_middleware(stack_specs.map((spec) => ({ ...spec })));
291
+ assert_middleware_stack_mounted(stack_specs, middleware_specs);
146
292
  }
147
293
  // Bootstrap status
148
294
  // - undefined / 'disabled': no route mounted; placeholder status.
@@ -169,13 +315,7 @@ export const create_app_server = async (options) => {
169
315
  backend,
170
316
  bootstrap_status,
171
317
  session_options: options.session_options,
172
- login_ip_rate_limiter,
173
- signup_ip_rate_limiter,
174
- bootstrap_ip_rate_limiter,
175
- login_account_rate_limiter,
176
- signup_account_rate_limiter,
177
- action_ip_rate_limiter,
178
- action_account_rate_limiter,
318
+ ...rate_limiters,
179
319
  audit_sse
180
320
  };
181
321
  const consumer_routes = options.create_route_specs(context);
@@ -236,11 +376,32 @@ export const create_app_server = async (options) => {
236
376
  }
237
377
  ws_mount = { endpoints: resolved_ws_endpoints, create_upgrade_websocket };
238
378
  }
239
- // Surface route (default: enabled)
240
- if (options.surface_route !== false) {
379
+ // Surface route (opt-in)
380
+ if (options.surface_route) {
241
381
  factory_routes.push(create_surface_route_spec(surface_ref));
242
382
  }
243
383
  const route_specs = [...consumer_routes, ...factory_routes];
384
+ // WS path checks — pure data, so they run before anything mounts.
385
+ // `register_ws_endpoint` mounts a `GET path` upgrade route: a duplicate
386
+ // path, or one a `RouteSpec` already registered as `GET path`, would be
387
+ // silently shadowed by Hono's last-wins semantics — fail fast instead.
388
+ if (ws_mount) {
389
+ const route_spec_get_paths = new Set();
390
+ for (const r of route_specs) {
391
+ if (r.method === 'GET')
392
+ route_spec_get_paths.add(r.path);
393
+ }
394
+ const seen_paths = new Set();
395
+ for (const endpoint of ws_mount.endpoints) {
396
+ if (seen_paths.has(endpoint.path)) {
397
+ throw new Error(`create_app_server: duplicate ws_endpoints path: ${endpoint.path}`);
398
+ }
399
+ if (route_spec_get_paths.has(endpoint.path)) {
400
+ throw new Error(`create_app_server: ws_endpoints path collides with a GET RouteSpec: ${endpoint.path}`);
401
+ }
402
+ seen_paths.add(endpoint.path);
403
+ }
404
+ }
244
405
  // Surface + logging
245
406
  const surface_middleware = options.post_route_middleware
246
407
  ? [...middleware_specs, ...options.post_route_middleware]
@@ -258,62 +419,11 @@ export const create_app_server = async (options) => {
258
419
  ws_endpoints: resolved_ws_endpoints
259
420
  });
260
421
  // Config-level diagnostics (concatenated after spec-level from generate_app_surface)
261
- const config_diagnostics = [];
262
- const cookie_opts = options.session_options.cookie_options;
263
- if (cookie_opts) {
264
- if (cookie_opts.secure === false) {
265
- config_diagnostics.push({
266
- level: 'warning',
267
- category: 'security',
268
- message: 'Session cookie secure=false — cookies sent over HTTP'
269
- });
270
- }
271
- if (cookie_opts.sameSite && cookie_opts.sameSite !== session_cookie_options.sameSite) {
272
- config_diagnostics.push({
273
- level: 'warning',
274
- category: 'security',
275
- message: `Session cookie sameSite='${cookie_opts.sameSite}' — weakened from default '${session_cookie_options.sameSite}'`
276
- });
277
- }
278
- if (cookie_opts.httpOnly === false) {
279
- config_diagnostics.push({
280
- level: 'warning',
281
- category: 'security',
282
- message: 'Session cookie httpOnly=false — cookie accessible to JS'
283
- });
284
- }
285
- }
286
- if (rate_limiter_mode === 'disabled_for_testing') {
287
- // one warning for the mode rather than one per limiter it nulled
288
- config_diagnostics.push({
289
- level: 'warning',
290
- category: 'security',
291
- message: "rate limiters disabled for testing (rate_limiters: 'disabled_for_testing') — " +
292
- 'every limiter not passed explicitly is off; never use in production'
293
- });
294
- }
295
- else {
296
- // One diagnostic per explicitly-null limiter — a deployment that disables
297
- // login's limiter but keeps signup's is a different posture than one that
298
- // disables both, and a single collapsed warning hid which surface was open.
299
- for (const [name, limiter] of [
300
- ['login IP', login_ip_rate_limiter],
301
- ['signup IP', signup_ip_rate_limiter],
302
- ['bootstrap IP', bootstrap_ip_rate_limiter],
303
- ['login account', login_account_rate_limiter],
304
- ['signup account', signup_account_rate_limiter],
305
- ['action IP', action_ip_rate_limiter],
306
- ['action account', action_account_rate_limiter]
307
- ]) {
308
- if (limiter === null) {
309
- config_diagnostics.push({
310
- level: 'warning',
311
- category: 'security',
312
- message: `${name} rate limiter explicitly disabled (null)`
313
- });
314
- }
315
- }
316
- }
422
+ const config_diagnostics = collect_config_diagnostics({
423
+ cookie_options: options.session_options.cookie_options,
424
+ rate_limiter_mode,
425
+ rate_limiters
426
+ });
317
427
  if (config_diagnostics.length) {
318
428
  surface_spec.surface.diagnostics = [...surface_spec.surface.diagnostics, ...config_diagnostics];
319
429
  }
@@ -389,16 +499,6 @@ export const create_app_server = async (options) => {
389
499
  // the adapter helper — built from this app, which Node's
390
500
  // `createNodeWebSocket({app})` needs, and only when there is something to mount
391
501
  const upgrade_websocket = ws_mount.create_upgrade_websocket(app);
392
- // Cross-surface collision: `register_ws_endpoint` mounts a `GET path`
393
- // upgrade route. If a `RouteSpec` already registered `GET path`,
394
- // Hono's last-wins semantics would silently shadow the consumer's
395
- // GET route — fail fast instead.
396
- const route_spec_get_paths = new Set();
397
- for (const r of route_specs) {
398
- if (r.method === 'GET')
399
- route_spec_get_paths.add(r.path);
400
- }
401
- const seen_paths = new Set();
402
502
  // Dedupe `auth_guard` wiring by transport reference — two specs
403
503
  // sharing one transport instance get a single listener,
404
504
  // otherwise revocation events would fire `close_sockets_for_*`
@@ -409,13 +509,6 @@ export const create_app_server = async (options) => {
409
509
  // disable, every spec sharing the transport must pass `auth_guard: false`.
410
510
  const guarded_transports = new WeakSet();
411
511
  for (const endpoint of ws_mount.endpoints) {
412
- if (seen_paths.has(endpoint.path)) {
413
- throw new Error(`create_app_server: duplicate ws_endpoints path: ${endpoint.path}`);
414
- }
415
- if (route_spec_get_paths.has(endpoint.path)) {
416
- throw new Error(`create_app_server: ws_endpoints path collides with a GET RouteSpec: ${endpoint.path}`);
417
- }
418
- seen_paths.add(endpoint.path);
419
512
  const { transport: endpoint_transport } = register_ws_endpoint({
420
513
  app,
421
514
  path: endpoint.path,
@@ -424,8 +517,8 @@ export const create_app_server = async (options) => {
424
517
  db: deps.db,
425
518
  actions: endpoint.actions,
426
519
  transport: endpoint.transport,
427
- // every revocation handler's close reaches this transport
428
- connection_closer: deps.connection_closer,
520
+ // added below instead, so the remover is the server's
521
+ connection_closer: null,
429
522
  max_connections_per_account: endpoint.max_connections_per_account,
430
523
  heartbeat: endpoint.heartbeat,
431
524
  artificial_delay: endpoint.artificial_delay,
@@ -438,13 +531,15 @@ export const create_app_server = async (options) => {
438
531
  action_account_rate_limiter
439
532
  });
440
533
  mounted_ws_endpoints[endpoint.path] = endpoint_transport;
534
+ // every revocation handler's close reaches this transport
535
+ releases.push(deps.connection_closer.add(endpoint_transport));
441
536
  if (endpoint.auth_guard !== false && !guarded_transports.has(endpoint_transport)) {
442
537
  guarded_transports.add(endpoint_transport);
443
- deps.audit.add_listener(create_ws_auth_guard(endpoint_transport, log));
538
+ releases.push(deps.audit.add_listener(create_ws_auth_guard(endpoint_transport, log)));
444
539
  }
445
540
  if (endpoint.extra_audit_handlers?.length) {
446
541
  for (const handler of endpoint.extra_audit_handlers) {
447
- deps.audit.add_listener(handler);
542
+ releases.push(deps.audit.add_listener(handler));
448
543
  }
449
544
  }
450
545
  }
@@ -461,8 +556,10 @@ export const create_app_server = async (options) => {
461
556
  }
462
557
  }
463
558
  // Auth cleanup — opt-in, and last: nothing after it can throw, so a failed
464
- // assembly never leaves a schedule running. Its closes go through
465
- // `deps.connection_closer`, which the mounts above have filled.
559
+ // assembly never leaves a schedule running. Its own throw (an invalid
560
+ // `interval_ms`) comes before any pass starts, and unwinds `releases` like
561
+ // any other. Its closes go through `deps.connection_closer`, which the
562
+ // mounts above have filled.
466
563
  const auth_cleanup = options.auth_cleanup
467
564
  ? start_auth_cleanup(deps, options.auth_cleanup === true ? undefined : options.auth_cleanup)
468
565
  : null;
@@ -480,8 +577,9 @@ export const create_app_server = async (options) => {
480
577
  catch (error) {
481
578
  log.error('create_app_server: closing live connections on shutdown failed:', error);
482
579
  }
483
- for (const limiter of built_rate_limiters)
484
- limiter.dispose();
580
+ // then what assembly acquired — the audit listeners and closer members
581
+ // this server added, and the limiters it built
582
+ run_releases(releases, log, 'shutdown');
485
583
  await backend.close();
486
584
  };
487
585
  return {
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The surface explorer route — `GET /api/surface`, serving the generated
3
+ * `AppSurface` as JSON.
4
+ *
5
+ * Opt-in on `create_app_server` (`surface_route: true`), and admin-only when
6
+ * mounted: the surface is a map of every route, its auth, and its schemas.
7
+ *
8
+ * @module
9
+ */
10
+ import type { RouteSpec } from '../http/route_spec.js';
11
+ import type { AppSurface } from '../http/surface.js';
12
+ /** Options for the surface explorer route. */
13
+ export interface SurfaceRouteOptions {
14
+ /** The generated app surface to serve — read per request, so a backfilled ref serves the final surface. */
15
+ surface: AppSurface;
16
+ }
17
+ /**
18
+ * Create the admin-only route spec that serves the `AppSurface` as JSON.
19
+ *
20
+ * Surface data reveals API structure (routes, auth, schemas), so the route
21
+ * requires the global admin role. It is also a non-RPC surface: it declares
22
+ * `required_scope: 'surface:app_surface'`, so a narrowed API token is refused
23
+ * it whatever its method list says (403 `token_scope_required`, ahead of the
24
+ * role gate), while a full-scope admin bearer passes. The admin role implies
25
+ * `actor: 'required'`, so the route takes the `?acting=` selector a
26
+ * multi-actor admin disambiguates with.
27
+ *
28
+ * @param options - the surface to serve
29
+ * @returns the `GET /api/surface` route spec
30
+ */
31
+ export declare const create_surface_route_spec: (options: SurfaceRouteOptions) => RouteSpec;
32
+ //# sourceMappingURL=surface_route.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"surface_route.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/surface_route.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AACvD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAIrD,8CAA8C;AAC9C,MAAM,WAAW,mBAAmB;IACnC,2GAA2G;IAC3G,OAAO,EAAE,UAAU,CAAC;CACpB;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,yBAAyB,GAAI,SAAS,mBAAmB,KAAG,SAmBvE,CAAC"}
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The surface explorer route — `GET /api/surface`, serving the generated
3
+ * `AppSurface` as JSON.
4
+ *
5
+ * Opt-in on `create_app_server` (`surface_route: true`), and admin-only when
6
+ * mounted: the surface is a map of every route, its auth, and its schemas.
7
+ *
8
+ * @module
9
+ */
10
+ import { z } from 'zod';
11
+ import { ActingActor } from '../http/auth_shape.js';
12
+ import { ROLE_ADMIN } from '../auth/role_schema.js';
13
+ /**
14
+ * Create the admin-only route spec that serves the `AppSurface` as JSON.
15
+ *
16
+ * Surface data reveals API structure (routes, auth, schemas), so the route
17
+ * requires the global admin role. It is also a non-RPC surface: it declares
18
+ * `required_scope: 'surface:app_surface'`, so a narrowed API token is refused
19
+ * it whatever its method list says (403 `token_scope_required`, ahead of the
20
+ * role gate), while a full-scope admin bearer passes. The admin role implies
21
+ * `actor: 'required'`, so the route takes the `?acting=` selector a
22
+ * multi-actor admin disambiguates with.
23
+ *
24
+ * @param options - the surface to serve
25
+ * @returns the `GET /api/surface` route spec
26
+ */
27
+ export const create_surface_route_spec = (options) => ({
28
+ method: 'GET',
29
+ path: '/api/surface',
30
+ auth: {
31
+ account: 'required',
32
+ actor: 'required',
33
+ roles: [ROLE_ADMIN],
34
+ // rule 3 — the surface map is not an RPC method, so a narrowed token
35
+ // gets none of it
36
+ required_scope: 'surface:app_surface'
37
+ },
38
+ handler: (c) => c.json(options.surface),
39
+ description: 'Application surface (routes, middleware, schemas) — admin only',
40
+ query: z.strictObject({ acting: ActingActor }),
41
+ input: z.null(),
42
+ output: z.looseObject({
43
+ routes: z.array(z.looseObject({})),
44
+ middleware: z.array(z.looseObject({}))
45
+ })
46
+ });