@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.
package/src/server/dev.ts CHANGED
@@ -6,11 +6,13 @@ import { getRequestListener } from '@hono/node-server'
6
6
  import type { Hono } from 'hono'
7
7
  import { createServer as createViteServer, type ViteDevServer } from 'vite'
8
8
  import { compile } from '../compiler/index.ts'
9
+ import type { LogLevel } from '../config.ts'
9
10
  import type { AnyMachineDef, EventOf } from '../engine/index.ts'
10
11
  import { machineStub, stator } from '../vite/index.ts'
11
12
  import type { AppStore } from './app-store.ts'
12
13
  import { findFreePort, installGracefulShutdown, printDevBanner } from './banner.ts'
13
- import { logger } from './logger.ts'
14
+ import { resolveAppConfig } from './config-compat.ts'
15
+ import { logger, setLogLevel } from './logger.ts'
14
16
  import type { MachineStore } from './machine-store.ts'
15
17
  import type { DiscoveredRoute } from './route-discovery.ts'
16
18
  import { isStatorQueryRoute } from './routing.ts'
@@ -39,11 +41,49 @@ export interface DevAppConfig {
39
41
  machinesDir: string
40
42
  routesDir: string
41
43
  staticDir?: string
44
+ /** Swappable persistence adapters, grouped by concern. Both optional —
45
+ * default to in-memory. Mirrors `StatorConfig.persistence`. */
46
+ persistence?: {
47
+ /** Session-lifecycle machine state. Defaults to InMemoryStore. */
48
+ session?: Store
49
+ /** App-lifecycle machine state for `persist: true` machines. Defaults to
50
+ * in-memory. */
51
+ app?: AppStore
52
+ }
53
+ /** Session policy. */
54
+ sessions?: {
55
+ /** Per-session TTL in seconds. Defaults to 24h (86400). */
56
+ ttlSeconds?: number
57
+ /** Session cookie policy. `cookie.sameSite: 'Strict'` opts into the
58
+ * controlled CSRF posture. */
59
+ cookie?: { sameSite?: 'Lax' | 'Strict' }
60
+ }
61
+ /** Dev-only tooling. */
62
+ dev?: {
63
+ /** Auto-inject the dev inspector toolbar. On by default; set false to disable. */
64
+ inspector?: boolean
65
+ }
66
+ /** Logging policy. */
67
+ logging?: {
68
+ /** Minimum level to emit. Default: `info` in dev. `LOG_LEVEL` env wins. */
69
+ level?: LogLevel
70
+ }
71
+ /** Origins allowed to make cross-site writes despite the CSRF guard (exact or
72
+ * wildcard-subdomain). Mirrors `StatorConfig.trustedOrigins`. */
73
+ trustedOrigins?: readonly string[]
74
+ /** Canonical app URL, exposed via `stator(c).origin`. */
75
+ origin?: string
76
+ /** Cross-origin READ policy (CORS); `origins` defaults to `trustedOrigins`. */
77
+ cors?: { origins?: string[]; credentials?: boolean }
78
+ // Deprecated flat keys — accepted (typed) so 2.1.0 callers don't break; nested
79
+ // wins. `createDevApp` never shipped `ssePingMs`, so it's not accepted here.
80
+ /** @deprecated use `persistence.session` */
42
81
  store?: Store
43
- /** Persistence for `persist: true` app machines. Defaults to in-memory. */
82
+ /** @deprecated use `persistence.app` */
44
83
  appStore?: AppStore
84
+ /** @deprecated use `sessions.ttlSeconds` */
45
85
  sessionTtlSeconds?: number
46
- /** Auto-inject the dev inspector toolbar. On by default; set false to disable. */
86
+ /** @deprecated use `dev.inspector` */
47
87
  inspector?: boolean
48
88
  }
49
89
 
@@ -60,6 +100,9 @@ export interface DevApp {
60
100
  machine: D,
61
101
  event: EventOf<D>,
62
102
  ): Promise<{ committed: boolean }>
