@statorjs/stator 2.2.0 → 2.4.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.
@@ -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
+ }
package/src/server/dev.ts CHANGED
@@ -12,6 +12,7 @@ import { machineStub, stator } from '../vite/index.ts'
12
12
  import type { AppStore } from './app-store.ts'
13
13
  import { findFreePort, installGracefulShutdown, printDevBanner } from './banner.ts'
14
14
  import { resolveAppConfig } from './config-compat.ts'
15
+ import { loadDotenv } from './env.ts'
15
16
  import { logger, setLogLevel } from './logger.ts'
16
17
  import type { MachineStore } from './machine-store.ts'
17
18
  import type { DiscoveredRoute } from './route-discovery.ts'
@@ -54,6 +55,9 @@ export interface DevAppConfig {
54
55
  sessions?: {
55
56
  /** Per-session TTL in seconds. Defaults to 24h (86400). */
56
57
  ttlSeconds?: number
58
+ /** Session cookie policy. `cookie.sameSite: 'Strict'` opts into the
59
+ * controlled CSRF posture. */
60
+ cookie?: { sameSite?: 'Lax' | 'Strict' }
57
61
  }
58
62
  /** Dev-only tooling. */
59
63
  dev?: {
@@ -65,6 +69,16 @@ export interface DevAppConfig {
65
69
  /** Minimum level to emit. Default: `info` in dev. `LOG_LEVEL` env wins. */
66
70
  level?: LogLevel
67
71
  }
72
+ /** Origins allowed to make cross-site writes despite the CSRF guard (exact or
73
+ * wildcard-subdomain). Mirrors `StatorConfig.trustedOrigins`. */
74
+ trustedOrigins?: readonly string[]
75
+ /** Canonical app URL, exposed via `stator(c).origin`. */
76
+ origin?: string
77
+ /** Signed-cookie signing key. Falls back to `STATOR_SECRET`. Mirrors
78
+ * `StatorConfig.secret`. */
79
+ secret?: string
80
+ /** Cross-origin READ policy (CORS); `origins` defaults to `trustedOrigins`. */
81
+ cors?: { origins?: string[]; credentials?: boolean }
68
82
  // Deprecated flat keys — accepted (typed) so 2.1.0 callers don't break; nested
69
83
  // wins. `createDevApp` never shipped `ssePingMs`, so it's not accepted here.
70
84
  /** @deprecated use `persistence.session` */
@@ -90,11 +104,17 @@ export interface DevApp {
90
104
  machine: D,
91
105
  event: EventOf<D>,
92
106
  ): Promise<{ committed: boolean }>
107
+ /** Break-glass: the current raw Hono app (a getter — the dev server rebuilds
108
+ * it on edits, so mount inside the pipeline, not post-hoc). */
109
+ readonly hono: Hono
93
110
  listen: (port: number) => Promise<void>
94
111
  close: () => Promise<void>
95
112
  }
96
113
 
97
114
  export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
115
+ // Load .env before anything reads process.env. No-op if the CLI already
116
+ // loaded it; covers a direct `createDevApp` call (a hand-written dev entry).
117
+ loadDotenv(resolve(config.root))
98
118
  // Vite's HMR websocket defaults to 24678 for EVERY dev server — two
99
119
  // stator apps side by side would fight over it (the loser's live reload
100
120
  // silently dies). Probe a free one instead.
@@ -129,6 +149,9 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
129
149
  const logLevel = process.env.LOG_LEVEL ?? resolved.logLevel ?? 'info'
130
150
  setLogLevel(logLevel)
131
151
  runtime.setLogLevel(logLevel)
152
+ // The session cookie is written by the Vite-loaded runtime, so configure that
153
+ // instance's SameSite (the native one would set a different module's global).
154
+ runtime.setSessionSameSite(resolved.sameSite ?? 'Lax')
132
155
  const inspectorOn = resolved.inspector ?? true
133
156
 
134
157
  const resultCache = new Map<string, ReturnType<typeof compile>>()
@@ -218,12 +241,21 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
218
241
  }
219
242
  }
