A reputation management platform built with TanStack Start, Better Auth, Drizzle ORM, and PostgreSQL.
Proprietary software. Copyright © 2026 Fortunatus EOOD. All rights reserved. Reputation Key is not open source: no license is granted to use, copy, modify or distribute this code. See LICENSE.
Authority: docs/BETA.md is the one page that governs the closed beta — what it is, what we owe Google and users, what the product does, and which rules are enforced by which test. It outranks every other document here.
# 0. Use the pinned Node runtime — 22.23.4, exactly (.nvmrc)
fnm use # or: nvm use
pnpm local:doctor # Docker, published stack ports, stale containers, VM headroom
# 1. Install dependencies
pnpm install
# 2. Set up environment
cp .env.example .env.local
# Edit .env.local with your DATABASE_URL and BETTER_AUTH_SECRET
# 3. Set up the database through the production-equivalent authority
# (pinned Better Auth schema, the Drizzle journal, and provider-subject
# initialization). DEPLOY_MIGRATE=1 is the explicit local/CI authority;
# Railway deployments use platform identity instead.
# On an empty database, also set one sealed
# REVIEW_PROVIDER_SUBJECT_HMAC_MIGRATOR_KEYS entry in .env.local.
DEPLOY_MIGRATE=1 pnpm db:migrate-deploy
# 4. Generate auth secret (if not set)
node --input-type=module -e "import { randomBytes } from 'node:crypto'; console.log(randomBytes(32).toString('base64url'))"
# 5. Start dev server
pnpm devTwo migration systems run in a fixed order, both behind one command:
DEPLOY_MIGRATE=1 pnpm db:migrate-deployIt provisions the pinned Better Auth tables, applies the Drizzle journal, performs provider-subject initialization, and then applies the declared capability posture (a no-op unless GOOGLE_CONTENT_CAPABILITIES_ALLOWED / AI_CAPABILITIES_ENABLED are set — see "Capability kill switches after a database reset" below). Railway invokes the same runner under platform identity instead of setting DEPLOY_MIGRATE. Use this same journaled workflow in development, CI and production. Never run pnpm db:push against this schema — it bypasses the authoritative journal and conceals deploy-time drift.
- Auth schema change: edit
src/shared/auth/org-schema.ts(the single source foradditionalFields), thenpnpm auth:migrate. - Business schema change: edit
src/shared/db/schema/, write the migration by hand as the nextdrizzle/NNNN_<name>.sql, append its entry todrizzle/meta/_journal.json, then runDEPLOY_MIGRATE=1 pnpm db:migrate-deployandpnpm check:schema-drift. The journal is history: the deployed database has applied it and keeps its data, so never regenerate it.pnpm db:baselinerefuses while an appended migration exists. Steps and journal rules:src/shared/db/CONTEXT.md, "Change loop".
CI runs pnpm db:migrate-deploy (.github/workflows/ci.yml, Predeploy migration parity). Schema authority and current deploy order: src/shared/db/CONTEXT.md.
- Web app: TanStack Start (React + SSR) —
pnpm dev/pnpm build/pnpm start - Worker: Plain Node.js script — built by
pnpm build, run withpnpm start:worker - Database: PostgreSQL via Drizzle ORM
- Auth: Better Auth with DB-backed sessions
- Redis: Optional in basic development; physically separate Cache Redis and Queue Redis are required in production
| Command | Description |
|---|---|
pnpm dev |
Start dev server on :3000 |
pnpm local:up |
Inner loop: services in Compose, web (HMR) + worker on the host |
pnpm local:down |
Remove the local services and their volumes |
pnpm build |
Build the web, worker, and migration bundles |
pnpm start |
Run built web server |
pnpm start:worker |
Run built worker |
pnpm test:unit |
Run unit tests |
pnpm test:e2e |
Run Playwright E2E tests |
pnpm typecheck |
TypeScript check: src + e2e, tooling (tsconfig.scripts.json), project-coverage guard |
pnpm lint |
ESLint + filename/component-boundary checks |
pnpm lint:ci |
lint + test-quality, Google ids, env contract, migration-journal, length/index ratchets |
pnpm format |
Prettier format |
Both stacks share the committed e2e/stack.env and the same Compose services:
Postgres, Redis, the TLS Google Business Profile sandbox, the AI provider stub
and the mail stub. scripts/e2e/stack-services.sh issues a run-scoped local CA
with openssl into the ignored e2e/.certs/ (the sandbox is TLS-only,
ADR 0050), starts the services, runs the deploy migration and the e2e seed.
Nothing here talks to real Google, OpenAI or Resend.
Inner loop - pnpm local:up. The web dev server (HMR) and the worker run
on the host against those services; every feature runs, including Google
import/sync through the sandbox, the AI pipeline (the stub synthesizes answers
for unscripted requests) and email. http://127.0.0.1:3000; sign in as
test@example.com (password E2E_TEST_PASSWORD in e2e/stack.env) or
staff@example.com / password123. To sign in as yourself, set
LOCAL_ADMIN_EMAIL (and optionally LOCAL_ADMIN_NAME, LOCAL_ADMIN_PASSWORD):
every seed makes that address an AccountAdmin of the seeded organization, with
the test@example.com password unless you set one. Ctrl-C stops both processes;
pnpm local:down removes the services and their volumes. The host processes
resolve the Compose service names to loopback through
scripts/local/loopback-hosts.mjs because local-provider-fetch.ts
deliberately compiles the AI stub address in. Mirroring the browser console
into the terminal is off, because it holds one of the browser's six connections
to the app per open tab; set REPKEY_DEVTOOLS_CONSOLE_PIPE=1 (for example in
local.env) to turn it on while you keep few tabs open.
Your settings in one place - ~/.config/repkey/local.env. Every checkout
and worktree reads this file first, then its own gitignored local.env, which
overrides it. Keep real provider credentials and LOCAL_ADMIN_EMAIL there once,
instead of copying local.env into each new worktree. The web server listens on
the port of BETTER_AUTH_URL, so a second stack can run on another origin; add
its callback to the OAuth client's redirect URIs if it uses real Google.
Beta features for an Organization you create by hand. The seeded local
admin joins the allowlisted e2e Organization, so Google import, Portals, Goals,
AI and notification email work out of the box. An Organization you create
yourself is not on BETA_ALLOWLIST_ORGS and every one of those refuses with
"not enabled for your organization yet". Add its ID to the list in local.env.
Avoid * on a stack you also run e2e against: it lights up the deliberately
dark "locked" Organization those tests rely on.
Capability kill switches after a database reset. Below the allowlist sit
two database-held kill switches: a fresh database seeds every Google content
capability denied (Connect Google then fails with capability_killed) and
every AI capability killed. The local seed lifts them for you. A deployed
environment declares them instead, on web:
GOOGLE_CONTENT_CAPABILITIES_ALLOWED=* and AI_CAPABILITIES_ENABLED=* (or
comma lists). Web's deploy step applies the declaration right after
migrations, so a reset plus a deploy comes back working. It lifts only an
untouched seed default: a denial or kill an operator made stays, and the deploy
log says so in one [declared-posture] line. Unset changes nothing; an unknown
name fails the deploy (ADR 0032, amended 2026-09-29).
Real Google - local.env + REPKEY_LOCAL_GOOGLE=real. To connect a real
Google account and import real locations from this stack, copy
local.env.example to local.env (gitignored) and set the real OAuth
credentials. pnpm local:up then drops every provider-sandbox endpoint pin,
selects Google's approved endpoints (production-fixed), and prints
Google: LIVE. Nothing about that path is production-only: outside
NODE_ENV=production the four provider keyrings derive local fallbacks from
OAUTH_STATE_SECRET and the opaque OAuth state lives in an in-memory
provider-ephemeral store.
One Google-side prerequisite, once: an OAuth 2.0 Web application client in the same Google Cloud project as production (the Business Profile API allowlist is per project) whose authorised redirect URIs include exactly
http://127.0.0.1:3000/api/auth/google/callback
The stack sends response_type=code, scope=openid https://www.googleapis.com/auth/business.manage, access_type=offline,
prompt=consent, PKCE S256 and an opaque state handle - the same request the
deployed cell sends. Without the redirect URI registered, Google answers
Error 400: redirect_uri; a wrong client answers invalid_client.
Real API quota is consumed and real reviewer data lands in the local database;
pnpm local:down deletes that volume. Re-run the import workflow only on a
fresh stack: a portal you created binds the property, and the fixture cleanup
cannot delete a bound property. Pub/Sub notifications cannot reach
localhost, so review updates arrive through the periodic sync rather than a
push. Real AI - REPKEY_LOCAL_AI=real. Independent of the Google posture: the
runner drops AI_PROVIDER_LOCAL_STUB so the gateway builds its pinned
api.openai.com connector, and refuses to start unless OPENAI_API_KEY looks
real, naming the model snapshot the account must be able to call. Analysis,
reply drafting and trends then run on the live model; the banner prints
AI: LIVE. Spend is bounded by the organisation cost window
(ai_organization_cost_windows) and recorded per operation. Verified locally:
a reply draft and a worker analysis both settled succeeded for 446 micros
total, and the live model classified a review mixed / wait_time / medium
where the stub's rating-keyed answer is neutral / service.
In sandbox mode, "Connect Google" cannot work: the authorize host is pinned
to accounts.google.com by the provider contract and the sandbox client id is a
placeholder, so Google answers Error 401: invalid_client. The stack prints that
warning at startup. The seed binds no property to Google, and the sandbox has no default scope.
To get a Google-bound property with synced reviews for the AI features, run
the import workflow against the running stack:
E2E_EXTERNAL_STACK= NODE_EXTRA_CA_CERTS=e2e/.certs/ca.crt pnpm test:e2e --project=critical e2e/critical/workflows/google-import-sync.spec.tsThen, in the app: Settings → AI & replies → that property (reply language, enable AI), and a portal's Settings tab for the public display name a reply draft must use.
Pre-merge - pnpm e2e:stack:up. The containerised production build
(NODE_ENV=test, E2E=1) on the same services - what CI's e2e job runs.
Use it to click through a build or to run the suite; the suite is not meant
for the dev server (production server-function ids, the auth rate limit):
pnpm e2e:stack:up
pnpm test:e2e --project=critical
pnpm e2e:stack:downUse a fresh volume and image lifecycle on every pass when reproducing a flake:
for i in 1 2 3; do pnpm e2e:stack:down && pnpm e2e:stack:up && pnpm test:e2e --project=critical; doneDeploy - pnpm ops deploy-ci-images --sha <main sha> --apply once main
is green; CI has already run the same stack, so deploying is not a test step.
It ends by asking Google whether it accepts the beta's "Connect Google": the
callback ${BETTER_AUTH_URL}/api/auth/google/callback must be an authorised
redirect URI on the OAuth client, and the client secret must be current. A
refusal (Google's Error 400: redirect_uri_mismatch page, which the app never
sees) fails the command with the exact URI to register and the console link.
Run the same check alone with pnpm ops check-google-oauth. It also makes sure the
object-store bucket's CORS rule lets the app origin PUT (avatar and logo
uploads); run that alone with pnpm ops storage-cors [--apply].
A commit whose main CI went green only after Re-run failed jobs
(gh run rerun --failed) deploys too. That re-run keeps the images and the
digest map an earlier attempt of the same run produced, and the deploy takes
that map when the run's latest attempt is green and no later attempt re-ran an
image job (docker image group (…) or docker); such a re-run writes a newer
map, and the newer map is the one deployed. A map from another run or another
commit, or a run whose latest attempt is red, is still refused
(scripts/ops/ci-image-digest-map.ts). Re-run the failed jobs, not the whole
workflow: a full re-run rebuilds the images, and when a rebuilt digest differs
from the one already tagged for the commit, docker fails with
immutable image tag has a conflicting digest and the commit cannot deploy.
An e2e failure that passed on the re-run gets a row in
docs/operations/e2e-flake-register.md.
Husky runs one gate: pre-commit — lint-staged (eslint --fix + prettier
--write on staged files). There is no pre-push hook (retired 2026-09-03 as a
duplicate of CI); pnpm typecheck and pnpm lint:ci run in CI.
Install hooks after cloning: pnpm install (the prepare script registers Husky automatically).
src/
├── contexts/ # Bounded business domains (ten packages; see root CONTEXT.md)
│ └── <name>/ # Each has: domain/, application/, infrastructure/, server/
├── components/ # React UI
│ ├── ui/ # shadcn primitives
│ ├── forms/ # shared form blocks (SubmitButton, FormErrorBanner, etc.)
│ ├── layout/ # app shell (sidebars, header, top bar)
│ ├── hooks/ # shared hooks (useMutationAction, useAction, usePropertyId)
│ └── features/ # domain-concept folders (portal/, identity/, property/, inbox/, etc.)
├── shared/ # Cross-cutting infrastructure
│ ├── auth/ # Better Auth config, middleware, permissions
│ ├── cache/ # Redis client + cache port/impl
│ ├── config/ # Zod-validated env schema
│ ├── db/ # Drizzle ORM, pool, schema/, migrations
│ ├── domain/ # Brand types, IDs, roles, permissions, clock, Result
│ ├── events/ # Master DomainEvent union and outbox schema registrations
│ ├── jobs/ # BullMQ queue, worker, registry
│ ├── hooks/ # usePermissions
│ ├── observability/ # Pino logger, request tracing (tracedHandler)
│ ├── rate-limit/ # Rate limiting middleware
│ └── testing/ # In-memory port fakes, test fixtures
├── routes/ # TanStack Router file-based routes
│ └── _authenticated/ # Protected routes with layout shell
├── hooks/ # Low-level utility hooks (use-as-ref, use-lazy-ref)
├── lib/ # Shared utilities (utils, compose-refs, lookups)
├── composition.ts # Dependency wiring
├── bootstrap.ts # Event/job handler registration
└── worker/ # Background worker entry point