@statorjs/stator 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statorjs/stator",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "Server-canonical web framework: business logic in composable state machines, UI as a thin renderer binding machine outputs to DOM positions. Ships TypeScript source (Vite/tsx-native by design).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -95,7 +95,7 @@
95
95
  "pino-pretty": "^13.1.3",
96
96
  "vite": "^6.0.0",
97
97
  "vitest": "^2.1.0",
98
- "@statorjs/stator": "2.3.0"
98
+ "@statorjs/stator": "2.4.0"
99
99
  },
100
100
  "scripts": {
101
101
  "typecheck": "tsc --noEmit",
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
@@ -30,6 +30,12 @@ export interface StatorConfig {
30
30
  * (redirects, SSE reconnect, OG tags) and as a spoof-proof same-origin
31
31
  * anchor. Exposed to middleware via `stator(c).origin`. */
32
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
33
39
  /** The swappable persistence adapters, grouped by concern. Both optional —
34
40
  * default to in-memory (restart-wipe). Neither is a machine-graph entry
35
41
  * point (machines are file-discovered). */
@@ -32,6 +32,7 @@ interface NestedReads {
32
32
  trustedOrigins?: readonly string[]
33
33
  origin?: string
34
34
  host?: string
35
+ secret?: string
35
36
  cors?: { origins?: readonly string[]; credentials?: boolean }
36
37
  }
37
38
 
@@ -47,6 +48,8 @@ export interface ResolvedAppConfig {
47
48
  sameSite?: 'Lax' | 'Strict'
48
49
  origin?: string
49
50
  host?: string
51
+ /** Signed-cookie signing key. `config.secret` ?? `STATOR_SECRET`. */
52
+ secret?: string
50
53
  cors?: { origins: readonly string[]; credentials: boolean }
51
54
  }
52
55
 
@@ -82,6 +85,8 @@ export function resolveAppConfig(config: NestedReads & DeprecatedFlatConfig): Re
82
85
  sameSite: config.sessions?.cookie?.sameSite,
83
86
  origin: config.origin,
84
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,
85
90
  cors: config.cors
86
91
  ? {
87
92
  origins: config.cors.origins ?? config.trustedOrigins ?? [],
@@ -20,6 +20,8 @@ declare module 'hono' {
20
20
  interface ContextVariableMap {
21
21
  stator: StatorConfigData
22
22
  statorStore: { persistence: Store }
23
+ /** Signed-cookie signing key, stashed by the bridge when configured. */
24
+ statorSecret: string
23
25
  }
24
26
  }
25
27
 
@@ -1,5 +1,5 @@
1
1
  import type { Context } from 'hono'
2
- import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
2
+ import { deleteCookie, getCookie, getSignedCookie, setCookie, setSignedCookie } from 'hono/cookie'
3
3
  import type { RouteCookieOptions } from './routing.ts'
4
4
 
5
5
  /**
@@ -17,8 +17,31 @@ export interface CookieJar {
17
17
  /** Write a cookie on the response. Defaults are hono's (not HttpOnly/Secure);
18
18
  * pass options for auth-shaped cookies. */
19
19
  set(name: string, value: string, options?: RouteCookieOptions): void
20
- /** Expire a cookie. `path`/`domain` must match how it was set. */
20
+ /** Expire a cookie. `path`/`domain` must match how it was set. Works for
21
+ * signed cookies too — deletion needs no signature. */
21
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
22
45
  }
23
46
 
24
47
  /** Build the cookie jar bound to a request context. Shared by `stator(c)`
@@ -33,5 +56,14 @@ export function cookieJar(c: Context): CookieJar {
33
56
  delete: (name, options) => {
34
57
  deleteCookie(c, name, options as never)
35
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
+ },
36
68
  }
37
69
  }
@@ -9,6 +9,7 @@ import { installGracefulShutdown, printStartupNotice } from './banner.ts'
9
9
  import { type DeprecatedFlatConfig, resolveAppConfig } from './config-compat.ts'
10
10
  import { discoverMachines } from './discovery.ts'
11
11
  import { wireAppEffects } from './effects.ts'
12
+ import { loadDotenv } from './env.ts'
12
13
  import { buildHonoApp } from './http.ts'
13
14
  import { logger, setLogLevel } from './logger.ts'
14
15
  import { MachineStore } from './machine-store.ts'
@@ -63,6 +64,9 @@ export interface CreateAppConfig extends DeprecatedFlatConfig {
63
64
  trustedOrigins?: readonly string[]
64
65
  /** Canonical app URL, exposed via `stator(c).origin`. */
65
66
  origin?: string
67
+ /** Signed-cookie signing key. Falls back to `STATOR_SECRET`. Mirrors
68
+ * `StatorConfig.secret`. */
69
+ secret?: string
66
70
  /** Listen host / bind address (used by `listen`). Default: all interfaces. */
67
71
  host?: string
68
72
  /** Cross-origin READ policy (CORS); `origins` defaults to `trustedOrigins`. */
@@ -96,6 +100,9 @@ export interface StatorApp {
96
100
  }
97
101
 
98
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()
99
106
  const machinesDir = resolve(config.machinesDir)
100
107
  const routesDir = resolve(config.routesDir)
101
108
  const staticDir = config.staticDir ? resolve(config.staticDir) : undefined
@@ -137,6 +144,7 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
137
144
  trustedOrigins: resolved.trustedOrigins,
138
145
  sameSite: resolved.sameSite,
139
146
  origin: resolved.origin,
147
+ secret: resolved.secret,
140
148
  cors: resolved.cors,
141
149
  middleware,
142
150
  })
