Skip to content

Recipicity Staging — Comprehensive Architecture Review

Scope: /opt/development/recipicity/staging/ (recipicity-api, recipicity-frontend, recipicity-admin) Review Date: 2026-02-25 Reviewer: Claude Code (Sonnet 4.6)


Table of Contents

  1. Backend Architecture
  2. Security
  3. Frontend Architecture
  4. Database
  5. Cross-Cutting Concerns
  6. Prioritized Action Plan

1. Backend Architecture

1.1 Route Design

Verdict: Functional but inconsistently organized.

The API is structured as a flat collection of Express routers registered in server.ts. At 35+ route files this is reaching the limits of manageable flat registration. There is no versioning prefix (e.g. /api/v1/), which means any breaking change requires a flag or a flag-day cutover.

A minor but telling symptom: server.ts mounts both /api/admin and then later adds /api/analytics separately after admin — but admin/index.ts already mounts analytics under the admin prefix as well. This means analytics is reachable at both /api/analytics and /api/admin/analytics with different auth requirements.

/api/search is implemented as a chain of redirects (302), not a real endpoint. GET /api/search → redirect → GET /api/search/recipes → redirect → GET /api/recipes. Two server-round-trips for a single read. Remove the redirects; mount the logic directly, or make it a thin alias that calls the same handler.

Inconsistent route naming conventions. Some files use .routes.ts suffix (cooking.routes.ts, tiktok.routes.ts, recipeImport.routes.ts) and others don't. This makes grep/discovery painful. Pick one and normalize.

The subscriptions.ts and subscriptions-minimal.ts files coexist. Having two files for the same domain with no clear ownership boundary is a maintenance liability. The -minimal file should be merged or deleted.

1.2 Service Layer

Verdict: Exists but is thin in places; logic leaks into routes.

There is a proper services/ directory but many routes still contain business logic inline. For example:

  • routes/users.ts contains complex follower/following count logic, email preference parsing, and digest preview generation inline rather than delegating to a service.
  • routes/social.ts does the full like/follow CRUD inline (acceptable for simple operations, but comment creation, notification dispatch, and authorization are all interleaved).

UsageService (src/services/usage.service.ts) runs 3 separate queries per limit check. checkRecipeLimit calls getUserLimits (1 DB hit) + prisma.recipe.count (1 DB hit) + prisma.userSubscription.findFirst (1 DB hit). getUserUsageSummary then calls all five check methods which fan out to 15 DB queries. This is called on every recipe creation. Merge into a single parameterized query or add Redis caching with a short TTL.

AIRecipeGenerationService.searchByIngredients loads every public recipe into memory. Line 223-244 of aiRecipeGeneration.service.ts:

const recipes = await prisma.recipe.findMany({
  where: { published: true, visibility: 'public' },
  include: { author: ..., _count: ... },
});
This is an unbounded full-table scan with no take limit. On a large dataset this will OOM the container. Push the ingredient matching into a PostgreSQL ILIKE ANY array query or use a full-text search index. At minimum add take: 500 as a safety guard.

Slug generation uses a busy-wait loop. database.ts lines 82-92:

while (true) {
  const existing = await baseClient.recipe.findFirst({ where: { authorId, slug } });
  if (!existing) break;
  counter++;
  slug = `${baseSlug}-${counter}`;
}
Under concurrent creation this loop can spin. Use a SELECT ... FOR UPDATE advisory lock or generate a UUID suffix immediately. The current approach also makes N separate DB round-trips.

1.3 Error Handling

Verdict: Solid foundation with a few gaps.

The errorHandler middleware (middleware/errorHandler.ts) correctly suppresses stack traces from client responses and maps common Prisma error codes. The asyncHandler wrapper prevents unhandled rejections leaking to the unhandledRejection process event.

Gap: several route files bypass asyncHandler and use try/catch directly — this is fine but inconsistent. Routes that use asyncHandler at the middleware level (router.use(asyncHandler)) are protected; those that use it per-handler are also fine. The inconsistency is not dangerous but adds noise.

