create-tigra 2.8.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -3
- package/bin/create-tigra.js +77 -37
- package/package.json +5 -5
- package/template/_claude/commands/create-server.md +8 -2
- package/template/_claude/rules/client/01-project-structure.md +12 -0
- package/template/_claude/rules/client/03-data-and-state.md +1 -1
- package/template/_claude/rules/client/04-design-system.md +23 -0
- package/template/_claude/rules/client/07-deployment.md +99 -0
- package/template/_claude/rules/client/08-lockfile-cross-platform.md +79 -0
- package/template/_claude/rules/client/core.md +1 -0
- package/template/_claude/rules/global/core.md +20 -1
- package/template/_claude/rules/global/investigation-before-conclusions.md +57 -0
- package/template/_claude/rules/server/core.md +2 -0
- package/template/_claude/rules/server/deployment.md +78 -0
- package/template/client/next.config.ts +12 -2
- package/template/client/package-lock.json +12345 -0
- package/template/client/package.json +3 -2
- package/template/client/src/components/common/SafeImage.tsx +2 -1
- package/template/client/src/lib/api/axios.config.ts +19 -4
- package/template/client/src/middleware.ts +7 -0
- package/template/gitignore +1 -0
- package/template/server/.env.example +42 -0
- package/template/server/.env.example.production +40 -0
- package/template/server/Dockerfile +29 -5
- package/template/server/docker-compose.yml +15 -4
- package/template/server/package-lock.json +6544 -6823
- package/template/server/package.json +76 -75
- package/template/server/prisma/seed.ts +20 -4
- package/template/server/src/app.ts +40 -8
- package/template/server/src/config/env.ts +72 -28
- package/template/server/src/config/rate-limit.config.ts +16 -0
- package/template/server/src/libs/__tests__/http.test.ts +23 -9
- package/template/server/src/libs/__tests__/origin-check.test.ts +53 -0
- package/template/server/src/libs/auth.ts +6 -16
- package/template/server/src/libs/cookies.ts +1 -1
- package/template/server/src/libs/duration.ts +30 -0
- package/template/server/src/libs/ip-block.ts +10 -4
- package/template/server/src/libs/origin-check.ts +38 -0
- package/template/server/src/libs/redis.ts +1 -1
- package/template/server/src/modules/auth/__tests__/auth.service.test.ts +274 -44
- package/template/server/src/modules/auth/auth.repo.ts +2 -0
- package/template/server/src/modules/auth/auth.service.ts +103 -12
- package/template/server/src/test/setup.ts +22 -2
- package/template/server/vitest.config.ts +43 -43
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
"build": "next build",
|
|
8
8
|
"start": "next start",
|
|
9
9
|
"lint": "eslint src/",
|
|
10
|
+
"typecheck": "tsc --noEmit",
|
|
10
11
|
"generate:env": "node -e \"import('fs').then(f=>{if(f.existsSync('.env'))console.log('.env already exists, skipping');else{f.copyFileSync('.env.example','.env');console.log('.env created from .env.example')}})\""
|
|
11
12
|
},
|
|
12
13
|
"dependencies": {
|
|
@@ -17,7 +18,7 @@
|
|
|
17
18
|
"class-variance-authority": "^0.7.1",
|
|
18
19
|
"clsx": "^2.1.1",
|
|
19
20
|
"lucide-react": "^0.563.0",
|
|
20
|
-
"next": "16.
|
|
21
|
+
"next": "16.2.9",
|
|
21
22
|
"next-themes": "^0.4.6",
|
|
22
23
|
"radix-ui": "^1.4.3",
|
|
23
24
|
"react": "19.2.3",
|
|
@@ -38,7 +39,7 @@
|
|
|
38
39
|
"@types/react": "^19",
|
|
39
40
|
"@types/react-dom": "^19",
|
|
40
41
|
"eslint": "^9",
|
|
41
|
-
"eslint-config-next": "16.
|
|
42
|
+
"eslint-config-next": "16.2.9",
|
|
42
43
|
"shadcn": "^3.8.4",
|
|
43
44
|
"tailwindcss": "^4",
|
|
44
45
|
"tw-animate-css": "^1.4.0",
|
|
@@ -36,12 +36,13 @@ function isLoopbackUrl(src: ImageProps['src']): boolean {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
export const SafeImage = (props: ImageProps): React.ReactElement => {
|
|
39
|
+
export const SafeImage = ({ alt, ...props }: ImageProps): React.ReactElement => {
|
|
40
40
|
const shouldSkipOptimization = isLoopbackUrl(props.src);
|
|
41
41
|
|
|
42
42
|
return (
|
|
43
43
|
<Image
|
|
44
44
|
{...props}
|
|
45
|
+
alt={alt}
|
|
45
46
|
unoptimized={props.unoptimized || shouldSkipOptimization}
|
|
46
47
|
/>
|
|
47
48
|
);
|
|
@@ -108,15 +108,30 @@ apiClient.interceptors.response.use(
|
|
|
108
108
|
refreshError.response.status < 500;
|
|
109
109
|
|
|
110
110
|
if (isAuthFailure) {
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
111
|
+
// The bootstrap probe (AuthInitializer's getMe) runs on EVERY page,
|
|
112
|
+
// including public ones. For an anonymous visitor, getMe 401 →
|
|
113
|
+
// refresh 401 is the NORMAL "not logged in" outcome — not a dead
|
|
114
|
+
// session. The probe must fail silently to "anonymous": clear any
|
|
115
|
+
// stale Redux state, but never trip the circuit breaker, clear the
|
|
116
|
+
// session cookie, or hard-redirect a visitor off a public page.
|
|
117
|
+
// (On protected pages, AuthInitializer handles the redirect itself.)
|
|
118
|
+
// Note: a genuine request queued behind a probe-initiated refresh is
|
|
119
|
+
// rejected via processQueue without tripping the breaker or
|
|
120
|
+
// redirecting — AuthInitializer covers the redirect on protected pages.
|
|
121
|
+
const isBootstrapProbe = originalRequest.url === API_ENDPOINTS.AUTH.ME;
|
|
122
|
+
|
|
123
|
+
if (!isBootstrapProbe) {
|
|
124
|
+
// A genuine authenticated action failed mid-session — the session
|
|
125
|
+
// is dead. Trip the circuit breaker FIRST — prevents any subsequent
|
|
126
|
+
// 401s from re-entering this flow while the redirect is pending.
|
|
127
|
+
isSessionDead = true;
|
|
128
|
+
}
|
|
114
129
|
|
|
115
130
|
const { logout } = await import('@/features/auth/store/authSlice');
|
|
116
131
|
const { store } = await import('@/store');
|
|
117
132
|
store.dispatch(logout());
|
|
118
133
|
|
|
119
|
-
if (typeof window !== 'undefined') {
|
|
134
|
+
if (!isBootstrapProbe && typeof window !== 'undefined') {
|
|
120
135
|
// Clear session indicator so middleware won't let user through
|
|
121
136
|
// to protected pages — prevents redirect loops
|
|
122
137
|
document.cookie = 'auth_session=; Max-Age=0; path=/; SameSite=Strict';
|
|
@@ -16,6 +16,13 @@ function isTokenExpired(token: string): boolean {
|
|
|
16
16
|
|
|
17
17
|
export function middleware(request: NextRequest): NextResponse {
|
|
18
18
|
const { pathname } = request.nextUrl;
|
|
19
|
+
|
|
20
|
+
// NOTE: This middleware only checks COOKIE PRESENCE (and a best-effort,
|
|
21
|
+
// unverified exp claim) for UX-level routing — redirecting users who are
|
|
22
|
+
// obviously logged out away from protected pages. It does NOT verify the
|
|
23
|
+
// JWT signature and is NOT a security boundary. Real authorization happens
|
|
24
|
+
// server-side on every API call; a forged cookie gets past this middleware
|
|
25
|
+
// but every API request it makes will be rejected with 401/403.
|
|
19
26
|
const accessToken = request.cookies.get('access_token')?.value;
|
|
20
27
|
const authSession = request.cookies.get('auth_session')?.value;
|
|
21
28
|
|
package/template/gitignore
CHANGED
|
@@ -21,6 +21,19 @@ PORT=8000
|
|
|
21
21
|
# Server host (0.0.0.0 = listen on all interfaces)
|
|
22
22
|
HOST=0.0.0.0
|
|
23
23
|
|
|
24
|
+
# ===================================================================
|
|
25
|
+
# SERVER TIMEOUTS
|
|
26
|
+
# ===================================================================
|
|
27
|
+
|
|
28
|
+
# Fastify request timeout in milliseconds (default: 30000 = 30s)
|
|
29
|
+
# Long-running routes (LLM calls, big exports) may need 180000+ (180s).
|
|
30
|
+
# IMPORTANT: the reverse proxy (Nginx/Coolify) timeout must be raised to
|
|
31
|
+
# match, or the proxy cuts the connection before the server does.
|
|
32
|
+
REQUEST_TIMEOUT_MS=30000
|
|
33
|
+
|
|
34
|
+
# Fastify connection timeout in milliseconds (default: 60000 = 60s)
|
|
35
|
+
CONNECTION_TIMEOUT_MS=60000
|
|
36
|
+
|
|
24
37
|
# ===================================================================
|
|
25
38
|
# DATABASE CONFIGURATION (MySQL 8.0+)
|
|
26
39
|
# ===================================================================
|
|
@@ -67,6 +80,25 @@ RATE_LIMIT_MULTIPLIER=1
|
|
|
67
80
|
# RATE_LIMIT_AUTH_LOGIN_MAX=10
|
|
68
81
|
# RATE_LIMIT_AUTH_REGISTER_MAX=5
|
|
69
82
|
|
|
83
|
+
# ===================================================================
|
|
84
|
+
# IP AUTO-BLOCK
|
|
85
|
+
# ===================================================================
|
|
86
|
+
#
|
|
87
|
+
# An IP that exceeds rate limits IP_AUTO_BLOCK_THRESHOLD times within
|
|
88
|
+
# IP_AUTO_BLOCK_WINDOW_SECONDS is blocked for IP_AUTO_BLOCK_DURATION_SECONDS.
|
|
89
|
+
# The threshold targets SUSTAINED abuse — keep it high enough that a
|
|
90
|
+
# retry-looping legitimate client or a NAT'd office sharing one IP cannot
|
|
91
|
+
# self-ban. See src/config/rate-limit.config.ts for the interaction notes.
|
|
92
|
+
|
|
93
|
+
# Rate-limit violations before an IP is auto-blocked (default: 20)
|
|
94
|
+
IP_AUTO_BLOCK_THRESHOLD=20
|
|
95
|
+
|
|
96
|
+
# Sliding window for counting violations, in seconds (default: 300 = 5 min)
|
|
97
|
+
IP_AUTO_BLOCK_WINDOW_SECONDS=300
|
|
98
|
+
|
|
99
|
+
# How long an auto-blocked IP stays blocked, in seconds (default: 3600 = 1 hour)
|
|
100
|
+
IP_AUTO_BLOCK_DURATION_SECONDS=3600
|
|
101
|
+
|
|
70
102
|
# ===================================================================
|
|
71
103
|
# ACCOUNT ACTIVATION
|
|
72
104
|
# ===================================================================
|
|
@@ -183,6 +215,16 @@ CLIENT_URL="http://localhost:3000"
|
|
|
183
215
|
# Staging: info
|
|
184
216
|
LOG_LEVEL=info
|
|
185
217
|
|
|
218
|
+
# ===================================================================
|
|
219
|
+
# DATABASE SEEDING (npm run prisma:seed — dev/test only)
|
|
220
|
+
# ===================================================================
|
|
221
|
+
|
|
222
|
+
# Passwords for the seeded demo accounts (admin@example.com / user@example.com).
|
|
223
|
+
# Optional in development — well-known dev defaults (Admin123! / User123!) are
|
|
224
|
+
# used when unset. The seed script REFUSES to run when NODE_ENV=production.
|
|
225
|
+
# SEED_ADMIN_PASSWORD="choose-a-dev-admin-password"
|
|
226
|
+
# SEED_USER_PASSWORD="choose-a-dev-user-password"
|
|
227
|
+
|
|
186
228
|
# ===================================================================
|
|
187
229
|
# ERROR TRACKING (Optional)
|
|
188
230
|
# ===================================================================
|
|
@@ -23,6 +23,19 @@ NODE_ENV=production
|
|
|
23
23
|
PORT=3000
|
|
24
24
|
HOST=0.0.0.0
|
|
25
25
|
|
|
26
|
+
# ===================================================================
|
|
27
|
+
# SERVER TIMEOUTS
|
|
28
|
+
# ===================================================================
|
|
29
|
+
|
|
30
|
+
# Fastify request timeout in milliseconds (default: 30000 = 30s)
|
|
31
|
+
# Long-running routes (LLM calls, big exports) may need 180000+ (180s).
|
|
32
|
+
# CRITICAL: the reverse proxy (Nginx/Coolify) timeout must be raised to
|
|
33
|
+
# match, or the proxy cuts the connection before the server does.
|
|
34
|
+
REQUEST_TIMEOUT_MS=30000
|
|
35
|
+
|
|
36
|
+
# Fastify connection timeout in milliseconds (default: 60000 = 60s)
|
|
37
|
+
CONNECTION_TIMEOUT_MS=60000
|
|
38
|
+
|
|
26
39
|
# ===================================================================
|
|
27
40
|
# DATABASE (MySQL 8.0+)
|
|
28
41
|
# ===================================================================
|
|
@@ -65,6 +78,22 @@ RATE_LIMIT_MULTIPLIER=1
|
|
|
65
78
|
RATE_LIMIT_AUTH_LOGIN_MAX=10
|
|
66
79
|
RATE_LIMIT_AUTH_REGISTER_MAX=5
|
|
67
80
|
|
|
81
|
+
# ===================================================================
|
|
82
|
+
# IP AUTO-BLOCK
|
|
83
|
+
# ===================================================================
|
|
84
|
+
|
|
85
|
+
# Rate-limit violations before an IP is auto-blocked for every route.
|
|
86
|
+
# Keep HIGH enough that a retry-looping legitimate client or a NAT'd
|
|
87
|
+
# office sharing one egress IP cannot self-ban (sustained abuse only).
|
|
88
|
+
# See src/config/rate-limit.config.ts before lowering.
|
|
89
|
+
IP_AUTO_BLOCK_THRESHOLD=20
|
|
90
|
+
|
|
91
|
+
# Sliding window for counting violations, in seconds (5 minutes)
|
|
92
|
+
IP_AUTO_BLOCK_WINDOW_SECONDS=300
|
|
93
|
+
|
|
94
|
+
# How long an auto-blocked IP stays blocked, in seconds (1 hour)
|
|
95
|
+
IP_AUTO_BLOCK_DURATION_SECONDS=3600
|
|
96
|
+
|
|
68
97
|
# ===================================================================
|
|
69
98
|
# ACCOUNT ACTIVATION
|
|
70
99
|
# ===================================================================
|
|
@@ -142,6 +171,17 @@ CLIENT_URL="https://yourdomain.com"
|
|
|
142
171
|
# Use warn to reduce costs in high-traffic scenarios
|
|
143
172
|
LOG_LEVEL=info
|
|
144
173
|
|
|
174
|
+
# ===================================================================
|
|
175
|
+
# DATABASE SEEDING
|
|
176
|
+
# ===================================================================
|
|
177
|
+
|
|
178
|
+
# DO NOT SEED PRODUCTION. The seed script (npm run prisma:seed) creates
|
|
179
|
+
# well-known demo accounts (admin@example.com) and hard-refuses to run when
|
|
180
|
+
# NODE_ENV=production. These variables exist only to keep .env files in sync
|
|
181
|
+
# across environments — leave them commented out in production.
|
|
182
|
+
# SEED_ADMIN_PASSWORD=
|
|
183
|
+
# SEED_USER_PASSWORD=
|
|
184
|
+
|
|
145
185
|
# ===================================================================
|
|
146
186
|
# ERROR TRACKING
|
|
147
187
|
# ===================================================================
|
|
@@ -23,6 +23,18 @@ RUN if [ -f pnpm-lock.yaml ]; then \
|
|
|
23
23
|
npm ci --omit=dev; \
|
|
24
24
|
fi
|
|
25
25
|
|
|
26
|
+
# Add the Prisma CLI to the production node_modules so the runtime image can
|
|
27
|
+
# run `npx prisma migrate deploy` at container start (see CMD in stage 3).
|
|
28
|
+
# `prisma` is a devDependency (correct for local dev), so the prod-only install
|
|
29
|
+
# above does NOT include it — without this step, npx would try to download the
|
|
30
|
+
# CLI from the registry on every container boot. Pinned to the exact
|
|
31
|
+
# @prisma/client version the lockfile installed, so CLI and client never drift.
|
|
32
|
+
# --no-save keeps package.json and the lockfile untouched. Because this stage
|
|
33
|
+
# runs on the same Alpine (musl) base as the runtime stage, the engine binaries
|
|
34
|
+
# downloaded here are the correct ones for production.
|
|
35
|
+
RUN npm install --no-save --no-audit --no-fund \
|
|
36
|
+
"prisma@$(node -p "require('@prisma/client/package.json').version")"
|
|
37
|
+
|
|
26
38
|
# ===================================================================
|
|
27
39
|
# Stage 2: Build (compile TypeScript)
|
|
28
40
|
# ===================================================================
|
|
@@ -69,7 +81,9 @@ RUN addgroup -g 1001 -S nodejs && \
|
|
|
69
81
|
|
|
70
82
|
WORKDIR /app
|
|
71
83
|
|
|
72
|
-
# Copy production dependencies from dependencies stage
|
|
84
|
+
# Copy production dependencies from dependencies stage.
|
|
85
|
+
# Includes the Prisma CLI (+ its engines) installed in stage 1, which the
|
|
86
|
+
# CMD below needs for `npx prisma migrate deploy`.
|
|
73
87
|
COPY --from=dependencies --chown=nodejs:nodejs /app/node_modules ./node_modules
|
|
74
88
|
|
|
75
89
|
# Copy generated Prisma client from builder stage (not present in prod deps)
|
|
@@ -91,12 +105,22 @@ USER nodejs
|
|
|
91
105
|
# Expose port (matches default PORT in env.ts; override via PORT env var)
|
|
92
106
|
EXPOSE 8000
|
|
93
107
|
|
|
94
|
-
# Health check for container orchestration
|
|
95
|
-
|
|
108
|
+
# Health check for container orchestration.
|
|
109
|
+
# start-period is 60s because CMD runs `prisma migrate deploy` BEFORE the server
|
|
110
|
+
# starts listening — a slow migration must not be counted as unhealthy, or
|
|
111
|
+
# Coolify restart-loops the container mid-migration.
|
|
112
|
+
HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \
|
|
96
113
|
CMD node -e "const p=process.env.PORT||8000;require('http').get('http://localhost:'+p+'/api/v1/live',(r)=>{process.exit(r.statusCode===200?0:1)})"
|
|
97
114
|
|
|
98
115
|
# Use dumb-init to handle signals properly
|
|
99
116
|
ENTRYPOINT ["dumb-init", "--"]
|
|
100
117
|
|
|
101
|
-
# Start the application
|
|
102
|
-
|
|
118
|
+
# Start the application — apply any pending Prisma migrations first, then boot.
|
|
119
|
+
# Runs inside Coolify on every container start, where DATABASE_URL is reachable
|
|
120
|
+
# (it isn't from CI, which is why migrations belong here and not in a workflow).
|
|
121
|
+
# `migrate deploy` is idempotent: it applies only pending migrations and exits 0
|
|
122
|
+
# when there is nothing to do. Requirements, all satisfied above:
|
|
123
|
+
# - prisma CLI in ./node_modules (installed in stage 1, copied with node_modules)
|
|
124
|
+
# - ./prisma/schema.prisma + ./prisma/migrations (copied from builder)
|
|
125
|
+
# `exec` replaces the shell so node receives signals from dumb-init directly.
|
|
126
|
+
CMD ["sh", "-c", "npx prisma migrate deploy && exec node dist/server.js"]
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# WARNING: These credentials are for LOCAL DEVELOPMENT ONLY.
|
|
2
2
|
# Change all passwords and secrets before using in any shared or production environment.
|
|
3
|
+
#
|
|
4
|
+
# Security posture:
|
|
5
|
+
# - All published ports are bound to 127.0.0.1 — nothing is reachable from the
|
|
6
|
+
# network (an unpassworded Redis or root-password MySQL on 0.0.0.0 is an open
|
|
7
|
+
# door on shared networks/VPS dev boxes). Don't remove the 127.0.0.1 prefix.
|
|
8
|
+
# - The admin UIs (phpMyAdmin, Redis Commander) are behind the "tools" profile
|
|
9
|
+
# and do NOT start by default:
|
|
10
|
+
# docker compose up -d → MySQL + Redis only
|
|
11
|
+
# docker compose --profile tools up -d → also phpMyAdmin + Redis Commander
|
|
3
12
|
|
|
4
13
|
name: {{PROJECT_NAME}}
|
|
5
14
|
|
|
@@ -9,7 +18,7 @@ services:
|
|
|
9
18
|
container_name: {{PROJECT_NAME}}-mysql
|
|
10
19
|
restart: unless-stopped
|
|
11
20
|
ports:
|
|
12
|
-
- '
|
|
21
|
+
- '127.0.0.1:${MYSQL_PORT:-{{MYSQL_PORT}}}:3306'
|
|
13
22
|
environment:
|
|
14
23
|
MYSQL_ROOT_PASSWORD: rootpassword
|
|
15
24
|
MYSQL_DATABASE: {{DATABASE_NAME}}
|
|
@@ -27,8 +36,9 @@ services:
|
|
|
27
36
|
image: phpmyadmin:latest
|
|
28
37
|
container_name: {{PROJECT_NAME}}-phpmyadmin
|
|
29
38
|
restart: unless-stopped
|
|
39
|
+
profiles: ["tools"]
|
|
30
40
|
ports:
|
|
31
|
-
- '
|
|
41
|
+
- '127.0.0.1:${PHPMYADMIN_PORT:-{{PHPMYADMIN_PORT}}}:80'
|
|
32
42
|
environment:
|
|
33
43
|
PMA_HOST: mysql
|
|
34
44
|
PMA_PORT: 3306
|
|
@@ -48,7 +58,7 @@ services:
|
|
|
48
58
|
container_name: {{PROJECT_NAME}}-redis
|
|
49
59
|
restart: unless-stopped
|
|
50
60
|
ports:
|
|
51
|
-
- '
|
|
61
|
+
- '127.0.0.1:${REDIS_PORT:-{{REDIS_PORT}}}:6379'
|
|
52
62
|
volumes:
|
|
53
63
|
- redis_data:/data
|
|
54
64
|
networks:
|
|
@@ -63,8 +73,9 @@ services:
|
|
|
63
73
|
image: rediscommander/redis-commander:latest
|
|
64
74
|
container_name: {{PROJECT_NAME}}-redis-commander
|
|
65
75
|
restart: unless-stopped
|
|
76
|
+
profiles: ["tools"]
|
|
66
77
|
ports:
|
|
67
|
-
- '
|
|
78
|
+
- '127.0.0.1:${REDIS_COMMANDER_PORT:-{{REDIS_COMMANDER_PORT}}}:8081'
|
|
68
79
|
environment:
|
|
69
80
|
REDIS_HOSTS: local:redis:6379
|
|
70
81
|
depends_on:
|