Skip to content

Latest commit

 

History

History
90 lines (70 loc) · 18.7 KB

File metadata and controls

90 lines (70 loc) · 18.7 KB

Sofra — Agent Rules

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.


§1 — Identity

  • 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), .dark class dark mode (not html[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, image ghcr.io/piwas-21/sofra; Postgres = shared postgres container, DB/role sofra. Infra source of truth: deploy/ repo (DEPLOYMENT.md §Sofra control plane).
  • Workspace context: one of five repos under the rumi-workspace meta-repo — master plan docs/plans/SOFRA-SAAS-PLAN.md, roadmap Track S.

§2 — Where to look

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 disabledBACKUP_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)

§3 — Architecture (load-bearing patterns)

  • RBAC: lib/rbac.tsrequireUser() / requireAdmin() / requirePartner(). Every (control) page AND server action AND route handler guards itself; layouts are chrome, not security boundaries (ADR-008). middleware.ts is locale routing only — it protects nothing.
  • Auth: Credentials-only, JWT sessions, bcrypt cost 12 (bcryptjs). Login requires User.status='ACTIVE' + non-null passwordHash. Anti-enumeration dummy-hash compare + IP/email rate limits live in lib/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 atomic ACTIVATING claim + per-plan Idempotency-Key; a not-yet-valid mandate returns 503 so Mollie retries (mandate race, PR #13). Money = EUR integer cents.
  • Tenant registry: lib/tenant-registry.ts reads the deploy repo's registry.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.local in dev), runtime adapter in lib/db.ts.
  • Email: Resend HTTP API (lib/email.ts); links built from NEXTAUTH_URL.
  • Server actions are progressively enhanced: they must work as plain form POSTs (that's what scripts/e2e-local.mjs exercises, 20 checks). Don't wrap actions in inline client components that break no-JS submission.

§4 — File length limits (workspace defaults)

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).

§5 — Hard rules

  1. Every new (control) surface calls its require*() 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() in lib/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.
  2. Migrations are handwritten SQL one-offs: create prisma/migrations/<ts>_name/migration.sql by hand (prisma migrate dev refuses non-TTY here), apply with migrate deploy via the ghcr.io/piwas-21/sofra:migrate image on the box — never on container start, never edit an applied migration.
  3. Never trust webhook bodies — fetch-and-verify only (§3).
  4. Dark mode = .dark class (Tailwind darkMode: "class"). html[data-theme] belongs to the tenant frontend, not here.
  5. Craft tokens only: colors/fonts come from tailwind.config.ts (craft.*, HSL vars in app/globals.css) — no ad-hoc hex in components.
  6. All user-visible strings are localized (next-intl message files ×6, keep key parity; ar is RTL on the marketing site — check mirrored layouts). The (control) plane follows the NEXT_LOCALE cookie (sofra #9): pages use controlLocale() + getTranslations({locale}), client components use useTranslations under the root-layout provider, and server actions return control.errors/auth.errors message keys rendered by <ActionError />. The control plane stays structurally LTR even under ar (strings translated; RTL layout is a separate effort).
  7. Money in EUR integer cents; ledger currency is EUR (NL company).
  8. No PII in logs — no partner/client emails, names, phones, or Mollie customer ids in console output.
  9. 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 .env must be $$-escaped (compose interpolation).
  10. Registry is read-only from this app (§3).

§6 — Pre-implementation verification (non-trivial work)

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).

§7 — Quality gates

  • CI (.github/workflows/ci.yml, on every PR to develop/main + on push to main; a develop push run was dropped 2026-08-16 as a bit-identical duplicate of the release PR's run; cache-warm.yml runs on push: develop in 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/2 on separate runners, so the whole suite still runs on every PR; the per-shard checks are suffixed and the REQUIRED context is the aggregate playwright (login smoke) job, green iff both shards pass) · gitleaks · TruffleHog · Trivy fs · npm audit · OSV · semgrep; weekly security-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 → workspace scripts/review-gate/ with the sofra.md overlay. No --no-verify, no bypasses.
  • E2E, unmockednpm 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 the test_ key (tests/e2e/billing-mollie.spec.ts) and a real Stripe Connect mint on an sk_test_ key (tests/e2e/connect-mint.spec.tsmintForProposalcreateExpressAccountrecordConnectAccount → the fields a registry entry carries, the chain that had never run in one piece anywhere because staging has no PROVISION_GITHUB_TOKEN; it creates REAL connected accounts and deletes every one, asserted, in an afterAll that 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 a live_/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 unique x-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), so MOLLIE_WEBHOOK_URL points at an inert sink and the spec POSTs the real tr_ 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}, where version is the commit the image was baked from (Dockerfile ARG BUILD_SHAbuild-image.yml, which then asserts the baked value matches github.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.ts pins 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 declares depends_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.txt is baked, not runtime — app/robots.ts keys off NEXT_PUBLIC_SITE_URL, a build arg — so a wrong posture takes a rebuild, not a box .env edit.
  • E2E against DEPLOYED stagingnpm run test:e2e:staging (tests/e2e/staging-live.spec.ts) runs against https://staging.sofrapiwas.com. Disjoint from the local suite by design: it covers only what cannot exist until something is deployed — the box .env reaching the container with the right values, the founder-run :migrate-staging one-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. Needs STAGING_ADMIN='{email: …, password: …}' in the gitignored .envthe quotes are load-bearing, because scripts/e2e-suite.sh sources that same file with set -a && . ./.env and bash reads an unquoted brace value as an assignment plus a command, killing the whole local suite under set -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 :staging bake passes), and that the Mollie key is test_ rather than live_ (mollieConfigured() reports only that some key is set).
  • The E2E_REMOTE interlock (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 relative page.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 a test.skip in 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 pure lib/ modules (format, mollie amount, validation schemas, rate-limit, tenant-registry, email-templates, email helpers — no DB/network; Mollie is never called). npm run test:coverage adds the coverage floor (W2 D9): v8 coverage scoped to those fully-unit-coverable pure modules (vitest.config.ts coverage.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 on tenantSlug) + the QA test accounts remain for manual/full-flow checks.

§8 — Git workflow

  • GitFlow: develop = default + integration branch — branch off it and open every feature/·fix/·chore/·docs/ PR to develop; merge only when CI is green + comments resolved. main = releases only, via a developmain release PR. Both branches are protected by the no-bypass main-develop ruleset (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, via deploy/.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).

§9 — AI guardrails (refusal list)

  • Never exercise billing actions against the live key (create plan/checkout from /admin/billing, re-POST webhooks with real tr_ ids) unless the owner explicitly asks — real money moves. Billing QA = local dev with MOLLIE_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.example gets placeholders only.
  • Box operations (env edits, rollouts, one-offs) go through the operating-rumi-infra skill patterns — never improvise SSH commands against the boxes.
  • Auth.js config (lib/auth.ts rate limits, dummy-hash, JWT callbacks) and lib/rbac.ts are 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.