@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statorjs/stator",
3
- "version": "2.2.0",
3
+ "version": "2.4.0",
4
4
  "description": "Server-canonical web framework: business logic in composable state machines, UI as a thin renderer binding machine outputs to DOM positions. Ships TypeScript source (Vite/tsx-native by design).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -95,7 +95,7 @@
95
95
  "pino-pretty": "^13.1.3",
96
96
  "vite": "^6.0.0",
97
97
  "vitest": "^2.1.0",
98
- "@statorjs/stator": "2.2.0"
98
+ "@statorjs/stator": "2.4.0"
99
99
  },
100
100
  "scripts": {
101
101
  "typecheck": "tsc --noEmit",
@@ -82,6 +82,11 @@ export async function buildApp(config: BuildConfig): Promise<BuildResult> {
82
82
  if (await exists(src)) await cp(src, join(outDir, d), { recursive: true })
83
83
  }
84
84
 
85
+ // A root-level middleware.ts is a single file, not a source dir — copy it too
86
+ // (raw TS, run under tsx/native in prod like the rest of the server).
87
+ const middlewareSrc = join(root, 'middleware.ts')
88
+ if (await exists(middlewareSrc)) await cp(middlewareSrc, join(outDir, 'middleware.ts'))
89
+
85
90
  // Compile every .stator into a sibling .stator.ts; collect CSS and islands.
86
91
  const statorFiles = await walk(outDir, (f) => f.endsWith('.stator'))
87
92
  let css = ''
@@ -16,6 +16,9 @@ export async function run(ctx: CliContext): Promise<void> {
16
16
  persistence: config.persistence,
17
17
  sessions: config.sessions,
18
18
  dev: config.dev,
19
+ trustedOrigins: config.trustedOrigins,
20
+ origin: config.origin,
21
+ cors: config.cors,
19
22
  })
20
23
  await app.listen(resolvePort(ctx.portFlag, config.port))
21
24
  }
@@ -23,7 +23,12 @@ export async function run(ctx: CliContext): Promise<void> {
23
23
  persistence: config.persistence,
24
24
  sessions: config.sessions,
25
25
  realtime: config.realtime,
26
+ trustedOrigins: config.trustedOrigins,
27
+ origin: config.origin,
28
+ host: config.host,
29
+ cors: config.cors,
26
30
  headExtras: await loadProductionHead(dist),
31
+ middlewareFile: resolve(dist, 'middleware.ts'),
27
32
  })
28
33
 
29
34
  await app.listen(resolvePort(ctx.portFlag, config.port))
package/src/cli/run.ts CHANGED
@@ -59,6 +59,12 @@ const ctx: CliContext = {
59
59
  rest: positionals.slice(1),
60
60
  }
61
61
 
62
+ // Load .env before importing the command — so `stator.config.ts` (imported by
63
+ // loadConfig inside the command) can read `process.env.*`. createApp/createDevApp
64
+ // load it again for the direct-`server.ts` path; idempotent.
65
+ const { loadDotenv } = await import('../server/env.ts')
66
+ loadDotenv(ctx.root)
67
+
62
68
  // Dynamic import per command so `check` never loads the dev server's Vite, etc.