220
243
  const rebuildServer = async (): Promise<void> => {
244
+ // Re-discover `middleware.ts` each rebuild so edits to it take effect. Loaded
245
+ // through the Vite runtime so its handlers share the runtime's instance.
246
+ const middleware = await runtime.discoverMiddleware(resolve(root, 'middleware.ts'), loader)
221
247
  app = await runtime.buildHonoApp({
222
248
  routes,
223
249
  store,
224
250
  staticDir: config.staticDir,
225
251
  headExtras,
226
252
  inspector: inspectorOn,
253
+ trustedOrigins: resolved.trustedOrigins,
254
+ sameSite: resolved.sameSite,
255
+ origin: resolved.origin,
256
+ secret: resolved.secret,
257
+ cors: resolved.cors,
258
+ middleware,
227
259
  })
228
260
  }
229
261
 
@@ -266,6 +298,9 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
266
298
 
267
299
  return {
268
300
  fetch: (request) => app.fetch(request),
301
+ get hono() {
302
+ return app
303
+ },
269
304
  vite,
270
305
  // `store` is read at call time, not captured — a machine-edit rebuild
271
306
  // swaps it and the method follows.
@@ -0,0 +1,27 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+
4
+ /**
5
+ * Load `.env` files into `process.env` — the home for server config/secrets
6
+ * that's uniform across dev and prod (unlike Vite's transform-time
7
+ * `import.meta.env`, absent in prod). Precedence, highest first:
8
+ *
9
+ * real shell env > .env.local > .env
10
+ *
11
+ * `process.loadEnvFile` (native, Node ≥20.12 — no dependency) never overrides
12
+ * an already-set key, so real env always wins, and `.env.local` is loaded
13
+ * *before* `.env`: the earlier load claims a key the later one can't reclaim.
14
+ * Absent files are skipped. Idempotent-safe — a second call adds nothing new,
15
+ * so the CLI (which loads before importing `stator.config.ts`) and
16
+ * `createApp`/`createDevApp` (which load for the direct `server.ts` path) can
17
+ * both call it without conflict.
18
+ *
19
+ * `.env.local` is for machine-local secrets and belongs in `.gitignore`; `.env`
20
+ * holds committed defaults.
21
+ */
22
+ export function loadDotenv(root: string = process.cwd()): void {
23
+ for (const name of ['.env.local', '.env']) {
24
+ const path = resolve(root, name)
25
+ if (existsSync(path)) process.loadEnvFile(path)
26
+ }
27
+ }
@@ -6,18 +6,19 @@ import { type Context, Hono } from 'hono'
6
6
  import { streamSSE } from 'hono/streaming'
7
7
  import { z } from 'zod'
8
8
  import { applyRenderedEffects, runApiRoute } from './api-route.ts'
9
- import { isBlockedCrossSite } from './csrf.ts'
9
+ import { crossSiteGuard } from './csrf.ts'
10
10
  import { scheduleSessionEffects } from './effects.ts'
11
11
  import { record, replayFor } from './event-dedupe.ts'
12
12
  import { scopedLogger } from './logger.ts'
13
13
  import type { MachineStore } from './machine-store.ts'
14
+ import type { MiddlewareDefinition } from './middleware.ts'
14
15
  import { runQueryRoute } from './query-route.ts'
15
16
  import { initialSyncPatches, recompute } from './recompute.ts'
16
17
  import { renderRoute } from './render.ts'
17
18
  import type { DiscoveredRoute } from './route-discovery.ts'
18
19
  import { buildRouteRequest } from './route-request.ts'
19
20
  import { isStatorQueryRoute, type RouteDefinition } from './routing.ts'
20
- import { getOrCreateSessionId } from './session.ts'
21
+ import { CLAIMS_KEY, getOrCreateSessionId, getSessionState } from './session.ts'
21
22
  import { withSessionLock } from './session-lock.ts'
22
23
  import { SessionRuntime } from './session-runtime.ts'
23
24
  import { fanOut, registerConnection, unregisterConnection } from './sse.ts'
@@ -37,6 +38,22 @@ export interface HttpConfig {
37
38
  inspector?: boolean
38
39
  /** SSE heartbeat interval in ms (default 25s). Tests shorten it. */
39
40
  ssePingMs?: number
41
+ /** Origins allowed to make cross-site writes despite the guard (exact or
42
+ * wildcard-subdomain). Feeds the default `crossSiteGuard`. */
43
+ trustedOrigins?: readonly string[]
44
+ /** Session cookie `SameSite`. `Strict` flips the guard to allowlist-only for
45
+ * same-site writes too. */
46
+ sameSite?: 'Lax' | 'Strict'
47
+ /** Canonical app origin, exposed to middleware via `stator(c).origin`. */
48
+ origin?: string
49
+ /** Signed-cookie signing key (`config.secret` ?? `STATOR_SECRET`). Stashed on
50
+ * the request context so `cookies.setSigned`/`getSigned` can reach it. */
51
+ secret?: string
52
+ /** Resolved CORS read policy, exposed via `stator(c).cors`. */
53
+ cors?: { origins: readonly string[]; credentials: boolean }
54
+ /** The app's discovered `middleware.ts` definition (if any). `withDefaults`
55
+ * controls whether the framework security stack is prepended. */
56
+ middleware?: MiddlewareDefinition
40
57
  }
41
58
 
42
59
  const eventSchema = z.object({
@@ -151,6 +168,55 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
151
168
  )
152
169
  })
