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.
- package/index.js +13 -11
- 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/hooks/stop-check.sh +3 -12
- package/templates/template-router/.claude/skills/project-conventions/SKILL.md +8 -8
- package/templates/template-router/CLAUDE.md +7 -3
- package/templates/template-router/doctor.config.json +16 -0
- package/templates/template-router/package.json +12 -8
- 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 -13
- package/templates/template-router/src/api/auth/index.ts +14 -8
- package/templates/template-router/src/api/auth/router-bridge.ts +2 -2
- package/templates/template-router/src/api/auth/session-store.test.ts +2 -4
- package/templates/template-router/src/api/auth/session-store.ts +20 -42
- package/templates/template-router/src/api/{auth/refresh.test.ts → refresh.test.ts} +19 -21
- package/templates/template-router/src/components/back-home-button.tsx +17 -0
- package/templates/template-router/src/env.ts +2 -3
- package/templates/template-router/src/lib/query-client.test.ts +0 -1
- package/templates/template-router/src/lib/single-flight.test.ts +0 -36
- package/templates/template-router/src/lib/storage-store.ts +61 -0
- package/templates/template-router/src/main.tsx +2 -2
- package/templates/{template-start/src/mocks/mock-server.ts → template-router/src/mocks/server.ts} +5 -5
- package/templates/template-router/src/providers/theme.test.tsx +43 -35
- package/templates/template-router/src/providers/theme.tsx +14 -28
- package/templates/template-router/src/routes/-error.tsx +39 -23
- package/templates/template-router/src/routes/-not-found.tsx +34 -16
- package/templates/template-router/src/routes/_authenticated.tsx +5 -5
- package/templates/template-router/src/routes/sign-in.tsx +43 -36
- 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/hooks/stop-check.sh +3 -12
- package/templates/template-start/.claude/skills/project-conventions/SKILL.md +8 -8
- package/templates/template-start/CLAUDE.md +7 -3
- package/templates/template-start/doctor.config.json +16 -0
- package/templates/template-start/package.json +12 -8
- 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 -21
- package/templates/template-start/src/api/auth/index.ts +14 -8
- package/templates/template-start/src/api/auth/router-bridge.ts +2 -2
- package/templates/template-start/src/api/auth/session-store.test.ts +2 -4
- package/templates/template-start/src/api/auth/session-store.ts +21 -54
- package/templates/template-start/src/api/{auth/refresh.test.ts → refresh.test.ts} +19 -21
- package/templates/template-start/src/components/back-home-button.tsx +17 -0
- package/templates/template-start/src/env.ts +2 -3
- package/templates/template-start/src/lib/query-client.test.ts +0 -1
- package/templates/template-start/src/lib/single-flight.test.ts +0 -36
- package/templates/template-start/src/lib/storage-store.ts +61 -0
- package/templates/{template-router/src/mocks/mock-server.ts → template-start/src/mocks/server.ts} +5 -5
- package/templates/template-start/src/providers/theme.test.tsx +43 -35
- package/templates/template-start/src/providers/theme.tsx +14 -28
- package/templates/template-start/src/router.tsx +2 -2
- package/templates/template-start/src/routes/-error.tsx +39 -23
- package/templates/template-start/src/routes/-not-found.tsx +34 -16
- package/templates/template-start/src/routes/_authenticated.tsx +5 -5
- package/templates/template-start/src/routes/sign-in.tsx +43 -36
- package/templates/template-start/src/test/mock-backend.ts +15 -0
- package/templates/template-start/src/test/setup.ts +0 -1
- package/templates/template-router/src/components/status-page.tsx +0 -65
- 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: '
|
|
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"
|
|
@@ -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
|
|
234
|
-
for (const script of ['lint', 'lint:fix', 'format', 'format:check'])
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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),
|
|
@@ -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
|
|
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
|
-
|
|
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()` / `
|
|
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).
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
26
|
+
"cn": "^0.2.5",
|
|
25
27
|
"ky": "^2.1.0",
|
|
26
|
-
"lucide-react": "^1.
|
|
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.
|
|
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.
|
|
49
|
+
"happy-dom": "^20.14.0",
|
|
47
50
|
"oxfmt": "0.66.0",
|
|
48
51
|
"oxlint": "^1.81.0",
|
|
49
|
-
"
|
|
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": "^
|
|
57
|
+
"vitest": "^5.0.0"
|
|
54
58
|
},
|
|
55
59
|
"trustedDependencies": []
|
|
56
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,15 +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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
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
|
+
const queryClient = useQueryClient()
|
|
26
|
+
return useMutation({
|
|
27
|
+
mutationFn: signIn,
|
|
28
|
+
onSuccess: () => queryClient.clear()
|
|
29
|
+
})
|
|
30
|
+
}
|
|
25
31
|
|
|
26
|
-
export const
|
|
32
|
+
export const useSignOut = () => {
|
|
27
33
|
const queryClient = useQueryClient()
|
|
28
34
|
return useMutation({
|
|
29
|
-
mutationFn:
|
|
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(
|
|
8
|
-
if ((
|
|
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).
|
|
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: '
|
|
68
|
+
window.dispatchEvent(new StorageEvent('storage', { key: 'unrelated', newValue: 'x' }))
|
|
71
69
|
|
|
72
70
|
expect(store.get()?.user.id).toBe('u_1')
|
|
73
71
|
})
|