@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.
- 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/cli/run.ts +6 -0
- package/src/config.ts +30 -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 +25 -1
- package/src/server/context.ts +103 -0
- package/src/server/cookies.ts +69 -0
- package/src/server/cors.ts +48 -0
- package/src/server/create-app.ts +41 -1
- package/src/server/csrf.ts +53 -9
- package/src/server/dev.ts +35 -0
- package/src/server/env.ts +27 -0
- package/src/server/http.ts +81 -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/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
|
}
|