diff --git a/README.md b/README.md index 7eed997..b150fb1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # PawFeed -A social platform where pets are the stars. Dogs, cats, and birds get their own profiles — owners manage them, but the pet is the identity. Think Instagram built from the ground up for animals: pets follow each other, post photos and videos, celebrate milestones, and track their health, all within a community that actually cares about animals. +A social platform where pets are the stars. Dogs, cats, birds, horses, rabbits, guinea pigs, hamsters, mice, rats, chinchillas, snakes, lizards, turtles, geckos, spiders, and aquariums all get their own profiles — owners manage them, but the pet is the identity. Think Instagram built from the ground up for animals: pets follow each other, post photos and videos, celebrate milestones, and track their health, all within a community that actually cares about animals. Species/breed is a fully data-driven taxonomy (`Species`/`Breed` + translation tables), so adding a new species is a seed-data change, not a code change. **Core value:** Pets are first-class social identities — not just content on a human's feed — so every feature is built around the pet's life, not the owner's. @@ -23,6 +23,7 @@ A social platform where pets are the stars. Dogs, cats, and birds get their own | Cache / Feed / Rate Limiting | **Self-hosted Redis** (`ioredis`, Docker service — not Upstash) | 5.x | | Client Fetching | TanStack Query | 5.x | | Video | Mux (upload, transcoding, HLS streaming) | — | +| Payments | Stripe (voluntary sponsoring — one-time & monthly, **LIVE mode**, not Test) | — | | i18n | next-intl (EN/DE message catalogs; legal pages fixed-German) | 4.x | | Theming | next-themes (account-bound dark mode via `Owner.themePreference`) | — | | Forms / Validation | react-hook-form + `@hookform/resolvers` + Zod | — | @@ -39,7 +40,7 @@ A social platform where pets are the stars. Dogs, cats, and birds get their own - Short-form video is handled by **Mux**: client uploads directly to a signed Mux URL → Mux transcodes and serves HLS via `mux-player-react`. The `getByPostId` query self-heals in dev by polling the Mux API directly, so webhooks are not required during local development. - **Health data is strictly owner-private.** The only publicly accessible health surface is the shareable health card (token URL with expiry), designed for vets, friends, and pet sitters. The emergency vet contact is intentionally excluded from the shared card — it lives only in the private dashboard. - **Deployment is self-hosted, not Vercel.** `next.config.ts` still sets `output: "standalone"`, but the app ships as a Docker image (`docker/Dockerfile`, `docker/docker-compose.yml`) to a NAS, run as **3 load-balanced replicas** (not 1 container, since 2026-08-16 — Next.js standalone is single-process, one container = one CPU core). `./start.sh` rebuilds the image and recreates all 3 replicas. -- **Content-Security-Policy runs in Report-Only mode** (`src/proxy.ts`, via Clerk's own `contentSecurityPolicy` middleware option) while directive gaps get closed from real violation reports collected in GlitchTip, before switching to enforced. +- **Content-Security-Policy is enforced** (`src/proxy.ts`, via Clerk's `contentSecurityPolicy` middleware option with `strict: true`) — a per-request nonce + `'strict-dynamic'` instead of broad `http:`/`https:` sources in `script-src`. Clerk forwards the generated CSP header (with nonce) on both the response and the downstream request, which is exactly what Next.js's own automatic nonce application reads during SSR, so framework scripts and page bundles pick it up for free; hand-written inline scripts (e.g. `next-themes`' FOUC-prevention script in `src/app/layout.tsx`) read the nonce from the `x-nonce` header explicitly. Mozilla Observatory: A+. `'unsafe-eval'` stays in `script-src` defensively for now (see Known Issues). - **Admin/moderation panel** lives at a secret path (`/p/[ADMIN_SECRET]/...`) — wrong or missing secret returns a bare 404, not 403, so the panel's existence isn't revealed to unauthenticated probing. Role-gated at the tRPC layer on top of that (`SUPER_ADMIN` / `MODERATOR`). --- @@ -67,7 +68,8 @@ src/ onboarding/pet/ # First-pet creation onboarding (auth)/ # Clerk-rendered sign-in / sign-up / password reset / email verify p/[secret]/ # Admin & moderation panel — path segment IS the secret (ADMIN_SECRET) - users/ moderators/ posts/ reports/ ads/ invites/ verification/ blacklist/ messages/ log/ legal/ + users/ moderators/ posts/ reports/ ads/ invites/ verification/ blacklist/ messages/ log/ legal/ broadcasts/ + unterstuetzen/ # Stripe sponsoring — one-time or monthly, grants a Sponsor badge health-card/[token]/ # Public shareable health card (no login required) join/ # Invite-code landing page, sets the pf_invite cookie banned/ # Shown to banned owners (proxy.ts redirect target) @@ -84,6 +86,7 @@ src/ trim-feeds/ # Trims each pet's Redis feed sorted set to a 90-day window webhooks/ mux/ # Mux webhook handler (video.asset.ready etc.) + stripe/ # Stripe webhook handler (checkout.session.completed etc.) — LIVE mode components/ # feed/ health/ layout/ notifications/ pet/ post-creation/ # profile/ safety/ social/ stories/ video/ content/ (AI-disclosure UI) # providers/ (ThemeProvider, ClerkThemeProvider) ui/ (shadcn copies) @@ -149,6 +152,8 @@ Warning / UserBan / Shadowban / BlacklistEntry — moderation actions Advertisement / AdReaction / AdComment / AdRepost — sponsored posts with species/breed targeting ModerationLog — audit trail for every admin/moderator action LegalDocumentVersion — versioned legal-doc content + update-banner trigger +Broadcast / BroadcastReceipt — system-wide admin announcements ("Mitteilungen"), owner banner + per-owner read receipts +SponsorContribution — Stripe sponsoring records (ONE_TIME | SUBSCRIPTION), grants the Sponsor badge ``` --- @@ -340,7 +345,7 @@ A daily cron (`/api/cron/trim-feeds`) trims each pet's Redis feed sorted set to ### Admin & moderation panel -Gated by path secret (`ADMIN_SECRET`, see `proxy.ts`) **and** by role at the tRPC layer: `admin.ts`'s `assertAdmin()` allows either `ADMIN_OWNER_ID` (env-bootstrapped super-admin) or an `AdminRole` row (`SUPER_ADMIN` / `MODERATOR`). Every action is written to `ModerationLog`. Covers users, moderators, posts, reports, ads, invites, verification requests, and blacklist entries. +Gated by path secret (`ADMIN_SECRET`, see `proxy.ts`) **and** by role at the tRPC layer: `admin.ts`'s `assertAdmin()` allows either `ADMIN_OWNER_ID` (env-bootstrapped super-admin) or an `AdminRole` row (`SUPER_ADMIN` / `MODERATOR`). Every action is written to `ModerationLog`. Covers users, moderators, posts, reports, ads, invites, verification requests, blacklist entries, and system-wide broadcast announcements (`createBroadcast`/`deleteBroadcast`, both `SUPER_ADMIN`-gated, deletion requires a reason for the audit trail). ### Invite system @@ -348,7 +353,7 @@ Gated by path secret (`ADMIN_SECRET`, see `proxy.ts`) **and** by role at the tRP ### Error monitoring & CSP -`@sentry/nextjs` reports both errors and performance traces to a self-hosted GlitchTip instance. CSP currently runs in `reportOnly` mode; violations land at `/api/csp-report`, get forwarded to GlitchTip, and directives get tightened as real traffic surfaces gaps — before switching to enforced. +`@sentry/nextjs` reports both errors and performance traces to a self-hosted GlitchTip instance. CSP is enforced (nonce + `'strict-dynamic'`, see Design decisions above); violations still get reported to `/api/csp-report` → GlitchTip (`report-uri` + Reporting API, including `lineNumber`/`columnNumber`/`sample` for fast diagnosis) even though the policy is no longer report-only, so regressions surface immediately without blocking users first. ### Health data privacy @@ -376,6 +381,52 @@ Instead of a standard circular ring, active stories are indicated by a paw-print --- +## Glossary + +The load-bearing concept in this codebase: **the pet, not the owner, is the social identity.** Every domain term below is defined in relation to that split. + +``` +Owner (Clerk account, auth only) + └── owns 1..n Pet(s) + └── Pet IS the social identity — profile URL, posts, followers, DMs + ├── Post (PHOTO | MILESTONE | REPOST | VIDEO) + │ ├── Milestone — 1:1 metadata row when Post.type = MILESTONE + │ ├── PostImage — 1:n photos on a PHOTO post + │ ├── VideoPost — Mux upload/asset state when Post.type = VIDEO + │ ├── Reaction / Comment / Repost — engagement on the Post + │ └── Hashtag (via PostHashtag) + ├── Story — 24h ephemeral photo, separate from Post + ├── Follow — Pet → Pet edge (the whole social graph is Pet-to-Pet) + ├── Conversation / Message — Pet ↔ Pet DMs (petAId always < petBId) + ├── WeightLog / VetVisit / Vaccine / EmergencyVet — owner-private health data + │ └── HealthCard — the one shareable, tokenized window into that data + └── VerificationRequest — "verified pet" application (blue check) +``` + +| Term | What it is | Relates to | +|---|---|---| +| **Owner** | A Clerk-authenticated human account. `Owner.id` **is** the Clerk `userId` — no separate mapping table. Owns pets, manages settings, is never itself a follower/poster/commenter. | 1:n **Pet**; the human behind one or more pets | +| **Pet** | The actual social identity — has its own profile URL, avatar, bio, follower count, posts, DMs. Every social action (`Post`, `Follow`, `Reaction`, `Comment`, `Message`, ...) is scoped by `petId`, never `ownerId`. | Belongs to one **Owner**; everything social hangs off this | +| **Species / Breed** | Fully data-driven taxonomy (with i18n translation tables) — 16 species categories from dogs to aquariums; `Breed` is the predominant fish species for an aquarium `Pet`, not a per-fish profile. | Attributes on **Pet**; drives ad targeting and the spider-exclusion filter | +| **Post** | The core content unit. `type` is one of `PHOTO`, `MILESTONE`, `REPOST`, `VIDEO`. Carries denormalized `reactionCount`/`commentCount`/`repostCount` (maintained transactionally, not computed on read). | Authored by a **Pet**; fans out to followers' Redis feed on create | +| **Milestone** | A 1:1 sidecar row on a `Post` when `type = MILESTONE` (e.g. "500 followers", "adoption anniversary"). Not a separate feed item — it's a `Post` with extra metadata and different card styling. | 1:1 with **Post** | +| **Story** | A 24-hour ephemeral photo, structurally separate from `Post` (own model, own view-tracking with 30-day retention). Not part of the main feed. | Authored by a **Pet** | +| **Follow** | A directed edge from one **Pet** to another. The entire social graph (`Follow`, `Block`, `Reaction`, ...) has zero owner columns — resolving "who can see this" never touches `Owner`. | Pet → Pet, no Owner involvement | +| **Reaction / Comment / Repost** | Engagement primitives on a `Post` (or `Advertisement`, which has its own parallel `AdReaction`/`AdComment`/`AdRepost` set). Each create/delete transactionally updates the parent's denormalized counter. | Attach to **Post** (or `Advertisement`) | +| **Notification** | Fire-and-forget row created on `FOLLOW`/`REACTION`/`COMMENT`/`MESSAGE`/`BIRTHDAY`/`ADOPTION_DAY` events, always wrapped in `.catch(() => {})` so a notification failure can never break the triggering mutation. | Targets a **Pet** (the recipient's active pet) | +| **Conversation / Message** | DMs. `petAId`/`petBId` are canonically ordered (`petAId < petBId`) so a conversation between two pets has exactly one row regardless of who initiated it. | Pet ↔ Pet | +| **HealthCard** | The *only* public window into a pet's otherwise strictly owner-private health data (`WeightLog`, `VetVisit`, `Vaccine`, `EmergencyVet`) — a tokenized, expiring URL meant for vets/sitters, deliberately excluding the emergency vet contact. | Derived view over health data owned by a **Pet** | +| **InviteCode** | Beta sign-up gate (word-prefix + 4-char code, e.g. `PAWS-7K3M`), consumed once via a `pf_invite` cookie. Currently disabled (`INVITE_REQUIRED=false`) for public launch — the model still exists for a possible future re-gate. | Gates **Owner** account creation, not pet creation | +| **AdminRole / ModerationLog** | `AdminRole` is a 3-tier permission row (`SUPPORT` < `MODERATOR` < `SUPER_ADMIN`) checked by every `admin.*` tRPC procedure via `assertAdmin()`; `ADMIN_OWNER_ID` bootstraps one super-admin without needing a row. Every admin action writes a `ModerationLog` entry (action, target, reason, moderator, IP). | Applies to an **Owner** acting as an admin; targets any entity | +| **Warning / UserBan / Shadowban / BlacklistEntry** | Moderation actions, all applied to an **Owner** (the account), not a specific **Pet** — banning stops the human, not just one of their pets. | Target an **Owner** | +| **Broadcast / BroadcastReceipt** | Admin-authored system-wide announcements (`SUPER_ADMIN`-gated create *and* delete, both audited). `BroadcastReceipt` is one row per `(broadcast, owner)`, created lazily the first time an owner's banner query surfaces it — no fan-out at send time. | Sent by an admin **Owner**; received by every **Owner** (not per-pet) | +| **SponsorContribution** | A voluntary Stripe payment (`ONE_TIME` or `SUBSCRIPTION`, **LIVE mode**) that grants the Sponsor badge, shown on the contributing owner's pet profile(s) and posts. | Made by an **Owner**; the badge displays on their **Pet**(s) | +| **VerificationRequest** | An owner-submitted application (with a required checklist) for the "verified pet" blue check, reviewed by an admin. | Requested for a **Pet**, reviewed by an admin **Owner** | +| **LegalDocumentVersion** | Versioned Impressum/Datenschutz/AGB content — a version bump triggers an in-app update banner every owner must acknowledge. | Acknowledged by an **Owner** (account-level, not per-pet) | +| **DataExportToken** | A short-lived token backing the GDPR self-service data export flow. | Issued to an **Owner** | + +--- + ## What's Shipped Foundation → content core → engagement → notifications → video → messaging → health tracking are all live, plus a full production layer built after MVP: @@ -387,14 +438,16 @@ Foundation → content core → engagement → notifications → video → messa - **Legal** — Impressum/Datenschutz/AGB, versioned with an update-banner trigger, AI-content disclosure badges on uploads - **GDPR** — tokenized personal-data export, self-service account deletion - **Ads** — sponsored posts with species/breed targeting, ad-specific reactions/comments/reposts -- **Security hardening** — Redis-backed rate limiting, CSP (Report-Only), HSTS/security headers, constant-time secret comparison, self-hosted error monitoring -- **Self-hosted deployment** — Docker on an OpenMediaVault NAS behind Nginx Proxy Manager + Cloudflare +- **Sponsoring** — voluntary Stripe payments (one-time/monthly, LIVE mode) grant a Sponsor badge shown on the pet profile and every post +- **Broadcasts** — admin-authored system-wide announcements with an owner-facing banner, per-owner delivered/read receipts, and audited creation/deletion +- **Security hardening** — Redis-backed rate limiting, enforced CSP (nonce + `strict-dynamic`, Mozilla Observatory A+), HSTS/security headers, constant-time secret comparison, self-hosted error monitoring +- **Self-hosted deployment** — Docker on an OpenMediaVault NAS behind Nginx Proxy Manager + Cloudflare, 3 load-balanced replicas, zero-downtime rolling deploys ### Open / next up | Item | Notes | |---|---| -| CSP enforced mode | Waiting on a clean Report-Only period in GlitchTip before flipping `reportOnly: false` | +| Remove `'unsafe-eval'` from CSP `script-src` | Kept defensively after 6 isolated eval violations (2026-08-15, never recurred) that live reproduction couldn't pin on app code — likely a browser extension. Revisit once confirmed clean over a longer window. | | Story video support | Stories currently photo-only; `StoryViewer` already has a progress timer, needs video playback | | Follower milestone auto-notifications | `MilestoneType` has `FOLLOWERS_500` etc. but no trigger wired yet | | Google / Apple OAuth | Supported natively by Clerk — deliberately deferred, no code change required | @@ -428,3 +481,5 @@ npx vitest # watch mode | Local dev cannot exercise authenticated routes | Clerk runs with production keys and rejects non-`pawfeed.org` origins; only public pages are testable against `npm run dev`. | | Mux webhook not required in local dev | `videos.getByPostId` polls Mux directly when status is `PROCESSING`. | | `CRON_SECRET` must be set for both cron routes | Without it, the routes return 401 and nothing fires — there is no serverless scheduler here, an external trigger (NAS crontab) is required. | +| Server-side requests to the app's own domain must target the DDNS hostname, not `pawfeed.org` | Nginx Proxy Manager's origin TLS certificate is only valid for `pawfeed.neodk.ipv64.de` — `pawfeed.org` works for browsers only because Cloudflare terminates TLS at the edge and doesn't strictly verify the origin cert. A plain `curl https://pawfeed.org/...` from the NAS itself (e.g. `docker/run-cron.sh`) fails with an SSL hostname mismatch; use `curl --resolve pawfeed.neodk.ipv64.de:443:127.0.0.1 https://pawfeed.neodk.ipv64.de/...` instead. | +| A 401 (not 403) from an admin tRPC procedure usually means a stale Clerk session, not a permissions bug | `protectedProcedure` throws `UNAUTHORIZED` (401) when Clerk's session is missing/expired; `assertAdmin()` throws `FORBIDDEN` (403) when the session is valid but the role is too low. A browser tab left open and backgrounded for hours can let Clerk's silent token refresh lapse — reload the page before suspecting a code regression. |