@lastshotlabs/snapshot 0.1.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 ADDED
@@ -0,0 +1,1598 @@
1
+ # @lastshotlabs/snapshot
2
+
3
+ React frontend framework for [bunshot](https://github.com/lastshotlabs/bunshot)-powered backends.
4
+
5
+ Provides auth, API client, WebSocket, routing guards, and theme — all wired via a single factory call.
6
+
7
+ ---
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ bun add @lastshotlabs/snapshot
13
+ ```
14
+
15
+ **Peer dependencies** (install separately):
16
+
17
+ ```bash
18
+ bun add react react-dom @tanstack/react-router @tanstack/react-query jotai @unhead/react
19
+ ```
20
+
21
+ ---
22
+
23
+ ## Scaffolding
24
+
25
+ The fastest way to start is with the scaffold CLI — it generates a complete Vite + TanStack Router + shadcn app pre-wired to snapshot.
26
+
27
+ ```bash
28
+ bunx @lastshotlabs/snapshot init "My App"
29
+ ```
30
+
31
+ **With a custom output directory:**
32
+
33
+ ```bash
34
+ bunx @lastshotlabs/snapshot init "My App" my-app-dir
35
+ ```
36
+
37
+ **Skip all prompts and accept defaults:**
38
+
39
+ ```bash
40
+ bunx @lastshotlabs/snapshot init "My App" --yes
41
+ ```
42
+
43
+ ### Prompts
44
+
45
+ | Prompt | Options | Default |
46
+ |---|---|---|
47
+ | Project name | free text | — |
48
+ | Package name | free text | derived from project name |
49
+ | Security profile | `hardened` · `prototype` | `hardened` |
50
+ | Layout | `minimal` · `top-nav` · `sidebar` | `top-nav` |
51
+ | Theme | `default` · `dark` · `minimal` · `vibrant` | `default` |
52
+ | Auth pages | yes · no | yes |
53
+ | MFA pages | yes · no (shown if auth pages: yes) | no |
54
+ | Passkey pages | yes · no (shown if auth pages: yes) | no |
55
+ | shadcn components | multi-select | recommended set |
56
+ | WebSocket support | yes · no | yes |
57
+ | Git init | yes · no | yes |
58
+
59
+ ### What gets generated
60
+
61
+ ```
62
+ my-app/
63
+ src/
64
+ routes/
65
+ __root.tsx
66
+ _authenticated.tsx
67
+ _authenticated/index.tsx
68
+ _authenticated/mfa-setup.tsx ← (if MFA pages: yes)
69
+ _authenticated/passkey.tsx ← (if passkey pages: yes)
70
+ _authenticated/settings/
71
+ index.tsx ← (if auth pages: yes)
72
+ password.tsx ← (if auth pages: yes)
73
+ sessions.tsx ← (if auth pages: yes)
74
+ delete-account.tsx ← (if auth pages: yes)
75
+ email-otp.tsx ← (if auth pages + mfa pages: yes)
76
+ _guest.tsx
77
+ _guest/auth/ ← login, register, forgot-password,
78
+ reset-password, verify-email,
79
+ oauth/callback (if auth pages: yes)
80
+ mfa-verify (if MFA pages: yes)
81
+ pages/
82
+ auth/ ← LoginPage, RegisterPage, ForgotPasswordPage,
83
+ ResetPasswordPage, VerifyEmailPage,
84
+ OAuthCallbackPage (if auth pages: yes)
85
+ MfaVerifyPage, MfaSetupPage (if MFA pages: yes)
86
+ PasskeyManagePage.tsx ← (if passkey pages: yes)
87
+ settings/
88
+ SettingsPage.tsx ← (if auth pages: yes)
89
+ SettingsPasswordPage.tsx ← (if auth pages: yes)
90
+ SettingsSessionsPage.tsx ← (if auth pages: yes)
91
+ SettingsDeleteAccountPage.tsx ← (if auth pages: yes)
92
+ SettingsEmailOtpPage.tsx ← (if auth pages + mfa pages: yes)
93
+ components/
94
+ layout/ ← RootLayout, AuthLayout, shared components (layout-specific)
95
+ ui/ ← shadcn components
96
+ shared/
97
+ api/ ← plain async functions (populated by snapshot sync)
98
+ hooks/ ← custom hooks (your code)
99
+ api/ ← generated TanStack Query hooks (snapshot sync)
100
+ lib/
101
+ snapshot.ts ← createSnapshot() call, all hooks exported
102
+ router.ts
103
+ utils.ts
104
+ store/ui.ts
105
+ styles/globals.css ← theme-specific CSS variables
106
+ types/api.ts ← generated types (snapshot sync)
107
+ main.tsx
108
+ public/
109
+ vite.svg
110
+ vite.config.ts
111
+ tsconfig.json ← project references root
112
+ tsconfig.app.json ← app compiler options + path aliases
113
+ tsconfig.node.json ← vite.config.ts compiler options
114
+ snapshot.config.json ← sync output directories (edit to customise)
115
+ components.json
116
+ package.json
117
+ index.html
118
+ .env
119
+ .gitignore ← includes routeTree.gen.ts
120
+ ```
121
+
122
+ > **Note:** `routeTree.gen.ts` is auto-generated by TanStack Router on the first `bun dev` run. TypeScript will show an error for it until you start the dev server once.
123
+
124
+ ### Layouts
125
+
126
+ - **Minimal** — bare `div` wrapper, no navigation
127
+ - **Top nav** — header with app name, theme toggle, sign in/out
128
+ - **Sidebar** — collapsible sidebar (mobile overlay + desktop fixed), top bar with hamburger
129
+
130
+ ### Themes
131
+
132
+ All themes include both `:root` (light) and `.dark` variable sets — dark mode always works regardless of theme.
133
+
134
+ - **Default** — shadcn neutral palette, light mode default
135
+ - **Dark** — same palette, dark mode default (seeds `localStorage` on first visit to prevent FOUC)
136
+ - **Minimal** — reduced border radius, muted/low-contrast palette
137
+ - **Vibrant** — saturated violet/indigo palette, higher contrast
138
+
139
+ ### After scaffolding
140
+
141
+ ```bash
142
+ cd my-app
143
+
144
+ # Fill in .env:
145
+ # VITE_API_URL — your bunshot backend URL
146
+ # VITE_WS_URL — your WebSocket URL (if WS enabled)
147
+
148
+ bun dev # start the dev server (also generates routeTree.gen.ts)
149
+ bun run sync # generate src/api/, src/hooks/api/, src/types/api.ts from your backend
150
+ ```
151
+
152
+ `snapshot.config.json` is pre-generated with the default output paths. Edit it if you need to rename directories or point sync at a different backend.
153
+
154
+ ---
155
+
156
+ ## Quick Start
157
+
158
+ ### 1. Create the snapshot instance
159
+
160
+ > **Note:** The examples below use `@lib/snapshot` — a path alias pointing to `src/lib/snapshot.ts`. All aliases are configured in `tsconfig.app.json` and `vite.config.ts` in the generated scaffold. Available aliases: `@` → `src`, `@lib`, `@components`, `@hooks`, `@api`, `@store`, `@styles`, `@types`. All hooks and primitives flow through `@lib/snapshot`, not through direct package imports.
161
+
162
+ ```ts
163
+ // src/lib/snapshot.ts
164
+ import { createSnapshot } from '@lastshotlabs/snapshot'
165
+
166
+ export const snapshot = createSnapshot({
167
+ apiUrl: import.meta.env.VITE_API_URL,
168
+ loginPath: '/login',
169
+ homePath: '/dashboard',
170
+ })
171
+
172
+ export const {
173
+ // Core auth
174
+ useUser,
175
+ useLogin,
176
+ useLogout,
177
+ useRegister,
178
+ useForgotPassword,
179
+ // Account management
180
+ useSetPassword,
181
+ useDeleteAccount,
182
+ useCancelDeletion,
183
+ useRefreshToken,
184
+ useSessions,
185
+ useRevokeSession,
186
+ useResetPassword,
187
+ useVerifyEmail,
188
+ useResendVerification,
189
+ // OAuth
190
+ getOAuthUrl,
191
+ getLinkUrl,
192
+ useOAuthExchange,
193
+ useOAuthUnlink,
194
+ // MFA (opt-in — only needed if your bunshot backend has MFA enabled)
195
+ useMfaVerify,
196
+ useMfaSetup,
197
+ useMfaVerifySetup,
198
+ useMfaDisable,
199
+ useMfaRecoveryCodes,
200
+ useMfaResend,
201
+ useMfaMethods,
202
+ usePendingMfaChallenge,
203
+ isMfaChallenge,
204
+ // WebAuthn (opt-in — only needed if bunshot has webauthn MFA enabled)
205
+ useWebAuthnRegisterOptions,
206
+ useWebAuthnRegister,
207
+ useWebAuthnCredentials,
208
+ useWebAuthnRemoveCredential,
209
+ useWebAuthnDisable,
210
+ // Passkey login (opt-in — only when allowPasswordlessLogin: true on server)
211
+ usePasskeyLoginOptions,
212
+ usePasskeyLogin,
213
+ // WebSocket
214
+ useSocket,
215
+ useRoom,
216
+ useRoomEvent,
217
+ useWebSocketManager,
218
+ // UI / routing
219
+ useTheme,
220
+ protectedBeforeLoad,
221
+ guestBeforeLoad,
222
+ QueryProvider,
223
+ // Primitives
224
+ api,
225
+ queryClient,
226
+ tokenStorage,
227
+ } = snapshot
228
+ ```
229
+
230
+ ### 2. Set up the router
231
+
232
+ ```ts
233
+ // src/lib/router.ts
234
+ import { createRouter } from '@tanstack/react-router'
235
+ import { routeTree } from '../routeTree.gen'
236
+ import { snapshot } from './snapshot'
237
+
238
+ export const router = createRouter({
239
+ routeTree,
240
+ context: { queryClient: snapshot.queryClient },
241
+ defaultPreload: 'intent',
242
+ defaultPreloadStaleTime: 0,
243
+ scrollRestoration: true,
244
+ })
245
+
246
+ declare module '@tanstack/react-router' {
247
+ interface Register {
248
+ router: typeof router
249
+ }
250
+ }
251
+ ```
252
+
253
+ ### 3. Wire up providers in main.tsx
254
+
255
+ ```tsx
256
+ // src/main.tsx
257
+ import { StrictMode } from 'react'
258
+ import { createRoot } from 'react-dom/client'
259
+ import { RouterProvider } from '@tanstack/react-router'
260
+ import { QueryProvider } from '@lib/snapshot'
261
+ import { router } from '@lib/router'
262
+
263
+ createRoot(document.getElementById('root')!).render(
264
+ <StrictMode>
265
+ <QueryProvider>
266
+ <RouterProvider router={router} />
267
+ </QueryProvider>
268
+ </StrictMode>
269
+ )
270
+ ```
271
+
272
+ ### 4. Set up the root route (manual setup only)
273
+
274
+ > **Using the scaffold?** `__root.tsx`, `_authenticated.tsx`, `_guest.tsx`, and all layout components are pre-generated. Skip to step 5.
275
+
276
+
277
+
278
+ ```tsx
279
+ // src/routes/__root.tsx
280
+ import { createRootRouteWithContext } from '@tanstack/react-router'
281
+ import { HeadProvider } from '@unhead/react'
282
+ import { Outlet } from '@tanstack/react-router'
283
+ import type { QueryClient } from '@tanstack/react-query'
284
+
285
+ function RootDocument() {
286
+ return <Outlet />
287
+ }
288
+
289
+ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
290
+ component: () => (
291
+ <HeadProvider>
292
+ <RootDocument />
293
+ </HeadProvider>
294
+ ),
295
+ })
296
+ ```
297
+
298
+ ### 5. Generate typed API hooks
299
+
300
+ Once your bunshot backend is running, sync its schema into your app:
301
+
302
+ ```bash
303
+ bun run sync
304
+ ```
305
+
306
+ This generates plain async functions in `src/api/`, TanStack Query hooks in `src/hooks/api/`, and updates `src/types/api.ts`. See [API Sync](#api-sync) for details.
307
+
308
+ ---
309
+
310
+ ## Configuration
311
+
312
+ ```ts
313
+ createSnapshot({
314
+ // Required
315
+ apiUrl: 'https://api.example.com',
316
+
317
+ // Auth mode — default: 'cookie' (recommended for browser apps)
318
+ auth: 'cookie', // 'cookie' | 'token'
319
+
320
+ // Static API credential — not a user session token.
321
+ // Do not use in browser deployments. Emits a runtime warning in browser contexts.
322
+ bearerToken: 'my-api-key',
323
+
324
+ // Redirect paths — dev error thrown if missing when a guarded route fires
325
+ loginPath: '/login',
326
+ homePath: '/dashboard',
327
+ forbiddenPath: '/403',
328
+ mfaPath: '/auth/mfa-verify', // redirect when login returns MFA challenge
329
+ mfaSetupPath: '/mfa-setup', // redirect when backend requires MFA setup (403)
330
+
331
+ // Callbacks — fire alongside redirects (analytics, state cleanup, etc.)
332
+ onUnauthenticated: () => console.log('not logged in'),
333
+ onForbidden: () => console.log('access denied'),
334
+
335
+ // Token storage
336
+ tokenStorage: 'sessionStorage', // 'sessionStorage' | 'memory' | 'localStorage' — default: 'sessionStorage' (token mode only)
337
+ tokenKey: 'x-user-token', // default: 'x-user-token'
338
+
339
+ // TanStack Query defaults
340
+ staleTime: 5 * 60 * 1000, // default: 5 minutes
341
+ gcTime: 10 * 60 * 1000, // default: 10 minutes
342
+ retry: 1, // default: 1
343
+
344
+ // WebSocket — entire block optional; WS is disabled if omitted
345
+ ws: {
346
+ url: 'wss://api.example.com/ws',
347
+
348
+ autoReconnect: true, // default: true
349
+ reconnectOnLogin: true, // default: true — reconnects after login succeeds
350
+ reconnectOnFocus: true, // default: true — reconnects when tab regains focus
351
+ maxReconnectAttempts: Infinity, // default: Infinity
352
+ reconnectBaseDelay: 1000, // default: 1000ms
353
+ reconnectMaxDelay: 30000, // default: 30000ms
354
+
355
+ onConnected: () => {},
356
+ onDisconnected: () => {},
357
+ onReconnecting: (attempt) => console.log(`Reconnect attempt ${attempt}`),
358
+ onReconnectFailed: () => console.log('Gave up reconnecting'),
359
+ },
360
+ })
361
+ ```
362
+
363
+ ---
364
+
365
+ ## Security Model
366
+
367
+ Snapshot is the hardened browser client for Bunshot-backed apps. The security contract between snapshot and Bunshot is normative — not optional guidance.
368
+
369
+ ### Default: cookie auth
370
+
371
+ Cookie auth is the default. Browser apps use `HttpOnly` session cookies managed by Bunshot. Tokens are never exposed to JavaScript.
372
+
373
+ The Bunshot browser contract requires:
374
+
375
+ **Session cookie:**
376
+ - `HttpOnly=true` — not readable by JS
377
+ - `Secure=true` in production
378
+ - `SameSite=Lax` minimum
379
+ - `Path=/`
380
+ - No broad `Domain` unless subdomain sharing is intentional
381
+
382
+ **CSRF cookie:**
383
+ - `HttpOnly=false` — must be readable by JS (snapshot reads it to send `x-csrf-token` header)
384
+ - `Secure=true` in production
385
+ - `SameSite=Lax`
386
+ - `Path=/`
387
+ - Rotated on login and logout
388
+
389
+ **CORS:**
390
+ - `Access-Control-Allow-Credentials: true`
391
+ - Exact-match origin allowlist — never `*`
392
+ - `Vary: Origin` on responses with dynamic `Access-Control-Allow-Origin`
393
+ - Allowed headers include `x-csrf-token`
394
+
395
+ **OAuth:**
396
+ - Bunshot validates `state` and completes the provider exchange server-side
397
+ - Session cookie is established during the server-side callback
398
+ - Browser callback page receives only success/error status — no provider code or intermediate exchange code
399
+ - Redirect allowlist (`allowedRedirectUrls`) is required and must be non-empty; unset or empty fails closed
400
+
401
+ **WebSocket:**
402
+ - Auth uses cookies, not query params (query params appear in server logs)
403
+ - CSRF protection is `Origin` header validation on upgrade — exact-match against an allowlist
404
+ - Missing or mismatched Origin is rejected
405
+
406
+ **Transport:**
407
+ - `https:` and `wss:` required in production
408
+ - localhost is the only exception for local development
409
+
410
+ ### Token mode (explicit opt-in)
411
+
412
+ Token mode is available for non-browser clients or unusual browser cases. Set `auth: 'token'` explicitly. It is not the recommended Bunshot web deployment model.
413
+
414
+ - Default storage is `'sessionStorage'` (tab-scoped, not shared across tabs)
415
+ - `'memory'` is available as a stricter opt-in (state lost on page reload)
416
+ - Auth state is not shared across tabs in either storage mode
417
+
418
+ ### Scaffold security profiles
419
+
420
+ The scaffold CLI offers two profiles:
421
+
422
+ **`hardened` (default):** Production-safe defaults. No static credentials in env. In-memory MFA challenge. Passive OAuth callback. No `useOAuthExchange` in exports.
423
+
424
+ **`prototype`:** Local dev ergonomics. Includes `VITE_BEARER_TOKEN` (with warning). Uses legacy OAuth exchange. Includes a startup guard that throws if the app runs on a non-localhost origin unless `VITE_ALLOW_PROTOTYPE_DEPLOYMENT=true` is set.
425
+
426
+ > Prototype mode is for local development only. The startup guard is a safety net, not a deployment strategy.
427
+
428
+ ### `bearerToken`
429
+
430
+ `bearerToken` in `createSnapshot` config is a static API credential — not a user session token. It is intended for machine-to-machine or API gateway auth, not browser user sessions. Using it in a browser context emits a runtime warning in all environments. It is not included in hardened scaffold output.
431
+
432
+ ---
433
+
434
+ ## Auth
435
+
436
+ ### Reading the current user
437
+
438
+ ```tsx
439
+ import { useUser } from '@lib/snapshot'
440
+
441
+ function ProfileBadge() {
442
+ const { user, isLoading, isError } = useUser()
443
+
444
+ if (isLoading) return <Spinner />
445
+ if (!user) return null
446
+ return <span>{user.email}</span>
447
+ }
448
+ ```
449
+
450
+ `useUser` returns `null` (not an error) when the user is not logged in. It caches the `/auth/me` response via TanStack Query.
451
+
452
+ ### Login
453
+
454
+ ```tsx
455
+ import { useLogin } from '@lib/snapshot'
456
+
457
+ function LoginForm() {
458
+ const login = useLogin()
459
+
460
+ async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
461
+ e.preventDefault()
462
+ const data = new FormData(e.currentTarget)
463
+ login.mutate(
464
+ { email: data.get('email') as string, password: data.get('password') as string },
465
+ {
466
+ onSuccess: (user) => console.log('logged in as', user.email),
467
+ onError: (err) => console.error(err.status, err.body),
468
+ }
469
+ )
470
+ }
471
+
472
+ return (
473
+ <form onSubmit={handleSubmit}>
474
+ <input name="email" type="email" />
475
+ <input name="password" type="password" />
476
+ <button disabled={login.isPending}>Login</button>
477
+ {login.isError && <p>{login.error.message}</p>}
478
+ </form>
479
+ )
480
+ }
481
+ ```
482
+
483
+ ### Logout
484
+
485
+ ```tsx
486
+ import { useLogout } from '@lib/snapshot'
487
+
488
+ function LogoutButton() {
489
+ const logout = useLogout()
490
+ return <button onClick={() => logout.mutate()}>Logout</button>
491
+ }
492
+ ```
493
+
494
+ Logout clears the stored token and the entire query cache — no stale user data remains.
495
+
496
+ ### Register
497
+
498
+ ```tsx
499
+ import { useRegister } from '@lib/snapshot'
500
+
501
+ const register = useRegister()
502
+ register.mutate({ email, password })
503
+ ```
504
+
505
+ ### Forgot Password
506
+
507
+ ```tsx
508
+ import { useForgotPassword } from '@lib/snapshot'
509
+
510
+ const forgotPassword = useForgotPassword()
511
+ forgotPassword.mutate({ email })
512
+ ```
513
+
514
+ All auth hooks return TanStack Query mutation results. Use `onSuccess`, `onError`, `onSettled` natively.
515
+
516
+ ### Cookie-based auth
517
+
518
+ Cookie auth is the default and recommended mode for browser apps. Tokens are never exposed to JavaScript, eliminating XSS token theft.
519
+
520
+ ```ts
521
+ export const snapshot = createSnapshot({
522
+ apiUrl: import.meta.env.VITE_API_URL,
523
+ loginPath: '/login',
524
+ homePath: '/dashboard',
525
+ // auth: 'cookie' is the default — no need to set it explicitly
526
+ })
527
+ ```
528
+
529
+ When cookie mode is active:
530
+
531
+ - All requests include `credentials: 'include'` so the browser sends the auth cookie automatically
532
+ - Mutating requests (POST, PUT, PATCH, DELETE) attach the `x-csrf-token` header, read from the `csrf_token` cookie set by bunshot
533
+ - Token storage becomes a no-op — `tokenStorage.get()` returns `null`, `set()` and `clear()` do nothing
534
+ - Login and register responses no longer extract a token from the response body
535
+ - The `bearerToken`, `tokenStorage`, and `tokenKey` config options are ignored
536
+
537
+ > **CORS requirement:** When using cookie auth cross-origin, the bunshot backend must set `Access-Control-Allow-Credentials: true`, `Vary: Origin`, and use an exact-match `Access-Control-Allow-Origin` allowlist (never `*`).
538
+
539
+ ### Token-based auth (explicit opt-in)
540
+
541
+ Token mode is available for non-browser clients or unusual browser cases where cookie auth is not appropriate. It is not the recommended Bunshot web deployment model.
542
+
543
+ ```ts
544
+ export const snapshot = createSnapshot({
545
+ apiUrl: import.meta.env.VITE_API_URL,
546
+ auth: 'token',
547
+ loginPath: '/login',
548
+ homePath: '/dashboard',
549
+ })
550
+ ```
551
+
552
+ Token mode behavior:
553
+
554
+ - The access token is stored client-side and sent as `x-user-token` on every request
555
+ - Default storage is `'sessionStorage'` (tab-scoped — survives page refresh, cleared on tab close, does not share across tabs)
556
+ - `'memory'` is available as a stricter opt-in (state lost on page reload, does not share across tabs)
557
+ - `'localStorage'` is available but not recommended for auth tokens
558
+
559
+ > Token mode is tab-scoped by default. A user logged in on one tab will not be authenticated in a new tab opened from the same browser.
560
+
561
+ ---
562
+
563
+ ## MFA (Multi-Factor Authentication)
564
+
565
+ MFA is fully opt-in. If your bunshot backend has MFA configured, snapshot provides hooks to handle every step of the flow. Apps that don't use MFA see zero changes.
566
+
567
+ ### Login with MFA
568
+
569
+ When a user with MFA enabled logs in, `useLogin` returns an `MfaChallenge` instead of an `AuthUser`. Use `isMfaChallenge` to distinguish:
570
+
571
+ ```tsx
572
+ import { useLogin, isMfaChallenge } from '@lib/snapshot'
573
+
574
+ function LoginPage() {
575
+ const login = useLogin()
576
+
577
+ // If mfaPath is configured, useLogin auto-redirects on MFA challenge.
578
+ // The challenge is stored in memory — read it on the MFA page with usePendingMfaChallenge().
579
+ // For manual handling:
580
+ useEffect(() => {
581
+ if (login.data && isMfaChallenge(login.data)) {
582
+ // login.data.mfaMethods — ['totp', 'emailOtp', etc.]
583
+ // mfaToken is stored internally — use usePendingMfaChallenge() on the MFA page
584
+ }
585
+ }, [login.data])
586
+
587
+ // ... form
588
+ }
589
+ ```
590
+
591
+ If `mfaPath` is set in `createSnapshot` config, the redirect happens automatically — no manual handling needed.
592
+
593
+ ### Verifying MFA during login
594
+
595
+ The MFA challenge is held in memory by the snapshot instance after `useLogin` redirects. Read it on the MFA page with `usePendingMfaChallenge`:
596
+
597
+ ```tsx
598
+ import { useMfaVerify, useMfaResend, usePendingMfaChallenge } from '@lib/snapshot'
599
+ import { Link } from '@tanstack/react-router'
600
+
601
+ function MfaVerifyPage() {
602
+ const pendingChallenge = usePendingMfaChallenge()
603
+ const verify = useMfaVerify()
604
+ const resend = useMfaResend()
605
+
606
+ // Challenge is gone if the user navigated here directly or refreshed the page
607
+ if (!pendingChallenge) {
608
+ return <p>Session expired. <Link to="/auth/login">Sign in again.</Link></p>
609
+ }
610
+
611
+ function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
612
+ e.preventDefault()
613
+ const code = new FormData(e.currentTarget).get('code') as string
614
+ verify.mutate({ code }) // mfaToken is read internally from the pending challenge
615
+ }
616
+
617
+ return (
618
+ <form onSubmit={handleSubmit}>
619
+ <input name="code" inputMode="numeric" maxLength={6} />
620
+ <button disabled={verify.isPending}>Verify</button>
621
+ {verify.isError && <p>{verify.error.message}</p>}
622
+ <button type="button" onClick={() => resend.mutate({ mfaToken: pendingChallenge.mfaToken })}>
623
+ Resend email code
624
+ </button>
625
+ </form>
626
+ )
627
+ }
628
+ ```
629
+
630
+ `useMfaVerify` completes the login — it stores the session (cookie mode) or token (token mode), fetches `/auth/me`, updates the auth cache, clears the pending challenge, and navigates to `homePath`.
631
+
632
+ The pending challenge is automatically cleared on successful verify, logout, and auth reset. If the user refreshes mid-flow, `usePendingMfaChallenge()` returns `null` — show an expired message and link back to login.
633
+
634
+ ### Setting up MFA
635
+
636
+ ```tsx
637
+ import { useMfaSetup, useMfaVerifySetup } from '@lib/snapshot'
638
+
639
+ function MfaSetupPage() {
640
+ const setup = useMfaSetup()
641
+ const verifySetup = useMfaVerifySetup()
642
+
643
+ // Step 1: Generate TOTP secret
644
+ // setup.mutate() → { secret, uri }
645
+
646
+ // Step 2: User scans QR code, enters code
647
+ // verifySetup.mutate({ code }) → { message, recoveryCodes }
648
+
649
+ // Step 3: Display recovery codes
650
+ }
651
+ ```
652
+
653
+ ### Disabling MFA
654
+
655
+ ```tsx
656
+ import { useMfaDisable } from '@lib/snapshot'
657
+
658
+ const disable = useMfaDisable()
659
+ disable.mutate({ code: '123456' }) // requires current TOTP code
660
+ ```
661
+
662
+ ### Recovery codes
663
+
664
+ ```tsx
665
+ import { useMfaRecoveryCodes } from '@lib/snapshot'
666
+
667
+ const regenerate = useMfaRecoveryCodes()
668
+ regenerate.mutate({ code: '123456' }) // requires TOTP code
669
+ // regenerate.data.recoveryCodes — new codes (old ones invalidated)
670
+ ```
671
+
672
+ ### Email OTP
673
+
674
+ ```tsx
675
+ import { useMfaEmailOtpEnable, useMfaEmailOtpVerifySetup, useMfaEmailOtpDisable } from '@lib/snapshot'
676
+
677
+ // Enable: sends verification code to user's email
678
+ const enable = useMfaEmailOtpEnable()
679
+ enable.mutate() // → { message, setupToken }
680
+
681
+ // Verify: confirm with the code from email
682
+ const verifySetup = useMfaEmailOtpVerifySetup()
683
+ verifySetup.mutate({ setupToken: enable.data.setupToken, code: '123456' })
684
+
685
+ // Disable
686
+ const disable = useMfaEmailOtpDisable()
687
+ disable.mutate({ code: '123456' }) // TOTP code if TOTP enabled, or { password } if only method
688
+ ```
689
+
690
+ ### Checking enabled MFA methods
691
+
692
+ ```tsx
693
+ import { useMfaMethods } from '@lib/snapshot'
694
+
695
+ function SecuritySettings() {
696
+ const { methods, isLoading } = useMfaMethods()
697
+ // methods: ['totp', 'emailOtp'] | null
698
+ }
699
+ ```
700
+
701
+ ### MFA setup required (forced enrollment)
702
+
703
+ When bunshot is configured with `mfa.required: true`, authenticated users without MFA receive a `403` with code `MFA_SETUP_REQUIRED` on any API call. If `mfaSetupPath` is set in `createSnapshot`, snapshot automatically redirects to that page.
704
+
705
+ ```ts
706
+ createSnapshot({
707
+ apiUrl: import.meta.env.VITE_API_URL,
708
+ mfaSetupPath: '/mfa-setup', // auto-redirect on MFA_SETUP_REQUIRED
709
+ })
710
+ ```
711
+
712
+ ---
713
+
714
+ ## Account Management
715
+
716
+ ```tsx
717
+ import {
718
+ useSetPassword, useDeleteAccount, useCancelDeletion, useRefreshToken,
719
+ useSessions, useRevokeSession,
720
+ useResetPassword, useVerifyEmail, useResendVerification,
721
+ } from '@lib/snapshot'
722
+
723
+ // Set or change password
724
+ const setPassword = useSetPassword()
725
+ setPassword.mutate({ password: 'new-pass' })
726
+ setPassword.mutate({ password: 'new-pass', currentPassword: 'old-pass' })
727
+
728
+ // Delete account — clears token, flushes query cache, navigates to loginPath
729
+ const deleteAccount = useDeleteAccount()
730
+ deleteAccount.mutate() // OAuth-only accounts (no password)
731
+ deleteAccount.mutate({ password: '…' }) // credential accounts
732
+
733
+ // Cancel a queued deletion (within the grace period configured on the backend)
734
+ const cancelDeletion = useCancelDeletion()
735
+ cancelDeletion.mutate()
736
+
737
+ // Manually refresh the access token
738
+ const refresh = useRefreshToken()
739
+ refresh.mutate() // uses cookie or stored refresh token
740
+ refresh.mutate({ refreshToken: '…' }) // explicit token
741
+
742
+ // List active sessions
743
+ const { sessions, isLoading } = useSessions()
744
+ // sessions: Session[] — { sessionId, createdAt, lastActiveAt, expiresAt, ipAddress?, userAgent?, isActive }
745
+
746
+ // Revoke a session (sign out of another device)
747
+ const revokeSession = useRevokeSession()
748
+ revokeSession.mutate(session.sessionId)
749
+
750
+ // Password reset flow (token from email link)
751
+ const resetPassword = useResetPassword()
752
+ resetPassword.mutate({ token, password })
753
+
754
+ // Email verification flow (token from email link)
755
+ const verifyEmail = useVerifyEmail()
756
+ verifyEmail.mutate({ token })
757
+
758
+ const resendVerification = useResendVerification()
759
+ resendVerification.mutate({ email })
760
+ ```
761
+
762
+ ---
763
+
764
+ ## OAuth
765
+
766
+ OAuth initiation is a simple redirect — no hook needed. Call `getOAuthUrl` and navigate:
767
+
768
+ ```ts
769
+ import { getOAuthUrl, getLinkUrl } from '@lib/snapshot'
770
+
771
+ // Redirect to OAuth provider sign-in
772
+ window.location.href = getOAuthUrl('google') // → {apiUrl}/auth/google
773
+
774
+ // Link an additional OAuth provider to an existing account
775
+ window.location.href = getLinkUrl('github') // → {apiUrl}/auth/github/link
776
+
777
+ // Supported providers: 'google' | 'apple' | 'microsoft' | 'github'
778
+ ```
779
+
780
+ After the OAuth flow completes, the provider redirects back to your callback page. In the default (hardened) browser flow, Bunshot establishes the session cookie server-side during the OAuth callback and redirects back with only a success or error indicator — no code exchange is needed in the browser:
781
+
782
+ ```tsx
783
+ // Hardened OAuth callback — passive, no exchange step
784
+ import { useEffect } from 'react'
785
+ import { useNavigate } from '@tanstack/react-router'
786
+ import { useUser, queryClient } from '@lib/snapshot'
787
+
788
+ function OAuthCallbackPage() {
789
+ const { error } = Route.useSearch() // only { success?, error? } in search params
790
+ const { user } = useUser()
791
+ const navigate = useNavigate()
792
+
793
+ useEffect(() => {
794
+ if (!error) queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
795
+ }, [])
796
+
797
+ useEffect(() => {
798
+ if (user) navigate({ to: '/' })
799
+ }, [user])
800
+
801
+ if (error) return <p>Sign in failed: {error}</p>
802
+ return <p>Signing in...</p>
803
+ }
804
+ ```
805
+
806
+ The scaffolded `OAuthCallbackPage` is generated this way automatically. No `useOAuthExchange` call — the session is already established by the time the browser lands on this page.
807
+
808
+ **Legacy exchange (prototype scaffold only):**
809
+
810
+ `useOAuthExchange` is available for compatibility with non-browser or prototype flows where Bunshot's one-time code pattern is used client-side:
811
+
812
+ ```ts
813
+ // @deprecated — use the hardened cookie flow above for browser apps
814
+ import { useOAuthExchange } from '@lib/snapshot'
815
+
816
+ const exchange = useOAuthExchange()
817
+ exchange.mutate({ code })
818
+ ```
819
+
820
+ > `useOAuthExchange` will be removed in the next major version. It is not included in hardened scaffold output.
821
+
822
+ Unlink a connected provider:
823
+
824
+ ```ts
825
+ import { useOAuthUnlink } from '@lib/snapshot'
826
+
827
+ const unlink = useOAuthUnlink()
828
+ unlink.mutate('google') // invalidates /auth/me cache on success
829
+ ```
830
+
831
+ ---
832
+
833
+ ## WebAuthn
834
+
835
+ WebAuthn registration requires `@simplewebauthn/browser` on the client side to call the browser's credential APIs. snapshot provides the hooks; you wire them to the browser API.
836
+
837
+ ```ts
838
+ import {
839
+ useWebAuthnRegisterOptions, useWebAuthnRegister,
840
+ useWebAuthnCredentials, useWebAuthnRemoveCredential, useWebAuthnDisable,
841
+ } from '@lib/snapshot'
842
+ import { startRegistration } from '@simplewebauthn/browser'
843
+
844
+ // Registration flow
845
+ function useRegisterSecurityKey(name?: string) {
846
+ const getOptions = useWebAuthnRegisterOptions()
847
+ const register = useWebAuthnRegister()
848
+
849
+ async function registerKey() {
850
+ // Step 1: get challenge from server
851
+ const { options, registrationToken } = await getOptions.mutateAsync()
852
+
853
+ // Step 2: browser prompts user to tap security key / use Touch ID
854
+ const attestationResponse = await startRegistration(options)
855
+
856
+ // Step 3: send result back to server
857
+ register.mutate({ registrationToken, attestationResponse, name })
858
+ }
859
+
860
+ return { registerKey, isPending: getOptions.isPending || register.isPending }
861
+ }
862
+
863
+ // List registered credentials
864
+ const { credentials, isLoading } = useWebAuthnCredentials()
865
+ // credentials: { credentialId, name?, createdAt, transports? }[]
866
+
867
+ // Remove a specific credential
868
+ const remove = useWebAuthnRemoveCredential()
869
+ remove.mutate(credentialId)
870
+
871
+ // Disable WebAuthn entirely
872
+ const disable = useWebAuthnDisable()
873
+ disable.mutate()
874
+ ```
875
+
876
+ ---
877
+
878
+ ## Passkey Login
879
+
880
+ Passkeys (Windows Hello, Face ID, Touch ID) as a **passwordless first-factor** — no password, no MFA prompt. Requires bunshot `mfa.webauthn.allowPasswordlessLogin: true` on the server.
881
+
882
+ ```ts
883
+ import {
884
+ usePasskeyLoginOptions, usePasskeyLogin,
885
+ isMfaChallenge,
886
+ } from '@lib/snapshot'
887
+ import { startAuthentication } from '@simplewebauthn/browser'
888
+
889
+ function usePasskeySignIn() {
890
+ const getOptions = usePasskeyLoginOptions()
891
+ const login = usePasskeyLogin()
892
+
893
+ async function signInWithPasskey(email?: string) {
894
+ // Step 1 — get challenge (enumeration-safe: safe to pass unknown email)
895
+ const { options, passkeyToken } = await getOptions.mutateAsync({ email })
896
+
897
+ // Step 2 — OS prompt (Windows Hello / Face ID / Touch ID)
898
+ // Throws NotAllowedError if user cancels — catch it and fall back to password
899
+ const assertionResponse = await startAuthentication(options)
900
+
901
+ // Step 3 — verify server-side; hook stores token + navigates on success
902
+ const result = await login.mutateAsync({ passkeyToken, assertionResponse })
903
+
904
+ // isMfaChallenge only when server has passkeyMfaBypass: false
905
+ if (isMfaChallenge(result)) {
906
+ // redirect to MFA page with result.mfaToken
907
+ }
908
+ }
909
+
910
+ return {
911
+ signInWithPasskey,
912
+ isPending: getOptions.isPending || login.isPending,
913
+ error: login.error,
914
+ }
915
+ }
916
+ ```
917
+
918
+ #### Handling cancellation and retries
919
+
920
+ ```ts
921
+ async function handlePasskeyLogin(email?: string) {
922
+ // Check browser support first — hide button if unsupported
923
+ if (!window.PublicKeyCredential) return
924
+
925
+ try {
926
+ const { options, passkeyToken } = await getOptions.mutateAsync({ email })
927
+ const assertionResponse = await startAuthentication(options)
928
+ await login.mutateAsync({ passkeyToken, assertionResponse })
929
+ } catch (err: any) {
930
+ if (err.name === 'NotAllowedError') {
931
+ // User cancelled the OS prompt — not an error, just fall back to password
932
+ return
933
+ }
934
+ // Network error or token expiry (410 / challenge-not-found) — retry once with fresh challenge
935
+ if (err.status === 410 || err.name === 'NetworkError') {
936
+ const { options: freshOptions, passkeyToken: freshToken } = await getOptions.mutateAsync({ email })
937
+ const assertionResponse = await startAuthentication(freshOptions)
938
+ await login.mutateAsync({ passkeyToken: freshToken, assertionResponse })
939
+ return
940
+ }
941
+ // 401 authentication failure — surface to user, do not retry
942
+ throw err
943
+ }
944
+ }
945
+ ```
946
+
947
+ `usePasskeyLogin` stores the session token and navigates to `homePath` on success, identical to `useLogin`.
948
+
949
+ ---
950
+
951
+ ## Route Guards
952
+
953
+ Assign `protectedBeforeLoad` and `guestBeforeLoad` in your route files:
954
+
955
+ ```ts
956
+ // src/routes/dashboard.tsx — authenticated users only
957
+ import { createFileRoute } from '@tanstack/react-router'
958
+ import { protectedBeforeLoad } from '@lib/snapshot'
959
+
960
+ export const Route = createFileRoute('/dashboard')({
961
+ beforeLoad: protectedBeforeLoad,
962
+ component: DashboardPage,
963
+ })
964
+ ```
965
+
966
+ ```ts
967
+ // src/routes/login.tsx — redirect to home if already logged in
968
+ import { createFileRoute } from '@tanstack/react-router'
969
+ import { guestBeforeLoad } from '@lib/snapshot'
970
+
971
+ export const Route = createFileRoute('/login')({
972
+ beforeLoad: guestBeforeLoad,
973
+ component: LoginPage,
974
+ })
975
+ ```
976
+
977
+ Both guards fetch `/auth/me` via the router context's `queryClient` (configured in step 2). TanStack Query serves from cache if the result is fresh.
978
+
979
+ ---
980
+
981
+ ## API Client
982
+
983
+ The `api` primitive gives direct access to the HTTP client — useful outside React (Jotai atoms, event handlers, utilities):
984
+
985
+ ```ts
986
+ import { api } from '@lib/snapshot'
987
+
988
+ // Typed response
989
+ const user = await api.get<User>('/users/123')
990
+
991
+ // With body
992
+ const post = await api.post<Post>('/posts', { title: 'Hello', body: '...' })
993
+
994
+ // With custom headers
995
+ const data = await api.get<Data>('/protected', {
996
+ headers: { 'x-custom-header': 'value' },
997
+ })
998
+
999
+ // With abort signal
1000
+ const controller = new AbortController()
1001
+ const data = await api.get<Data>('/slow-endpoint', { signal: controller.signal })
1002
+ ```
1003
+
1004
+ Available methods: `get`, `post`, `put`, `patch`, `delete` — all return `Promise<T>`.
1005
+
1006
+ ### Error handling
1007
+
1008
+ Non-2xx responses throw `ApiError`:
1009
+
1010
+ ```ts
1011
+ import { ApiError } from '@lastshotlabs/snapshot'
1012
+
1013
+ try {
1014
+ await api.post('/posts', body)
1015
+ } catch (err) {
1016
+ if (err instanceof ApiError) {
1017
+ console.log(err.status) // HTTP status code
1018
+ console.log(err.body) // parsed JSON response body
1019
+ console.log(err.message) // "HTTP 422"
1020
+ }
1021
+ }
1022
+ ```
1023
+
1024
+ In TanStack Query mutations, errors are typed automatically when you annotate the mutation:
1025
+
1026
+ ```ts
1027
+ const mutation = useMutation<Post, ApiError, CreatePostBody>({
1028
+ mutationFn: (body) => api.post('/posts', body),
1029
+ })
1030
+ ```
1031
+
1032
+ > `ApiError` is the one thing imported directly from the package. Everything else (`api`, `useUser`, etc.) comes from `@lib/snapshot`.
1033
+
1034
+ ---
1035
+
1036
+ ## WebSocket
1037
+
1038
+ ### Basic usage
1039
+
1040
+ ```tsx
1041
+ import { useSocket } from '@lib/snapshot'
1042
+
1043
+ function StatusIndicator() {
1044
+ const socket = useSocket()
1045
+ return <span>{socket.isConnected ? 'Live' : 'Offline'}</span>
1046
+ }
1047
+ ```
1048
+
1049
+ `useSocket()` returns a `SocketHook` with:
1050
+ - `isConnected: boolean`
1051
+ - `send(type, payload)` — send a message to the server
1052
+ - `on(event, handler)` / `off(event, handler)` — raw event listeners
1053
+ - `subscribe(room)` / `unsubscribe(room)` — available but prefer `useRoom` / `useRoomEvent`, which handle cleanup and auto-resubscription on reconnect
1054
+ - `reconnect()` — manual reconnect trigger
1055
+
1056
+ If `ws` is not configured in `createSnapshot`, `useSocket()` is a no-op: `isConnected` is always `false` and all methods are safe to call (they do nothing).
1057
+
1058
+ ### Typed events
1059
+
1060
+ ```ts
1061
+ // src/types/ws.ts
1062
+ export interface WebSocketEvents {
1063
+ 'chat:message': { roomId: string; content: string; author: string }
1064
+ 'presence:update': { roomId: string; members: string[] }
1065
+ 'notification': { id: string; text: string }
1066
+ }
1067
+
1068
+ // src/lib/snapshot.ts
1069
+ export const snapshot = createSnapshot<WebSocketEvents>({ ... })
1070
+ ```
1071
+
1072
+ With the type parameter, `useSocket<WebSocketEvents>()` is fully typed.
1073
+
1074
+ ### Room hooks
1075
+
1076
+ ```tsx
1077
+ import { useRoom, useRoomEvent } from '@lib/snapshot'
1078
+
1079
+ function ChatRoom({ roomId }: { roomId: string }) {
1080
+ const { isSubscribed } = useRoom(`chat:${roomId}`)
1081
+ const [messages, setMessages] = useState<ChatMessage[]>([])
1082
+
1083
+ useRoomEvent(`chat:${roomId}`, 'chat:message', (msg) => {
1084
+ setMessages(prev => [...prev, msg])
1085
+ })
1086
+
1087
+ if (!isSubscribed) return <Spinner />
1088
+ return <MessageList messages={messages} />
1089
+ }
1090
+ ```
1091
+
1092
+ `useRoom` subscribes on mount and unsubscribes on unmount. The WebSocket manager automatically re-subscribes to all rooms after reconnect — no manual handling needed.
1093
+
1094
+ `useRoomEvent` is scoped — the handler only fires when the event name matches AND the message was received from the specified room. Events from other rooms with the same name are ignored.
1095
+
1096
+ ### Building custom hooks
1097
+
1098
+ Use `useWebSocketManager` for direct access to the `WebSocketManager` instance:
1099
+
1100
+ ```ts
1101
+ import { useWebSocketManager } from '@lib/snapshot'
1102
+ import { useState, useEffect } from 'react'
1103
+
1104
+ export function usePresence(roomId: string) {
1105
+ const manager = useWebSocketManager()
1106
+ const [members, setMembers] = useState<string[]>([])
1107
+
1108
+ useEffect(() => {
1109
+ if (!manager) return
1110
+ manager.subscribe(`presence:${roomId}`)
1111
+ const handler = (data: { roomId: string; members: string[] }) => {
1112
+ if (data.roomId === roomId) setMembers(data.members)
1113
+ }
1114
+ manager.on('presence:update', handler)
1115
+ return () => {
1116
+ manager.unsubscribe(`presence:${roomId}`)
1117
+ manager.off('presence:update', handler)
1118
+ }
1119
+ }, [roomId, manager])
1120
+
1121
+ return members
1122
+ }
1123
+ ```
1124
+
1125
+ ### WebSocket auth
1126
+
1127
+ The browser sends the auth cookie automatically on the WebSocket upgrade request — no token in query params (which appear in server logs). After login, snapshot automatically reconnects the WebSocket so the new connection carries the authenticated cookie (when `reconnectOnLogin: true`, which is the default).
1128
+
1129
+ ---
1130
+
1131
+ ## Theme
1132
+
1133
+ ```tsx
1134
+ import { useTheme } from '@lib/snapshot'
1135
+
1136
+ function ThemeToggle() {
1137
+ const { theme, toggle } = useTheme()
1138
+ return (
1139
+ <button onClick={toggle}>
1140
+ {theme === 'dark' ? 'Light mode' : 'Dark mode'}
1141
+ </button>
1142
+ )
1143
+ }
1144
+ ```
1145
+
1146
+ `useTheme` returns:
1147
+ - `theme: 'light' | 'dark'`
1148
+ - `toggle()` — switches between light and dark
1149
+ - `set(t: 'light' | 'dark')` — set explicitly
1150
+
1151
+ Theme is persisted in `localStorage` under the key `snapshot-theme`. The `dark` class is automatically applied to `document.documentElement` (compatible with Tailwind v4's `dark:` variant).
1152
+
1153
+ On first load, the theme defaults to the user's OS preference (`prefers-color-scheme`).
1154
+
1155
+ ---
1156
+
1157
+ ## Token Storage
1158
+
1159
+ Access the token storage directly for custom auth flows:
1160
+
1161
+ ```ts
1162
+ import { tokenStorage } from '@lib/snapshot'
1163
+
1164
+ tokenStorage.get() // returns string | null
1165
+ tokenStorage.set('token') // stores a token
1166
+ tokenStorage.clear() // removes the token
1167
+ ```
1168
+
1169
+ ### Building custom auth hooks
1170
+
1171
+ ```ts
1172
+ import { api, tokenStorage } from '@lib/snapshot'
1173
+ import { useMutation } from '@tanstack/react-query'
1174
+
1175
+ export function useImpersonate() {
1176
+ return useMutation({
1177
+ mutationFn: (userId: string) =>
1178
+ api.post<{ token: string }>('/admin/impersonate', { userId }),
1179
+ onSuccess: ({ token }) => tokenStorage.set(token),
1180
+ })
1181
+ }
1182
+ ```
1183
+
1184
+ ---
1185
+
1186
+ ## Composition Patterns
1187
+
1188
+ All hooks and primitives returned by `createSnapshot` are designed for composition. Apps build domain hooks from them — no reimplementing, no copying package internals.
1189
+
1190
+ ### Custom API calls with Jotai
1191
+
1192
+ ```ts
1193
+ // src/store/products.ts
1194
+ import { atom } from 'jotai'
1195
+ import { api } from '@lib/snapshot'
1196
+ import type { Product } from '@/types/api'
1197
+
1198
+ const selectedIdAtom = atom<string | null>(null)
1199
+
1200
+ // Works outside React — no hooks required
1201
+ export const selectedProductAtom = atom(async (get) => {
1202
+ const id = get(selectedIdAtom)
1203
+ if (!id) return null
1204
+ return api.get<Product>(`/products/${id}`)
1205
+ })
1206
+ ```
1207
+
1208
+ ### Custom query hooks
1209
+
1210
+ ```ts
1211
+ // src/hooks/useProducts.ts
1212
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
1213
+ import { api } from '@lib/snapshot'
1214
+ import type { Product, CreateProductBody } from '@/types/api'
1215
+
1216
+ export function useProducts() {
1217
+ return useQuery({
1218
+ queryKey: ['products'],
1219
+ queryFn: () => api.get<Product[]>('/products'),
1220
+ })
1221
+ }
1222
+
1223
+ export function useCreateProduct() {
1224
+ const queryClient = useQueryClient()
1225
+ return useMutation({
1226
+ mutationFn: (body: CreateProductBody) => api.post<Product>('/products', body),
1227
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['products'] }),
1228
+ })
1229
+ }
1230
+ ```
1231
+
1232
+ ---
1233
+
1234
+ ## Instance Shape
1235
+
1236
+ `createSnapshot` returns a `SnapshotInstance<TWSEvents>` with:
1237
+
1238
+ | Property | Type | Description |
1239
+ |---|---|---|
1240
+ | `useUser` | Hook | Current auth user, loading, error state |
1241
+ | `useLogin` | Hook | Login mutation |
1242
+ | `useLogout` | Hook | Logout mutation |
1243
+ | `useRegister` | Hook | Register mutation |
1244
+ | `useForgotPassword` | Hook | Forgot password mutation |
1245
+ | `useSocket` | Hook | WebSocket connection and messaging |
1246
+ | `useRoom` | Hook | Subscribe to a named room |
1247
+ | `useRoomEvent` | Hook | Listen to events in a named room |
1248
+ | `useTheme` | Hook | Light/dark theme toggle |
1249
+ | `useMfaVerify` | Hook | Complete MFA login with code |
1250
+ | `useMfaSetup` | Hook | Generate TOTP secret + QR URI |
1251
+ | `useMfaVerifySetup` | Hook | Confirm TOTP setup, get recovery codes |
1252
+ | `useMfaDisable` | Hook | Disable MFA (requires TOTP code) |
1253
+ | `useMfaRecoveryCodes` | Hook | Regenerate recovery codes |
1254
+ | `useMfaEmailOtpEnable` | Hook | Initiate email OTP setup |
1255
+ | `useMfaEmailOtpVerifySetup` | Hook | Confirm email OTP setup |
1256
+ | `useMfaEmailOtpDisable` | Hook | Disable email OTP method |
1257
+ | `useMfaResend` | Hook | Resend email OTP code during login |
1258
+ | `useMfaMethods` | Hook | Query enabled MFA methods |
1259
+ | `isMfaChallenge` | Utility | Type guard for `LoginResult` |
1260
+ | `usePendingMfaChallenge` | Hook | Read the in-memory MFA challenge on the MFA verify page |
1261
+ | `useSetPassword` | Hook | Set or change account password |
1262
+ | `useDeleteAccount` | Hook | Delete account with full session teardown |
1263
+ | `useCancelDeletion` | Hook | Cancel a queued account deletion |
1264
+ | `useRefreshToken` | Hook | Exchange refresh token for new access token |
1265
+ | `useSessions` | Hook | List active sessions for current user |
1266
+ | `useRevokeSession` | Hook | Revoke a session by ID |
1267
+ | `useResetPassword` | Hook | Reset password using email token |
1268
+ | `useVerifyEmail` | Hook | Verify email address using token |
1269
+ | `useResendVerification` | Hook | Resend email verification link |
1270
+ | `getOAuthUrl` | Utility | Get OAuth provider redirect URL |
1271
+ | `getLinkUrl` | Utility | Get OAuth account-linking redirect URL |
1272
+ | `useOAuthExchange` | Hook | **Legacy.** Exchange OAuth one-time code for session. Not used in hardened browser flow. Removed in next major version. |
1273
+ | `useOAuthUnlink` | Hook | Unlink an OAuth provider |
1274
+ | `useWebAuthnRegisterOptions` | Hook | Get WebAuthn registration challenge |
1275
+ | `useWebAuthnRegister` | Hook | Complete WebAuthn credential registration |
1276
+ | `useWebAuthnCredentials` | Hook | List registered WebAuthn credentials |
1277
+ | `useWebAuthnRemoveCredential` | Hook | Remove a WebAuthn credential |
1278
+ | `useWebAuthnDisable` | Hook | Disable WebAuthn MFA method |
1279
+ | `usePasskeyLoginOptions` | Hook | Get passkey login challenge options (passkeyPages: true) |
1280
+ | `usePasskeyLogin` | Hook | Complete passwordless passkey login (passkeyPages: true) |
1281
+ | `api` | Primitive | `ApiClient` — typed HTTP methods |
1282
+ | `tokenStorage` | Primitive | Read/write/clear auth token |
1283
+ | `queryClient` | Primitive | Stable `QueryClient` singleton |
1284
+ | `useWebSocketManager` | Hook | Raw `WebSocketManager` instance |
1285
+ | `protectedBeforeLoad` | Loader | Redirect unauthenticated users |
1286
+ | `guestBeforeLoad` | Loader | Redirect authenticated users |
1287
+ | `QueryProvider` | Component | Pre-bound `QueryClientProvider` |
1288
+
1289
+ ---
1290
+
1291
+ ## Peer Dependencies
1292
+
1293
+ | Package | Required Version |
1294
+ |---|---|
1295
+ | `react` | `>=19.0.0` |
1296
+ | `react-dom` | `>=19.0.0` |
1297
+ | `@tanstack/react-router` | `>=1.0.0` |
1298
+ | `@tanstack/react-query` | `>=5.0.0` |
1299
+ | `jotai` | `>=2.0.0` |
1300
+ | `@unhead/react` | `>=2.0.0` |
1301
+ | `vite` | `>=5.0.0` · optional — only required for the Vite plugin |
1302
+ | `zod` | `^3.0.0` · optional — only required for `--zod` flag |
1303
+
1304
+ ---
1305
+
1306
+ ## API Sync
1307
+
1308
+ Generate fully-typed TanStack Query hooks directly from your bunshot backend's OpenAPI schema:
1309
+
1310
+ ```bash
1311
+ bun run sync # reads VITE_API_URL from .env
1312
+ bunx snapshot sync --api http://localhost:3000 # explicit URL
1313
+ bunx snapshot sync --file ./openapi.json # local file
1314
+ bunx snapshot sync --watch # re-run automatically on schema change
1315
+ bunx snapshot sync --zod # also generate Zod validators
1316
+ ```
1317
+
1318
+ Run it any time your backend routes or types change. It only touches generated files and is safe to re-run as many times as needed.
1319
+
1320
+ ### URL resolution
1321
+
1322
+ The command resolves the API URL in this order:
1323
+
1324
+ 1. `--api <url>` flag
1325
+ 2. `VITE_API_URL` environment variable (bun loads `.env` automatically for `bun run sync`)
1326
+ 3. `VITE_API_URL` in the `.env` file in the current directory
1327
+
1328
+ The schema is fetched from `{apiUrl}/openapi.json`. Use `--file` to skip the network and read directly from a local file.
1329
+
1330
+ ### What gets generated
1331
+
1332
+ **`src/types/api.ts`** — TypeScript types for every schema in `components.schemas`:
1333
+
1334
+ ```ts
1335
+ // Generated by bunx snapshot sync. Do not edit manually.
1336
+
1337
+ export interface User {
1338
+ id: string
1339
+ email: string
1340
+ createdAt: string
1341
+ }
1342
+
1343
+ export type UserRole = 'admin' | 'member' | 'guest'
1344
+ ```
1345
+
1346
+ **`src/api/{tag}.ts`** — one file per OpenAPI tag containing plain async functions. No React dependencies — callable anywhere (Jotai atoms, event handlers, utilities).
1347
+
1348
+ ```ts
1349
+ // Generated by bunx snapshot sync. Do not edit manually.
1350
+
1351
+ import { api } from '@lib/snapshot'
1352
+ import type { User, CreateUserBody } from '../types/api'
1353
+
1354
+ /** List all users */
1355
+ export const listUsers = (): Promise<User[]> =>
1356
+ api.get<User[]>('/users')
1357
+
1358
+ /** Get user by ID */
1359
+ export const getUser = (id: string): Promise<User> =>
1360
+ api.get<User>(`/users/${id}`)
1361
+
1362
+ /** Create a user */
1363
+ export const createUser = (body: CreateUserBody): Promise<User> =>
1364
+ api.post<User>('/users', body)
1365
+ ```
1366
+
1367
+ **`src/hooks/api/{tag}.ts`** — one file per OpenAPI tag containing TanStack Query hooks. Imports plain functions from `../../api/{tag}`.
1368
+
1369
+ ```ts
1370
+ // Generated by bunx snapshot sync. Do not edit manually.
1371
+
1372
+ import { useQuery, useMutation, useQueryClient, type UseQueryOptions, type UseMutationOptions, type QueryKey } from '@tanstack/react-query'
1373
+ import { ApiError } from '@lastshotlabs/snapshot'
1374
+ import { listUsers, getUser, createUser } from '../../api/users'
1375
+ import type { User, CreateUserBody } from '../../types/api'
1376
+
1377
+ /** List all users */
1378
+ export function useListUsersQuery(
1379
+ options?: Omit<UseQueryOptions<User[], ApiError>, 'queryKey' | 'queryFn'>
1380
+ ) {
1381
+ return useQuery({
1382
+ queryKey: ['users'],
1383
+ queryFn: listUsers,
1384
+ ...options,
1385
+ })
1386
+ }
1387
+
1388
+ /** Get user by ID */
1389
+ export function useGetUserQuery(
1390
+ params: { id: string },
1391
+ options?: Omit<UseQueryOptions<User, ApiError>, 'queryKey' | 'queryFn'>
1392
+ ) {
1393
+ return useQuery({
1394
+ queryKey: ['users', params.id],
1395
+ queryFn: () => getUser(params.id),
1396
+ ...options,
1397
+ })
1398
+ }
1399
+
1400
+ /** Create a user */
1401
+ export function useCreateUserMutation(
1402
+ options?: UseMutationOptions<User, ApiError, CreateUserBody> & { invalidateKeys?: QueryKey[] }
1403
+ ) {
1404
+ const { invalidateKeys, ...mutationOptions } = options ?? {}
1405
+ const queryClient = useQueryClient()
1406
+ return useMutation({
1407
+ mutationFn: createUser,
1408
+ ...mutationOptions,
1409
+ onSuccess: (...args) => {
1410
+ invalidateKeys?.forEach((key) => queryClient.invalidateQueries({ queryKey: key }))
1411
+ mutationOptions.onSuccess?.(...args)
1412
+ },
1413
+ })
1414
+ }
1415
+ ```
1416
+
1417
+ GET operations become `useQuery` hooks. All other methods become `useMutation` hooks. Operations with an `operationId` get clean names (`listUsers` → `useListUsersQuery`). Operations without one fall back to method + path segments. Deprecated operations have `/** @deprecated */` added to both exports.
1418
+
1419
+ For mutations that include path parameters (e.g. `PUT /users/{id}`), the mutation variables combine path params and body into a single object:
1420
+
1421
+ ```ts
1422
+ // Generated for PUT /users/{id}
1423
+ export const updateUser = (id: string, body: UpdateUserBody): Promise<User> =>
1424
+ api.put<User>(`/users/${id}`, body)
1425
+
1426
+ export function useUpdateUserMutation(
1427
+ options?: UseMutationOptions<User, ApiError, { id: string; body: UpdateUserBody }> & { invalidateKeys?: QueryKey[] }
1428
+ ) {
1429
+ const { invalidateKeys, ...mutationOptions } = options ?? {}
1430
+ const queryClient = useQueryClient()
1431
+ return useMutation({
1432
+ mutationFn: (vars) => updateUser(vars.id, vars.body),
1433
+ ...mutationOptions,
1434
+ onSuccess: (...args) => {
1435
+ invalidateKeys?.forEach((key) => queryClient.invalidateQueries({ queryKey: key }))
1436
+ mutationOptions.onSuccess?.(...args)
1437
+ },
1438
+ })
1439
+ }
1440
+ ```
1441
+
1442
+ Usage:
1443
+
1444
+ ```ts
1445
+ const update = useUpdateUserMutation()
1446
+ update.mutate({ id: user.id, body: { email: 'new@example.com' } })
1447
+ ```
1448
+
1449
+ ### Mutation hook options
1450
+
1451
+ Generated mutation hooks accept the full `UseMutationOptions` type (no restrictions), plus an `invalidateKeys` extension:
1452
+
1453
+ **Override `mutationFn` to inject context** (e.g. an `accountId` from a parent component or store):
1454
+
1455
+ ```ts
1456
+ const { accountId } = useAccount()
1457
+
1458
+ const create = useCreateUserMutation({
1459
+ mutationFn: (body) => createUser({ ...body, accountId }),
1460
+ })
1461
+ ```
1462
+
1463
+ **Auto-invalidate queries on success:**
1464
+
1465
+ ```ts
1466
+ const create = useCreateUserMutation({
1467
+ invalidateKeys: [['users'], ['stats']],
1468
+ })
1469
+ ```
1470
+
1471
+ **Both together:**
1472
+
1473
+ ```ts
1474
+ const create = useCreateUserMutation({
1475
+ mutationFn: (body) => createUser({ ...body, accountId }),
1476
+ invalidateKeys: [['users', accountId]],
1477
+ onSuccess: () => toast.success('User created'),
1478
+ })
1479
+ ```
1480
+
1481
+ `invalidateKeys` runs before `onSuccess` — by the time your callback fires, the relevant queries are already invalidated.
1482
+
1483
+ ### Paginated endpoints
1484
+
1485
+ When an endpoint returns a bunshot pagination envelope (`{ data: T[], total: number, page: number, perPage: number }`), sync detects it automatically and generates a paginated hook:
1486
+
1487
+ ```ts
1488
+ // Generated for GET /users (paginated response)
1489
+ export const listUsers = (page = 1, perPage = 20): Promise<PaginatedResponse<User>> =>
1490
+ api.get<PaginatedResponse<User>>(`/users?page=${page}&perPage=${perPage}`)
1491
+
1492
+ export function useListUsersQuery(
1493
+ params: { page?: number; perPage?: number } = {},
1494
+ options?: Omit<UseQueryOptions<PaginatedResponse<User>, ApiError>, 'queryKey' | 'queryFn'>
1495
+ ) {
1496
+ return useQuery({
1497
+ queryKey: ['users', params.page ?? 1, params.perPage ?? 20],
1498
+ queryFn: () => listUsers(params.page ?? 1, params.perPage ?? 20),
1499
+ ...options,
1500
+ })
1501
+ }
1502
+ ```
1503
+
1504
+ The `page` and `perPage` values are included in the `queryKey` so TanStack Query caches each page separately. `PaginatedResponse<T>` is exported from `src/types/api.ts`.
1505
+
1506
+ ### Zod form schemas (`--zod`)
1507
+
1508
+ Pass `--zod` to also generate Zod validators for mutation request bodies. These are placed alongside each mutation hook and are ready to use with `react-hook-form` or any Zod-compatible form library:
1509
+
1510
+ ```ts
1511
+ /** Zod schema for useCreateUserMutation form validation */
1512
+ export const createUserSchema = z.object({
1513
+ email: z.string(),
1514
+ role: z.enum(['admin', 'member']),
1515
+ })
1516
+ export type CreateUserInput = z.infer<typeof createUserSchema>
1517
+ ```
1518
+
1519
+ Usage with `react-hook-form`:
1520
+
1521
+ ```ts
1522
+ import { createUserSchema, type CreateUserInput } from '@api/users'
1523
+ import { useForm } from 'react-hook-form'
1524
+ import { zodResolver } from '@hookform/resolvers/zod'
1525
+
1526
+ const form = useForm<CreateUserInput>({ resolver: zodResolver(createUserSchema) })
1527
+ ```
1528
+
1529
+ `zod` must be installed in your project (`bun add zod`). It is an optional peer dependency of snapshot.
1530
+
1531
+ ### Watch mode (`--watch` / `-w`)
1532
+
1533
+ Keep hooks in sync while developing — sync re-runs automatically whenever the schema changes:
1534
+
1535
+ ```bash
1536
+ bunx snapshot sync --watch # polls API every 3s
1537
+ bunx snapshot sync --file ./openapi.json --watch # polls file every 1s
1538
+ ```
1539
+
1540
+ Ctrl+C stops the watcher cleanly. On each change, all affected hook files and `src/types/api.ts` are regenerated.
1541
+
1542
+ ### Vite plugin
1543
+
1544
+ Run sync automatically as part of the Vite dev server lifecycle — no manual `bun run sync` needed on startup:
1545
+
1546
+ ```ts
1547
+ // vite.config.ts
1548
+ import { snapshotSync } from '@lastshotlabs/snapshot/vite'
1549
+
1550
+ export default defineConfig({
1551
+ plugins: [
1552
+ snapshotSync({ file: 'openapi.json' }), // file mode: re-runs on file change in dev
1553
+ // snapshotSync({ apiUrl: 'http://localhost:3000' }), // API mode: runs once on start
1554
+ ],
1555
+ })
1556
+ ```
1557
+
1558
+ The plugin runs `snapshot sync` on `buildStart` (both `vite dev` and `vite build`). In dev mode with `file` option, it also watches the schema file via Vite's watcher and re-generates on changes. Dropping the schema file into your project for the first time also triggers sync automatically — no dev server restart needed.
1559
+
1560
+ > **Note:** API URL polling in dev mode is not supported by the Vite plugin. For live schema updates from a running API, use `bunx snapshot sync --watch` in a separate terminal instead.
1561
+
1562
+ `vite` must be installed in your project. It is an optional peer dependency of snapshot.
1563
+
1564
+ ### Using plain functions in Jotai atoms
1565
+
1566
+ Plain functions live in `src/api/` with no React dependencies — import them directly anywhere:
1567
+
1568
+ ```ts
1569
+ // src/store/users.ts
1570
+ import { atom } from 'jotai'
1571
+ import { getUser } from '@api/users'
1572
+
1573
+ const selectedIdAtom = atom<string | null>(null)
1574
+
1575
+ export const selectedUserAtom = atom(async (get) => {
1576
+ const id = get(selectedIdAtom)
1577
+ return id ? getUser(id) : null
1578
+ })
1579
+ ```
1580
+
1581
+ ---
1582
+
1583
+ ## Build
1584
+
1585
+ ```bash
1586
+ bun run build # tsup → dist/
1587
+ bun run typecheck # tsc --noEmit
1588
+ bun run dev # tsup --watch
1589
+ ```
1590
+
1591
+ Output:
1592
+ - `dist/index.mjs` — library ESM
1593
+ - `dist/index.cjs` — library CommonJS
1594
+ - `dist/index.d.ts` — TypeScript declarations
1595
+ - `dist/cli.mjs` — self-contained CLI executable (bundled, no runtime deps)
1596
+ - `dist/vite.js` — Vite plugin ESM
1597
+ - `dist/vite.cjs` — Vite plugin CommonJS
1598
+ - `dist/vite.d.ts` — Vite plugin TypeScript declarations