/dev/conventionssource: CLAUDE.md

CLAUDE.md

Read this before changing anything. This file is the source of truth for how the Clipt codebase is shaped. The same content is rendered at /dev/conventions for human reading.

Project

Clipt is a content-clipping platform where streamers, fans, clippers, and brands all share in clip earnings. Tagline: "Every clip pays the creator."

We serve four user types and the system has to feel right for each:

PersonaWhat they do on Clipt
Streamer / creatorConnects their Twitch / YouTube / Kick channel; receives a share of every clip and every paid placement off their footage.
FanWatches a live stream and one-taps to clip the last 30 seconds; gets credit and (later) tipping.
ClipperBrowses a marketplace of paid campaigns; produces clips; gets paid per verified view.
BrandFunds clipping campaigns with KYC'd clippers, automated FTC disclosure, and audit-grade reports.

The build is sequenced through the prompt pack at _prompt-pack/CLIPT_PROMPT_PACK.md — each "Prompt N.M" is one shippable increment. Don't skip ahead.

Stack

shadcn note. The current shadcn@latest CLI ships only Tailwind v4 + @base-ui/react components, which are incompatible with our pinned Tailwind v3. All components in src/components/ui/ are vendored by hand in the classic Radix-Slot + HSL CSS-variable style. components.json is set to style: "default"; if you need a new primitive, vendor it manually following the existing files' shape.

Conventions

Server / client split

Forms

Supabase clients

Styling

Database

Background jobs

Local dev

Two terminals: pnpm dev (Next, port 3006) and pnpm inngest:dev (Inngest dev server at 127.0.0.1:8288, configured to talk to /api/inngest). The dev server is unauthenticated and discovers functions automatically — you don't need the prod signing key locally.

A debug page at /dev/inngest triggers clip/requested against a freshly-inserted dummy clip so you can confirm the wiring without running through the full pipeline.

Object storage

Verified attribution

Key rotation

The signing key is rotated annually (or immediately if compromise is suspected). Procedure:

  1. Generate the new keypair with the script (writes the new public-key files and prints the private key):
    node scripts/generate-attribution-key.mjs --write
    
    This prepends a new entry into apps/web/public/.well-known/clipt-attribution-public-keys.json, marking the old current key with validUntil = <now ISO>. The single-key file at apps/web/public/.well-known/clipt-attribution-public-key is overwritten with the new key.
  2. Paste the printed private key into the production env as ATTRIBUTION_SIGNING_KEY. Keep the old value somewhere recoverable for at most 24h in case rollback is needed.
  3. Deploy — once the JWKS file is live, every clip signed AFTER the rotation uses the new key. Verifiers walk the JWKS array, so clips signed by the old key still verify until that key is removed from the array.
  4. Prune old keys from the JWKS array only when no clip in the wild was signed with that key — practically, remove a key on the anniversary of the rotation that retired it.
  5. Commit the public-key file changes; the JWKS is the public source of truth.

Billing & subscriptions

Tests

Folder rules

The repo is a pnpm workspace (pnpm-workspace.yaml at root) with two packages:

