@statorjs/stator 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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: 'Lax',
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
- const existing = getCookie(c, SESSION_COOKIE)
31
- if (existing) return { sessionId: existing, isNew: false }
32
- const sessionId = randomUUID()
33
- setSessionCookie(c, sessionId)
34
- return { sessionId, isNew: true }
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
  }