create-tigra 2.0.3 → 2.1.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/bin/create-tigra.js +2 -2
- package/package.json +1 -1
- package/template/_claude/hooks/restrict-paths.sh +69 -0
- package/template/_claude/settings.json +15 -0
- package/template/_claude/skills/role/SKILL.md +37 -0
- package/template/client/next.config.ts +10 -1
- package/template/client/src/lib/api/axios.config.ts +16 -6
- package/template/client/src/middleware.ts +10 -1
- package/template/gitignore +3 -0
- package/template/server/src/libs/auth.ts +11 -0
- package/template/server/src/modules/auth/auth.service.ts +9 -7
package/bin/create-tigra.js
CHANGED
|
@@ -124,11 +124,11 @@ async function main() {
|
|
|
124
124
|
program
|
|
125
125
|
.name('create-tigra')
|
|
126
126
|
.description('Create a production-ready full-stack app with Next.js + Fastify + Prisma + Redis')
|
|
127
|
-
.version('2.0
|
|
127
|
+
.version('2.1.0')
|
|
128
128
|
.argument('[project-name]', 'Name for your new project')
|
|
129
129
|
.action(async (projectNameArg) => {
|
|
130
130
|
console.log();
|
|
131
|
-
console.log(chalk.bold(' Create Tigra') + chalk.dim(' v2.0
|
|
131
|
+
console.log(chalk.bold(' Create Tigra') + chalk.dim(' v2.1.0'));
|
|
132
132
|
console.log();
|
|
133
133
|
|
|
134
134
|
let projectName = projectNameArg;
|
package/package.json
CHANGED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Restrict Claude's WRITE access based on the .developer-role file.
|
|
3
|
+
# Claude can always READ any file for full project context.
|
|
4
|
+
# Only Edit/Write operations are blocked on the other side's directory.
|
|
5
|
+
#
|
|
6
|
+
# To switch roles, either:
|
|
7
|
+
# 1. Edit .developer-role and type: frontend, backend, or fullstack
|
|
8
|
+
# 2. Use /role command in Claude
|
|
9
|
+
#
|
|
10
|
+
# fullstack (or empty/missing file) = no restrictions
|
|
11
|
+
|
|
12
|
+
ROLE_FILE="$CLAUDE_PROJECT_DIR/.developer-role"
|
|
13
|
+
|
|
14
|
+
# Read role from file, trim whitespace
|
|
15
|
+
if [ -f "$ROLE_FILE" ]; then
|
|
16
|
+
ROLE=$(tr -d '[:space:]' < "$ROLE_FILE")
|
|
17
|
+
else
|
|
18
|
+
ROLE=""
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
# No file, empty, or fullstack = full access
|
|
22
|
+
if [ -z "$ROLE" ] || [ "$ROLE" = "fullstack" ]; then
|
|
23
|
+
exit 0
|
|
24
|
+
fi
|
|
25
|
+
|
|
26
|
+
# Read stdin (JSON from Claude Code)
|
|
27
|
+
INPUT=$(cat)
|
|
28
|
+
|
|
29
|
+
# Extract tool_name from JSON
|
|
30
|
+
TOOL_NAME=$(echo "$INPUT" | grep -oP '"tool_name"\s*:\s*"[^"]*"' | head -1 | sed 's/.*:.*"\(.*\)"/\1/')
|
|
31
|
+
|
|
32
|
+
# Only block write operations (Edit, Write). Allow Read, Glob, Grep.
|
|
33
|
+
if [ "$TOOL_NAME" != "Edit" ] && [ "$TOOL_NAME" != "Write" ]; then
|
|
34
|
+
exit 0
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
# Extract file_path or path from JSON
|
|
38
|
+
FILE_PATH=""
|
|
39
|
+
for field in file_path path; do
|
|
40
|
+
value=$(echo "$INPUT" | grep -oP "\"${field}\"\s*:\s*\"[^\"]*\"" | head -1 | sed 's/.*:.*"\(.*\)"/\1/')
|
|
41
|
+
if [ -n "$value" ]; then
|
|
42
|
+
FILE_PATH="$value"
|
|
43
|
+
break
|
|
44
|
+
fi
|
|
45
|
+
done
|
|
46
|
+
|
|
47
|
+
# No file path found = allow
|
|
48
|
+
if [ -z "$FILE_PATH" ]; then
|
|
49
|
+
exit 0
|
|
50
|
+
fi
|
|
51
|
+
|
|
52
|
+
deny_with_reason() {
|
|
53
|
+
cat <<DENY_EOF
|
|
54
|
+
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"$1"}}
|
|
55
|
+
DENY_EOF
|
|
56
|
+
exit 0
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if [ "$ROLE" = "frontend" ]; then
|
|
60
|
+
if echo "$FILE_PATH" | grep -qiE "(^|/|\\\\)server(/|\\\\|$)"; then
|
|
61
|
+
deny_with_reason "BLOCKED: Role is set to frontend. You can read server/ files but cannot modify them. Only the backend developer can edit server/ code."
|
|
62
|
+
fi
|
|
63
|
+
elif [ "$ROLE" = "backend" ]; then
|
|
64
|
+
if echo "$FILE_PATH" | grep -qiE "(^|/|\\\\)client(/|\\\\|$)"; then
|
|
65
|
+
deny_with_reason "BLOCKED: Role is set to backend. You can read client/ files but cannot modify them. Only the frontend developer can edit client/ code."
|
|
66
|
+
fi
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
exit 0
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: role
|
|
3
|
+
description: Switch developer role to restrict Claude's access to frontend-only, backend-only, or full access
|
|
4
|
+
argument-hint: "[frontend | backend | fullstack]"
|
|
5
|
+
disable-model-invocation: true
|
|
6
|
+
allowed-tools: Read, Write
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
The user wants to switch their developer role. The argument is: $ARGUMENTS
|
|
10
|
+
|
|
11
|
+
Read the file `.developer-role` in the project root to see the current role.
|
|
12
|
+
|
|
13
|
+
## Rules
|
|
14
|
+
|
|
15
|
+
- If the argument is `frontend`, `backend`, or `fullstack` — write that word to `.developer-role` and confirm the switch.
|
|
16
|
+
- If no argument is given — read `.developer-role` and tell the user their current role, then ask which role they want.
|
|
17
|
+
- If the argument is not one of the three valid roles — tell the user the valid options.
|
|
18
|
+
|
|
19
|
+
## What each role does
|
|
20
|
+
|
|
21
|
+
| Role | Access |
|
|
22
|
+
|------|--------|
|
|
23
|
+
| `frontend` | Can only work in `client/`. Blocked from `server/`. |
|
|
24
|
+
| `backend` | Can only work in `server/`. Blocked from `client/`. |
|
|
25
|
+
| `fullstack` | Full access to everything. No restrictions. |
|
|
26
|
+
|
|
27
|
+
## Response format
|
|
28
|
+
|
|
29
|
+
After switching, confirm like this:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
Role switched to **{role}**.
|
|
33
|
+
|
|
34
|
+
- frontend → client/ only
|
|
35
|
+
- backend → server/ only
|
|
36
|
+
- fullstack → full access
|
|
37
|
+
```
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import type { NextConfig } from "next";
|
|
2
2
|
|
|
3
|
+
const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000";
|
|
4
|
+
const apiOrigin = (() => {
|
|
5
|
+
try {
|
|
6
|
+
return new URL(apiBaseUrl).origin;
|
|
7
|
+
} catch {
|
|
8
|
+
return "http://localhost:8000";
|
|
9
|
+
}
|
|
10
|
+
})();
|
|
11
|
+
|
|
3
12
|
const nextConfig: NextConfig = {
|
|
4
13
|
async headers() {
|
|
5
14
|
return [
|
|
@@ -22,7 +31,7 @@ const nextConfig: NextConfig = {
|
|
|
22
31
|
"base-uri 'self'",
|
|
23
32
|
"form-action 'self'",
|
|
24
33
|
"frame-ancestors 'none'",
|
|
25
|
-
`connect-src 'self' ${
|
|
34
|
+
`connect-src 'self' ${apiOrigin}`,
|
|
26
35
|
].join("; "),
|
|
27
36
|
},
|
|
28
37
|
],
|
|
@@ -80,12 +80,22 @@ apiClient.interceptors.response.use(
|
|
|
80
80
|
} catch (refreshError) {
|
|
81
81
|
processQueue(refreshError);
|
|
82
82
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
83
|
+
// Only logout on definitive auth failures (server responded with 4xx).
|
|
84
|
+
// Network errors (server unreachable, timeout) should NOT destroy the session.
|
|
85
|
+
const isAuthFailure =
|
|
86
|
+
axios.isAxiosError(refreshError) &&
|
|
87
|
+
refreshError.response != null &&
|
|
88
|
+
refreshError.response.status >= 400 &&
|
|
89
|
+
refreshError.response.status < 500;
|
|
90
|
+
|
|
91
|
+
if (isAuthFailure) {
|
|
92
|
+
const { logout } = await import('@/features/auth/store/authSlice');
|
|
93
|
+
const { store } = await import('@/store');
|
|
94
|
+
store.dispatch(logout());
|
|
95
|
+
|
|
96
|
+
if (typeof window !== 'undefined') {
|
|
97
|
+
window.location.href = ROUTES.LOGIN;
|
|
98
|
+
}
|
|
89
99
|
}
|
|
90
100
|
|
|
91
101
|
return Promise.reject(refreshError);
|
|
@@ -23,12 +23,21 @@ export function middleware(request: NextRequest): NextResponse {
|
|
|
23
23
|
path === '/' ? pathname === '/' : pathname.startsWith(path)
|
|
24
24
|
);
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
// No token at all — user is not logged in, redirect to login
|
|
27
|
+
if (isProtectedPath && !token) {
|
|
27
28
|
const loginUrl = new URL('/login', request.url);
|
|
28
29
|
loginUrl.searchParams.set('from', pathname);
|
|
29
30
|
return NextResponse.redirect(loginUrl);
|
|
30
31
|
}
|
|
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
|
+
}
|
|
40
|
+
|
|
32
41
|
const isAuthPath = authPaths.some((path) => pathname.startsWith(path));
|
|
33
42
|
|
|
34
43
|
if (isAuthPath && token) {
|
package/template/gitignore
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
|
2
2
|
import { v4 as uuidv4 } from 'uuid';
|
|
3
3
|
import { env } from '@config/env.js';
|
|
4
|
+
import { prisma } from '@libs/prisma.js';
|
|
4
5
|
import { UnauthorizedError, ForbiddenError } from '@shared/errors/errors.js';
|
|
5
6
|
import type { JwtPayload, UserRole } from '@shared/types/index.js';
|
|
6
7
|
|
|
@@ -58,6 +59,16 @@ export async function authenticate(
|
|
|
58
59
|
} catch {
|
|
59
60
|
throw new UnauthorizedError('Invalid or expired token');
|
|
60
61
|
}
|
|
62
|
+
|
|
63
|
+
// Verify user still exists, is active, and not soft-deleted
|
|
64
|
+
const user = await prisma.user.findUnique({
|
|
65
|
+
where: { id: request.user.userId },
|
|
66
|
+
select: { isActive: true, deletedAt: true },
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
if (!user || !user.isActive || user.deletedAt) {
|
|
70
|
+
throw new UnauthorizedError('Account is deactivated or deleted');
|
|
71
|
+
}
|
|
61
72
|
}
|
|
62
73
|
|
|
63
74
|
export async function optionalAuth(
|
|
@@ -54,15 +54,17 @@ function sanitizeUser(user: {
|
|
|
54
54
|
isActive: boolean;
|
|
55
55
|
createdAt: Date;
|
|
56
56
|
updatedAt: Date;
|
|
57
|
-
password: string;
|
|
58
57
|
}): SanitizedUser {
|
|
59
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
60
|
-
const { password: _password, createdAt, updatedAt, avatarUrl, ...rest } = user;
|
|
61
58
|
return {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
59
|
+
id: user.id,
|
|
60
|
+
email: user.email,
|
|
61
|
+
firstName: user.firstName,
|
|
62
|
+
lastName: user.lastName,
|
|
63
|
+
avatarUrl: user.avatarUrl ?? null,
|
|
64
|
+
role: user.role,
|
|
65
|
+
isActive: user.isActive,
|
|
66
|
+
createdAt: user.createdAt.toISOString(),
|
|
67
|
+
updatedAt: user.updatedAt.toISOString(),
|
|
66
68
|
};
|
|
67
69
|
}
|
|
68
70
|
|