create-top-secret-starter 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/index.js +13 -11
  2. package/package.json +1 -1
  3. package/templates/template-api/CLAUDE.md +1 -1
  4. package/templates/template-api/drizzle/0000_damp_taskmaster.sql +1 -1
  5. package/templates/template-api/drizzle/meta/0000_snapshot.json +2 -2
  6. package/templates/template-api/package.json +1 -1
  7. package/templates/template-api/src/app.test.ts +1 -1
  8. package/templates/template-api/src/db/schema.ts +1 -1
  9. package/templates/template-api/src/index.ts +2 -2
  10. package/templates/template-api/src/modules/auth/service.ts +2 -2
  11. package/templates/template-api/src/modules/notes/index.ts +7 -4
  12. package/templates/template-api/src/modules/notes/service.ts +9 -8
  13. package/templates/template-api/src/redis.ts +1 -1
  14. package/templates/template-router/.claude/hooks/stop-check.sh +3 -12
  15. package/templates/template-router/.claude/skills/project-conventions/SKILL.md +8 -8
  16. package/templates/template-router/CLAUDE.md +7 -3
  17. package/templates/template-router/doctor.config.json +16 -0
  18. package/templates/template-router/package.json +12 -8
  19. package/templates/template-router/src/api/auth/auth.test.ts +11 -35
  20. package/templates/template-router/src/api/auth/create-guards.ts +20 -0
  21. package/templates/template-router/src/api/auth/guards.ts +4 -13
  22. package/templates/template-router/src/api/auth/index.ts +14 -8
  23. package/templates/template-router/src/api/auth/router-bridge.ts +2 -2
  24. package/templates/template-router/src/api/auth/session-store.test.ts +2 -4
  25. package/templates/template-router/src/api/auth/session-store.ts +20 -42
  26. package/templates/template-router/src/api/{auth/refresh.test.ts → refresh.test.ts} +19 -21
  27. package/templates/template-router/src/components/back-home-button.tsx +17 -0
  28. package/templates/template-router/src/env.ts +2 -3
  29. package/templates/template-router/src/lib/query-client.test.ts +0 -1
  30. package/templates/template-router/src/lib/single-flight.test.ts +0 -36
  31. package/templates/template-router/src/lib/storage-store.ts +61 -0
  32. package/templates/template-router/src/main.tsx +2 -2
  33. package/templates/{template-start/src/mocks/mock-server.ts → template-router/src/mocks/server.ts} +5 -5
  34. package/templates/template-router/src/providers/theme.test.tsx +43 -35
  35. package/templates/template-router/src/providers/theme.tsx +14 -28
  36. package/templates/template-router/src/routes/-error.tsx +39 -23
  37. package/templates/template-router/src/routes/-not-found.tsx +34 -16
  38. package/templates/template-router/src/routes/_authenticated.tsx +5 -5
  39. package/templates/template-router/src/routes/sign-in.tsx +43 -36
  40. package/templates/template-router/src/test/mock-backend.ts +15 -0
  41. package/templates/template-router/src/test/setup.ts +0 -1
  42. package/templates/template-router/vite.config.ts +1 -1
  43. package/templates/template-start/.claude/hooks/stop-check.sh +3 -12
  44. package/templates/template-start/.claude/skills/project-conventions/SKILL.md +8 -8
  45. package/templates/template-start/CLAUDE.md +7 -3
  46. package/templates/template-start/doctor.config.json +16 -0
  47. package/templates/template-start/package.json +12 -8
  48. package/templates/template-start/src/api/auth/auth.test.ts +11 -35
  49. package/templates/template-start/src/api/auth/create-guards.ts +20 -0
  50. package/templates/template-start/src/api/auth/guards.ts +6 -21
  51. package/templates/template-start/src/api/auth/index.ts +14 -8
  52. package/templates/template-start/src/api/auth/router-bridge.ts +2 -2
  53. package/templates/template-start/src/api/auth/session-store.test.ts +2 -4
  54. package/templates/template-start/src/api/auth/session-store.ts +21 -54
  55. package/templates/template-start/src/api/{auth/refresh.test.ts → refresh.test.ts} +19 -21
  56. package/templates/template-start/src/components/back-home-button.tsx +17 -0
  57. package/templates/template-start/src/env.ts +2 -3
  58. package/templates/template-start/src/lib/query-client.test.ts +0 -1
  59. package/templates/template-start/src/lib/single-flight.test.ts +0 -36
  60. package/templates/template-start/src/lib/storage-store.ts +61 -0
  61. package/templates/{template-router/src/mocks/mock-server.ts → template-start/src/mocks/server.ts} +5 -5
  62. package/templates/template-start/src/providers/theme.test.tsx +43 -35
  63. package/templates/template-start/src/providers/theme.tsx +14 -28
  64. package/templates/template-start/src/router.tsx +2 -2
  65. package/templates/template-start/src/routes/-error.tsx +39 -23
  66. package/templates/template-start/src/routes/-not-found.tsx +34 -16
  67. package/templates/template-start/src/routes/_authenticated.tsx +5 -5
  68. package/templates/template-start/src/routes/sign-in.tsx +43 -36
  69. package/templates/template-start/src/test/mock-backend.ts +15 -0
  70. package/templates/template-start/src/test/setup.ts +0 -1
  71. package/templates/template-router/src/components/status-page.tsx +0 -65
  72. package/templates/template-start/src/components/status-page.tsx +0 -65
