@levironexe/architect 0.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/CHANGELOG.md +32 -0
- package/CONTRIBUTING.md +55 -0
- package/README.md +341 -0
- package/dist/analyzers/ast-parser.d.ts +3 -0
- package/dist/analyzers/ast-parser.js +305 -0
- package/dist/analyzers/ast-parser.js.map +1 -0
- package/dist/analyzers/dependency-graph.d.ts +2 -0
- package/dist/analyzers/dependency-graph.js +67 -0
- package/dist/analyzers/dependency-graph.js.map +1 -0
- package/dist/analyzers/duplication.d.ts +2 -0
- package/dist/analyzers/duplication.js +56 -0
- package/dist/analyzers/duplication.js.map +1 -0
- package/dist/analyzers/file-walker.d.ts +3 -0
- package/dist/analyzers/file-walker.js +80 -0
- package/dist/analyzers/file-walker.js.map +1 -0
- package/dist/cli/context-runner.d.ts +1 -0
- package/dist/cli/context-runner.js +16 -0
- package/dist/cli/context-runner.js.map +1 -0
- package/dist/cli/index.d.ts +24 -0
- package/dist/cli/index.js +217 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/init-runner.d.ts +25 -0
- package/dist/cli/init-runner.js +152 -0
- package/dist/cli/init-runner.js.map +1 -0
- package/dist/cli/scan-runner.d.ts +8 -0
- package/dist/cli/scan-runner.js +133 -0
- package/dist/cli/scan-runner.js.map +1 -0
- package/dist/formatters/plan-json.d.ts +2 -0
- package/dist/formatters/plan-json.js +4 -0
- package/dist/formatters/plan-json.js.map +1 -0
- package/dist/formatters/plan-markdown.d.ts +2 -0
- package/dist/formatters/plan-markdown.js +42 -0
- package/dist/formatters/plan-markdown.js.map +1 -0
- package/dist/formatters/plan-prompt.d.ts +4 -0
- package/dist/formatters/plan-prompt.js +5 -0
- package/dist/formatters/plan-prompt.js.map +1 -0
- package/dist/formatters/plan-terminal.d.ts +5 -0
- package/dist/formatters/plan-terminal.js +62 -0
- package/dist/formatters/plan-terminal.js.map +1 -0
- package/dist/generators/blueprint-renderer.d.ts +3 -0
- package/dist/generators/blueprint-renderer.js +27 -0
- package/dist/generators/blueprint-renderer.js.map +1 -0
- package/dist/generators/claudeWriter.d.ts +3 -0
- package/dist/generators/claudeWriter.js +9 -0
- package/dist/generators/claudeWriter.js.map +1 -0
- package/dist/generators/copilotWriter.d.ts +3 -0
- package/dist/generators/copilotWriter.js +11 -0
- package/dist/generators/copilotWriter.js.map +1 -0
- package/dist/generators/cursorWriter.d.ts +3 -0
- package/dist/generators/cursorWriter.js +14 -0
- package/dist/generators/cursorWriter.js.map +1 -0
- package/dist/generators/genericWriter.d.ts +3 -0
- package/dist/generators/genericWriter.js +9 -0
- package/dist/generators/genericWriter.js.map +1 -0
- package/dist/generators/template-context.d.ts +18 -0
- package/dist/generators/template-context.js +126 -0
- package/dist/generators/template-context.js.map +1 -0
- package/dist/generators/templateRenderer.d.ts +2 -0
- package/dist/generators/templateRenderer.js +19 -0
- package/dist/generators/templateRenderer.js.map +1 -0
- package/dist/generators/windsurfWriter.d.ts +3 -0
- package/dist/generators/windsurfWriter.js +14 -0
- package/dist/generators/windsurfWriter.js.map +1 -0
- package/dist/generators/writer-types.d.ts +11 -0
- package/dist/generators/writer-types.js +40 -0
- package/dist/generators/writer-types.js.map +1 -0
- package/dist/llm/claude-provider.d.ts +8 -0
- package/dist/llm/claude-provider.js +22 -0
- package/dist/llm/claude-provider.js.map +1 -0
- package/dist/llm/concern-classifier.d.ts +15 -0
- package/dist/llm/concern-classifier.js +61 -0
- package/dist/llm/concern-classifier.js.map +1 -0
- package/dist/llm/config.d.ts +11 -0
- package/dist/llm/config.js +120 -0
- package/dist/llm/config.js.map +1 -0
- package/dist/llm/ollama-provider.d.ts +8 -0
- package/dist/llm/ollama-provider.js +27 -0
- package/dist/llm/ollama-provider.js.map +1 -0
- package/dist/llm/openai-provider.d.ts +8 -0
- package/dist/llm/openai-provider.js +19 -0
- package/dist/llm/openai-provider.js.map +1 -0
- package/dist/llm/prompt-builder.d.ts +12 -0
- package/dist/llm/prompt-builder.js +132 -0
- package/dist/llm/prompt-builder.js.map +1 -0
- package/dist/llm/provider.d.ts +17 -0
- package/dist/llm/provider.js +2 -0
- package/dist/llm/provider.js.map +1 -0
- package/dist/llm/response-parser.d.ts +6 -0
- package/dist/llm/response-parser.js +128 -0
- package/dist/llm/response-parser.js.map +1 -0
- package/dist/planner/plan-generator.d.ts +7 -0
- package/dist/planner/plan-generator.js +275 -0
- package/dist/planner/plan-generator.js.map +1 -0
- package/dist/planner/plan-prompt-builder.d.ts +9 -0
- package/dist/planner/plan-prompt-builder.js +92 -0
- package/dist/planner/plan-prompt-builder.js.map +1 -0
- package/dist/planner/plan-response-parser.d.ts +7 -0
- package/dist/planner/plan-response-parser.js +21 -0
- package/dist/planner/plan-response-parser.js.map +1 -0
- package/dist/planner/plan-validator.d.ts +3 -0
- package/dist/planner/plan-validator.js +49 -0
- package/dist/planner/plan-validator.js.map +1 -0
- package/dist/reporters/scan-json.d.ts +13 -0
- package/dist/reporters/scan-json.js +26 -0
- package/dist/reporters/scan-json.js.map +1 -0
- package/dist/reporters/terminal.d.ts +6 -0
- package/dist/reporters/terminal.js +224 -0
- package/dist/reporters/terminal.js.map +1 -0
- package/dist/scoring/consistency-score.d.ts +3 -0
- package/dist/scoring/consistency-score.js +23 -0
- package/dist/scoring/consistency-score.js.map +1 -0
- package/dist/scoring/duplication-score.d.ts +3 -0
- package/dist/scoring/duplication-score.js +16 -0
- package/dist/scoring/duplication-score.js.map +1 -0
- package/dist/scoring/health-score.d.ts +4 -0
- package/dist/scoring/health-score.js +20 -0
- package/dist/scoring/health-score.js.map +1 -0
- package/dist/scoring/issue-builder.d.ts +4 -0
- package/dist/scoring/issue-builder.js +62 -0
- package/dist/scoring/issue-builder.js.map +1 -0
- package/dist/scoring/modularity-score.d.ts +3 -0
- package/dist/scoring/modularity-score.js +56 -0
- package/dist/scoring/modularity-score.js.map +1 -0
- package/dist/scoring/pattern-analysis.d.ts +3 -0
- package/dist/scoring/pattern-analysis.js +74 -0
- package/dist/scoring/pattern-analysis.js.map +1 -0
- package/dist/scoring/separation-score.d.ts +3 -0
- package/dist/scoring/separation-score.js +35 -0
- package/dist/scoring/separation-score.js.map +1 -0
- package/dist/skills/detector.d.ts +4 -0
- package/dist/skills/detector.js +104 -0
- package/dist/skills/detector.js.map +1 -0
- package/dist/skills/lister.d.ts +9 -0
- package/dist/skills/lister.js +35 -0
- package/dist/skills/lister.js.map +1 -0
- package/dist/skills/loader.d.ts +6 -0
- package/dist/skills/loader.js +76 -0
- package/dist/skills/loader.js.map +1 -0
- package/dist/skills/structure-check.d.ts +2 -0
- package/dist/skills/structure-check.js +37 -0
- package/dist/skills/structure-check.js.map +1 -0
- package/dist/skills/validator.d.ts +6 -0
- package/dist/skills/validator.js +229 -0
- package/dist/skills/validator.js.map +1 -0
- package/dist/types/analysis.d.ts +130 -0
- package/dist/types/analysis.js +41 -0
- package/dist/types/analysis.js.map +1 -0
- package/dist/types/concern.d.ts +48 -0
- package/dist/types/concern.js +16 -0
- package/dist/types/concern.js.map +1 -0
- package/dist/types/generation.d.ts +32 -0
- package/dist/types/generation.js +2 -0
- package/dist/types/generation.js.map +1 -0
- package/dist/types/issue.d.ts +12 -0
- package/dist/types/issue.js +2 -0
- package/dist/types/issue.js.map +1 -0
- package/dist/types/pattern.d.ts +15 -0
- package/dist/types/pattern.js +2 -0
- package/dist/types/pattern.js.map +1 -0
- package/dist/types/plan.d.ts +56 -0
- package/dist/types/plan.js +2 -0
- package/dist/types/plan.js.map +1 -0
- package/dist/types/scan-output.d.ts +84 -0
- package/dist/types/scan-output.js +2 -0
- package/dist/types/scan-output.js.map +1 -0
- package/dist/types/scoring.d.ts +15 -0
- package/dist/types/scoring.js +2 -0
- package/dist/types/scoring.js.map +1 -0
- package/dist/types/skill.d.ts +97 -0
- package/dist/types/skill.js +2 -0
- package/dist/types/skill.js.map +1 -0
- package/dist/utils/agent-detector.d.ts +2 -0
- package/dist/utils/agent-detector.js +22 -0
- package/dist/utils/agent-detector.js.map +1 -0
- package/dist/utils/interactive.d.ts +6 -0
- package/dist/utils/interactive.js +15 -0
- package/dist/utils/interactive.js.map +1 -0
- package/dist/utils/path.d.ts +5 -0
- package/dist/utils/path.js +31 -0
- package/dist/utils/path.js.map +1 -0
- package/dist/utils/progress.d.ts +17 -0
- package/dist/utils/progress.js +48 -0
- package/dist/utils/progress.js.map +1 -0
- package/dist/utils/thresholds.d.ts +6 -0
- package/dist/utils/thresholds.js +48 -0
- package/dist/utils/thresholds.js.map +1 -0
- package/package.json +63 -0
- package/skills/meta/general-js.skill.yaml +131 -0
- package/skills/patterns/clerk-auth.skill.yaml +349 -0
- package/skills/patterns/docker-deploy.skill.yaml +214 -0
- package/skills/patterns/drizzle.skill.yaml +277 -0
- package/skills/patterns/mongoose.skill.yaml +290 -0
- package/skills/patterns/nextauth.skill.yaml +308 -0
- package/skills/patterns/playwright-e2e.skill.yaml +265 -0
- package/skills/patterns/prisma.skill.yaml +255 -0
- package/skills/patterns/s3-storage.skill.yaml +235 -0
- package/skills/patterns/selenium-e2e.skill.yaml +276 -0
- package/skills/patterns/supabase-auth.skill.yaml +298 -0
- package/skills/patterns/supabase.skill.yaml +304 -0
- package/skills/patterns/vercel-deploy.skill.yaml +219 -0
- package/skills/patterns/vitest-testing.skill.yaml +262 -0
- package/skills/stacks/express-api.skill.yaml +155 -0
- package/skills/stacks/fastify-api.skill.yaml +119 -0
- package/skills/stacks/hono-api.skill.yaml +130 -0
- package/skills/stacks/nestjs.skill.yaml +135 -0
- package/skills/stacks/nextjs-app-router.skill.yaml +176 -0
- package/skills/stacks/react-spa.skill.yaml +153 -0
- package/skills/stacks/vue-nuxt.skill.yaml +115 -0
- package/templates/architect-plan.md +139 -0
- package/templates/architect-refactor.md +119 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
schema_version: "2.0.0"
|
|
2
|
+
id: prisma
|
|
3
|
+
name: "Prisma ORM"
|
|
4
|
+
version: "2.0.0"
|
|
5
|
+
description: "Schema-first Prisma ORM with singleton client, repository/model pattern, migration-only schema changes, error handling for known request errors, and N+1 prevention via include/select."
|
|
6
|
+
category: pattern
|
|
7
|
+
language: javascript
|
|
8
|
+
frameworks:
|
|
9
|
+
- prisma
|
|
10
|
+
dependencies:
|
|
11
|
+
none:
|
|
12
|
+
- drizzle-orm
|
|
13
|
+
- mongoose
|
|
14
|
+
- sequelize
|
|
15
|
+
detection:
|
|
16
|
+
dependencies:
|
|
17
|
+
any:
|
|
18
|
+
- prisma
|
|
19
|
+
- "@prisma/client"
|
|
20
|
+
source_indicators:
|
|
21
|
+
- "PrismaClient"
|
|
22
|
+
- "prisma.$"
|
|
23
|
+
- "from '@prisma/client'"
|
|
24
|
+
- "prisma.user."
|
|
25
|
+
- "prisma.post."
|
|
26
|
+
structure:
|
|
27
|
+
required_dirs:
|
|
28
|
+
- path: prisma
|
|
29
|
+
purpose: "Prisma schema file (schema.prisma) and migration history. The only place where the database schema is defined — all table structure, relations, and enums live here. Any schema change must go through `npx prisma migrate dev` to generate a migration file and update the generated Prisma Client."
|
|
30
|
+
- path: prisma/migrations
|
|
31
|
+
purpose: "Auto-generated migration SQL files created by `npx prisma migrate dev`. Never edit these files manually — Prisma tracks their checksums and will refuse to apply migrations if they are modified after generation."
|
|
32
|
+
- path: src/lib
|
|
33
|
+
purpose: "Prisma Client singleton in db.ts — the one and only place that calls `new PrismaClient()`. All repositories and services import the prisma instance from here. Prevents connection pool exhaustion in serverless environments where each cold start could otherwise create a new pool."
|
|
34
|
+
recommended_dirs:
|
|
35
|
+
- path: src/repositories
|
|
36
|
+
purpose: "Data access layer — one file per Prisma model (e.g., user.repository.ts). All prisma.user.*(), prisma.post.*() calls belong here. Services call repository functions; they never import prisma directly. This makes the data layer mockable in unit tests."
|
|
37
|
+
- path: src/services
|
|
38
|
+
purpose: "Business logic layer — orchestrates calls to one or more repositories, validates input, applies rules, and returns domain objects. Never calls prisma directly."
|
|
39
|
+
separation:
|
|
40
|
+
rules:
|
|
41
|
+
- concern: singleton_client
|
|
42
|
+
belongs_in: src/lib
|
|
43
|
+
rule_text: "Create exactly one PrismaClient instance using the global singleton pattern in src/lib/db.ts. In serverless/Next.js environments, Hot Module Replacement recreates modules on every file save — without the global pattern, each save creates a new PrismaClient and a new connection pool until connections are exhausted."
|
|
44
|
+
example: |
|
|
45
|
+
// src/lib/db.ts — the only file that calls new PrismaClient()
|
|
46
|
+
import { PrismaClient } from '@prisma/client';
|
|
47
|
+
|
|
48
|
+
const globalForPrisma = globalThis as unknown as {
|
|
49
|
+
prisma: PrismaClient | undefined;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const prisma =
|
|
53
|
+
globalForPrisma.prisma ??
|
|
54
|
+
new PrismaClient({
|
|
55
|
+
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
|
59
|
+
anti_indicators:
|
|
60
|
+
- "new PrismaClient()"
|
|
61
|
+
- concern: migrations
|
|
62
|
+
belongs_in: prisma/migrations
|
|
63
|
+
rule_text: "All schema changes must go through the Prisma migration system. Use `npx prisma migrate dev --name description` in development and `npx prisma migrate deploy` in production CI/CD. Never run raw `ALTER TABLE` or `CREATE TABLE` SQL manually — it breaks migration history and Prisma Client type generation."
|
|
64
|
+
example: |
|
|
65
|
+
# Development: creates migration file + applies it + regenerates Prisma Client
|
|
66
|
+
npx prisma migrate dev --name add-phone-to-users
|
|
67
|
+
|
|
68
|
+
# Production CI/CD: applies pending migrations without interactive prompts
|
|
69
|
+
npx prisma migrate deploy
|
|
70
|
+
|
|
71
|
+
# After pulling schema changes: regenerate Prisma Client types locally
|
|
72
|
+
npx prisma generate
|
|
73
|
+
indicators:
|
|
74
|
+
- "prisma migrate dev"
|
|
75
|
+
- "prisma migrate deploy"
|
|
76
|
+
- "prisma generate"
|
|
77
|
+
- concern: data_access
|
|
78
|
+
belongs_in: src/repositories
|
|
79
|
+
rule_text: "Prisma queries live in src/repositories/ or src/models/. Services and API routes call repository functions — they never import prisma directly. Repository functions own the select/include shape of queries, handle errors, and return typed domain objects."
|
|
80
|
+
example: |
|
|
81
|
+
// src/repositories/user.repository.ts
|
|
82
|
+
import { prisma } from '@/lib/db';
|
|
83
|
+
import type { Prisma } from '@prisma/client';
|
|
84
|
+
|
|
85
|
+
export async function findUserById(id: string) {
|
|
86
|
+
return prisma.user.findUnique({
|
|
87
|
+
where: { id },
|
|
88
|
+
select: { id: true, email: true, name: true, createdAt: true },
|
|
89
|
+
// Exclude: password hash, internal fields
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function createUser(data: Prisma.UserCreateInput) {
|
|
94
|
+
return prisma.user.create({ data });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function listUsersWithPosts(limit = 20) {
|
|
98
|
+
return prisma.user.findMany({
|
|
99
|
+
take: limit,
|
|
100
|
+
include: { posts: { take: 5, orderBy: { createdAt: 'desc' } } },
|
|
101
|
+
orderBy: { createdAt: 'desc' },
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
indicators:
|
|
105
|
+
- "prisma.user."
|
|
106
|
+
- "prisma.post."
|
|
107
|
+
- ".findMany("
|
|
108
|
+
- ".findUnique("
|
|
109
|
+
- "from '@/lib/db'"
|
|
110
|
+
- concern: transactions
|
|
111
|
+
belongs_in: src/repositories
|
|
112
|
+
rule_text: "Use prisma.$transaction() for operations that must succeed or fail together. Interactive transactions (callback form) support dependent queries where the second query uses the result of the first. Batch transactions (array form) are faster for independent mutations."
|
|
113
|
+
example: |
|
|
114
|
+
// src/repositories/order.repository.ts
|
|
115
|
+
export async function createOrderWithInventoryUpdate(
|
|
116
|
+
userId: string,
|
|
117
|
+
productId: string,
|
|
118
|
+
quantity: number
|
|
119
|
+
) {
|
|
120
|
+
return prisma.$transaction(async (tx) => {
|
|
121
|
+
// Check and decrement inventory atomically
|
|
122
|
+
const product = await tx.product.update({
|
|
123
|
+
where: { id: productId, stock: { gte: quantity } },
|
|
124
|
+
data: { stock: { decrement: quantity } },
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Create the order using updated product data
|
|
128
|
+
return tx.order.create({
|
|
129
|
+
data: { userId, productId, quantity, total: product.price * quantity },
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
indicators:
|
|
134
|
+
- "prisma.$transaction"
|
|
135
|
+
- "$transaction("
|
|
136
|
+
patterns:
|
|
137
|
+
data_flow:
|
|
138
|
+
direction: "API Route/Server Action → Service → Repository → Prisma Client → Database"
|
|
139
|
+
rules:
|
|
140
|
+
- "API routes and Server Actions call service functions only — never prisma directly."
|
|
141
|
+
- "Services contain business logic and call repository functions — never prisma directly."
|
|
142
|
+
- "Repositories are the only files that import prisma from src/lib/db.ts."
|
|
143
|
+
- "Migrations run via `prisma migrate` — never raw SQL."
|
|
144
|
+
- "Prisma Client types are regenerated after every schema change with `prisma generate`."
|
|
145
|
+
- "For N+1 prevention: use include/select in repositories rather than loading relations in loops."
|
|
146
|
+
error_handling:
|
|
147
|
+
recommended: "Catch PrismaClientKnownRequestError in repositories. P2002 = unique constraint violation (duplicate email), P2025 = record not found (delete/update of non-existent row), P2003 = foreign key constraint violation."
|
|
148
|
+
naming:
|
|
149
|
+
schema: "prisma/schema.prisma — single data model source of truth"
|
|
150
|
+
client: "src/lib/db.ts — exports the prisma singleton"
|
|
151
|
+
repositories: "src/repositories/[model].repository.ts — e.g. user.repository.ts, post.repository.ts"
|
|
152
|
+
models: "src/models/[model].model.ts — domain model classes wrapping repository calls"
|
|
153
|
+
anti_patterns:
|
|
154
|
+
- id: multiple_prisma_clients
|
|
155
|
+
severity: critical
|
|
156
|
+
description: "Calling `new PrismaClient()` in multiple files instead of importing the singleton. Each PrismaClient creates its own connection pool. In serverless environments (Vercel, Lambda), each function invocation can create a new pool — leading to database 'too many connections' errors under moderate traffic."
|
|
157
|
+
bad_example: |
|
|
158
|
+
// ❌ New PrismaClient in every file — separate connection pool per module
|
|
159
|
+
// src/repositories/user.repository.ts
|
|
160
|
+
import { PrismaClient } from '@prisma/client';
|
|
161
|
+
const prisma = new PrismaClient(); // pool #1
|
|
162
|
+
|
|
163
|
+
// src/repositories/post.repository.ts
|
|
164
|
+
import { PrismaClient } from '@prisma/client';
|
|
165
|
+
const prisma = new PrismaClient(); // pool #2 — now two pools competing
|
|
166
|
+
good_example: |
|
|
167
|
+
// ✓ Import the singleton everywhere — one pool for the entire process
|
|
168
|
+
// src/lib/db.ts: export const prisma = globalThis.prisma ?? new PrismaClient();
|
|
169
|
+
import { prisma } from '@/lib/db';
|
|
170
|
+
- id: prisma_in_route_handler
|
|
171
|
+
severity: critical
|
|
172
|
+
description: "Calling prisma directly in API route handlers, Server Actions, or React components. This bypasses the service/repository layer, makes the code untestable (can't mock prisma calls in unit tests), and scatters query logic across the codebase."
|
|
173
|
+
bad_example: |
|
|
174
|
+
// ❌ Direct prisma call in API route — no service layer, untestable
|
|
175
|
+
import { prisma } from '@/lib/db';
|
|
176
|
+
export async function GET() {
|
|
177
|
+
const users = await prisma.user.findMany({
|
|
178
|
+
include: { posts: true },
|
|
179
|
+
});
|
|
180
|
+
return Response.json(users);
|
|
181
|
+
}
|
|
182
|
+
good_example: |
|
|
183
|
+
// ✓ API route calls service, service calls repository
|
|
184
|
+
import { userService } from '@/services/user.service';
|
|
185
|
+
export async function GET() {
|
|
186
|
+
const users = await userService.listUsersWithPosts();
|
|
187
|
+
return Response.json(users);
|
|
188
|
+
}
|
|
189
|
+
// src/services/user.service.ts calls userRepository.listWithPosts()
|
|
190
|
+
// src/repositories/user.repository.ts calls prisma.user.findMany(...)
|
|
191
|
+
- id: manual_sql_migration
|
|
192
|
+
severity: warning
|
|
193
|
+
description: "Modifying the database schema with raw SQL (ALTER TABLE, CREATE TABLE) instead of Prisma's migration system. The migration history becomes out of sync with the schema.prisma file — `prisma migrate deploy` in production applies migrations that have already been applied or skips needed ones."
|
|
194
|
+
bad_example: |
|
|
195
|
+
-- ❌ Manual SQL bypasses Prisma migration tracking
|
|
196
|
+
-- Run directly in DB console:
|
|
197
|
+
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
|
|
198
|
+
-- schema.prisma still has no phone field — Prisma Client has no phone type
|
|
199
|
+
good_example: |
|
|
200
|
+
// ✓ 1. Add field in schema.prisma
|
|
201
|
+
// model User { phone String? }
|
|
202
|
+
// 2. Generate + apply migration:
|
|
203
|
+
// npx prisma migrate dev --name add-phone-to-users
|
|
204
|
+
// 3. Prisma Client regenerated automatically — phone field is now typed
|
|
205
|
+
- id: n_plus_one_query
|
|
206
|
+
severity: warning
|
|
207
|
+
description: "Loading a list of records and then fetching their relations in a loop — creates N+1 database queries (1 for the list + N for each record's relations). Use Prisma's include or nested select to fetch everything in a single query."
|
|
208
|
+
bad_example: |
|
|
209
|
+
// ❌ N+1: 1 query for posts + 1 query per post for the author
|
|
210
|
+
const posts = await prisma.post.findMany(); // query 1
|
|
211
|
+
for (const post of posts) {
|
|
212
|
+
post.author = await prisma.user.findUnique({ where: { id: post.userId } }); // N queries
|
|
213
|
+
}
|
|
214
|
+
good_example: |
|
|
215
|
+
// ✓ Single query: posts JOIN users via include
|
|
216
|
+
const posts = await prisma.post.findMany({
|
|
217
|
+
include: { author: { select: { id: true, name: true, email: true } } },
|
|
218
|
+
});
|
|
219
|
+
- id: unhandled_known_request_error
|
|
220
|
+
severity: warning
|
|
221
|
+
description: "Not catching PrismaClientKnownRequestError — unique constraint violations (P2002) and not-found errors (P2025) crash with an unhandled error instead of returning a user-friendly message. These errors are predictable and must be handled explicitly."
|
|
222
|
+
bad_example: |
|
|
223
|
+
// ❌ Unique constraint crash surfaces as a 500 Internal Server Error
|
|
224
|
+
export async function createUser(email: string) {
|
|
225
|
+
return prisma.user.create({ data: { email } });
|
|
226
|
+
// If email already exists: Unhandled PrismaClientKnownRequestError P2002
|
|
227
|
+
}
|
|
228
|
+
good_example: |
|
|
229
|
+
// ✓ Catch known errors and return domain-appropriate responses
|
|
230
|
+
import { Prisma } from '@prisma/client';
|
|
231
|
+
export async function createUser(email: string) {
|
|
232
|
+
try {
|
|
233
|
+
return await prisma.user.create({ data: { email } });
|
|
234
|
+
} catch (e) {
|
|
235
|
+
if (e instanceof Prisma.PrismaClientKnownRequestError) {
|
|
236
|
+
if (e.code === 'P2002') throw new Error('Email already in use');
|
|
237
|
+
}
|
|
238
|
+
throw e;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
- id: select_star_in_production
|
|
242
|
+
severity: warning
|
|
243
|
+
description: "Using findMany() or findUnique() without a select clause — fetches all columns including password hashes, internal flags, and large text fields. Always specify exactly what fields you need, especially in API responses."
|
|
244
|
+
bad_example: |
|
|
245
|
+
// ❌ Returns all fields including passwordHash, internalFlags, etc.
|
|
246
|
+
export async function GET() {
|
|
247
|
+
const users = await prisma.user.findMany(); // returns passwordHash!
|
|
248
|
+
return Response.json(users);
|
|
249
|
+
}
|
|
250
|
+
good_example: |
|
|
251
|
+
// ✓ Explicit select — only the fields the client needs
|
|
252
|
+
const users = await prisma.user.findMany({
|
|
253
|
+
select: { id: true, email: true, name: true, createdAt: true },
|
|
254
|
+
// passwordHash, internalFlags, etc. never leave the DB layer
|
|
255
|
+
});
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
schema_version: "2.0.0"
|
|
2
|
+
id: s3-storage
|
|
3
|
+
name: "AWS S3 / R2 Storage"
|
|
4
|
+
version: "2.0.0"
|
|
5
|
+
description: "Object storage with pre-signed URLs for direct browser uploads, key-based DB references, server-side S3 client singleton, file type validation before signing, and secure download URLs."
|
|
6
|
+
category: pattern
|
|
7
|
+
language: javascript
|
|
8
|
+
frameworks: []
|
|
9
|
+
dependencies:
|
|
10
|
+
none: []
|
|
11
|
+
detection:
|
|
12
|
+
dependencies:
|
|
13
|
+
any:
|
|
14
|
+
- "@aws-sdk/client-s3"
|
|
15
|
+
- "@aws-sdk/s3-request-presigner"
|
|
16
|
+
source_indicators:
|
|
17
|
+
- "S3Client"
|
|
18
|
+
- "PutObjectCommand"
|
|
19
|
+
- "getSignedUrl"
|
|
20
|
+
- "from '@aws-sdk/client-s3'"
|
|
21
|
+
- "s3-request-presigner"
|
|
22
|
+
structure:
|
|
23
|
+
required_dirs: []
|
|
24
|
+
recommended_dirs:
|
|
25
|
+
- path: src/lib
|
|
26
|
+
purpose: "S3 client singleton in s3.ts — the only file that calls `new S3Client()`. Reads credentials from environment variables (or IAM role when deployed on EC2/ECS). Imported by the upload service only — never by route handlers or components directly."
|
|
27
|
+
- path: src/services
|
|
28
|
+
purpose: "Upload and download service logic in upload.service.ts — createUploadUrl() generates a pre-signed PUT URL, createDownloadUrl() generates a pre-signed GET URL. Validates allowed MIME types before generating the URL to prevent arbitrary file uploads."
|
|
29
|
+
separation:
|
|
30
|
+
rules:
|
|
31
|
+
- concern: presigned_urls
|
|
32
|
+
belongs_in: src/services
|
|
33
|
+
rule_text: "Never proxy file bytes through your server. The upload flow is: (1) client calls your API to request a pre-signed PUT URL, (2) client uploads directly to S3 using that URL, (3) client notifies your API with the S3 key after upload completes. Server memory is never touched by file bytes."
|
|
34
|
+
example: |
|
|
35
|
+
// src/services/upload.service.ts
|
|
36
|
+
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
|
37
|
+
import { PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
|
|
38
|
+
import { s3 } from '@/lib/s3';
|
|
39
|
+
import { randomUUID } from 'crypto';
|
|
40
|
+
|
|
41
|
+
const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
|
|
42
|
+
|
|
43
|
+
export async function createUploadUrl(originalFilename: string, contentType: string) {
|
|
44
|
+
// Validate MIME type server-side before signing
|
|
45
|
+
if (!ALLOWED_MIME_TYPES.includes(contentType)) {
|
|
46
|
+
throw new Error(`File type ${contentType} is not allowed`);
|
|
47
|
+
}
|
|
48
|
+
// Randomize key to prevent path traversal and name collisions
|
|
49
|
+
const ext = originalFilename.split('.').pop();
|
|
50
|
+
const key = `uploads/${randomUUID()}.${ext}`;
|
|
51
|
+
const url = await getSignedUrl(
|
|
52
|
+
s3,
|
|
53
|
+
new PutObjectCommand({
|
|
54
|
+
Bucket: process.env.S3_BUCKET!,
|
|
55
|
+
Key: key,
|
|
56
|
+
ContentType: contentType, // required: browser upload rejects 403 without this
|
|
57
|
+
}),
|
|
58
|
+
{ expiresIn: 300 } // 5 minutes — enough for large files, short enough to limit misuse
|
|
59
|
+
);
|
|
60
|
+
return { url, key };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function createDownloadUrl(key: string) {
|
|
64
|
+
return getSignedUrl(
|
|
65
|
+
s3,
|
|
66
|
+
new GetObjectCommand({ Bucket: process.env.S3_BUCKET!, Key: key }),
|
|
67
|
+
{ expiresIn: 3600 } // 1 hour
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
indicators:
|
|
71
|
+
- "getSignedUrl"
|
|
72
|
+
- "PutObjectCommand"
|
|
73
|
+
- "expiresIn"
|
|
74
|
+
- concern: key_storage
|
|
75
|
+
belongs_in: src/models
|
|
76
|
+
rule_text: "Store the S3 object key (e.g. `uploads/abc123.jpg`) in your database — NOT the full S3 URL. Generate signed download URLs on-demand when serving the file to clients. This allows bucket migration, CDN changes, and URL policy changes without data migrations."
|
|
77
|
+
example: |
|
|
78
|
+
// ✓ Store key in DB — not the full URL
|
|
79
|
+
await db.user.update({
|
|
80
|
+
where: { id: userId },
|
|
81
|
+
data: { avatarKey: 'uploads/abc123-abc.jpg' }, // key only
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ✓ Generate download URL on-demand in an API response
|
|
85
|
+
const user = await db.user.findUnique({ where: { id: userId } });
|
|
86
|
+
const avatarUrl = user.avatarKey
|
|
87
|
+
? await createDownloadUrl(user.avatarKey)
|
|
88
|
+
: null;
|
|
89
|
+
return { ...user, avatarUrl }; // URL expires — never cached in DB
|
|
90
|
+
indicators:
|
|
91
|
+
- "avatarKey"
|
|
92
|
+
- "fileKey"
|
|
93
|
+
- "imageKey"
|
|
94
|
+
- "objectKey"
|
|
95
|
+
- concern: client_singleton
|
|
96
|
+
belongs_in: src/lib
|
|
97
|
+
rule_text: "Create the S3 client as a module-level singleton in src/lib/s3.ts. Never call `new S3Client()` outside this file. Credentials must come from environment variables or an IAM instance role — never hardcoded."
|
|
98
|
+
example: |
|
|
99
|
+
// src/lib/s3.ts
|
|
100
|
+
import { S3Client } from '@aws-sdk/client-s3';
|
|
101
|
+
|
|
102
|
+
// Singleton: AWS SDK reuses HTTP connections for performance
|
|
103
|
+
export const s3 = new S3Client({
|
|
104
|
+
region: process.env.AWS_REGION ?? 'us-east-1',
|
|
105
|
+
// No credentials field: uses AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY env vars
|
|
106
|
+
// or IAM instance role automatically
|
|
107
|
+
});
|
|
108
|
+
anti_indicators:
|
|
109
|
+
- "new S3Client({"
|
|
110
|
+
- concern: upload_confirmation
|
|
111
|
+
belongs_in: src/api
|
|
112
|
+
rule_text: "After the client uploads directly to S3, it must call your API with the key to confirm the upload. Your API then validates that the key actually exists in S3 before storing it in the database. Without confirmation, an attacker can store arbitrary S3 keys pointing to non-existent or malicious objects."
|
|
113
|
+
example: |
|
|
114
|
+
// app/api/upload/confirm/route.ts
|
|
115
|
+
import { HeadObjectCommand } from '@aws-sdk/client-s3';
|
|
116
|
+
import { s3 } from '@/lib/s3';
|
|
117
|
+
|
|
118
|
+
export async function POST(req: Request) {
|
|
119
|
+
const { key } = await req.json();
|
|
120
|
+
// Validate that the object actually exists in S3 before saving key to DB
|
|
121
|
+
await s3.send(new HeadObjectCommand({ Bucket: process.env.S3_BUCKET!, Key: key }));
|
|
122
|
+
// Only now save to DB
|
|
123
|
+
await db.user.update({ where: { id: userId }, data: { avatarKey: key } });
|
|
124
|
+
return Response.json({ success: true });
|
|
125
|
+
}
|
|
126
|
+
indicators:
|
|
127
|
+
- "HeadObjectCommand"
|
|
128
|
+
- "confirm"
|
|
129
|
+
patterns:
|
|
130
|
+
data_flow:
|
|
131
|
+
direction: "Client → POST /upload/presign (server validates MIME) → S3 pre-signed PUT URL → Client uploads directly to S3 → Client POST /upload/confirm → Server verifies object exists → DB stores key"
|
|
132
|
+
rules:
|
|
133
|
+
- "File bytes never touch your server — client uploads directly to S3."
|
|
134
|
+
- "Validate MIME type in createUploadUrl() before generating the signed URL."
|
|
135
|
+
- "Store S3 object keys in DB — generate signed download URLs on-demand."
|
|
136
|
+
- "Confirm uploads server-side with HeadObjectCommand before persisting the key."
|
|
137
|
+
- "Set ContentType on PutObjectCommand — browsers reject pre-signed uploads without it."
|
|
138
|
+
error_handling:
|
|
139
|
+
recommended: "Catch NoSuchKey (S3ServiceException code NoSuchKey) when generating download URLs — return null or 404 if the object was deleted. Set maxFileSize on the client and validate server-side in the presign endpoint."
|
|
140
|
+
naming:
|
|
141
|
+
client: "src/lib/s3.ts — singleton S3Client; credentials from environment variables only"
|
|
142
|
+
upload_service: "src/services/upload.service.ts — createUploadUrl() and createDownloadUrl() live here"
|
|
143
|
+
key_pattern: "[folder]/[uuid].[ext] — e.g. uploads/a1b2c3d4-ef56-7890-abcd-ef1234567890.jpg"
|
|
144
|
+
anti_patterns:
|
|
145
|
+
- id: proxy_upload
|
|
146
|
+
severity: warning
|
|
147
|
+
description: "Receiving uploaded files in your server and re-uploading to S3 — doubles bandwidth cost, risks memory exhaustion on large files, and adds significant latency. A 50 MB file received by your server means 100 MB of total bandwidth usage."
|
|
148
|
+
bad_example: |
|
|
149
|
+
// ❌ File received by server, then uploaded to S3
|
|
150
|
+
app.post('/upload', upload.single('file'), async (req, res) => {
|
|
151
|
+
await s3.send(new PutObjectCommand({
|
|
152
|
+
Body: req.file.buffer, // 50 MB in server RAM
|
|
153
|
+
Bucket: process.env.S3_BUCKET,
|
|
154
|
+
Key: `uploads/${req.file.originalname}`,
|
|
155
|
+
}));
|
|
156
|
+
res.json({ key: `uploads/${req.file.originalname}` });
|
|
157
|
+
});
|
|
158
|
+
good_example: |
|
|
159
|
+
// ✓ Server generates presigned URL — client uploads directly
|
|
160
|
+
app.post('/upload/presign', async (req, res) => {
|
|
161
|
+
const { url, key } = await createUploadUrl(req.body.filename, req.body.contentType);
|
|
162
|
+
res.json({ url, key }); // file never touches server
|
|
163
|
+
});
|
|
164
|
+
- id: store_full_url
|
|
165
|
+
severity: warning
|
|
166
|
+
description: "Storing full S3 URLs in the database — breaks when bucket, region, CDN domain, or URL signing policy changes. Migrating data is expensive; migrating a key column is trivial."
|
|
167
|
+
bad_example: |
|
|
168
|
+
// ❌ Full URL stored — breaks on bucket migration or CDN change
|
|
169
|
+
await db.user.update({
|
|
170
|
+
where: { id: userId },
|
|
171
|
+
data: { avatarUrl: 'https://my-bucket.s3.us-east-1.amazonaws.com/uploads/photo.jpg' },
|
|
172
|
+
});
|
|
173
|
+
good_example: |
|
|
174
|
+
// ✓ Store key only — generate signed URL on every read
|
|
175
|
+
await db.user.update({
|
|
176
|
+
where: { id: userId },
|
|
177
|
+
data: { avatarKey: 'uploads/abc123.jpg' },
|
|
178
|
+
});
|
|
179
|
+
- id: hardcoded_credentials
|
|
180
|
+
severity: critical
|
|
181
|
+
description: "Embedding AWS access keys directly in source code or committing them in .env files. Secrets in git history are permanent — even if removed in a later commit, they remain in git log and forks."
|
|
182
|
+
bad_example: |
|
|
183
|
+
// ❌ Hardcoded credentials — leaked in git history forever
|
|
184
|
+
new S3Client({
|
|
185
|
+
region: 'us-east-1',
|
|
186
|
+
credentials: {
|
|
187
|
+
accessKeyId: 'AKIAIOSFODNN7EXAMPLE',
|
|
188
|
+
secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
good_example: |
|
|
192
|
+
// ✓ AWS SDK reads credentials from environment automatically
|
|
193
|
+
// Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in .env (never committed)
|
|
194
|
+
// or use IAM role on EC2/ECS — no credentials needed in code
|
|
195
|
+
new S3Client({ region: process.env.AWS_REGION });
|
|
196
|
+
- id: missing_content_type_on_presign
|
|
197
|
+
severity: warning
|
|
198
|
+
description: "Generating a pre-signed PutObjectCommand without setting ContentType. S3 pre-signed URLs are signed against specific request parameters — if ContentType is in the signature but the browser upload omits or changes it, S3 returns a 403 Forbidden."
|
|
199
|
+
bad_example: |
|
|
200
|
+
// ❌ No ContentType — browser upload fails with 403
|
|
201
|
+
const url = await getSignedUrl(s3, new PutObjectCommand({
|
|
202
|
+
Bucket: process.env.S3_BUCKET,
|
|
203
|
+
Key: key,
|
|
204
|
+
// Missing: ContentType
|
|
205
|
+
}), { expiresIn: 300 });
|
|
206
|
+
good_example: |
|
|
207
|
+
// ✓ ContentType in the command matches what the browser will send
|
|
208
|
+
const url = await getSignedUrl(s3, new PutObjectCommand({
|
|
209
|
+
Bucket: process.env.S3_BUCKET,
|
|
210
|
+
Key: key,
|
|
211
|
+
ContentType: contentType, // e.g. 'image/jpeg'
|
|
212
|
+
}), { expiresIn: 300 });
|
|
213
|
+
// Client must set 'Content-Type': contentType header when PUT-ing to this URL
|
|
214
|
+
- id: no_file_type_validation
|
|
215
|
+
severity: critical
|
|
216
|
+
description: "Generating a pre-signed upload URL without validating the file's MIME type server-side. An attacker can request a signed URL for a .exe, .html (phishing), or .svg (XSS) file and upload it to your bucket — it will be served from your domain."
|
|
217
|
+
bad_example: |
|
|
218
|
+
// ❌ No MIME type validation — attacker uploads malware.exe to your S3 bucket
|
|
219
|
+
export async function POST(req: Request) {
|
|
220
|
+
const { filename, contentType } = await req.json();
|
|
221
|
+
// contentType can be anything the client sends
|
|
222
|
+
const url = await getSignedUrl(s3, new PutObjectCommand({
|
|
223
|
+
Bucket: process.env.S3_BUCKET, Key: `uploads/${filename}`, ContentType: contentType,
|
|
224
|
+
}), { expiresIn: 300 });
|
|
225
|
+
return Response.json({ url });
|
|
226
|
+
}
|
|
227
|
+
good_example: |
|
|
228
|
+
// ✓ Validate against allowlist before signing
|
|
229
|
+
const ALLOWED = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
|
230
|
+
export async function POST(req: Request) {
|
|
231
|
+
const { filename, contentType } = await req.json();
|
|
232
|
+
if (!ALLOWED.has(contentType)) return Response.json({ error: 'File type not allowed' }, { status: 400 });
|
|
233
|
+
const { url, key } = await createUploadUrl(filename, contentType);
|
|
234
|
+
return Response.json({ url, key });
|
|
235
|
+
}
|