@benjavicente/start-client-core 1.167.9 → 1.168.3
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/dist/esm/client/hydrateStart.js +4 -2
- package/dist/esm/client/hydrateStart.js.map +1 -1
- package/dist/esm/client-rpc/serverFnFetcher.d.ts +21 -0
- package/dist/esm/client-rpc/serverFnFetcher.js +94 -71
- package/dist/esm/client-rpc/serverFnFetcher.js.map +1 -1
- package/dist/esm/createCsrfMiddleware.d.ts +46 -0
- package/dist/esm/createCsrfMiddleware.js +63 -0
- package/dist/esm/createCsrfMiddleware.js.map +1 -0
- package/dist/esm/createMiddleware.d.ts +4 -0
- package/dist/esm/createMiddleware.js.map +1 -1
- package/dist/esm/createServerFn.d.ts +44 -31
- package/dist/esm/createServerFn.js +1 -1
- package/dist/esm/createServerFn.js.map +1 -1
- package/dist/esm/fake-entries/plugin-adapters.d.ts +3 -0
- package/dist/esm/fake-entries/plugin-adapters.js +7 -0
- package/dist/esm/fake-entries/plugin-adapters.js.map +1 -0
- package/dist/esm/fake-entries/router.d.ts +1 -0
- package/dist/esm/fake-entries/router.js +6 -0
- package/dist/esm/fake-entries/router.js.map +1 -0
- package/dist/esm/{fake-start-entry.d.ts → fake-entries/start.d.ts} +0 -1
- package/dist/esm/fake-entries/start.js +6 -0
- package/dist/esm/fake-entries/start.js.map +1 -0
- package/dist/esm/getDefaultSerovalPlugins.d.ts +2 -1
- package/dist/esm/getDefaultSerovalPlugins.js.map +1 -1
- package/dist/esm/index.d.ts +4 -1
- package/dist/esm/index.js +3 -1
- package/dist/esm/tests/createCsrfMiddleware.test.d.ts +1 -0
- package/package.json +10 -11
- package/skills/start-core/SKILL.md +11 -7
- package/skills/start-core/auth-server-primitives/SKILL.md +410 -0
- package/skills/start-core/deployment/SKILL.md +9 -0
- package/skills/start-core/execution-model/SKILL.md +68 -19
- package/skills/start-core/middleware/SKILL.md +42 -9
- package/skills/start-core/server-functions/SKILL.md +115 -17
- package/src/client/hydrateStart.ts +12 -6
- package/src/client-rpc/serverFnFetcher.ts +132 -103
- package/src/createCsrfMiddleware.ts +197 -0
- package/src/createMiddleware.ts +4 -0
- package/src/createServerFn.ts +192 -63
- package/src/fake-entries/plugin-adapters.ts +4 -0
- package/src/fake-entries/router.ts +1 -0
- package/src/{fake-start-entry.ts → fake-entries/start.ts} +0 -1
- package/src/getDefaultSerovalPlugins.ts +2 -1
- package/src/index.tsx +16 -0
- package/src/start-entry.d.ts +9 -2
- package/src/tests/createCsrfMiddleware.test.ts +290 -0
- package/src/tests/createServerFn.test-d.ts +152 -2
- package/src/tests/createServerMiddleware.test-d.ts +16 -3
- package/bin/intent.js +0 -25
- package/dist/esm/fake-start-entry.js +0 -7
- package/dist/esm/fake-start-entry.js.map +0 -1
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: start-core/auth-server-primitives
|
|
3
|
+
description: >-
|
|
4
|
+
Server-side authentication primitives for TanStack Start: session
|
|
5
|
+
cookies (HttpOnly, Secure, SameSite, __Host- prefix), session
|
|
6
|
+
read/issue/destroy via createServerFn and middleware, OAuth
|
|
7
|
+
authorization-code flow with state and PKCE, password-reset
|
|
8
|
+
enumeration defense, CSRF for non-GET RPCs, rate limiting auth
|
|
9
|
+
endpoints, session rotation on privilege change. Pairs with
|
|
10
|
+
router-core/auth-and-guards for the routing side.
|
|
11
|
+
type: sub-skill
|
|
12
|
+
library: tanstack-start
|
|
13
|
+
library_version: '1.166.2'
|
|
14
|
+
requires:
|
|
15
|
+
- start-core
|
|
16
|
+
- start-core/server-functions
|
|
17
|
+
- start-core/middleware
|
|
18
|
+
sources:
|
|
19
|
+
- TanStack/router:docs/start/framework/react/guide/authentication-overview.md
|
|
20
|
+
- TanStack/router:docs/start/framework/react/guide/authentication-server-primitives.md
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
# Auth Server Primitives
|
|
24
|
+
|
|
25
|
+
This skill covers the **server half** of authentication: session storage, cookie issuance, OAuth flow, password-reset hardening, CSRF, rate limiting. For the **routing half** (`_authenticated` layout, `beforeLoad` redirects, RBAC checks), see [router-core/auth-and-guards](../../../../router-core/skills/router-core/auth-and-guards/SKILL.md).
|
|
26
|
+
|
|
27
|
+
> **CRITICAL**: A route guard does NOT protect a `createServerFn` on that route. Server functions are RPC endpoints reachable by direct POST regardless of which route renders them. Auth must be enforced **inside the handler** (or via middleware), not on the calling route.
|
|
28
|
+
> **CRITICAL**: Validating the _shape_ of a client-supplied identifier (`z.string().uuid().parse(...)`) is not authorization. A parsed UUID is still _some_ tenant — re-check membership against the session principal before using it.
|
|
29
|
+
> **CRITICAL**: Read session/cookies inside `.handler()` or middleware `.server()`, not at module scope. Module-level reads run before requests exist (and are also undefined on Cloudflare Workers).
|
|
30
|
+
|
|
31
|
+
## Session Cookies
|
|
32
|
+
|
|
33
|
+
The recommended session storage is an HTTP-only cookie holding either an opaque session ID (with server-side lookup) or a signed/encrypted token. The cookie flags matter — set them all.
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
// src/server/session.ts
|
|
37
|
+
import {
|
|
38
|
+
getRequestHeader,
|
|
39
|
+
setResponseHeader,
|
|
40
|
+
} from '@benjavicente/react-start/server'
|
|
41
|
+
|
|
42
|
+
const SESSION_COOKIE = '__Host-session' // __Host- prefix binds to the exact origin + path '/'
|
|
43
|
+
const ONE_DAY = 60 * 60 * 24
|
|
44
|
+
|
|
45
|
+
export function setSessionCookie(token: string) {
|
|
46
|
+
setResponseHeader(
|
|
47
|
+
'Set-Cookie',
|
|
48
|
+
[
|
|
49
|
+
`${SESSION_COOKIE}=${token}`,
|
|
50
|
+
`HttpOnly`, // not readable from JS — defeats XSS exfiltration
|
|
51
|
+
`Secure`, // HTTPS only (required for __Host- prefix)
|
|
52
|
+
`SameSite=Lax`, // sent on top-level navigations, blocks most CSRF
|
|
53
|
+
`Path=/`, // required for __Host- prefix
|
|
54
|
+
`Max-Age=${ONE_DAY}`,
|
|
55
|
+
].join('; '),
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function clearSessionCookie() {
|
|
60
|
+
setResponseHeader(
|
|
61
|
+
'Set-Cookie',
|
|
62
|
+
`${SESSION_COOKIE}=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0`,
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function readSessionToken(): string | null {
|
|
67
|
+
const header = getRequestHeader('cookie')
|
|
68
|
+
if (!header) return null
|
|
69
|
+
for (const part of header.split(/;\s*/)) {
|
|
70
|
+
// Split only on the FIRST '=' — signed/base64 values often contain '='.
|
|
71
|
+
const eq = part.indexOf('=')
|
|
72
|
+
if (eq === -1) continue
|
|
73
|
+
if (part.slice(0, eq) === SESSION_COOKIE) return part.slice(eq + 1)
|
|
74
|
+
}
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Flag rationale:
|
|
80
|
+
|
|
81
|
+
- `HttpOnly` — JavaScript can't read the cookie, so an XSS bug can't steal the session.
|
|
82
|
+
- `Secure` — HTTPS only. Required when using `__Host-` prefix.
|
|
83
|
+
- `SameSite=Lax` — blocks CSRF on most cross-origin POST/PUT/DELETE. Use `Strict` for highest-security flows where loss of cross-site GET navigation is acceptable.
|
|
84
|
+
- `__Host-` prefix — binds the cookie to the exact origin (no Domain attribute, Path must be `/`, Secure must be set). Prevents subdomain takeover from forging a session cookie.
|
|
85
|
+
- `Path=/` — required by `__Host-`.
|
|
86
|
+
- `Max-Age` — finite lifetime so a stolen cookie isn't useful forever. Pair with server-side session rotation.
|
|
87
|
+
|
|
88
|
+
## Session Lookup as Middleware
|
|
89
|
+
|
|
90
|
+
Use middleware to centralize session loading so every protected handler sees a typed session:
|
|
91
|
+
|
|
92
|
+
```tsx
|
|
93
|
+
// src/server/auth-middleware.ts
|
|
94
|
+
import { createMiddleware } from '@benjavicente/react-start'
|
|
95
|
+
import { readSessionToken } from './session'
|
|
96
|
+
|
|
97
|
+
export const authMiddleware = createMiddleware({ type: 'function' }).server(
|
|
98
|
+
async ({ next }) => {
|
|
99
|
+
const token = readSessionToken()
|
|
100
|
+
const session = token ? await db.sessions.findValid(token) : null
|
|
101
|
+
if (!session) throw new Error('Unauthorized')
|
|
102
|
+
return next({ context: { session } })
|
|
103
|
+
},
|
|
104
|
+
)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Attach it to every server function that needs a logged-in user:
|
|
108
|
+
|
|
109
|
+
```tsx
|
|
110
|
+
import { createServerFn } from '@benjavicente/react-start'
|
|
111
|
+
import { authMiddleware } from '~/server/auth-middleware'
|
|
112
|
+
|
|
113
|
+
export const getMyOrders = createServerFn({ method: 'GET' })
|
|
114
|
+
.middleware([authMiddleware])
|
|
115
|
+
.handler(async ({ context }) => {
|
|
116
|
+
return db.orders.findMany({ where: { userId: context.session.userId } })
|
|
117
|
+
})
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
> **Route guards do not cover this.** A `createFileRoute('/_authenticated/orders')` with a `beforeLoad` redirect does NOT protect `getMyOrders` — the RPC is reachable via direct POST whether or not the user ever hits the route. Apply `authMiddleware` (or re-check inside `.handler()`) on every server function that needs auth.
|
|
121
|
+
|
|
122
|
+
## Issuing a Session on Login
|
|
123
|
+
|
|
124
|
+
```tsx
|
|
125
|
+
// src/server/login.functions.ts
|
|
126
|
+
import { createServerFn } from '@benjavicente/react-start'
|
|
127
|
+
import { z } from 'zod'
|
|
128
|
+
import { setSessionCookie } from './session'
|
|
129
|
+
|
|
130
|
+
export const login = createServerFn({ method: 'POST' })
|
|
131
|
+
.inputValidator(z.object({ email: z.string().email(), password: z.string() }))
|
|
132
|
+
.handler(async ({ data }) => {
|
|
133
|
+
const user = await db.users.findByEmail(data.email)
|
|
134
|
+
// Always run verifyPasswordHash — even when the user doesn't exist —
|
|
135
|
+
// so the user-not-found branch takes the same time as wrong-password.
|
|
136
|
+
// DUMMY_PASSWORD_HASH is a hash of any throwaway password computed once
|
|
137
|
+
// at startup with the same algorithm/cost as real password hashes.
|
|
138
|
+
const hashToCheck = user?.passwordHash ?? DUMMY_PASSWORD_HASH
|
|
139
|
+
const passwordMatches = await verifyPasswordHash(hashToCheck, data.password)
|
|
140
|
+
const ok = user != null && passwordMatches
|
|
141
|
+
if (!ok) throw new Error('Invalid email or password')
|
|
142
|
+
|
|
143
|
+
// ROTATE on privilege change: destroy any existing session, then issue fresh.
|
|
144
|
+
await db.sessions.revokeAllForUser(user.id)
|
|
145
|
+
const token = await db.sessions.create({ userId: user.id })
|
|
146
|
+
setSessionCookie(token)
|
|
147
|
+
return { ok: true }
|
|
148
|
+
})
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Logout
|
|
152
|
+
|
|
153
|
+
```tsx
|
|
154
|
+
import { createServerFn } from '@benjavicente/react-start'
|
|
155
|
+
import { authMiddleware } from '~/server/auth-middleware'
|
|
156
|
+
import { clearSessionCookie } from '~/server/session'
|
|
157
|
+
|
|
158
|
+
export const logout = createServerFn({ method: 'POST' })
|
|
159
|
+
.middleware([authMiddleware])
|
|
160
|
+
.handler(async ({ context }) => {
|
|
161
|
+
await db.sessions.revoke(context.session.id)
|
|
162
|
+
clearSessionCookie()
|
|
163
|
+
return { ok: true }
|
|
164
|
+
})
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## OAuth: state + PKCE
|
|
168
|
+
|
|
169
|
+
For OAuth authorization-code flow, generate a one-time `state` (CSRF defense) and a PKCE verifier (defense against authorization-code interception). Store both in a short-lived signed cookie keyed to this exact login attempt.
|
|
170
|
+
|
|
171
|
+
```tsx
|
|
172
|
+
// src/server/oauth.functions.ts
|
|
173
|
+
import { createServerFn } from '@benjavicente/react-start'
|
|
174
|
+
import { redirect } from '@benjavicente/react-router'
|
|
175
|
+
import {
|
|
176
|
+
getRequestHeader,
|
|
177
|
+
setResponseHeader,
|
|
178
|
+
} from '@benjavicente/react-start/server'
|
|
179
|
+
import crypto from 'node:crypto'
|
|
180
|
+
|
|
181
|
+
const OAUTH_STATE_COOKIE = '__Host-oauth' // expires fast; one-shot
|
|
182
|
+
|
|
183
|
+
function base64url(buf: Buffer) {
|
|
184
|
+
return buf
|
|
185
|
+
.toString('base64')
|
|
186
|
+
.replace(/=/g, '')
|
|
187
|
+
.replace(/\+/g, '-')
|
|
188
|
+
.replace(/\//g, '_')
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export const startOAuth = createServerFn({ method: 'GET' }).handler(
|
|
192
|
+
async () => {
|
|
193
|
+
const state = base64url(crypto.randomBytes(32))
|
|
194
|
+
const verifier = base64url(crypto.randomBytes(32))
|
|
195
|
+
const challenge = base64url(
|
|
196
|
+
crypto.createHash('sha256').update(verifier).digest(),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
setResponseHeader(
|
|
200
|
+
'Set-Cookie',
|
|
201
|
+
`${OAUTH_STATE_COOKIE}=${signed({ state, verifier })}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=600`,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
throw redirect({
|
|
205
|
+
href:
|
|
206
|
+
`https://provider.example/authorize` +
|
|
207
|
+
`?response_type=code` +
|
|
208
|
+
`&client_id=${process.env.OAUTH_CLIENT_ID}` +
|
|
209
|
+
`&redirect_uri=${encodeURIComponent(process.env.OAUTH_REDIRECT_URI!)}` +
|
|
210
|
+
`&state=${state}` +
|
|
211
|
+
`&code_challenge=${challenge}` +
|
|
212
|
+
`&code_challenge_method=S256`,
|
|
213
|
+
})
|
|
214
|
+
},
|
|
215
|
+
)
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
In the callback handler, **verify the cookie state matches the returned state** and exchange the code with the verifier. If state is missing or doesn't match, abort — the request did not originate from your `startOAuth`.
|
|
219
|
+
|
|
220
|
+
## Password Reset: defeat user enumeration
|
|
221
|
+
|
|
222
|
+
When a user requests a reset, do not let the response shape or timing reveal whether the email is registered.
|
|
223
|
+
|
|
224
|
+
```tsx
|
|
225
|
+
import { createServerFn } from '@benjavicente/react-start'
|
|
226
|
+
import { z } from 'zod'
|
|
227
|
+
|
|
228
|
+
export const requestPasswordReset = createServerFn({ method: 'POST' })
|
|
229
|
+
.inputValidator(z.object({ email: z.string().email() }))
|
|
230
|
+
.handler(async ({ data }) => {
|
|
231
|
+
const user = await db.users.findByEmail(data.email)
|
|
232
|
+
if (user) {
|
|
233
|
+
const token = await db.passwordResets.issue(user.id)
|
|
234
|
+
await sendResetEmail(user.email, token)
|
|
235
|
+
}
|
|
236
|
+
// Always 200, always the same body, regardless of whether the user exists.
|
|
237
|
+
// The user is told to check their inbox; no confirmation either way.
|
|
238
|
+
return { ok: true }
|
|
239
|
+
})
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Do NOT:
|
|
243
|
+
|
|
244
|
+
- Return 200 if exists, 404 if not.
|
|
245
|
+
- Use a different message ("we sent you a link" vs "no account found").
|
|
246
|
+
- Skip the work when the user doesn't exist (timing leak — measurable from the wire).
|
|
247
|
+
|
|
248
|
+
## CSRF for non-GET RPCs
|
|
249
|
+
|
|
250
|
+
`SameSite=Lax` on the session cookie blocks most cross-site CSRF for POST/PUT/DELETE. Two cases need extra defense:
|
|
251
|
+
|
|
252
|
+
1. **Top-level GET navigation that mutates** — never do this. Always use POST/PUT/DELETE for mutations.
|
|
253
|
+
2. **POST from a page on a sibling subdomain** — `SameSite=Lax` does NOT block this; verify the `Origin` header matches your app's origin in middleware.
|
|
254
|
+
|
|
255
|
+
```tsx
|
|
256
|
+
import { createMiddleware } from '@benjavicente/react-start'
|
|
257
|
+
import { getRequest } from '@benjavicente/react-start/server'
|
|
258
|
+
|
|
259
|
+
export const csrfMiddleware = createMiddleware().server(async ({ next }) => {
|
|
260
|
+
const request = getRequest()
|
|
261
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
262
|
+
const origin = request.headers.get('origin')
|
|
263
|
+
// Compare the FULL origin (scheme + host + port) — host alone lets
|
|
264
|
+
// http://example.com pass a check meant for https://example.com.
|
|
265
|
+
if (!origin || new URL(origin).origin !== process.env.APP_ORIGIN) {
|
|
266
|
+
throw new Error('Origin check failed')
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return next()
|
|
270
|
+
})
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Attach this to global request middleware in `src/start.ts` so it covers every non-GET request, including server routes and SSR.
|
|
274
|
+
|
|
275
|
+
## Rate Limiting Auth Endpoints
|
|
276
|
+
|
|
277
|
+
A login endpoint without rate limiting is a credential-stuffing target. Limit per-IP (and ideally per-account) with a sliding window.
|
|
278
|
+
|
|
279
|
+
```tsx
|
|
280
|
+
import { createMiddleware } from '@benjavicente/react-start'
|
|
281
|
+
import { getRequest } from '@benjavicente/react-start/server'
|
|
282
|
+
|
|
283
|
+
function rateLimitMiddleware(opts: {
|
|
284
|
+
key: string
|
|
285
|
+
max: number
|
|
286
|
+
windowMs: number
|
|
287
|
+
}) {
|
|
288
|
+
return createMiddleware().server(async ({ next }) => {
|
|
289
|
+
const request = getRequest()
|
|
290
|
+
const ip =
|
|
291
|
+
request.headers.get('cf-connecting-ip') ??
|
|
292
|
+
request.headers.get('x-forwarded-for')?.split(',')[0] ??
|
|
293
|
+
'unknown'
|
|
294
|
+
const bucketKey = `rl:${opts.key}:${ip}`
|
|
295
|
+
const allowed = await rateLimiter.consume(
|
|
296
|
+
bucketKey,
|
|
297
|
+
opts.max,
|
|
298
|
+
opts.windowMs,
|
|
299
|
+
)
|
|
300
|
+
if (!allowed) throw new Error('Too many requests')
|
|
301
|
+
return next()
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// On the login server function:
|
|
306
|
+
export const login = createServerFn({ method: 'POST' }).middleware([
|
|
307
|
+
rateLimitMiddleware({ key: 'login', max: 5, windowMs: 60_000 }),
|
|
308
|
+
])
|
|
309
|
+
// ...
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
## Session Rotation on Privilege Change
|
|
313
|
+
|
|
314
|
+
Whenever the user's privileges change — login, logout, role change, password change — **destroy the old session and issue a new one**. This neutralizes session-fixation attacks where an attacker plants their own session ID in the victim's browser before login.
|
|
315
|
+
|
|
316
|
+
```tsx
|
|
317
|
+
// In the login handler (already shown above): destroy any pre-login session, then create a fresh one.
|
|
318
|
+
await db.sessions.revokeAllForUser(user.id)
|
|
319
|
+
const token = await db.sessions.create({ userId: user.id })
|
|
320
|
+
setSessionCookie(token)
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
```tsx
|
|
324
|
+
// On password change / role grant:
|
|
325
|
+
await db.sessions.revokeAllForUser(user.id) // destroy existing
|
|
326
|
+
const token = await db.sessions.create({ userId: user.id }) // issue fresh
|
|
327
|
+
setSessionCookie(token)
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
## Common Mistakes
|
|
331
|
+
|
|
332
|
+
### CRITICAL: Trusting the route guard for server-function auth
|
|
333
|
+
|
|
334
|
+
```tsx
|
|
335
|
+
// WRONG — the RPC is callable directly via POST regardless of the route
|
|
336
|
+
export const Route = createFileRoute('/_authenticated/orders')({
|
|
337
|
+
beforeLoad: ({ context }) => {
|
|
338
|
+
if (!context.auth.isAuthenticated) throw redirect({ to: '/login' })
|
|
339
|
+
},
|
|
340
|
+
})
|
|
341
|
+
const getMyOrders = createServerFn({ method: 'GET' }).handler(async () => {
|
|
342
|
+
return db.orders.findMany() // ← anyone can hit the RPC and get all orders
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
// CORRECT — auth enforced on the handler itself
|
|
346
|
+
const getMyOrders = createServerFn({ method: 'GET' })
|
|
347
|
+
.middleware([authMiddleware])
|
|
348
|
+
.handler(async ({ context }) => {
|
|
349
|
+
return db.orders.findMany({ where: { userId: context.session.userId } })
|
|
350
|
+
})
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
### CRITICAL: Treating shape validation as authorization
|
|
354
|
+
|
|
355
|
+
A parsed UUID is _some_ workspace, not an _authorized_ workspace.
|
|
356
|
+
|
|
357
|
+
```tsx
|
|
358
|
+
// WRONG — UUID is well-formed but the user may not be a member
|
|
359
|
+
const getWorkspaceData = createServerFn({ method: 'GET' })
|
|
360
|
+
.middleware([authMiddleware])
|
|
361
|
+
.inputValidator(z.object({ workspaceId: z.string().uuid() }))
|
|
362
|
+
.handler(async ({ context, data }) => {
|
|
363
|
+
return db.workspaces.findById(data.workspaceId) // missing membership check!
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
// CORRECT — verify the session principal has access to that workspace
|
|
367
|
+
const getWorkspaceData = createServerFn({ method: 'GET' })
|
|
368
|
+
.middleware([authMiddleware])
|
|
369
|
+
.inputValidator(z.object({ workspaceId: z.string().uuid() }))
|
|
370
|
+
.handler(async ({ context, data }) => {
|
|
371
|
+
const member = await db.memberships.find({
|
|
372
|
+
userId: context.session.userId,
|
|
373
|
+
workspaceId: data.workspaceId,
|
|
374
|
+
})
|
|
375
|
+
if (!member) throw new Error('Not a member of this workspace')
|
|
376
|
+
return db.workspaces.findById(data.workspaceId)
|
|
377
|
+
})
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
### HIGH: Returning different responses based on email existence
|
|
381
|
+
|
|
382
|
+
Already covered above — `requestPasswordReset` must return the same body regardless of whether the email matches a user.
|
|
383
|
+
|
|
384
|
+
### HIGH: Reading cookies/env at module scope
|
|
385
|
+
|
|
386
|
+
```tsx
|
|
387
|
+
// WRONG — module-load time, before any request exists
|
|
388
|
+
const SESSION_SECRET = process.env.SESSION_SECRET
|
|
389
|
+
export function signSession(payload) {
|
|
390
|
+
return sign(payload, SESSION_SECRET)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// CORRECT — read inside per-request callback
|
|
394
|
+
export function signSession(payload) {
|
|
395
|
+
return sign(payload, process.env.SESSION_SECRET)
|
|
396
|
+
}
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
On Cloudflare Workers and other edge runtimes, the module-level read evaluates to `undefined` even on the server because env is injected per-request. See [start-core/execution-model](../execution-model/SKILL.md).
|
|
400
|
+
|
|
401
|
+
### MEDIUM: Long-lived sessions with no rotation
|
|
402
|
+
|
|
403
|
+
A session token that never rotates is functionally a long-lived credential. Rotate on login, logout, password change, and role/permission change.
|
|
404
|
+
|
|
405
|
+
## Cross-References
|
|
406
|
+
|
|
407
|
+
- [router-core/auth-and-guards](../../../../router-core/skills/router-core/auth-and-guards/SKILL.md) — the routing side: `_authenticated` layout, `beforeLoad`, `redirect`, RBAC checks.
|
|
408
|
+
- [start-core/server-functions](../server-functions/SKILL.md) — how to expose RPCs (and how the route guard does NOT cover them).
|
|
409
|
+
- [start-core/middleware](../middleware/SKILL.md) — composing `authMiddleware` and others.
|
|
410
|
+
- [start-core/execution-model](../execution-model/SKILL.md) — why module-level env/secret reads are wrong.
|
|
@@ -57,6 +57,15 @@ export default defineConfig({
|
|
|
57
57
|
|
|
58
58
|
Deploy: `npx wrangler login && pnpm run deploy`
|
|
59
59
|
|
|
60
|
+
> **Worker env is per-request.** Cloudflare Workers inject env vars at request time. `process.env.X` at module scope evaluates to `undefined` even on the server. The Cloudflare-canonical way to read env (including from module scope) is the `cloudflare:workers` env binding:
|
|
61
|
+
>
|
|
62
|
+
> ```ts
|
|
63
|
+
> import { env } from 'cloudflare:workers'
|
|
64
|
+
> const apiHost = env.API_HOST
|
|
65
|
+
> ```
|
|
66
|
+
>
|
|
67
|
+
> Or read `process.env.X` per-request inside `.handler()` / middleware `.server()`. See [Cloudflare's environment-variables docs](https://developers.cloudflare.com/workers/configuration/environment-variables/) and [start-core/execution-model](../execution-model/SKILL.md).
|
|
68
|
+
|
|
60
69
|
### Netlify
|
|
61
70
|
|
|
62
71
|
```bash
|
|
@@ -14,6 +14,7 @@ requires:
|
|
|
14
14
|
sources:
|
|
15
15
|
- TanStack/router:docs/start/framework/react/guide/execution-model.md
|
|
16
16
|
- TanStack/router:docs/start/framework/react/guide/environment-variables.md
|
|
17
|
+
- TanStack/router:docs/start/framework/react/guide/import-protection.md
|
|
17
18
|
---
|
|
18
19
|
|
|
19
20
|
# Execution Model
|
|
@@ -21,19 +22,21 @@ sources:
|
|
|
21
22
|
Understanding where code runs is fundamental to TanStack Start. This skill covers the isomorphic execution model and how to control environment boundaries.
|
|
22
23
|
|
|
23
24
|
> **CRITICAL**: ALL code in TanStack Start is isomorphic by default — it runs in BOTH server and client bundles. Route loaders run on BOTH server (during SSR) AND client (during navigation). Server-only operations MUST use `createServerFn`.
|
|
24
|
-
> **CRITICAL**: Module-level `process.env` access
|
|
25
|
+
> **CRITICAL**: Module-level `process.env` access is wrong on **two** axes — security (values leak into the client bundle) AND runtime correctness (on Cloudflare Workers and other edge runtimes, env is injected per-request, so module-level reads evaluate to `undefined` even on the server). Read env inside `.handler()` or another per-request function, never at module scope.
|
|
25
26
|
> **CRITICAL**: `VITE_` prefixed environment variables are exposed to the client bundle. Server secrets must NOT have the `VITE_` prefix.
|
|
26
27
|
|
|
27
28
|
## Execution Control APIs
|
|
28
29
|
|
|
29
|
-
| API
|
|
30
|
-
|
|
|
31
|
-
| `createServerFn()`
|
|
32
|
-
| `createServerOnlyFn(fn)`
|
|
33
|
-
| `createClientOnlyFn(fn)`
|
|
34
|
-
| `createIsomorphicFn()`
|
|
35
|
-
| `<ClientOnly>`
|
|
36
|
-
| `useHydrated()`
|
|
30
|
+
| API | Use Case | Client Behavior | Server Behavior |
|
|
31
|
+
| ------------------------------------------- | --------------------------- | ------------------------- | --------------------- |
|
|
32
|
+
| `createServerFn()` | RPC calls, data mutations | Network request to server | Direct execution |
|
|
33
|
+
| `createServerOnlyFn(fn)` | Utility functions | Throws error | Direct execution |
|
|
34
|
+
| `createClientOnlyFn(fn)` | Browser utilities | Direct execution | Throws error |
|
|
35
|
+
| `createIsomorphicFn()` | Different impl per env | Uses `.client()` impl | Uses `.server()` impl |
|
|
36
|
+
| `<ClientOnly>` | Browser-only components | Renders children | Renders fallback |
|
|
37
|
+
| `useHydrated()` | Hydration-dependent logic | `true` after hydration | `false` |
|
|
38
|
+
| `import '@benjavicente/<fw>-start/server-only'` | Mark whole file server-only | Import denied | Allowed |
|
|
39
|
+
| `import '@benjavicente/<fw>-start/client-only'` | Mark whole file client-only | Allowed | Import denied |
|
|
37
40
|
|
|
38
41
|
## Server-Only Execution
|
|
39
42
|
|
|
@@ -42,7 +45,7 @@ Understanding where code runs is fundamental to TanStack Start. This skill cover
|
|
|
42
45
|
The primary way to run server-only code. On the client, calls become fetch requests:
|
|
43
46
|
|
|
44
47
|
```tsx
|
|
45
|
-
// Use @
|
|
48
|
+
// Use @benjavicente/<framework>-start for your framework (react, solid, vue)
|
|
46
49
|
import { createServerFn } from '@benjavicente/react-start'
|
|
47
50
|
|
|
48
51
|
const fetchUser = createServerFn().handler(async () => {
|
|
@@ -59,7 +62,7 @@ const user = await fetchUser()
|
|
|
59
62
|
For utility functions that must never run on client:
|
|
60
63
|
|
|
61
64
|
```tsx
|
|
62
|
-
// Use @
|
|
65
|
+
// Use @benjavicente/<framework>-start for your framework (react, solid, vue)
|
|
63
66
|
import { createServerOnlyFn } from '@benjavicente/react-start'
|
|
64
67
|
|
|
65
68
|
const getSecret = createServerOnlyFn(() => process.env.DATABASE_URL)
|
|
@@ -73,7 +76,7 @@ const getSecret = createServerOnlyFn(() => process.env.DATABASE_URL)
|
|
|
73
76
|
### createClientOnlyFn
|
|
74
77
|
|
|
75
78
|
```tsx
|
|
76
|
-
// Use @
|
|
79
|
+
// Use @benjavicente/<framework>-start for your framework (react, solid, vue)
|
|
77
80
|
import { createClientOnlyFn } from '@benjavicente/react-start'
|
|
78
81
|
|
|
79
82
|
const saveToStorage = createClientOnlyFn((key: string, value: string) => {
|
|
@@ -84,7 +87,7 @@ const saveToStorage = createClientOnlyFn((key: string, value: string) => {
|
|
|
84
87
|
### ClientOnly Component
|
|
85
88
|
|
|
86
89
|
```tsx
|
|
87
|
-
// Use @
|
|
90
|
+
// Use @benjavicente/<framework>-router for your framework (react, solid, vue)
|
|
88
91
|
import { ClientOnly } from '@benjavicente/react-router'
|
|
89
92
|
|
|
90
93
|
function Analytics() {
|
|
@@ -99,7 +102,7 @@ function Analytics() {
|
|
|
99
102
|
### useHydrated Hook
|
|
100
103
|
|
|
101
104
|
```tsx
|
|
102
|
-
// Use @
|
|
105
|
+
// Use @benjavicente/<framework>-router for your framework (react, solid, vue)
|
|
103
106
|
import { useHydrated } from '@benjavicente/react-router'
|
|
104
107
|
|
|
105
108
|
function TimeZoneDisplay() {
|
|
@@ -117,7 +120,7 @@ Behavior: SSR → `false`, first client render → `false`, after hydration →
|
|
|
117
120
|
## Environment-Specific Implementations
|
|
118
121
|
|
|
119
122
|
```tsx
|
|
120
|
-
// Use @
|
|
123
|
+
// Use @benjavicente/<framework>-start for your framework (react, solid, vue)
|
|
121
124
|
import { createIsomorphicFn } from '@benjavicente/react-start'
|
|
122
125
|
|
|
123
126
|
const getDeviceInfo = createIsomorphicFn()
|
|
@@ -125,6 +128,45 @@ const getDeviceInfo = createIsomorphicFn()
|
|
|
125
128
|
.client(() => ({ type: 'client', userAgent: navigator.userAgent }))
|
|
126
129
|
```
|
|
127
130
|
|
|
131
|
+
## Import Protection: File Markers
|
|
132
|
+
|
|
133
|
+
> Experimental.
|
|
134
|
+
|
|
135
|
+
The `.server.*` and `.client.*` filename suffixes (e.g. `db.server.ts`) opt a file into Start's import protection — it can't be imported from the wrong environment. When you can't or don't want to rename the file, add a side-effect import at the top of the file to apply the same protection by marker:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
// src/lib/secrets.ts (filename can't be *.server.ts)
|
|
139
|
+
import '@benjavicente/react-start/server-only'
|
|
140
|
+
// (or @benjavicente/solid-start/server-only, @benjavicente/vue-start/server-only)
|
|
141
|
+
|
|
142
|
+
export function getApiKey() {
|
|
143
|
+
return process.env.API_KEY
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
// src/lib/storage.ts
|
|
149
|
+
import '@benjavicente/react-start/client-only'
|
|
150
|
+
// (or @benjavicente/solid-start/client-only, @benjavicente/vue-start/client-only)
|
|
151
|
+
|
|
152
|
+
export function savePreferences(prefs: Record<string, string>) {
|
|
153
|
+
localStorage.setItem('prefs', JSON.stringify(prefs))
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Rules:
|
|
158
|
+
|
|
159
|
+
- Both markers in the same file is an error.
|
|
160
|
+
- Type-only imports are ignored (they erase to nothing at runtime).
|
|
161
|
+
- Default behavior is `error` in production builds and `mock` in dev. The mock returns a recursive Proxy so dev keeps running while you fix the import graph.
|
|
162
|
+
|
|
163
|
+
Pick the right tool:
|
|
164
|
+
|
|
165
|
+
- File should never run on the wrong side **and** has no client API → `*.server.ts` filename or `import '@benjavicente/<fw>-start/server-only'`.
|
|
166
|
+
- One symbol needs to behave differently per environment → `createIsomorphicFn().client(...).server(...)`.
|
|
167
|
+
- One function should error if called from the wrong side → `createServerOnlyFn` / `createClientOnlyFn`.
|
|
168
|
+
- Component renders only after hydration → `<ClientOnly>` or `useHydrated()`.
|
|
169
|
+
|
|
128
170
|
## Environment Variables
|
|
129
171
|
|
|
130
172
|
### Server-Side (inside createServerFn)
|
|
@@ -229,22 +271,29 @@ export const Route = createFileRoute('/dashboard')({
|
|
|
229
271
|
})
|
|
230
272
|
```
|
|
231
273
|
|
|
232
|
-
### 2. CRITICAL:
|
|
274
|
+
### 2. CRITICAL: Reading process.env at module scope
|
|
275
|
+
|
|
276
|
+
Module-level `process.env` reads are wrong for **two** reasons, not one:
|
|
277
|
+
|
|
278
|
+
1. **Security:** the value can be inlined into the client bundle, leaking secrets.
|
|
279
|
+
2. **Runtime correctness (edge runtimes):** Cloudflare Workers and other edge SSR runtimes inject env at request time. Module-level code runs at module load, before the env exists, so the read evaluates to `undefined` even on the server. The bug only surfaces at deploy time.
|
|
233
280
|
|
|
234
281
|
```tsx
|
|
235
|
-
// WRONG —
|
|
282
|
+
// WRONG — leaks to client AND is undefined on Workers
|
|
236
283
|
const apiKey = process.env.SECRET_KEY
|
|
237
284
|
export function fetchData() {
|
|
238
|
-
/* uses apiKey */
|
|
285
|
+
/* uses apiKey, which is undefined under Worker SSR */
|
|
239
286
|
}
|
|
240
287
|
|
|
241
|
-
// CORRECT —
|
|
288
|
+
// CORRECT — read per-request, inside the handler
|
|
242
289
|
const fetchData = createServerFn({ method: 'GET' }).handler(async () => {
|
|
243
290
|
const apiKey = process.env.SECRET_KEY
|
|
244
291
|
return fetch(url, { headers: { Authorization: apiKey } })
|
|
245
292
|
})
|
|
246
293
|
```
|
|
247
294
|
|
|
295
|
+
The same rule applies to middleware `.server()` callbacks, server-route handlers, and any function that runs per request — read env there, not at the top of the file.
|
|
296
|
+
|
|
248
297
|
### 3. CRITICAL: Using VITE\_ prefix for server secrets
|
|
249
298
|
|
|
250
299
|
```bash
|