Recipicity — Complete Architecture Review¶
Date: 2026-02-25 Last Updated: 2026-02-25 (immediate fixes applied) Scope: Full stack — API, Frontend, Admin, Database, Security, Infrastructure Reviewers: 5 specialist agents (Backend Architect, Security Auditor, Frontend Engineer, Database Specialist, Comprehensive Reviewer)
Executive Summary¶
Recipicity is a well-built social recipe platform with strong security fundamentals (parameterized queries, JWT validation, BFF proxy, security headers, audit logging) and an impressively lean frontend (only 5 runtime deps). However, four systemic issues threaten production scalability:
- N+1 query epidemic — up to 100 extra DB queries per request on key endpoints
- Zero transactions — multi-step writes risk data inconsistency on partial failure
- ~~Redis underutilization — Redis exists but is barely used for caching; rate limiting is per-container in-memory~~ Partially fixed — rate limiting now Redis-backed (H6)
- Unnecessary client rendering — Homepage and 18+ protected pages ship as client components when server rendering would improve LCP and reduce bundle size
Total findings across all reviews: 70+
| Severity | Count | Key Themes |
|---|---|---|
| Critical | ~~7~~ 2 remaining | ~~Privilege escalation~~, N+1 queries, no transactions, ~~homepage LCP~~, ~~memory leak in AI search~~, ~~stored XSS in JSON-LD~~, ~~no service layer~~ |
| High | ~~14~~ 10 remaining | SSRF, ~~root containers~~, missing auth checks, no caching, no loading states, accessibility, ~~duplicate PrismaClient~~, UsageService query fan-out |
| Medium | ~~25+~~ 24 remaining | Validation gaps, ~~code duplication~~, CSRF, missing indexes, inconsistent patterns |
| Low | 20+ | Naming, minor a11y, pagination at scale, cosmetic inconsistencies |
CRITICAL Findings (Fix Immediately)¶
~~C1. Privilege Escalation — Legacy Import Trusts Client userId~~ FIXED¶
Files: recipeImport.routes.ts:946 | Flagged by: Backend, Security, Comprehensive
Risk: Any authenticated user can create recipes under ANY other user's account.
// FIXED: userId now derived from authenticated token
const { url, saveAsDraft = true } = req.body;
const userId = req.user?.userId;
C2. N+1 Query Epidemic¶
Files: users.ts:377-388, users.ts:470-485, tags.ts:223-281, recipes.ts:319-323 | Flagged by: Backend, Database, Comprehensive
- User search (50 users): 100 extra queries per request
- Tag recipe listing (12 recipes, auth'd): 48 extra queries per request
- Recipe listing: fetches ALL individual rating rows instead of aggregating
- Profile view: 3 extra count queries each
Fix: Use Prisma _count includes or single raw SQL with subqueries. Batch user-specific lookups (likes, bookmarks, ratings) using findMany with recipeId: { in: recipeIds }.
Effort: 1-2 days. Impact: 10-50x reduction in DB queries.
C3. Zero Transaction Usage¶
Files: bookmarks.ts:176-206, recipes.ts:673-743 | Flagged by: Database, Backend, Comprehensive
Zero uses of prisma.$transaction() in entire codebase. Bookmark + collection sync, recipe + tag creation can leave data inconsistent on partial failure.
Effort: 1 day.
C4. Homepage Is a Client Component (LCP Bottleneck)¶
Files: home-content.tsx:1 | Flagged by: Frontend
Entire homepage forced into client bundle because of 2 conditional CTA links. Hero image uses CSS background-image to external Unsplash URL, bypassing Next.js Image optimization.
Fix: Split into server component + tiny <HomeCTA /> client island. Replace CSS background with <Image priority>.
Effort: 2 hours.
~~C5. AI Ingredient Search Loads All Recipes Into Memory~~ FIXED¶
File: aiRecipeGeneration.service.ts:223-244 | Flagged by: Comprehensive
prisma.recipe.findMany() with no take limit fetches EVERY public recipe into Node.js memory for in-process filtering. Will OOM the container at scale.
Status: :white_check_mark: Fixed 2026-02-25. Added take: 500 limit with orderBy: createdAt desc.
~~C6. Stored XSS via JSON-LD Script Injection~~ FIXED¶
File: json-ld.tsx:3 | Flagged by: Comprehensive, Frontend
JSON.stringify(data) does not escape </script>. A recipe title containing </script><script>alert(1)</script> breaks out of the JSON-LD script block.
Status: :white_check_mark: Fixed 2026-02-25. Added .replace(/<\/script/gi, '<\\/script') after stringify.
~~C7. Multiple new PrismaClient() Instances (Bypass Singleton)~~ FIXED¶
Files: admin/users.ts:8, nutrition.ts:14 | Flagged by: Comprehensive
These files create their own PrismaClient instead of importing the singleton. Each opens a separate connection pool, bypasses the slug auto-generation extension, and increases PgBouncer pressure.
Status: :white_check_mark: Fixed 2026-02-25. Both files now import from lib/database.ts.
HIGH Findings¶
| # | Finding | Source | Status |
|---|---|---|---|
| ~~H1~~ | ~~Docker API container runs as root (no USER directive)~~ | Security | :white_check_mark: FIXED |
| H2 | SSRF via recipe import — DNS rebinding + IPv6 bypass | Security | Open |
| H3 | optionalAuthMiddleware doesn't check active/banned status | Backend | Open |
| ~~H4~~ | ~~Admin logout broken — bcrypt hashing produces different hash each time~~ | Security | :white_check_mark: FIXED |
| H5 | No Redis caching on expensive queries (listings, popular, cuisines) | Database | Open |
| ~~H6~~ | ~~Rate limiting per-container not shared (in-memory, not Redis)~~ | Database+Security | :white_check_mark: FIXED |
| H7 | No loading.tsx skeletons on 18 of 20 routes | Frontend | Open |
| H8 | No next: { revalidate } on any server-side fetch |
Frontend | Open |
| H9 | Dropdown menus fail WCAG AA keyboard accessibility | Frontend | Open |
| H10 | Zero next/dynamic lazy loading for modals/heavy components |
Frontend | Open |
| ~~H11~~ | ~~getUserId helper duplicated 6+ times (4 different implementations)~~ | Backend | :white_check_mark: FIXED |
| H12 | Feature check boilerplate duplicated 5+ times in import routes | Backend | Open |
| H13 | UsageService runs 15 DB queries per usage summary call | Comprehensive | Open |
| ~~H14~~ | ~~unhandledRejection calls exit(0) — Swarm sees clean exit, may not restart~~ |
Comprehensive | :white_check_mark: FIXED |
MEDIUM Findings (Summarized)¶
| # | Finding | Source |
|---|---|---|
| ~~M1~~ | ~~Unauthenticated data-deletion status endpoint leaks emails~~ | Security |
| M2 | No CSRF token; null-origin CORS bypass | Security |
| M3 | Regex HTML sanitizer (bypassable with svg/img) | Security |
| M4 | Duplicate comment endpoints (social.ts + comments.ts) | Backend |
| M5 | Inline route handlers in server.ts (subscription plans, 120 lines) | Backend |
| M6 | No validation on profile update (schema exists but unused) | Backend |
| M7 | Notification settings accepts arbitrary fields (userId injection) | Backend |
| M8 | Email preferences accepts arbitrary JSON | Backend |
| M9 | CUID validation middleware exists but never applied | Backend |
| M10 | Missing indexes: User (active, oauth), GroceryList (userId), Report | Database |
| M11 | Collections list eager-loads ALL items + recipes + authors | Database |
| M12 | Duplicate notification preference storage (3 separate locations) | Database |
| M13 | MinIO client initialized twice (upload.ts + users.ts) | Backend |
| M14 | 18 protected pages unnecessarily fully client-rendered | Frontend |
| M15 | AuthProvider re-fetches user on every page navigation (no cache) | Frontend |
| M16 | Notification polling continues in background tabs | Frontend |
| M17 | Inconsistent error response shapes across endpoints | Backend |
| M18 | Missing rate limiting on upload and tags routes | Backend |
| M19 | Admin audit middleware logs entire request bodies (GDPR concern) | Backend |
| M20 | No nested error boundaries in frontend | Frontend |
| M21 | Silent error swallowing (empty catch blocks, no monitoring) | Frontend |
| M22 | CSP allows unsafe-inline and unsafe-eval in scripts | Frontend |
| M23 | Cookie consent modal lacks focus trap + ARIA dialog | Frontend |
| M24 | Recipe search uses ILIKE on JSONB (full table scan, no GIN index) | Database |
| M25 | Analytics reachable at both /api/analytics AND /api/admin/analytics | Comprehensive |
| M26 | OAuth code stored in resetToken column — concurrent login/reset clobber | Comprehensive |
| M27 | Admin backup exports to container filesystem (lost on deploy, incomplete) | Comprehensive |
Cross-Cutting Themes¶
1. Caching Gap Spans the Entire Stack¶
- Backend: No Redis query caching (H5)
- Frontend: No
revalidateon server fetches (H8) - Infrastructure: Rate limiting per-container (H6)
A unified caching strategy (Redis for API, ISR for public pages, Redis-backed rate limiting) would address all three simultaneously.
2. Security Tooling Built But Not Wired Up¶
Joi validation schemas exist but several endpoints don't use them (M6). validateObjectId middleware exists but is never applied (M9). ~~RedisManager.rateLimit() exists but rate limiter uses in-memory store.~~ (Fixed: H6) Pattern: security tools are built, just not connected.
3. DRY Violations Create Maintenance Risk¶
~~6+ copies of getUserId~~ (Fixed: H11 — consolidated into auth-helpers.ts), 5 copies of feature check boilerplate, 3 copies of asyncHandler, 4 copies of email preferences parsing, 2 MinIO client initializations. Each duplicate is a place where fixes can be missed.
4. Frontend Performance vs. Server Components¶
The recipe detail page demonstrates the correct pattern (server component with client islands). But the homepage and all 18 protected pages don't follow it, missing streaming SSR, reducing LCP, and inflating the JS bundle.
Prioritized Action Plan¶
Immediate — Fix This Week :white_check_mark: COMPLETED¶
All 10 immediate items fixed and deployed to staging on 2026-02-25.
| # | Action | Status |
|---|---|---|
| 1 | Fix req.body.userId → req.user?.userId in recipeImport (C1) |
:white_check_mark: Done |
| 2 | Fix JSON-LD XSS by escaping </script> (C6) |
:white_check_mark: Done |
| 3 | Import singleton PrismaClient in admin/users.ts + nutrition.ts (C7) | :white_check_mark: Done |
| 4 | Add USER appuser to API Dockerfile (H1) |
:white_check_mark: Done |
| 5 | Fix admin logout bcrypt → SHA-256 lookup (H4) | :white_check_mark: Done |
| 6 | Add auth to data-deletion status endpoint (M1) | :white_check_mark: Done |
| 7 | Add take: 500 limit to AI ingredient search (C5) |
:white_check_mark: Done |
| 8 | Fix exit(0) → exit(1) in unhandledRejection (H14) |
:white_check_mark: Done |
| 9 | Switch rate limiter to Redis store (H6) | :white_check_mark: Done |
| 10 | Consolidate getUserId/getAdminId into shared auth-helpers.ts (H11) |
:white_check_mark: Done |
Short-Term — Next Sprint (~5-7 days)¶
| # | Action | Effort |
|---|---|---|
| 11 | Fix N+1 queries in users.ts, tags.ts, recipes.ts (C2) | 1-2 days |
| 12 | Wrap multi-step writes in $transaction (C3) | 1 day |
| 13 | Add Redis caching for expensive endpoints (H5) | 1 day |
| 14 | Convert HomeContent to server component + Image priority (C4) | 2 hours |
| 15 | Add next: { revalidate } to public server fetches (H8) |
2 hours |
| 16 | Add loading.tsx skeletons for major routes (H7) | 1 day |
| 17 | Lazy-load modals with next/dynamic (H10) | 2 hours |
| 18 | Extract requireFeature() middleware (H12) | 1 hour |
Medium-Term — This Quarter¶
| # | Action | Effort |
|---|---|---|
| 19 | Add missing DB indexes (User, GroceryList, Report) (M10) | 1 hour |
| 20 | Denormalize averageRating/ratingCount on Recipe model | 1 day |
| 21 | SSRF hardening — resolve-time IP check, IPv6 blocking (H2) | 1 day |
| 22 | Replace regex sanitizer with DOMPurify (M3) | 2 hours |
| 23 | Keyboard navigation for dropdown menus (H9) | 1 day |
| 24 | CSRF tokens on state-changing endpoints (M2) | 2 days |
| 25 | Convert protected pages to server+client split (M14) | 3 days |
| 26 | Standardize error response format across all endpoints (M17) | 2 days |
Long-Term — Strategic¶
| # | Action | Effort |
|---|---|---|
| 27 | Extract service layer (RecipeService, UserService, etc.) | 2-3 sprints |
| 28 | PostgreSQL full-text search (tsvector) for recipe search | 1 week |
| 29 | Cursor-based pagination for high-traffic endpoints | 3 days |
| 30 | Consolidate notification preferences into single model | 2 days |
What's Working Well (Preserve These)¶
- Parameterized queries everywhere — Zero SQL injection risk. No
$queryRawUnsafeusage. - BFF proxy architecture — httpOnly cookies, CSRF origin check, path allowlist. Production-grade.
- 5 runtime dependencies — Incredibly lean frontend. No state management library bloat.
- Schema.org Recipe JSON-LD — Full structured data with ingredients, instructions, nutrition, ratings.
- Recipe detail page pattern — Server component with client islands is the ideal architecture.
- JWT secret validation at startup — Enforces 32-char minimum, prevents admin/user secret reuse.
- Docker secrets in production — No hardcoded secrets in source code.
- Account lockout + anti-enumeration — Progressive lockout, generic error messages.
- Recipe model has 7 well-chosen indexes — Composite indexes on common query patterns.
- Optimistic UI updates — Like/bookmark with proper rollback on failure.
- Admin auth separation — Separate JWT secret, MFA support, audit logging.
- Privacy compliance — GDPR/CCPA, cookie consent with granular categories, data export/deletion.
Full individual reports:
- Backend Review (694 lines, 37 findings)
- Security Review (795 lines, 27 findings)
- Frontend Review (625 lines, B+ grade)
- Database Review (458 lines, 12 recommendations)
- Comprehensive Review (cross-cutting findings)