@statorjs/stator 2.1.0 → 2.3.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.
@@ -0,0 +1,92 @@
1
+ import type { LogLevel } from '../config.ts'
2
+ import type { AppStore } from './app-store.ts'
3
+ import type { Store } from './store.ts'
4
+
5
+ /**
6
+ * The pre-nesting flat config keys. `createApp` shipped all of these at 2.1.0 (and
7
+ * `createDevApp` all but `ssePingMs`), so they stay ACCEPTED — typed and
8
+ * `@deprecated`, not removed — to keep those callers compiling and running:
9
+ * nesting the args would otherwise be a breaking change (a major). The removal is
10
+ * parked as a major-cutover (see ROADMAP surface hygiene). Nested values win when
11
+ * both a flat key and its nested home are present.
12
+ */
13
+ export interface DeprecatedFlatConfig {
14
+ /** @deprecated use `persistence.session` */
15
+ store?: Store
16
+ /** @deprecated use `persistence.app` */
17
+ appStore?: AppStore
18
+ /** @deprecated use `sessions.ttlSeconds` */
19
+ sessionTtlSeconds?: number
20
+ /** @deprecated use `realtime.pingMs` */
21
+ ssePingMs?: number
22
+ /** @deprecated use `dev.inspector` */
23
+ inspector?: boolean
24
+ }
25
+
26
+ interface NestedReads {
27
+ persistence?: { session?: Store; app?: AppStore }
28
+ sessions?: { ttlSeconds?: number; cookie?: { sameSite?: 'Lax' | 'Strict' } }
29
+ realtime?: { pingMs?: number }
30
+ dev?: { inspector?: boolean }
31
+ logging?: { level?: LogLevel }
32
+ trustedOrigins?: readonly string[]
33
+ origin?: string
34
+ host?: string
35
+ cors?: { origins?: readonly string[]; credentials?: boolean }
36
+ }
37
+
38
+ /** The flat internal values `createApp`/`createDevApp` actually consume. */
39
+ export interface ResolvedAppConfig {
40
+ session?: Store
41
+ app?: AppStore
42
+ sessionTtlSeconds?: number
43
+ ssePingMs?: number
44
+ inspector?: boolean
45
+ logLevel?: LogLevel
46
+ trustedOrigins?: readonly string[]
47
+ sameSite?: 'Lax' | 'Strict'
48
+ origin?: string
49
+ host?: string
50
+ cors?: { origins: readonly string[]; credentials: boolean }
51
+ }
52
+
53
+ /**
54
+ * Resolve the nested config, falling back to the deprecated flat keys, and warn
55
+ * once (per app construction) if any flat key is in use. Nested wins. Exported for
56
+ * testing.
57
+ */
58
+ export function resolveAppConfig(config: NestedReads & DeprecatedFlatConfig): ResolvedAppConfig {
59
+ const deprecated: string[] = []
60
+ if (config.store !== undefined) deprecated.push('store → persistence.session')
61
+ if (config.appStore !== undefined) deprecated.push('appStore → persistence.app')
62
+ if (config.sessionTtlSeconds !== undefined)
63
+ deprecated.push('sessionTtlSeconds → sessions.ttlSeconds')
64
+ if (config.ssePingMs !== undefined) deprecated.push('ssePingMs → realtime.pingMs')
65
+ if (config.inspector !== undefined) deprecated.push('inspector → dev.inspector')
66
+
67
+ if (deprecated.length > 0) {
68
+ console.warn(
69
+ 'stator: these createApp/createDevApp options are deprecated and will be removed in a future major — move to the nested shape:\n' +
70
+ deprecated.map((d) => ` ${d}`).join('\n'),
71
+ )
72
+ }
73
+
74
+ return {
75
+ session: config.persistence?.session ?? config.store,
76
+ app: config.persistence?.app ?? config.appStore,
77
+ sessionTtlSeconds: config.sessions?.ttlSeconds ?? config.sessionTtlSeconds,
78
+ ssePingMs: config.realtime?.pingMs ?? config.ssePingMs,
79
+ inspector: config.dev?.inspector ?? config.inspector,
80
+ logLevel: config.logging?.level,
81
+ trustedOrigins: config.trustedOrigins,
82
+ sameSite: config.sessions?.cookie?.sameSite,
83
+ origin: config.origin,
84
+ host: config.host,
85
+ cors: config.cors
86
+ ? {
87
+ origins: config.cors.origins ?? config.trustedOrigins ?? [],
88
+ credentials: config.cors.credentials ?? false,
89
+ }
90
+ : undefined,
91
+ }
92
+ }
@@ -0,0 +1,101 @@
1
+ import type { Context } from 'hono'
2
+ import { type CookieJar, cookieJar } from './cookies.ts'
3
+ import { getSessionState, rotateSessionNow } from './session.ts'
4
+ import type { Store } from './store.ts'
5
+
6
+ /**
7
+ * The resolved config data `buildHonoApp` stashes on the context (early), for
8
+ * the security defaults, `cors()`, and app middleware to read.
9
+ */
10
+ interface StatorConfigData {
11
+ readonly origin?: string
12
+ readonly trustedOrigins: readonly string[]
13
+ readonly sameSite: 'Lax' | 'Strict'
14
+ readonly cors?: { readonly origins: readonly string[]; readonly credentials: boolean }
15
+ }
16
+
17
+ // Type `c.get('stator')` / `c.set('stator', …)` — the stored config data, plus
18
+ // the store the bridge stashes so middleware session ops can rotate/clear now.
19
+ declare module 'hono' {
20
+ interface ContextVariableMap {
21
+ stator: StatorConfigData
22
+ statorStore: { persistence: Store }
23
+ }
24
+ }
25
+
26
+ /**
27
+ * What `stator(c)` returns — resolved config *plus* per-request session ops.
28
+ * Read it in middleware; handlers get the same ops on their `ctx`.
29
+ */
30
+ export interface StatorContext {
31
+ readonly origin?: string
32
+ readonly trustedOrigins: readonly string[]
33
+ readonly sameSite: 'Lax' | 'Strict'
34
+ readonly cors?: { readonly origins: readonly string[]; readonly credentials: boolean }
35
+
36
+ /** The established session id for this request. */
37
+ readonly sid: string
38
+ /** Read this session's app-defined claims (identity/data). `undefined` if none. */
39
+ claims<T = unknown>(): T | undefined
40
+ /** Replace this session's claims — persisted at request end. */
41
+ setClaims(claims: unknown): void
42
+ /** Drop this session's claims (keeps the session) — persisted at request end. */
43
+ clearClaims(): void
44
+ /** Rotate the session id *now* (fixation defense on privilege change): state
45
+ * moves to a fresh id, the response carries the new cookie. Immediate because
46
+ * middleware runs upstream of the handler pipeline. */
47
+ rotateSession(opts?: { clear?: boolean }): Promise<void>
48
+ /** Destroy this session *now* — its state is deleted and the browser starts
49
+ * anonymous. Sugar for `rotateSession({ clear: true })`. */
50
+ clearSession(): Promise<void>
51
+ /** Read/write app-owned cookies (e.g. a login `returnTo`). Distinct from the
52
+ * framework-managed session cookie. */
53
+ readonly cookies: CookieJar
54
+ }
55
+
56
+ const DEFAULT: StatorConfigData = { trustedOrigins: [], sameSite: 'Lax' }
57
+
58
+ /**
59
+ * Read Stator's request context — resolved config + session ops. Safe outside the
60
+ * framework pipeline (inert config defaults, empty session) if the bridge/session
61
+ * setup never ran.
62
+ */
63
+ export function stator(c: Context): StatorContext {
64
+ const cfg = c.get('stator') ?? DEFAULT
65
+ const session = getSessionState(c)
66
+ return {
67
+ origin: cfg.origin,
68
+ trustedOrigins: cfg.trustedOrigins,
69
+ sameSite: cfg.sameSite,
70
+ cors: cfg.cors,
71
+ get sid() {
72
+ return session?.sid ?? ''
73
+ },
74
+ claims<T = unknown>(): T | undefined {
75
+ return session?.claims as T | undefined
76
+ },
77
+ setClaims(claims: unknown): void {
78
+ if (session) {
79
+ session.claims = claims
80
+ session.claimsDirty = true
81
+ }
82
+ },
83
+ clearClaims(): void {
84
+ if (session) {
85
+ session.claims = undefined
86
+ session.claimsDirty = true
87
+ }
88
+ },
89
+ async rotateSession(opts?: { clear?: boolean }): Promise<void> {
90
+ const store = c.get('statorStore')
91
+ if (!store) return // inert outside the framework pipeline
92
+ await rotateSessionNow(c, store, opts)
93
+ },
94
+ async clearSession(): Promise<void> {
95
+ const store = c.get('statorStore')
96
+ if (!store) return
97
+ await rotateSessionNow(c, store, { clear: true })
98
+ },
99
+ cookies: cookieJar(c),
100
+ }
101
+ }
@@ -0,0 +1,37 @@
1
+ import type { Context } from 'hono'
2
+ import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
3
+ import type { RouteCookieOptions } from './routing.ts'
4
+
5
+ /**
6
+ * A focused read/write cookie surface for app-owned cookies (a login
7
+ * `returnTo`, a consent flag, a theme). Thin over `hono/cookie` — no jar state,
8
+ * writes append `Set-Cookie` on the response immediately.
9
+ *
10
+ * `get` reads the *inbound* request cookie: a `set` this request is not visible
11
+ * to a later `get` in the same request (standard cookie semantics). The session
12
+ * cookie (`stator_sid`) is framework-managed — use the session ops, not this.
13
+ */
14
+ export interface CookieJar {
15
+ /** The inbound value of `name`, or `undefined` if absent. */
16
+ get(name: string): string | undefined
17
+ /** Write a cookie on the response. Defaults are hono's (not HttpOnly/Secure);
18
+ * pass options for auth-shaped cookies. */
19
+ set(name: string, value: string, options?: RouteCookieOptions): void
20
+ /** Expire a cookie. `path`/`domain` must match how it was set. */
21
+ delete(name: string, options?: Pick<RouteCookieOptions, 'path' | 'domain'>): void
22
+ }
23
+
24
+ /** Build the cookie jar bound to a request context. Shared by `stator(c)`
25
+ * (middleware) and the API-route handler `ctx`, so both read/write cookies
26
+ * the same way. */
27
+ export function cookieJar(c: Context): CookieJar {
28
+ return {
29
+ get: (name) => getCookie(c, name),
30
+ set: (name, value, options) => {
31
+ setCookie(c, name, value, options as never)
32
+ },
33
+ delete: (name, options) => {
34
+ deleteCookie(c, name, options as never)
35
+ },
36
+ }
37
+ }
@@ -0,0 +1,48 @@
1
+ import type { MiddlewareHandler } from 'hono'
2
+ import { stator } from './context.ts'
3
+ import { matchOrigin } from './origin-match.ts'
4
+
5
+ export interface CorsOptions {
6
+ /** Allowed origins (exact or wildcard-subdomain). Defaults to the app's
7
+ * `cors.origins`, which itself defaults to `trustedOrigins`. */
8
+ origins?: readonly string[]
9
+ /** Send `Access-Control-Allow-Credentials: true` (needed to read a response
10
+ * with cookies). Defaults to the app's `cors.credentials`. */
11
+ credentials?: boolean
12
+ /** Methods advertised on preflight. Default: GET/POST/PUT/PATCH/DELETE/OPTIONS. */
13
+ methods?: readonly string[]
14
+ /** Request headers advertised on preflight. Default: reflects the request's. */
15
+ headers?: readonly string[]
16
+ }
17
+
18
+ const DEFAULT_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'] as const
19
+
20
+ /**
21
+ * CORS middleware — governs which cross-origin sites may READ responses (distinct
22
+ * from `crossSiteGuard`, which governs cross-site WRITES). Opt-in, not a default.
23
+ * Reflects the request `Origin` when it matches the allowlist — never `*`, so
24
+ * credentialed (cookie-bearing) reads work — and answers preflight with 204.
25
+ * Wildcards go through the shared boundary-safe matcher.
26
+ */
27
+ export function cors(opts: CorsOptions = {}): MiddlewareHandler {
28
+ return async (c, next) => {
29
+ const ctx = stator(c)
30
+ const origins = opts.origins ?? ctx.cors?.origins ?? ctx.trustedOrigins
31
+ const credentials = opts.credentials ?? ctx.cors?.credentials ?? false
32
+ const reqOrigin = c.req.header('origin')
33
+
34
+ if (reqOrigin && matchOrigin(reqOrigin, origins)) {
35
+ c.header('Access-Control-Allow-Origin', reqOrigin)
36
+ c.header('Vary', 'Origin')
37
+ if (credentials) c.header('Access-Control-Allow-Credentials', 'true')
38
+ }
39
+
40
+ if (c.req.method === 'OPTIONS') {
41
+ c.header('Access-Control-Allow-Methods', (opts.methods ?? DEFAULT_METHODS).join(', '))
42
+ const reqHeaders = opts.headers?.join(', ') ?? c.req.header('access-control-request-headers')
43
+ if (reqHeaders) c.header('Access-Control-Allow-Headers', reqHeaders)
44
+ return c.body(null, 204)
45
+ }
46
+ await next()
47
+ }
48
+ }
@@ -1,39 +1,78 @@
1
1
  import { resolve } from 'node:path'