103
+ /** Break-glass: the current raw Hono app (a getter — the dev server rebuilds
104
+ * it on edits, so mount inside the pipeline, not post-hoc). */
105
+ readonly hono: Hono
63
106
  listen: (port: number) => Promise<void>
64
107
  close: () => Promise<void>
65
108
  }
@@ -92,7 +135,17 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
92
135
  const root = resolve(config.root)
93
136
  const machinesDir = resolve(config.machinesDir)
94
137
  const routesDir = resolve(config.routesDir)
95
- const inspectorOn = config.inspector ?? true
138
+ const resolved = resolveAppConfig(config)
139
+ // Level precedence: LOG_LEVEL env > config > dev default (info). Apply to this
140
+ // module's logger AND the Vite-loaded runtime instance — SSE/http/effects log
141
+ // through the latter — via setLogLevel, which also covers the scoped children.
142
+ const logLevel = process.env.LOG_LEVEL ?? resolved.logLevel ?? 'info'
143
+ setLogLevel(logLevel)
144
+ runtime.setLogLevel(logLevel)
145
+ // The session cookie is written by the Vite-loaded runtime, so configure that
146
+ // instance's SameSite (the native one would set a different module's global).
147
+ runtime.setSessionSameSite(resolved.sameSite ?? 'Lax')
148
+ const inspectorOn = resolved.inspector ?? true
96
149
 
97
150
  const resultCache = new Map<string, ReturnType<typeof compile>>()
