@statorjs/stator 2.2.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 +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/config.ts +24 -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 +20 -1
- 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 +33 -1
- package/src/server/csrf.ts +53 -9
- package/src/server/dev.ts +27 -0
- package/src/server/http.ts +76 -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.3.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.3.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/config.ts
CHANGED
|
@@ -23,6 +23,13 @@ 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
|
|
26
33
|
/** The swappable persistence adapters, grouped by concern. Both optional —
|
|
27
34
|
* default to in-memory (restart-wipe). Neither is a machine-graph entry
|
|
28
35
|
* point (machines are file-discovered). */
|
|
@@ -39,6 +46,13 @@ export interface StatorConfig {
|
|
|
39
46
|
sessions?: {
|
|
40
47
|
/** Per-session idle TTL in seconds. Default: 86400 (24h). */
|
|
41
48
|
ttlSeconds?: number
|
|
49
|
+
/** Session cookie policy. */
|
|
50
|
+
cookie?: {
|
|
51
|
+
/** `SameSite` attribute. Default `Lax` (allows same-site subdomains).
|
|
52
|
+
* `Strict` withholds the cookie from every cross-site request and flips
|
|
53
|
+
* the CSRF guard to allowlist-only — the controlled posture. */
|
|
54
|
+
sameSite?: 'Lax' | 'Strict'
|
|
55
|
+
}
|
|
42
56
|
// cookieName?, rotation? — reserved policy siblings (not wired yet).
|
|
43
57
|
}
|
|
44
58
|
/** Realtime / push policy. Protocol-neutral so a future WS transport doesn't
|
|
@@ -53,6 +67,16 @@ export interface StatorConfig {
|
|
|
53
67
|
/** Dev inspector toolbar (`dev` only). Default: on. */
|
|
54
68
|
inspector?: boolean
|
|
55
69
|
}
|
|
70
|
+
/** Origins allowed to make cross-site writes despite the CSRF guard — exact
|
|
71
|
+
* (`https://app.example.com`) or wildcard-subdomain (`https://*.example.com`).
|
|
72
|
+
* Same-origin and same-site writes are already allowed; this is for decoupled
|
|
73
|
+
* frontends or partner domains. */
|
|
74
|
+
trustedOrigins?: string[]
|
|
75
|
+
/** Cross-origin READ policy (CORS) — governs which cross-origin sites may read
|
|
76
|
+
* responses (distinct from `trustedOrigins`, which governs cross-site writes).
|
|
77
|
+
* Opt in per route/app with the `cors()` middleware; `origins` defaults to
|
|
78
|
+
* `trustedOrigins`. */
|
|
79
|
+
cors?: { origins?: string[]; credentials?: boolean }
|
|
56
80
|
/** Logging policy. */
|
|
57
81
|
logging?: {
|
|
58
82
|
/** 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,14 @@ 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
|
+
cors?: { origins?: readonly string[]; credentials?: boolean }
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
/** The flat internal values `createApp`/`createDevApp` actually consume. */
|
|
@@ -39,6 +43,11 @@ export interface ResolvedAppConfig {
|
|
|
39
43
|
ssePingMs?: number
|
|
40
44
|
inspector?: boolean
|
|
41
45
|
logLevel?: LogLevel
|
|
46
|
+
trustedOrigins?: readonly string[]
|
|
47
|
+
sameSite?: 'Lax' | 'Strict'
|
|
48
|
+
origin?: string
|
|
49
|
+
host?: string
|
|
50
|
+
cors?: { origins: readonly string[]; credentials: boolean }
|
|
42
51
|
}
|
|
43
52
|
|
|
44
53
|
/**
|
|
@@ -69,5 +78,15 @@ export function resolveAppConfig(config: NestedReads & DeprecatedFlatConfig): Re
|
|
|
69
78
|
ssePingMs: config.realtime?.pingMs ?? config.ssePingMs,
|
|
70
79
|
inspector: config.dev?.inspector ?? config.inspector,
|
|
71
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,
|
|
72
91
|
}
|
|
73
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,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'
|
|
@@ -11,7 +12,9 @@ import { wireAppEffects } from './effects.ts'
|
|
|
11
12
|
import { buildHonoApp } from './http.ts'
|
|
12
13
|
import { logger, setLogLevel } from './logger.ts'
|
|
13
14
|
import { MachineStore } from './machine-store.ts'
|
|
15
|
+
import { discoverMiddleware } from './middleware.ts'
|
|
14
16
|
import { discoverRoutes } from './route-discovery.ts'
|
|
17
|
+
import { setSessionSameSite } from './session.ts'
|
|
15
18
|
import { InMemoryStore, type Store } from './store.ts'
|
|
16
19
|
|
|
17
20
|
export interface CreateAppConfig extends DeprecatedFlatConfig {
|
|
@@ -34,6 +37,9 @@ export interface CreateAppConfig extends DeprecatedFlatConfig {
|
|
|
34
37
|
/** Per-session TTL in seconds. Every set to any of the session's machines
|
|
35
38
|
* refreshes this expiry. Defaults to 24h (86400). */
|
|
36
39
|
ttlSeconds?: number
|
|
40
|
+
/** Session cookie policy. `cookie.sameSite: 'Strict'` opts into the
|
|
41
|
+
* controlled CSRF posture. */
|
|
42
|
+
cookie?: { sameSite?: 'Lax' | 'Strict' }
|
|
37
43
|
}
|
|
38
44
|
/** Realtime / push policy. */
|
|
39
45
|
realtime?: {
|
|
@@ -52,9 +58,21 @@ export interface CreateAppConfig extends DeprecatedFlatConfig {
|
|
|
52
58
|
* `LOG_LEVEL` env takes precedence over this. */
|
|
53
59
|
level?: LogLevel
|
|
54
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 }
|
|
55
70
|
/** Extra `<head>` HTML per GET route. A production build uses this to link the
|
|
56
71
|
* prebuilt `components.css`; ignored if omitted. */
|
|
57
72
|
headExtras?: (filePath: string) => string | Promise<string>
|
|
73
|
+
/** Path to the app's `middleware.ts` (if any). Loaded and validated; its
|
|
74
|
+
* default export must be `defineMiddleware`/`dangerouslyDefineMiddleware`. */
|
|
75
|
+
middlewareFile?: string
|
|
58
76
|
}
|
|
59
77
|
|
|
60
78
|
export interface StatorApp {
|
|
@@ -71,6 +89,10 @@ export interface StatorApp {
|
|
|
71
89
|
machine: D,
|
|
72
90
|
event: EventOf<D>,
|
|
73
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
|
|
74
96
|
}
|
|
75
97
|
|
|
76
98
|
export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
|
|
@@ -83,6 +105,7 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
|
|
|
83
105
|
// is the production entry point, so it defaults quiet). setLogLevel also covers
|
|
84
106
|
// the scoped children (http/sse/…), which a bare `logger.level =` would miss.
|
|
85
107
|
setLogLevel(process.env.LOG_LEVEL ?? resolved.logLevel ?? 'warn')
|
|
108
|
+
setSessionSameSite(resolved.sameSite ?? 'Lax')
|
|
86
109
|
const { defs } = await discoverMachines(machinesDir)
|
|
87
110
|
const sessionStore = resolved.session ?? new InMemoryStore()
|
|
88
111
|
const store = new MachineStore(defs, sessionStore, {
|
|
@@ -95,6 +118,9 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
|
|
|
95
118
|
await store.bootAppMachines()
|
|
96
119
|
|
|
97
120
|
const routes = await discoverRoutes(routesDir)
|
|
121
|
+
const middleware = config.middlewareFile
|
|
122
|
+
? await discoverMiddleware(config.middlewareFile)
|
|
123
|
+
: undefined
|
|
98
124
|
const inspector = resolved.inspector
|
|
99
125
|
const app = await buildHonoApp({
|
|
100
126
|
routes,
|
|
@@ -108,12 +134,17 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
|
|
|
108
134
|
: config.headExtras,
|
|
109
135
|
inspector,
|
|
110
136
|
ssePingMs: resolved.ssePingMs,
|
|
137
|
+
trustedOrigins: resolved.trustedOrigins,
|
|
138
|
+
sameSite: resolved.sameSite,
|
|
139
|
+
origin: resolved.origin,
|
|
140
|
+
cors: resolved.cors,
|
|
141
|
+
middleware,
|
|
111
142
|
})
|
|
112
143
|
|
|
113
144
|
return {
|
|
114
145
|
listen(port: number): Promise<void> {
|
|
115
146
|
return new Promise((resolveFn) => {
|
|
116
|
-
const server = serve({ fetch: app.fetch, port }, () => {
|
|
147
|
+
const server = serve({ fetch: app.fetch, port, hostname: resolved.host }, () => {
|
|
117
148
|
// Always-on (level-independent) so `warn` prod still confirms boot.
|
|
118
149
|
printStartupNotice({ port, machines: defs.length, routes: routes.length })
|
|
119
150
|
resolveFn()
|
|
@@ -138,5 +169,6 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
|
|
|
138
169
|
fetch: (request: Request) => app.fetch(request),
|
|
139
170
|
store,
|
|
140
171
|
dispatchToApp: (machine, event) => dispatchToApp(store, machine, event),
|
|
172
|
+
hono: app,
|
|
141
173
|
}
|
|
142
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
|
+
}
|
package/src/server/dev.ts
CHANGED
|
@@ -54,6 +54,9 @@ export interface DevAppConfig {
|
|
|
54
54
|
sessions?: {
|
|
55
55
|
/** Per-session TTL in seconds. Defaults to 24h (86400). */
|
|
56
56
|
ttlSeconds?: number
|
|
57
|
+
/** Session cookie policy. `cookie.sameSite: 'Strict'` opts into the
|
|
58
|
+
* controlled CSRF posture. */
|
|
59
|
+
cookie?: { sameSite?: 'Lax' | 'Strict' }
|
|
57
60
|
}
|
|
58
61
|
/** Dev-only tooling. */
|
|
59
62
|
dev?: {
|
|
@@ -65,6 +68,13 @@ export interface DevAppConfig {
|
|
|
65
68
|
/** Minimum level to emit. Default: `info` in dev. `LOG_LEVEL` env wins. */
|
|
66
69
|
level?: LogLevel
|
|
67
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 }
|
|
68
78
|
// Deprecated flat keys — accepted (typed) so 2.1.0 callers don't break; nested
|
|
69
79
|
// wins. `createDevApp` never shipped `ssePingMs`, so it's not accepted here.
|
|
70
80
|
/** @deprecated use `persistence.session` */
|
|
@@ -90,6 +100,9 @@ export interface DevApp {
|
|
|
90
100
|
machine: D,
|
|
91
101
|
event: EventOf<D>,
|
|
92
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
|
|
93
106
|
listen: (port: number) => Promise<void>
|
|
94
107
|
close: () => Promise<void>
|
|
95
108
|
}
|
|
@@ -129,6 +142,9 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
|
|
|
129
142
|
const logLevel = process.env.LOG_LEVEL ?? resolved.logLevel ?? 'info'
|
|
130
143
|
setLogLevel(logLevel)
|
|
131
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')
|
|
132
148
|
const inspectorOn = resolved.inspector ?? true
|
|
133
149
|
|
|
134
150
|
const resultCache = new Map<string, ReturnType<typeof compile>>()
|
|
@@ -218,12 +234,20 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
|
|
|
218
234
|
}
|
|
219
235
|
}
|
|
220
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)
|
|
221
240
|
app = await runtime.buildHonoApp({
|
|
222
241
|
routes,
|
|
223
242
|
store,
|
|
224
243
|
staticDir: config.staticDir,
|
|
225
244
|
headExtras,
|
|
226
245
|
inspector: inspectorOn,
|
|
246
|
+
trustedOrigins: resolved.trustedOrigins,
|
|
247
|
+
sameSite: resolved.sameSite,
|
|
248
|
+
origin: resolved.origin,
|
|
249
|
+
cors: resolved.cors,
|
|
250
|
+
middleware,
|
|
227
251
|
})
|
|
228
252
|
}
|
|
229
253
|
|
|
@@ -266,6 +290,9 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
|
|
|
266
290
|
|
|
267
291
|
return {
|
|
268
292
|
fetch: (request) => app.fetch(request),
|
|
293
|
+
get hono() {
|
|
294
|
+
return app
|
|
295
|
+
},
|
|
269
296
|
vite,
|
|
270
297
|
// `store` is read at call time, not captured — a machine-edit rebuild
|
|
271
298
|
// swaps it and the method follows.
|
package/src/server/http.ts
CHANGED
|
@@ -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 {
|
|
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({
|
|
@@ -151,6 +165,53 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
|
|
|
151
165
|
)
|
|
152
166
|
})
|
|
153
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
|
+
|
|
154
215
|
// Compile matchers for every route, in discovery's specificity order. Our own
|
|
155
216
|
// matcher (not Hono's router) is the routing authority: GET/API dispatch and
|
|
156
217
|
// SSE/POST resolution all go through `matchPath`, so rest params (`*name`) and
|
|
@@ -291,9 +352,6 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
|
|
|
291
352
|
})
|
|
292
353
|
|
|
293
354
|
app.post('/__events', async (c) => {
|
|
294
|
-
if (isBlockedCrossSite(c)) {
|
|
295
|
-
return c.json({ error: 'cross-site request blocked' }, 403)
|
|
296
|
-
}
|
|
297
355
|
const { sessionId } = getOrCreateSessionId(c)
|
|
298
356
|
const routeKey = c.req.header('X-Stator-Route')
|
|
299
357
|
if (!routeKey) {
|
|
@@ -341,6 +399,19 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
|
|
|
341
399
|
return c.json({ error: `unknown machine "${body.machine}"` }, 404)
|
|
342
400
|
}
|
|
343
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
|
+
|
|
344
415
|
return withSessionLock(sessionId, async () => {
|
|
345
416
|
// Idempotent replay: a duplicate POST (client retry after a lost
|
|
346
417
|
// response) returns the original response body verbatim instead of
|
|
@@ -408,7 +479,6 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
|
|
|
408
479
|
const matched = matchPath(matchers, c.req.path)
|
|
409
480
|
const apiRoute = matched?.route[method]
|
|
410
481
|
if (!matched || !apiRoute) return next()
|
|
411
|
-
if (isBlockedCrossSite(c)) return c.text('cross-site request blocked', 403)
|
|
412
482
|
return runApiRoute(c, matched.route, apiRoute, config.store, matched.params)
|
|
413
483
|
})
|
|
414
484
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -20,8 +20,13 @@ export type { AppStore } from './app-store.ts'
|
|
|
20
20
|
export { InMemoryAppStore } from './app-store.ts'
|
|
21
21
|
export type { CachedStoreOptions } from './cached-store.ts'
|
|
22
22
|
export { CachedStore } from './cached-store.ts'
|
|
23
|
+
export type { StatorContext } from './context.ts'
|
|
24
|
+
export { stator } from './context.ts'
|
|
25
|
+
export type { CorsOptions } from './cors.ts'
|
|
26
|
+
export { cors } from './cors.ts'
|
|
23
27
|
export type { CreateAppConfig, StatorApp } from './create-app.ts'
|
|
24
28
|
export { createApp } from './create-app.ts'
|
|
29
|
+
export { crossSiteGuard } from './csrf.ts'
|
|
25
30
|
export type {
|
|
26
31
|
ActionHelpers,
|
|
27
32
|
DefineMachineConfig,
|
|
@@ -49,6 +54,13 @@ export type { InstanceHandle } from './instance-proxy.ts'
|
|
|
49
54
|
export { createInstanceProxy, defForProxy } from './instance-proxy.ts'
|
|
50
55
|
export { logger, scopedLogger, setLogLevel } from './logger.ts'
|
|
51
56
|
export { MachineStore } from './machine-store.ts'
|
|
57
|
+
export type { MiddlewareDefinition } from './middleware.ts'
|
|
58
|
+
export {
|
|
59
|
+
dangerouslyDefineMiddleware,
|
|
60
|
+
defineMiddleware,
|
|
61
|
+
discoverMiddleware,
|
|
62
|
+
isMiddlewareDefinition,
|
|
63
|
+
} from './middleware.ts'
|
|
52
64
|
export { runQueryRoute } from './query-route.ts'
|
|
53
65
|
export { recompute } from './recompute.ts'
|
|
54
66
|
export { RedisAppStore, RedisStore } from './redis-store.ts'
|
|
@@ -106,7 +118,9 @@ export {
|
|
|
106
118
|
isStatorQueryRoute,
|
|
107
119
|
isStatorRoute,
|
|
108
120
|
} from './routing.ts'
|
|
109
|
-
export {
|
|
121
|
+
export type { SecurityHeadersOptions } from './security-headers.ts'
|
|
122
|
+
export { securityHeaders } from './security-headers.ts'
|
|
123
|
+
export { getOrCreateSessionId, SESSION_COOKIE, setSessionSameSite } from './session.ts'
|
|
110
124
|
export { SessionRuntime } from './session-runtime.ts'
|
|
111
125
|
export type { Connection } from './sse.ts'
|
|
112
126
|
export {
|
|
@@ -6,6 +6,7 @@ import { abortEntryEffects, isEffectInFlight } from './effects.ts'
|
|
|
6
6
|
import { createInstanceProxy, type InstanceHandle } from './instance-proxy.ts'
|
|
7
7
|
import { scopedLogger } from './logger.ts'
|
|
8
8
|
import { serverReadsResolver } from './reads-helpers.ts'
|
|
9
|
+
import { RESERVED_KEY_PREFIX } from './session.ts'
|
|
9
10
|
import type { Store } from './store.ts'
|
|
10
11
|
import { APP_SCOPE, armAfterTimers, cancelAfterTimers } from './timers.ts'
|
|
11
12
|
|
|
@@ -87,6 +88,12 @@ export class MachineStore {
|
|
|
87
88
|
this.sessionTtlSeconds = opts?.sessionTtlSeconds ?? 86400
|
|
88
89
|
this.appStore = opts?.appStore ?? new InMemoryAppStore()
|
|
89
90
|
for (const def of defs) {
|
|
91
|
+
if (def.name.startsWith(RESERVED_KEY_PREFIX)) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`stator: machine name "${def.name}" is reserved — the "${RESERVED_KEY_PREFIX}" ` +
|
|
94
|
+
`prefix is framework-only (it namespaces per-session storage keys like claims)`,
|
|
95
|
+
)
|
|
96
|
+
}
|
|
90
97
|
if (this.defs.has(def.name)) {
|
|
91
98
|
throw new Error(`stator: duplicate machine name "${def.name}"`)
|
|
92
99
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { pathToFileURL } from 'node:url'
|
|
3
|
+
import type { MiddlewareHandler } from 'hono'
|
|
4
|
+
import type { ModuleLoader } from './discovery.ts'
|
|
5
|
+
|
|
6
|
+
// A global-registry symbol so the brand survives the dev dual-instance: the app's
|
|
7
|
+
// `middleware.ts` is loaded through Vite while the framework may check it from the
|
|
8
|
+
// native module instance — `Symbol.for` resolves to the same symbol in both.
|
|
9
|
+
const MIDDLEWARE_BRAND = Symbol.for('stator.middleware.definition')
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The value an app's `middleware.ts` exports (default) — an ordered list of
|
|
13
|
+
* Hono middleware plus whether the framework's security defaults are prepended.
|
|
14
|
+
* Opaque: build it with `defineMiddleware` / `dangerouslyDefineMiddleware`.
|
|
15
|
+
*/
|
|
16
|
+
export interface MiddlewareDefinition {
|
|
17
|
+
readonly [MIDDLEWARE_BRAND]: true
|
|
18
|
+
readonly handlers: readonly MiddlewareHandler[]
|
|
19
|
+
/** Prepend the framework's default security stack (`crossSiteGuard`, …)? */
|
|
20
|
+
readonly withDefaults: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Type guard for a discovered `middleware.ts` default export. */
|
|
24
|
+
export function isMiddlewareDefinition(value: unknown): value is MiddlewareDefinition {
|
|
25
|
+
return typeof value === 'object' && value !== null && MIDDLEWARE_BRAND in value
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Define the app's HTTP middleware. The framework's security defaults
|
|
30
|
+
* (`crossSiteGuard`, …) run first, then these handlers, then the route — so a
|
|
31
|
+
* guard added here can't be missed by a route added later. Export the result as
|
|
32
|
+
* the default from `middleware.ts` at the app root.
|
|
33
|
+
*
|
|
34
|
+
* Handlers are plain Hono middleware and run in array order.
|
|
35
|
+
*/
|
|
36
|
+
export function defineMiddleware(handlers: readonly MiddlewareHandler[]): MiddlewareDefinition {
|
|
37
|
+
return { [MIDDLEWARE_BRAND]: true, handlers, withDefaults: true }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Like `defineMiddleware`, but WITHOUT the framework security defaults — these
|
|
42
|
+
* handlers are the entire stack. The `dangerously` name is the point: opting out
|
|
43
|
+
* of the defaults is a visible, greppable decision that says "this app owns its
|
|
44
|
+
* security." Re-add the exported primitives (`crossSiteGuard()`, …) as needed.
|
|
45
|
+
*
|
|
46
|
+
* This only skips the *security* defaults; framework request plumbing (sessions,
|
|
47
|
+
* dispatch routing, logging, static) is not middleware and is never bypassed.
|
|
48
|
+
*/
|
|
49
|
+
export function dangerouslyDefineMiddleware(
|
|
50
|
+
handlers: readonly MiddlewareHandler[],
|
|
51
|
+
): MiddlewareDefinition {
|
|
52
|
+
return { [MIDDLEWARE_BRAND]: true, handlers, withDefaults: false }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const nativeLoader: ModuleLoader = (file) => import(/* @vite-ignore */ pathToFileURL(file).href)
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Load an app's `middleware.ts` (if present) and validate its default export.
|
|
59
|
+
* Returns `undefined` when there is no file — the framework then applies its
|
|
60
|
+
* security defaults with no user handlers (safe-by-default). `loader` is injected
|
|
61
|
+
* so dev can go through Vite; prod/native uses `import`. The single toolchain
|
|
62
|
+
* touch-point, kept swappable for the post-Vite pipeline.
|
|
63
|
+
*/
|
|
64
|
+
export async function discoverMiddleware(
|
|
65
|
+
file: string,
|
|
66
|
+
loader: ModuleLoader = nativeLoader,
|
|
67
|
+
): Promise<MiddlewareDefinition | undefined> {
|
|
68
|
+
if (!existsSync(file)) return undefined
|
|
69
|
+
const mod = await loader(file)
|
|
70
|
+
const def = (mod as { default?: unknown }).default
|
|
71
|
+
if (!isMiddlewareDefinition(def)) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`${file} must \`export default defineMiddleware([...])\` ` +
|
|
74
|
+
`(or dangerouslyDefineMiddleware([...]))`,
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
return def
|
|
78
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Match an incoming `Origin` against an allowlist of patterns — shared by the
|
|
3
|
+
* cross-site write guard (`trustedOrigins`) and `cors()`. Patterns are origins
|
|
4
|
+
* with a scheme, either exact (`https://app.example.com`) or wildcard-subdomain
|
|
5
|
+
* (`https://*.example.com`).
|
|
6
|
+
*
|
|
7
|
+
* Boundary-safe by construction: `https://*.example.com` matches `app.example.com`
|
|
8
|
+
* and `a.b.example.com` (any depth), and NEVER `example.com.evil.com` (suffix
|
|
9
|
+
* attack), `evilexample.com` (no label boundary), or the apex `example.com` (list
|
|
10
|
+
* it explicitly if wanted). Scheme must match; port matches when the pattern omits
|
|
11
|
+
* one, else must be equal. Malformed origins and patterns fail closed (no match).
|
|
12
|
+
*/
|
|
13
|
+
export function matchOrigin(
|
|
14
|
+
origin: string | undefined | null,
|
|
15
|
+
patterns: readonly string[],
|
|
16
|
+
): boolean {
|
|
17
|
+
if (!origin) return false
|
|
18
|
+
let incoming: URL
|
|
19
|
+
try {
|
|
20
|
+
incoming = new URL(origin)
|
|
21
|
+
} catch {
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
24
|
+
return patterns.some((pattern) => matchOne(incoming, pattern))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** `scheme://*.rest` — `rest` may carry a `:port`, split off below. */
|
|
28
|
+
const WILDCARD = /^([a-z][a-z0-9+.-]*):\/\/\*\.([^/]+)$/i
|
|
29
|
+
|
|
30
|
+
function matchOne(incoming: URL, pattern: string): boolean {
|
|
31
|
+
const wild = WILDCARD.exec(pattern)
|
|
32
|
+
if (wild) {
|
|
33
|
+
const rawScheme = wild[1]
|
|
34
|
+
const rawRest = wild[2]
|
|
35
|
+
if (rawScheme === undefined || rawRest === undefined) return false
|
|
36
|
+
const scheme = rawScheme.toLowerCase()
|
|
37
|
+
let domain = rawRest.toLowerCase()
|
|
38
|
+
let port = ''
|
|
39
|
+
const colon = domain.lastIndexOf(':')
|
|
40
|
+
if (colon !== -1) {
|
|
41
|
+
port = domain.slice(colon + 1)
|
|
42
|
+
domain = domain.slice(0, colon)
|
|
43
|
+
}
|
|
44
|
+
if (incoming.protocol !== `${scheme}:`) return false
|
|
45
|
+
if (port && incoming.port !== port) return false
|
|
46
|
+
const host = incoming.hostname.toLowerCase()
|
|
47
|
+
// A non-empty subdomain label before `.domain` (any depth), on a label
|
|
48
|
+
// boundary — the leading dot is what defeats the suffix attack.
|
|
49
|
+
return host.length > domain.length + 1 && host.endsWith(`.${domain}`)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let pat: URL
|
|
53
|
+
try {
|
|
54
|
+
pat = new URL(pattern)
|
|
55
|
+
} catch {
|
|
56
|
+
return false
|
|
57
|
+
}
|
|
58
|
+
if (incoming.protocol !== pat.protocol) return false
|
|
59
|
+
if (incoming.hostname.toLowerCase() !== pat.hostname.toLowerCase()) return false
|
|
60
|
+
// A pattern with no explicit port matches any port; with one, it must be equal.
|
|
61
|
+
if (pat.port && incoming.port !== pat.port) return false
|
|
62
|
+
return true
|
|
63
|
+
}
|
package/src/server/routing.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/server/session.ts
CHANGED
|
@@ -1,9 +1,39 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
2
|
import type { Context } from 'hono'
|
|
3
3
|
import { getCookie, setCookie } from 'hono/cookie'
|
|
4
|
+
import type { Store } from './store.ts'
|
|
4
5
|
|
|
5
6
|
export const SESSION_COOKIE = 'stator_sid'
|
|
6
7
|
|
|
8
|
+
/** Reserved storage-key prefix — framework-only. Machine names may not use it,
|
|
9
|
+
* so per-session framework keys (like claims) can't collide with a machine. */
|
|
10
|
+
export const RESERVED_KEY_PREFIX = '__'
|
|
11
|
+
/** Session-claims storage key (a reserved pseudo-machine name in the Store). */
|
|
12
|
+
export const CLAIMS_KEY = '__claims'
|
|
13
|
+
|
|
14
|
+
/** Per-request session state — established once and shared across middleware and
|
|
15
|
+
* handlers so they see one `sid` (and one claims snapshot), instead of each
|
|
16
|
+
* calling `getOrCreateSessionId` and risking two ids on a first request. */
|
|
17
|
+
export interface SessionState {
|
|
18
|
+
sid: string
|
|
19
|
+
isNew: boolean
|
|
20
|
+
/** App-defined claims; `undefined` until loaded (returning sessions) or set. */
|
|
21
|
+
claims: unknown
|
|
22
|
+
/** Set when claims changed this request and must persist at request end. */
|
|
23
|
+
claimsDirty: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
declare module 'hono' {
|
|
27
|
+
interface ContextVariableMap {
|
|
28
|
+
statorSession: SessionState
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The established session state for this request, if any. */
|
|
33
|
+
export function getSessionState(c: Context): SessionState | undefined {
|
|
34
|
+
return c.get('statorSession')
|
|
35
|
+
}
|
|
36
|
+
|
|
7
37
|
/** Secure cookie flag — set when running behind HTTPS. Enabled by
|
|
8
38
|
* NODE_ENV=production; can be overridden via STATOR_SECURE_COOKIE. */
|
|
9
39
|
function shouldUseSecureCookie(): boolean {
|
|
@@ -12,12 +42,24 @@ function shouldUseSecureCookie(): boolean {
|
|
|
12
42
|
return process.env.NODE_ENV === 'production'
|
|
13
43
|
}
|
|
14
44
|
|
|
45
|
+
/** The session cookie's `SameSite`. `Lax` (default) allows same-site sibling
|
|
46
|
+
* subdomains; `Strict` withholds the cookie from every cross-site request (the
|
|
47
|
+
* controlled posture — paired with the guard's allowlist-only same-site branch).
|
|
48
|
+
* Set once at app construction; every `createApp` re-sets it, so it can't leak
|
|
49
|
+
* across apps in one process. */
|
|
50
|
+
let cookieSameSite: 'Lax' | 'Strict' = 'Lax'
|
|
51
|
+
|
|
52
|
+
/** Configure the session cookie's `SameSite`. Called at app construction. */
|
|
53
|
+
export function setSessionSameSite(value: 'Lax' | 'Strict'): void {
|
|
54
|
+
cookieSameSite = value
|
|
55
|
+
}
|
|
56
|
+
|
|
15
57
|
/** Write the session cookie — shared by session creation and rotation so
|
|
16
58
|
* the flags can never drift apart. */
|
|
17
59
|
export function setSessionCookie(c: Context, sessionId: string): void {
|
|
18
60
|
setCookie(c, SESSION_COOKIE, sessionId, {
|
|
19
61
|
httpOnly: true,
|
|
20
|
-
sameSite:
|
|
62
|
+
sameSite: cookieSameSite,
|
|
21
63
|
path: '/',
|
|
22
64
|
secure: shouldUseSecureCookie(),
|
|
23
65
|
})
|
|
@@ -27,9 +69,58 @@ export function getOrCreateSessionId(c: Context): {
|
|
|
27
69
|
sessionId: string
|
|
28
70
|
isNew: boolean
|
|
29
71
|
} {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
72
|
+
// Idempotent per request: the first caller (middleware or handler) establishes
|
|
73
|
+
// the session; everyone after reads the same one. Closes the double-create bug.
|
|
74
|
+
const established = c.get('statorSession')
|
|
75
|
+
if (established) return { sessionId: established.sid, isNew: established.isNew }
|
|
76
|
+
|
|
77
|
+
const cookieSid = getCookie(c, SESSION_COOKIE)
|
|
78
|
+
const sessionId = cookieSid ?? randomUUID()
|
|
79
|
+
const isNew = cookieSid === undefined
|
|
80
|
+
if (isNew) setSessionCookie(c, sessionId)
|
|
81
|
+
|
|
82
|
+
c.set('statorSession', { sid: sessionId, isNew, claims: undefined, claimsDirty: false })
|
|
83
|
+
return { sessionId, isNew }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Rotate (or, with `clear`, destroy) the current session *right now* and update
|
|
88
|
+
* the shared session state + cookie. The immediate counterpart to a handler's
|
|
89
|
+
* deferred `rotateSession`/`clearSession` — used from middleware, which runs
|
|
90
|
+
* upstream of the machine pipeline and has no "after the handler" seam.
|
|
91
|
+
*
|
|
92
|
+
* Rotate: state moves to a fresh id (fixation defense on privilege change);
|
|
93
|
+
* any claims set this request follow to the new id at persist time. Clear: the
|
|
94
|
+
* old session's state is DELETED and the browser starts anonymous. Returns the
|
|
95
|
+
* new session id.
|
|
96
|
+
*/
|
|
97
|
+
export async function rotateSessionNow(
|
|
98
|
+
c: Context,
|
|
99
|
+
store: { persistence: Pick<Store, 'deleteSession' | 'renameSession'> },
|
|
100
|
+
opts?: { clear?: boolean },
|
|
101
|
+
): Promise<string> {
|
|
102
|
+
const { sessionId } = getOrCreateSessionId(c)
|
|
103
|
+
const clear = opts?.clear === true
|
|
104
|
+
const newSessionId = randomUUID()
|
|
105
|
+
if (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
|
+
const session = getSessionState(c)
|
|
118
|
+
if (session) {
|
|
119
|
+
session.sid = newSessionId
|
|
120
|
+
if (clear) {
|
|
121
|
+
session.claims = undefined
|
|
122
|
+
session.claimsDirty = false
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return newSessionId
|
|
35
126
|
}
|