2
2
  import { serve } from '@hono/node-server'
3
+ import type { Hono } from 'hono'
4
+ import type { LogLevel } from '../config.ts'
3
5
  import type { AnyMachineDef, EventOf } from '../engine/index.ts'
4
6
  import { dispatchToApp } from './app-dispatch.ts'
5
7
  import type { AppStore } from './app-store.ts'
6
- import { installGracefulShutdown } from './banner.ts'
8
+ import { installGracefulShutdown, printStartupNotice } from './banner.ts'
9
+ import { type DeprecatedFlatConfig, resolveAppConfig } from './config-compat.ts'
7
10
  import { discoverMachines } from './discovery.ts'
8
11
  import { wireAppEffects } from './effects.ts'
9
12
  import { buildHonoApp } from './http.ts'
10
- import { logger } from './logger.ts'
13
+ import { logger, setLogLevel } from './logger.ts'
11
14
  import { MachineStore } from './machine-store.ts'
15
+ import { discoverMiddleware } from './middleware.ts'
12
16
  import { discoverRoutes } from './route-discovery.ts'
17
+ import { setSessionSameSite } from './session.ts'
13
18
  import { InMemoryStore, type Store } from './store.ts'
14
19
 
15
- export interface CreateAppConfig {
20
+ export interface CreateAppConfig extends DeprecatedFlatConfig {
16
21
  machinesDir: string
17
22
  routesDir: string
18
23
  staticDir?: string
19
- /** Persistence adapter for session-lifecycle machine state. Defaults to
20
- * InMemoryStore — fine for dev, V1 adapters swap in here. */
21
- store?: Store
22
- /** Persistence for `persist: true` app-lifecycle machines (no TTL, one
23
- * blob per machine). Defaults to in-memory (restart-wipe); pass
24
- * RedisAppStore for durable app state. */
25
- appStore?: AppStore
26
- /** Per-session TTL in seconds. Every set to any of the session's
27
- * machines refreshes this expiry. Defaults to 24h (86400). */
28
- sessionTtlSeconds?: number
29
- /** SSE heartbeat interval in ms (default 25s). Tests shorten it. */
30
- ssePingMs?: number
24
+ /** Swappable persistence adapters, grouped by concern. Both optional —
25
+ * default to in-memory (restart-wipe). Mirrors `StatorConfig.persistence`. */
26
+ persistence?: {
27
+ /** Session-lifecycle machine state. Defaults to InMemoryStore — fine for
28
+ * dev, V1 adapters swap in here. */
29
+ session?: Store
30
+ /** App-lifecycle machine state for `persist: true` machines (no TTL, one
31
+ * blob per machine). Defaults to in-memory (restart-wipe); pass
32
+ * RedisAppStore for durable app state. */
33
+ app?: AppStore
34
+ }
35
+ /** Session policy. */
36
+ sessions?: {
37
+ /** Per-session TTL in seconds. Every set to any of the session's machines
38
+ * refreshes this expiry. Defaults to 24h (86400). */
39
+ ttlSeconds?: number
40
+ /** Session cookie policy. `cookie.sameSite: 'Strict'` opts into the
41
+ * controlled CSRF posture. */
42
+ cookie?: { sameSite?: 'Lax' | 'Strict' }
43
+ }
44
+ /** Realtime / push policy. */
45
+ realtime?: {
46
+ /** SSE heartbeat interval in ms (default 25s). Tests shorten it. */
47
+ pingMs?: number
48
+ }
49
+ /** Dev-only tooling. */
50
+ dev?: {
51
+ /** Serve + inject the wire inspector toolbar (the dev server's on by
52
+ * default; production opts in — demo sites want the wire visible). */
53
+ inspector?: boolean
54
+ }
55
+ /** Logging policy. */
56
+ logging?: {
57
+ /** Minimum level to emit. Default: `warn` in production, `info` in dev.
58
+ * `LOG_LEVEL` env takes precedence over this. */
59
+ level?: LogLevel
60
+ }
61
+ /** Origins allowed to make cross-site writes despite the CSRF guard (exact or
62
+ * wildcard-subdomain). Mirrors `StatorConfig.trustedOrigins`. */
63
+ trustedOrigins?: readonly string[]
64
+ /** Canonical app URL, exposed via `stator(c).origin`. */
65
+ origin?: string
66
+ /** Listen host / bind address (used by `listen`). Default: all interfaces. */
67
+ host?: string
68
+ /** Cross-origin READ policy (CORS); `origins` defaults to `trustedOrigins`. */
69
+ cors?: { origins?: string[]; credentials?: boolean }
31
70
  /** Extra `<head>` HTML per GET route. A production build uses this to link the
32
71
  * prebuilt `components.css`; ignored if omitted. */
33
72
  headExtras?: (filePath: string) => string | Promise<string>
34
- /** Serve + inject the wire inspector toolbar (the dev server's on by
35
- * default; production opts in — demo sites want the wire visible). */
36
- inspector?: boolean
73
+ /** Path to the app's `middleware.ts` (if any). Loaded and validated; its
74
+ * default export must be `defineMiddleware`/`dangerouslyDefineMiddleware`. */
75
+ middlewareFile?: string
37
76
  }
38
77
 
39
78
  export interface StatorApp {
@@ -50,6 +89,10 @@ export interface StatorApp {
50
89
  machine: D,
51
90
  event: EventOf<D>,
52
91
  ): Promise<{ committed: boolean }>
92
+ /** Break-glass: the raw Hono app. The unsupported paved-road exit — for
93
+ * sub-app mounts / protocol upgrades the framework surface doesn't cover.
94
+ * Mutate before `listen`. */
95
+ hono: Hono
53
96
  }
54
97
 
55
98
  export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
@@ -57,11 +100,17 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
57
100
  const routesDir = resolve(config.routesDir)
58
101
  const staticDir = config.staticDir ? resolve(config.staticDir) : undefined
59
102
 
103
+ const resolved = resolveAppConfig(config)
104
+ // Level precedence: LOG_LEVEL env > config > the production default (createApp
105
+ // is the production entry point, so it defaults quiet). setLogLevel also covers
106
+ // the scoped children (http/sse/…), which a bare `logger.level =` would miss.
107
+ setLogLevel(process.env.LOG_LEVEL ?? resolved.logLevel ?? 'warn')
108
+ setSessionSameSite(resolved.sameSite ?? 'Lax')
60
109
  const { defs } = await discoverMachines(machinesDir)
61
- const persistence = config.store ?? new InMemoryStore()
62
- const store = new MachineStore(defs, persistence, {
63
- sessionTtlSeconds: config.sessionTtlSeconds,
64
- appStore: config.appStore,
110
+ const sessionStore = resolved.session ?? new InMemoryStore()
111
+ const store = new MachineStore(defs, sessionStore, {
112
+ sessionTtlSeconds: resolved.sessionTtlSeconds,
113
+ appStore: resolved.app,
65
114
  })
66
115
  // Wire the effect scheduler BEFORE booting: a fresh app machine fires its
67
116
  // initial-state entry effect during boot, and that must have somewhere to go.
@@ -69,25 +118,35 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
69
118
  await store.bootAppMachines()
70
119
 
71
120
  const routes = await discoverRoutes(routesDir)
121
+ const middleware = config.middlewareFile
122
+ ? await discoverMiddleware(config.middlewareFile)
123
+ : undefined
124
+ const inspector = resolved.inspector
72
125
  const app = await buildHonoApp({
73
126
  routes,
74
127
  store,
75
128
  staticDir,
76
- headExtras: config.inspector
129
+ headExtras: inspector
77
130
  ? async (filePath) => {
78
131
  const base = (await config.headExtras?.(filePath)) ?? ''
79
132
  return `${base}\n<script src="/@stator/inspector.js" defer></script>`
80
133
  }
81
134
  : config.headExtras,
82
- inspector: config.inspector,
83
- ssePingMs: config.ssePingMs,
135
+ inspector,
136
+ ssePingMs: resolved.ssePingMs,
137
+ trustedOrigins: resolved.trustedOrigins,
138
+ sameSite: resolved.sameSite,
139
+ origin: resolved.origin,
140
+ cors: resolved.cors,
141
+ middleware,
84
142
  })
85
143
 
86
144
  return {
87
145
  listen(port: number): Promise<void> {
88
146
  return new Promise((resolveFn) => {
89
- const server = serve({ fetch: app.fetch, port }, () => {
90
- logger.info({ port, machines: defs.length, routes: routes.length }, 'listening')
147
+ const server = serve({ fetch: app.fetch, port, hostname: resolved.host }, () => {
148
+ // Always-on (level-independent) so `warn` prod still confirms boot.
149
+ printStartupNotice({ port, machines: defs.length, routes: routes.length })
91
150
  resolveFn()
92
151
  })
93
152
  // Production is strict about its port (a collision is a deploy
@@ -110,5 +169,6 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
110
169
  fetch: (request: Request) => app.fetch(request),
111
170
  store,
112
171
  dispatchToApp: (machine, event) => dispatchToApp(store, machine, event),
172
+ hono: app,
113
173
  }
114
174
  }
@@ -1,4 +1,5 @@
1
- import type { Context } from 'hono'
1
+ import type { Context, MiddlewareHandler } from 'hono'
2
+ import { matchOrigin } from './origin-match.ts'
2
3
 
3
4
  /**
4
5
  * CSRF signal check for state-changing requests. The session cookie
@@ -11,21 +12,64 @@ import type { Context } from 'hono'
11
12
  * they carry no ambient cookie authority a forgery could abuse, and the header
12
13
  * is browser-only, so a real browser can never suppress it.
13
14
  *
14
- * Note the primary vector blocked is `cross-site`. `same-site` (sibling
15
- * subdomain) is allowed so legitimate multi-subdomain deployments keep working;
16
- * harden with `SameSite=Strict` / an app-level check if subdomains are
17
- * untrusted.
15
+ * `trustedOrigins` is an allowlist (exact or wildcard-subdomain — see
16
+ * `matchOrigin`) of origins permitted despite being cross-site, for a decoupled
17
+ * frontend or partner domain that legitimately writes. `same-site` (sibling
18
+ * subdomain) is allowed by default; under `strict` (the `SameSite=Strict`
19
+ * posture) a same-site write must match `trustedOrigins`, exactly like a
20
+ * cross-site one — the allowlist becomes the whole gate.
18
21
  */
19
- export function isBlockedCrossSite(c: Context): boolean {
20
- const site = c.req.header('sec-fetch-site')
21
- if (site) return site === 'cross-site'
22
+ export function isBlockedCrossSite(
23
+ c: Context,
24
+ trustedOrigins: readonly string[] = [],
25
+ strict = false,
26
+ ): boolean {
22
27
  const origin = c.req.header('origin')
28
+ const site = c.req.header('sec-fetch-site')
29
+
30
+ if (site) {
31
+ if (site === 'same-origin' || site === 'none') return false
32
+ // same-site is allowed by default; under strict it must be allowlisted.
33
+ if (site === 'same-site') return strict ? !matchOrigin(origin, trustedOrigins) : false
34
+ // cross-site: allowed only if allowlisted.
35
+ return !matchOrigin(origin, trustedOrigins)
36
+ }
37
+
38
+ // No Sec-Fetch-Site (older browsers): fall back to an Origin host comparison.
23
39
  if (origin) {
40
+ let sameHost: boolean
24
41
  try {
25
- return new URL(origin).host !== new URL(c.req.url).host
42
+ sameHost = new URL(origin).host === new URL(c.req.url).host
26
43
  } catch {
27
44
  return true
28
45
  }
46
+ if (sameHost) return false
47
+ return !matchOrigin(origin, trustedOrigins)
29
48
  }
49
+
50
+ // No browser signal at all — server-to-server / webhook / test harness.
30
51
  return false
31
52
  }
53
+
54
+ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
55
+
56
+ /**
57
+ * The framework's default cross-site write guard, as composable middleware. It
58
+ * rejects state-changing (non-safe-method) requests that `isBlockedCrossSite`
59
+ * flags — before route matching, so a cross-site write to an unknown path 403s
60
+ * rather than revealing its existence with a 404. Reads its `trustedOrigins`
61
+ * allowlist from options; exported so an app on `dangerouslyDefineMiddleware`
62
+ * can re-add it.
63
+ */
64
+ export function crossSiteGuard(
65
+ opts: { trustedOrigins?: readonly string[]; strict?: boolean } = {},
66
+ ): MiddlewareHandler {
67
+ const trusted = opts.trustedOrigins ?? []
68
+ const strict = opts.strict ?? false
69
+ return async (c, next) => {
70
+ if (!SAFE_METHODS.has(c.req.method) && isBlockedCrossSite(c, trusted, strict)) {
71
+ return c.json({ error: 'cross-site request blocked' }, 403)
72
+ }
73
+ await next()
74
+ }
75
+ }