@statorjs/stator 1.1.1 → 1.2.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/README.md CHANGED
@@ -96,7 +96,7 @@ const [counter] = Stator.reads([Counter])
96
96
  </html>
97
97
  ```
98
98
 
99
- Run with `tsx server.ts`. See the repo's `apps/example` for the full wiring
99
+ Run with `tsx server.ts`. See the repo's `examples/desksmith` for the full wiring
100
100
  (type sync, production build, deploy).
101
101
 
102
102
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statorjs/stator",
3
- "version": "1.1.1",
3
+ "version": "1.2.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",
@@ -88,7 +88,7 @@
88
88
  "pino-pretty": "^13.1.3",
89
89
  "vite": "^6.0.0",
90
90
  "vitest": "^2.1.0",
91
- "@statorjs/stator": "1.1.1"
91
+ "@statorjs/stator": "1.2.0"
92
92
  },
93
93
  "scripts": {
94
94
  "typecheck": "tsc --noEmit",
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto'
1
2
  import type { Context } from 'hono'
2
3
  import { setCookie } from 'hono/cookie'
3
4
  import { scheduleSessionEffects } from './effects.ts'
@@ -13,7 +14,7 @@ import type {
13
14
  Directive,
14
15
  RouteRequest,
15
16
  } from './routing.ts'
16
- import { getOrCreateSessionId } from './session.ts'
17
+ import { getOrCreateSessionId, setSessionCookie } from './session.ts'
17
18
  import { withSessionLock } from './session-lock.ts'
18
19
  import { SessionRuntime } from './session-runtime.ts'
19
20
  import { fanOut } from './sse.ts'
@@ -42,6 +43,7 @@ export async function runApiRoute(
42
43
  params?: Record<string, string>,
43
44
  ): Promise<Response> {
44
45
  const { sessionId } = getOrCreateSessionId(c)
46
+ let rotation: { clear: boolean } | null = null
45
47
  const request = params
46
48
  ? { ...buildRouteRequest(c, discovered.paramNames), params }
47
49
  : buildRouteRequest(c, discovered.paramNames)
@@ -63,6 +65,12 @@ export async function runApiRoute(
63
65
  // The target machine is addressed by its def; read the name off it.
64
66
  const dispatchedTouched = runtime.processEvent(machine.name, event)
65
67
  for (const name of dispatchedTouched) touched.add(name)
68
+ // Same honesty as client dispatch: a guard-dropped event commits
69
+ // nothing, and handlers (login flows especially) need to know.
70
+ return { committed: dispatchedTouched.size > 0 }
71
+ },
72
+ rotateSession: (opts) => {
73
+ rotation = { clear: opts?.clear === true }
66
74
  },
67
75
  }
68
76
 
@@ -78,9 +86,32 @@ export async function runApiRoute(
78
86
  await runtime.persistTouched(touched)
79
87
  await fanOut(touched, { sessionId })
80
88
  }
89
+
90
+ // Session rotation (fixation defense). Order matters: the runtime has
91
+ // already persisted under the OLD id and fan-out has reached the old
92
+ // id's connections (about to be navigated away) — now the whole
93
+ // session moves (or, for logout, dies) and the response carries the
94
+ // new cookie. Effect completions must chase the NEW id.
95
+ let effectsSessionId = sessionId
96
+ if (rotation !== null) {
97
+ const newSessionId = randomUUID()
98
+ if ((rotation as { clear: boolean }).clear) {
99
+ await store.persistence.deleteSession(sessionId)
100
+ } else {
101
+ if (!store.persistence.renameSession) {
102
+ throw new Error(
103
+ 'stator: rotateSession requires a store with renameSession — ' +
104
+ 'the configured custom store does not implement it.',
105
+ )
106
+ }
107
+ await store.persistence.renameSession(sessionId, newSessionId)
108
+ }
109
+ setSessionCookie(c, newSessionId)
110
+ effectsSessionId = newSessionId
111
+ }
81
112
  // Effects queued by dispatched events run after this callback returns
82
113
  // (never under the session lock); see server/effects.ts.
83
- scheduleSessionEffects(runtime, store, sessionId)
114
+ scheduleSessionEffects(runtime, store, effectsSessionId)
84
115
 
85
116
  // Escape hatch: handler returned a real Response. Pass through.
86
117
  if (result instanceof Response) return result
@@ -18,7 +18,7 @@ export async function dispatchToApp<D extends AnyMachineDef>(
18
18
  store: MachineStore,
19
19
  machine: D,
20
20
  event: EventOf<D>,
21
- ): Promise<void> {
21
+ ): Promise<{ committed: boolean }> {
22
22
  const name = machine.name
23
23
  const handle = store.appInstance(name)
24
24
  if (!handle) {
@@ -46,6 +46,10 @@ export async function dispatchToApp<D extends AnyMachineDef>(
46
46
  await store.persistAppMachine(touchedName)
47
47
  }
48
48
  await fanOut(touched)
49
+ // Same contract as client and API-route dispatch: did the TARGET machine
50
+ // actually commit a transition? Webhook receivers use this to tell a
51
+ // processed event from a guard-dropped duplicate.
52
+ return { committed: touched.has(name) }
49
53
  } finally {
50
54
  runtime.dispose()
51
55
  }
@@ -99,6 +99,20 @@ export class CachedStore implements Store {
99
99
  await this.backing.deleteSession(sid)
100
100
  }
101
101
 
102
+ async renameSession(oldSid: string, newSid: string): Promise<void> {
103
+ // Drop (don't move) cache entries — the next read repopulates from the
104
+ // backing under the new id. Simpler than key surgery, and rotation is
105
+ // rare (login/logout).
106
+ const prefix = `${oldSid}:`
107
+ for (const k of [...this.cache.keys()]) {
108
+ if (k.startsWith(prefix)) this.cache.delete(k)
109
+ }
110
+ if (!this.backing.renameSession) {
111
+ throw new Error('stator: CachedStore backing store does not support renameSession')
112
+ }
113
+ await this.backing.renameSession(oldSid, newSid)
114
+ }
115
+
102
116
  /** Insert / refresh an entry at LRU-end and evict oldest if over capacity. */
103
117
  private populate(key: string, snapshot: unknown, ttlMs: number): void {
104
118
  this.cache.delete(key)
@@ -69,6 +69,14 @@ export class RedisStore implements Store {
69
69
  await this.client.del(this.key(sid))
70
70
  }
71
71
 
72
+ async renameSession(oldSid: string, newSid: string): Promise<void> {
73
+ // RENAME is atomic and carries the hash's TTL; it throws when the source
74
+ // is missing (an empty session has nothing to move — fine).
75
+ if ((await this.client.exists(this.key(oldSid))) === 1) {
76
+ await this.client.rename(this.key(oldSid), this.key(newSid))
77
+ }
78
+ }
79
+
72
80
  /** Close the connection. Call on graceful shutdown. */
73
81
  async close(): Promise<void> {
74
82
  await this.client.quit()
@@ -136,7 +136,18 @@ export interface ApiRouteHelpers {
136
136
  * Processes the event under the dispatch context, persists touched machines,
137
137
  * fires cross-machine subscriptions. The machine must be in the route's
138
138
  * loaded graph (its `reads`, transitively). */
139
- dispatch: <D extends AnyMachineDef>(machine: D, event: EventOf<D>) => Promise<void>
139
+ dispatch: <D extends AnyMachineDef>(
140
+ machine: D,
141
+ event: EventOf<D>,
142
+ ) => Promise<{ committed: boolean }>
143
+ /** Rotate the session id — the fixation defense for privilege changes.
144
+ * Call on login (state moves to a fresh id; the old id becomes worthless
145
+ * to anyone who captured it) and on logout with `{ clear: true }` (the
146
+ * old session's state is DELETED and the browser starts anonymous).
147
+ * Applied after the handler returns: state persists under the new id and
148
+ * the response carries the new cookie. Requires a store with
149
+ * `renameSession` (all built-in stores have it). */
150
+ rotateSession: (opts?: { clear?: boolean }) => void
140
151
  }
141
152
 
142
153
  export interface ApiRouteDefinition {
@@ -12,6 +12,17 @@ function shouldUseSecureCookie(): boolean {
12
12
  return process.env.NODE_ENV === 'production'
13
13
  }
14
14
 
15
+ /** Write the session cookie — shared by session creation and rotation so
16
+ * the flags can never drift apart. */
17
+ export function setSessionCookie(c: Context, sessionId: string): void {
18
+ setCookie(c, SESSION_COOKIE, sessionId, {
19
+ httpOnly: true,
20
+ sameSite: 'Lax',
21
+ path: '/',
22
+ secure: shouldUseSecureCookie(),
23
+ })
24
+ }
25
+
15
26
  export function getOrCreateSessionId(c: Context): {
16
27
  sessionId: string
17
28
  isNew: boolean
@@ -19,11 +30,6 @@ export function getOrCreateSessionId(c: Context): {
19
30
  const existing = getCookie(c, SESSION_COOKIE)
20
31
  if (existing) return { sessionId: existing, isNew: false }
21
32
  const sessionId = randomUUID()
22
- setCookie(c, SESSION_COOKIE, sessionId, {
23
- httpOnly: true,
24
- sameSite: 'Lax',
25
- path: '/',
26
- secure: shouldUseSecureCookie(),
27
- })
33
+ setSessionCookie(c, sessionId)
28
34
  return { sessionId, isNew: true }
29
35
  }
@@ -29,6 +29,10 @@ export interface Store {
29
29
  ): Promise<void>
30
30
  has(sessionId: string, machineName: string): Promise<boolean>
31
31
  deleteSession(sessionId: string): Promise<void>
32
+ /** Move EVERY machine snapshot from one session id to another (used by
33
+ * session rotation on privilege change). Optional for custom adapters;
34
+ * rotation fails loudly when the configured store lacks it. */
35
+ renameSession?(oldSessionId: string, newSessionId: string): Promise<void>
32
36
  }
33
37
 
34
38
  /**
@@ -92,4 +96,16 @@ export class InMemoryStore implements Store {
92
96
  async deleteSession(sid: string): Promise<void> {
93
97
  this.dropSession(sid)
94
98
  }
99
+
100
+ async renameSession(oldSid: string, newSid: string): Promise<void> {
101
+ if (this.isExpired(oldSid)) {
102
+ this.dropSession(oldSid)
103
+ return
104
+ }
105
+ const session = this.data.get(oldSid)
106
+ const expiry = this.expiryAt.get(oldSid)
107
+ this.dropSession(oldSid)
108
+ if (session) this.data.set(newSid, session)
109
+ if (expiry !== undefined) this.expiryAt.set(newSid, expiry)
110
+ }
95
111
  }