.                              repo root — supabase + scripts + docs live here
├── pnpm-workspace.yaml        declares packages: ['apps/*']
├── package.json               root scripts that delegate via pnpm --filter
├── supabase/migrations/       SQL migrations (project-wide, single source)
├── scripts/                   dev tooling (db.mjs reads apps/web/.env.local)
├── _prompt-pack/              the source-of-truth playbook (don't edit)
├── _design-handoff/           Claude Design exports (reference only)
│
├── apps/web/                  Next.js 15 app (the @clipt/web package)
│   ├── src/
│   │   ├── app/               routes (App Router) — thin shells
│   │   │   ├── api/           route handlers (webhooks, OAuth, Inngest only)
│   │   │   ├── (dashboard)/   grouped routes for the authed product surface
│   │   │   └── dev/           developer-only utility routes (/dev/health, etc.)
│   │   ├── components/{ui,shared}/
│   │   ├── features/<feature>/{components,server,schema.ts,types.ts}
│   │   ├── hooks/             reusable client hooks
│   │   ├── lib/               cross-cutting utilities + clients
│   │   │   ├── supabase/      Supabase client factories
│   │   │   ├── storage/       R2 facade (Supabase Storage backed today)
│   │   │   ├── crypto/        encryption + signing helpers
│   │   │   └── workers/       typed clients for the Python worker (videoWorker.ts)
│   │   ├── inngest/           Inngest client + functions
│   │   └── types/             shared TS types (database.ts is generated)
│   ├── public/                static assets
│   ├── .env.local             web-app secrets (gitignored). The Python
│   │                          worker reads STORAGE_* from here too via
│   │                          its own env loader; only WORKER_HMAC_KEY
│   │                          must be the same on both sides.
│   └── package.json           name: "@clipt/web"
│
└── workers/video/             Python FastAPI service (the video worker)
    ├── app/
    │   ├── main.py            FastAPI entry; mounts /healthz + /jobs/*
    │   ├── auth.py            JWT verification (HS256, audience clipt-video-worker)
    │   ├── config.py          env loader (WORKER_HMAC_KEY + STORAGE_*)
    │   ├── storage.py         boto3 S3 helper (works with R2 + Supabase Storage)
    │   └── jobs/              one module per endpoint:
    │       ├── transcribe.py  Whisper captions      (stub today, Prompt 1.9)
    │       ├── reframe.py     9:16 + caption burn   (stub today, Prompt 1.10)
    │       └── download_youtube.py  yt-dlp pull     (stub today, Phase 2)
    ├── Dockerfile             python:3.12-slim + ffmpeg + tini
    ├── fly.toml               app=clipt-video-worker, region=ams, perf-1x/4GB
    └── requirements.txt

Routes in apps/web/src/app/ are thin: they import a feature's components / actions and arrange them. No business logic.

Running locally (three terminals from the repo root):

pnpm dev                                              # Next on :3006
pnpm inngest:dev                                      # Inngest dashboard on :8288
cd workers/video && WORKER_HMAC_KEY=… STORAGE_*=… \   # FastAPI worker on :8000
  ./.venv/Scripts/python.exe -m uvicorn app.main:app --host 127.0.0.1 --port 8000

The worker is not yet deployed to Fly.io — the account requires billing info we haven't added (same gate that blocked Cloudflare R2). The Dockerfile + fly.toml are deploy-ready; when billing lands, run flyctl apps create clipt-video-worker --org personal && flyctl secrets set … && flyctl deploy and update VIDEO_WORKER_URL in apps/web/.env.local to point at the public Fly URL.

Naming

WhatStyle
File names (TS/TSX)kebab-case (use-toast.ts, theme-provider.tsx) — except React components which are PascalCase (Logo.tsx, ThemeToggle.tsx) when the file exports a single component named the same
React componentsPascalCase
Variables / functionscamelCase
Database tables / columnssnake_case
TypeScript types / interfacesPascalCase
Env varsSCREAMING_SNAKE_CASE; client-exposed keys must start with NEXT_PUBLIC_
Inngest event namesdomain/event-name (e.g. clip/requested, clip/captions-updated)

Commit style

Conventional Commits. Allowed prefixes: feat:, fix:, chore:, docs:, refactor:, perf:, test:. The body is expected for non-trivial changes — describe the why, not the what. Tag the prompt-pack reference in the subject when the commit closes a Prompt N.M (feat: supabase setup + initial schema (Prompt 0.3)).

Dependencies

When uncertain

Prefer clarity over cleverness. This codebase is being built solo with heavy AI assistance — the next person reading this code might be future-you a month from now or a Claude Code session that lacks all of today's context. Optimize for someone who has read this file once and is now staring at one new file.