Gap: server.ts lines 640-650uncaughtException and unhandledRejection both call gracefulShutdown, which closes the HTTP server and calls process.exit(0). This is wrong. An uncaught exception should exit with code 1 (process.exit(1)), not 0. Docker Swarm uses the exit code to decide whether to restart the container. unhandledRejection calling gracefulShutdown is also aggressive — many unhandledRejection cases are non-fatal (e.g. a fire-and-forget notification email). Consider logging + alerting on unhandled rejection without killing the process for non-critical paths.

1.4 Middleware Stack Order

Verdict: One critical ordering issue.

In server.ts, Helmet and CORS are applied before body parsing. This is correct. However:

The Paddle webhook route is commented out (line 24, 369). The comment says it needs the raw body before express.json(). If uncommented as-is it will break because body parsing is applied first. This is a production landmine for whenever Paddle is re-enabled.

sanitizeHtml middleware (middleware/security.ts) uses a regex-based approach to strip dangerous HTML. The approach is reasonable for an API that trusts React to escape on render, but the regex patterns have known gaps (e.g., <svg onload=...> is not stripped, CSS expression(), <meta http-equiv="refresh">). The comment is honest about this being intentional, but it should be documented that this protection depends entirely on the frontend never using dangerouslySetInnerHTML on user content — which is currently mostly true (see Section 2 for the one exception).

1.5 N+1 Queries

High-severity, multiple locations:

routes/users.ts/recent endpoint (lines 377-389):

const usersWithCounts = await Promise.all(
  users.map(async (user) => {
    const [recipeCount, followerCount] = await Promise.all([
      prisma.recipe.count({ where: { authorId: user.id, ... } }),
      prisma.follow.count({ where: { followingId: user.id } }),
    ]);
  })
);
This is a textbook N+1. For limit=24 users this is 48 additional queries. Use _count in the initial findMany select instead.

routes/users.ts/search endpoint (lines 470-484): Same pattern. 2N+1 queries for a search result. The comment says // FIXED: Manually count for each user to ensure accuracy but the fix has introduced a worse problem than the one it solved. Prisma's _count relation aggregate is accurate.

routes/tags.ts (line 224): recipes.map(async (recipe) => {...}) — same pattern.

routes/admin/reports.ts (line 59): reports.map(async (report) => {...}) — same pattern.

All four cases should use Prisma's include: { _count: { select: { ... } } } on the primary query. The comment "manually count to ensure accuracy" is a misunderstanding — Prisma's _count is a COUNT(*) aggregate computed in the same query, not a cached value.

1.6 Missing Input Validation

  • routes/users.tsPUT /profile (line 125): firstName, lastName, bio, location are applied to the DB update with no length validation. A user can store a 1MB bio.
  • routes/social.tsPOST /:recipeId/comments: content is trimmed and checked for empty but has no maximum length. A 100KB comment is valid.
  • routes/users.tsPUT /notification-settings (line 724): updateData is passed directly to prisma.notificationSettings.upsert with only partial validation on three fields. Any unknown JSON key not in the schema will be silently dropped by Prisma, but string keys could cause unexpected type coercion.

2. Security

2.1 Authentication and Authorization

[Medium] JWT stored in httpOnly cookie via BFF — this is the right approach.

The BFF pattern (src/app/bff/) stores the JWT in an httpOnly cookie and proxies requests to the API, injecting the Bearer token server-side. This prevents JavaScript from accessing the token and eliminates the most common JWT-in-localStorage XSS attack. The implementation is correct.

[Medium] Refresh token is a re-signed access token, not a separate long-lived credential.

POST /api/auth/refresh simply re-signs a new 24h access token from the existing token payload. There is no refresh token rotation and no revocation list. If a token is stolen, it can be refreshed indefinitely until the original 24h expires. For a recipe platform this is probably acceptable, but should be a conscious decision, not an accidental one.

