create-top-secret-starter 0.3.0 → 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 (50) hide show
  1. package/index.js +9 -9
  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/skills/project-conventions/SKILL.md +8 -8
  15. package/templates/template-router/CLAUDE.md +1 -1
  16. package/templates/template-router/package.json +8 -7
  17. package/templates/template-router/src/api/auth/auth.test.ts +11 -35
  18. package/templates/template-router/src/api/auth/create-guards.ts +20 -0
  19. package/templates/template-router/src/api/auth/guards.ts +4 -11
  20. package/templates/template-router/src/api/auth/index.ts +9 -9
  21. package/templates/template-router/src/api/auth/session-store.test.ts +1 -3
  22. package/templates/template-router/src/api/{auth/refresh.test.ts → refresh.test.ts} +19 -21
  23. package/templates/template-router/src/lib/query-client.test.ts +0 -1
  24. package/templates/template-router/src/main.tsx +2 -2
  25. package/templates/template-router/src/mocks/{mock-server.ts → server.ts} +5 -5
  26. package/templates/template-router/src/providers/theme.test.tsx +5 -6
  27. package/templates/template-router/src/providers/theme.tsx +1 -1
  28. package/templates/template-router/src/routes/_authenticated.tsx +5 -5
  29. package/templates/template-router/src/routes/sign-in.tsx +11 -11
  30. package/templates/template-router/src/test/mock-backend.ts +15 -0
  31. package/templates/template-router/src/test/setup.ts +0 -1
  32. package/templates/template-router/vite.config.ts +1 -1
  33. package/templates/template-start/.claude/skills/project-conventions/SKILL.md +8 -8
  34. package/templates/template-start/CLAUDE.md +1 -1
  35. package/templates/template-start/package.json +8 -7
  36. package/templates/template-start/src/api/auth/auth.test.ts +11 -35
  37. package/templates/template-start/src/api/auth/create-guards.ts +20 -0
  38. package/templates/template-start/src/api/auth/guards.ts +6 -19
  39. package/templates/template-start/src/api/auth/index.ts +9 -9
  40. package/templates/template-start/src/api/auth/session-store.test.ts +1 -3
  41. package/templates/template-start/src/api/{auth/refresh.test.ts → refresh.test.ts} +19 -21
  42. package/templates/template-start/src/lib/query-client.test.ts +0 -1
  43. package/templates/template-start/src/mocks/{mock-server.ts → server.ts} +5 -5
  44. package/templates/template-start/src/providers/theme.test.tsx +5 -6
  45. package/templates/template-start/src/providers/theme.tsx +1 -1
  46. package/templates/template-start/src/router.tsx +2 -2
  47. package/templates/template-start/src/routes/_authenticated.tsx +5 -5
  48. package/templates/template-start/src/routes/sign-in.tsx +11 -11
  49. package/templates/template-start/src/test/mock-backend.ts +15 -0
  50. package/templates/template-start/src/test/setup.ts +0 -1
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"
@@ -253,7 +253,7 @@ const applyBackendOverlay = () => {
253
253
  for (const file of ['.claude', '.mcp.json', 'CLAUDE.md']) {
254
254
  fs.renameSync(path.join(webDir, file), path.join(targetDir, file))
255
255
  }
256
- patchFile(
256
+ editFile(
257
257
  path.join(targetDir, 'CLAUDE.md'),
258
258
  source =>
259
259
  source
@@ -266,7 +266,7 @@ const applyBackendOverlay = () => {
266
266
  const dest = path.join(targetDir, file)
267
267
  fs.renameSync(path.join(webDir, file), dest)
268
268
  // textual edit: re-serialising would break the oxfmt style the file itself enforces
269
- patchFile(dest, source => source.replaceAll('"src/', '"apps/web/src/'))
269
+ editFile(dest, source => source.replaceAll('"src/', '"apps/web/src/'))
270
270
  }
271
271
  editJson(path.join(targetDir, 'package.json'), pkg => {
272
272
  pkg.name = name
@@ -278,7 +278,7 @@ const applyBackendOverlay = () => {
278
278
  'README.md',
279
279
  path.join('apps', 'web', 'src', 'routes', '_authenticated', 'index.tsx')
280
280
  ]) {
281
- patchFile(path.join(targetDir, file), source => source.replaceAll('PLACEHOLDER', name))
281
+ editFile(path.join(targetDir, file), source => source.replaceAll('PLACEHOLDER', name))
282
282
  }
283
283
  }
284
284
 
@@ -292,11 +292,11 @@ if (backend) {
292
292
  })
293
293
  // textual edits: re-serialising would break the oxfmt style the project enforces
294
294
  if (pm !== 'bun') {
295
- patchFile(path.join(targetDir, '.claude', 'launch.json'), source =>
295
+ editFile(path.join(targetDir, '.claude', 'launch.json'), source =>
296
296
  source.replace('"runtimeExecutable": "bun"', `"runtimeExecutable": "${pm}"`)
297
297
  )
298
298
  // MCP servers start through the pm's package runner
299
- patchFile(path.join(targetDir, '.mcp.json'), source =>
299
+ editFile(path.join(targetDir, '.mcp.json'), source =>
300
300
  pm === 'pnpm'
301
301
  ? source
302
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.3.0",
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),
@@ -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).
@@ -58,6 +58,6 @@ Dev-only code must put `import.meta.env.DEV` **first** in the condition so Vite
58
58
 
59
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.
60
60
 
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 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.
62
62
 
63
63
  </important>
@@ -17,15 +17,15 @@
17
17
  "verify": "bun run format:check && bun run lint && bun run typecheck && bun run doctor && bun run test"
18
18
  },
19
19
  "dependencies": {
20
- "@base-ui/react": "^1.7.0",
20
+ "@base-ui/react": "^1.8.0",
21
21
  "@fontsource-variable/inter": "^5.3.0",
22
22
  "@hookform/resolvers": "^5.9.1",
23
23
  "@tanstack/react-query": "^5.102.8",
24
24
  "@tanstack/react-router": "^1.170.32",
25
25
  "class-variance-authority": "^0.7.1",
26
- "cn": "^0.2.4",
26
+ "cn": "^0.2.5",
27
27
  "ky": "^2.1.0",
28
- "lucide-react": "^1.39.0",
28
+ "lucide-react": "^1.41.0",
29
29
  "react": "^19.2.8",
30
30
  "react-dom": "^19.2.8",
31
31
  "react-hook-form": "^7.87.0",
@@ -36,24 +36,25 @@
36
36
  "devDependencies": {
37
37
  "@babel/core": "^8.0.1",
38
38
  "@rolldown/plugin-babel": "^0.2.3",
39
+ "@standard-schema/spec": "^1.1.0",
39
40
  "@tailwindcss/vite": "^4.3.3",
40
41
  "@tanstack/router-plugin": "^1.168.35",
41
42
  "@testing-library/jest-dom": "^7.0.1",
42
43
  "@testing-library/react": "^16.3.3",
43
44
  "@types/node": "^26.4.1",
44
45
  "@types/react": "^19.2.18",
45
- "@types/react-dom": "^19.2.5",
46
+ "@types/react-dom": "^19.2.7",
46
47
  "@vitejs/plugin-react": "^6.1.1",
47
48
  "babel-plugin-react-compiler": "^1.0.0",
48
- "happy-dom": "^20.13.2",
49
+ "happy-dom": "^20.14.0",
49
50
  "oxfmt": "0.66.0",
50
51
  "oxlint": "^1.81.0",
51
52
  "react-doctor": "^0.9.13",
52
- "shadcn": "^4.20.1",
53
+ "shadcn": "^4.21.0",
53
54
  "tailwindcss": "^4.3.3",
54
55
  "typescript": "^7.0.2",
55
56
  "vite": "8.1.5",
56
- "vitest": "^4.1.11"
57
+ "vitest": "^5.0.0"
57
58
  },
58
59
  "trustedDependencies": []
59
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,13 +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
- export const requireSession = (location: { href: string }) => {
6
- if (!sessionStore.get()) {
7
- throw redirect({ to: '/sign-in', search: { redirect: location.href } })
8
- }
9
- }
10
-
11
- export const redirectIfAuthenticated = (search: { redirect?: string }) => {
12
- if (sessionStore.get()) throw redirect({ to: search.redirect ?? '/' })
13
- }
4
+ export const { requireSession, requireNoSession } = createGuards({
5
+ getSession: sessionStore.get
6
+ })
@@ -6,33 +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 = () => {
24
+ export const useSignIn = () => {
25
25
  const queryClient = useQueryClient()
26
26
  return useMutation({
27
- mutationFn: login,
27
+ mutationFn: signIn,
28
28
  onSuccess: () => queryClient.clear()
29
29
  })
30
30
  }
31
31
 
32
- export const useLogout = () => {
32
+ export const useSignOut = () => {
33
33
  const queryClient = useQueryClient()
34
34
  return useMutation({
35
- mutationFn: logout,
35
+ mutationFn: signOut,
36
36
  onSuccess: () => queryClient.clear()
37
37
  })
38
38
  }
@@ -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
@@ -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
  })
@@ -1,24 +1,22 @@
1
- import { afterAll, afterEach, beforeAll, expect, it, vi } from 'vitest'
2
-
3
- import { fetchMe, login } from '@/api/auth'
1
+ import { getCurrentUser, signIn } from '@/api/auth'
4
2
  import { sessionStore } from '@/api/auth/session-store'
5
- import { installMockServer } from '@/mocks/mock-server'
3
+ import { demoCredentials, setupMockBackend } from '@/test/mock-backend'
4
+
5
+ setupMockBackend()
6
6
 
7
- const creds = { email: 'demo@example.com', password: 'demo1234' }
7
+ it('transparently refreshes when the access token is rejected', async () => {
8
+ await signIn(demoCredentials)
9
+ const session = sessionStore.get()!
10
+ sessionStore.set({ ...session, accessToken: 'expired' })
8
11
 
9
- let uninstall: () => void
12
+ const me = await getCurrentUser()
10
13
 
11
- beforeAll(() => {
12
- uninstall = installMockServer()
13
- })
14
- afterAll(() => uninstall())
15
- afterEach(() => {
16
- sessionStore.clear()
17
- localStorage.clear()
14
+ expect(me.email).toBe(demoCredentials.email)
15
+ expect(sessionStore.get()?.accessToken).not.toBe('expired')
18
16
  })
19
17
 
20
18
  it('clears the session and stops when the refresh token is rejected', async () => {
21
- await login(creds)
19
+ await signIn(demoCredentials)
22
20
  const session = sessionStore.get()!
23
21
  // both tokens dead: the access token forces a 401, the refresh token can't fix it
24
22
  sessionStore.set({
@@ -28,7 +26,7 @@ it('clears the session and stops when the refresh token is rejected', async () =
28
26
  })
29
27
 
30
28
  const spy = vi.spyOn(globalThis, 'fetch')
31
- await expect(fetchMe()).rejects.toThrow()
29
+ await expect(getCurrentUser()).rejects.toThrow()
32
30
 
33
31
  expect(sessionStore.get()).toBeNull()
34
32
  // auth/me (401) + auth/refresh (401). A third call means the retry guard broke.
@@ -36,12 +34,12 @@ it('clears the session and stops when the refresh token is rejected', async () =
36
34
  })
37
35
 
38
36
  it('collapses concurrent 401s into a single refresh call', async () => {
39
- await login(creds)
37
+ await signIn(demoCredentials)
40
38
  const session = sessionStore.get()!
41
39
  sessionStore.set({ ...session, accessToken: 'expired' })
42
40
 
43
41
  const spy = vi.spyOn(globalThis, 'fetch')
44
- await Promise.all([fetchMe(), fetchMe(), fetchMe()])
42
+ await Promise.all([getCurrentUser(), getCurrentUser(), getCurrentUser()])
45
43
 
46
44
  const refreshCalls = spy.mock.calls.filter(([input]) =>
47
45
  String(input instanceof Request ? input.url : input).includes('auth/refresh')
@@ -49,10 +47,10 @@ it('collapses concurrent 401s into a single refresh call', async () => {
49
47
  expect(refreshCalls).toHaveLength(1)
50
48
  })
51
49
 
52
- // mock-server never rejects the retried request, so this case needs its own backend
50
+ // the mock backend never rejects the retried request, so this case needs its own backend
53
51
  it('stops after one refresh when the fresh token is also rejected', async () => {
54
52
  sessionStore.set({
55
- user: { id: 'u_demo', email: creds.email },
53
+ user: { id: 'u_demo', email: demoCredentials.email },
56
54
  accessToken: 'stale',
57
55
  refreshToken: 'stale'
58
56
  })
@@ -71,13 +69,13 @@ it('stops after one refresh when the fresh token is also rejected', async () =>
71
69
  })
72
70
  vi.spyOn(globalThis, 'fetch').mockImplementation(stub as unknown as typeof fetch)
73
71
 
74
- await expect(fetchMe()).rejects.toThrow()
72
+ await expect(getCurrentUser()).rejects.toThrow()
75
73
  // without the `retryCount > 0` guard the retried 401 triggers another refresh
76
74
  expect(refreshCalls).toBe(1)
77
75
  })
78
76
 
79
77
  it('refuses to refresh with no session at all', async () => {
80
78
  sessionStore.clear()
81
- await expect(fetchMe()).rejects.toThrow()
79
+ await expect(getCurrentUser()).rejects.toThrow()
82
80
  expect(sessionStore.get()).toBeNull()
83
81
  })
@@ -1,5 +1,4 @@
1
1
  import { HTTPError } from 'ky'
2
- import { beforeEach, expect, it, vi } from 'vitest'
3
2
 
4
3
  import { queryClient } from '@/lib/query-client'
5
4
 
@@ -9,8 +9,8 @@ import '@/index.css'
9
9
 
10
10
  // DEV first: a build-time literal, so the mock chunk is dropped from production
11
11
  if (import.meta.env.DEV && mocksEnabled) {
12
- const { installMockServer } = await import('@/mocks/mock-server')
13
- installMockServer()
12
+ const { installMockBackend } = await import('@/mocks/server')
13
+ installMockBackend()
14
14
  }
15
15
 
16
16
  const rootEl = document.getElementById('root')