153
170
 
171
+ // Context bridge — expose resolved config to every middleware (cors, app
172
+ // middleware) that follows, off `stator(c)`. Runs first. Also establishes the
173
+ // session once and eager-loads its claims (for a returning session), so the
174
+ // whole request shares one sid + claims snapshot; dirty claims persist at the
175
+ // end, to the current (possibly rotated) sid.
176
+ app.use('*', async (c, next) => {
177
+ c.set('stator', {
178
+ origin: config.origin,
179
+ trustedOrigins: config.trustedOrigins ?? [],
180
+ sameSite: config.sameSite ?? 'Lax',
181
+ cors: config.cors,
182
+ })
183
+ // Stash the store so middleware session ops (rotate/clear) can act now.
184
+ c.set('statorStore', config.store)
185
+ // Stash the signing key so the cookie jar's signed methods can reach it.
186
+ if (config.secret !== undefined) c.set('statorSecret', config.secret)
187
+ const { sessionId, isNew } = getOrCreateSessionId(c)
188
+ const session = getSessionState(c)
189
+ if (!isNew && session) {
190
+ session.claims = (await config.store.persistence.get(sessionId, CLAIMS_KEY)) ?? undefined
191
+ }
192
+ await next()
193
+ // Same session object (rotation mutates `.sid` in place), so persist to the
194
+ // current — possibly rotated — id.
195
+ if (session?.claimsDirty) {
196
+ await config.store.persistence.set(session.sid, CLAIMS_KEY, session.claims, {
197
+ ttlSeconds: config.store.sessionTtlSeconds,
198
+ })
199
+ }
200
+ })
201
+
202
+ // Security defaults (unless the app opted out via dangerouslyDefineMiddleware),
203
+ // then the app's own middleware — all ahead of route matching, so a guard here
204
+ // can't be missed by a route added later. crossSiteGuard runs before matching,
205
+ // so a cross-site write to an unknown path 403s (not a route-revealing 404).
206
+ const mw = config.middleware
207
+ if (mw?.withDefaults ?? true) {
208
+ app.use(
209
+ '*',
210
+ crossSiteGuard({
211
+ trustedOrigins: config.trustedOrigins,
212
+ strict: config.sameSite === 'Strict',
213
+ }),
214
+ )
215
+ }
216
+ for (const handler of mw?.handlers ?? []) {
217
+ app.use('*', handler)
218
+ }
219
+
154
220
  // Compile matchers for every route, in discovery's specificity order. Our own
155
221
  // matcher (not Hono's router) is the routing authority: GET/API dispatch and
156
222
  // SSE/POST resolution all go through `matchPath`, so rest params (`*name`) and