[Low] OAuth code exchange abuses resetToken field.

routes/auth.ts lines 960-966:

await prisma.user.update({
  where: { id: user.id },
  data: {
    resetToken: `oauth:${authCode}`,
    resetTokenExpiry: expiresAt,
  },
});
OAuth codes are stored in the resetToken column alongside Redis. This means if a user initiates a password reset and an OAuth login simultaneously, one will clobber the other. Use a dedicated oauthCode column or rely solely on Redis (which is already being used and is correct).

[Low] optionalAuthMiddleware in auth.ts swallows all JWT errors silently (line 110). This is intentional but means a token that was signed with the wrong secret, or a garbled token, will be treated as "unauthenticated" rather than "bad request." For public endpoints this is fine; for endpoints that conditionally show private content (own drafts, etc.), a garbled token grants public-only access when the user expected authenticated access. Consider returning 401 on JsonWebTokenError in the optional middleware as well.

[Low] Admin auth.ts uses raw SQL for all queries. While parameterized with tagged template literals (safe from SQL injection), this bypasses Prisma's type system. WHERE email = ${email.toLowerCase()} OR username = ${email} — note that the username branch does NOT lowercase the value. A username Chef would not match a login attempt with chef. Inconsistency between the user login (which does case-insensitive via Prisma mode: 'insensitive') and admin login.

2.2 Rate Limiting

[Medium] Global rate limiter skips all private IP ranges.

server.ts lines 170-179:

skip: (req): boolean => {
  const ip = req.ip || req.connection.remoteAddress;
  return !!(
    ip === '127.0.0.1' || ip === '::1' || ip?.startsWith('192.168.') || ip?.startsWith('10.')
  );
},
The global limiter on /api/ skips all 192.168.x.x and 10.x.x.x IPs. In a Docker Swarm environment, the Traefik proxy sets X-Forwarded-For. If trust proxy: 1 (which is set) correctly extracts the real client IP this is fine. If Traefik is misconfigured or an internal service calls the API directly, the rate limit is bypassed. Verify this in production with a curl from a swarm node.

The rateLimiter.ts standardLimiter (100 req/15min) is applied per-router at the route file level, while the global /api/ limiter allows 500 req/15min. A client can use those 500 requests against any combination of endpoints before the per-router 100-limit kicks in. Consider making the global limiter more restrictive (e.g., 200) or removing the per-router limiters and relying on the global one.

[Low] authLimiter is defined twice — once in middleware/rateLimiter.ts and once inline in routes/auth.ts. The inline version is what's used; the exported one is unused. This is dead code and a confusion risk.

2.3 CORS

[Low] Development CORS accepts http://${LHOST}:3000 and http://${LHOST}:3020, where LHOST defaults to process.env.DOMAIN || 'recipicity.com'. In development this evaluates to http://recipicity.com:3000 which is not a valid local development origin. This likely means CORS is broken in local dev unless DOMAIN is set correctly, and developers work around it (or the API and frontend share a host behind a proxy). Not a security risk but a developer experience issue.

2.4 XSS / Injection

[Medium] dangerouslySetInnerHTML in json-ld.tsx.

src/components/seo/json-ld.tsx:

<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }} />
JSON.stringify does not escape </script>. If recipe title or description contains </script><script>alert(1)</script>, the JSON-LD block will break out of the script tag. The fix is one line:
JSON.stringify(data).replace(/<\/script>/gi, '<\\/script>')
This is a stored XSS vector if any user-controlled field feeds into the JSON-LD data without sanitization.

