create-tigra 2.2.0 → 2.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 (47) hide show
  1. package/bin/create-tigra.js +14 -1
  2. package/package.json +4 -1
  3. package/template/_claude/commands/create-client.md +1 -4
  4. package/template/_claude/commands/create-server.md +0 -1
  5. package/template/_claude/hooks/restrict-paths.sh +2 -2
  6. package/template/_claude/rules/client/01-project-structure.md +0 -3
  7. package/template/_claude/rules/client/03-data-and-state.md +1 -1
  8. package/template/_claude/rules/server/project-conventions.md +40 -0
  9. package/template/client/package.json +2 -2
  10. package/template/client/public/logo.png +0 -0
  11. package/template/client/src/app/globals.css +61 -59
  12. package/template/client/src/app/icon.png +0 -0
  13. package/template/client/src/app/page.tsx +66 -35
  14. package/template/client/src/app/providers.tsx +0 -2
  15. package/template/client/src/components/common/SafeImage.tsx +48 -0
  16. package/template/client/src/features/auth/hooks/useAuth.ts +2 -2
  17. package/template/client/src/lib/api/axios.config.ts +14 -7
  18. package/template/client/src/middleware.ts +20 -29
  19. package/template/server/.env.example +9 -0
  20. package/template/server/.env.example.production +9 -0
  21. package/template/server/package.json +2 -1
  22. package/template/server/postman/collection.json +114 -5
  23. package/template/server/postman/environment.json +2 -2
  24. package/template/server/prisma/schema.prisma +17 -1
  25. package/template/server/src/app.ts +4 -1
  26. package/template/server/src/config/env.ts +6 -0
  27. package/template/server/src/jobs/cleanup-deleted-accounts.job.ts +3 -6
  28. package/template/server/src/libs/auth.ts +45 -1
  29. package/template/server/src/libs/cookies.ts +35 -4
  30. package/template/server/src/libs/ip-block.ts +90 -29
  31. package/template/server/src/libs/requestLogger.ts +1 -1
  32. package/template/server/src/libs/storage/file-storage.service.ts +65 -18
  33. package/template/server/src/libs/storage/file-validator.ts +0 -8
  34. package/template/server/src/modules/admin/admin.controller.ts +4 -3
  35. package/template/server/src/modules/auth/auth.repo.ts +18 -0
  36. package/template/server/src/modules/auth/auth.service.ts +52 -26
  37. package/template/server/src/modules/users/users.controller.ts +39 -21
  38. package/template/server/src/modules/users/users.routes.ts +127 -6
  39. package/template/server/src/modules/users/users.schemas.ts +24 -4
  40. package/template/server/src/modules/users/users.service.ts +23 -10
  41. package/template/server/src/shared/types/index.ts +2 -0
  42. package/template/client/src/app/(auth)/layout.tsx +0 -18
  43. package/template/client/src/app/(auth)/login/page.tsx +0 -13
  44. package/template/client/src/app/(auth)/register/page.tsx +0 -13
  45. package/template/client/src/app/(main)/dashboard/page.tsx +0 -22
  46. package/template/client/src/app/(main)/layout.tsx +0 -11
  47. package/template/client/src/app/favicon.ico +0 -0
@@ -2,8 +2,7 @@ import { NextResponse } from 'next/server';
2
2
 
3
3
  import type { NextRequest } from 'next/server';
4
4
 
5
- const protectedPaths = ['/', '/dashboard', '/profile', '/admin'];
6
- const authPaths = ['/login', '/register'];
5
+ const protectedPaths = ['/dashboard', '/profile', '/admin'];
7
6
 
