@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 +2 -2
- package/src/cli/run.ts +6 -0
- package/src/config.ts +6 -0
- package/src/server/config-compat.ts +5 -0
- package/src/server/context.ts +2 -0
- package/src/server/cookies.ts +34 -2
- package/src/server/create-app.ts +8 -0
- package/src/server/dev.ts +8 -0
- package/src/server/env.ts +27 -0
- package/src/server/http.ts +5 -0
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/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 ?? [],
|
package/src/server/context.ts
CHANGED
package/src/server/cookies.ts
CHANGED
|
@@ -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
|
}
|
package/src/server/create-app.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/server/http.ts
CHANGED
|
@@ -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) {
|