package/src/server/dev.ts CHANGED
@@ -12,6 +12,7 @@ import { machineStub, stator } from '../vite/index.ts'
12
12
  import type { AppStore } from './app-store.ts'
13
13
  import { findFreePort, installGracefulShutdown, printDevBanner } from './banner.ts'
14
14
  import { resolveAppConfig } from './config-compat.ts'
15
+ import { loadDotenv } from './env.ts'
15
16
  import { logger, setLogLevel } from './logger.ts'
16
17
  import type { MachineStore } from './machine-store.ts'
17
18
  import type { DiscoveredRoute } from './route-discovery.ts'
@@ -73,6 +74,9 @@ export interface DevAppConfig {
73
74
  trustedOrigins?: readonly string[]
74
75
  /** Canonical app URL, exposed via `stator(c).origin`. */
75
76
  origin?: string
77
+ /** Signed-cookie signing key. Falls back to `STATOR_SECRET`. Mirrors
78
+ * `StatorConfig.secret`. */
79
+ secret?: string
76
80
  /** Cross-origin READ policy (CORS); `origins` defaults to `trustedOrigins`. */
77
81
  cors?: { origins?: string[]; credentials?: boolean }
78
82
  // Deprecated flat keys — accepted (typed) so 2.1.0 callers don't break; nested
@@ -108,6 +112,9 @@ export interface DevApp {
108
112
  }
109
113
 
110
114
  export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
115
+ // Load .env before anything reads process.env. No-op if the CLI already
116
+ // loaded it; covers a direct `createDevApp` call (a hand-written dev entry).
117
+ loadDotenv(resolve(config.root))
111
118
  // Vite's HMR websocket defaults to 24678 for EVERY dev server — two
112
119
  // stator apps side by side would fight over it (the loser's live reload
113
120
  // silently dies). Probe a free one instead.
@@ -246,6 +253,7 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
246
253
  trustedOrigins: resolved.trustedOrigins,
247
254
  sameSite: resolved.sameSite,
248
255
  origin: resolved.origin,
256
+ secret: resolved.secret,
249
257
  cors: resolved.cors,
250
258
  middleware,
251
259
  })
@@ -0,0 +1,27 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+
4
+ /**
5
+ * Load `.env` files into `process.env` — the home for server config/secrets
6
+ * that's uniform across dev and prod (unlike Vite's transform-time
7
+ * `import.meta.env`, absent in prod). Precedence, highest first:
8
+ *
9
+ * real shell env > .env.local > .env
10
+ *
11
+ * `process.loadEnvFile` (native, Node ≥20.12 — no dependency) never overrides
12
+ * an already-set key, so real env always wins, and `.env.local` is loaded
13
+ * *before* `.env`: the earlier load claims a key the later one can't reclaim.
14
+ * Absent files are skipped. Idempotent-safe — a second call adds nothing new,
15
+ * so the CLI (which loads before importing `stator.config.ts`) and
16
+ * `createApp`/`createDevApp` (which load for the direct `server.ts` path) can
17
+ * both call it without conflict.
18
+ *
19
+ * `.env.local` is for machine-local secrets and belongs in `.gitignore`; `.env`
20
+ * holds committed defaults.
21
+ */
22
+ export function loadDotenv(root: string = process.cwd()): void {
23
+ for (const name of ['.env.local', '.env']) {
24
+ const path = resolve(root, name)
25
+ if (existsSync(path)) process.loadEnvFile(path)
26
+ }
27
+ }
@@ -46,6 +46,9 @@ export interface HttpConfig {
46
46
  sameSite?: 'Lax' | 'Strict'
47
47
  /** Canonical app origin, exposed to middleware via `stator(c).origin`. */
48
48
  origin?: string
49
+ /** Signed-cookie signing key (`config.secret` ?? `STATOR_SECRET`). Stashed on
50
+ * the request context so `cookies.setSigned`/`getSigned` can reach it. */
51
+ secret?: string
49
52
  /** Resolved CORS read policy, exposed via `stator(c).cors`. */
50
53
  cors?: { origins: readonly string[]; credentials: boolean }
51
54
  /** The app's discovered `middleware.ts` definition (if any). `withDefaults`
@@ -179,6 +182,8 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
179
182
  })
180
183
  // Stash the store so middleware session ops (rotate/clear) can act now.
181
184
  c.set('statorStore', config.store)
185
+ // Stash the signing key so the cookie jar's signed methods can reach it.
186
+ if (config.secret !== undefined) c.set('statorSecret', config.secret)
182
187
  const { sessionId, isNew } = getOrCreateSessionId(c)
183
188
  const session = getSessionState(c)
184
189
  if (!isNew && session) {