package/index.js CHANGED
@@ -84,7 +84,7 @@ if (router && !ROUTERS.includes(router)) {
84
84
  }
85
85
  router ??= must(
86
86
  await select({
87
- message: 'Routing?',
87
+ message: 'Router?',
88
88
  options: [
89
89
  { value: 'router', label: ROUTER_LABELS.router, hint: 'SPA' },
90
90
  { value: 'start', label: ROUTER_LABELS.start, hint: 'SSR, full-stack' }
@@ -158,7 +158,7 @@ const restoreGitignore = dir => {
158
158
  const src = path.join(dir, '_gitignore')
159
159
  if (fs.existsSync(src)) fs.renameSync(src, path.join(dir, '.gitignore'))
160
160
  }
161
- const patchFile = (file, fn) => fs.writeFileSync(file, fn(fs.readFileSync(file, 'utf8')))
161
+ const editFile = (file, fn) => fs.writeFileSync(file, fn(fs.readFileSync(file, 'utf8')))
162
162
  // The api source keeps Redis-only code between `redis:start` / `redis:end` marker
163
163
  // comments, so one source tree serves both answers to the Redis prompt.
164
164
  const REDIS_MARKER = /^[ \t]*(\/\/|#|<!--) redis:(start|end)( -->)?\n/gm
@@ -168,7 +168,7 @@ const REDIS_BLOCK =
168
168
  const HOLE = '__REDIS_HOLE__'
169
169
 
170
170
  const applyRedisMarkers = (file, keep) =>
171
- patchFile(file, source =>
171
+ editFile(file, source =>
172
172
  source
173
173
  .replace(keep ? REDIS_MARKER : REDIS_BLOCK, HOLE)
174
174
  // dropping the last entry of an object or call leaves a dangling comma
@@ -209,7 +209,7 @@ const applyBackendOverlay = () => {
209
209
  fs.rmSync(path.join(webDir, '_gitignore'), { force: true })
210
210
 
211
211
  // dev proxy: same origin for the session cookie
212
- patchFile(path.join(webDir, 'vite.config.ts'), source =>
212
+ editFile(path.join(webDir, 'vite.config.ts'), source =>
213
213
  source.replace(
214
214
  'export default defineConfig({\n',
215
215
  "export default defineConfig({\n server: { proxy: { '/api': 'http://localhost:3000' } },\n"
@@ -230,8 +230,9 @@ const applyBackendOverlay = () => {
230
230
  pkg.devDependencies = Object.fromEntries(
231
231
  Object.entries(pkg.devDependencies).sort(([a], [b]) => a.localeCompare(b))
232
232
  )
233
- // lint and format run once, from the workspace root
234
- for (const script of ['lint', 'lint:fix', 'format', 'format:check']) delete pkg.scripts[script]
233
+ // lint, format and verify run once, from the workspace root
234
+ for (const script of ['lint', 'lint:fix', 'format', 'format:check', 'verify'])
235
+ delete pkg.scripts[script]
235
236
  delete pkg.trustedDependencies
236
237
  })
237
238
 
@@ -252,7 +253,7 @@ const applyBackendOverlay = () => {
252
253
  for (const file of ['.claude', '.mcp.json', 'CLAUDE.md']) {
253
254
  fs.renameSync(path.join(webDir, file), path.join(targetDir, file))
254
255
  }
255
- patchFile(
256
+ editFile(
256
257
  path.join(targetDir, 'CLAUDE.md'),
257
258
  source =>
258
259
  source
@@ -265,7 +266,7 @@ const applyBackendOverlay = () => {
265
266
  const dest = path.join(targetDir, file)
266
267
  fs.renameSync(path.join(webDir, file), dest)
267
268
  // textual edit: re-serialising would break the oxfmt style the file itself enforces
268
- patchFile(dest, source => source.replaceAll('"src/', '"apps/web/src/'))
269
+ editFile(dest, source => source.replaceAll('"src/', '"apps/web/src/'))
269
270
  }
270
271
  editJson(path.join(targetDir, 'package.json'), pkg => {
271
272
  pkg.name = name
@@ -277,7 +278,7 @@ const applyBackendOverlay = () => {
277
278
  'README.md',
278
279
  path.join('apps', 'web', 'src', 'routes', '_authenticated', 'index.tsx')
279
280
  ]) {
280
- patchFile(path.join(targetDir, file), source => source.replaceAll('PLACEHOLDER', name))
281
+ editFile(path.join(targetDir, file), source => source.replaceAll('PLACEHOLDER', name))
281
282
  }
282
283
  }
283
284
 
@@ -287,14 +288,15 @@ if (backend) {
287
288
  restoreGitignore(targetDir)
288
289
  editJson(path.join(targetDir, 'package.json'), pkg => {
289
290
  pkg.name = name
291
+ if (pm !== 'bun') pkg.scripts.verify = pkg.scripts.verify.replaceAll('bun run', `${pm} run`)
290
292
  })
291
293
  // textual edits: re-serialising would break the oxfmt style the project enforces
292
294
  if (pm !== 'bun') {
293
- patchFile(path.join(targetDir, '.claude', 'launch.json'), source =>
295
+ editFile(path.join(targetDir, '.claude', 'launch.json'), source =>
294
296
  source.replace('"runtimeExecutable": "bun"', `"runtimeExecutable": "${pm}"`)
295
297
  )
296
298
  // MCP servers start through the pm's package runner
297
- patchFile(path.join(targetDir, '.mcp.json'), source =>
299
+ editFile(path.join(targetDir, '.mcp.json'), source =>
298
300
  pm === 'pnpm'
299
301
  ? source
300
302
  .replaceAll('"command": "bunx"', '"command": "pnpm"')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-top-secret-starter",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Scaffold a Vite + React 19 + TypeScript + shadcn starter with TanStack Router",
5
5
  "keywords": [
6
6
  "create",
@@ -5,7 +5,7 @@ root `verify` covers it. Tests run on PGlite with the committed migrations — n
5
5
  server needed.
6
6
 
7
7
  - Feature layout per [Elysia best practices](https://elysiajs.com/essential/best-practice):
8
- `src/modules/<feature>/{index,service,model}.ts` — controller / plain static-class
8
+ `src/modules/<feature>/{index,service,model}.ts` — controller / plain factory
9
9
  service / `t` schemas. Services never touch `Context`; one Elysia instance is one
10
10
  controller.
11
11
  - `src/app.ts` is the Eden boundary: no Bun globals, no `process.env`, deps injected
@@ -57,7 +57,7 @@ CREATE TABLE "verification" (
57
57
  ALTER TABLE "note" ADD CONSTRAINT "note_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
58
58
  ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
59
59
  ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
60
- CREATE INDEX "note_userId_idx" ON "note" USING btree ("user_id");--> statement-breakpoint
60
+ CREATE INDEX "note_user_id_idx" ON "note" USING btree ("user_id");--> statement-breakpoint
61
61
  CREATE UNIQUE INDEX "account_issuer_accountId_uidx" ON "account" USING btree ("issuer","account_id");--> statement-breakpoint
62
62
  CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint
63
63
  CREATE INDEX "session_userId_idx" ON "session" USING btree ("user_id");--> statement-breakpoint
@@ -35,8 +35,8 @@
35
35
  }
36
36
  },
37
37
  "indexes": {
38
- "note_userId_idx": {
39
- "name": "note_userId_idx",
38
+ "note_user_id_idx": {
39
+ "name": "note_user_id_idx",
40
40
  "columns": [
41
41
  {
42
42
  "expression": "user_id",
@@ -32,6 +32,6 @@
32
32
  "@types/bun": "^1.4.0",
33
33
  "drizzle-kit": "^0.31.10",
34
34
  "typescript": "^7.0.2",
35
- "vitest": "^4.1.11"
35
+ "vitest": "^5.0.0"
36
36
  }
37
37
  }
@@ -111,7 +111,7 @@ describe('notes', () => {
111
111
  expect(own.status).toBe(204)
112
112
  })
113
113
 
114
- it('validates the body', async () => {
114
+ it('rejects a note without a title', async () => {
115
115
  const cookie = await signUp('carol@example.com')
116
116
  const res = await json('/api/notes', { method: 'POST', cookie, body: JSON.stringify({}) })
117
117
  expect(res.status).toBe(422)
@@ -17,5 +17,5 @@ export const note = pgTable(
17
17
  .references(() => user.id, { onDelete: 'cascade' }),
18
18
  createdAt: timestamp('created_at').defaultNow().notNull()
19
19
  },
20
- table => [index('note_userId_idx').on(table.userId)]
20
+ table => [index('note_user_id_idx').on(table.userId)]
21
21
  )
@@ -3,12 +3,12 @@ import { createDb } from './db'
3
3
  import { env } from './env'
4
4
  import { createAuth } from './modules/auth/service'
5
5
  // redis:start
6
- import { createRedisStorage } from './redis'
6
+ import { createRedis } from './redis'
7
7
  // redis:end
8
8
 
9
9
  const db = createDb(env.DATABASE_URL)
10
10
  // redis:start
11
- const redis = env.REDIS_URL ? createRedisStorage(env.REDIS_URL) : undefined
11
+ const redis = env.REDIS_URL ? createRedis(env.REDIS_URL) : undefined
12
12
  // redis:end
13
13
  const auth = createAuth({
14
14
  db,
@@ -6,7 +6,7 @@ import { Elysia } from 'elysia'
6
6
  import type { Db } from '../../db'
7
7
  import * as schema from '../../db/schema'
8
8
 
9
- export type AuthOptions = {
9
+ export type AuthDeps = {
10
10
  db: Db
11
11
  secret: string
12
12
  baseURL: string
@@ -20,7 +20,7 @@ export const createAuth = ({
20
20
  baseURL,
21
21
  trustedOrigins = [],
22
22
  secondaryStorage
23
- }: AuthOptions) =>
23
+ }: AuthDeps) =>
24
24
  betterAuth({
25
25
  secret,
26
26
  baseURL,
@@ -3,18 +3,21 @@ import { Elysia, t } from 'elysia'
3
3
  import type { Db } from '../../db'
4
4
  import { authService, type Auth } from '../auth/service'
5
5
  import { NoteModel } from './model'
6
- import { Notes } from './service'
6
+ import { createNotes } from './service'
7
7
 
8
8
  export const notesController = ({ db, auth }: { db: Db; auth: Auth }) =>
9
9
  new Elysia({ prefix: '/notes' })
10
10
  .use(authService(auth))
11
- .get('/', ({ user }) => Notes.list(db, user.id), {
11
+ .get('/', ({ user }) => createNotes({ db, userId: user.id }).list(), {
12
12
  auth: true,
13
13
  response: { 200: t.Array(NoteModel.note), 401: NoteModel.error }
14
14
  })
15
15
  .post(
16
16
  '/',
17
- ({ user, body, status }) => Notes.create(db, user.id, body).then(n => status(201, n)),
17
+ ({ user, body, status }) =>
18
+ createNotes({ db, userId: user.id })
19
+ .create(body)
20
+ .then(n => status(201, n)),
18
21
  {
19
22
  auth: true,
20
23
  body: NoteModel.createBody,
@@ -25,7 +28,7 @@ export const notesController = ({ db, auth }: { db: Db; auth: Auth }) =>
25
28
  '/:id',
26
29
  async ({ user, params, status }) => {
27
30
  // a foreign id must look identical to a missing one
28
- if (!(await Notes.remove(db, user.id, params.id)))
31
+ if (!(await createNotes({ db, userId: user.id }).delete(params.id)))
29
32
  return status(404, { message: 'Not found' })
30
33
  // explicit Response: Elysia's status(204) 500s under vitest's Node runtime
31
34
  return new Response(null, { status: 204 })
@@ -10,27 +10,28 @@ const toDto = (row: typeof note.$inferSelect): NoteModel['note'] => ({
10
10
  createdAt: row.createdAt.toISOString()
11
11
  })
12
12
 
13
- // no Elysia Context: db and user id are passed in, which the PGlite tests need
14
- export abstract class Notes {
15
- static async list(db: Db, userId: string) {
13
+ // no Elysia Context: the db and the owner are bound up front, so every query is
14
+ // already scoped to one user and the PGlite tests can build one directly
15
+ export const createNotes = ({ db, userId }: { db: Db; userId: string }) => ({
16
+ list: async () => {
16
17
  const rows = await db
17
18
  .select()
18
19
  .from(note)
19
20
  .where(eq(note.userId, userId))
20
21
  .orderBy(desc(note.createdAt))
21
22
  return rows.map(toDto)
22
- }
23
+ },
23
24
 
24
- static async create(db: Db, userId: string, { title }: NoteModel['createBody']) {
25
+ create: async ({ title }: NoteModel['createBody']) => {
25
26
  const [created] = await db.insert(note).values({ title, userId }).returning()
26
27
  return toDto(created!)
27
- }
28
+ },
28
29
 
29
- static async remove(db: Db, userId: string, id: string) {
30
+ delete: async (id: string) => {
30
31
  const deleted = await db
31
32
  .delete(note)
32
33
  .where(and(eq(note.id, id), eq(note.userId, userId)))
33
34
  .returning({ id: note.id })
34
35
  return deleted.length > 0
35
36
  }
36
- }
37
+ })
@@ -2,7 +2,7 @@ import { RedisClient } from 'bun'
2
2
  import type { SecondaryStorage } from '@better-auth/core/db'
3
3
 
4
4
  // Bun's built-in client; a shared store survives restarts and extra instances
5
- export const createRedisStorage = (url: string) => {
5
+ export const createRedis = (url: string) => {
6
6
  const redis = new RedisClient(url)
7
7
  const storage: SecondaryStorage = {
8
8
  get: key => redis.get(key),
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bash
2
2
  # Stop-hook gate: block finishing when the tree is red.
3
- # Runs the package scripts so the same commands work in the flat and the
3
+ # Runs the package script so the same command works in the flat and the
4
4
  # workspace (apps/*) layout. Exit 2 blocks the turn and stderr becomes the
5
5
  # reason Claude reads; any other non-zero code is a silent no-op.
6
6
  set -uo pipefail
@@ -10,17 +10,8 @@ run="npm run"
10
10
  [ -f bun.lock ] && run="bun run"
11
11
  [ -f pnpm-lock.yaml ] && run="pnpm run"
12
12
 
13
- fail=0
14
- out=""
15
- for script in format:check typecheck lint test; do
16
- if ! o=$($run "$script" 2>&1); then
17
- fail=1
18
- out+=$'\n'"=== $script ==="$'\n'"$o"$'\n'
19
- fi
20
- done
21
-
22
- if [ "$fail" = "1" ]; then
23
- printf 'checks failed — fix before finishing:\n%s' "$out" >&2
13
+ if ! out=$($run verify 2>&1); then
14
+ printf 'verify failed — fix before finishing:\n%s' "$out" >&2
24
15
  exit 2
25
16
  fi
26
17
  exit 0
@@ -33,11 +33,11 @@ Refresh is wrapped in `singleFlight` (`src/lib/single-flight.ts`) so concurrent
33
33
  `src/api/auth/session-store.ts` is the single source of truth: a localStorage-backed
34
34
  external store with cross-tab sync (`storage` event) and zod validation on read.
35
35
 
36
- | Context | How to read it |
37
- | ----------- | ----------------------------------------------------------------------------------------- |
38
- | Component | `useSession()` (from `session-store.ts`, `useSyncExternalStore`) |
39
- | Route guard | `requireSession()` / `redirectIfAuthenticated()` from `@/api/auth/guards` in `beforeLoad` |
40
- | Query cache | **Never.** Do not mirror session into React Query |
36
+ | Context | How to read it |
37
+ | ----------- | ---------------------------------------------------------------------------------- |
38
+ | Component | `useSession()` (from `session-store.ts`, `useSyncExternalStore`) |
39
+ | Route guard | `requireSession()` / `requireNoSession()` from `@/api/auth/guards` in `beforeLoad` |
40
+ | Query cache | **Never.** Do not mirror session into React Query |
41
41
 
42
42
  The guard helpers own the redirect contract (`search.redirect` carries the return
43
43
  URL). Don't read `sessionStore.get()` in route files directly.
@@ -47,7 +47,7 @@ independently; a bug in a guard is a broken experience, not an access hole.
47
47
 
48
48
  `beforeLoad` only runs on navigation. Session presence can change without one — a
49
49
  sign-in or sign-out in another tab, or a failed token refresh — so
50
- `src/api/auth/router-bridge.ts` (`bindSessionToRouter`, called once from
50
+ `src/api/auth/router-bridge.ts` (`invalidateOnAuthChange`, called once from
51
51
  `src/router.tsx`) subscribes to the store and calls `router.invalidate()` on
52
52
  presence transitions.
53
53
  **Any new check that reads live state needs the same treatment**: ask "when this
@@ -135,6 +135,6 @@ The rules above describe the SPA-only variant. When the backend is on:
135
135
 
136
136
  ## Mock backend
137
137
 
138
- `src/mocks/mock-server.ts` patches global `fetch` for `/api/*`, dev-only under
139
- `VITE_ENABLE_MOCKS`. Seed login `demo@example.com` / `demo1234`. `bun run graduate <name>`
138
+ `src/mocks/server.ts` patches global `fetch` for `/api/*`, dev-only under
139
+ `VITE_ENABLE_MOCKS`. Seed sign-in `demo@example.com` / `demo1234`. `bun run graduate <name>`
140
140
  removes it along with the demo account (see root `CLAUDE.md`, Starter state).
@@ -32,7 +32,9 @@ Don't write tests that merely restate the implementation — zero confidence val
32
32
 
33
33
  Package manager is **bun**; scripts are in `package.json`.
34
34
 
35
- A **Stop hook** (`.claude/hooks/stop-check.sh`) runs `format:check`, `typecheck`, `lint` and `test` before a turn can finish; a PostToolUse hook formats/lints every `.ts(x)` write. Don't hand-format to match the hooks do it.
35
+ **`bun run verify` is the single definition of green.** The Stop hook (`.claude/hooks/stop-check.sh`) runs it before a turn can finish don't re-list the individual checks anywhere new.
36
+
37
+ A PostToolUse hook formats/lints every `.ts(x)` write. Don't hand-format to match.
36
38
 
37
39
  ## Architecture
38
40
 
@@ -44,7 +46,9 @@ React 19 SPA with React Compiler on — no manual `useMemo`/`useCallback`.
44
46
 
45
47
  For how to add an API module, a form, a route or a UI primitive, use the **project-conventions** skill.
46
48
 
47
- The web app is **SPA-only**: `src/api/auth/session-store.ts` and the route guards touch `localStorage` and `document` at module load with no environment check. The TanStack Start template renders on the server, so SSR-safe copies of those two files live in `create-cli/start-overrides/` (and `backend-overlay/web-start/` for the backend variant) change them together.
49
+ Persisted state goes through `createStorageStore` in `src/lib/storage-store.ts` the only module allowed to touch `localStorage`. It owns decoding, cross-tab sync and the SSR snapshot, so `session-store.ts` and the theme store work unchanged on the TanStack Start template's server runtime.
50
+
51
+ Route guards are the remaining SPA-only code: they redirect on a session the server cannot see. SSR-safe copies live in `create-cli/start-overrides/` (and `backend-overlay/web-start/` for the backend variant) — change them together.
48
52
 
49
53
  Dev-only code must put `import.meta.env.DEV` **first** in the condition so Vite drops the branch at build time. `mocksEnabled` alone is a runtime value and would still ship the mock backend as a lazy chunk.
50
54
 
@@ -54,6 +58,6 @@ Dev-only code must put `import.meta.env.DEV` **first** in the condition so Vite
54
58
 
55
59
  `dependencies` holds only what is imported from `src/`; CLIs and build plugins are dev deps so `bun audit --prod` stays readable. `trustedDependencies: []` — don't add entries without checking what the script does.
56
60
 
57
- `vite` is **pinned to 8.1.x** (8.1.5). Every 8.2.x and 8.3.0-beta.0 collapse router `autoCodeSplitting` from 12 chunks to 9 and grow the entry from 83 to 117 kB gzip. Re-test before unpinning.
61
+ `vite` is **pinned to 8.1.x** (8.1.5). Every 8.2.x and 8.3.0-beta.0 collapse router `autoCodeSplitting` from 13 chunks to 10 and grow the entry from 81 to 115 kB gzip (measured on 8.2.2). Re-test before unpinning.
58
62
 
59
63
  </important>
@@ -0,0 +1,16 @@
1
+ {
2
+ "$schema": "https://react.doctor/schema/config.json",
3
+ "ignore": {
4
+ "files": ["src/components/ui/**", "src/routeTree.gen.ts"],
5
+ "overrides": [
6
+ {
7
+ "files": ["src/routes/**"],
8
+ "rules": ["react-doctor/only-export-components"]
9
+ },
10
+ {
11
+ "files": ["src/api/auth/session-store.ts"],
12
+ "rules": ["react-doctor/client-localstorage-no-version"]
13
+ }
14
+ ]
15
+ }
16
+ }
@@ -9,21 +9,23 @@
9
9
  "lint": "oxlint",
10
10
  "lint:fix": "oxlint --fix",
11
11
  "typecheck": "tsc -b",
12
+ "doctor": "react-doctor . --no-score --no-supply-chain --blocking error",
12
13
  "test": "vitest run",
13
14
  "format": "oxfmt",
14
15
  "format:check": "oxfmt --check",
15
- "preview": "vite preview"
16
+ "preview": "vite preview",
17
+ "verify": "bun run format:check && bun run lint && bun run typecheck && bun run doctor && bun run test"
16
18
  },
17
19
  "dependencies": {
18
- "@base-ui/react": "^1.7.0",
20
+ "@base-ui/react": "^1.8.0",
19
21
  "@fontsource-variable/inter": "^5.3.0",
20
22
  "@hookform/resolvers": "^5.9.1",
21
23
  "@tanstack/react-query": "^5.102.8",
22
24
  "@tanstack/react-router": "^1.170.32",
23
25
  "class-variance-authority": "^0.7.1",
24
- "cn": "^0.2.4",
26
+ "cn": "^0.2.5",
25
27
  "ky": "^2.1.0",
26
- "lucide-react": "^1.39.0",
28
+ "lucide-react": "^1.41.0",
27
29
  "react": "^19.2.8",
28
30
  "react-dom": "^19.2.8",
29
31
  "react-hook-form": "^7.87.0",
@@ -34,23 +36,25 @@
34
36
  "devDependencies": {
35
37
  "@babel/core": "^8.0.1",
36
38
  "@rolldown/plugin-babel": "^0.2.3",
39
+ "@standard-schema/spec": "^1.1.0",
37
40
  "@tailwindcss/vite": "^4.3.3",
38
41
  "@tanstack/router-plugin": "^1.168.35",
39
42
  "@testing-library/jest-dom": "^7.0.1",
40
43
  "@testing-library/react": "^16.3.3",
41
44
  "@types/node": "^26.4.1",
42
45
  "@types/react": "^19.2.18",
43
- "@types/react-dom": "^19.2.5",
46
+ "@types/react-dom": "^19.2.7",
44
47
  "@vitejs/plugin-react": "^6.1.1",
45
48
  "babel-plugin-react-compiler": "^1.0.0",
46
- "happy-dom": "^20.13.2",
49
+ "happy-dom": "^20.14.0",
47
50
  "oxfmt": "0.66.0",
48
51
  "oxlint": "^1.81.0",
49
- "shadcn": "^4.20.1",
52
+ "react-doctor": "^0.9.13",
53
+ "shadcn": "^4.21.0",
50
54
  "tailwindcss": "^4.3.3",
51
55
  "typescript": "^7.0.2",
52
56
  "vite": "8.1.5",
53
- "vitest": "^4.1.11"
57
+ "vitest": "^5.0.0"
54
58
  },
55
59
  "trustedDependencies": []
56
60
  }
@@ -1,46 +1,22 @@
1
- import { afterAll, afterEach, beforeAll } from 'vitest'
2
-
3
- import { fetchMe, login, logout } from '@/api/auth'
1
+ import { signIn, signOut } from '@/api/auth'
4
2
  import { sessionStore } from '@/api/auth/session-store'
5
- import { installMockServer } from '@/mocks/mock-server'
6
-
7
- const creds = { email: 'demo@example.com', password: 'demo1234' }
8
-
9
- let uninstall: () => void
3
+ import { demoCredentials, setupMockBackend } from '@/test/mock-backend'
10
4
 
11
- beforeAll(() => {
12
- uninstall = installMockServer()
13
- })
14
- afterAll(() => uninstall())
15
- afterEach(() => {
16
- sessionStore.clear()
17
- localStorage.clear()
18
- })
5
+ setupMockBackend()
19
6
 
20
- it('logs in and persists the session', async () => {
21
- const session = await login(creds)
22
- expect(session.user.email).toBe(creds.email)
23
- expect(sessionStore.get()?.user.email).toBe(creds.email)
7
+ it('keeps the session after a successful sign-in', async () => {
8
+ const session = await signIn(demoCredentials)
9
+ expect(session.user.email).toBe(demoCredentials.email)
10
+ expect(sessionStore.get()?.user.email).toBe(demoCredentials.email)
24
11
  })
25
12
 
26
13
  it('rejects bad credentials without touching the session', async () => {
27
- await expect(login({ ...creds, password: 'wrong' })).rejects.toThrow()
14
+ await expect(signIn({ ...demoCredentials, password: 'wrong' })).rejects.toThrow()
28
15
  expect(sessionStore.get()).toBeNull()
29
16
  })
30
17
 
31
- it('transparently refreshes when the access token is rejected', async () => {
32
- await login(creds)
33
- const session = sessionStore.get()!
34
- sessionStore.set({ ...session, accessToken: 'expired' })
35
-
36
- const me = await fetchMe()
37
-
38
- expect(me.email).toBe(creds.email)
39
- expect(sessionStore.get()?.accessToken).not.toBe('expired')
40
- })
41
-
42
- it('clears the session on logout', async () => {
43
- await login(creds)
44
- await logout()
18
+ it('leaves no session behind after signing out', async () => {
19
+ await signIn(demoCredentials)
20
+ await signOut()
45
21
  expect(sessionStore.get()).toBeNull()
46
22
  })
@@ -0,0 +1,20 @@
1
+ import { redirect } from '@tanstack/react-router'
2
+
3
+ type Options<S> = {
4
+ getSession: () => S | Promise<S>
5
+ /** SSR cannot see the session, so guards defer to the client and run again on hydration. */
6
+ isServer?: boolean
7
+ }
8
+
9
+ export const createGuards = <S>({ getSession, isServer = false }: Options<S>) => ({
10
+ requireSession: async (location: { href: string }) => {
11
+ if (isServer) return
12
+ if (!(await getSession())) {
13
+ throw redirect({ to: '/sign-in', search: { redirect: location.href } })
14
+ }
15
+ },
16
+ requireNoSession: async (search: { redirect?: string }) => {
17
+ if (isServer) return
18
+ if (await getSession()) throw redirect({ to: search.redirect ?? '/' })
19
+ }
20
+ })
@@ -1,15 +1,6 @@
1
- import { redirect } from '@tanstack/react-router'
2
-
1
+ import { createGuards } from '@/api/auth/create-guards'
3
2
  import { sessionStore } from '@/api/auth/session-store'
4
3
 
5
- // `beforeLoad` runs outside React, so guards read the store directly instead of useSession().
6
-
7
- export const requireSession = (location: { href: string }) => {
8
- if (!sessionStore.get()) {
9
- throw redirect({ to: '/sign-in', search: { redirect: location.href } })
10
- }
11
- }
12
-
13
- export const redirectIfAuthenticated = (search: { redirect?: string }) => {
14
- if (sessionStore.get()) throw redirect({ to: search.redirect ?? '/' })
15
- }
4
+ export const { requireSession, requireNoSession } = createGuards({
5
+ getSession: sessionStore.get
6
+ })
@@ -6,27 +6,33 @@ import { sessionStore } from '@/api/auth/session-store'
6
6
 
7
7
  export type Credentials = { email: string; password: string }
8
8
 
9
- export const login = async (credentials: Credentials) => {
10
- const session = await api.post('auth/login', { json: credentials }).json(SessionSchema)
9
+ export const signIn = async (credentials: Credentials) => {
10
+ const session = await api.post('auth/sign-in', { json: credentials }).json(SessionSchema)
11
11
  sessionStore.set(session)
12
12
  return session
13
13
  }
14
14
 
15
- export const logout = async () => {
15
+ export const signOut = async () => {
16
16
  try {
17
- await api.post('auth/logout')
17
+ await api.post('auth/sign-out')
18
18
  } catch {}
19
19
  sessionStore.clear()
20
20
  }
21
21
 
22
- export const fetchMe = () => api.get('auth/me').json(UserSchema)
22
+ export const getCurrentUser = () => api.get('auth/me').json(UserSchema)
23
23
 
24
- export const useLogin = () => useMutation({ mutationFn: login })
24
+ export const useSignIn = () => {
25
+ const queryClient = useQueryClient()
26
+ return useMutation({
27
+ mutationFn: signIn,
28
+ onSuccess: () => queryClient.clear()
29
+ })
30
+ }
25
31
 
26
- export const useLogout = () => {
32
+ export const useSignOut = () => {
27
33
  const queryClient = useQueryClient()
28
34
  return useMutation({
29
- mutationFn: logout,
35
+ mutationFn: signOut,
30
36
  onSuccess: () => queryClient.clear()
31
37
  })
32
38
  }
@@ -4,8 +4,8 @@ import { sessionStore } from '@/api/auth/session-store'
4
4
  // re-run them. Token rotation also notifies, but must not restart loaders.
5
5
  export const invalidateOnAuthChange = (router: { invalidate: () => Promise<void> }) => {
6
6
  let signedIn = sessionStore.get() !== null
7
- sessionStore.subscribe(session => {
8
- if ((session !== null) === signedIn) return
7
+ sessionStore.subscribe(() => {
8
+ if ((sessionStore.get() !== null) === signedIn) return
9
9
  signedIn = !signedIn
10
10
  void router.invalidate()
11
11
  })
@@ -1,5 +1,3 @@
1
- import { beforeEach, describe, expect, it, vi } from 'vitest'
2
-
3
1
  import type { Session } from '@/api/auth/schema'
4
2
 
5
3
  // the module reads localStorage at load, so each case needs a fresh import
@@ -42,7 +40,7 @@ describe('cross-tab sync', () => {
42
40
  )
43
41
 
44
42
  expect(store.get()?.user.id).toBe('u_1')
45
- expect(listener).toHaveBeenCalledWith(expect.objectContaining({ accessToken: 'access' }))
43
+ expect(listener).toHaveBeenCalled()
46
44
  })
47
45
 
48
46
  it('drops the session when another tab signs out', async () => {
@@ -67,7 +65,7 @@ describe('cross-tab sync', () => {
67
65
  localStorage.setItem('session', JSON.stringify(session))
68
66
  const store = await load()
69
67
 
70
- window.dispatchEvent(new StorageEvent('storage', { key: 'theme', newValue: 'dark' }))
68
+ window.dispatchEvent(new StorageEvent('storage', { key: 'unrelated', newValue: 'x' }))
71
69
 
72
70
  expect(store.get()?.user.id).toBe('u_1')
73
71
  })