63
69
  const { run } = (await import(`./commands/${command}.ts`)) as {
64
70
  run: (ctx: CliContext) => Promise<void>
package/src/config.ts CHANGED
@@ -23,6 +23,19 @@ export type LogLevel = 'silent' | 'fatal' | 'error' | 'warn' | 'info' | 'debug'
23
23
  export interface StatorConfig {
24
24
  /** Listen port for `dev`/`start`. Precedence: `--port` flag > `$PORT` > this > 3000. */
25
25
  port?: number
26
+ /** Listen host / bind address. Default: all interfaces. Containers behind a
27
+ * proxy typically need `0.0.0.0`. */
28
+ host?: string
29
+ /** Canonical app URL (`https://example.com`) — for absolute-URL generation
30
+ * (redirects, SSE reconnect, OG tags) and as a spoof-proof same-origin
31
+ * anchor. Exposed to middleware via `stator(c).origin`. */
32
+ origin?: string
33
+ /** Signing secret for signed cookies (`cookies.setSigned`/`getSigned`) — the
34
+ * sealed short-lived-state primitive (OAuth `state`/PKCE, a magic-link token,
35
+ * a WebAuthn challenge). Falls back to `process.env.STATOR_SECRET`. Use a long
36
+ * random string (≥32 chars); keep it out of source — set it in `.env.local` or
37
+ * a platform secret. Absent → the signed-cookie methods throw. */
38
+ secret?: string
26
39
  /** The swappable persistence adapters, grouped by concern. Both optional —
27
40
  * default to in-memory (restart-wipe). Neither is a machine-graph entry
28
41
  * point (machines are file-discovered). */
@@ -39,6 +52,13 @@ export interface StatorConfig {
39
52
  sessions?: {
40
53
  /** Per-session idle TTL in seconds. Default: 86400 (24h). */
41
54
  ttlSeconds?: number
55
+ /** Session cookie policy. */
56
+ cookie?: {
57
+ /** `SameSite` attribute. Default `Lax` (allows same-site subdomains).
58
+ * `Strict` withholds the cookie from every cross-site request and flips
59
+ * the CSRF guard to allowlist-only — the controlled posture. */
60
+ sameSite?: 'Lax' | 'Strict'
61
+ }
42
62
  // cookieName?, rotation? — reserved policy siblings (not wired yet).
43
63
  }
44
64
  /** Realtime / push policy. Protocol-neutral so a future WS transport doesn't
@@ -53,6 +73,16 @@ export interface StatorConfig {
53
73
  /** Dev inspector toolbar (`dev` only). Default: on. */
54
74
  inspector?: boolean
55
75
  }
76
+ /** Origins allowed to make cross-site writes despite the CSRF guard — exact
77
+ * (`https://app.example.com`) or wildcard-subdomain (`https://*.example.com`).
78
+ * Same-origin and same-site writes are already allowed; this is for decoupled
79
+ * frontends or partner domains. */
80
+ trustedOrigins?: string[]
81
+ /** Cross-origin READ policy (CORS) — governs which cross-origin sites may read
82
+ * responses (distinct from `trustedOrigins`, which governs cross-site writes).
83
+ * Opt in per route/app with the `cors()` middleware; `origins` defaults to
84
+ * `trustedOrigins`. */
85
+ cors?: { origins?: string[]; credentials?: boolean }
56
86
  /** Logging policy. */
57
87
  logging?: {
58
88
  /** Minimum level to emit. Default: `warn` in production, `info` in dev.
@@ -30,6 +30,13 @@ export interface DefineMachineConfig<
30
30
  /** Typed event surface. Pass `{} as MyEvents` — a phantom carrier the engine
31
31
  * reads only for its type. Actions/guards then narrow per transition. */
32
32
  events?: E
33
+ /** Event types NO client may dispatch to `/__events` — effect completions
34
+ * (`CHARGE_APPROVED`), timer/`after:` events, cross-machine internals: events
35
+ * only the server/engine generates. A production `stator start` rejects a
36
+ * client POST of one with 403 (dev does not enforce). Typed against the event
37
+ * union, so a name that isn't a real event is a compile error. The chart still
38
+ * handles them normally when the engine/effects raise them. */
39
+ serverOnly?: readonly E['type'][]
33
40
  emits?: EmitsConfig<C, E>
34
41
  context: C
35
42
  initial: NoInfer<S>
@@ -116,6 +123,7 @@ export function defineMachine<
116
123
  persist: config.persist ?? false,
117
124
  reads,
118
125
  subscribes: config.subscribes ?? [],
126
+ serverOnly: config.serverOnly ?? [],
119
127
  emits: normalizeEmits<C, E>(config.emits),
120
128
  selectors: (config.selectors ?? {}) as Sel,
121
129
  capabilities: computeCapabilities(reads),
@@ -243,6 +243,10 @@ export interface MachineDef<
243
243
  persist: boolean
244
244
  reads: AnyMachineDef[]
245
245
  subscribes: SubscribeEntry[]
246
+ /** Event types no client may dispatch to `/__events` (server-generated only:
247
+ * effect completions, timers, cross-machine internals). Normalized to `[]`.
248
+ * Enforced with a 403 in production; the chart handles them normally. */
249
+ serverOnly: readonly string[]
246
250
  /** Normalized: every declared emit, possibly with a payload selector. */
247
251
  emits: Record<string, EmitDeclaration<C, E>>
248
252
  selectors: Sel
@@ -1,7 +1,7 @@
1
- import { randomUUID } from 'node:crypto'
2
1
  import type { Context } from 'hono'
3
2
  import { setCookie } from 'hono/cookie'
4
3
  import { safeNavigationUrl } from '../wire/safe-url.ts'
4
+ import { cookieJar } from './cookies.ts'
5
5
  import { scheduleSessionEffects } from './effects.ts'
6
6
  import { scopedLogger } from './logger.ts'
7
7
  import type { MachineStore } from './machine-store.ts'
@@ -15,7 +15,7 @@ import type {
15
15
  Directive,
16
16
  RouteRequest,
17
17
  } from './routing.ts'
18
- import { getOrCreateSessionId, setSessionCookie } from './session.ts'
18
+ import { getOrCreateSessionId, getSessionState, rotateSessionNow } from './session.ts'
19
19
  import { withSessionLock } from './session-lock.ts'
20
20
  import { SessionRuntime } from './session-runtime.ts'
21
21
  import { fanOut } from './sse.ts'
@@ -73,6 +73,25 @@ export async function runApiRoute(
73
73
  rotateSession: (opts) => {
74
74
  rotation = { clear: opts?.clear === true }
75
75
  },
76
+ clearSession: () => {
77
+ rotation = { clear: true }
78
+ },
79
+ claims: <T = unknown>() => getSessionState(c)?.claims as T | undefined,
80
+ setClaims: (claims) => {
81
+ const s = getSessionState(c)
82
+ if (s) {
83
+ s.claims = claims
84
+ s.claimsDirty = true
85
+ }
86
+ },
87
+ clearClaims: () => {
88
+ const s = getSessionState(c)
89
+ if (s) {
90
+ s.claims = undefined
91
+ s.claimsDirty = true
92
+ }
93
+ },
94
+ cookies: cookieJar(c),
76
95
  }
77
96
 
78
97
  let result: Response | ApiRouteEnvelope
@@ -101,20 +120,11 @@ export async function runApiRoute(
101
120
  // new cookie. Effect completions must chase the NEW id.
102
121
  let effectsSessionId = sessionId
103
122
  if (rotation !== null) {
104
- const newSessionId = randomUUID()
105
- if ((rotation as { clear: boolean }).clear) {
106
- await store.persistence.deleteSession(sessionId)
107
- } else {
108
- if (!store.persistence.renameSession) {
109
- throw new Error(
110
- 'stator: rotateSession requires a store with renameSession — ' +
111
- 'the configured custom store does not implement it.',
112
- )
113
- }
114
- await store.persistence.renameSession(sessionId, newSessionId)
115
- }
116
- setSessionCookie(c, newSessionId)
117
- effectsSessionId = newSessionId
123
+ // Apply the deferred rotation now the handler has returned. The helper
124
+ // moves (or, on clear, deletes) the session, sets the new cookie, and
125
+ // updates the shared session state so the bridge persists claims to the
126
+ // new id. Effect completions must chase the NEW id.
127
+ effectsSessionId = await rotateSessionNow(c, store, rotation)
118
128
  }
119
129
  // Effects queued by dispatched events run after this callback returns
120
130
  // (never under the session lock); see server/effects.ts.
@@ -201,8 +211,10 @@ function synthesizeResponse(
201
211
  // same-origin, else fall back to '/' (no open redirect).
202
212
  return c.redirect(sameOriginReferer(request), 303)
203
213
  }
204
- // No actionable directive for a no-JS client. Send a minimal 204.
205
- return new Response(null, { status: 204 })
214
+ // No actionable directive for a no-JS client. Send a minimal 204 — via
215
+ // c.body so any cookies set this request (session rotation, the cookie jar)
216
+ // survive; a bare `new Response` would drop c's accumulated Set-Cookie.
217
+ return c.body(null, 204)
206
218
  }
207
219
 
208
220
  // JSON / client-runtime path.
@@ -25,10 +25,15 @@ export interface DeprecatedFlatConfig {
25
25
 
26
26
  interface NestedReads {
27
27
  persistence?: { session?: Store; app?: AppStore }
28
- sessions?: { ttlSeconds?: number }
28
+ sessions?: { ttlSeconds?: number; cookie?: { sameSite?: 'Lax' | 'Strict' } }
29
29
  realtime?: { pingMs?: number }
30
30
  dev?: { inspector?: boolean }
31
31
  logging?: { level?: LogLevel }
32
+ trustedOrigins?: readonly string[]
33
+ origin?: string
34
+ host?: string
35
+ secret?: string
36
+ cors?: { origins?: readonly string[]; credentials?: boolean }
32
37
  }
33
38
 
34
39
  /** The flat internal values `createApp`/`createDevApp` actually consume. */
@@ -39,6 +44,13 @@ export interface ResolvedAppConfig {
39
44
  ssePingMs?: number
40
45
  inspector?: boolean
41
46
  logLevel?: LogLevel
47
+ trustedOrigins?: readonly string[]
48
+ sameSite?: 'Lax' | 'Strict'
49
+ origin?: string
50
+ host?: string
51
+ /** Signed-cookie signing key. `config.secret` ?? `STATOR_SECRET`. */
52
+ secret?: string
53
+ cors?: { origins: readonly string[]; credentials: boolean }
42
54
  }
43
55
 
44
56
  /**
@@ -69,5 +81,17 @@ export function resolveAppConfig(config: NestedReads & DeprecatedFlatConfig): Re
69
81
  ssePingMs: config.realtime?.pingMs ?? config.ssePingMs,
70
82
  inspector: config.dev?.inspector ?? config.inspector,
71
83
  logLevel: config.logging?.level,
84
+ trustedOrigins: config.trustedOrigins,
85
+ sameSite: config.sessions?.cookie?.sameSite,
86
+ origin: config.origin,
87
+ host: config.host,
88
+ // The signing key: explicit config wins, else the env (loadable via .env).
89
+ secret: config.secret ?? process.env.STATOR_SECRET,
90
+ cors: config.cors
91
+ ? {
92
+ origins: config.cors.origins ?? config.trustedOrigins ?? [],
93
+ credentials: config.cors.credentials ?? false,
94
+ }
95
+ : undefined,
72
96
  }
73
97
  }
@@ -0,0 +1,103 @@
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
+ /** Signed-cookie signing key, stashed by the bridge when configured. */
24
+ statorSecret: string
25
+ }
26
+ }
27
+
28
+ /**
29
+ * What `stator(c)` returns — resolved config *plus* per-request session ops.
30
+ * Read it in middleware; handlers get the same ops on their `ctx`.
31
+ */
32
+ export interface StatorContext {
33
+ readonly origin?: string
34
+ readonly trustedOrigins: readonly string[]
35
+ readonly sameSite: 'Lax' | 'Strict'
36
+ readonly cors?: { readonly origins: readonly string[]; readonly credentials: boolean }
37
+
38
+ /** The established session id for this request. */
39
+ readonly sid: string
40
+ /** Read this session's app-defined claims (identity/data). `undefined` if none. */
41
+ claims<T = unknown>(): T | undefined
42
+ /** Replace this session's claims — persisted at request end. */
43
+ setClaims(claims: unknown): void
44
+ /** Drop this session's claims (keeps the session) — persisted at request end. */
45
+ clearClaims(): void
46
+ /** Rotate the session id *now* (fixation defense on privilege change): state
47
+ * moves to a fresh id, the response carries the new cookie. Immediate because
48
+ * middleware runs upstream of the handler pipeline. */
49
+ rotateSession(opts?: { clear?: boolean }): Promise<void>
50
+ /** Destroy this session *now* — its state is deleted and the browser starts
51
+ * anonymous. Sugar for `rotateSession({ clear: true })`. */
52
+ clearSession(): Promise<void>
53
+ /** Read/write app-owned cookies (e.g. a login `returnTo`). Distinct from the
54
+ * framework-managed session cookie. */
55
+ readonly cookies: CookieJar
56
+ }
57
+
58
+ const DEFAULT: StatorConfigData = { trustedOrigins: [], sameSite: 'Lax' }
59
+
60
+ /**
61
+ * Read Stator's request context — resolved config + session ops. Safe outside the
62
+ * framework pipeline (inert config defaults, empty session) if the bridge/session
63
+ * setup never ran.
64
+ */
65
+ export function stator(c: Context): StatorContext {
66
+ const cfg = c.get('stator') ?? DEFAULT
67
+ const session = getSessionState(c)
68
+ return {
69
+ origin: cfg.origin,
70
+ trustedOrigins: cfg.trustedOrigins,
71
+ sameSite: cfg.sameSite,
72
+ cors: cfg.cors,
73
+ get sid() {
74
+ return session?.sid ?? ''
75
+ },
76
+ claims<T = unknown>(): T | undefined {
77
+ return session?.claims as T | undefined
78
+ },
79
+ setClaims(claims: unknown): void {
80
+ if (session) {
81
+ session.claims = claims
82
+ session.claimsDirty = true
83
+ }
84
+ },
85
+ clearClaims(): void {
86
+ if (session) {
87
+ session.claims = undefined
88
+ session.claimsDirty = true
89
+ }
90
+ },
91
+ async rotateSession(opts?: { clear?: boolean }): Promise<void> {
92
+ const store = c.get('statorStore')
93
+ if (!store) return // inert outside the framework pipeline
94
+ await rotateSessionNow(c, store, opts)
95
+ },
96
+ async clearSession(): Promise<void> {
97
+ const store = c.get('statorStore')
98
+ if (!store) return
99
+ await rotateSessionNow(c, store, { clear: true })
100
+ },
101
+ cookies: cookieJar(c),
102
+ }
103
+ }
@@ -0,0 +1,69 @@
1
+ import type { Context } from 'hono'
2
+ import { deleteCookie, getCookie, getSignedCookie, setCookie, setSignedCookie } 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. Works for
21
+ * signed cookies too — deletion needs no signature. */
22
+ delete(name: string, options?: Pick<RouteCookieOptions, 'path' | 'domain'>): void
23
+ /** Read a signed cookie, verifying its signature against the app secret.
24
+ * `undefined` if absent OR the signature is invalid (tampered, or signed
25
+ * with a since-rotated secret) — an untrustworthy value is treated as absent.
26
+ * Throws if no secret is configured. The sealed short-lived-state read
27
+ * (OAuth `state`/PKCE, a magic-link token, a WebAuthn challenge). */
28
+ getSigned(name: string): Promise<string | undefined>
29
+ /** Write a signed cookie — the value is tamper-evident, not encrypted (still
30
+ * readable by the client, so don't put secrets *in* it; put a nonce it can't
31
+ * forge). Throws if no secret is configured. */
32
+ setSigned(name: string, value: string, options?: RouteCookieOptions): Promise<void>
33
+ }
34
+
35
+ /** Read the configured signing secret, or throw a fix-it error. */
36
+ function requireSecret(c: Context): string {
37
+ const secret = c.get('statorSecret')
38
+ if (!secret) {
39
+ throw new Error(
40
+ 'stator: signed cookies require a secret — set `secret` in stator.config.ts ' +
41
+ 'or `STATOR_SECRET` in the environment (e.g. .env.local).',
42
+ )
43
+ }
44
+ return secret
45
+ }
46
+
47
+ /** Build the cookie jar bound to a request context. Shared by `stator(c)`
48
+ * (middleware) and the API-route handler `ctx`, so both read/write cookies
49
+ * the same way. */
50
+ export function cookieJar(c: Context): CookieJar {
51
+ return {
52
+ get: (name) => getCookie(c, name),
53
+ set: (name, value, options) => {
54
+ setCookie(c, name, value, options as never)
55
+ },
56
+ delete: (name, options) => {
57
+ deleteCookie(c, name, options as never)
58
+ },
59
+ getSigned: async (name) => {
60
+ // hono returns `false` for a present-but-invalid signature; collapse it to
61
+ // undefined so callers never trust (or even see) a tampered value.
62
+ const value = await getSignedCookie(c, requireSecret(c), name)
63
+ return value === false ? undefined : value
64
+ },
65
+ setSigned: async (name, value, options) => {
66
+ await setSignedCookie(c, name, value, requireSecret(c), options as never)
67
+ },
68
+ }
69
+ }
@@ -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,5 +1,6 @@
1
1
  import { resolve } from 'node:path'
2
2
  import { serve } from '@hono/node-server'
3
+ import type { Hono } from 'hono'
3
4
  import type { LogLevel } from '../config.ts'
4
5
  import type { AnyMachineDef, EventOf } from '../engine/index.ts'
5
6
  import { dispatchToApp } from './app-dispatch.ts'
@@ -8,10 +9,13 @@ import { installGracefulShutdown, printStartupNotice } from './banner.ts'
8
9
  import { type DeprecatedFlatConfig, resolveAppConfig } from './config-compat.ts'
9
10
  import { discoverMachines } from './discovery.ts'
10
11
  import { wireAppEffects } from './effects.ts'
12
+ import { loadDotenv } from './env.ts'
11
13
  import { buildHonoApp } from './http.ts'
12
14
  import { logger, setLogLevel } from './logger.ts'
13
15
  import { MachineStore } from './machine-store.ts'
16
+ import { discoverMiddleware } from './middleware.ts'
14
17
  import { discoverRoutes } from './route-discovery.ts'
18
+ import { setSessionSameSite } from './session.ts'
15
19
  import { InMemoryStore, type Store } from './store.ts'
16
20
 
17
21
  export interface CreateAppConfig extends DeprecatedFlatConfig {
@@ -34,6 +38,9 @@ export interface CreateAppConfig extends DeprecatedFlatConfig {
34
38
  /** Per-session TTL in seconds. Every set to any of the session's machines
35
39
  * refreshes this expiry. Defaults to 24h (86400). */
36
40
  ttlSeconds?: number
41
+ /** Session cookie policy. `cookie.sameSite: 'Strict'` opts into the
42
+ * controlled CSRF posture. */
43
+ cookie?: { sameSite?: 'Lax' | 'Strict' }
37
44
  }
38
45
  /** Realtime / push policy. */
39
46
  realtime?: {
@@ -52,9 +59,24 @@ export interface CreateAppConfig extends DeprecatedFlatConfig {
52
59
  * `LOG_LEVEL` env takes precedence over this. */
53
60
  level?: LogLevel
54
61
  }
62
+ /** Origins allowed to make cross-site writes despite the CSRF guard (exact or
63
+ * wildcard-subdomain). Mirrors `StatorConfig.trustedOrigins`. */
64
+ trustedOrigins?: readonly string[]
65
+ /** Canonical app URL, exposed via `stator(c).origin`. */
66
+ origin?: string
67
+ /** Signed-cookie signing key. Falls back to `STATOR_SECRET`. Mirrors
68
+ * `StatorConfig.secret`. */
69
+ secret?: string
70
+ /** Listen host / bind address (used by `listen`). Default: all interfaces. */
71
+ host?: string
72
+ /** Cross-origin READ policy (CORS); `origins` defaults to `trustedOrigins`. */
73
+ cors?: { origins?: string[]; credentials?: boolean }
55
74
  /** Extra `<head>` HTML per GET route. A production build uses this to link the
56
75
  * prebuilt `components.css`; ignored if omitted. */
57
76
  headExtras?: (filePath: string) => string | Promise<string>
77
+ /** Path to the app's `middleware.ts` (if any). Loaded and validated; its
78
+ * default export must be `defineMiddleware`/`dangerouslyDefineMiddleware`. */
79
+ middlewareFile?: string
58
80
  }
59
81
 
60
82
  export interface StatorApp {
@@ -71,9 +93,16 @@ export interface StatorApp {
71
93
  machine: D,
72
94
  event: EventOf<D>,
73
95
  ): Promise<{ committed: boolean }>
96
+ /** Break-glass: the raw Hono app. The unsupported paved-road exit — for
97
+ * sub-app mounts / protocol upgrades the framework surface doesn't cover.
98
+ * Mutate before `listen`. */
99
+ hono: Hono
74
100
  }
75
101
 
76
102
  export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
103
+ // Load .env before anything reads process.env (LOG_LEVEL below, app secrets).
104
+ // No-op if the CLI already loaded it; covers the direct `server.ts` entry.
105
+ loadDotenv()
77
106
  const machinesDir = resolve(config.machinesDir)
78
107
  const routesDir = resolve(config.routesDir)
79
108
  const staticDir = config.staticDir ? resolve(config.staticDir) : undefined
@@ -83,6 +112,7 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
83
112
  // is the production entry point, so it defaults quiet). setLogLevel also covers
84
113
  // the scoped children (http/sse/…), which a bare `logger.level =` would miss.
85
114
  setLogLevel(process.env.LOG_LEVEL ?? resolved.logLevel ?? 'warn')
115
+ setSessionSameSite(resolved.sameSite ?? 'Lax')
86
116
  const { defs } = await discoverMachines(machinesDir)
87
117
  const sessionStore = resolved.session ?? new InMemoryStore()
88
118
  const store = new MachineStore(defs, sessionStore, {
@@ -95,6 +125,9 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
95
125
  await store.bootAppMachines()
96
126
 
97
127
  const routes = await discoverRoutes(routesDir)
128
+ const middleware = config.middlewareFile
129
+ ? await discoverMiddleware(config.middlewareFile)
130
+ : undefined
98
131
  const inspector = resolved.inspector
99
132
  const app = await buildHonoApp({
100
133
  routes,
@@ -108,12 +141,18 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
108
141
  : config.headExtras,
109
142
  inspector,
110
143
  ssePingMs: resolved.ssePingMs,
144
+ trustedOrigins: resolved.trustedOrigins,
145
+ sameSite: resolved.sameSite,
146
+ origin: resolved.origin,
147
+ secret: resolved.secret,
148
+ cors: resolved.cors,
149
+ middleware,
111
150
  })
112
151
 
113
152
  return {
114
153
  listen(port: number): Promise<void> {
115
154
  return new Promise((resolveFn) => {
116
- const server = serve({ fetch: app.fetch, port }, () => {
155
+ const server = serve({ fetch: app.fetch, port, hostname: resolved.host }, () => {
117
156
  // Always-on (level-independent) so `warn` prod still confirms boot.
118
157
  printStartupNotice({ port, machines: defs.length, routes: routes.length })
119
158
  resolveFn()
@@ -138,5 +177,6 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
138
177
  fetch: (request: Request) => app.fetch(request),
139
178
  store,
140
179
  dispatchToApp: (machine, event) => dispatchToApp(store, machine, event),
180
+ hono: app,
141
181
  }
142
182
  }