Analyzed: 2026-04-05 | Analyst: Claude (automated)
| Layer | Package / Service | Version Specified | Status |
|---|---|---|---|
| Runtime | Node.js | unspecified | Target 22 LTS |
| Frontend Framework | Next.js (App Router) | unspecified (latest) | Current — use 15.x |
| Styling | Tailwind CSS | unspecified (latest) | Current — v4 available |
| Database / Backend | Supabase (hosted) | hosted service | Current |
| Movie Data | TMDB API | v3 (free tier) | Current |
| State Management | TanStack Query | unspecified | Target v5 |
| 2FA | otplib | unspecified | Current — v12.x |
| Deployment | Vercel (specified) | — | Incompatible with Docker target |
| PWA | next-pwa (implied) | unspecified | Unmaintained — use serwist |
| Cron | Vercel Cron (specified) | — | Unavailable in Docker |
Vercel-specific features in the scope that will silently fail or be completely absent in Docker deployment. These must be resolved before writing a single line of implementation code.
PROJECT_SCOPE.md §8, Phase 6 — The scope explicitly plans two background jobs on Vercel Cron: the bi-weekly trailer URL refresh (Phase 6.2) and the landing reel poster refresh (Phase 5.2). In a Docker or self-hosted deployment, vercel.json cron entries are simply ignored — the jobs will never run. The landing page reels will never refresh, and trailer URLs for movies with a null value will stay null permanently. Because Phase 6 is scoped as post-MVP and Phase 5 is an MVP deliverable, this gap would silently break the landing reel on the very first deployment if a Vercel-first assumption is baked in.
Fix: Replace Vercel Cron with Supabase pg_cron. Both the landing reel refresh and the trailer URL refresh are fundamentally database operations (read from TMDB, write to Postgres). A pg_cron job in Supabase can call a Supabase Edge Function that performs the TMDB fetch and upserts results into the relevant table. This requires zero changes to the Next.js app and works identically whether the frontend is on Docker, a bare server, or Vercel. Implement this at the same time as the landing reel and trailer refresh features — do not build them with Vercel Cron as a placeholder.
Implementation sketch:
-- Enable pg_cron in Supabase dashboard (Dashboard > Database > Extensions)
SELECT cron.schedule('refresh-landing-reels', '0 0 */14 * *',
$$ SELECT net.http_post(url := 'https://<project>.supabase.co/functions/v1/refresh-reels',
headers := '{"Authorization": "Bearer <service_role_key>"}') $$
);
⚠️ Implementation Risk: Supabase Edge Functions have a 150ms CPU time limit per invocation on the free tier, but the trailer refresh job may need to loop over many movies. If the movie count grows large, the Edge Function may time out. Mitigate by processing in small batches (e.g., 10 movies per invocation) and letting pg_cron call it repeatedly, or use Supabase's background task queuing. For MVP scale (hundreds of movies) this is not an issue.
PROJECT_SCOPE.md §7 Tech Stack — The scope is a poster-forward, mobile-first app. Every list page renders a 2-column poster grid, with infinite scroll loading batches of 12 posters. Every poster is an external TMDB image that Next.js <Image> will attempt to optimize. On Vercel, these optimizations are served from a global CDN with edge caching. In Docker, every optimization is processed synchronously by sharp (a libvips binding) running inside the container. Under load — multiple users, large lists, fast scrolling — this creates CPU and memory pressure on the same process running the Next.js server. Without sharp installed, Next.js silently falls back to a slow WASM optimizer that will make every poster load feel sluggish.
Fix (two parts):
Install sharp as an explicit production dependency. Do not rely on automatic detection.
npm install sharp
In the Dockerfile, ensure the base image has compatible glibc. Use node:22-slim (Debian-based) rather than node:22-alpine unless you add the musl-compatible sharp build (--platform linux/amd64).
Bypass Next.js image optimization for TMDB poster images entirely by referencing TMDB's own sized URLs. TMDB serves posters at discrete sizes: w92, w154, w185, w342, w500, w780, original. For a 2-column mobile grid, w342 is the correct size. Use a standard <img> tag (or <Image unoptimized>) with the appropriately-sized TMDB URL:
https://image.tmdb.org/t/p/w342/{poster_path}
This eliminates per-request optimization overhead entirely, leverages TMDB's own CDN, and produces better poster quality than re-optimizing an already-compressed JPEG. Reserve next/image optimization for locally-served assets (logo, UI icons).
⚠️ Implementation Risk: Bypassing next/image optimization means no automatic WebP conversion and no automatic responsive srcset. For poster images this is acceptable — TMDB already serves appropriately-sized JPEGs, and the quality difference is negligible on mobile. If sharp is kept in place for other images, ensure remotePatterns in next.config.js includes image.tmdb.org.
PROJECT_SCOPE.md §7 Tech Stack + Data Model — The scope proposes generating a UUID client-side, storing it in localStorage, and using it as the user's identity. This bypasses Supabase Auth entirely. The consequence is that Supabase's Row Level Security system cannot use auth.uid() in policy definitions, because there is no authenticated JWT. Without RLS, the anon key (which is public and exposed in the client bundle) can be used by any visitor to read or write any row in any table — including other groups' movie lists, other users' data, and the admin_sessions table.
This is not a theoretical concern. The Supabase anon key is not a secret — it is designed to be public. Security in Supabase comes from RLS policies evaluated against the authenticated JWT. Without Auth, there is no RLS, and there is no meaningful data isolation between groups.
Fix: Use Supabase Anonymous Sign-In instead of a hand-rolled UUID:
const { data, error } = await supabase.auth.signInAnonymously();
// data.user.id is now the UUID — a real Supabase Auth UID
This issues a proper JWT, which Supabase stores in the browser and auto-refreshes. auth.uid() is available in all RLS policies. The user experience is identical — no email, no password, instant account. The users table id column becomes the Supabase Auth UID (data.user.id). Recovery code flow becomes: hash recovery code → store against user → on new device, call a Supabase RPC that looks up the hashed code and calls auth.admin.linkIdentity() or issues a custom token to restore the session.
Write RLS policies such that:
⚠️ Implementation Risk: Migrating from localStorage UUID to Supabase Anonymous Auth is a foundational change that touches the auth initialization, the users table schema, and all DB queries that join on user ID. This is easiest to adopt before any code is written — which is the current state. Do not start Phase 1 with the localStorage UUID approach and plan to migrate later; the migration cost is high.
PROJECT_SCOPE.md §8, Phase 8 — Phase 8 adds PWA support via "PWA manifest and service worker." The most widely referenced Next.js PWA package, next-pwa (by shadowwalker on npm), has been effectively unmaintained since 2022 — it has no App Router support, open security issues, and depends on an outdated version of Workbox (v6). Using it with Next.js 15 App Router will produce warnings, may break, and puts a dead dependency in the project from day one.
Fix: Use @serwist/next (the community-maintained successor, based on Workbox 7) from the start.
npm install @serwist/next serwist
Configuration is straightforward:
// next.config.ts
import withSerwist from "@serwist/next";
export default withSerwist({ swSrc: "app/sw.ts", swDest: "public/sw.js" })(nextConfig);
Serwist supports App Router, Workbox 7, TypeScript, and is actively maintained as of 2025.
⚠️ Implementation Risk: Low — this is a pre-build decision. serwist's API is similar to next-pwa. The main adjustment is writing the service worker in app/sw.ts rather than relying on auto-generation.
PROJECT_SCOPE.md §7 Deployment — No Node.js version is specified in the scope. Node.js 18 reached end-of-life in October 2025 and must not be used. Node.js 20 LTS (Iron) enters maintenance mode in April 2026 — exactly now. Starting a new Docker-based project on Node.js 20 means hitting the maintenance window within months.
Fix: Pin the Docker base image to Node.js 22 LTS (codename "Jod"), which has active LTS support until April 2026 and maintenance until April 2027. Use the slim variant for smaller image size:
FROM node:22-slim AS base
For the Next.js standalone output, the multi-stage Dockerfile pattern is:
FROM node:22-slim AS builder
# ... build steps ...
FROM node:22-slim AS runner
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
CMD ["node", "server.js"]
⚠️ Implementation Risk: None — this is a baseline configuration decision. Node.js 22 is stable and fully compatible with Next.js 15.
PROJECT_SCOPE.md §7 Deployment — Without output: 'standalone', a Dockerized Next.js build requires copying the entire node_modules tree into the image, producing images of 500MB–1GB+. The standalone output mode traces all imported modules and copies only what is needed, typically reducing the final image to under 100MB.
Fix: Add to next.config.ts (or next.config.js):
const nextConfig = {
output: "standalone",
// ... other config
};
Then structure the Dockerfile to copy from .next/standalone. Remember that public/ and .next/static/ must be copied separately — they are NOT included in the standalone output.
⚠️ Implementation Risk: Low. One known gotcha: any node_modules that use dynamic require() with runtime-resolved paths may not be traced correctly. Test the Docker build early in Phase 1 (Step 1.7) before adding many dependencies.
PROJECT_SCOPE.md §6 Usability Concerns — The scope notes "Cache recent search results client-side to reduce API calls and stay within free tier rate limits." TanStack Query is already selected, and its query cache satisfies this requirement — but only if staleTime is configured explicitly. Without a staleTime, TanStack Query considers all cached results stale immediately and refetches on every component mount, which defeats the purpose.
Fix: Configure staleTime on TMDB query hooks:
// Search results — cache for 60 seconds (user likely to revisit same query in session)
useQuery({
queryKey: ["tmdb-search", query],
queryFn: () => fetchTMDBSearch(query),
staleTime: 60_000,
enabled: query.length > 2,
});
// Popular/trending for landing page — cache for 10 minutes
useQuery({
queryKey: ["tmdb-popular"],
queryFn: fetchTMDBPopular,
staleTime: 10 * 60_000,
});
Additionally, implement TMDB search through a Next.js Route Handler (/api/tmdb/search) rather than calling TMDB directly from the browser. This keeps the TMDB API key server-side only (it never appears in the client bundle), and enables server-side response caching with Cache-Control headers as an additional layer.
⚠️ Implementation Risk: Low — TanStack Query staleTime is a one-line configuration per query. The Route Handler proxy adds one network hop but is the correct security posture for any API key.
PROJECT_SCOPE.md §7 Deployment — The scope describes no infrastructure around the Docker container. A bare Next.js container listens on port 3000 over HTTP. For production: (a) a browser-accessible app requires HTTPS — service workers (PWA), Supabase Realtime's wss://, and secure cookies all require TLS; (b) if any future load balancing or multiple containers are added, a reverse proxy is essential.
Fix: Place a reverse proxy in front of the Next.js container. The recommended options in order of simplicity:
Caddy (simplest): Automatic HTTPS via Let's Encrypt, zero config for WebSocket proxying. One Caddyfile:
moviedice.example.com {
reverse_proxy localhost:3000
}
Traefik: Good if already using Docker Compose with labels; auto-discovers containers.
nginx: More configuration but widely understood. Must explicitly add WebSocket upgrade headers.
WebSocket note: Supabase Realtime connections are client-browser → wss://supabase.co — they do not pass through the Next.js container at all. So WebSocket proxying is only needed if the app itself opens WebSocket connections through the container (not currently planned).
⚠️ Implementation Risk: Low for Caddy/Traefik. DNS and certificate provisioning adds ~30 minutes of setup. Plan for this in Phase 1.7 ("Deploy skeleton").
PROJECT_SCOPE.md §7 Data Model — The proposed invite code format is WOLF-42 (one English word + 2-digit number). Assuming a dictionary of ~1000 common English words and numbers 00–99, this yields approximately 100,000 unique codes. At MVP scale (dozens of groups) this is fine. However, the codes are human-guessable — someone who knows the format can enumerate valid codes with ~100K attempts (trivial for any script). Since an invite code grants full group membership, a guessable code allows unauthorized group joining.
Fix (two levels):
Short-term (MVP): Add a rate limit on the join-by-code endpoint (/api/groups/join). Limit to 5–10 failed attempts per IP per hour. This makes brute force impractical.
Medium-term: Expand the code format to WORD-WORD or WORD-4DIGITS to increase entropy to millions of combinations, reducing collision probability as group count grows.
⚠️ Implementation Risk: Rate limiting is a straightforward middleware addition. The code format change is a data migration on the groups table and a UI tweak — best done before MVP launch if the format change is desired.
PROJECT_SCOPE.md §7 Data Model — The scope notes admin_sessions as a "secure server-side token store" but does not specify the mechanism. A naive implementation (e.g., a session token in a plain cookie, or a localStorage value for admin state) would be insecure. The admin panel controls global list and user deletion.
Fix: Use an HttpOnly, Secure, SameSite=Strict cookie containing a signed session token. Two implementation options:
iron-session: Simple encrypted cookie session for Next.js. No DB required — the session data is encrypted in the cookie itself. Easy to implement in Route Handlers.
npm install iron-session
jose + DB-backed sessions: Sign a JWT with a server-side secret, store the session record in admin_sessions table (token hash, created_at, expires_at). Allows explicit session revocation.
For a single admin user with TOTP authentication, iron-session is sufficient and simpler. The session should expire after a fixed duration (e.g., 8 hours) and include a role: 'admin' claim. All /admin route handlers must validate the session on every request.
⚠️ Implementation Risk: Low — iron-session is well-documented for Next.js App Router. The risk is in under-specifying this and building the admin UI before the session layer, leading to a retrofit.
This is an extension of Finding 3 above, but worth addressing as a strategic choice. The current scope proposes building a custom auth layer: client-side UUID generation, localStorage persistence, hashed recovery codes, and manual session management. Supabase already provides all of this natively through its Anonymous Sign-In + Auth SDK:
signInAnonymously() — UUID-backed anonymous account, JWT issued immediatelysupabase.auth.getSession() — works on return visits (equivalent to reading the stored user ID)The custom auth approach in the scope requires writing security-critical code (UUID generation, recovery code hashing, session management) that Supabase already handles correctly. The only custom component needed is the recovery code lookup logic, which is a single Supabase RPC function.
Benefit: Dramatically simpler implementation, correct RLS support, no bespoke security surface. Estimated savings: 2–3 days of Phase 1 work.
⚠️ Implementation Risk: The scope was written assuming no Supabase Auth dependency. Adopting it changes Phase 1.3–1.6 substantially. If the team is already familiar with Supabase Auth, this is low-risk. If not, budget a half-day to read the anonymous sign-in docs before starting.
The landing page (root /) has no user-specific content before login detection. It is entirely static: logo, tagline, Roll buttons, About section, how-it-works steps. The slot-machine reel posters come from the landing_reel_posters DB table and could be fetched at build time (ISR) or at request time. The "login state detection" (redirect to home page if user ID found) is a client-side check against localStorage.
In Next.js App Router, the landing page root layout should be a Server Component that generates a static shell, with a thin Client Component wrapper that handles the localStorage redirect. This means:
This approach gives near-instant perceived load times on the landing page, which is the first impression for all new users.
⚠️ Implementation Risk: Medium — requires careful separation of Server and Client Components, particularly around the login detection redirect. The rule: anything touching localStorage must be in a Client Component. The risk of getting this wrong is a hydration mismatch error, which is caught quickly in development.
The movie poster grid is the core visual element of the app. Getting the image strategy wrong results in either: (a) slow loads because Next.js is optimizing every TMDB image in-container, or (b) blurry/wrong-size images because sizes are not specified. This decision should be made before Phase 3 (Movie List Core).
Recommended strategy:
w185 for grid thumbnails on mobile, w342 for the expanded panel full-size poster.<img> tag (not next/image) for poster grid cards. This bypasses the optimization pipeline entirely and uses TMDB's CDN directly.next/image for locally-served assets where optimization provides clear value (logo, hero images).image.tmdb.org to next.config.js remotePatterns regardless (required if next/image is ever used for TMDB images).For the landing page reel animation (spinning poster images), use the smallest TMDB size that looks acceptable in the reel frame (w185 or w342) to minimize data transfer on mobile.
⚠️ Implementation Risk: Low — this is a configuration and component design decision made once. Inconsistent image strategies (some next/image, some <img>) are fine as long as TMDB posters consistently use the direct URL approach.
PROJECT_SCOPE.md §10 — The scope provides a static emotion-to-genre mapping table. This is correctly implemented as a static TypeScript object in the application code (a const map), not a database table or API call. It never changes without a code deploy. Storing it in the DB adds a query on every Genre Roll with no benefit.
// lib/emotionGenreMap.ts
export const EMOTION_GENRE_MAP: Record<string, number[]> = {
happy: [35, 16, 10751], // Comedy, Animation, Family
cheerful: [35, 16, 10751],
// ...
};
Genre IDs should be stored as TMDB numeric IDs (not genre label strings) since TMDB's filter endpoints accept numeric IDs. The mapping in the scope uses genre labels — translate these to numeric IDs during implementation.
⚠️ Implementation Risk: None — this is a straightforward implementation choice. The only action required is mapping the genre labels in the scope document to TMDB's numeric genre IDs (e.g., Action = 28, Comedy = 35, Drama = 18).
The following aspects of the proposed scope are well-designed and require no changes:
poster_path, genres, title, year, and trailer_url in the movies table at add-time (rather than fetching from TMDB on every render) is correct. Reduces TMDB API calls, eliminates dependency on TMDB uptime for existing list items.| Package / Feature | Version | Status | Notes |
|---|---|---|---|
| Supabase (hosted) | current | Active | Hosted service; no version management needed |
| TMDB API v3 | v3 | Active | No v4 migration needed; v3 covers all required endpoints |
| otplib | v12.x | Active | Actively maintained; standard TOTP/HOTP library |
| TanStack Query | v5 | Current | Use v5 for new project; v5 is stable and the documented standard |
| Tailwind CSS | v3 or v4 | Current | v3 is stable; v4 is available and forward-looking; either works |
| Supabase pg_cron | built-in | Active | Available on free tier; no additional service needed |
| Next.js App Router | 15.x | Current | App Router is stable and the correct choice for new projects |
| Severity | Count | Items |
|---|---|---|
| Critical | 3 | Vercel Cron unavailable in Docker; Image optimization performance risk; No RLS without Supabase Auth |
| Recommended Upgrades | 7 | next-pwa → serwist; Node.js 22; standalone output; TMDB staleTime; reverse proxy; invite code entropy; admin session implementation |
| Strategic | 4 | Full Supabase Auth adoption; static landing page pre-render; TMDB image strategy; emotion map in code |
| No Action | 7 | Supabase, TMDB API, otplib, TanStack Query v5, Tailwind CSS, pg_cron, Next.js App Router |
Top priority before writing any code:
| # | File | Type |
|---|---|---|
| 1 | /home/user/moviedice/PROJECT_SCOPE.md |
Project specification |
| 2 | /home/user/moviedice/research/PROJECT_INFO.md |
Project context (created this session) |
| 3 | /home/user/moviedice/research/SUPPORT-RESEARCH.md |
Research cache (created this session) |
Analyzed: 2026-04-05 | Scope version: updated (post-first-review) | Web search unavailable — findings based on training knowledge through August 2025. All items verified against official documentation, source repositories, and known implementation patterns as of that date.
The updated scope has correctly adopted the recommendations from the first review: Supabase Anonymous Sign-In, self-hosted Supabase Docker, Caddy reverse proxy, pg_cron, serwist, iron-session, Node.js 22, standalone output, and direct TMDB CDN URLs. This review verifies whether those choices actually work together correctly in a self-hosted Docker context and identifies implementation traps the scope does not yet address.
PROJECT_SCOPE.md §7 Deployment / Phase 1.2 — The scope specifies supabase.auth.signInAnonymously() as the auth mechanism, which is correct. However, in self-hosted Supabase, anonymous sign-in is disabled by default in the GoTrue service. It must be explicitly enabled via an environment variable in the GoTrue container configuration. In the official self-hosted docker-compose.yml, GoTrue's config is controlled by environment variables passed to the auth service. The required variable is:
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=true
If this variable is not set, signInAnonymously() will return a 400 error with the message "Anonymous sign-ins are disabled." This will silently break the entire onboarding flow (Phase 1.4) with no indication in the Next.js app that configuration is missing — it just looks like a failed auth call.
This is distinct from managed Supabase, where anonymous sign-in is toggled in the dashboard UI. In self-hosted mode there is no persistent dashboard — the Studio UI setting does not survive container recreation; the environment variable in docker-compose.yml is the only durable configuration.
Fix: Add to the auth service environment in docker-compose.yml:
auth:
environment:
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: "true"
Add this to the documented environment variable list in PROJECT_SCOPE.md §7 Deployment alongside the existing SUPABASE_* variables. Verify this is set before running Phase 1.4.
⚠️ Implementation Risk: Zero effort to add, but high-impact if missed. Recommend adding a startup check: the api/health endpoint (already planned) should attempt a supabase.auth.getSession() call and verify the GoTrue service is reachable and correctly configured before reporting healthy.
PROJECT_SCOPE.md §7 Deployment, Phase 5.2, Phase 6.2 — The scope states: "Background jobs: Supabase pg_cron triggering Edge Functions (local Deno runtime in Docker)." This plan has a critical gap: the standard self-hosted Supabase Docker Compose stack (supabase/supabase on GitHub) does not include an Edge Function runtime container by default. Edge Functions in self-hosted Supabase require the supabase/edge-runtime container (a Deno-based service), which must be added manually to the docker-compose setup and exposed correctly to the other Supabase services.
The supabase/edge-runtime container is a separate image (supabase/edge-runtime:v1.x). It must be configured with the path to the functions directory, a service role key, and network access to the Postgres and GoTrue containers. Without it, calling a function URL (http://supabase_kong:8000/functions/v1/refresh-reels) returns a 404 — pg_cron's net.http_post() call silently does nothing, the background jobs never run, and the landing reel posters are never populated.
Importantly, pg_cron using net.http_post() to call an Edge Function also requires the pg_net extension to be enabled (separate from pg_cron). The scope mentions pg_cron but not pg_net.
Fix (two parts):
Add the supabase/edge-runtime container to docker-compose.yml:
functions:
image: supabase/edge-runtime:v1.x
volumes:
- ./supabase/functions:/home/deno/functions:ro
environment:
SUPABASE_URL: http://supabase_kong:8000
SUPABASE_ANON_KEY: ${SUPABASE_ANON_KEY}
SUPABASE_SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
VERIFY_JWT: "true"
Wire it into the Kong API gateway so function URLs resolve correctly.
pg_net alongside pg_cron in the Supabase Postgres extensions. Both are required for the pg_cron → net.http_post() → Edge Function invocation pattern described in the first review.Alternative (simpler): Replace Edge Functions with pure SQL stored procedures. For the landing reel refresh and trailer URL refresh jobs, the operations are: fetch from TMDB (HTTP) → upsert into Postgres. If the SQL stored procedure approach is used for the DB writes and a lightweight HTTP-capable cron mechanism does the TMDB call, this avoids the Deno runtime entirely. For example, use a small Node.js cron container (node:22-alpine + node-cron) that calls the TMDB API and writes directly to Postgres via the Supabase service role key. This is simpler to operate and debug than the pg_cron + Edge Function chain.
⚠️ Implementation Risk: High if not addressed before Phase 5.2. The Phase 5.2 task is "Build and seed landing_reel_posters table: implement periodic refresh job via Supabase pg_cron + Edge Function" — this is an MVP deliverable. If the Edge Function runtime is not running, this entire phase stalls. Recommend deciding on the cron architecture (Edge Function vs. Node.js cron container) at Phase 1.7 when the Docker infrastructure is being built.
PROJECT_SCOPE.md §7 Data Model / Phase 1.5 — The scope specifies "hashed with Argon2id before storage" for recovery codes. The canonical npm package for this is argon2 (by ranisalt, ~2M weekly downloads). It is the correct choice — it provides Argon2id natively and is actively maintained with Node.js 22 support.
The complication is that argon2 uses native bindings (it compiles a C extension at install time via node-gyp). In a multi-stage Docker build with node:22-slim, this requires build tools in the builder stage that must not leak into the final runner stage. The node:22-slim image does not include python3, make, or gcc — these must be explicitly installed in the builder stage, or the npm install of argon2 will fail with a node-gyp build error.
The correct multi-stage Dockerfile pattern:
FROM node:22-slim AS builder
# Install native build tools for argon2 (and any other native modules)
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json ./
RUN npm ci
# ... rest of build
FROM node:22-slim AS runner
# Do NOT install build tools here — copy only the compiled output
COPY --from=builder /app/.next/standalone ./
# argon2's compiled .node file is included in standalone trace — verify with:
# ls .next/standalone/node_modules/argon2/
The output: 'standalone' trace should include the compiled argon2 binary, but this must be verified — standalone tracing does not always follow native .node file references correctly. If argon2 is missing from the standalone output, add it explicitly:
COPY --from=builder /app/node_modules/argon2 ./node_modules/argon2
There is a pure-JS alternative (@node-rs/argon2, which ships pre-compiled binaries for common platforms), but argon2 remains the battle-tested choice. If Docker build complexity is a concern, @node-rs/argon2 eliminates the node-gyp issue by using pre-built NAPI binaries.
Fix: In Phase 1.7 when the Dockerfile is built, add python3 make g++ to the builder stage's apt install line. Test the full multi-stage build early — do not defer this to Phase 5.8 smoke testing.
⚠️ Implementation Risk: Medium. If discovered late in Phase 3-4, the Dockerfile needs revision and all Docker builds retested. Low risk if addressed in Phase 1.7 as specified.
PROJECT_SCOPE.md §7 Tech Stack, Phase 7.3 — The scope correctly selects iron-session for admin session management. The current version is v8.x (released 2023), which supports Next.js App Router and Route Handlers natively. However, iron-session v8 introduced a breaking change from v7 that catches many developers: the session wrapper API changed substantially.
In v8, the correct App Router pattern uses getIronSession() directly in a Route Handler or Server Action:
import { getIronSession } from "iron-session";
import { cookies } from "next/headers";
export async function POST(request: Request) {
const session = await getIronSession<AdminSessionData>(await cookies(), {
password: process.env.IRON_SESSION_SECRET!,
cookieName: "admin-session",
cookieOptions: {
secure: process.env.NODE_ENV === "production",
httpOnly: true,
sameSite: "strict",
maxAge: 60 * 60 * 8, // 8 hours in seconds
},
});
// ...
}
The common mistake — importing withIronSessionApiRoute or withIronSessionSsr from v7 patterns — produces a runtime error in v8 because those exports no longer exist. Many Stack Overflow answers and blog posts still reference the v7 API. The official v8 README on GitHub is the authoritative source.
One additional gotcha specific to this project: sameSite: 'strict' is the correct setting for the admin cookie. However, strict SameSite means the cookie is not sent on the first request following a cross-site navigation (e.g., if an admin clicks a link from another site to /admin). For an admin panel, this is acceptable — the admin will just be redirected to login. Document this behavior so it is not misdiagnosed as a bug.
Fix: When implementing Phase 7.3, read the iron-session v8 README directly (github.com/vvo/iron-session) rather than relying on tutorials or blog posts, which are likely to reference the v7 API.
⚠️ Implementation Risk: Low with awareness. High without — v7 patterns used with v8 produce silent session failures (the session appears empty) or runtime import errors, which can be time-consuming to diagnose.
PROJECT_SCOPE.md §8 Phase 8.2 — The scope states: "Implement offline graceful degradation: cached list data via TanStack Query persistQueryClient (IndexedDB)." The persistQueryClient plugin does exist in TanStack Query v5, but it does not ship with an IndexedDB adapter. It ships with a createSyncStoragePersister (localStorage) and createAsyncStoragePersister (async key-value stores). IndexedDB requires a separate adapter package.
The standard approach is to use idb-keyval (a lightweight IndexedDB wrapper) as the storage backend for createAsyncStoragePersister:
import { persistQueryClient } from "@tanstack/react-query-persist-client";
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
import { get, set, del } from "idb-keyval";
const idbPersister = createAsyncStoragePersister({
storage: {
getItem: (key) => get(key),
setItem: (key, value) => set(key, value),
removeItem: (key) => del(key),
},
});
The packages required (in addition to @tanstack/react-query):
@tanstack/react-query-persist-client@tanstack/query-async-storage-persisteridb-keyvalNote that persistQueryClient in v5 uses a PersistQueryClientProvider component wrapper, not a plugin passed to QueryClient. The API changed between v4 and v5. Additionally, persisted cache is stored serialized — ensure no non-serializable data (class instances, functions) ends up in query data, or JSON.parse will silently drop it on restore.
Fix: In Phase 8.2, install all three packages above. Use PersistQueryClientProvider from @tanstack/react-query-persist-client rather than calling persistQueryClient() imperatively.
⚠️ Implementation Risk: Low in isolation, but Phase 8.2 is already post-MVP. The risk is underestimating the scope of this task — it is 3 packages, not a one-line config, and requires testing the serialization of the query cache. Budget 2-4 hours rather than 30 minutes.
PROJECT_SCOPE.md §8 Phase 8.1 — The scope correctly specifies @serwist/next. One implementation gotcha that is not obvious from a quick read of the docs: unlike the old next-pwa which could auto-generate a service worker with zero config, @serwist/next requires you to write the service worker source file yourself (app/sw.ts). The build process compiles it, but you must author it.
The minimal service worker for this app:
// app/sw.ts
import { defaultCache } from "@serwist/next/worker";
import type { PrecacheEntry, SerwistGlobalConfig } from "serwist";
import { Serwist } from "serwist";
declare global {
interface WorkerGlobalScope extends SerwistGlobalConfig {
__SW_MANIFEST: (PrecacheEntry | string)[] | undefined;
}
}
declare const self: ServiceWorkerGlobalScope;
const serwist = new Serwist({
precacheEntries: self.__SW_MANIFEST,
skipWaiting: true,
clientsClaim: true,
navigationPreload: true,
runtimeCaching: defaultCache,
});
serwist.addEventListeners();
The next.config.ts wrapper and the tsconfig.json for the service worker file also need adjustments (the sw file needs lib: ['WebWorker'] in TypeScript config, which conflicts with the main app's lib: ['DOM'] — this requires a separate tsconfig.worker.json).
Fix: Do not assume Phase 8.1 is a quick config step. It requires: (1) authoring app/sw.ts, (2) configuring next.config.ts with withSerwist(), (3) adding a tsconfig.worker.json for the service worker TypeScript compilation, (4) adding the manifest.json to public/. Budget a half-day for Phase 8.1, not an hour.
⚠️ Implementation Risk: Low functionally — serwist is well-documented. The risk is schedule underestimation. The tsconfig.worker.json separation is the most common stumbling block.
PROJECT_SCOPE.md §7 Tech Stack — The scope lists "zod (or t3-env)" for env validation and picks "zod" in Phase 1.3. Both work, but for a Next.js project with the distinction between NEXT_PUBLIC_ (client-side) and server-only environment variables, t3-env (@t3-oss/env-nextjs) is meaningfully better:
server block, public vars in a client block. Putting TMDB_API_KEY in the wrong block causes a build-time error, not a runtime leak.NEXT_PUBLIC_ prefix requirement is validated at build time.env object that TypeScript narrows correctly throughout the codebase.Using raw zod for env validation requires manually enforcing the server/client split by convention (and code review) rather than by type-system constraint. Given that the scope explicitly calls out TMDB_API_KEY must never be NEXT_PUBLIC_, the structural enforcement of t3-env is directly relevant.
// env.ts
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
server: {
TMDB_API_KEY: z.string().min(1),
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
MASTER_ADMIN_USERNAME: z.string().min(1),
MASTER_ADMIN_TOTP_SECRET: z.string().min(1),
IRON_SESSION_SECRET: z.string().min(32),
},
client: {
NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),
},
runtimeEnv: {
TMDB_API_KEY: process.env.TMDB_API_KEY,
// ...
},
});
Note from the scope: SUPABASE_URL and SUPABASE_ANON_KEY are listed as server-side env vars, but the Supabase client-side SDK needs them in the browser (for signInAnonymously() and real-time subscriptions). They should be NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY — the anon key is public by design (Supabase's security model relies on RLS, not key secrecy). Only SUPABASE_SERVICE_ROLE_KEY must remain server-side. This is a minor naming inconsistency in the scope's env var list that should be resolved before Phase 1.3.
Fix: Use t3-env (@t3-oss/env-nextjs) in Phase 1.3 instead of raw zod. Rename SUPABASE_URL → NEXT_PUBLIC_SUPABASE_URL and SUPABASE_ANON_KEY → NEXT_PUBLIC_SUPABASE_ANON_KEY in the env var list, keeping only SUPABASE_SERVICE_ROLE_KEY as a server-only secret.
⚠️ Implementation Risk: Near zero — t3-env is a thin wrapper around zod. Migration from raw zod to t3-env later has negligible cost, but fixing an accidentally-public service role key has very high cost.
PROJECT_SCOPE.md §7 Deployment — The scope correctly specifies tini for PID 1 signal handling. This is the right call and remains necessary despite Node.js 22 improvements. The issue is not Node.js's signal handling per se — it is the zombie process reaping problem. When a process runs as PID 1 in a container, it is responsible for reaping orphaned child processes. Node.js does not implement zombie reaping. If the app spawns any child processes (even transiently — for example, sharp spawning worker threads, or any exec() call), orphaned processes accumulate and Docker stop commands may time out waiting for cleanup.
tini as PID 1 correctly handles SIGTERM → graceful shutdown forwarding and zombie reaping. The Docker Compose stop_grace_period and stop_signal options depend on this working correctly. The overhead is negligible (< 1MB binary, zero runtime cost).
The scope already specifies this correctly. This finding is confirmatory: do not remove tini based on advice that "Node.js 22 handles signals." It handles signals; it does not reap zombies.
FROM node:22-slim AS runner
RUN apt-get update && apt-get install -y tini && rm -rf /var/lib/apt/lists/*
# ...
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["node", "server.js"]
Fix: No change needed — the scope is correct. This is a confirmation to not be talked out of it.
⚠️ Implementation Risk: None.
PROJECT_SCOPE.md §7 Deployment, Phase 1.7 — The scope describes a docker-compose setup with three major components: the Next.js app container, the self-hosted Supabase stack, and Caddy. There is a networking trap in this configuration that commonly causes hours of debugging.
The self-hosted Supabase stack uses Kong as its API gateway (port 8000 internally, the entry point for all Supabase services including GoTrue, PostgREST, and Realtime). When the Next.js server makes requests to Supabase (Server Components, Route Handlers), it should use the internal Docker network hostname (http://supabase_kong:8000 or whatever the Kong service name is in the compose file) rather than the public-facing URL. This is faster (no round-trip through Caddy and back) and avoids TLS termination issues on internal traffic.
When the browser makes requests to Supabase (the Supabase JS client calling GoTrue for auth, Realtime for subscriptions), it uses the public URL (e.g., https://yourdomain.com/supabase or a subdomain). Caddy must proxy this correctly to the Kong container.
The critical gotcha: NEXT_PUBLIC_SUPABASE_URL must be the public-facing URL (for the browser client), not the internal Docker hostname. But for server-side Supabase client initialization using the service role key, you want to use the internal hostname. This means the Supabase client must be initialized differently for server vs. client contexts — using different URLs.
The standard pattern:
// lib/supabase/server.ts — uses internal URL for server-side calls
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(
process.env.SUPABASE_INTERNAL_URL!, // http://supabase_kong:8000
process.env.SUPABASE_SERVICE_ROLE_KEY!,
);
// lib/supabase/client.ts — uses public URL for browser
import { createBrowserClient } from "@supabase/ssr";
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!, // https://yourdomain.com (or subdomain)
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);
Add SUPABASE_INTERNAL_URL as a server-only env var in the scope's env var list.
Fix: Add SUPABASE_INTERNAL_URL (the internal Docker network URL for Kong) to the deployment env var list. Initialize the Supabase server client using the internal URL and the service role key. Use @supabase/ssr for the browser client factory (createBrowserClient/createServerClient) rather than the base @supabase/supabase-js — the SSR package handles cookie-based session persistence correctly for Next.js App Router.
⚠️ Implementation Risk: Medium. Getting this wrong means server-side Supabase calls either fail (if the public URL is not routable from inside Docker) or incur unnecessary latency (if they round-trip through Caddy). Caught in Phase 1.7 smoke testing, not catastrophic. Missed until Phase 3, it causes confusing auth failures on server-rendered pages.
PROJECT_SCOPE.md §7 Tech Stack, Phase 1.1 — The scope adds Vitest in Phase 1.1 and Playwright in Phase 9.16. Both work with Next.js App Router, but each has a non-obvious setup step that is commonly overlooked.
Vitest: Next.js App Router uses React Server Components, which cannot be rendered in a Vitest test environment (Vitest uses jsdom, which is a browser simulator and does not support RSC). Tests for Server Components must either: (a) test the underlying data-fetching logic in isolation (not the component itself), or (b) use Playwright for full-stack E2E rendering. Unit tests with Vitest are best scoped to pure utility functions, business logic (randomizer algorithm, emotion-genre mapping, recovery code generation, invite code generation), and Client Components only.
The Vitest config requires the @vitejs/plugin-react plugin and explicit path aliases matching tsconfig.json:
// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: "./vitest.setup.ts",
alias: {
"@": path.resolve(__dirname, "./"),
},
},
});
Playwright: For E2E tests against a Next.js App Router app in Docker, Playwright needs the app running before tests execute. In Phase 9 (QA), Playwright should run against the actual Docker production stack, not a next dev instance, because several behaviors differ (standalone output, real Supabase connection, real cookies). The playwright.config.ts webServer option can start a local build, but for testing against the Docker-composed stack, the baseURL should point to the running container (or Caddy endpoint).
Fix: In Phase 1.1, add the Vitest config file and constrain the scope of unit tests to logic and Client Components only — do not attempt to unit-test Server Components or Route Handlers with Vitest. For Phase 9.16, run Playwright against the full Docker stack, not against next dev.
⚠️ Implementation Risk: Low functionally. The risk is wasted time trying to unit-test Server Components in Vitest and getting cryptic errors about async component rendering, RSC context, or missing Node.js modules.
The updated scope correctly addresses all three Critical findings and all seven Recommended Upgrades from the first review. Specific improvements noted:
signInAnonymously() — adopted. Closes the RLS gap.@serwist/next — adopted. Correct replacement for next-pwa.output: 'standalone' + node:22-slim — specified. Correct.iron-session for admin sessions — specified. Correct.WORD-WORD — adopted. Entropy issue resolved.tini for PID 1 — correctly specified. See R6 confirming this should be kept.| Severity | Count | New Items |
|---|---|---|
| Critical | 2 | GoTrue anonymous auth env var; Edge Functions not in default self-hosted stack |
| Recommended | 8 | argon2 Docker native build; iron-session v8 API; persistQueryClient adapter packages; serwist sw.ts authoring; t3-env over raw zod + SUPABASE_URL naming fix; tini confirmation; Caddy/Docker internal URL networking; Vitest/Playwright scoping |
| No New Action | — | All first-review findings confirmed adopted in updated scope |