Auto-loaded by Claude Code on every session in this repository. These rules apply to ALL changes in
sofra/. This app is LIVE at https://sofrapiwas.com (marketing site + control plane) and bills real money (Mollie,live_key since 2026-07-07). Work accordingly.
- Stack: Next.js 15 (App Router) · React 19 · TypeScript · Tailwind ("craft" design system) · next-intl (site: en/fr/de/nl/tr/ar,
ar= RTL) · Auth.js v5 beta (Credentials + JWT) · Prisma 7 +@prisma/adapter-pg· Mollie (subscriptions) - Two surfaces, one app:
app/[locale]/= localized public marketing site;app/(control)/= control plane (admin/admin, partner/dashboard) with its own root layout — localized via the NEXT_LOCALE cookie (lib/control-locale.ts), no locale prefix in its URLs. - Deliberately NOT bound by the frontend repo's rules: Tailwind (not CSS Modules),
.darkclass dark mode (nothtml[data-theme]), craft tokens (not RUMI tokens). Do not import frontend conventions here. - Hosting: runs on the staging Netcup box (
159.195.34.105) behind Caddy, imageghcr.io/piwas-21/sofra; Postgres = sharedpostgrescontainer, DB/rolesofra. Infra source of truth:deploy/repo (DEPLOYMENT.md§Sofra control plane). - Workspace context: one of five repos under the
rumi-workspacemeta-repo — master plandocs/plans/SOFRA-SAAS-PLAN.md, roadmap Track S.
| When | Read |
|---|---|
| Any architectural question | docs/adr/ — ADRs 001–014 (tenancy, provisioning, control plane, partner model, modules, billing split, billing identity + VAT + invoicing, tenant backup visibility) |
| Partner program / CRM semantics | ADR-009 + workspace docs/plans/SOFRA-PARTNER-PLAN.md (Client = CRM row; Tenant = registry entry; joined only by tenantSlug) |
| Billing work | ADR-005/ADR-011 + lib/billing.ts (fetch-and-verify, ACTIVATING claim, idempotency) |
| Any login / password / invite flow | lib/actions/auth-actions.ts + lib/auth-form.ts (the shared FormState, rate limit and address guard) + lib/invite-resend.ts. One rule governs the two self-service forms: they answer the same sentence to every address, so neither can be used to learn which restaurants are customers. That is why they share ONE component (EmailRequestForm) — two copies drift, and the day one says "no account found" the pair becomes an oracle. Spec: workspace docs/plans/EMAIL-SPEC-CONTROL-PLANE.md §M4/M6 |
| Anything about VAT, invoices, or a customer's legal details | ADR-013 + workspace docs/plans/SOFRA-BILLING-IDENTITY-PLAN.md. Three rules that are easy to break by accident: a VAT status belongs to the number it was proven for and an outage (UNAVAILABLE) must never overwrite a VALID; every reader resolves an identity through resolveIdentityForPlan, never plan.billingIdentity, or a form will overwrite a record it never displayed; and invoicing runs inside the Mollie webhook, so it may never throw — refusals are recorded and re-issued from /admin/invoices. Seller identity is env (SOFRA_LEGAL_*), and its absence blocks every invoice on purpose |
| Deploy/migrate/ops | deploy repo DEPLOYMENT.md §Sofra control plane + the operating-rumi-infra skill |
Anything about tenant backups — the /admin/backups page, the box agent's three endpoints, or "do we still have that restaurant's data?" |
ADR-014 + lib/backup-*.ts. Five rules that are easy to break by accident: every credential points BOX → SOFRA and the control plane must never gain one that can reach a box (so no SSH and no Actions: write token — it cannot be narrowed to one workflow, so it would also dispatch deprovision-tenant.yml --drop-db); the whole-box push PRUNES what it stops listing, which is what makes an emptied repository visible and is the reason a partial push must never be sent; and the retention date is a display of the box's restic forget policy, so it may never promise longer than an artifact we can actually see. delete is implemented and disabled — BACKUP_DELETE_ENABLED — because retention already removes copies and a button does not. Since D5 the same verdicts are also MAILED (twice-daily sweep → founder inbox), which adds the fourth rule: an unreadable registry must stop the sweep rather than degrade it as the page does — with no registry nothing is "expected", so it would mail an all-clear at the moment it went blind. The fifth is that the agent bearer is per box (BACKUP_AGENT_SECRET_<BOX>, D1a — the shared one is retired, and a box whose value is missing gets 401 and goes quiet, which the alarm reports). D6 answers the question that always comes next: restore is deliberately not a button — it writes into a live database, so it stays deploy/restore-tenant.sh, rehearse-by-default |
| Manual QA logins | workspace docs/runbooks/sofra-test-accounts.md (ADMIN + PARTNER test accounts; never run billing flows from them — the key is live) |
- RBAC:
lib/rbac.ts—requireUser()/requireAdmin()/requirePartner(). Every(control)page AND server action AND route handler guards itself; layouts are chrome, not security boundaries (ADR-008).middleware.tsis locale routing only — it protects nothing. - Auth: Credentials-only, JWT sessions, bcrypt cost 12 (
bcryptjs). Login requiresUser.status='ACTIVE'+ non-nullpasswordHash. Anti-enumeration dummy-hash compare + IP/email rate limits live inlib/auth.ts— keep them. - Mollie webhook (
app/api/webhooks/mollie/route.ts): unsigned by design → never trust the body; re-fetch by id and verify (fetch-and-verify). Activation uses an atomicACTIVATINGclaim + per-planIdempotency-Key; a not-yet-valid mandate returns 503 so Mollie retries (mandate race, PR #13). Money = EUR integer cents. - Tenant registry:
lib/tenant-registry.tsreads the deploy repo'sregistry.yml(bind-mounted:ro,TENANT_REGISTRY_PATH). Read-only seam (ADR-007) — lifecycle changes happen in the deploy repo, never here. - DB: Prisma 7, no Rust engines; connection URL lives in
prisma.config.ts(reads.env.localin dev), runtime adapter inlib/db.ts. - Email: Resend HTTP API (
lib/email.ts); links built fromNEXTAUTH_URL. - Server actions are progressively enhanced: they must work as plain form POSTs (that's what
scripts/e2e-local.mjsexercises, 20 checks). Don't wrap actions in inline client components that break no-JS submission.
Page 200 · component 250 · server action / lib file 200 · type file 150 LOC. Enforced by scripts/check-single-file.mjs: a PostToolUse hook warns in-loop after each edit, and --all mode fails CI (file_length job) on any over-limit file not in scripts/file-length-baseline.txt. Grandfathered files go in that baseline (currently lib/billing.ts); remove a line once refactored under limit. The checker also warns on emails/phones inside console.* (§5.8 PII rule).
- Every new
(control)surface calls itsrequire*()guard first — page, action, and route handler alike. The only exceptions are the five UNAUTHENTICATED recovery surfaces, and they are a closed list rather than a habit:/login,/forgot,/reset/[token],/invite/[token]and/invite/resend(G12). A guard on any of them would lock out the exact person it is meant to let in — someone who has no session, and in the invite case has never had a password. What they carry instead is the SAME obligation in another form: a rate limit (limited()inlib/auth-form.ts), and an answer that does not depend on whether the address exists, so the page cannot be asked "is this restaurant a customer of yours". Adding a sixth means arguing for it in the PR. - Migrations are handwritten SQL one-offs: create
prisma/migrations/<ts>_name/migration.sqlby hand (prisma migrate devrefuses non-TTY here), apply withmigrate deployvia theghcr.io/piwas-21/sofra:migrateimage on the box — never on container start, never edit an applied migration. - Never trust webhook bodies — fetch-and-verify only (§3).
- Dark mode =
.darkclass (TailwinddarkMode: "class").html[data-theme]belongs to the tenant frontend, not here. - Craft tokens only: colors/fonts come from
tailwind.config.ts(craft.*, HSL vars inapp/globals.css) — no ad-hoc hex in components. - All user-visible strings are localized (next-intl message files ×6, keep key parity;
aris RTL on the marketing site — check mirrored layouts). The(control)plane follows the NEXT_LOCALE cookie (sofra #9): pages usecontrolLocale()+getTranslations({locale}), client components useuseTranslationsunder the root-layout provider, and server actions returncontrol.errors/auth.errorsmessage keys rendered by<ActionError />. The control plane stays structurally LTR even underar(strings translated; RTL layout is a separate effort). - Money in EUR integer cents; ledger currency is EUR (NL company).
- No PII in logs — no partner/client emails, names, phones, or Mollie customer ids in console output.
- Env at the edges: secrets only via env (
AUTH_SECRET,DATABASE_URL,MOLLIE_API_KEY,RESEND_API_KEY); never committed, never logged.$values in box.envmust be$$-escaped (compose interpolation). - Registry is read-only from this app (§3).
Output before writing code: (1) which require*() guard covers each new surface; (2) schema change? → migration plan (handwritten SQL + box apply step in the PR's deploy notes); (3) marketing UI string change? → 6-locale parity list; (4) billing-state change? → walk the PENDING→ACTIVATING→ACTIVE machine and say why nothing strands (Mollie only redelivers on non-2xx); (5) sibling-convention check (2–3 neighboring files).
- CI (
.github/workflows/ci.yml, on every PR todevelop/main+ on push tomain; adeveloppush run was dropped 2026-08-16 as a bit-identical duplicate of the release PR's run;cache-warm.ymlruns onpush: developin its place and does nothing but populate ci.yml's.next/Playwright cache keys, because Actions caches are ref-scoped and a PR reads only its own scope + its base branch's): typecheck · eslint · next build · prisma migrations-apply + drift check · vitest unit + coverage floor · i18n parity (6 locales) · file-length · playwright login smoke (sharded 2 ways since 2026-08-17 —--shard=i/2on separate runners, so the whole suite still runs on every PR; the per-shard checks are suffixed and the REQUIRED context is the aggregateplaywright (login smoke)job, green iff both shards pass) · gitleaks · TruffleHog · Trivy fs · npm audit · OSV · semgrep; weeklysecurity-audit.yml. SonarCloud autoscan (no CI job — don't add one). - Review gate ACTIVE in this repo (since 2026-07-07): Stop + PreToolUse + PostToolUse (file-length checker) hooks (
.claude/settings.json) + git pre-push → workspacescripts/review-gate/with thesofra.mdoverlay. No--no-verify, no bypasses. - E2E, unmocked —
npm run test:e2e:full(scripts/e2e-suite.sh) stands up a throwaway Postgres, migrates, seeds, builds and runs the whole Playwright suite: the O2 self-serve funnel (tests/e2e/self-serve-signup.spec.ts, 12 tests), a real Mollie first payment on thetest_key (tests/e2e/billing-mollie.spec.ts) and a real Stripe Connect mint on ansk_test_key (tests/e2e/connect-mint.spec.ts—mintForProposal→createExpressAccount→recordConnectAccount→ the fields a registry entry carries, the chain that had never run in one piece anywhere because staging has noPROVISION_GITHUB_TOKEN; it creates REAL connected accounts and deletes every one, asserted, in anafterAllthat inventories the database AND Stripe rather than what a test remembered to report). Three things to know before touching it: (1) the suite refuses to start on alive_/non-sk_test_key and the billing and mint specs skip with a stated reason when no test key is present — they never silently pass; (2) it uses its own registry fixture, because since O2 an unreadable registry makes the signup fail closed, so an absent one is not a neutral condition; (3) each test gets a uniquex-forwarded-for(tests/e2e/helpers/fixtures.ts) because the intake allows only 5 POSTs/IP/15min — the header, not a loosened limit, is how the suite avoids rate-limiting itself, and the worker index must be part of it or parallel workers collide. Mollie cannot call localhost (it validates reachability and 422s), soMOLLIE_WEBHOOK_URLpoints at an inert sink and the spec POSTs the realtr_id to the local handler — fetch-and-verify still runs against the real API; only the delivery hop is stood in for. /api/health(app/api/health/route.ts) — public, unauthenticated, dependency-free:{status, service, version, builtAt}, whereversionis the commit the image was baked from (DockerfileARG BUILD_SHA←build-image.yml, which then asserts the baked value matchesgithub.sha). It exists so a deployed environment can be told apart from a months-old one; everything else about a stale image looks healthy.status: "ok"means this process serves HTTP and not that the DB is up — liveness and readiness are separate on purpose, because pinging Postgres from a public unauthenticated route is a DoS lever. Do not add fields:tests/e2e/health.spec.tspins the payload to those four keys precisely so a DB status or a Mollie mode cannot be added quietly. The Docker HEALTHCHECK deliberately still probes/en— nothing declaresdepends_on: service_healthy, so the probe's only reader is a human, and a rendered page proves strictly more.indexing-monitor.yml(daily) — guards that production stays crawlable (robots.txt and no de-indexing header; the two live in different places and can disagree) and that every non-canonical copy stays hidden.robots.txtis baked, not runtime —app/robots.tskeys offNEXT_PUBLIC_SITE_URL, a build arg — so a wrong posture takes a rebuild, not a box.envedit.- E2E against DEPLOYED staging —
npm run test:e2e:staging(tests/e2e/staging-live.spec.ts) runs againsthttps://staging.sofrapiwas.com. Disjoint from the local suite by design: it covers only what cannot exist until something is deployed — the box.envreaching the container with the right values, the founder-run:migrate-stagingone-off, Caddy + TLS, and that the deployed bake is the staging one. Read-only by construction (no account, no payment, no row), so there is nothing to restore. NeedsSTAGING_ADMIN='{email: …, password: …}'in the gitignored.env— the quotes are load-bearing, becausescripts/e2e-suite.shsources that same file withset -a && . ./.envand bash reads an unquoted brace value as an assignment plus a command, killing the whole local suite underset -euo pipefail. Two things it deliberately does NOT prove, so don't read them into a green run: that the deployed image is current (no health/version endpoint — a months-old:stagingbake passes), and that the Mollie key istest_rather thanlive_(mollieConfigured()reports only that some key is set). - The
E2E_REMOTEinterlock (playwright.config.ts): remote runs the staging spec and nothing else; local runs everything but the staging spec. Not a convenience — every other spec here mutates (signup rows, real test-key payments, repointed billing records) and reaches its target through relativepage.goto, so an unrestricted remote run would write into the long-lived shared environment, which has no throwaway DB to drop. Keep it enforced in the config rather than as atest.skipin each spec: that is what lets a missing credential be a hard error instead of a skip that exits 0 having verified nothing behind the login. - Tests (DEV-PHASES-PLAN W1/W2):
npm run test= Vitest unit suite over the purelib/modules (format, mollie amount, validation schemas, rate-limit, tenant-registry, email-templates, email helpers — no DB/network; Mollie is never called).npm run test:coverageadds the coverage floor (W2 D9): v8 coverage scoped to those fully-unit-coverable pure modules (vitest.config.tscoverage.include), enforced in CI at ≥95% lines/statements/functions, ≥90% branches — modules with network/DB branches stay out of scope (they need mocks §7 forbids). Raise the floor as coverage grows.npm run test:e2e= Playwright login smoke (admin→/admin, partner→/dashboard, partner-blocked-from-/admin) against a seeded throwaway DB (scripts/seed-e2e.mjs).scripts/e2e-local.mjs(the no-browser progressive-enhancement walk of the partner program; needs a clean local DB — leftover LIVE client collides ontenantSlug) + the QA test accounts remain for manual/full-flow checks.
- GitFlow:
develop= default + integration branch — branch off it and open everyfeature/·fix/·chore/·docs/PR todevelop; merge only when CI is green + comments resolved.main= releases only, via adevelop→mainrelease PR. Both branches are protected by the no-bypassmain-developruleset (blocks direct push; open a PR). Commits:type(scope): description. - Merge ≠ deploy. A merge builds the image; rollout is manual on the staging box:
docker compose -f docker-compose.prod.yml pull sofra && up -d sofra(in/opt/rumi/deploy, viadeploy/.ssh/staging.sh). Schema changes: run the migrate one-off BEFORE rolling the app. - PR body uses
.github/pull_request_template.md— including the NFR triage section (DEV-PHASES-PLAN P1).
- Never exercise billing actions against the live key (create plan/checkout from
/admin/billing, re-POST webhooks with realtr_ids) unless the owner explicitly asks — real money moves. Billing QA = local dev withMOLLIE_API_KEY_TEST. - Never edit an applied migration,
prisma/migrations/*history, or run destructive SQL against the box DB without explicit instruction. - Never commit
.env,.env.local, or any key material;.env.examplegets placeholders only. - Box operations (env edits, rollouts, one-offs) go through the
operating-rumi-infraskill patterns — never improvise SSH commands against the boxes. - Auth.js config (
lib/auth.tsrate limits, dummy-hash, JWT callbacks) andlib/rbac.tsare security-load-bearing — change only with explicit instruction + security-review skill. - Post-merge bot comments (Gemini, SonarCloud) get triaged with rationale on the PR — applied or declined, never ignored.