[Low] src/app/home-content.tsx line 58: Hard-coded Unsplash image URL as a background-image inline style. Not a security vulnerability (it's a static string), but it references an external image without integrity checks and would fail in environments with strict CSP.

[Low] SQL injection via $queryRaw in admin routes. All raw SQL in routes/admin/auth.ts and middleware/adminAuth.ts uses Prisma's tagged template literals ($queryRaw\...``), which are parameterized and safe from injection. No issues here.

2.5 Secrets and Configuration

[Low] secrets.ts logs the name of every secret loaded (console.info(\Loaded ${secretName} from Docker secret file``). In production this leaks the list of configured secrets to stdout, which may be indexed by log aggregation. Change to debug-level or remove.

[Low] server.ts readiness check creates a new Redis instance on every /readyz poll (lines 249-262). new Redis(...) opens a connection, pings, then disconnects. At Swarm health check intervals (every 10-30s) this creates and tears down connections constantly. Reuse the singleton RedisManager.getInstance().

[Good] httpOnly: true, secure: true, sameSite: "lax" on the JWT cookie. Correct defaults.

[Low] sameSite: "lax" allows the cookie to be sent on top-level GET navigations from external sites. For staging.recipicity.com behind Traefik with no external embeds this is fine, but sameSite: "strict" would be marginally stronger without breaking the login flow.

2.7 Dependency Vulnerabilities

[Medium] playwright (^1.58.2) is listed as a production dependency in package.json. Playwright bundles Chromium (~300MB) and is a test/scraping tool. Its presence in production dependencies means it may be included in the production Docker image and increases the attack surface. If it's used for recipe import scraping (browserFetch.service.ts), that's a deliberate choice, but it should be explicitly documented and the container hardened (--cap-drop ALL, --no-sandbox).

[Low] "axios": "^1.13.5" is a production dependency but the codebase primarily uses native fetch. Axios appears in minor utility code. Not a vulnerability, but is dead weight in the bundle.


3. Frontend Architecture

3.1 Server vs. Client Components

Verdict: Generally well-structured with some unnecessary client components.

The app router layout is correct: (public) pages use server components for SSR, (protected) pages appropriately use "use client" because they require auth state. The BFF pattern properly separates server-side auth from client-side interactivity.

43 files contain "use client" directives. This count is not itself a problem — it includes many justified client components (forms, infinite scroll, cooking session timer). However, several whole route pages are client components when only a small interactive island within them needs to be.

Examples: - src/app/(protected)/bookmarks/page.tsx"use client" for the whole page. The static header, metadata, and shell could be server-rendered, with only the infinite scroll grid as a client component. - src/app/(protected)/collections/page.tsx"use client" with useEffect + useState for data fetching. This entire pattern (fetch on mount) is what Server Components are designed to replace. - src/app/(protected)/meal-plans/page.tsx — same pattern.

Any "use client" page that does useEffect(() => { fetch(url).then(...) }, []) can be refactored to a server component with a client island for the interactive parts. This improves TTFB, eliminates loading spinners, and makes the content crawlable.

home-content.tsx is marked "use client" solely to call useAuth() and conditionally render one line (user vs. unauthenticated CTA). The entire hero, features grid, and trending recipes section are rendered client-side. The server can pass trendingRecipes as a prop (it already does), and the auth state check can be moved to a tiny <UserGreeting> client island.

3.2 Data Fetching Patterns

serverFetch reads the JWT from cookies and forwards it to the API. This is correct for server components. However serverFetch has no caching strategy — every server component render makes a fresh API call. Next.js 13+ fetch automatically deduplicates within a request but does not cache across requests unless { cache: 'force-cache' } or { next: { revalidate: N } } is specified.

The sitemap (src/app/sitemap.ts) does use { next: { revalidate: 3600 } } — this is the only location using revalidation. Recipe pages, user profiles, and discover pages would benefit from short-lived cache (e.g., 60 seconds) to reduce API load on popular content.

No Suspense boundaries on server component data fetches. If serverFetch throws (API down, network error), the entire page renders an error state. Wrapping data-fetching sections in <Suspense fallback={<Skeleton />}> and <ErrorBoundary> would allow partial rendering and graceful degradation.

3.3 Bundle Size and Performance

No code splitting analysis was run, but several observations:

  • lucide-react at ^0.501.0 is imported at the icon-component level throughout. This is tree-shakeable, so no bundle concern.
  • react-hook-form is a production dependency but only used in a subset of forms. Not a concern at ~25KB gzipped.
  • The frontend package.json has 4 runtime dependencies. This is extremely lean and commendable.
  • No image optimization analysis, but next/image is used in several places (correct). The Unsplash URL hard-coded in home-content.tsx bypasses next/image optimization because it's used as a CSS background-image. Use next/image with fill and objectFit: cover for the hero section.

3.4 Accessibility (a11y)

  • horizontal-recipe-scroll.tsx scroll buttons have aria-label="Scroll left/right". Good.
  • Recipe cards — not reviewed in full but should verify alt text on next/image components.
  • No role="main" or landmark regions verified in layouts. The PageLayout component should be checked.
  • Interactive elements using onClick on non-button/anchor elements should be audited.

3.5 SEO

layout.tsx robots config:

robots: {
  index: process.env.NODE_ENV === "production" && process.env.NEXT_PUBLIC_SITE_URL === "https://recipicity.com",
  follow: ...
}
This correctly prevents staging from being indexed. Good.

Recipe page uses generateMetadata with getRecipeBySlug. This makes two API calls per page render (one for metadata, one for content). The recipe data should be fetched once and passed to both. Next.js does deduplicate fetch calls to the same URL within a single render, but only if the URL is identical — serverFetch constructs the URL dynamically so dedup may not trigger if the token differs between calls.

JSON-LD for recipes is present via JsonLd component — good for structured data. The XSS risk noted above (Section 2.4) must be fixed.


4. Database

4.1 Schema Design

The schema is well-normalized with appropriate use of junction tables (RecipeTag, Like, Bookmark, Follow). CUIDs are used as primary keys — these are not sortable by time and require full index scans for range queries. UUIDs v7 or ULIDs would be sortable, but this is a low-priority change with high migration cost.

Inconsistent column naming convention. The schema uses camelCase Prisma field names mapped to snake_case DB columns (correct via @map) for some fields, but other columns are camelCase in the database itself (createdAt, updatedAt appear to be stored as-is based on Prisma defaults). This creates confusion when writing raw SQL queries (as is done heavily in admin routes).

User.password is a non-optional String. OAuth users created via POST /api/auth/oauth are given a random password hash (line 1163 of auth.ts). This is a reasonable workaround but means the column semantics are "password or random bytes" — not distinguishable without checking oauthProvider. Consider adding a passwordSet: Boolean @default(true) flag, or making password nullable for OAuth-only users (requires a migration).

User.emailPreferences is a Json field that is parsed/serialized manually in multiple route handlers with duplicated try/catch + default merge logic. This JSON blob has grown to include both email and push notification preferences. Extract this to a proper NotificationSettings table (which the schema already has! — notificationSettings model exists) and migrate the JSON field data. The code already has a try/catch fallback for when the table doesn't exist, suggesting this migration is in progress but incomplete.

No soft delete pattern. Recipes and users are hard-deleted. If a user deletes an account and a recipe has comments or ratings pointing to their userId, the cascade behavior must be verified. The schema shows Comment.userId has a relation to User — check the Prisma onDelete behavior (likely Cascade or SetNull).

4.2 Index Coverage

The Prisma schema migrations directory shows only 5 migration files, none containing explicit index creation. Prisma adds indexes for @unique fields and relation keys automatically, but no composite or partial indexes are visible. Missing indexes likely include:

  • Recipe(authorId, published) — used in virtually every user profile query
  • Recipe(published, visibility, createdAt) — used for discover/trending feeds
  • Follow(followerId) and Follow(followingId) — used in every social query
  • Like(recipeId) and Like(userId) — used for like counts and checks
  • Comment(recipeId, createdAt) — used for comment pagination

These are implicitly created by Prisma for foreign keys on PostgreSQL (BTREE), so the relation IDs are indexed. But the compound indexes for common filter patterns (e.g., WHERE authorId = X AND published = true) are not confirmed. Run EXPLAIN ANALYZE on the most common queries in production.

4.3 Pagination

Most list endpoints use offset pagination (skip, take). This is fine for small datasets but degrades for large offsets (the DB must scan and discard all rows up to the offset). For social feeds (recipes, notifications, activity) consider cursor-based pagination using cursor + take. Prisma supports this natively.

Comment pagination (social.ts lines 261-279) correctly uses skip/take, and the total is queried separately — creating a second round-trip per page load. Use _count or a window function to get the total in one query.

4.4 Transactions

Notably absent. The like/follow/comment create operations do not use transactions. For example, creating a like and sending a notification are done as two separate operations. If the notification insert fails, the like still exists (acceptable). But consider the recipe creation flow — creating a recipe, then looping to create tags one by one (lines 371-406 of aiRecipeGeneration.service.ts) — if the process crashes mid-loop, the recipe exists with partial tags. Wrap in prisma.$transaction.

The only $transaction in the codebase is in dataRetention.service.js. For safety-critical multi-step writes (recipe + tags, user + subscription, etc.) transactions should be the default.

4.5 Admin Backup Strategy

[Medium] The "database backup" feature (routes/admin/database.ts) is not a real backup.

The backup writes a JSON export of only 8 tables (users, recipes, admin_users, admin_sessions, likes, bookmarks, follows, comments) to the container's filesystem at /app/backups. This misses tags, meal plans, grocery lists, collections, subscriptions, notifications, ratings, comments (duplicated?), and all junction tables.

More critically, this backup file lives inside the container. When the container is replaced during a deployment, the backup is lost. This gives admins false confidence that they have a backup. Real backups should use pg_dump piped to MinIO/S3. The current feature should either be removed or clearly labeled "partial data export."


5. Cross-Cutting Concerns

5.1 Multiple Prisma Client Instances (Critical)

routes/admin/users.ts line 8: const prisma = new PrismaClient(); routes/nutrition.ts line 14: const prisma = new PrismaClient();

These files instantiate their own PrismaClient rather than importing the singleton from lib/database. Each PrismaClient instance maintains its own connection pool to PostgreSQL. With PgBouncer in transaction pooling mode (which is standard for this infrastructure), each instance opens separate connections. Two extra instances in production means more connection pressure and the slug auto-generation extension from database.ts does NOT apply to these instances.

Fix: Replace both with import { prisma } from '../../lib/database';.

5.2 JavaScript Mixed with TypeScript

Several source files are .js not .ts: - routes/sitemap.js - routes/gdpr.js - routes/robots.js - routes/analytics-config.js - services/dataRetention.service.js - scripts/digest-scheduler.js

These files are require()'d from TypeScript with require('./routes/...') in server.ts. This bypasses the TypeScript type system for these routes. The GDPR route in particular handles sensitive data deletion and would benefit from type safety. Convert these to .ts.

5.3 Admin Users Table Uses Raw SQL, Not Prisma

The AdminUser model is in the Prisma schema and prisma.adminUser is used in some places (routes/admin/database.ts, routes/admin/auth.ts partial). But most admin auth operations use prisma.$queryRaw against the admin_users table directly. This dual access pattern means schema changes require updating both the Prisma model and raw SQL strings. Pick one approach.

5.4 Lack of Observability

  • No structured logging. console.info/warn/error throughout, with ad-hoc string interpolation. In production this makes log parsing and alerting difficult. Consider pino or winston with JSON output.
  • No request ID propagation. A request comes in, the requestLogger logs it, but there is no correlation ID that links the incoming request log to subsequent DB query logs or error logs.
  • No distributed tracing hooks.
  • No metrics endpoint (Prometheus/OpenMetrics). Uptime Kuma does external HTTP checks, but there is no internal metrics for request rate, DB connection pool usage, or AI token spend per minute.

5.5 No Input Sanitization on Frontend

The frontend has zero input validation libraries in package.json (react-hook-form is used for form wiring but no schema validation like Zod or Yup). Form inputs are sent as-is to the BFF, which forwards to the API. The API's Joi validation is the only defense. Client-side validation is a UX concern (immediate feedback) and a defense-in-depth measure. Add Zod schemas on the frontend matching the API's Joi schemas.


6. Prioritized Action Plan

P0 — Fix Immediately (Production Risk)

# Issue File Impact
1 Multiple new PrismaClient() instances admin/users.ts:8, nutrition.ts:14 Connection pool exhaustion, slug extension bypass
2 searchByIngredients full table scan aiRecipeGeneration.service.ts:223 OOM on large datasets
3 JSON-LD stored XSS components/seo/json-ld.tsx:3 Stored XSS via recipe title/description
4 Admin backup not a real backup (misleading) admin/database.ts Data loss confidence gap

P1 — High Priority (Performance/Security)

# Issue File Impact
5 N+1 queries in /recent and /search (users) routes/users.ts:377,471 2-48x DB load per request
6 N+1 queries in tags route routes/tags.ts:224 DB load on tag pages
7 UsageService 15 queries per summary call services/usage.service.ts DB load on every recipe create
8 Slug generation busy-wait loop lib/database.ts:82-92 Race condition under concurrent creates
9 unhandledRejection kills process with exit(0) server.ts:649 Swarm thinks container exited cleanly, may not restart
10 OAuth code stored in resetToken column routes/auth.ts:960 Clobbering reset tokens during OAuth flow

P2 — Medium Priority (Architecture Debt)

# Issue File Impact
11 JS files mixed in TS codebase routes/gdpr.js, etc. Type safety gaps on sensitive routes
12 "use client" whole pages with mount-fetch bookmarks/page.tsx, collections/page.tsx, etc. Worse TTFB, CWV, SEO
13 No cache headers on serverFetch lib/api/server-client.ts Every render hits API regardless of staleness
14 Recipe description/title missing max-length in profile update routes/users.ts:131 Unbounded DB storage
15 subscriptions.ts vs subscriptions-minimal.ts Routes dir Maintenance confusion
16 Admin uses raw SQL + Prisma dual access routes/admin/auth.ts Schema change risk

P3 — Low Priority / Housekeeping

# Issue File Impact
17 Dead authLimiter export middleware/rateLimiter.ts Code confusion
18 secrets.ts logs secret names to stdout utils/secrets.ts:53 Log pollution
19 /readyz creates new Redis connection each call server.ts:249 Connection churn
20 sameSite: "lax" on auth cookie bff/auth/login/route.ts:36 Could be strict
21 playwright in production dependencies package.json Image bloat, attack surface
22 No structured logging Global Ops visibility
23 Unsplash image bypasses next/image home-content.tsx:58 No optimization
24 Route naming convention inconsistency routes/*.ts Developer UX
25 Double /api/analytics registration server.ts, admin/index.ts Confusing routing

Summary Scorecard

Area Score Key Issue
Backend Architecture 6/10 N+1 queries, multiple PrismaClient instances, fat routes
Security 7/10 JSON-LD XSS, rate limit skip scope, OAuth/resetToken conflict
Frontend Architecture 7/10 Unnecessary "use client" pages, no client-side validation
Database 6/10 Missing indexes documentation, no transactions, fake backup
Observability 4/10 No structured logging, no metrics, no request IDs

Overall: Production-ready for a small user base with the P0/P1 issues resolved. The BFF auth pattern is well-implemented and is one of the best architectural decisions in the codebase. The main risks are the full-table scan in ingredient search, multiple Prisma clients, and the stored XSS in JSON-LD.