@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.
- package/dist/actions/CLAUDE.md +35 -18
- package/dist/actions/connection_closer.d.ts +16 -4
- package/dist/actions/connection_closer.d.ts.map +1 -1
- package/dist/actions/connection_closer.js +20 -4
- package/dist/actions/register_action_ws.d.ts +10 -3
- package/dist/actions/register_action_ws.d.ts.map +1 -1
- package/dist/actions/register_action_ws.js +6 -3
- package/dist/actions/register_ws_endpoint.d.ts +3 -1
- package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
- package/dist/actions/register_ws_endpoint.js +3 -1
- package/dist/auth/CLAUDE.md +14 -10
- package/dist/auth/actor_lookup_action_specs.d.ts +2 -2
- package/dist/auth/actor_lookup_action_specs.js +2 -2
- package/dist/auth/audit_emitter.d.ts +73 -19
- package/dist/auth/audit_emitter.d.ts.map +1 -1
- package/dist/auth/audit_emitter.js +57 -21
- package/dist/auth/bearer_auth.d.ts +3 -2
- package/dist/auth/bearer_auth.d.ts.map +1 -1
- package/dist/auth/bearer_auth.js +3 -2
- package/dist/auth/middleware.d.ts +30 -0
- package/dist/auth/middleware.d.ts.map +1 -1
- package/dist/auth/middleware.js +59 -0
- package/dist/auth/request_context.d.ts +1 -1
- package/dist/auth/request_context.js +1 -1
- package/dist/auth/token_scope.d.ts +4 -2
- package/dist/auth/token_scope.d.ts.map +1 -1
- package/dist/auth/token_scope.js +10 -2
- package/dist/http/CLAUDE.md +3 -4
- package/dist/http/auth_shape.d.ts +3 -2
- package/dist/http/auth_shape.d.ts.map +1 -1
- package/dist/http/auth_shape.js +3 -2
- package/dist/http/common_routes.d.ts +1 -14
- package/dist/http/common_routes.d.ts.map +1 -1
- package/dist/http/common_routes.js +1 -19
- package/dist/http/ip_canonical.d.ts +1 -1
- package/dist/http/ip_canonical.js +1 -1
- package/dist/realtime/sse_auth_guard.d.ts +6 -2
- package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
- package/dist/realtime/sse_auth_guard.js +3 -1
- package/dist/server/app_server.d.ts +157 -19
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/server/app_server.js +226 -128
- package/dist/server/surface_route.d.ts +32 -0
- package/dist/server/surface_route.d.ts.map +1 -0
- package/dist/server/surface_route.js +46 -0
- package/dist/testing/CLAUDE.md +50 -32
- package/dist/testing/app_server.d.ts +40 -5
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/app_server.js +84 -68
- package/dist/testing/attack_surface.d.ts +1 -1
- package/dist/testing/attack_surface.d.ts.map +1 -1
- package/dist/testing/audit_completeness.js +1 -1
- package/dist/testing/audit_drift_guard.d.ts +5 -3
- package/dist/testing/audit_drift_guard.d.ts.map +1 -1
- package/dist/testing/audit_drift_guard.js +14 -4
- package/dist/testing/cross_backend/account_lifecycle.d.ts.map +1 -1
- package/dist/testing/cross_backend/account_lifecycle.js +1 -2
- package/dist/testing/cross_backend/capabilities.d.ts +3 -1
- package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_spine_surface.d.ts +2 -2
- package/dist/testing/cross_backend/default_spine_surface.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_spine_surface.js +5 -2
- package/dist/testing/cross_backend/identity_parity.js +1 -1
- package/dist/testing/cross_backend/in_process_setup.d.ts.map +1 -1
- package/dist/testing/cross_backend/in_process_setup.js +4 -1
- package/dist/testing/cross_backend/method_coverage.d.ts +1 -1
- package/dist/testing/cross_backend/method_coverage.d.ts.map +1 -1
- package/dist/testing/cross_backend/method_coverage.js +2 -2
- package/dist/testing/cross_backend/peer_ping_ws.d.ts +1 -1
- package/dist/testing/cross_backend/peer_ping_ws.d.ts.map +1 -1
- package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts +1 -1
- package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts.map +1 -1
- package/dist/testing/cross_backend/setup.d.ts +4 -4
- package/dist/testing/cross_backend/setup.js +1 -1
- package/dist/testing/cross_backend/sse_round_trip.d.ts +1 -1
- package/dist/testing/cross_backend/sse_round_trip.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_reset_actions.d.ts +51 -32
- package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_reset_actions.js +70 -51
- package/dist/testing/cross_backend/testing_server_bun.d.ts +1 -1
- package/dist/testing/cross_backend/testing_server_bun.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_server_bun.js +80 -64
- package/dist/testing/cross_backend/testing_server_core.d.ts +10 -4
- package/dist/testing/cross_backend/testing_server_core.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_server_deno.d.ts +1 -1
- package/dist/testing/cross_backend/testing_server_deno.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_server_deno.js +1 -1
- package/dist/testing/cross_backend/testing_server_node.d.ts +1 -1
- package/dist/testing/cross_backend/testing_server_node.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_server_node.js +2 -2
- package/dist/testing/cross_backend/ws_round_trip.d.ts +3 -2
- package/dist/testing/cross_backend/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/cross_backend/ws_round_trip.js +51 -13
- package/dist/testing/stubs.d.ts +65 -10
- package/dist/testing/stubs.d.ts.map +1 -1
- package/dist/testing/stubs.js +95 -41
- package/dist/testing/ws_round_trip.d.ts +1 -3
- package/dist/testing/ws_round_trip.d.ts.map +1 -1
- package/dist/ui/AdminSurface.svelte +3 -1
- package/dist/ui/AdminSurface.svelte.d.ts.map +1 -1
- package/dist/ui/CLAUDE.md +3 -1
- package/dist/ui/async_slot.svelte.d.ts +4 -4
- package/dist/ui/async_slot.svelte.js +4 -4
- package/dist/ui/keyed_async_slot.svelte.d.ts +4 -4
- package/dist/ui/keyed_async_slot.svelte.js +3 -3
- 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 '
|
|
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
|
-
*
|
|
84
|
-
*
|
|
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,
|
|
88
|
-
*
|
|
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
|
|
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
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const
|
|
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:
|
|
270
|
+
connection_closer: null
|
|
130
271
|
})
|
|
131
272
|
: null;
|
|
132
273
|
if (audit_sse) {
|
|
133
|
-
deps.
|
|
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
|
-
|
|
285
|
+
const stack_specs = [proxy_spec, ...auth_middleware];
|
|
286
|
+
let middleware_specs = stack_specs;
|
|
144
287
|
if (options.transform_middleware) {
|
|
145
|
-
|
|
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
|
-
|
|
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 (
|
|
240
|
-
if (options.surface_route
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
//
|
|
428
|
-
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
|
|
465
|
-
// `
|
|
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
|
-
|
|
484
|
-
|
|
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
|
+
});
|