@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/package.json +9 -2
- package/src/build/build.ts +5 -0
- package/src/cli/commands/build.ts +15 -0
- package/src/cli/commands/check.ts +72 -0
- package/src/cli/commands/dev.ts +24 -0
- package/src/cli/commands/start.ts +35 -0
- package/src/cli/commands/test.ts +31 -0
- package/src/cli/config.ts +40 -0
- package/src/cli/loader.js +40 -0
- package/src/cli/run.ts +72 -0
- package/src/cli/stator.js +27 -0
- package/src/config.ts +92 -0
- package/src/engine/define-machine.ts +8 -0
- package/src/engine/types.ts +4 -0
- package/src/server/api-route.ts +30 -18
- package/src/server/banner.ts +18 -0
- package/src/server/config-compat.ts +92 -0
- package/src/server/context.ts +101 -0
- package/src/server/cookies.ts +37 -0
- package/src/server/cors.ts +48 -0
- package/src/server/create-app.ts +87 -27
- package/src/server/csrf.ts +53 -9
- package/src/server/dev.ts +71 -7
- package/src/server/http.ts +79 -7
- package/src/server/index.ts +16 -2
- package/src/server/logger.ts +24 -4
- package/src/server/machine-store.ts +7 -0
- package/src/server/middleware.ts +78 -0
- package/src/server/origin-match.ts +63 -0
- package/src/server/routing.ts +14 -0
- package/src/server/security-headers.ts +32 -0
- package/src/server/session.ts +97 -6
- package/src/server/sse.ts +5 -5
|
@@ -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
|
+
}
|
package/src/server/create-app.ts
CHANGED
|
@@ -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
|
-
/**
|
|
20
|
-
*
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
/**
|
|
35
|
-
* default
|
|
36
|
-
|
|
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
|
|
62
|
-
const store = new MachineStore(defs,
|
|
63
|
-
sessionTtlSeconds:
|
|
64
|
-
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:
|
|
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
|
|
83
|
-
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
|
-
|
|
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
|
}
|
package/src/server/csrf.ts
CHANGED
|
@@ -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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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(
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
+
}
|