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.
- package/index.js +9 -9
- package/package.json +1 -1
- package/templates/template-api/CLAUDE.md +1 -1
- package/templates/template-api/drizzle/0000_damp_taskmaster.sql +1 -1
- package/templates/template-api/drizzle/meta/0000_snapshot.json +2 -2
- package/templates/template-api/package.json +1 -1
- package/templates/template-api/src/app.test.ts +1 -1
- package/templates/template-api/src/db/schema.ts +1 -1
- package/templates/template-api/src/index.ts +2 -2
- package/templates/template-api/src/modules/auth/service.ts +2 -2
- package/templates/template-api/src/modules/notes/index.ts +7 -4
- package/templates/template-api/src/modules/notes/service.ts +9 -8
- package/templates/template-api/src/redis.ts +1 -1
- package/templates/template-router/.claude/skills/project-conventions/SKILL.md +8 -8
- package/templates/template-router/CLAUDE.md +1 -1
- package/templates/template-router/package.json +8 -7
- package/templates/template-router/src/api/auth/auth.test.ts +11 -35
- package/templates/template-router/src/api/auth/create-guards.ts +20 -0
- package/templates/template-router/src/api/auth/guards.ts +4 -11
- package/templates/template-router/src/api/auth/index.ts +9 -9
- package/templates/template-router/src/api/auth/session-store.test.ts +1 -3
- package/templates/template-router/src/api/{auth/refresh.test.ts → refresh.test.ts} +19 -21
- package/templates/template-router/src/lib/query-client.test.ts +0 -1
- package/templates/template-router/src/main.tsx +2 -2
- package/templates/template-router/src/mocks/{mock-server.ts → server.ts} +5 -5
- package/templates/template-router/src/providers/theme.test.tsx +5 -6
- package/templates/template-router/src/providers/theme.tsx +1 -1
- package/templates/template-router/src/routes/_authenticated.tsx +5 -5
- package/templates/template-router/src/routes/sign-in.tsx +11 -11
- package/templates/template-router/src/test/mock-backend.ts +15 -0
- package/templates/template-router/src/test/setup.ts +0 -1
- package/templates/template-router/vite.config.ts +1 -1
- package/templates/template-start/.claude/skills/project-conventions/SKILL.md +8 -8
- package/templates/template-start/CLAUDE.md +1 -1
- package/templates/template-start/package.json +8 -7
- package/templates/template-start/src/api/auth/auth.test.ts +11 -35
- package/templates/template-start/src/api/auth/create-guards.ts +20 -0
- package/templates/template-start/src/api/auth/guards.ts +6 -19
- package/templates/template-start/src/api/auth/index.ts +9 -9
- package/templates/template-start/src/api/auth/session-store.test.ts +1 -3
- package/templates/template-start/src/api/{auth/refresh.test.ts → refresh.test.ts} +19 -21
- package/templates/template-start/src/lib/query-client.test.ts +0 -1
- package/templates/template-start/src/mocks/{mock-server.ts → server.ts} +5 -5
- package/templates/template-start/src/providers/theme.test.tsx +5 -6
- package/templates/template-start/src/providers/theme.tsx +1 -1
- package/templates/template-start/src/router.tsx +2 -2
- package/templates/template-start/src/routes/_authenticated.tsx +5 -5
- package/templates/template-start/src/routes/sign-in.tsx +11 -11
- package/templates/template-start/src/test/mock-backend.ts +15 -0
- 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: '
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
|
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 "
|
|
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
|
|
@@ -111,7 +111,7 @@ describe('notes', () => {
|
|
|
111
111
|
expect(own.status).toBe(204)
|
|
112
112
|
})
|
|
113
113
|
|
|
114
|
-
it('
|
|
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('
|
|
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 {
|
|
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 ?
|
|
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
|
|
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
|
-
}:
|
|
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 {
|
|
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 }) =>
|
|
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 }) =>
|
|
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
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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()` / `
|
|
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` (`
|
|
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/
|
|
139
|
-
`VITE_ENABLE_MOCKS`. Seed
|
|
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
|
|
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.
|
|
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.
|
|
26
|
+
"cn": "^0.2.5",
|
|
27
27
|
"ky": "^2.1.0",
|
|
28
|
-
"lucide-react": "^1.
|
|
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.
|
|
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.
|
|
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.
|
|
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": "^
|
|
57
|
+
"vitest": "^5.0.0"
|
|
57
58
|
},
|
|
58
59
|
"trustedDependencies": []
|
|
59
60
|
}
|
|
@@ -1,46 +1,22 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
12
|
-
uninstall = installMockServer()
|
|
13
|
-
})
|
|
14
|
-
afterAll(() => uninstall())
|
|
15
|
-
afterEach(() => {
|
|
16
|
-
sessionStore.clear()
|
|
17
|
-
localStorage.clear()
|
|
18
|
-
})
|
|
5
|
+
setupMockBackend()
|
|
19
6
|
|
|
20
|
-
it('
|
|
21
|
-
const session = await
|
|
22
|
-
expect(session.user.email).toBe(
|
|
23
|
-
expect(sessionStore.get()?.user.email).toBe(
|
|
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(
|
|
14
|
+
await expect(signIn({ ...demoCredentials, password: 'wrong' })).rejects.toThrow()
|
|
28
15
|
expect(sessionStore.get()).toBeNull()
|
|
29
16
|
})
|
|
30
17
|
|
|
31
|
-
it('
|
|
32
|
-
await
|
|
33
|
-
|
|
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 {
|
|
2
|
-
|
|
1
|
+
import { createGuards } from '@/api/auth/create-guards'
|
|
3
2
|
import { sessionStore } from '@/api/auth/session-store'
|
|
4
3
|
|
|
5
|
-
export const
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
10
|
-
const session = await api.post('auth/
|
|
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
|
|
15
|
+
export const signOut = async () => {
|
|
16
16
|
try {
|
|
17
|
-
await api.post('auth/
|
|
17
|
+
await api.post('auth/sign-out')
|
|
18
18
|
} catch {}
|
|
19
19
|
sessionStore.clear()
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
export const
|
|
22
|
+
export const getCurrentUser = () => api.get('auth/me').json(UserSchema)
|
|
23
23
|
|
|
24
|
-
export const
|
|
24
|
+
export const useSignIn = () => {
|
|
25
25
|
const queryClient = useQueryClient()
|
|
26
26
|
return useMutation({
|
|
27
|
-
mutationFn:
|
|
27
|
+
mutationFn: signIn,
|
|
28
28
|
onSuccess: () => queryClient.clear()
|
|
29
29
|
})
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
export const
|
|
32
|
+
export const useSignOut = () => {
|
|
33
33
|
const queryClient = useQueryClient()
|
|
34
34
|
return useMutation({
|
|
35
|
-
mutationFn:
|
|
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: '
|
|
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 {
|
|
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 {
|
|
3
|
+
import { demoCredentials, setupMockBackend } from '@/test/mock-backend'
|
|
4
|
+
|
|
5
|
+
setupMockBackend()
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
12
|
+
const me = await getCurrentUser()
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
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(
|
|
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
|
|
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([
|
|
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
|
|
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:
|
|
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(
|
|
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(
|
|
79
|
+
await expect(getCurrentUser()).rejects.toThrow()
|
|
82
80
|
expect(sessionStore.get()).toBeNull()
|
|
83
81
|
})
|
|
@@ -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 {
|
|
13
|
-
|
|
12
|
+
const { installMockBackend } = await import('@/mocks/server')
|
|
13
|
+
installMockBackend()
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
const rootEl = document.getElementById('root')
|