8
7
  function isTokenExpired(token: string): boolean {
9
8
  try {
@@ -17,38 +16,33 @@ function isTokenExpired(token: string): boolean {
17
16
 
18
17
  export function middleware(request: NextRequest): NextResponse {
19
18
  const { pathname } = request.nextUrl;
20
- const token = request.cookies.get('access_token')?.value;
19
+ const accessToken = request.cookies.get('access_token')?.value;
20
+ const authSession = request.cookies.get('auth_session')?.value;
21
21
 
22
- const isProtectedPath = protectedPaths.some((path) =>
23
- path === '/' ? pathname === '/' : pathname.startsWith(path)
24
- );
22
+ const isProtectedPath = protectedPaths.some((path) => pathname.startsWith(path));
25
23
 
26
- // No token at all — user is not logged in, redirect to login
27
- if (isProtectedPath && !token) {
28
- const loginUrl = new URL('/login', request.url);
29
- loginUrl.searchParams.set('from', pathname);
30
- return NextResponse.redirect(loginUrl);
31
- }
32
-
33
- // Expired token on protected path — allow through so client-side can attempt refresh.
34
- // Delete the stale access_token so AuthInitializer starts fresh: getMe() → 401 → refresh.
35
- if (isProtectedPath && token && isTokenExpired(token)) {
36
- const response = NextResponse.next();
37
- response.cookies.delete('access_token');
38
- return response;
39
- }
24
+ if (isProtectedPath) {
25
+ // No access_token AND no auth_session — truly unauthenticated
26
+ if (!accessToken && !authSession) {
27
+ const loginUrl = new URL('/login', request.url);
28
+ loginUrl.searchParams.set('from', pathname);
29
+ return NextResponse.redirect(loginUrl);
30
+ }
40
31
 
41
- const isAuthPath = authPaths.some((path) => pathname.startsWith(path));
32
+ // No access_token BUT auth_session exists — session is alive,
33
+ // let through so client-side can do getMe() → 401 → refresh → retry
34
+ if (!accessToken && authSession) {
35
+ return NextResponse.next();
36
+ }
42
37
 
43
- if (isAuthPath && token) {
44
- if (isTokenExpired(token)) {
45
- // Only delete the expired access token — keep the refresh token
46
- // so the client-side interceptor can still recover the session
38
+ // Expired access_token delete stale cookie, let through for client-side refresh
39
+ if (accessToken && isTokenExpired(accessToken)) {
47
40
  const response = NextResponse.next();
48
41
  response.cookies.delete('access_token');
49
42
  return response;
50
43
  }
51
- return NextResponse.redirect(new URL('/', request.url));
44
+
45
+ return NextResponse.next();
52
46
  }
53
47
 
54
48
  return NextResponse.next();
@@ -56,11 +50,8 @@ export function middleware(request: NextRequest): NextResponse {
56
50
 
57
51
  export const config = {
58
52
  matcher: [
59
- '/',
60
53
  '/dashboard/:path*',
61
54
  '/profile/:path*',
62
55
  '/admin/:path*',
63
- '/login',
64
- '/register',
65
56
  ],
66
57
  };
@@ -94,6 +94,15 @@ JWT_REFRESH_EXPIRY="7d"
94
94
  # For production: generate a separate secret: openssl rand -base64 48
95
95
  # COOKIE_SECRET="change-this-to-a-different-secret-at-least-32-chars"
96
96
 
97
+ # Cookie domain for cross-origin deployments
98
+ # REQUIRED when client and API are on different subdomains:
99
+ # Client: https://app.example.com | API: https://api.example.com
100
+ # → Set COOKIE_DOMAIN=".example.com" (note the leading dot)
101
+ # NOT needed when client and API share the same hostname (local dev, same-origin prod)
102
+ # Without this, cookies are scoped to the API hostname only and the browser
103
+ # will silently reject them on cross-origin requests (login appears to do nothing).
104
+ # COOKIE_DOMAIN=".example.com"
105
+
97
106
  # ===================================================================
98
107
  # CORS (Cross-Origin Resource Sharing)
99
108
  # ===================================================================
@@ -80,6 +80,14 @@ JWT_REFRESH_EXPIRY="7d"
80
80
  # Multiple origins separated by commas
81
81
  CORS_ORIGIN="https://yourdomain.com,https://app.yourdomain.com"
82
82
 
83
+ # Cookie domain for cross-origin deployments
84
+ # REQUIRED when client and API are on different subdomains:
85
+ # Client: https://app.example.com | API: https://api.example.com
86
+ # → Set COOKIE_DOMAIN=".example.com" (leading dot = all subdomains)
87
+ # This enables cookies to be shared between client and API subdomains.
88
+ # Without it, login will silently fail (server returns 200 but browser drops cookies).
89
+ COOKIE_DOMAIN=".yourdomain.com"
90
+
83
91
  # ===================================================================
84
92
  # LOGGING
85
93
  # ===================================================================
@@ -102,6 +110,7 @@ SENTRY_DSN="https://YOUR_PUBLIC_KEY@o0.ingest.sentry.io/YOUR_PROJECT_ID"
102
110
  # [ ] DATABASE_URL uses secure credentials and SSL
103
111
  # [ ] JWT_SECRET is a strong random string (48+ chars)
104
112
  # [ ] CORS_ORIGIN is set to your exact frontend URL(s)
113
+ # [ ] COOKIE_DOMAIN is set if client and API are on different subdomains
105
114
  # [ ] REDIS_URL uses authentication if Redis is exposed
106
115
  # [ ] LOG_LEVEL is set to info or warn
107
116
  # [ ] SENTRY_DSN is configured for error tracking
@@ -22,7 +22,8 @@
22
22
  "redis:flush": "tsx scripts/flush-redis.ts",
23
23
  "docker:up": "docker compose up -d",
24
24
  "docker:down": "docker compose down",
25
- "docker:logs": "docker compose logs -f"
25
+ "docker:logs": "docker compose logs -f",
26
+ "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')}})\""
26
27
  },
27
28
  "prisma": {
28
29
  "seed": "tsx prisma/seed.ts"
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "info": {
3
- "name": "Tigra Server API",
4
- "_postman_id": "tigra-server-collection",
5
- "description": "API collection for the Tigra server template.\n\nAll authenticated requests inherit Bearer Token auth from the collection root.\nLogin/Register requests auto-set tokens via test scripts.",
3
+ "name": "{{PROJECT_DISPLAY_NAME}} API",
4
+ "_postman_id": "{{PROJECT_NAME}}-server-collection",
5
+ "description": "API collection for {{PROJECT_DISPLAY_NAME}}.\n\nAll authenticated requests inherit Bearer Token auth from the collection root.\nLogin/Register requests auto-set tokens via test scripts.",
6
6
  "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
7
7
  },
8
8
  "auth": {
@@ -31,6 +31,10 @@
31
31
  {
32
32
  "key": "userId",
33
33
  "value": ""
34
+ },
35
+ {
36
+ "key": "targetUserId",
37
+ "value": ""
34
38
  }
35
39
  ],
36
40
  "item": [
@@ -358,6 +362,111 @@
358
362
  "name": "Admin",
359
363
  "description": "Admin-only endpoints. Requires ADMIN role. Auth inherited from collection root.",
360
364
  "item": [
365
+ {
366
+ "name": "User Management",
367
+ "description": "Manage users by ID. Requires ADMIN role. Set {{targetUserId}} before calling.",
368
+ "item": [
369
+ {
370
+ "name": "Update Profile (by ID)",
371
+ "request": {
372
+ "method": "PATCH",
373
+ "header": [
374
+ {
375
+ "key": "Content-Type",
376
+ "value": "application/json"
377
+ }
378
+ ],
379
+ "body": {
380
+ "mode": "raw",
381
+ "raw": "{\n \"firstName\": \"Jane\",\n \"lastName\": \"Smith\"\n}"
382
+ },
383
+ "url": {
384
+ "raw": "{{baseUrl}}/users/{{targetUserId}}",
385
+ "host": ["{{baseUrl}}"],
386
+ "path": ["users", "{{targetUserId}}"]
387
+ },
388
+ "description": "Update a user's profile by ID. Requires owner or admin access."
389
+ },
390
+ "response": []
391
+ },
392
+ {
393
+ "name": "Change Password (by ID)",
394
+ "request": {
395
+ "method": "PATCH",
396
+ "header": [
397
+ {
398
+ "key": "Content-Type",
399
+ "value": "application/json"
400
+ }
401
+ ],
402
+ "body": {
403
+ "mode": "raw",
404
+ "raw": "{\n \"newPassword\": \"NewPassword456\"\n}"
405
+ },
406
+ "url": {
407
+ "raw": "{{baseUrl}}/users/{{targetUserId}}/password",
408
+ "host": ["{{baseUrl}}"],
409
+ "path": ["users", "{{targetUserId}}", "password"]
410
+ },
411
+ "description": "Change a user's password by ID. Admin: only newPassword required. Invalidates all target user sessions."
412
+ },
413
+ "response": []
414
+ },
415
+ {
416
+ "name": "Delete Account (by ID)",
417
+ "request": {
418
+ "method": "DELETE",
419
+ "header": [],
420
+ "url": {
421
+ "raw": "{{baseUrl}}/users/{{targetUserId}}",
422
+ "host": ["{{baseUrl}}"],
423
+ "path": ["users", "{{targetUserId}}"]
424
+ },
425
+ "description": "Soft-delete a user's account by ID. No password required for admin. Invalidates all target user sessions."
426
+ },
427
+ "response": []
428
+ },
429
+ {
430
+ "name": "Upload Avatar (by ID)",
431
+ "request": {
432
+ "method": "POST",
433
+ "header": [],
434
+ "body": {
435
+ "mode": "formdata",
436
+ "formdata": [
437
+ {
438
+ "key": "file",
439
+ "type": "file",
440
+ "src": "",
441
+ "description": "Image file (JPEG, PNG, or WebP, max 10MB)"
442
+ }
443
+ ]
444
+ },
445
+ "url": {
446
+ "raw": "{{baseUrl}}/users/{{targetUserId}}/avatar",
447
+ "host": ["{{baseUrl}}"],
448
+ "path": ["users", "{{targetUserId}}", "avatar"]
449
+ },
450
+ "description": "Upload or replace a user's avatar by ID. Requires admin access. Accepts JPEG, PNG, or WebP."
451
+ },
452
+ "response": []
453
+ },
454
+ {
455
+ "name": "Delete Avatar (by ID)",
456
+ "request": {
457
+ "method": "DELETE",
458
+ "header": [],
459
+ "url": {
460
+ "raw": "{{baseUrl}}/users/{{targetUserId}}/avatar",
461
+ "host": ["{{baseUrl}}"],
462
+ "path": ["users", "{{targetUserId}}", "avatar"]
463
+ },
464
+ "description": "Delete a user's avatar by ID. Requires admin access."
465
+ },
466
+ "response": []
467
+ }
468
+ ]
469
+ },
361
470
  {
362
471
  "name": "List Blocked IPs",
363
472
  "request": {
@@ -384,14 +493,14 @@
384
493
  ],
385
494
  "body": {
386
495
  "mode": "raw",
387
- "raw": "{\n \"ip\": \"1.2.3.4\"\n}"
496
+ "raw": "{\n \"ip\": \"1.2.3.4\",\n \"reason\": \"Spam bot\"\n}"
388
497
  },
389
498
  "url": {
390
499
  "raw": "{{baseUrl}}/admin/blocked-ips",
391
500
  "host": ["{{baseUrl}}"],
392
501
  "path": ["admin", "blocked-ips"]
393
502
  },
394
- "description": "Permanently block an IP address. Blocked IPs receive 403 IP_BLOCKED on all requests."
503
+ "description": "Permanently block an IP address. Persisted to database and cached in Redis. Blocked IPs receive 403 IP_BLOCKED on all requests. Optional reason field for audit trail."
395
504
  },
396
505
  "response": []
397
506
  },
@@ -1,6 +1,6 @@
1
1
  {
2
- "id": "tigra-server-env",
3
- "name": "Tigra Server - Local",
2
+ "id": "{{PROJECT_NAME}}-server-env",
3
+ "name": "{{PROJECT_DISPLAY_NAME}} Server - Local",
4
4
  "values": [
5
5
  {
6
6
  "key": "baseUrl",
@@ -18,7 +18,7 @@ model User {
18
18
  password String
19
19
  firstName String
20
20
  lastName String
21
- avatarUrl String? // SEO-friendly path: /uploads/avatars/{userId}/{firstName-lastName}-avatar.webp
21
+ avatarUrl String? // SEO-friendly path: /uploads/users/{userId}/avatar/{firstName-lastName}-avatar.webp
22
22
  role UserRole @default(USER)
23
23
  isActive Boolean @default(true)
24
24
  deletedAt DateTime?
@@ -32,6 +32,7 @@ model User {
32
32
 
33
33
  refreshTokens RefreshToken[]
34
34
  sessions Session[]
35
+ blockedIps BlockedIp[]
35
36
 
36
37
  // Performance indexes for high-traffic scenarios (10K-100K users/day)
37
38
  @@index([role]) // For role-based authorization queries
@@ -75,3 +76,18 @@ model Session {
75
76
  @@index([expiresAt]) // For expired session cleanup
76
77
  @@map("sessions")
77
78
  }
79
+
80
+ model BlockedIp {
81
+ id String @id @default(uuid())
82
+ ip String @unique @db.VarChar(45)
83
+ reason String? @db.VarChar(500)
84
+ blockedBy String
85
+
86
+ createdAt DateTime @default(now())
87
+ updatedAt DateTime @updatedAt
88
+
89
+ user User @relation(fields: [blockedBy], references: [id])
90
+
91
+ @@index([ip])
92
+ @@map("blocked_ips")
93
+ }
@@ -21,7 +21,7 @@ import { adminRoutes } from '@modules/admin/admin.routes.js';
21
21
  import { fileStorageService } from '@libs/storage/file-storage.service.js';
22
22
  import { registerJobs } from '@jobs/index.js';
23
23
  import { RATE_LIMIT_ENABLED, getRateLimitRedisStore } from '@config/rate-limit.config.js';
24
- import { isIpBlocked, recordRateLimitViolation } from '@libs/ip-block.js';
24
+ import { isIpBlocked, recordRateLimitViolation, syncBlockedIpsToRedis } from '@libs/ip-block.js';
25
25
  import { ForbiddenError } from '@shared/errors/errors.js';
26
26
  import {
27
27
  serializerCompiler,
@@ -135,6 +135,9 @@ export async function buildApp() {
135
135
  // Initialize file storage (create directories)
136
136
  await fileStorageService.initialize();
137
137
 
138
+ // --- Sync permanent IP blocks from DB to Redis ---
139
+ await syncBlockedIpsToRedis();
140
+
138
141
  // --- IP Block Check (runs before everything else) ---
139
142
  app.addHook('onRequest', async (request: FastifyRequest) => {
140
143
  if (await isIpBlocked(request.ip)) {
@@ -39,6 +39,12 @@ const envSchema = z.object({
39
39
  // Separate secret for cookie signing (defaults to JWT_SECRET if not set)
40
40
  COOKIE_SECRET: z.string().min(32, 'COOKIE_SECRET must be at least 32 characters').optional(),
41
41
 
42
+ // Cookie domain for cross-origin deployments (client ≠ server hostname)
43
+ // Required when client and API are on different subdomains (e.g., app.example.com + api.example.com)
44
+ // Set to the shared parent domain with a leading dot: ".example.com"
45
+ // Leave empty for same-origin deployments or local development
46
+ COOKIE_DOMAIN: z.string().optional(),
47
+
42
48
  // --- CORS ---
43
49
  // In development: CORS_ORIGIN is optional (allows all origins)
44
50
  // In production: REQUIRED for security
@@ -2,7 +2,7 @@
2
2
  * Cleanup Deleted Accounts Job
3
3
  *
4
4
  * Permanently purges soft-deleted user accounts after a 30-day retention period.
5
- * Deletes avatar files from disk and hard-deletes the user record (cascades to
5
+ * Deletes all user media from disk and hard-deletes the user record (cascades to
6
6
  * refresh tokens and sessions via onDelete: Cascade).
7
7
  *
8
8
  * Runs once daily.
@@ -32,7 +32,6 @@ export function startCleanupDeletedAccountsJob(app: FastifyInstance): void {
32
32
  },
33
33
  select: {
34
34
  id: true,
35
- avatarUrl: true,
36
35
  },
37
36
  });
38
37
 
@@ -49,10 +48,8 @@ export function startCleanupDeletedAccountsJob(app: FastifyInstance): void {
49
48
 
50
49
  for (const user of usersToDelete) {
51
50
  try {
52
- // Delete avatar files from disk if they exist
53
- if (user.avatarUrl) {
54
- await fileStorageService.deleteAvatar(user.id);
55
- }
51
+ // Delete all user media from disk (no-op if dir doesn't exist)
52
+ await fileStorageService.deleteUserMedia(user.id);
56
53
 
57
54
  // Hard delete user record (cascades to RefreshToken + Session)
58
55
  await prisma.user.delete({
@@ -2,7 +2,7 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
2
2
  import { v4 as uuidv4 } from 'uuid';
3
3
  import { env } from '@config/env.js';
4
4
  import { prisma } from '@libs/prisma.js';
5
- import { UnauthorizedError, ForbiddenError } from '@shared/errors/errors.js';
5
+ import { UnauthorizedError, ForbiddenError, BadRequestError } from '@shared/errors/errors.js';
6
6
  import type { JwtPayload, UserRole } from '@shared/types/index.js';
7
7
 
8
8
  let app: FastifyInstance | null = null;
@@ -97,3 +97,47 @@ export function authorize(...roles: UserRole[]) {
97
97
  };
98
98
  }
99
99
 
100
+ /**
101
+ * Middleware: resolves targetUserId from the authenticated user's JWT.
102
+ * Used for /users/me routes.
103
+ * Must run AFTER authenticate.
104
+ */
105
+ export async function resolveMe(
106
+ request: FastifyRequest,
107
+ _reply: FastifyReply,
108
+ ): Promise<void> {
109
+ request.targetUserId = request.user.userId;
110
+ request.isAdminAction = false;
111
+ }
112
+
113
+ /**
114
+ * Middleware: resolves targetUserId from :userId param.
115
+ * Allows access if the authenticated user is the owner OR has ADMIN role.
116
+ * Must run AFTER authenticate.
117
+ *
118
+ * Sets:
119
+ * - request.targetUserId: the resolved user ID from params
120
+ * - request.isAdminAction: true if admin is acting on a different user
121
+ */
122
+ export async function resolveTargetUser(
123
+ request: FastifyRequest,
124
+ _reply: FastifyReply,
125
+ ): Promise<void> {
126
+ const params = request.params as { userId?: string };
127
+ const targetUserId = params.userId;
128
+
129
+ if (!targetUserId) {
130
+ throw new BadRequestError('Missing userId parameter', 'MISSING_USER_ID');
131
+ }
132
+
133
+ const isOwner = request.user.userId === targetUserId;
134
+ const isAdmin = request.user.role === 'ADMIN';
135
+
136
+ if (!isOwner && !isAdmin) {
137
+ throw new ForbiddenError('You do not have permission to perform this action');
138
+ }
139
+
140
+ request.targetUserId = targetUserId;
141
+ request.isAdminAction = !isOwner && isAdmin;
142
+ }
143
+
@@ -4,6 +4,14 @@ import { parseDurationMs } from '@libs/auth.js';
4
4
 
5
5
  const isProduction = env.NODE_ENV === 'production';
6
6
 
7
+ // Cross-origin deployment: client and API on different subdomains require sameSite 'none'.
8
+ // Same-origin deployment (or local dev): 'strict' is the safest default.
9
+ const isCrossOrigin = Boolean(env.COOKIE_DOMAIN);
10
+ const sameSitePolicy = isProduction && isCrossOrigin ? 'none' as const : 'strict' as const;
11
+
12
+ // When set, cookies are shared across subdomains (e.g., ".example.com" covers app.example.com + api.example.com)
13
+ const cookieDomain = env.COOKIE_DOMAIN || undefined;
14
+
7
15
  const ACCESS_TOKEN_MAX_AGE_MS = parseDurationMs(env.JWT_ACCESS_EXPIRY, 15 * 60 * 1000);
8
16
  const REFRESH_TOKEN_MAX_AGE_MS = parseDurationMs(env.JWT_REFRESH_EXPIRY, 7 * 24 * 60 * 60 * 1000);
9
17
 
@@ -15,7 +23,8 @@ export function setAuthCookies(
15
23
  reply.setCookie('access_token', accessToken, {
16
24
  httpOnly: true,
17
25
  secure: isProduction,
18
- sameSite: 'strict',
26
+ sameSite: sameSitePolicy,
27
+ domain: cookieDomain,
19
28
  path: '/',
20
29
  maxAge: Math.floor(ACCESS_TOKEN_MAX_AGE_MS / 1000), // setCookie expects seconds
21
30
  });
@@ -23,24 +32,46 @@ export function setAuthCookies(
23
32
  reply.setCookie('refresh_token', refreshToken, {
24
33
  httpOnly: true,
25
34
  secure: isProduction,
26
- sameSite: 'strict',
35
+ sameSite: sameSitePolicy,
36
+ domain: cookieDomain,
27
37
  path: '/api/v1/auth',
28
38
  maxAge: Math.floor(REFRESH_TOKEN_MAX_AGE_MS / 1000),
29
39
  });
40
+
41
+ // Non-sensitive session indicator visible to Next.js middleware and client JS.
42
+ // Lets middleware distinguish "never logged in" from "access token expired but session alive."
43
+ reply.setCookie('auth_session', '1', {
44
+ httpOnly: false,
45
+ secure: isProduction,
46
+ sameSite: sameSitePolicy,
47
+ domain: cookieDomain,
48
+ path: '/',
49
+ maxAge: Math.floor(REFRESH_TOKEN_MAX_AGE_MS / 1000),
50
+ });
30
51
  }
31
52
 
32
53
  export function clearAuthCookies(reply: FastifyReply): void {
33
54
  reply.clearCookie('access_token', {
34
55
  httpOnly: true,
35
56
  secure: isProduction,
36
- sameSite: 'strict',
57
+ sameSite: sameSitePolicy,
58
+ domain: cookieDomain,
37
59
  path: '/',
38
60
  });
39
61
 
40
62
  reply.clearCookie('refresh_token', {
41
63
  httpOnly: true,
42
64
  secure: isProduction,
43
- sameSite: 'strict',
65
+ sameSite: sameSitePolicy,
66
+ domain: cookieDomain,
44
67
  path: '/api/v1/auth',
45
68
  });
69
+
70
+ reply.clearCookie('auth_session', {
71
+ httpOnly: false,
72
+ secure: isProduction,
73
+ sameSite: sameSitePolicy,
74
+ domain: cookieDomain,
75
+ path: '/',
76
+ });
46
77
  }