98
151
  const compiledFor = async (file: string) => {
@@ -144,9 +197,9 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
144
197
  const rebuildStore = async (): Promise<void> => {
145
198
  const { defs } = await runtime.discoverMachines(machinesDir, loader)
146
199
  machineCount = defs.length
147
- store = new runtime.MachineStore(defs, config.store ?? new runtime.InMemoryStore(), {
148
- sessionTtlSeconds: config.sessionTtlSeconds,
149
- appStore: config.appStore,
200
+ store = new runtime.MachineStore(defs, resolved.session ?? new runtime.InMemoryStore(), {
201
+ sessionTtlSeconds: resolved.sessionTtlSeconds,
202
+ appStore: resolved.app,
150
203
  })
151
204
  // Wire the effect scheduler before booting — a fresh app machine's
152
205
  // initial-state entry effect fires during boot and needs a live scheduler.
@@ -181,12 +234,20 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
181
234
  }
182
235
  }
183
236
  const rebuildServer = async (): Promise<void> => {
237
+ // Re-discover `middleware.ts` each rebuild so edits to it take effect. Loaded
238
+ // through the Vite runtime so its handlers share the runtime's instance.
239
+ const middleware = await runtime.discoverMiddleware(resolve(root, 'middleware.ts'), loader)
184
240
  app = await runtime.buildHonoApp({
185
241
  routes,
186
242
  store,
187
243
  staticDir: config.staticDir,
188
244
  headExtras,
189
245
  inspector: inspectorOn,
246
+ trustedOrigins: resolved.trustedOrigins,
247
+ sameSite: resolved.sameSite,
248
+ origin: resolved.origin,
249
+ cors: resolved.cors,
250
+ middleware,
190
251
  })
191
252
  }
192
253
 
@@ -229,6 +290,9 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
229
290
 
230
291
  return {
231
292
  fetch: (request) => app.fetch(request),
293
+ get hono() {
294
+ return app
295
+ },
232
296
  vite,
233
297
  // `store` is read at call time, not captured — a machine-edit rebuild
234
298
  // swaps it and the method follows.
@@ -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,19 @@ 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
+ /** Resolved CORS read policy, exposed via `stator(c).cors`. */
50
+ cors?: { origins: readonly string[]; credentials: boolean }
51
+ /** The app's discovered `middleware.ts` definition (if any). `withDefaults`
52
+ * controls whether the framework security stack is prepended. */
53
+ middleware?: MiddlewareDefinition
40
54
  }
41
55
 
42
56
  const eventSchema = z.object({
@@ -137,7 +151,9 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
137
151
  const ms = Math.round(performance.now() - start)
138
152
  const status = c.res.status
139
153
  const isLive = c.req.path === '/__sse'
140
- httpLog[status >= 500 ? 'error' : status >= 400 ? 'warn' : 'info'](
154
+ // Successful requests log at debug (one line per asset is prod noise); 4xx
155
+ // warns and 5xx errors stay visible at the default level.
156
+ httpLog[status >= 500 ? 'error' : status >= 400 ? 'warn' : 'debug'](
141
157
  {
142
158
  method: c.req.method,
143
159
  path: c.req.path,
@@ -149,6 +165,53 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
149
165
  )
150
166
  })
151
167
 
168
+ // Context bridge — expose resolved config to every middleware (cors, app
169
+ // middleware) that follows, off `stator(c)`. Runs first. Also establishes the
170
+ // session once and eager-loads its claims (for a returning session), so the
171
+ // whole request shares one sid + claims snapshot; dirty claims persist at the
172
+ // end, to the current (possibly rotated) sid.
173
+ app.use('*', async (c, next) => {
174
+ c.set('stator', {
175
+ origin: config.origin,
176
+ trustedOrigins: config.trustedOrigins ?? [],
177
+ sameSite: config.sameSite ?? 'Lax',
178
+ cors: config.cors,
179
+ })
180
+ // Stash the store so middleware session ops (rotate/clear) can act now.
181
+ c.set('statorStore', config.store)
182
+ const { sessionId, isNew } = getOrCreateSessionId(c)
183
+ const session = getSessionState(c)
184
+ if (!isNew && session) {
185
+ session.claims = (await config.store.persistence.get(sessionId, CLAIMS_KEY)) ?? undefined
186
+ }
187
+ await next()
188
+ // Same session object (rotation mutates `.sid` in place), so persist to the
189
+ // current — possibly rotated — id.
190
+ if (session?.claimsDirty) {
191
+ await config.store.persistence.set(session.sid, CLAIMS_KEY, session.claims, {
192
+ ttlSeconds: config.store.sessionTtlSeconds,
193
+ })
194
+ }
195
+ })
196
+
197
+ // Security defaults (unless the app opted out via dangerouslyDefineMiddleware),
198
+ // then the app's own middleware — all ahead of route matching, so a guard here
199
+ // can't be missed by a route added later. crossSiteGuard runs before matching,
200
+ // so a cross-site write to an unknown path 403s (not a route-revealing 404).
201
+ const mw = config.middleware
202
+ if (mw?.withDefaults ?? true) {
203
+ app.use(
204
+ '*',
205
+ crossSiteGuard({
206
+ trustedOrigins: config.trustedOrigins,
207
+ strict: config.sameSite === 'Strict',
208
+ }),
209
+ )
210
+ }
211
+ for (const handler of mw?.handlers ?? []) {
212
+ app.use('*', handler)
213
+ }
214
+
152
215
  // Compile matchers for every route, in discovery's specificity order. Our own
153
216
  // matcher (not Hono's router) is the routing authority: GET/API dispatch and
154
217
  // SSE/POST resolution all go through `matchPath`, so rest params (`*name`) and
@@ -289,9 +352,6 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
289
352
  })
290
353
 
291
354
  app.post('/__events', async (c) => {
292
- if (isBlockedCrossSite(c)) {
293
- return c.json({ error: 'cross-site request blocked' }, 403)
294
- }
295
355
  const { sessionId } = getOrCreateSessionId(c)
296
356
  const routeKey = c.req.header('X-Stator-Route')
297
357
  if (!routeKey) {
@@ -339,6 +399,19 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
339
399
  return c.json({ error: `unknown machine "${body.machine}"` }, 404)
340
400
  }
341
401
 
402
+ // `serverOnly` events are server-generated — effect completions
403
+ // (`CHARGE_APPROVED`), `after:` timers, cross-machine internals — that no
404
+ // client legitimately sends. A client POST of one is forgery (e.g. faking a
405
+ // settled charge), so reject at the wire boundary with 403. The chart still
406
+ // handles the event when the engine/effects raise it internally: that path
407
+ // is `runtime.processEvent`, never `/__events`, so it's untouched. Enforced
408
+ // in dev and prod alike — the declaration is explicit, so there's no
409
+ // false-positive risk and no dev/prod divergence. Origin legitimacy only,
410
+ // not per-user authorization — guards still own that.
411
+ if (originDef.serverOnly.includes(body.event.type)) {
412
+ return c.json({ error: `event type "${body.event.type}" is server-only` }, 403)
413
+ }
414
+
342
415
  return withSessionLock(sessionId, async () => {
343
416
  // Idempotent replay: a duplicate POST (client retry after a lost
344
417
  // response) returns the original response body verbatim instead of
@@ -406,7 +479,6 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
406
479
  const matched = matchPath(matchers, c.req.path)
407
480
  const apiRoute = matched?.route[method]
408
481
  if (!matched || !apiRoute) return next()
409
- if (isBlockedCrossSite(c)) return c.text('cross-site request blocked', 403)
410
482
  return runApiRoute(c, matched.route, apiRoute, config.store, matched.params)
411
483
  })
412
484
  }
@@ -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,
@@ -47,8 +52,15 @@ export type { HttpConfig } from './http.ts'
47
52
  export { buildHonoApp } from './http.ts'
48
53
  export type { InstanceHandle } from './instance-proxy.ts'
49
54
  export { createInstanceProxy, defForProxy } from './instance-proxy.ts'
50
- export { logger, scopedLogger } from './logger.ts'
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 {
@@ -3,8 +3,10 @@ import pino, { type Logger } from 'pino'
3
3
 
4
4
  /**
5
5
  * Framework logger. Pretty colored output in dev (auto-detected via
6
- * NODE_ENV), JSON in production for log aggregators. Level controlled by
7
- * LOG_LEVEL env (default 'info').
6
+ * NODE_ENV), JSON in production for log aggregators. Level defaults to `warn`
7
+ * in production (quiet — errors and warnings only) and `info` in dev; the
8
+ * `LOG_LEVEL` env overrides both, and `stator.config.ts`'s `logging.level`
9
+ * overrides the default when `LOG_LEVEL` is unset (applied at app construction).
8
10
  *
9
11
  * Application code can use this module-level logger or call `child()` for
10
12
  * scoped context. The framework uses scoped children for SSE events,
@@ -23,8 +25,8 @@ function hasPinoPretty(): boolean {
23
25
  }
24
26
 
25
27
  function buildLogger(): Logger {
26
- const level = process.env.LOG_LEVEL ?? 'info'
27
28
  const isProd = process.env.NODE_ENV === 'production'
29
+ const level = process.env.LOG_LEVEL ?? (isProd ? 'warn' : 'info')
28
30
 
29
31
  if (isProd || !hasPinoPretty()) {
30
32
  return pino({ level })
@@ -46,7 +48,25 @@ function buildLogger(): Logger {
46
48
 
47
49
  export const logger: Logger = buildLogger()
48
50
 
51
+ // Pino children capture the parent level at CREATION and do not follow later
52
+ // changes to it. The framework's scoped loggers (http, sse, effect, …) are all
53
+ // created at module load, so `logger.level = …` alone would never quiet them.
54
+ // Track them and set the level on each explicitly in `setLogLevel`.
55
+ const scopedChildren: Logger[] = []
56
+
49
57
  /** Child logger with a `scope` tag for filtering. */
50
58
  export function scopedLogger(scope: string): Logger {
51
- return logger.child({ scope })
59
+ const child = logger.child({ scope })
60
+ scopedChildren.push(child)
61
+ return child
62
+ }
63
+
64
+ /**
65
+ * Set the level on the root logger AND every scoped child. Children created
66
+ * after this call inherit the new root level, so this is complete regardless of
67
+ * import order. Called once at app construction from the resolved config.
68
+ */
69
+ export function setLogLevel(level: string): void {
70
+ logger.level = level
71
+ for (const child of scopedChildren) child.level = level
52
72
  }
@@ -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
+ }