docs(01): create phase plan

This commit is contained in:
admin committed 2026-06-05 17:29:13 +02:00
1 parent be93b6c56d
commit d3fe4982ff
7 files changed
+1343 -11

No files matched your search

+6 -2
View File
@@ -33,7 +33,11 @@ Decimal phases appear between their surrounding integers in numeric order.
3. Owner can edit and delete a pet profile they own
4. Owner with multiple pets can switch the active pet without logging out; the active pet identity is reflected throughout the UI
5. Owner can upload a photo via presigned R2 URL — the image is stored in Cloudflare R2 and served via CDN, never through the API server
**Plans**: TBD
**Plans**:
- 01: Walking Skeleton — scaffold, Clerk proxy, Prisma schema, tRPC wiring, test scaffold
- 02: Auth screens — sign-up, log-in, forgot/reset password, verify-email, app shell
- 03: Pet CRUD — create/edit/delete pet, My Pets grid, profile view, breed seed, unit tests
- 04: Media pipeline — presigned R2 upload, AvatarUpload component, CDN delivery, unit tests
**UI hint**: yes
### Phase 2: Content Core
@@ -121,7 +125,7 @@ Phases execute in numeric order. Phase 5 depends only on Phase 2 (not Phase 4) s
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
| 1. Foundation | 0/TBD | Not started | - |
| 1. Foundation | 0/4 | Not started | - |
| 2. Content Core | 0/TBD | Not started | - |
| 3. Engagement and Discovery | 0/TBD | Not started | - |
| 4. Notifications | 0/TBD | Not started | - |
+16 -9
View File
@@ -1,10 +1,15 @@
---
gsd_state_version: '1.0'
status: planning
gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
status: executing
stopped_at: Phase 1 plans complete (01-01 through 01-04)
last_updated: "2026-06-05T15:29:08.194Z"
last_activity: 2026-06-05 — Phase 1 plans created (01-01 through 01-04); ready for execution
progress:
total_phases: 7
completed_phases: 0
total_plans: 0
total_plans: 4
completed_plans: 0
percent: 0
---
@@ -21,15 +26,16 @@ See: .planning/PROJECT.md (updated 2026-06-05)
## Current Position
Phase: 1 of 7 (Foundation)
Plan: 0 of TBD in current phase
Status: Ready to plan
Last activity: 2026-06-05 — Roadmap created; 43/43 v1 requirements mapped across 7 phases
Plan: 0 of 4 in current phase
Status: Ready to execute
Last activity: 2026-06-05 — Phase 1 plans created (01-01 through 01-04); ready for execution
Progress: [░░░░░░░░░░] 0%
## Performance Metrics
**Velocity:**
- Total plans completed: 0
- Average duration: -
- Total execution time: 0 hours
@@ -41,6 +47,7 @@ Progress: [░░░░░░░░░░] 0%
| - | - | - | - |
**Recent Trend:**
- Last 5 plans: -
- Trend: -
@@ -79,6 +86,6 @@ None yet.
## Session Continuity
Last session: 2026-06-05
Stopped at: Roadmap created — ready to run /gsd-plan-phase 1
Resume file: None
Last session: 2026-06-05T15:00:00.000Z
Stopped at: Phase 1 plans complete (01-01 through 01-04)
Resume file: .planning/phases/01-foundation/01-01-PLAN.md
@@ -0,0 +1,288 @@
---
phase: 01-foundation
plan: 01
type: execute
wave: 0
depends_on: []
files_modified:
- package.json
- next.config.ts
- postcss.config.mjs
- src/app/globals.css
- src/app/layout.tsx
- src/app/(app)/layout.tsx
- src/app/(app)/pets/page.tsx
- src/app/(app)/onboarding/pet/page.tsx
- proxy.ts
- .env.example
- .env.local
- prisma/schema.prisma
- prisma/seed.ts
- src/lib/prisma.ts
- src/lib/r2.ts
- src/lib/auth.ts
- src/trpc/init.ts
- src/trpc/query-client.ts
- src/trpc/server.ts
- src/trpc/client.tsx
- src/trpc/routers/_app.ts
- src/trpc/routers/pets.ts
- src/app/api/trpc/[trpc]/route.ts
- src/context/ActivePetContext.tsx
- src/hooks/useActivePet.ts
- vitest.config.ts
- src/__tests__/auth.test.ts
- src/__tests__/pet-profile.test.ts
- src/__tests__/media-upload.test.ts
- src/__tests__/helpers/prisma-mock.ts
autonomous: false
requirements: [AUTH-01, AUTH-04, PET-01]
user_setup:
- service: supabase
why: "PostgreSQL host for all relational data"
env_vars:
- name: DATABASE_URL
source: "Supabase Dashboard -> Project Settings -> Database -> Connection string (pooled, port 6543, ?pgbouncer=true)"
- name: DIRECT_URL
source: "Supabase Dashboard -> Project Settings -> Database -> Connection string (direct, port 5432)"
dashboard_config:
- task: "Create a Supabase project and select a region close to the developer"
location: "supabase.com/dashboard -> New project"
- service: clerk
why: "Owner authentication (sign-up, login, email verification, password reset)"
env_vars:
- name: NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
source: "Clerk Dashboard -> API Keys (keyless mode acceptable for initial local dev)"
- name: CLERK_SECRET_KEY
source: "Clerk Dashboard -> API Keys"
dashboard_config:
- task: "Create a Clerk application; enable Email + Password; enable email verification link"
location: "clerk.com/dashboard -> Create application"
- service: cloudflare-r2
why: "Object storage for pet avatar photos (presigned PUT uploads)"
env_vars:
- name: CF_ACCOUNT_ID
source: "Cloudflare Dashboard -> R2 -> Account ID"
- name: R2_ACCESS_KEY_ID
source: "Cloudflare Dashboard -> R2 -> Manage R2 API Tokens"
- name: R2_SECRET_ACCESS_KEY
source: "Cloudflare Dashboard -> R2 -> Manage R2 API Tokens"
- name: R2_BUCKET_NAME
source: "Cloudflare Dashboard -> R2 -> bucket name you create"
- name: NEXT_PUBLIC_R2_PUBLIC_URL
source: "Cloudflare R2 -> bucket -> Public Development URL or custom CDN domain"
dashboard_config:
- task: "Create an R2 bucket and configure CORS to allow PUT and Content-Type header from http://localhost:3000 (and production origin)"
location: "Cloudflare Dashboard -> R2 -> bucket -> Settings -> CORS Policy"
must_haves:
truths:
- "A signed-in owner can submit a pet-create form and the pet persists to Supabase PostgreSQL"
- "The newly created pet's name is read back from the DB and displayed in the app navigation"
- "Unauthenticated visits to /pets redirect to the Clerk sign-in flow"
- "npm run dev serves the full stack and npx vitest run executes the test scaffold"
artifacts:
- path: "proxy.ts"
provides: "Clerk auth enforcement on all non-public routes (Next.js 16 proxy convention)"
contains: "clerkMiddleware"
- path: "src/lib/prisma.ts"
provides: "PrismaClient singleton with Accelerate extension"
contains: "withAccelerate"
- path: "prisma/schema.prisma"
provides: "Owner, Species, Breed, Pet models"
contains: "model Pet"
- path: "src/trpc/routers/pets.ts"
provides: "pets.create, pets.list, pets.byId procedures with ownership assertion"
exports: ["petsRouter"]
- path: "src/app/api/trpc/[trpc]/route.ts"
provides: "tRPC fetch handler"
exports: ["GET", "POST"]
- path: "vitest.config.ts"
provides: "Vitest config with jsdom environment and @ path alias"
- path: "src/__tests__/pet-profile.test.ts"
provides: "Test scaffold (Wave 0) for PET-01..05 + ownership"
key_links:
- from: "src/app/(app)/onboarding/pet/page.tsx"
to: "pets.create"
via: "tRPC mutation via useTRPC hook"
pattern: "pets\\.create"
- from: "src/trpc/routers/pets.ts"
to: "prisma.pet"
via: "Prisma query"
pattern: "prisma\\.pet\\.(create|findMany|findUnique)"
- from: "proxy.ts"
to: "Clerk"
via: "clerkMiddleware + createRouteMatcher"
pattern: "createRouteMatcher"
---
<objective>
Establish the OnlyPets Walking Skeleton: the thinnest possible end-to-end vertical slice that exercises auth, routing, a real Supabase DB read and write, an interactive UI element, and the local full-stack run. After this plan, a signed-in owner can create a pet (name + species) via a form, the pet persists in PostgreSQL, and its name appears in the navigation. This plan also lands the Wave 0 test scaffold so all downstream tasks have automated verification targets.
Purpose: Every subsequent phase depends on owner auth, the pet-as-actor identity model, the media pipeline foundation, and the tRPC/Prisma wiring. None can be retrofitted cheaply. This is the architectural spine.
Output: Scaffolded Next.js 16 app; Clerk proxy.ts; Prisma schema (Owner/Species/Breed/Pet) pushed to Supabase; tRPC wiring with pets.create/list/byId; minimal onboarding + /pets pages; ActivePetContext; Vitest config + failing test scaffolds.
</objective>
<execution_context>
@T:/CC-Projekte/.claude/gsd-core/workflows/execute-plan.md
@T:/CC-Projekte/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/01-foundation/SKELETON.md
@.planning/phases/01-foundation/01-RESEARCH.md
@.planning/phases/01-foundation/01-UI-SPEC.md
@.planning/phases/01-foundation/01-VALIDATION.md
</context>
<artifacts_this_phase_produces>
This plan creates the following symbols consumed by Plans 02–04 and all later phases:
- `proxy.ts` default export (Clerk middleware) — auth gate
- `prisma` singleton (`src/lib/prisma.ts`) — DB access for all routers
- `getPresignedUploadUrl(key, contentType)` (`src/lib/r2.ts`) — used by Plan 04 media router
- `getOwnerId()` / Clerk `auth()` wrapper (`src/lib/auth.ts`)
- `createTRPCContext`, `router`, `publicProcedure`, `protectedProcedure` (`src/trpc/init.ts`)
- `appRouter` + `AppRouter` type (`src/trpc/routers/_app.ts`)
- `petsRouter` with `create`, `list`, `byId` (`src/trpc/routers/pets.ts`) — extended by Plan 03 (update/delete) and Plan 04 (setAvatar)
- `useTRPC` hook + `TRPCReactProvider` (`src/trpc/client.tsx`)
- `ActivePetProvider`, `useActivePet` (`src/context/ActivePetContext.tsx`, `src/hooks/useActivePet.ts`) — wired fully in Plan 04
- Prisma models: `Owner`, `Species`, `Breed`, `Pet`
- Test scaffolds: `src/__tests__/{auth,pet-profile,media-upload}.test.ts`, `src/__tests__/helpers/prisma-mock.ts`
</artifacts_this_phase_produces>
<tasks>
<task type="auto">
<name>Task 1: Scaffold project, install stack, configure Tailwind v4 + shadcn + Vitest test scaffold</name>
<files>package.json, next.config.ts, postcss.config.mjs, src/app/globals.css, src/app/layout.tsx, vitest.config.ts, src/__tests__/auth.test.ts, src/__tests__/pet-profile.test.ts, src/__tests__/media-upload.test.ts, src/__tests__/helpers/prisma-mock.ts, .env.example</files>
<read_first>
- .planning/phases/01-foundation/01-RESEARCH.md (Standard Stack table; Installation block; Pitfall 2 Tailwind v4; Pattern 5 Clerk orange; Wave 0 Gaps; Validation Architecture)
- .planning/phases/01-foundation/01-UI-SPEC.md (Design System; shadcn init command; Components Required; Color CSS variables)
- .planning/phases/01-foundation/01-VALIDATION.md (Wave 0 Requirements; Per-Task Verification Map)
</read_first>
<action>
Scaffold with `npx create-next-app@latest onlypets --typescript --eslint --app --turbopack --src-dir --tailwind` into the current repo (the project root is the existing OnlyPets directory — scaffold in place; do not nest a subfolder). Pin exact versions per RESEARCH.md Standard Stack: next 16.2.7, react/react-dom 19.2.7, typescript 6.0.3.
Install runtime deps: @clerk/nextjs@7.4.3, @trpc/server@11.17.0, @trpc/client@11.17.0, @trpc/tanstack-react-query@11.17.0, @tanstack/react-query@5.101.0, zod@4.4.3, prisma@7.8.0, @prisma/client@7.8.0, @prisma/extension-accelerate@3.0.1, @supabase/supabase-js@2.107.0, @aws-sdk/client-s3@3.1062.0, @aws-sdk/s3-request-presigner@3.1062.0, sonner@2.0.7, lucide-react@1.17.0, @upstash/redis@1.38.0, server-only, client-only.
Install dev deps: vitest, @vitest/coverage-v8, @vitejs/plugin-react.
Use @trpc/tanstack-react-query (NOT @trpc/react-query). Verify postcss.config.mjs uses `@tailwindcss/postcss` (NOT `tailwindcss`) and globals.css uses `@import "tailwindcss";` (NOT the three v3 `@tailwind` directives). If create-next-app emitted a `tailwind.config.ts`, delete it — Tailwind v4 has no JS config file.
In src/app/globals.css under `:root`, set shadcn CSS variables to the orange-500 design system per UI-SPEC: `--primary: 24.6 95% 53.1%;`, `--primary-foreground: 0 0% 100%;`, `--background: 0 0% 98%;`, `--muted: 240 4.8% 95.9%;`. Load Inter via `next/font/google` in src/app/layout.tsx and apply as `font-sans` on `<html>`.
Run `npx shadcn@latest init` with preset default / base color neutral / CSS variables yes, then `npx shadcn@latest add button input form label textarea select avatar card dialog dropdown-menu sonner progress separator badge skeleton`.
Create vitest.config.ts with jsdom environment, @vitejs/plugin-react, and a `@` -> `./src` path alias matching tsconfig.
Create the Wave 0 test scaffolds with FAILING placeholder tests (use `it.todo` or `expect(...).toBe` against not-yet-implemented imports guarded so the file runs):
- src/__tests__/auth.test.ts — describe blocks for AUTH-01 (owner row created with Clerk id, no password in app DB) and AUTH-02 (email verification status readable). Mark as `it.todo` referencing the procedures Plan 02 will implement.
- src/__tests__/pet-profile.test.ts — describe blocks for PET-01 (create with valid input), PET-02 (update rejected for non-owner -> FORBIDDEN), PET-03 (delete by owner), PET-05 (adoption story persists). Mark `it.todo`.
- src/__tests__/media-upload.test.ts — describe block for presigned URL generation with correct ContentType. Mark `it.todo`.
- src/__tests__/helpers/prisma-mock.ts — export a typed mock PrismaClient (vi.fn-based) so unit tests never hit a real DB.
Create .env.example listing every env var from user_setup with placeholder values (DATABASE_URL, DIRECT_URL, NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, CLERK_SECRET_KEY, CF_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET_NAME, NEXT_PUBLIC_R2_PUBLIC_URL).
Do NOT install @trpc/react-query. Do NOT create middleware.ts (Next.js 16 uses proxy.ts — created in Task 2).
</action>
<verify>
<automated>npx tsc --noEmit && npx vitest run --reporter=dot</automated>
</verify>
<acceptance_criteria>
- `npx tsc --noEmit` exits 0
- `npx vitest run` runs (todos reported, no crash) and exits 0
- `grep -v '^#' postcss.config.mjs | grep -c '@tailwindcss/postcss'` returns >= 1
- No `tailwind.config.ts` file exists at repo root
- package.json lists @trpc/tanstack-react-query and does NOT list @trpc/react-query
- src/__tests__/pet-profile.test.ts and src/__tests__/media-upload.test.ts exist
</acceptance_criteria>
<done>Project scaffolds, type-checks, Tailwind v4 + shadcn configured with orange primary, all stack deps installed at pinned versions, Vitest runs the failing/todo test scaffold green.</done>
</task>
<task type="auto">
<name>Task 2: Wire Clerk proxy.ts, Prisma schema + singleton, tRPC stack, R2 client, ActivePetContext</name>
<files>proxy.ts, src/app/layout.tsx, src/lib/prisma.ts, src/lib/r2.ts, src/lib/auth.ts, prisma/schema.prisma, prisma/seed.ts, src/trpc/init.ts, src/trpc/query-client.ts, src/trpc/server.ts, src/trpc/client.tsx, src/trpc/routers/_app.ts, src/trpc/routers/pets.ts, src/app/api/trpc/[trpc]/route.ts, src/context/ActivePetContext.tsx, src/hooks/useActivePet.ts</files>
<read_first>
- .planning/phases/01-foundation/01-RESEARCH.md (Pattern 1 Two-Layer Identity; Pattern 2 Presigned R2; Pattern 3 proxy.ts; Pattern 4 Prisma Singleton; Pattern 6 Active Pet Context; Prisma Schema code example; tRPC Route Handler code example; Security Domain)
- src/app/globals.css (verify Inter + orange vars from Task 1)
</read_first>
<action>
Create proxy.ts at REPO ROOT (not src/) per RESEARCH Pattern 3: `clerkMiddleware` + `createRouteMatcher` with public routes `/sign-up(.*)`, `/log-in(.*)`, `/forgot-password(.*)`, `/reset-password(.*)`, `/verify-email(.*)`; call `await auth.protect()` for non-public routes; export the documented `config.matcher` array including `/(api|trpc)(.*)` and `/__clerk/(.*)`.
Wrap src/app/layout.tsx children in `<ClerkProvider appearance={{ variables: { colorPrimary: '#F97316', colorBackground: '#FAFAFA', colorText: '#09090B' } }}>` and mount `<TRPCReactProvider>`, `<ActivePetProvider>`, and Sonner `<Toaster />`.
Create prisma/schema.prisma exactly per RESEARCH Prisma Schema example: datasource with `url=env("DATABASE_URL")` and `directUrl=env("DIRECT_URL")`; models Owner (id = Clerk userId String @id), Species (id, name @unique), Breed (id, name, speciesId FK, @@unique([speciesId, name])), Pet (id cuid, ownerId FK, speciesId FK, breedId String? FK, name, bio String?, adoptionStory String?, avatarKey String?, createdAt, updatedAt, @@index([ownerId])).
Create src/lib/prisma.ts as the global singleton with `withAccelerate()` per RESEARCH Pattern 4.
Create src/lib/r2.ts exporting `r2` S3Client (region 'auto', endpoint `https://${CF_ACCOUNT_ID}.r2.cloudflarestorage.com`) and `getPresignedUploadUrl(key, contentType)` with `expiresIn: 300` per RESEARCH Pattern 2. Mark file `import 'server-only'`.
Create src/lib/auth.ts exporting an async `getOwnerId()` that calls Clerk `auth()` and returns userId (or null).
Create src/trpc/init.ts: `createTRPCContext` reading `{ userId } = await auth()` and `prisma`; `protectedProcedure` throws `TRPCError UNAUTHORIZED` when no userId. Per RESEARCH Pattern 1.
Create src/trpc/routers/pets.ts `petsRouter` with: `create` (protected; Zod input name<=30, speciesId, breedId optional, bio<=150 optional, adoptionStory<=500 optional; upsert Owner row from ctx.userId then create Pet with ownerId=ctx.userId); `list` (protected; findMany where ownerId=ctx.userId); `byId` (protected; findUnique then assert pet.ownerId===ctx.userId else FORBIDDEN). Implement the ownership-assertion helper inline so Plan 03 reuses the pattern.
Create src/trpc/routers/_app.ts exporting `appRouter` ({ pets: petsRouter }) and `export type AppRouter = typeof appRouter`.
Create src/trpc/query-client.ts (makeQueryClient factory), src/trpc/server.ts (createCallerFactory for RSC), src/trpc/client.tsx (`TRPCReactProvider`, `useTRPC` via @trpc/tanstack-react-query), and src/app/api/trpc/[trpc]/route.ts per RESEARCH tRPC Route Handler example (`fetchRequestHandler`, export GET and POST).
Create src/context/ActivePetContext.tsx (`ActivePetProvider`, `useActivePet`) per RESEARCH Pattern 6 (localStorage-backed); src/hooks/useActivePet.ts re-exports the hook.
Create prisma/seed.ts that seeds Species (Dog, Cat, Bird) idempotently (Plan 03 extends it with breeds). Register `prisma.seed` in package.json.
SECURITY (T-1-02): never trust client-supplied pet ids — every pets mutation/scoped query asserts `pet.ownerId === ctx.userId`. SECURITY (T-1-03): R2 credentials live only in server-only modules; presigned URL generation only in tRPC procedures.
</action>
<verify>
<automated>npx tsc --noEmit && npx prisma validate && npx vitest run --reporter=dot</automated>
</verify>
<acceptance_criteria>
- `npx prisma validate` exits 0
- `npx tsc --noEmit` exits 0
- `grep -c 'clerkMiddleware' proxy.ts` returns >= 1 and proxy.ts is at repo root
- `grep -c 'withAccelerate' src/lib/prisma.ts` returns >= 1
- `grep -Ec 'pet\.ownerId === ctx\.userId|ownerId !== ctx\.userId' src/trpc/routers/pets.ts` returns >= 1 (ownership assertion present)
- src/trpc/routers/_app.ts exports `AppRouter` type
- No `middleware.ts` exists at repo root
</acceptance_criteria>
<done>Clerk proxy gates routes; Prisma schema validates with Owner/Species/Breed/Pet; tRPC wired end-to-end with ownership-asserted pets router; R2 helper and ActivePetContext exist; species seed ready.</done>
</task>
<task type="checkpoint:human-action" gate="blocking-human">
<what-built>Prisma schema (Owner, Species, Breed, Pet) and tRPC pets router are complete. The schema must now be pushed to the live Supabase PostgreSQL database before any verification can read or write pets. This is the mandatory [BLOCKING] schema push.</what-built>
<how-to-verify>
1. Confirm `.env.local` contains real `DATABASE_URL` (pooled, port 6543, `?pgbouncer=true`) and `DIRECT_URL` (direct, port 5432) from your Supabase project. If you have not created the Supabase project yet, create it now (supabase.com/dashboard -> New project) and copy both connection strings.
2. Run: `npx prisma db push` (creates tables against Supabase). If running non-interactively/CI and a destructive prompt blocks, use `npx prisma db push --accept-data-loss`.
3. Run: `npx prisma db seed` (seeds Dog/Cat/Bird species).
4. Run: `npx prisma studio` and confirm the `Species` table has 3 rows and `Owner`/`Pet`/`Breed` tables exist and are empty.
5. Confirm `npx prisma generate` ran (Prisma client types available).
</how-to-verify>
<resume-signal>Type "pushed" once `prisma db push` succeeds and Species table shows 3 rows, or describe the error.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → proxy.ts | Unauthenticated requests cross here; Clerk middleware must gate all non-public routes |
| Browser → tRPC API | Client-supplied input (pet fields, pet ids) is untrusted; Zod validation + ownership assertion required |
| API server → Supabase | Prisma queries scoped by owner_id; no raw client-supplied id trusted |
| Server env → client bundle | R2 + Clerk secret credentials must never reach the client bundle |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-1-01 | Spoofing | Auth / session | mitigate | Clerk-managed httpOnly JWT sessions; no hand-rolled auth; proxy.ts protects all non-public routes via `auth.protect()` |
| T-1-02 | Tampering / EoP | tRPC pets procedures | mitigate | `protectedProcedure` requires userId; every pet query/mutation asserts `pet.ownerId === ctx.userId`, throws FORBIDDEN otherwise (ASVS V4) |
| T-1-03 | Information Disclosure | R2 credentials | mitigate | R2 creds in server-only env vars; `src/lib/r2.ts` marked `server-only`; presigned URL generation only in tRPC procedures |
| T-1-04 | Tampering | Zod input validation | mitigate | All tRPC inputs validated with Zod (name<=30, bio<=150, adoptionStory<=500) per ASVS V5 |
| T-1-05 | Elevation of Privilege | Route access | mitigate | `createRouteMatcher` public allowlist; all other routes require auth via proxy.ts |
| T-1-SC | Tampering | npm installs | mitigate | All packages [VERIFIED: npm registry] in RESEARCH Package Legitimacy Audit; no [ASSUMED]/[SUS]/[SLOP] packages — no blocking checkpoint required |
</threat_model>
<verification>
- `npx tsc --noEmit` clean
- `npx prisma validate` passes; `npx prisma db push` applied to Supabase (Task 3 checkpoint)
- `npx vitest run` green (scaffold)
- `npm run dev` starts; visiting `/pets` while logged out redirects to Clerk sign-in
- Species table seeded with Dog/Cat/Bird
</verification>
<success_criteria>
- Walking skeleton runs locally: signed-in owner can create a pet and see it in nav (full flow completed once Plan 02 auth pages + Plan 03 form land; skeleton wiring is in place here)
- Clerk auth gate enforced via proxy.ts
- Prisma schema pushed to Supabase; species seeded
- tRPC pets router (create/list/byId) with ownership assertion operational
- Vitest scaffold green; downstream tasks have test targets
</success_criteria>
<output>
Create `.planning/phases/01-foundation/01-01-SUMMARY.md` when done
</output>
@@ -0,0 +1,222 @@
---
phase: 01-foundation
plan: 02
type: execute
wave: 1
depends_on: [01-01]
files_modified:
- src/app/(auth)/sign-up/[[...sign-up]]/page.tsx
- src/app/(auth)/log-in/[[...sign-in]]/page.tsx
- src/app/(auth)/forgot-password/page.tsx
- src/app/(auth)/reset-password/page.tsx
- src/app/(auth)/verify-email/page.tsx
- src/app/(auth)/layout.tsx
- src/app/(app)/layout.tsx
- src/app/(app)/onboarding/pet/page.tsx
- src/components/layout/Sidebar.tsx
- src/components/layout/MobileNav.tsx
- src/middleware.ts
autonomous: false
requirements: [AUTH-01, AUTH-02, AUTH-03, AUTH-04]
---
<objective>
Wire all Clerk-backed authentication screens and the protected app shell that houses every post-auth route in Phase 1. After this plan, an owner can sign up with email and password, verify their email, log in, and reset a forgotten password — all through Clerk-rendered UI customized to match the OnlyPets orange-500 design system. A signed-in owner who visits `/pets` sees the app shell (sidebar on desktop, bottom nav on mobile) and is redirected to `/onboarding/pet` when they have no pets yet. An unsigned-in owner who visits any protected route is redirected to `/log-in`.
Purpose: AUTH-01 through AUTH-04 are the prerequisite gate that every downstream plan depends on. No content, no pet profile, no media pipeline can be used without a working auth flow. The app shell establishes the navigation context that Plans 03 and 04 populate with real content.
Output: Five auth pages (sign-up, log-in, forgot-password, reset-password, verify-email) in the `(auth)` route group; a centered 448px auth layout; a protected `(app)` layout with desktop sidebar and mobile bottom nav; onboarding redirect logic; Inter font applied; Clerk appearance overridden to orange-500.
</objective>
<execution_context>
@T:/CC-Projekte/.claude/gsd-core/workflows/execute-plan.md
@T:/CC-Projekte/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/phases/01-foundation/SKELETON.md
@.planning/phases/01-foundation/01-RESEARCH.md
@.planning/phases/01-foundation/01-UI-SPEC.md
@.planning/phases/01-foundation/01-VALIDATION.md
</context>
<artifacts_this_phase_produces>
- `src/app/(auth)/layout.tsx` — centered 448px card layout for all auth pages
- `src/app/(auth)/sign-up/[[...sign-up]]/page.tsx` — Clerk `<SignUp>` with orange appearance
- `src/app/(auth)/log-in/[[...sign-in]]/page.tsx` — Clerk `<SignIn>` with orange appearance
- `src/app/(auth)/forgot-password/page.tsx` — Clerk forgot-password flow
- `src/app/(auth)/reset-password/page.tsx` — Clerk reset-password flow
- `src/app/(auth)/verify-email/page.tsx` — email verification pending screen
- `src/app/(app)/layout.tsx` — protected layout with sidebar + mobile nav; redirects no-pet owners to onboarding
- `src/components/layout/Sidebar.tsx` — 240px desktop sidebar consumed by all app routes
- `src/components/layout/MobileNav.tsx` — bottom tab bar for mobile (< 768px)
- `src/app/(app)/onboarding/pet/page.tsx` — landing page stub (form wired in Plan 03)
</artifacts_this_phase_produces>
<tasks>
<task type="auto">
<name>Task 1: Auth layout and Clerk-rendered auth pages (sign-up, log-in, forgot-password, reset-password, verify-email)</name>
<files>src/app/(auth)/layout.tsx, src/app/(auth)/sign-up/[[...sign-up]]/page.tsx, src/app/(auth)/log-in/[[...sign-in]]/page.tsx, src/app/(auth)/forgot-password/page.tsx, src/app/(auth)/reset-password/page.tsx, src/app/(auth)/verify-email/page.tsx</files>
<read_first>
- .planning/phases/01-foundation/01-UI-SPEC.md (Auth Screens table; Auth Copy table; Spacing Scale; Color palette; Typography; Component Specifications — Primary CTA; Owner Identity Subordination)
- .planning/phases/01-foundation/01-RESEARCH.md (Pattern 3 proxy.ts — public route list; Pattern 5 Clerk Appearance; Standard Stack @clerk/nextjs 7.4.3)
</read_first>
<action>
Create `src/app/(auth)/layout.tsx`: a server component that renders a centered column with max-width 448px and horizontal padding md (16px) — `mx-auto max-w-[448px] px-4`. Apply `min-h-screen flex items-center justify-center bg-background` so auth cards are vertically centered. Render `{children}`. No sidebar, no nav — auth pages are standalone.
Create `src/app/(auth)/sign-up/[[...sign-up]]/page.tsx` using Clerk's `<SignUp>` component from `@clerk/nextjs`. Pass `appearance` with `variables: { colorPrimary: '#F97316', colorBackground: '#FAFAFA', colorText: '#09090B' }` and `elements: { formButtonPrimary: 'bg-orange-500 hover:bg-orange-600 text-white text-sm' }` to match the design system. Set Clerk routing props: `path="/sign-up"`, `signInUrl="/log-in"`, `forceRedirectUrl="/onboarding/pet"`. Page heading (rendered by Clerk): "Create your account" — controlled via Clerk appearance `layout.logoPlacement: 'none'` and sign-up copy customization. Page is a server component (Clerk `<SignUp>` is a server-renderable component in Clerk 7).
Create `src/app/(auth)/log-in/[[...sign-in]]/page.tsx` using Clerk's `<SignIn>` component. Same appearance overrides. Set `path="/log-in"`, `signUpUrl="/sign-up"`, `forceRedirectUrl="/pets"`. Clerk renders the "Welcome back" heading and "Log in" CTA automatically — no custom copy needed beyond what Clerk exposes.
Create `src/app/(auth)/forgot-password/page.tsx`: Render a centered card (shadcn `<Card>`) with:
- Page heading: "Reset your password" (text-xl font-semibold)
- Single `<Input>` for email with `<Label>` "Email address"
- Primary CTA `<Button>` "Send reset link" (bg-orange-500)
- This page initiates Clerk's built-in reset flow via `useSignIn().sendPasswordResetEmail()`. Mark as `'use client'`. On submit, call `signIn.create({ strategy: 'reset_password_email_code', identifier: email })` and redirect to `/reset-password`. Show inline error on failure. Confirmation message: "Check your email — we sent a reset link to {email}."
Create `src/app/(auth)/reset-password/page.tsx`: Render a centered card with:
- Page heading: "Set a new password" (text-xl font-semibold)
- `<Input type="password">` "New password" and "Confirm password" fields with `<Label>`
- Primary CTA `<Button>` "Reset password"
- Mark as `'use client'`. Use `useSignIn().attemptFirstFactor({ strategy: 'reset_password_email_code', code, password })` where `code` comes from the URL search params Clerk appends. On success, redirect to `/pets`.
- Show error states per UI-SPEC Error States: "Password must be at least 8 characters.", "Passwords don't match."
Create `src/app/(auth)/verify-email/page.tsx`: Static server component card with:
- Page heading: "Verify your email" (text-xl font-semibold)
- Body text: "We sent a verification link to {email}. Check your inbox to continue." — if email is not available server-side, omit the email interpolation and show generic message.
- "Resend email" link (ghost button) — client component island that calls Clerk's `prepareEmailAddressVerification`.
- Body/caption text only; no primary CTA needed.
SECURITY: All auth pages are on the public route list in `proxy.ts` — confirm routes `/sign-up(.*)`, `/log-in(.*)`, `/forgot-password(.*)`, `/reset-password(.*)`, `/verify-email(.*)` are in the `createRouteMatcher` array.
</action>
<verify>
<automated>npx tsc --noEmit && npx vitest run --reporter=dot</automated>
</verify>
<acceptance_criteria>
- `npx tsc --noEmit` exits 0
- Each auth page file exists under its correct route segment path
- `grep -c "colorPrimary.*F97316" src/app/(auth)/sign-up/[[...sign-up]]/page.tsx` returns >= 1
- `grep -c "SignUp" src/app/(auth)/sign-up/[[...sign-up]]/page.tsx` returns >= 1
- `grep -c "SignIn" src/app/(auth)/log-in/[[...sign-in]]/page.tsx` returns >= 1
- Auth layout file exists with max-w-[448px]
</acceptance_criteria>
<done>All five auth screens exist with correct Clerk components, orange-500 appearance, and copy per UI-SPEC. Auth layout centers content at 448px.</done>
</task>
<task type="auto">
<name>Task 2: Protected app shell — layout, sidebar, mobile nav, onboarding redirect</name>
<files>src/app/(app)/layout.tsx, src/app/(app)/onboarding/pet/page.tsx, src/components/layout/Sidebar.tsx, src/components/layout/MobileNav.tsx</files>
<read_first>
- .planning/phases/01-foundation/01-UI-SPEC.md (Navigation (App Shell); Screen Inventory — Pet Profile Screens; Responsive Breakpoints; Active Pet Switcher UX; Color — accent reservation; Component Specifications — Pet Avatar, Pet Card)
- .planning/phases/01-foundation/01-RESEARCH.md (Pattern 6 Active Pet Context; Recommended Project Structure)
</read_first>
<action>
Create `src/app/(app)/layout.tsx` as a server component that:
1. Calls `const { userId } = await auth()` from `@clerk/nextjs/server`. If no userId, redirect to `/log-in` (belt-and-suspenders; proxy.ts already handles this).
2. Queries the owner's pets via `const caller = createCaller(...)` (tRPC server caller from Plan 01): `const pets = await caller.pets.list()`. If `pets.length === 0`, redirect to `/onboarding/pet`.
3. Renders the app shell: `<div className="flex min-h-screen">` with `<Sidebar />` on desktop (hidden on mobile via `hidden lg:flex`) + `<main className="flex-1 overflow-auto">` + `<MobileNav />` at bottom on mobile.
4. Wraps children in `<ActivePetProvider>` if not already in root layout (confirm root layout has this; if so, skip re-wrapping).
Create `src/components/layout/Sidebar.tsx` as a client component (`'use client'`):
- Fixed 240px left sidebar visible at `lg:` breakpoint
- Top: OnlyPets wordmark (`text-xl font-semibold text-foreground`) + a small paw icon (use Lucide `PawPrint` icon in orange-500)
- Pet switcher section: uses `useActivePet()` to show the active pet's avatar (sm, 32px) + pet name + `ChevronDown` icon. Opens a shadcn `<DropdownMenu>` listing all owned pets (each with avatar + name + species badge). Bottom of list: "+ Add Pet" item linking to `/onboarding/pet`. Active pet indicated with orange-500 dot (`bg-orange-500 w-2 h-2 rounded-full`).
- Nav links (use Next.js `<Link>`):
- Home (`/`) — `Home` icon
- My Pets (`/pets`) — `Heart` icon
- Explore (`/explore`) — `Compass` icon (placeholder; links inactive in Phase 1 — show as muted, not clickable, or just render as links for future phases)
- Notifications (`/notifications`) — `Bell` icon (placeholder)
- Messages (`/messages`) — `MessageCircle` icon (placeholder)
- Active nav item: left border `border-l-2 border-orange-500` + `text-foreground font-medium`; inactive: `text-muted-foreground`
- Bottom: Owner avatar (using Clerk `<UserButton>`) + "Sign Out" link
- All text: text-sm; sidebar bg: bg-card (muted background)
- Mark placeholder nav links (`/explore`, `/notifications`, `/messages`) with `aria-disabled="true"` and muted styling so they're visually present but non-navigable in Phase 1
Create `src/components/layout/MobileNav.tsx` as a client component (`'use client'`):
- Bottom tab bar visible below `lg:` breakpoint (`fixed bottom-0 left-0 right-0 lg:hidden`)
- Background: bg-card; border-top: border-t border
- Five tabs: Home, Explore, Post (center, accent-filled `+` or camera icon with bg-orange-500 rounded-full), Notifications, Profile
- Active tab: icon + label in orange-500; inactive: text-muted-foreground
- Post tab center: `bg-orange-500 text-white rounded-full p-3` button (links to post creation — placeholder in Phase 1, routes added in Phase 2)
- Pet switcher on mobile: top bar above the tab bar showing active pet name + avatar + `ChevronDown` that opens the same dropdown as sidebar (reuse logic via `useActivePet()`)
- Height: 64px (h-16)
Create `src/app/(app)/onboarding/pet/page.tsx` as a stub that renders a centered container (max-w-[512px]) with:
- Page heading: "Add your first pet" (text-xl font-semibold)
- Subheading: "Your pet will be their own star on OnlyPets." (text-sm text-muted-foreground)
- A placeholder div with dashed border where PetForm will render (added in Plan 03)
- Note in a TODO comment: `{/* PetForm goes here — Plan 03 */}`
SECURITY: `(app)/layout.tsx` must check auth server-side. Even though proxy.ts gates the route, the layout must not render pet data for an owner that has no pets — it redirects them to onboarding rather than showing an empty/broken shell.
</action>
<verify>
<automated>npx tsc --noEmit && npx vitest run --reporter=dot</automated>
</verify>
<acceptance_criteria>
- `npx tsc --noEmit` exits 0
- `src/components/layout/Sidebar.tsx` exists and imports `useActivePet`
- `src/components/layout/MobileNav.tsx` exists and contains `lg:hidden`
- `src/app/(app)/layout.tsx` contains an auth check and a pets.list call
- Onboarding page exists at correct route
</acceptance_criteria>
<done>Protected app shell renders sidebar on desktop and bottom nav on mobile. Signed-in owners with no pets are redirected to onboarding. Signed-out owners are redirected to log-in.</done>
</task>
<task type="checkpoint:human-action" gate="blocking-human">
<what-built>Auth pages and app shell are complete. This is the first visual checkpoint — the auth flow and navigation structure are now live in local dev.</what-built>
<how-to-verify>
1. Run `npm run dev` and open http://localhost:3000
2. Visit http://localhost:3000/pets — confirm redirect to /log-in
3. Visit http://localhost:3000/log-in — confirm Clerk sign-in page renders with orange CTA
4. Visit http://localhost:3000/sign-up — confirm Clerk sign-up page renders with orange CTA
5. Sign up with a test email → confirm redirect to /onboarding/pet and app shell (sidebar) is visible
6. Visit http://localhost:3000/forgot-password — confirm the "Reset your password" form renders
7. On desktop (>= 1024px), confirm the 240px sidebar is visible with the OnlyPets wordmark
8. On mobile (< 768px viewport), confirm the bottom tab bar is visible and the sidebar is hidden
</how-to-verify>
<resume-signal>Type "auth-ok" once the sign-up → onboarding/pet redirect works and both sidebar and mobile nav render correctly, or describe any errors.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → Clerk hosted UI | All auth credential handling (password hashing, session cookies) stays within Clerk's infrastructure |
| Browser → app server | After Clerk auth, Clerk JWT is verified by proxy.ts on every request |
| App shell → DB | pets.list in layout is always scoped to ctx.userId — no cross-owner pet leakage |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-2-01 | Spoofing | Auth pages | mitigate | Clerk-managed; no custom credential handling; httpOnly session cookies set by Clerk |
| T-2-02 | Elevation of Privilege | (app)/layout.tsx | mitigate | auth() called server-side in layout; redirect to /log-in if no userId; pets.list scoped by ctx.userId |
| T-2-03 | Information Disclosure | Sidebar pet list | mitigate | Sidebar only receives pets from pets.list which is ownership-scoped; no cross-owner data |
| T-2-04 | Tampering | Forgot/reset password | mitigate | Reset uses Clerk's strategy; no custom token generation or storage in app DB |
</threat_model>
<verification>
- `npx tsc --noEmit` clean
- `npx vitest run` green (scaffold still todo)
- `npm run dev` starts; unauthenticated /pets redirects to /log-in
- Clerk sign-up renders with orange-500 primary; sign-up → email verification → /onboarding/pet flow works
- Sidebar visible at lg:, hidden below; MobileNav visible below lg:, hidden above
- Forgot-password and reset-password pages render per UI-SPEC copy
</verification>
<success_criteria>
- AUTH-01: Owner can sign up with email and password via Clerk
- AUTH-02: Clerk email verification is triggered after sign-up; verified via manual email check
- AUTH-03: Forgot-password and reset-password pages exist and call Clerk reset APIs
- AUTH-04: Clerk session persists across browser refresh (httpOnly cookie); active pet from localStorage re-selected on refresh (ActivePetProvider already wired in Plan 01)
- App shell renders correctly on desktop and mobile
</success_criteria>
<output>
Create `.planning/phases/01-foundation/01-02-SUMMARY.md` when done
</output>
@@ -0,0 +1,352 @@
---
phase: 01-foundation
plan: 03
type: execute
wave: 1
depends_on: [01-01, 01-02]
files_modified:
- prisma/schema.prisma
- prisma/seed.ts
- src/trpc/routers/pets.ts
- src/app/(app)/pets/page.tsx
- src/app/(app)/pets/[petId]/page.tsx
- src/app/(app)/pets/[petId]/edit/page.tsx
- src/app/(app)/onboarding/pet/page.tsx
- src/components/pet/PetForm.tsx
- src/components/pet/PetCard.tsx
- src/__tests__/pet-profile.test.ts
autonomous: false
requirements: [PET-01, PET-02, PET-03, PET-04, PET-05]
---
<objective>
Deliver the complete pet profile lifecycle: create, view, edit, delete, and switch active pet. After this plan, an owner can create a pet (name, species, breed, bio, adoption story) on the onboarding page, view it on `/pets/[petId]`, edit and save changes, delete the pet with a confirmation dialog, manage multiple pets, and switch between them without logging out. The active pet is reflected in the sidebar and persisted in localStorage. Species (Dog, Cat, Bird) and breed data are seeded and served via tRPC.
Purpose: The pet-as-actor identity pattern is the architectural spine of the entire platform. Every downstream phase (content, feed, engagement, DMs, health tracking) presupposes a working pet profile with the correct ownership assertions. Breeds and species must be seeded before Plan 04's avatar upload can reference a real Pet row.
Output: Full pet CRUD tRPC procedures (update, delete, setBreed, listSpecies, listBreeds); PetForm component with all fields; My Pets grid; Pet profile page; Edit pet page with delete confirmation; breed seed data; all pet-profile unit tests passing.
</objective>
<execution_context>
@T:/CC-Projekte/.claude/gsd-core/workflows/execute-plan.md
@T:/CC-Projekte/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/phases/01-foundation/SKELETON.md
@.planning/phases/01-foundation/01-RESEARCH.md
@.planning/phases/01-foundation/01-UI-SPEC.md
@.planning/phases/01-foundation/01-VALIDATION.md
</context>
<artifacts_this_phase_produces>
- `src/trpc/routers/pets.ts` — extended with `update`, `delete`, `listSpecies`, `listBreeds` procedures (ownership-asserted)
- `prisma/seed.ts` — extended with breed taxonomy for Dog (20 common breeds), Cat (15 breeds), Bird (10 common species/breeds)
- `src/components/pet/PetForm.tsx` — reusable create/edit form; avatar upload zone stub (wired in Plan 04)
- `src/components/pet/PetCard.tsx` — pet card for My Pets grid
- `src/app/(app)/pets/page.tsx` — My Pets grid with Add Pet button
- `src/app/(app)/pets/[petId]/page.tsx` — pet profile view
- `src/app/(app)/pets/[petId]/edit/page.tsx` — edit pet form + delete confirmation dialog
- `src/app/(app)/onboarding/pet/page.tsx` — updated to render PetForm for pet creation
- `src/__tests__/pet-profile.test.ts` — unit tests now implemented (not just scaffolded)
</artifacts_this_phase_produces>
<tasks>
<task type="auto">
<name>Task 1: Extend tRPC pets router (update, delete, listSpecies, listBreeds) and extend breed seed</name>
<files>src/trpc/routers/pets.ts, prisma/seed.ts, prisma/schema.prisma</files>
<read_first>
- .planning/phases/01-foundation/01-RESEARCH.md (Pattern 1 Two-Layer Identity; Prisma Schema code example; Anti-Patterns — pet ownership not asserted; Pitfall 7 Breed Data Without Species Seeding; Security Domain ASVS V4 and V5; Don't Hand-Roll breed taxonomy)
- .planning/phases/01-foundation/01-VALIDATION.md (Per-Task Verification Map: 1-02-01 through 1-02-05)
- src/trpc/routers/pets.ts (existing create, list, byId from Plan 01)
</read_first>
<action>
Extend `src/trpc/routers/pets.ts` with the following additional procedures (keep existing `create`, `list`, `byId` intact):
**`pets.update`** (protectedProcedure):
- Zod input: `{ petId: z.string(), name: z.string().max(30).optional(), bio: z.string().max(150).optional().nullable(), adoptionStory: z.string().max(500).optional().nullable(), breedId: z.string().optional().nullable() }`
- Fetch pet by petId. Assert `pet.ownerId === ctx.userId` — throw `TRPCError({ code: 'FORBIDDEN' })` if not.
- Run `prisma.pet.update({ where: { id: input.petId }, data: { ...only provided fields } })`.
- Return updated pet.
**`pets.delete`** (protectedProcedure):
- Zod input: `{ petId: z.string() }`
- Fetch pet. Assert ownership. Run `prisma.pet.delete({ where: { id: input.petId } })`.
- Return `{ success: true }`.
**`pets.listSpecies`** (protectedProcedure — auth required to prevent scraping):
- No input.
- Return `prisma.species.findMany({ orderBy: { name: 'asc' } })`.
**`pets.listBreeds`** (protectedProcedure):
- Zod input: `{ speciesId: z.string() }`
- Return `prisma.breed.findMany({ where: { speciesId: input.speciesId }, orderBy: { name: 'asc' } })`.
Re-export `petsRouter` as before. Keep all existing procedures. Add the ownership assertion helper as a local function to avoid duplication:
```typescript
async function assertPetOwnership(prisma: PrismaClient, petId: string, userId: string) {
const pet = await prisma.pet.findUnique({ where: { id: petId } })
if (!pet || pet.ownerId !== userId) throw new TRPCError({ code: 'FORBIDDEN' })
return pet
}
```
Extend `prisma/seed.ts` to also seed breeds after species. Seed order: (1) species, (2) breeds. Use `upsert` with `where: { speciesId_name: { speciesId, name } }` to make seed idempotent.
Dog breeds to seed (20): Labrador Retriever, Golden Retriever, German Shepherd, French Bulldog, Bulldog, Poodle, Beagle, Rottweiler, German Shorthaired Pointer, Dachshund, Pembroke Welsh Corgi, Australian Shepherd, Yorkshire Terrier, Boxer, Cavalier King Charles Spaniel, Doberman Pinscher, Great Dane, Miniature Schnauzer, Siberian Husky, Border Collie.
Cat breeds to seed (15): Domestic Shorthair, Domestic Longhair, Maine Coon, Ragdoll, Siamese, Persian, Bengal, British Shorthair, Sphynx, Scottish Fold, American Shorthair, Abyssinian, Norwegian Forest Cat, Birman, Russian Blue.
Bird breeds/types to seed (10): Budgerigar (Budgie), Cockatiel, Lovebird, African Grey Parrot, Cockatoo, Conure, Macaw, Canary, Finch, Amazon Parrot.
SECURITY (T-1-04): All Zod schemas enforce character limits (name<=30, bio<=150, adoptionStory<=500) at the tRPC layer — server-side enforcement, not just client-side.
</action>
<verify>
<automated>npx prisma validate && npx tsc --noEmit && npx vitest run --reporter=dot</automated>
</verify>
<acceptance_criteria>
- `npx prisma validate` exits 0
- `npx tsc --noEmit` exits 0
- `grep -c "assertPetOwnership" src/trpc/routers/pets.ts` returns >= 2 (used in update and delete)
- `grep -c "pets.update\|pets.delete\|listSpecies\|listBreeds" src/trpc/routers/pets.ts` returns >= 4
- `grep -c "Labrador Retriever" prisma/seed.ts` returns >= 1 (breeds present in seed)
- No `middleware.ts` created (proxy.ts stays as the auth gate)
</acceptance_criteria>
<done>tRPC pets router has full CRUD + species/breed queries with ownership assertions. Breed seed is extended with 45 entries across Dog/Cat/Bird.</done>
</task>
<task type="auto">
<name>Task 2: PetForm component (create/edit) with all fields</name>
<files>src/components/pet/PetForm.tsx, src/components/pet/PetCard.tsx</files>
<read_first>
- .planning/phases/01-foundation/01-UI-SPEC.md (Component Specifications — Pet Profile Form Fields; Pet Avatar; Pet Species Badge; Pet Card; Toast Notifications; Copywriting Contract — Pet Profile Copy; Error States; Empty States)
- .planning/phases/01-foundation/01-RESEARCH.md (Pattern 1 Two-Layer Identity — ownership check; Don't Hand-Roll — breed taxonomy from DB)
</read_first>
<action>
Create `src/components/pet/PetForm.tsx` as a client component (`'use client'`).
Props: `{ mode: 'create' | 'edit', initialValues?: PetFormValues, petId?: string, onSuccess?: (petId: string) => void }`
Fields per UI-SPEC:
- **Pet name**: `<Input>` maxLength={30}, required. Label: "Pet name". Error: "Pet name must be 30 characters or fewer." / "This field is required."
- **Species**: shadcn `<Select>`. Label: "Species". Options populated from `trpc.pets.listSpecies.useQuery()`. On change, reset breed selection (species and breed are coupled).
- **Breed**: shadcn `<Select>`. Label: "Breed". Options populated from `trpc.pets.listBreeds.useQuery({ speciesId })` — only fetches when a species is selected. Show disabled state when no species selected. Empty state message: "No breeds found for this species." (inside the select). Required when species is selected.
- **Bio**: `<Textarea>` maxLength={150}. Label: "Bio". Placeholder: "Tell us about {petName}…" (use current name field value, fall back to "your pet"). Optional. Character counter below: `{charCount}/150` in text-xs text-muted-foreground.
- **Adoption origin story**: `<Textarea>` maxLength={500}. Label: "Adoption story". Placeholder: "How did {petName} join your family?". Optional. Character counter: `{charCount}/500`.
- **Avatar photo**: render `<AvatarUploadZone petId={petId} />` — a stub that renders the idle state (dashed border, Camera icon, "Upload photo" caption) in Phase 3. Plan 04 will wire the actual upload. In create mode, `petId` is undefined; show the upload zone as disabled with a tooltip "Save your pet first to upload a photo."
Submit behavior:
- In `create` mode: call `trpc.pets.create.useMutation()`. On success, call `onSuccess(newPet.id)` and show toast "Pet profile created".
- In `edit` mode: call `trpc.pets.update.useMutation({ petId })`. On success, call `onSuccess(petId)` and show toast "Changes saved".
- On error: show toast "Something went wrong. Please try again."
Primary CTA: `<Button type="submit">` — "Add Pet" in create mode, "Save changes" in edit mode. `bg-orange-500` via shadcn default variant (CSS variable already overridden to orange). Show `<Loader2 className="animate-spin" />` on loading state.
Use `react-hook-form` for form state (if installed — check package.json; shadcn's `<Form>` component requires it). If not installed, install it: `npm install react-hook-form @hookform/resolvers`. Use zod resolver with the same Zod schema constraints as the tRPC procedure input.
Create `src/components/pet/PetCard.tsx` as a client component:
Props: `{ pet: Pet & { species: Species, breed: Breed | null }, isActive: boolean, onSelect: (petId: string) => void }`
- Renders a shadcn `<Card>` (width ~200px, cursor-pointer)
- Content: shadcn `<Avatar>` at lg size (96px) + pet name (text-xl font-semibold) + species badge (shadcn `<Badge variant="secondary">` with species name) + "Active" indicator when `isActive === true` (orange-500 dot or `border-2 border-orange-500` ring on avatar)
- onClick: calls `onSelect(pet.id)`
- Hover: subtle scale or shadow transition (`transition-shadow hover:shadow-md`)
</action>
<verify>
<automated>npx tsc --noEmit && npx vitest run --reporter=dot</automated>
</verify>
<acceptance_criteria>
- `npx tsc --noEmit` exits 0
- `grep -c "listSpecies\|listBreeds" src/components/pet/PetForm.tsx` returns >= 2 (breed fetched from DB, not hardcoded)
- `grep -c "maxLength={30}\|z.string().max(30)" src/components/pet/PetForm.tsx` returns >= 1
- `grep -c "maxLength={150}" src/components/pet/PetForm.tsx` returns >= 1
- `grep -c "maxLength={500}" src/components/pet/PetForm.tsx` returns >= 1
- No hardcoded breed array in PetForm (no `["Labrador", ...]` literal)
- PetCard.tsx exists and imports Avatar and Badge
</acceptance_criteria>
<done>PetForm renders all required fields, fetches species and breeds from DB, enforces character limits, and calls the correct tRPC mutation in create/edit mode. PetCard renders pet identity for the My Pets grid.</done>
</task>
<task type="auto">
<name>Task 3: Pet profile pages (My Pets grid, profile view, edit page with delete dialog, onboarding)</name>
<files>src/app/(app)/pets/page.tsx, src/app/(app)/pets/[petId]/page.tsx, src/app/(app)/pets/[petId]/edit/page.tsx, src/app/(app)/onboarding/pet/page.tsx</files>
<read_first>
- .planning/phases/01-foundation/01-UI-SPEC.md (Screen Inventory — Pet Profile Screens; Copywriting Contract — delete dialog copy; Empty States — My Pets; Owner Identity Subordination Rule; Pet Avatar sizes)
- .planning/phases/01-foundation/01-RESEARCH.md (Pattern 6 Active Pet Context — pet switch behavior)
</read_first>
<action>
**`src/app/(app)/onboarding/pet/page.tsx`** (update from Plan 02 stub):
- Render the centered onboarding card (max-w-[512px] mx-auto p-8):
- Heading: "Add your first pet" (text-xl font-semibold)
- Subheading: "Your pet will be their own star on OnlyPets." (text-sm text-muted-foreground, mb-8)
- `<PetForm mode="create" onSuccess={(petId) => router.push(\`/pets/${petId}\`)} />`
- This is a client component (`'use client'`) due to router.push in onSuccess.
**`src/app/(app)/pets/page.tsx`** — My Pets grid:
- Server component that prefetches `pets.list` via tRPC server caller, passes data as props (or use React Query on client).
- Page heading: "My Pets" (text-xl font-semibold) with "Add Pet" `<Button>` (`<Link href="/onboarding/pet">`) aligned top-right.
- Pet cards rendered in a CSS grid: `grid grid-cols-2 md:grid-cols-3 gap-4`.
- Each `<PetCard>` receives `isActive={pet.id === activePetId}` and `onSelect={(id) => setActivePet(id)}` — uses `useActivePet()` so this section is a client component island. Consider splitting: server fetches data, `<PetGrid>` client component renders cards with active pet state.
- Empty state when `pets.length === 0`: card with "Add your first pet" heading + "Give your pet their own profile on OnlyPets." body + "Add Pet" CTA button.
**`src/app/(app)/pets/[petId]/page.tsx`** — Pet profile view:
- Server component. Fetch pet via `pets.byId({ petId: params.petId })`. If not found or not owned, `notFound()`.
- Layout: centered column max-w-[640px]:
- `<Avatar>` at xl size (128px, `w-32 h-32`) at top center. If no avatarKey, show pet name initials.
- Pet name: text-xl font-semibold, centered below avatar.
- Species badge (`<Badge variant="secondary">`): below pet name.
- "Managed by @{ownerUsername}" caption: text-xs text-muted-foreground — owner identity subordinate per UI-SPEC rule.
- Bio: text-sm, rendered if present.
- Adoption story: text-sm, labeled "Adoption story" (text-base font-semibold section heading), rendered if present.
- Edit link: `<Link href={\`/pets/${petId}/edit\`}>` with `<Button variant="outline">` "Edit profile" — top-right or below avatar.
- Posts grid placeholder (empty, with "No posts yet" — Phase 2 adds posts).
**`src/app/(app)/pets/[petId]/edit/page.tsx`** — Edit pet:
- Client component (needs router for post-delete redirect, and for the delete confirmation dialog).
- Prefetch pet data server-side or fetch on mount via `pets.byId`.
- Render `<PetForm mode="edit" initialValues={pet} petId={pet.id} onSuccess={() => router.push(\`/pets/${pet.id}\`)} />`.
- Below the form, after a `<Separator>`, render the destructive "Delete pet" section:
- `<Button variant="ghost" className="text-destructive hover:text-destructive">Delete pet</Button>` that opens a shadcn `<Dialog>`.
- Dialog content per UI-SPEC:
- Heading: "Delete pet profile?" (text-base font-semibold)
- Body: "This will permanently delete {pet.name}'s profile and all their posts. This can't be undone." (text-sm)
- Actions: `<Button variant="destructive">Yes, delete</Button>` + `<Button variant="outline">Keep pet</Button>`
- On confirm: call `trpc.pets.delete.useMutation()`. On success: if deleted pet was active pet, call `setActivePet('')` (or set to first remaining pet). Redirect to `/pets`. Show toast "Pet profile deleted".
SECURITY: Edit and delete pages must only render if the pet belongs to the current owner — the tRPC procedures already enforce this, but the page should also gracefully handle `notFound()` rather than showing a generic error.
</action>
<verify>
<automated>npx tsc --noEmit && npx vitest run --reporter=dot</automated>
</verify>
<acceptance_criteria>
- `npx tsc --noEmit` exits 0
- `grep -c "Delete pet profile" src/app/(app)/pets/\\[petId\\]/edit/page.tsx` returns >= 1 (dialog copy present)
- `grep -c "Yes, delete" src/app/(app)/pets/\\[petId\\]/edit/page.tsx` returns >= 1
- `grep -c "Keep pet" src/app/(app)/pets/\\[petId\\]/edit/page.tsx` returns >= 1
- `grep -c "Managed by" src/app/(app)/pets/\\[petId\\]/page.tsx` returns >= 1 (owner subordination)
- Onboarding page no longer has "PetForm goes here" TODO stub — it renders PetForm
</acceptance_criteria>
<done>My Pets grid, pet profile view, edit page with delete dialog, and onboarding form are all wired to real tRPC mutations and DB reads. Pet identity hierarchy (pet-first, owner-subordinate) is enforced in the profile view.</done>
</task>
<task type="auto">
<name>Task 4: Implement pet-profile unit tests (not just scaffolds)</name>
<files>src/__tests__/pet-profile.test.ts, src/__tests__/helpers/prisma-mock.ts</files>
<read_first>
- .planning/phases/01-foundation/01-VALIDATION.md (Per-Task Verification Map rows 1-02-01 through 1-02-05; Wave 0 Requirements)
- .planning/phases/01-foundation/01-RESEARCH.md (Validation Architecture; Security Domain — ownership assertion threat T-1-02)
- src/__tests__/helpers/prisma-mock.ts (existing mock from Plan 01 scaffold)
- src/trpc/routers/pets.ts (procedures to test)
</read_first>
<action>
Implement the pet-profile unit tests in `src/__tests__/pet-profile.test.ts`. Replace `it.todo` stubs with real implementations using the Prisma mock helper.
The tests call tRPC procedure logic directly (not over HTTP) using `createCallerFactory` or by constructing a caller with a mock context `{ userId: 'test-owner-1', prisma: mockPrisma }`.
**PET-01 — Create pet with valid input:**
- Mock `prisma.owner.upsert` → returns `{ id: 'test-owner-1' }`
- Mock `prisma.pet.create` → returns a pet object with all fields
- Call `petsRouter.createCaller(ctx).create({ name: 'Buddy', speciesId: 'species-dog', bio: 'A good boy' })`
- Assert return value contains `name: 'Buddy'` and `ownerId: 'test-owner-1'`
**PET-02 — Update rejected for non-owner (FORBIDDEN):**
- Mock `prisma.pet.findUnique` → returns `{ id: 'pet-1', ownerId: 'other-owner' }` (different owner)
- Call `petsRouter.createCaller({ userId: 'test-owner-1', prisma: mockPrisma }).update({ petId: 'pet-1', name: 'New Name' })`
- Assert it throws `TRPCError` with `code: 'FORBIDDEN'`
**PET-03 — Delete by owner succeeds:**
- Mock `prisma.pet.findUnique` → returns `{ id: 'pet-1', ownerId: 'test-owner-1' }` (correct owner)
- Mock `prisma.pet.delete` → returns the deleted pet
- Call `petsRouter.createCaller(ctx).delete({ petId: 'pet-1' })`
- Assert return value is `{ success: true }`
**PET-04 — Delete rejected for non-owner:**
- Mock findUnique → `{ ownerId: 'other-owner' }`
- Assert throws FORBIDDEN
**PET-05 — Adoption origin story persists:**
- Mock `prisma.pet.create` → returns pet with `adoptionStory: 'Found at the shelter'`
- Call `create({ name: 'Luna', speciesId: 'species-cat', adoptionStory: 'Found at the shelter' })`
- Assert `result.adoptionStory === 'Found at the shelter'`
**Ownership — Non-owner cannot update:**
- Same pattern as PET-02 for the update procedure
Update `src/__tests__/helpers/prisma-mock.ts` if needed to export typed mock functions for all the Prisma methods used in the tests above (`pet.create`, `pet.findUnique`, `pet.update`, `pet.delete`, `owner.upsert`).
All tests should use `vi.fn()` mocks that reset between tests via `beforeEach(() => { vi.clearAllMocks() })`.
Ensure all tests are synchronous-compatible (all async — use `async/await`).
</action>
<verify>
<automated>npx vitest run src/__tests__/pet-profile.test.ts --reporter=verbose</automated>
</verify>
<acceptance_criteria>
- `npx vitest run src/__tests__/pet-profile.test.ts --reporter=verbose` exits 0
- All test cases pass (not todo)
- At least 6 test cases in the file
- `grep -c "FORBIDDEN" src/__tests__/pet-profile.test.ts` returns >= 2 (ownership rejection tested for both update and delete)
- No real DB calls — all Prisma methods are mocked via prisma-mock helper
</acceptance_criteria>
<done>Pet profile unit tests are fully implemented and passing. Non-owner FORBIDDEN assertions cover all mutation procedures. Adoption story persistence is verified.</done>
</task>
<task type="checkpoint:human-action" gate="blocking-human">
<what-built>Full pet profile CRUD is complete. This is the functional checkpoint for the pet identity layer — an owner can create, view, edit, and delete pet profiles with breed selection.</what-built>
<how-to-verify>
1. Run `npm run dev` and sign in with a test account
2. Visit /onboarding/pet — confirm species selector shows Dog/Cat/Bird
3. Select "Dog" and confirm breed selector populates with dog breeds (e.g., Labrador Retriever)
4. Create a pet with name, species, breed, bio, and adoption story → confirm redirect to /pets/[petId]
5. Confirm pet name appears as primary identity and "Managed by @username" appears below in muted text
6. Visit /pets — confirm pet card appears in the grid; click it to set as active pet
7. Visit /pets/[petId]/edit — edit the pet name → save → confirm changes persisted
8. Open delete dialog → confirm "Delete pet profile?", "This will permanently delete...", "Yes, delete" / "Keep pet" buttons
9. Run `npx vitest run src/__tests__/pet-profile.test.ts --reporter=verbose` → all tests pass
10. If you have multiple pets, confirm the pet switcher in the sidebar shows all pets and switching works without page reload
</how-to-verify>
<resume-signal>Type "pets-ok" once the full pet CRUD flow works end-to-end and unit tests pass, or describe any failures.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → tRPC (pets.update/delete) | Client supplies petId; server must assert ownership before every mutation |
| tRPC → Prisma | All queries scoped by ownerId; no raw client petId trusted |
| UI → Zod | Character limits enforced both client-side (maxLength attr) and server-side (Zod schema) |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-3-01 | Tampering / EoP | pets.update / pets.delete | mitigate | assertPetOwnership helper called in every mutation; throws FORBIDDEN if ownerId mismatch; unit-tested in Task 4 |
| T-3-02 | Tampering | Zod input validation | mitigate | name<=30, bio<=150, adoptionStory<=500 enforced at tRPC layer; HTML maxLength is defense-in-depth only |
| T-3-03 | Information Disclosure | pets.byId | mitigate | Ownership assertion before returning pet data; notFound() if not owned |
| T-3-04 | Spoofing | Species/breed queries | mitigate | listSpecies/listBreeds are protectedProcedure — require auth to prevent unauthenticated taxonomy scraping |
</threat_model>
<verification>
- `npx tsc --noEmit` clean
- `npx vitest run` green (pet-profile tests implemented and passing)
- `npm run dev`: owner can create a pet with all fields, view the profile, edit details, delete with confirmation
- Species and breed selectors populated from DB (seeded data), not hardcoded
- Active pet switcher updates localStorage and sidebar without page reload (PET-04)
- "Managed by @owner" attribution visible on pet profile in muted caption style (Owner Identity Subordination)
</verification>
<success_criteria>
- PET-01: Owner can create a pet profile with name, breed, species, bio, avatar stub, and adoption origin story
- PET-02: Owner can edit their pet's profile details (update mutation with ownership assertion)
- PET-03: Owner can delete a pet profile (delete mutation with confirmation dialog)
- PET-04: Owner with multiple pets can switch active pet without logout; pet switcher in sidebar updates localStorage
- PET-05: Adoption origin story field (adoptionStory TEXT) persists to DB and renders on profile
</success_criteria>
<output>
Create `.planning/phases/01-foundation/01-03-SUMMARY.md` when done
</output>
@@ -0,0 +1,405 @@
---
phase: 01-foundation
plan: 04
type: execute
wave: 2
depends_on: [01-01, 01-02, 01-03]
files_modified:
- src/trpc/routers/media.ts
- src/trpc/routers/_app.ts
- src/components/pet/AvatarUpload.tsx
- src/components/pet/PetForm.tsx
- src/app/(app)/pets/[petId]/edit/page.tsx
- src/app/(app)/onboarding/pet/page.tsx
- src/__tests__/media-upload.test.ts
- .env.example
autonomous: false
requirements: [PET-01]
---
<objective>
Complete the media pipeline for Phase 1: owner can upload a pet avatar photo directly to Cloudflare R2 via a presigned PUT URL, and the avatar is immediately served from the Cloudflare CDN. The image bytes never pass through the Next.js API server. After this plan, the pet profile page and the sidebar active pet display show the real avatar instead of initials.
Purpose: The presigned upload pattern established here is reused by every subsequent media upload in the platform — post images (Phase 2), story media (Phase 2), and video thumbnails (Phase 5). Getting the pattern right in Phase 1 eliminates the risk of retrofitting it across all content types later. The `media.getPresignedUrl` tRPC procedure and `AvatarUpload` component become the template for all future upload flows.
Output: `src/trpc/routers/media.ts` (`getPresignedUrl` procedure); `AvatarUpload` component with all five states (idle, dragging, uploading, complete, error); avatar wired into PetForm (create mode: disabled until pet exists; edit mode: functional); pet profile and sidebar show R2-hosted avatar; media upload unit tests passing; Phase 1 walking skeleton complete with a real end-to-end media round trip.
</objective>
<execution_context>
@T:/CC-Projekte/.claude/gsd-core/workflows/execute-plan.md
@T:/CC-Projekte/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/phases/01-foundation/SKELETON.md
@.planning/phases/01-foundation/01-RESEARCH.md
@.planning/phases/01-foundation/01-UI-SPEC.md
@.planning/phases/01-foundation/01-VALIDATION.md
</context>
<artifacts_this_phase_produces>
- `src/trpc/routers/media.ts` — `media.getPresignedUrl` procedure (server-only R2 presigned PUT generation)
- `src/trpc/routers/_app.ts` — updated to include `media: mediaRouter`
- `src/components/pet/AvatarUpload.tsx` — five-state upload zone component
- `src/components/pet/PetForm.tsx` — updated: AvatarUpload wired and functional in edit mode
- `src/app/(app)/pets/[petId]/edit/page.tsx` — avatar upload active on edit page
- `src/__tests__/media-upload.test.ts` — unit tests implemented (not just stubs)
- Phase 1 walking skeleton complete: owner signs up → creates pet → uploads avatar → avatar served from CDN
</artifacts_this_phase_produces>
<tasks>
<task type="auto">
<name>Task 1: tRPC media router — getPresignedUrl procedure</name>
<files>src/trpc/routers/media.ts, src/trpc/routers/_app.ts</files>
<read_first>
- .planning/phases/01-foundation/01-RESEARCH.md (Pattern 2 Presigned R2 PUT Upload — full code example; Pitfall 4 Presigned URL ContentType Mismatch; Security Domain — T-1-03 R2 credentials server-only; Anti-Patterns — routing media through API server)
- src/lib/r2.ts (getPresignedUploadUrl function from Plan 01)
- src/trpc/routers/_app.ts (existing appRouter to add media sub-router)
</read_first>
<action>
Create `src/trpc/routers/media.ts`:
```typescript
import 'server-only'
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { protectedProcedure, router } from '@/trpc/init'
import { getPresignedUploadUrl } from '@/lib/r2'
import { prisma } from '@/lib/prisma'
import { randomUUID } from 'crypto'
export const mediaRouter = router({
getPresignedUrl: protectedProcedure
.input(z.object({
petId: z.string(),
contentType: z.enum(['image/jpeg', 'image/png', 'image/webp']),
fileSize: z.number().max(10 * 1024 * 1024, 'Photo must be under 10 MB'),
}))
.mutation(async ({ ctx, input }) => {
// Ownership assertion — verify pet belongs to authenticated owner
const pet = await ctx.prisma.pet.findUnique({ where: { id: input.petId } })
if (!pet || pet.ownerId !== ctx.userId) {
throw new TRPCError({ code: 'FORBIDDEN' })
}
// Generate a unique R2 key scoped by owner and pet
const ext = input.contentType.split('/')[1].replace('jpeg', 'jpg')
const key = `avatars/${ctx.userId}/${input.petId}/${randomUUID()}.${ext}`
const presignedUrl = await getPresignedUploadUrl(key, input.contentType)
return { presignedUrl, key }
}),
confirmUpload: protectedProcedure
.input(z.object({
petId: z.string(),
key: z.string().startsWith('avatars/'),
}))
.mutation(async ({ ctx, input }) => {
// Ownership assertion before writing avatar key to DB
const pet = await ctx.prisma.pet.findUnique({ where: { id: input.petId } })
if (!pet || pet.ownerId !== ctx.userId) {
throw new TRPCError({ code: 'FORBIDDEN' })
}
const updated = await ctx.prisma.pet.update({
where: { id: input.petId },
data: { avatarKey: input.key },
})
return { avatarKey: updated.avatarKey }
}),
})
```
Key design decisions:
- `contentType` is restricted to `image/jpeg | image/png | image/webp` — no other types can get a presigned URL (Pitfall 4 prevention: the enum is encoded into the presigned URL signature, so the browser must send the exact same Content-Type in the PUT)
- `fileSize` validated at the tRPC layer (max 10 MB) — the browser also enforces this but server-side is authoritative
- R2 key format: `avatars/{ownerId}/{petId}/{uuid}.{ext}` — scoped so the CDN path is predictable and organized; no key guessing from other pets
- `confirmUpload` key validation: `z.string().startsWith('avatars/')` — prevents clients from registering arbitrary R2 paths as pet avatars
- Mark file with `import 'server-only'` — R2 credentials must never reach the client bundle
Update `src/trpc/routers/_app.ts` to add `media: mediaRouter` to the appRouter object. Export `AppRouter` type as before.
SECURITY (T-1-03): `src/trpc/routers/media.ts` imports `server-only` at the top. R2 credentials (`CF_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`) are only read in `src/lib/r2.ts` which also imports `server-only`. These env vars must NOT be prefixed `NEXT_PUBLIC_` — verify `.env.example` has them without the prefix.
</action>
<verify>
<automated>npx tsc --noEmit && npx prisma validate && npx vitest run --reporter=dot</automated>
</verify>
<acceptance_criteria>
- `npx tsc --noEmit` exits 0
- `grep -c "server-only" src/trpc/routers/media.ts` returns >= 1
- `grep -c "mediaRouter" src/trpc/routers/_app.ts` returns >= 1
- `grep -c "FORBIDDEN" src/trpc/routers/media.ts` returns >= 2 (ownership checked in both procedures)
- `grep -c "startsWith.*avatars" src/trpc/routers/media.ts` returns >= 1 (key validation present)
- `.env.example` does NOT contain `NEXT_PUBLIC_R2_ACCESS_KEY_ID` or `NEXT_PUBLIC_R2_SECRET_ACCESS_KEY` (secrets are server-only)
</acceptance_criteria>
<done>media.getPresignedUrl and media.confirmUpload procedures exist with server-only protection, ownership assertions, ContentType restriction, and key scope validation.</done>
</task>
<task type="auto">
<name>Task 2: AvatarUpload component — five-state upload zone</name>
<files>src/components/pet/AvatarUpload.tsx, src/components/pet/PetForm.tsx</files>
<read_first>
- .planning/phases/01-foundation/01-UI-SPEC.md (Component Specifications — Avatar Upload Zone; Pet Avatar sizes; Toast Notifications; Error States — upload file too large, upload wrong format; Color — orange-500 on drag/progress; Animation — progress bar transition)
- .planning/phases/01-foundation/01-RESEARCH.md (Pattern 2 Presigned R2 PUT Upload; Pitfall 4 ContentType Mismatch; Don't Hand-Roll — file validation client-side then presigned URL ContentType)
- src/trpc/routers/media.ts (getPresignedUrl, confirmUpload input schema)
</read_first>
<action>
Create `src/components/pet/AvatarUpload.tsx` as a client component (`'use client'`).
Props: `{ petId: string | undefined, currentAvatarUrl?: string | null, onUploadComplete?: (key: string) => void }`
**State machine (5 states):**
1. **idle** (no currentAvatarUrl): Dashed border circle — `border-dashed border-2 border-muted rounded-full w-32 h-32 flex items-center justify-center cursor-pointer`. Contents: `<Camera>` Lucide icon (text-muted-foreground) + "Upload photo" caption (text-xs text-muted-foreground below icon).
2. **idle with existing avatar** (currentAvatarUrl present): Show `<Avatar className="w-32 h-32">` with the CDN URL. On hover: overlay with `<Camera>` icon and 50% black overlay (`bg-black/50`). Caption "Change photo" on hover.
3. **dragging** (dragover active): Change border to `border-orange-500`, background to `bg-orange-50/50`.
4. **uploading**: Show current avatar (or placeholder) with progress bar overlay. Progress bar: `<Progress value={uploadProgress} className="absolute bottom-0 left-0 right-0 h-1" />` where the fill uses orange-500. Image overlaid with 50% opacity. Show spinner or percentage text.
5. **error**: Red border `border-red-500`, error message below the zone (text-xs text-destructive). Reset to idle on next file selection.
**Upload flow:**
```
File selected (input[type=file] or drop)
→ Client validates: size <= 10MB, type in [jpeg, png, webp]
→ If invalid: show error state ("Photo must be under 10 MB." or "Upload a JPG, PNG, or WebP image.")
→ If valid and petId is undefined: show toast "Save your pet first to upload a photo." (disabled state)
→ If valid and petId exists:
1. Call trpc.media.getPresignedUrl.mutate({ petId, contentType, fileSize })
2. Receive { presignedUrl, key }
3. PUT file directly to presignedUrl using fetch():
fetch(presignedUrl, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type }, // MUST match contentType sent to getPresignedUrl
})
Track progress via XMLHttpRequest if fetch progress is needed, or show indeterminate state
4. On success (fetch 200): call trpc.media.confirmUpload.mutate({ petId, key })
5. On confirmUpload success: set component to "complete with avatar" state using the CDN URL
CDN URL: `${process.env.NEXT_PUBLIC_R2_PUBLIC_URL}/${key}`
6. Call onUploadComplete(key) prop
7. Show toast "Profile photo updated"
8. On any error: set error state, show toast "Upload failed — please try again"
```
**Drag and drop**: Attach `onDragOver`, `onDragLeave`, `onDrop` handlers on the container. On drop, extract `event.dataTransfer.files[0]` and run the same upload flow.
**Hidden file input**: `<input type="file" accept="image/jpeg,image/png,image/webp" className="hidden" ref={fileInputRef} onChange={handleFileChange} />` — clicking the zone triggers `fileInputRef.current?.click()`.
**Progress tracking**: Use `XMLHttpRequest` instead of `fetch` to get `xhr.upload.onprogress` events. Update `uploadProgress` state (0–100) for the progress bar. Alternatively, show a simple indeterminate `<Loader2 className="animate-spin">` overlay if XHR progress is complex.
**Disabled state (no petId)**: When `petId` is undefined (create mode before save), render the idle zone with reduced opacity (`opacity-50 cursor-not-allowed`) and a tooltip via `title` attribute: "Save your pet profile first, then add a photo."
Update `src/components/pet/PetForm.tsx` to wire `<AvatarUpload>` properly:
- Pass `petId` prop (undefined in create mode, pet.id in edit mode)
- Pass `currentAvatarUrl={avatarKey ? \`${process.env.NEXT_PUBLIC_R2_PUBLIC_URL}/${avatarKey}\` : null}`
- On `onUploadComplete`, update local state so the displayed avatar refreshes without a full page reload
**Avatar CDN URL construction**: The public CDN URL is `${NEXT_PUBLIC_R2_PUBLIC_URL}/${key}`. This env var must be in `.env.example` and `.env.local`. It is a `NEXT_PUBLIC_` prefixed var — the only R2 env var that is client-accessible, because it is only the CDN domain, not credentials.
</action>
<verify>
<automated>npx tsc --noEmit && npx vitest run --reporter=dot</automated>
</verify>
<acceptance_criteria>
- `npx tsc --noEmit` exits 0
- `grep -c "Content-Type.*file.type\|file\.type" src/components/pet/AvatarUpload.tsx` returns >= 1 (Content-Type sent in PUT matches the presigned URL)
- `grep -c "confirmUpload" src/components/pet/AvatarUpload.tsx` returns >= 1 (two-step upload with DB confirmation)
- `grep -c "10.*1024.*1024\|10MB\|10 MB" src/components/pet/AvatarUpload.tsx` returns >= 1 (client-side size validation)
- `grep -c "NEXT_PUBLIC_R2_PUBLIC_URL" src/components/pet/AvatarUpload.tsx` returns >= 1 (CDN URL construction)
- No R2 access keys or secrets in AvatarUpload.tsx (client component must not have credentials)
- `grep -c "AvatarUpload" src/components/pet/PetForm.tsx` returns >= 1 (wired into form)
</acceptance_criteria>
<done>AvatarUpload component implements the full five-state upload zone, presigned PUT flow, DB confirmation, progress tracking, and error handling. PetForm renders the upload zone in both create and edit modes.</done>
</task>
<task type="auto">
<name>Task 3: Wire avatar into pet profile view and sidebar; update edit page</name>
<files>src/app/(app)/pets/[petId]/page.tsx, src/app/(app)/pets/[petId]/edit/page.tsx, src/components/layout/Sidebar.tsx</files>
<read_first>
- .planning/phases/01-foundation/01-UI-SPEC.md (Pet Avatar sizes; Pet Profile Screens — focal point "Pet avatar at 128px xl, centered above pet name"; Active Pet Switcher UX — shows active pet avatar sm 32px)
- src/components/pet/AvatarUpload.tsx (onUploadComplete callback; CDN URL construction)
</read_first>
<action>
**`src/app/(app)/pets/[petId]/page.tsx`** — update avatar display:
- If `pet.avatarKey` is set, render `<Avatar className="w-32 h-32"><AvatarImage src={\`${process.env.NEXT_PUBLIC_R2_PUBLIC_URL}/${pet.avatarKey}\`} alt={pet.name} /></Avatar>`
- If no avatarKey, render `<Avatar className="w-32 h-32"><AvatarFallback>{pet.name.slice(0, 2).toUpperCase()}</AvatarFallback></Avatar>`
- This is a server component — use `NEXT_PUBLIC_R2_PUBLIC_URL` as a public env var (accessible in server components via `process.env`).
- Add `<Image>` via Next.js `<Image>` component for optimized delivery if Cloudflare domain is added to `next.config.ts`'s `images.remotePatterns`. Add the R2 public URL domain to `remotePatterns` so Next.js doesn't block external image URLs.
**`src/app/(app)/pets/[petId]/edit/page.tsx`** — AvatarUpload already wired through PetForm, but ensure:
- When `onUploadComplete` fires, invalidate the `pets.byId` query via React Query's `queryClient.invalidateQueries` so the profile page shows the updated avatar on next visit without stale data.
- The "Profile photo" label section in the form renders `<AvatarUpload>` prominently at the top of the form (above the text fields), per UI-SPEC focal point: "Avatar upload zone at top of 512px form, centered."
**`src/components/layout/Sidebar.tsx`** — update pet switcher avatar:
- The active pet avatar in the sidebar switcher (sm size, 32px) should show `<AvatarImage src={CDN_URL} />` if the pet has an avatarKey. Fall back to initials.
- The dropdown list items also show avatar per-pet.
- After avatar upload on the edit page, the sidebar should reflect the new avatar. This is handled by React Query cache invalidation on `pets.list` — trigger invalidation from `onUploadComplete` in PetForm.
**`next.config.ts`** — add R2 public URL domain to `images.remotePatterns`:
```typescript
// next.config.ts
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: process.env.NEXT_PUBLIC_R2_PUBLIC_URL
? new URL(process.env.NEXT_PUBLIC_R2_PUBLIC_URL).hostname
: '*.r2.cloudflarestorage.com',
},
],
},
}
```
Use a fallback pattern for when the env var is not set in CI/build.
</action>
<verify>
<automated>npx tsc --noEmit && npx vitest run --reporter=dot</automated>
</verify>
<acceptance_criteria>
- `npx tsc --noEmit` exits 0
- `grep -c "AvatarImage" src/app/(app)/pets/\\[petId\\]/page.tsx` returns >= 1 (avatar displayed from CDN)
- `grep -c "AvatarFallback" src/app/(app)/pets/\\[petId\\]/page.tsx` returns >= 1 (fallback to initials)
- `grep -c "remotePatterns" next.config.ts` returns >= 1 (R2 domain whitelisted)
- `grep -c "NEXT_PUBLIC_R2_PUBLIC_URL" src/components/layout/Sidebar.tsx` returns >= 1 (sidebar uses CDN URL)
</acceptance_criteria>
<done>Pet profile page, edit page, and sidebar all display the R2-hosted avatar from CDN. Next.js Image remote patterns configured for R2 domain.</done>
</task>
<task type="auto">
<name>Task 4: Implement media upload unit tests</name>
<files>src/__tests__/media-upload.test.ts, src/__tests__/helpers/prisma-mock.ts</files>
<read_first>
- .planning/phases/01-foundation/01-VALIDATION.md (Per-Task Verification Map row 1-03-01; Wave 0 Requirements — media-upload.test.ts)
- .planning/phases/01-foundation/01-RESEARCH.md (Security Domain — T-1-03; Pitfall 4 ContentType Mismatch; Validation Architecture)
- src/trpc/routers/media.ts (getPresignedUrl, confirmUpload procedures to test)
</read_first>
<action>
Implement `src/__tests__/media-upload.test.ts`. Replace `it.todo` stubs with real unit tests.
Mock `src/lib/r2.ts`'s `getPresignedUploadUrl` using `vi.mock('@/lib/r2', () => ({ getPresignedUploadUrl: vi.fn().mockResolvedValue('https://fake-r2.example.com/presigned-url') }))`.
**Test 1 — getPresignedUrl generates URL with correct contentType:**
- Mock `prisma.pet.findUnique` → returns `{ id: 'pet-1', ownerId: 'owner-1' }`
- Call `mediaRouter.createCaller({ userId: 'owner-1', prisma: mockPrisma }).getPresignedUrl({ petId: 'pet-1', contentType: 'image/jpeg', fileSize: 1024 })`
- Assert `getPresignedUploadUrl` was called with a key matching `/^avatars\/owner-1\/pet-1\//` and `contentType: 'image/jpeg'`
- Assert the returned `presignedUrl` is the mocked value
- Assert returned `key` starts with `'avatars/'`
**Test 2 — getPresignedUrl rejects non-owner (FORBIDDEN):**
- Mock `prisma.pet.findUnique` → returns `{ id: 'pet-1', ownerId: 'other-owner' }`
- Call with `userId: 'attacker'`
- Assert throws `TRPCError` with `code: 'FORBIDDEN'`
**Test 3 — getPresignedUrl rejects unsupported contentType (Zod validation):**
- Call with `contentType: 'image/gif'` (not in the allowed enum)
- Assert throws a Zod validation error (TRPCError with code 'BAD_REQUEST')
**Test 4 — getPresignedUrl rejects oversized file:**
- Call with `fileSize: 11 * 1024 * 1024` (11 MB > 10 MB limit)
- Assert throws TRPCError with code 'BAD_REQUEST'
**Test 5 — confirmUpload writes avatarKey to DB:**
- Mock `prisma.pet.findUnique` → returns owned pet
- Mock `prisma.pet.update` → returns pet with `avatarKey: 'avatars/owner-1/pet-1/uuid.jpg'`
- Call `mediaRouter.createCaller(ctx).confirmUpload({ petId: 'pet-1', key: 'avatars/owner-1/pet-1/uuid.jpg' })`
- Assert `prisma.pet.update` called with `data: { avatarKey: 'avatars/owner-1/pet-1/uuid.jpg' }`
- Assert return value contains `{ avatarKey: 'avatars/owner-1/pet-1/uuid.jpg' }`
**Test 6 — confirmUpload rejects arbitrary key path:**
- Call with `key: 'malicious/../../etc/passwd'` (does not start with 'avatars/')
- Assert throws TRPCError (Zod validation error — key must start with 'avatars/')
Use `beforeEach(() => { vi.clearAllMocks() })` to reset mocks between tests.
Update `src/__tests__/helpers/prisma-mock.ts` if needed to add `pet.update` mock.
</action>
<verify>
<automated>npx vitest run src/__tests__/media-upload.test.ts --reporter=verbose</automated>
</verify>
<acceptance_criteria>
- `npx vitest run src/__tests__/media-upload.test.ts --reporter=verbose` exits 0
- At least 6 test cases pass
- `grep -c "FORBIDDEN" src/__tests__/media-upload.test.ts` returns >= 2 (ownership and path rejection)
- `grep -c "image/gif" src/__tests__/media-upload.test.ts` returns >= 1 (unsupported type tested)
- `grep -c "11 \* 1024\|11MB\|fileSize.*11" src/__tests__/media-upload.test.ts` returns >= 1 (oversize tested)
- No real R2 calls — getPresignedUploadUrl is mocked via vi.mock
</acceptance_criteria>
<done>Media upload unit tests cover: presigned URL generation with correct contentType and key scope, non-owner FORBIDDEN, unsupported contentType, oversized file, confirmUpload DB write, and path traversal prevention.</done>
</task>
<task type="checkpoint:human-action" gate="blocking-human">
<what-built>The Phase 1 walking skeleton is complete. The full end-to-end flow is now functional: sign up → verify email → create pet with all fields → upload avatar directly to Cloudflare R2 → avatar served from CDN → appears in profile and sidebar.</what-built>
<how-to-verify>
1. Ensure `.env.local` has all R2 vars set: `CF_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`, `NEXT_PUBLIC_R2_PUBLIC_URL`
2. Confirm the R2 bucket has CORS configured: allow PUT, allow Content-Type header, from http://localhost:3000
3. Run `npm run dev`
4. Sign in and navigate to /pets/[petId]/edit
5. Click the avatar upload zone — confirm dashed-border idle state with camera icon and "Upload photo" label
6. Drag a JPG image onto the zone — confirm border turns orange (dragging state)
7. Drop or select a JPG file — confirm progress indicator appears, then avatar image shows on completion
8. Confirm the CDN URL in the browser DevTools Network tab: image request should go to your Cloudflare R2 CDN domain (NOT to localhost:3000/api/...)
9. Hard-refresh the page — confirm avatar persists (stored in DB as avatarKey, loaded from CDN on page render)
10. Check sidebar — confirm active pet avatar updates to show the uploaded photo
11. Try uploading a file > 10MB — confirm "Photo must be under 10 MB" error
12. Try uploading a .gif file — confirm "Upload a JPG, PNG, or WebP image." error
13. Run full test suite: `npx vitest run` — all tests pass (pet-profile + media-upload)
14. Run `npx tsc --noEmit` — exits 0
PHASE 1 SUCCESS CRITERIA CHECK:
- [ ] Owner can create an account, verify email, reset password, and stay logged in across refresh (AUTH-01..04)
- [ ] Owner can create a pet profile with all fields including avatar photo and adoption origin story (PET-01)
- [ ] Owner can edit and delete pet profile with confirmation dialog (PET-02, PET-03)
- [ ] Owner with multiple pets can switch active pet; active pet visible in nav (PET-04)
- [ ] Adoption story field saves to DB and displays on profile (PET-05)
- [ ] Avatar photo stored in R2, served from CDN, never through API server
</how-to-verify>
<resume-signal>Type "phase1-complete" once all success criteria are checked and the full suite passes, or describe any remaining issues.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → R2 presigned PUT | Only allowed contentTypes (jpeg/png/webp) can get a presigned URL; file bytes never touch the Next.js API server |
| Browser → media.getPresignedUrl | Ownership check before any URL is issued; attacker cannot get presigned URL for another owner's pet |
| Browser → media.confirmUpload | Key must start with 'avatars/' — prevents path traversal or arbitrary key registration |
| Server env → client | R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY are not NEXT_PUBLIC_ — never reaches browser |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-4-01 | Tampering | media.getPresignedUrl | mitigate | Ownership assertion: pet.ownerId === ctx.userId before any URL generation |
| T-4-02 | Tampering | Presigned URL ContentType | mitigate | contentType in tRPC input must be jpeg/png/webp (Zod enum); same value encoded in URL signature — browser must match exactly (Pitfall 4) |
| T-4-03 | Information Disclosure | R2 credentials | mitigate | `server-only` import in media.ts and r2.ts; R2 creds not prefixed NEXT_PUBLIC_; never exposed to client bundle |
| T-4-04 | Tampering | R2 bucket CORS | mitigate | R2 CORS configured to allow PUT from localhost:3000 and production domain only — not wildcard `*` |
| T-4-05 | Tampering | confirmUpload key validation | mitigate | key.startsWith('avatars/') enforced by Zod; prevents registering arbitrary R2 paths as pet avatars |
| T-4-06 | Denial of Service | File size | mitigate | fileSize <= 10MB enforced at tRPC layer (server-authoritative) + client-side check (defense in depth) |
</threat_model>
<verification>
- `npx tsc --noEmit` clean
- `npx vitest run` green (all 6 media tests + all pet-profile tests passing)
- `npm run dev`: full upload flow works — file selected → presigned URL fetched → PUT to R2 → confirmUpload → DB updated → CDN URL shown in profile and sidebar
- Image request in DevTools Network goes to R2/CDN domain, not localhost API
- Error states work: > 10MB shows size error, .gif shows format error
- Phase 1 success criteria all satisfied
</verification>
<success_criteria>
- PET-01 (avatar portion): Owner can upload a photo via presigned R2 URL; image stored in Cloudflare R2; served via CDN
- Media bytes never routed through the API server (confirmed via DevTools Network tab)
- `media.getPresignedUrl` and `media.confirmUpload` procedures have ownership assertions and are covered by unit tests
- All 6 media upload unit tests pass
- Phase 1 walking skeleton complete: sign up → create pet → upload avatar → avatar in sidebar → session persists across refresh
</success_criteria>
<output>
Create `.planning/phases/01-foundation/01-04-SUMMARY.md` when done
</output>
@@ -0,0 +1,54 @@
# Walking Skeleton — OnlyPets
**Phase:** 1
**Generated:** 2026-06-05
## Capability Proven End-to-End
A signed-in owner can create a pet (name + species) through a form, the pet is persisted to Supabase PostgreSQL via Prisma, and the new pet's name immediately appears in the app navigation — exercising auth, routing, a real DB write, a real DB read, and an interactive UI element in one deployable Next.js 16 application.
## Architectural Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Framework | Next.js 16.2.7 App Router (React 19.2, Turbopack default) | `latest` on npm 2026-06-05; RSC + co-located route handlers; SSR for future explore/breed SEO pages |
| Language | TypeScript 6.0.3 | First-class `next.config.ts`; required by all stack members |
| Styling | Tailwind CSS v4.3.0 (CSS-native `@import "tailwindcss"`, no `tailwind.config.ts`) + shadcn/ui (preset `default`, neutral base) | v4 engine; shadcn copies component source into repo — no dependency lock-in; orange `#F97316` primary via CSS variables |
| Auth | Clerk `@clerk/nextjs` 7.4.3 via `proxy.ts` (NOT `middleware.ts`) | Declares peerDep `^16.0.10`; owner accounts only — pets are app-domain entities, never Clerk users |
| Data layer | Supabase PostgreSQL 16 + Prisma 7.8.0 (`@prisma/client` 7.8.0) + `@prisma/extension-accelerate` 3.0.1 | Relational social graph; Accelerate for serverless connection pooling on Vercel; `DATABASE_URL` (pooled) + `DIRECT_URL` (migrations) |
| API layer | tRPC 11.17.0 (`@trpc/server`, `@trpc/client`, `@trpc/tanstack-react-query`) + `@tanstack/react-query` 5.101.0 + Zod 4.4.3 | End-to-end type safety; ownership assertions live in tRPC procedures; Zod validates all inputs |
| Media | Cloudflare R2 via `@aws-sdk/client-s3` 3.1062.0 + `@aws-sdk/s3-request-presigner` 3.1062.0 | Presigned PUT; client uploads bytes directly to R2 — never through the API server |
| Identity model | Two-layer: `owner_id` from Clerk JWT (server) + `active_pet_id` from React Context + localStorage (client); ownership asserted server-side on every pet mutation | The pet-as-actor invariant cannot be retrofitted; established in Phase 1 |
| Test runner | Vitest 2.x + `@vitest/coverage-v8` (jsdom env) | Integrates with Turbopack/ESM better than `@next/jest` for unit tests of tRPC procedures |
| Deployment target | Local dev (`npm run dev`) for Phase 1; Vercel for staging (Accelerate connection string required only on Vercel) | Local dev not blocked on Vercel/Accelerate setup |
| Directory layout | `src/app` route groups `(auth)` + `(app)`; `src/trpc`, `src/lib`, `src/components`, `src/context`, `src/hooks`; `prisma/` at root; `proxy.ts` at root | Per RESEARCH.md recommended structure |
## Stack Touched in Phase 1
- [x] Project scaffold — `create-next-app@latest` (Next.js 16, TS, Tailwind v4, Turbopack, `--src-dir`); Vitest test runner; shadcn init
- [x] Routing — `(auth)` group (sign-up, log-in, forgot/reset password) and `(app)` group (`/onboarding/pet`, `/pets`, `/pets/[petId]`, `/pets/[petId]/edit`); `api/trpc/[trpc]/route.ts`
- [x] Database — real write (`pets.create`) AND real read (`pets.list`, `pets.byId`) against Supabase PostgreSQL via Prisma; `species`/`breed` taxonomy seed
- [x] UI — interactive pet-create form wired to `pets.create` tRPC mutation; result reflected in nav switcher
- [x] Deployment — `npm run dev` runs the full stack locally against Supabase; Vercel documented as staging target
## Out of Scope (Deferred to Later Slices)
- Photo/video posts, carousels, captions (Phase 2)
- Stories, milestones, social graph (follow/unfollow), home feed (Phase 2)
- Reactions, comments, paw-backs, explore, search (Phase 3)
- Notifications (Phase 4), video/Mux (Phase 5), DMs (Phase 6), health tracking (Phase 7)
- Social OAuth (Google/Apple) — v2 (`AUTH-V2-01`, `AUTH-V2-02`)
- Dark mode — explicitly out of scope per UI-SPEC (light mode only for Phase 1)
- Cloudflare Images URL transforms — avatar served via R2 CDN URL in Phase 1; transforms deferred
- Reptile/fish pet types — v2
## Subsequent Slice Plan
Each later phase adds one vertical slice on top of this skeleton without altering its architectural decisions:
- Phase 2: A pet can post a photo and stories/milestones; owner sees a chronological home feed; block/report baseline
- Phase 3: Owner can react/comment, pet can paw-back, owner can explore/search pets
- Phase 4: Owner receives in-app notifications for social activity
- Phase 5: Pet can post async-transcoded short video via Mux
- Phase 6: Pet can exchange real-time DMs via Supabase Realtime
- Phase 7: Owner can log health events and share a signed health card