From 7b78d9b48deff043c53ee6ea4439dbeda8389338 Mon Sep 17 00:00:00 2001 From: Kunj Hirapara Date: Sun, 13 Sep 2026 21:38:27 +0530 Subject: [PATCH 1/2] docs: never read the values of production secrets Two disclosures happened in one session, both while answering the question "is this variable set?", and both avoidable. `npx convex env list --prod` was run to check for one variable and printed every production value, including AUTH_JWT_PRIVATE_KEY -- a key that mints a valid Convex token for any user. Five secrets needed rotating. A per-variable existence check was then handed over as a multi-line command. It wrapped when pasted, which split `[ -n` from its `]`; the test became a syntax error and the shell executed each value as a command, printing the Google and GitHub OAuth client secrets. Two more rotations. So the rule is that values are never read, existence is checked only when it is genuinely necessary, and .env.example is the reference for names and shapes. The allowed forms are written down alongside the forbidden ones, because "be careful" is not a procedure -- the second incident happened while being careful. Includes the one-line constraint, which is not a style preference: it is the difference between a check that prints names and the same check printing secrets. --- CLAUDE.md | 82 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..28bb22c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,82 @@ +# Working in this repo + +## Production secrets: never read their values + +**Do not read, print, or otherwise cause the display of the value of any +production environment variable or secret.** This applies to the Convex +deployment, the VM's `.env`, the container environment, Portainer, GitHub +Actions secrets, and anything else holding real credentials. + +Checking that a variable **exists** is allowed — but only when it is genuinely +necessary to answer the question at hand, and only in a form that cannot print +the value. Otherwise work from `.env.example`, which carries every variable +name, its shape, and a note on what it is for. That file is the reference; the +real values are not. + +### Forbidden + +These print values. Do not run them, or any variation of them, against +production: + +``` +npx convex env list --prod +npx convex env get --prod +docker exec env +docker exec printenv +docker inspect # .Config.Env contains every value +cat .env / cat .env.local / cat .env.production +echo "$SOME_SECRET" +``` + +Reading `.env.example` is fine and encouraged — it contains only placeholders. + +### Allowed, when necessary + +Prefer the application's own report, which is designed for this and publishes a +boolean rather than names: + +``` +curl -s https://commit.kunjdeveloper.com/api/health +``` + +`integrations.auth` says whether sign-in is configured. When it is false, the +**container log** names what is missing — the endpoint deliberately does not, +because it is public. See `src/lib/auth/readiness.ts`. + +If a per-variable existence check is genuinely required, it must print names +only, and it must be **one line**: + +``` +docker exec commit sh -c 'for v in AUTH_SECRET AUTH_JWT_KID; do [ -n "$(printenv $v)" ] && echo "$v set" || echo "$v MISSING"; done' +``` + +Never break a command like that across lines when handing it to someone to +paste. A wrapped `[ -n` is split from its `]`, the test becomes a syntax error, +and the shell then executes each **value** as a command — printing the secrets +it was written to avoid printing. + +### Why this rule exists + +Both of these happened in this repo, in a single session: + +1. `npx convex env list --prod` was run to check whether a variable was set. It + printed every production value, including `AUTH_JWT_PRIVATE_KEY` — a key that + can mint a valid Convex token for any user. Five secrets needed rotating. +2. A multi-line existence check was pasted into a terminal, wrapped, and + executed the values. The Google and GitHub OAuth client secrets were + disclosed and needed rotating. + +Neither was necessary. In both cases the question was "is this set?", and in +both cases the answer was available without the value — from `/api/health`, from +the application's own behaviour, or from a correctly written one-line check. + +A leaked secret cannot be unleaked. It has to be rotated, on a live system, +usually in a hurry. The cost of being careful here is a few seconds; the cost of +not being is an unplanned rotation of production credentials. + +## Related + +- `.env.example` — every variable, with placeholders and notes. +- `src/lib/auth/readiness.ts` — what "configured" means for auth, and why the + health endpoint reports a boolean rather than a list of names. +- `docs/HANDOFF.md` — deployment topology, and the owner-only actions. From ec2d12404e4ee4d1f251b304377a0a790e7d272c Mon Sep 17 00:00:00 2001 From: Kunj Hirapara Date: Sun, 13 Sep 2026 21:43:07 +0530 Subject: [PATCH 2/2] docs: add commands and architecture to CLAUDE.md Extends the secrets policy into the full onboarding file, covering the things that take reading several files to work out and are expensive to learn by being bitten. Commands, including the two surprises: `npm run lint` is tsc, not ESLint -- there is no ESLint config in this repo -- and the test runner is bare `node --test` with type stripping, so it resolves neither `@/` aliases nor extensionless specifiers. Tests import with relative paths and explicit .ts extensions, which is also why convex/lib holds import-free modules; that pattern looks arbitrary until you know what the runner cannot do. Architecture, limited to what is not discoverable from one file: The three runtimes and what each forbids -- Node, Edge middleware, and Convex's V8 isolate, which is why adapterAuth hand-rolls a constant-time compare. The Auth.js/Convex seam, and the four ways to break it: an issuer that differs by a trailing slash, an env var Convex requires statically because auth.config names it, an AUTH_URL set to the empty string, and the pairing between allowDangerousEmailAccountLinking and the signIn callback that makes it safe. The three ids for one person. Passing a session id where Stream expects streamUserId does not error -- it mints a valid token for a user Stream has never seen, and their recordings are simply gone. Deployment, where publishing is not shipping and Portainer keeps its own copy of docker-compose.yml, so repo changes to that file do not reach the VM and env vars in the VM's .env reach nothing unless named in Portainer's copy. Also notes that README's auth section still describes Clerk. --- CLAUDE.md | 151 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 145 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 28bb22c..9bf7c9e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,141 @@ -# Working in this repo +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +npm run dev # Next.js dev server +npx convex dev # Convex backend — needs its own terminal, and dev needs both +npm run typecheck # tsc --noEmit +npm run lint # ALSO tsc --noEmit. There is no ESLint here. +npm test # node --experimental-strip-types --test "src/**/*.test.ts" +npm run build # next build +npm run ci:validate # typecheck + test + build — what CI runs +``` + +Run one test file, or one case: + +```bash +node --experimental-strip-types --test src/lib/auth/linking.test.ts +node --experimental-strip-types --test --test-name-pattern "refuses" src/lib/auth/linking.test.ts +``` + +### The test runner does not understand this project's imports + +It is bare `node --test` with type stripping — no bundler, no path mapping. Test +files and anything they reach must therefore use **relative paths with explicit +`.ts` extensions**: + +```ts +import { mayLinkToExistingUser } from "./linking.ts"; // yes +import { RETENTION_DAYS } from "../../../convex/lib/retention.ts"; // yes +import { siteUrl } from "@/lib/siteUrl"; // fails: ERR_MODULE_NOT_FOUND +``` + +`@/…` aliases and extensionless specifiers work everywhere Next compiles, and +nowhere the test runner looks. This is the reason for the `convex/lib` pattern +described below. + +## Architecture + +### Three runtimes, and what each cannot do + +Code lands in one of three places, and the constraints are not interchangeable: + +1. **Next.js server (Node)** — route handlers, server components, `src/auth.ts`. + Full Node: native modules, `node:crypto`, sockets. +2. **Next.js middleware (Edge)** — `src/middleware.ts`. No native modules, no + `node:` builtins. It imports `src/auth.config.ts` and nothing else from the + auth stack; importing `src/auth.ts` fails the build, which is the point. +3. **Convex functions** — a V8 isolate, not Node. `node:crypto` is not something + to rely on there (see `convex/lib/adapterAuth.ts`, which hand-rolls a + constant-time compare for this reason). + +### Auth: Auth.js owns sessions, Convex owns authorization + +Auth.js (v5) holds the session in a cookie. Convex has never heard of that +cookie. They meet at exactly one seam: + +`/api/auth/convex-token` mints a short-lived RS256 JWT → the client bridge in +`ConvexAuthProvider` attaches it → Convex verifies it against +`/.well-known/jwks.json`, registered as a `customJwt` provider in +`convex/auth.config.ts`. + +Consequences worth knowing before changing anything here: + +- **`issuer` must match exactly.** `convex/auth.config.ts` uses `SITE_URL` on the + Convex deployment; the token route uses `NEXT_PUBLIC_APP_URL`. A trailing + slash or an http/https mismatch rejects every token, and the symptom is "you + must be signed in" shown to people who are. +- **Convex statically requires any env var named in `auth.config.ts`**, whether + or not the code path runs. A conditional guard does not exempt it — a missing + one fails `convex deploy`. +- **`AUTH_URL` must never be empty.** `@auth/core` does + `trustHost ??= !!(AUTH_URL ?? ...)`, and `??` falls through only on + null/undefined — so an empty string yields `trustHost: false` and every Auth.js + route answers `UntrustedHost`, rendered as "problem with the server + configuration". An empty variable is worse than an absent one. +- **`src/auth.config.ts` sets `allowDangerousEmailAccountLinking` on both OAuth + providers**, and that is only safe because the `signIn` callback in + `src/auth.ts` enforces `mayLinkToExistingUser` first. These are a pair. + Weakening either one turns the flag into what its name says. + +Authorization is **not** decided in middleware or on the client. Every Convex +function re-checks with `requirePermission`; `useUserRole` exists to hide +affordances the server would reject anyway. + +### Identity: three different ids for the same person + +This is the most common source of silent bugs. + +| | | +|---|---| +| `users._id` | Convex document id. What an Auth.js session subject carries. | +| `users.clerkId` | Historical app id. Referenced by `interviewerIds`, `candidateId`, `auditLogs.actorClerkId`. For Auth.js-created users it equals `_id`. | +| `users.streamUserId` | The id **Stream** knows them by — always the original Clerk id. | + +Anything talking to Stream must go through `resolveStreamUserId` +(`src/lib/auth/streamIdentity.ts`). Passing the session id instead does not +error: it mints a valid token for a user Stream has never seen, and their calls +and recordings are simply absent. + +`convex/lib/subjectResolution.ts` is the one place that turns a token subject +into a user row, and it tries the document id, then `by_clerk_id`, then +`by_legacy_clerk_id`, because a token may come from either provider at any point +in the migration. + +### `convex/lib/*` exists so Convex logic can be tested + +Convex functions cannot be imported by the test runner. Anything worth testing +is therefore extracted into `convex/lib/` as a module with **no imports at all** +(`retention.ts`, `owner.ts`, `permissions.ts`, `subjectResolution.ts`), and +tested from `src/` via a relative path. A module in `convex/lib` that imports a +sibling becomes untestable, because the runner cannot resolve the extensionless +specifier. + +### Code execution + +`/api/execute` spawns sibling containers on the host Docker socket — they are +not children of the app container and are not covered by its limits. Caps live +in `src/lib/docker-runner.ts`. The gate in front of it is a verified email, which +is what stands between "anyone on the internet" and "spawns containers on the +host". + +### Deployment: publishing is not shipping + +CI builds and pushes an image to GHCR and deploys Convex **before** pushing the +image, so the backend is always live before the frontend that depends on it. +Nothing pushes to the VM: Watchtower is meant to pull. `verify-rollout` in +`.github/workflows/deploy.yml` exists because a green build once meant nothing +had actually been deployed for weeks, and it now also fails when the running +image reports it cannot sign anyone in. + +The stack is **Portainer-managed**. Portainer keeps its own copy of +`docker-compose.yml`, so repo changes to that file do not reach the VM until +someone updates the stack in Portainer's UI. Env vars only reach the container +if named in that compose file — putting them in the VM's `.env` alone does +nothing. ## Production secrets: never read their values @@ -71,12 +208,14 @@ both cases the answer was available without the value — from `/api/health`, fr the application's own behaviour, or from a correctly written one-line check. A leaked secret cannot be unleaked. It has to be rotated, on a live system, -usually in a hurry. The cost of being careful here is a few seconds; the cost of -not being is an unplanned rotation of production credentials. +usually in a hurry. ## Related - `.env.example` — every variable, with placeholders and notes. -- `src/lib/auth/readiness.ts` — what "configured" means for auth, and why the - health endpoint reports a boolean rather than a list of names. -- `docs/HANDOFF.md` — deployment topology, and the owner-only actions. +- `docs/HANDOFF.md` — deployment topology, owner-only actions, and the decisions + behind them. +- `docs/superpowers/plans/2026-09-12-custom-auth.md` — the Clerk→Auth.js + migration, including a list of where the plan turned out to be wrong. +- `README.md` — local setup and Docker runtime images. Its auth section still + describes Clerk and is out of date.