@@ -291,9 +357,6 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
291
357
  })
292
358
 
293
359
  app.post('/__events', async (c) => {
294
- if (isBlockedCrossSite(c)) {
295
- return c.json({ error: 'cross-site request blocked' }, 403)
296
- }
297
360
  const { sessionId } = getOrCreateSessionId(c)
298
361
  const routeKey = c.req.header('X-Stator-Route')
299
362
  if (!routeKey) {
@@ -341,6 +404,19 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
341
404
  return c.json({ error: `unknown machine "${body.machine}"` }, 404)
342
405
  }
343
406
 
407
+ // `serverOnly` events are server-generated — effect completions
408
+ // (`CHARGE_APPROVED`), `after:` timers, cross-machine internals — that no
409
+ // client legitimately sends. A client POST of one is forgery (e.g. faking a
410
+ // settled charge), so reject at the wire boundary with 403. The chart still
411
+ // handles the event when the engine/effects raise it internally: that path
412
+ // is `runtime.processEvent`, never `/__events`, so it's untouched. Enforced
413
+ // in dev and prod alike — the declaration is explicit, so there's no
414
+ // false-positive risk and no dev/prod divergence. Origin legitimacy only,
415
+ // not per-user authorization — guards still own that.
416
+ if (originDef.serverOnly.includes(body.event.type)) {
417
+ return c.json({ error: `event type "${body.event.type}" is server-only` }, 403)
418
+ }
419
+
344
420
  return withSessionLock(sessionId, async () => {
345
421
  // Idempotent replay: a duplicate POST (client retry after a lost
346
422
  // response) returns the original response body verbatim instead of
@@ -408,7 +484,6 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
408
484
  const matched = matchPath(matchers, c.req.path)
409
485
  const apiRoute = matched?.route[method]
410
486
  if (!matched || !apiRoute) return next()
411
- if (isBlockedCrossSite(c)) return c.text('cross-site request blocked', 403)
412
487
  return runApiRoute(c, matched.route, apiRoute, config.store, matched.params)
413
488
  })
414
489
  }
@@ -20,8 +20,13 @@ export type { AppStore } from './app-store.ts'
20
20
  export { InMemoryAppStore } from './app-store.ts'
21
21
  export type { CachedStoreOptions } from './cached-store.ts'
22
22
  export { CachedStore } from './cached-store.ts'
23
+ export type { StatorContext } from './context.ts'
24
+ export { stator } from './context.ts'
25
+ export type { CorsOptions } from './cors.ts'
26
+ export { cors } from './cors.ts'
23
27
  export type { CreateAppConfig, StatorApp } from './create-app.ts'
24
28
  export { createApp } from './create-app.ts'
29
+ export { crossSiteGuard } from './csrf.ts'
25
30
  export type {
26
31
  ActionHelpers,
27
32
  DefineMachineConfig,
@@ -49,6 +54,13 @@ export type { InstanceHandle } from './instance-proxy.ts'
49
54
  export { createInstanceProxy, defForProxy } from './instance-proxy.ts'
50
55
  export { logger, scopedLogger, setLogLevel } from './logger.ts'
51
56
  export { MachineStore } from './machine-store.ts'
57
+ export type { MiddlewareDefinition } from './middleware.ts'
58
+ export {
59
+ dangerouslyDefineMiddleware,
60
+ defineMiddleware,
61
+ discoverMiddleware,
62
+ isMiddlewareDefinition,
63
+ } from './middleware.ts'
52
64
  export { runQueryRoute } from './query-route.ts'
53
65
  export { recompute } from './recompute.ts'
54
66
  export { RedisAppStore, RedisStore } from './redis-store.ts'
@@ -106,7 +118,9 @@ export {
106
118
  isStatorQueryRoute,
107
119
  isStatorRoute,
108
120
  } from './routing.ts'
109
- export { getOrCreateSessionId, SESSION_COOKIE } from './session.ts'
121
+ export type { SecurityHeadersOptions } from './security-headers.ts'
122
+ export { securityHeaders } from './security-headers.ts'
123
+ export { getOrCreateSessionId, SESSION_COOKIE, setSessionSameSite } from './session.ts'
110
124
  export { SessionRuntime } from './session-runtime.ts'
111
125
  export type { Connection } from './sse.ts'
112
126
  export {
@@ -6,6 +6,7 @@ import { abortEntryEffects, isEffectInFlight } from './effects.ts'
6
6
  import { createInstanceProxy, type InstanceHandle } from './instance-proxy.ts'
7
7
  import { scopedLogger } from './logger.ts'
8
8
  import { serverReadsResolver } from './reads-helpers.ts'
9
+ import { RESERVED_KEY_PREFIX } from './session.ts'
9
10
  import type { Store } from './store.ts'
10
11
  import { APP_SCOPE, armAfterTimers, cancelAfterTimers } from './timers.ts'
11
12
 
@@ -87,6 +88,12 @@ export class MachineStore {
87
88
  this.sessionTtlSeconds = opts?.sessionTtlSeconds ?? 86400
88
89
  this.appStore = opts?.appStore ?? new InMemoryAppStore()
89
90
  for (const def of defs) {
91
+ if (def.name.startsWith(RESERVED_KEY_PREFIX)) {
92
+ throw new Error(
93
+ `stator: machine name "${def.name}" is reserved — the "${RESERVED_KEY_PREFIX}" ` +
94
+ `prefix is framework-only (it namespaces per-session storage keys like claims)`,
95
+ )
96
+ }
90
97
  if (this.defs.has(def.name)) {
91
98
  throw new Error(`stator: duplicate machine name "${def.name}"`)
92
99
  }
@@ -0,0 +1,78 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { pathToFileURL } from 'node:url'
3
+ import type { MiddlewareHandler } from 'hono'
4
+ import type { ModuleLoader } from './discovery.ts'
5
+
6
+ // A global-registry symbol so the brand survives the dev dual-instance: the app's
7
+ // `middleware.ts` is loaded through Vite while the framework may check it from the
8
+ // native module instance — `Symbol.for` resolves to the same symbol in both.
9
+ const MIDDLEWARE_BRAND = Symbol.for('stator.middleware.definition')
10
+
11
+ /**
12
+ * The value an app's `middleware.ts` exports (default) — an ordered list of
13
+ * Hono middleware plus whether the framework's security defaults are prepended.
14
+ * Opaque: build it with `defineMiddleware` / `dangerouslyDefineMiddleware`.
15
+ */
16
+ export interface MiddlewareDefinition {
17
+ readonly [MIDDLEWARE_BRAND]: true
18
+ readonly handlers: readonly MiddlewareHandler[]
19
+ /** Prepend the framework's default security stack (`crossSiteGuard`, …)? */
20
+ readonly withDefaults: boolean
21
+ }
22
+
23
+ /** Type guard for a discovered `middleware.ts` default export. */
24
+ export function isMiddlewareDefinition(value: unknown): value is MiddlewareDefinition {
25
+ return typeof value === 'object' && value !== null && MIDDLEWARE_BRAND in value
26
+ }
27
+
28
+ /**
29
+ * Define the app's HTTP middleware. The framework's security defaults
30
+ * (`crossSiteGuard`, …) run first, then these handlers, then the route — so a
31
+ * guard added here can't be missed by a route added later. Export the result as
32
+ * the default from `middleware.ts` at the app root.
33
+ *
34
+ * Handlers are plain Hono middleware and run in array order.
35
+ */
36
+ export function defineMiddleware(handlers: readonly MiddlewareHandler[]): MiddlewareDefinition {
37
+ return { [MIDDLEWARE_BRAND]: true, handlers, withDefaults: true }
38
+ }
39
+
40
+ /**
41
+ * Like `defineMiddleware`, but WITHOUT the framework security defaults — these
42
+ * handlers are the entire stack. The `dangerously` name is the point: opting out
43
+ * of the defaults is a visible, greppable decision that says "this app owns its
44
+ * security." Re-add the exported primitives (`crossSiteGuard()`, …) as needed.
45
+ *
46
+ * This only skips the *security* defaults; framework request plumbing (sessions,
47
+ * dispatch routing, logging, static) is not middleware and is never bypassed.
48
+ */
49
+ export function dangerouslyDefineMiddleware(
50
+ handlers: readonly MiddlewareHandler[],
51
+ ): MiddlewareDefinition {
52
+ return { [MIDDLEWARE_BRAND]: true, handlers, withDefaults: false }
53
+ }
54
+
55
+ const nativeLoader: ModuleLoader = (file) => import(/* @vite-ignore */ pathToFileURL(file).href)
56
+
57
+ /**
58
+ * Load an app's `middleware.ts` (if present) and validate its default export.
59
+ * Returns `undefined` when there is no file — the framework then applies its
60
+ * security defaults with no user handlers (safe-by-default). `loader` is injected
61
+ * so dev can go through Vite; prod/native uses `import`. The single toolchain
62
+ * touch-point, kept swappable for the post-Vite pipeline.
63
+ */
64
+ export async function discoverMiddleware(
65
+ file: string,
66
+ loader: ModuleLoader = nativeLoader,
67
+ ): Promise<MiddlewareDefinition | undefined> {
68
+ if (!existsSync(file)) return undefined
69
+ const mod = await loader(file)
70
+ const def = (mod as { default?: unknown }).default
71
+ if (!isMiddlewareDefinition(def)) {
72
+ throw new Error(
73
+ `${file} must \`export default defineMiddleware([...])\` ` +
74
+ `(or dangerouslyDefineMiddleware([...]))`,
75
+ )
76
+ }
77
+ return def
78
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Match an incoming `Origin` against an allowlist of patterns — shared by the
3
+ * cross-site write guard (`trustedOrigins`) and `cors()`. Patterns are origins
4
+ * with a scheme, either exact (`https://app.example.com`) or wildcard-subdomain
5
+ * (`https://*.example.com`).
6
+ *
7
+ * Boundary-safe by construction: `https://*.example.com` matches `app.example.com`
8
+ * and `a.b.example.com` (any depth), and NEVER `example.com.evil.com` (suffix
9
+ * attack), `evilexample.com` (no label boundary), or the apex `example.com` (list
10
+ * it explicitly if wanted). Scheme must match; port matches when the pattern omits
11
+ * one, else must be equal. Malformed origins and patterns fail closed (no match).
12
+ */
13
+ export function matchOrigin(
14
+ origin: string | undefined | null,
15
+ patterns: readonly string[],
16
+ ): boolean {
17
+ if (!origin) return false
18
+ let incoming: URL
19
+ try {
20
+ incoming = new URL(origin)
21
+ } catch {
22
+ return false
23
+ }
24
+ return patterns.some((pattern) => matchOne(incoming, pattern))
25
+ }
26
+
27
+ /** `scheme://*.rest` — `rest` may carry a `:port`, split off below. */
28
+ const WILDCARD = /^([a-z][a-z0-9+.-]*):\/\/\*\.([^/]+)$/i
29
+
30
+ function matchOne(incoming: URL, pattern: string): boolean {
31
+ const wild = WILDCARD.exec(pattern)
32
+ if (wild) {
33
+ const rawScheme = wild[1]
34
+ const rawRest = wild[2]
35
+ if (rawScheme === undefined || rawRest === undefined) return false
36
+ const scheme = rawScheme.toLowerCase()
37
+ let domain = rawRest.toLowerCase()
38
+ let port = ''
39
+ const colon = domain.lastIndexOf(':')
40
+ if (colon !== -1) {
41
+ port = domain.slice(colon + 1)
42
+ domain = domain.slice(0, colon)
43
+ }
44
+ if (incoming.protocol !== `${scheme}:`) return false
45
+ if (port && incoming.port !== port) return false
46
+ const host = incoming.hostname.toLowerCase()
47
+ // A non-empty subdomain label before `.domain` (any depth), on a label
48
+ // boundary — the leading dot is what defeats the suffix attack.
49
+ return host.length > domain.length + 1 && host.endsWith(`.${domain}`)
50
+ }
51
+
52
+ let pat: URL
53
+ try {
54
+ pat = new URL(pattern)
55
+ } catch {
56
+ return false
57
+ }
58
+ if (incoming.protocol !== pat.protocol) return false
59
+ if (incoming.hostname.toLowerCase() !== pat.hostname.toLowerCase()) return false
60
+ // A pattern with no explicit port matches any port; with one, it must be equal.
61
+ if (pat.port && incoming.port !== pat.port) return false
62
+ return true
63
+ }
@@ -1,5 +1,6 @@
1
1
  import type { HtmlFragment } from '../template/types.ts'
2
2
  import type { WireEnvelope } from '../wire/index.ts'
3
+ import type { CookieJar } from './cookies.ts'
3
4
  import type { AnyMachineDef, EventOf, ReadsMap } from './define-machine.ts'
4
5
 
5
6
  /** Machine context passed to a route's render function. Keyed by machine name. */
@@ -148,6 +149,19 @@ export interface ApiRouteHelpers {
148
149
  * the response carries the new cookie. Requires a store with
149
150
  * `renameSession` (all built-in stores have it). */
150
151
  rotateSession: (opts?: { clear?: boolean }) => void
152
+ /** Read this session's app-defined claims (identity/data). `undefined` if none. */
153
+ claims: <T = unknown>() => T | undefined
154
+ /** Replace this session's claims — persisted at request end. */
155
+ setClaims: (claims: unknown) => void
156
+ /** Drop this session's claims (keeps the session) — persisted at request end. */
157
+ clearClaims: () => void
158
+ /** Destroy this session — its state is deleted and the browser starts
159
+ * anonymous. Sugar for `rotateSession({ clear: true })`; applied after the
160
+ * handler returns. */
161
+ clearSession: () => void
162
+ /** Read/write app-owned cookies (e.g. a login `returnTo`). Distinct from the
163
+ * framework-managed session cookie; writes take effect on this response. */
164
+ readonly cookies: CookieJar
151
165
  }
152
166
 
153
167
  export interface ApiRouteDefinition {
@@ -0,0 +1,32 @@
1
+ import type { MiddlewareHandler } from 'hono'
2
+
3
+ export interface SecurityHeadersOptions {
4
+ /** `X-Frame-Options`. Default `SAMEORIGIN`; `false` to omit. */
5
+ frameOptions?: 'DENY' | 'SAMEORIGIN' | false
6
+ /** `Referrer-Policy`. Default `strict-origin-when-cross-origin`; `false` to omit. */
7
+ referrerPolicy?: string | false
8
+ /** `Strict-Transport-Security` max-age in seconds (HSTS). Off by default —
9
+ * only meaningful over HTTPS, and easy to lock yourself out with. */
10
+ hsts?: number | false
11
+ /** `Content-Security-Policy`. Off by default — CSP varies per app. */
12
+ contentSecurityPolicy?: string | false
13
+ }
14
+
15
+ /**
16
+ * Baseline security response headers — opt-in (not a default), matching SvelteKit
17
+ * and Stator's current stance of leaving HTTP hardening to the app/platform.
18
+ * `X-Content-Type-Options: nosniff` is always set; frame + referrer are on with
19
+ * safe defaults; HSTS and CSP are opt-in.
20
+ */
21
+ export function securityHeaders(opts: SecurityHeadersOptions = {}): MiddlewareHandler {
22
+ const frame = opts.frameOptions ?? 'SAMEORIGIN'
23
+ const referrer = opts.referrerPolicy ?? 'strict-origin-when-cross-origin'
24
+ return async (c, next) => {
25
+ c.header('X-Content-Type-Options', 'nosniff')
26
+ if (frame) c.header('X-Frame-Options', frame)
27
+ if (referrer) c.header('Referrer-Policy', referrer)
28
+ if (opts.hsts) c.header('Strict-Transport-Security', `max-age=${opts.hsts}; includeSubDomains`)
29
+ if (opts.contentSecurityPolicy) c.header('Content-Security-Policy', opts.contentSecurityPolicy)
30
+ await next()
31
+ }
32
+ }