@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 +2 -2
- package/src/build/build.ts +5 -0
- package/src/cli/commands/dev.ts +3 -0
- package/src/cli/commands/start.ts +5 -0
- package/src/cli/run.ts +6 -0
- package/src/config.ts +30 -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/config-compat.ts +25 -1
- package/src/server/context.ts +103 -0
- package/src/server/cookies.ts +69 -0
- package/src/server/cors.ts +48 -0
- package/src/server/create-app.ts +41 -1
- package/src/server/csrf.ts +53 -9
- package/src/server/dev.ts +35 -0
- package/src/server/env.ts +27 -0
- package/src/server/http.ts +81 -6
- package/src/server/index.ts +15 -1
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statorjs/stator",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
98
|
+
"@statorjs/stator": "2.4.0"
|
|
99
99
|
},
|
|
100
100
|
"scripts": {
|
|
101
101
|
"typecheck": "tsc --noEmit",
|
package/src/build/build.ts
CHANGED
|
@@ -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 = ''
|
package/src/cli/commands/dev.ts
CHANGED
|
@@ -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),
|
package/src/engine/types.ts
CHANGED
|
@@ -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
|
package/src/server/api-route.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/server/create-app.ts
CHANGED
|
@@ -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
|
}
|