Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 1 addition & 13 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,7 @@ FEATURE_FLAGS=emailDeliveryApi=true,strictApiRateLimiting=true,telemetryIngestio
NEXT_PUBLIC_FEATURE_FLAGS=integrityDeterrentMode=false

# -----------------------------------------------------------------------------
# Clerk authentication
# -----------------------------------------------------------------------------

NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_replace_me
CLERK_SECRET_KEY=sk_test_replace_me
CLERK_WEBHOOK_SECRET=whsec_replace_me

# Set this on the Convex deployment with `npx convex env set CLERK_ISSUER_URL ...`.
# Keep it here only as a setup checklist item.
CLERK_ISSUER_URL=https://your-clerk-issuer.clerk.accounts.dev

# -----------------------------------------------------------------------------
# Auth.js (replacing Clerk — see docs/superpowers/plans/2026-09-12-custom-auth.md)
# Auth.js — the only authentication provider
# -----------------------------------------------------------------------------

# Signs the short-lived JWT that Convex verifies. Generate a keypair with:
Expand Down
7 changes: 3 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,8 @@ jobs:
runs-on: ubuntu-latest
env:
NEXT_PUBLIC_CONVEX_URL: https://example.convex.cloud
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: pk_test_placeholder
NEXT_PUBLIC_STREAM_API_KEY: stream_test_placeholder
STREAM_SECRET_KEY: stream_secret_placeholder
CLERK_WEBHOOK_SECRET: webhook_secret_placeholder
SMTP_HOST: smtp.example.com
SMTP_PORT: "587"
SMTP_USER: smtp-user
Expand All @@ -41,5 +39,6 @@ jobs:

- name: Unit tests
run: npm run test
# Production build is validated in deploy.yml with real secrets;
# running it here with placeholder keys fails Clerk's prerender validation.
# Production build is validated in deploy.yml with real secrets. It is not
# repeated here: prerendering reaches code that reads AUTH_* at request
# time, and placeholder values make it fail for reasons CI cannot act on.
1 change: 0 additions & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@ jobs:
ghcr.io/${{ github.repository }}:latest
ghcr.io/${{ github.repository }}:${{ github.sha }}
build-args: |
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }}
NEXT_PUBLIC_CONVEX_URL=${{ secrets.NEXT_PUBLIC_CONVEX_URL }}
NEXT_PUBLIC_STREAM_API_KEY=${{ secrets.NEXT_PUBLIC_STREAM_API_KEY }}
NEXT_PUBLIC_APP_URL=${{ secrets.NEXT_PUBLIC_APP_URL }}
Expand Down
4 changes: 0 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,11 @@ yarn-error.log*

# vercel
.vercel
.clerk

# typescript
*.tsbuildinfo
next-env.d.ts

# clerk configuration (can include secrets)
/.clerk/

# convex local state
.convex/

Expand Down
18 changes: 11 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,18 +92,23 @@ 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. |
| `users.clerkId` | The **internal user id**, despite the name. Referenced as a plain `v.string()` by `interviewerIds`, `candidateId`, `auditLogs.actorClerkId`. For Auth.js-created users it equals `_id`; for the accounts that predate the migration it is still their original Clerk id. |
| `users.streamUserId` | The id **Stream** knows them by — whatever it was when their first call was created. |

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.
into a user row. It is a single `db.get` on the document id: Auth.js is the only
provider in `convex/auth.config.ts`, so that is the only thing a subject can be.
The `by_clerk_id` / `by_legacy_clerk_id` fallbacks went with Clerk — no token in
existence carries a Clerk id, so those reads could only ever miss.

`clerkId` is deliberately **not** dropped. It stopped being an authentication
identifier when Clerk was removed, but it is still the id the rest of the
database references, so renaming it is a data migration of its own.

### `convex/lib/*` exists so Convex logic can be tested

Expand Down Expand Up @@ -217,5 +222,4 @@ usually in a hurry.
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.
- `README.md` — local setup and Docker runtime images.
4 changes: 1 addition & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,11 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . .

# NEXT_PUBLIC_* vars are embedded into the client bundle at build time
ARG NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
ARG NEXT_PUBLIC_CONVEX_URL
ARG NEXT_PUBLIC_STREAM_API_KEY
ARG NEXT_PUBLIC_APP_URL

ENV NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY \
NEXT_PUBLIC_CONVEX_URL=$NEXT_PUBLIC_CONVEX_URL \
ENV NEXT_PUBLIC_CONVEX_URL=$NEXT_PUBLIC_CONVEX_URL \
NEXT_PUBLIC_STREAM_API_KEY=$NEXT_PUBLIC_STREAM_API_KEY \
NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL \
NEXT_TELEMETRY_DISABLED=1
Expand Down
82 changes: 60 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Commit 💻

Commit is a modern, real-time technical interviewing platform built with [Next.js](https://nextjs.org), [Convex](https://convex.dev/), [Clerk](https://clerk.com/), and [Stream](https://getstream.io/).
Commit is a modern, real-time technical interviewing platform built with [Next.js](https://nextjs.org), [Convex](https://convex.dev/), [Auth.js](https://authjs.dev/), and [Stream](https://getstream.io/).

It offers real-time video, collaborative code execution, structured feedback scorecards, and scheduling tools to make technical interviewing seamless and professional.

Expand All @@ -10,7 +10,7 @@ It offers real-time video, collaborative code execution, structured feedback sco

- **Real-Time Video Intervews:** Powered by Stream with customizable rooms, host controls, and health metrics.
- **Live Collaborative Code Editor:** Secure code execution environment for Python, JavaScript, and Java using Monaco Editor and Docker.
- **Identity & Roles:** Secure authentication via Clerk with a robust Hybrid RBAC (Role-Based Access Control) system.
- **Identity & Roles:** Self-hosted authentication via Auth.js — Google, GitHub, email magic links and passwords — with a Hybrid RBAC (Role-Based Access Control) system.
- **Interactive Dashboards:** Comprehensive pipelines, schedules, and analytics powered by Convex's reactive datastore.
- **Structured Feedback Scorecards:** Blind-grading, weighted scoring, and internal candidate packet drafting.
- **Automated Notifications:** Email and in-app notifications with timezone-awareness and retry support.
Expand Down Expand Up @@ -68,17 +68,32 @@ Ensure you have the following installed on your local machine:

### 2. Set Up Environment Variables

Create a `.env.local` file in the root of the project. Your environment variables should include keys for Clerk, Convex, Stream, and SMTP (optional for local dev).
Copy `.env.example` to `.env.local` — it is the authoritative list, with a note
on every variable and how to generate the ones that need generating. The
essentials:

```env
# Convex
NEXT_PUBLIC_CONVEX_URL=your_convex_url

# Clerk
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=your_clerk_pub_key
CLERK_SECRET_KEY=your_clerk_secret_key
# Required for user syncing across Convex
CLERK_WEBHOOK_SECRET=your_clerk_webhook_secret
# Auth.js — see .env.example for the full set and how to generate each one
AUTH_SECRET=openssl rand -base64 32
AUTH_URL=http://localhost:3000
AUTH_ADAPTER_SECRET=a long random string, also set on the Convex deployment
AUTH_JWT_KID=k1
AUTH_JWT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----
...
-----END PRIVATE KEY-----
"
AUTH_JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----
"
# OAuth providers are optional locally; email + password works without them.
AUTH_GOOGLE_ID=...
AUTH_GOOGLE_SECRET=...
AUTH_GITHUB_ID=...
AUTH_GITHUB_SECRET=...

# Stream (Video & Chat)
NEXT_PUBLIC_STREAM_API_KEY=your_stream_api_key
Expand Down Expand Up @@ -225,16 +240,34 @@ access — but if you would rather not have a second container holding it, delet
the service and add `0 4 * * * docker image prune -f` to the host crontab
instead. Set `IMAGE_GC_INTERVAL_SECONDS` to change the cadence.

### Clerk + Convex Auth
### Auth.js + Convex

Convex validates Clerk JWTs against the issuer configured in `convex/auth.config.ts`. Set this on the Convex deployment itself, not only in `.env.local` or your Docker/Portainer environment:
The Next server mints a short-lived RS256 JWT for the signed-in user. Convex
verifies it against the public half, which the app serves at
`/.well-known/jwks.json`; `convex/auth.config.ts` registers exactly one
`customJwt` provider pointing there.

Two variables must be set **on the Convex deployment itself**, not only in
`.env.local` or your Docker/Portainer environment:

```bash
npx convex env set CLERK_ISSUER_URL https://your-clerk-issuer
npx convex env set SITE_URL https://your-public-origin
npx convex env set AUTH_ADAPTER_SECRET the-same-value-the-app-has
npx convex deploy
```

Use the issuer from the Clerk JWT template used for Convex, and keep the JWT template audience/application ID as `convex`. If this value points at a development Clerk instance while the deployed frontend uses production Clerk keys, Convex will reject browser tokens with `No auth provider found matching the given token`.
`SITE_URL` is what Convex derives the issuer and JWKS URL from, so it has to be
the origin the browser actually reaches — if it points somewhere else, Convex
rejects every token with `No auth provider found matching the given token`.
`AUTH_ADAPTER_SECRET` guards `convex/authAdapter.ts`, which Auth.js calls to
create and read users; it must be identical on both sides or every sign-in
fails.

`AUTH_URL` deserves its own warning. Auth.js only trusts the request host when
`AUTH_URL`, `AUTH_TRUST_HOST`, `VERCEL` or `CF_PAGES` is set — and it uses `??`,
so setting `AUTH_URL` to the **empty string** stops that chain at the first term
and yields `false`, at which point every auth route answers 500. An empty value
is strictly worse than an absent one. `/api/health` checks for exactly this.

---

Expand Down Expand Up @@ -279,21 +312,26 @@ so they are *not* covered by the app container's limit and get their own caps in
| monitoring profile | ~1.1 | ~2 GB |

The practical ceilings are external before they are local: Stream
participant-minutes first, then Convex function calls and bandwidth, then Clerk
MAU.
participant-minutes first, then Convex function calls and bandwidth. Auth is
self-hosted, so it has no per-user ceiling of its own beyond the database.

---

## ✅ Before opening public signup

- [ ] Rotate `STREAM_SECRET_KEY` and `CLERK_WEBHOOK_SECRET`. An earlier version
of `convex/observability.ts` logged both to Convex function logs on every
developer-dashboard load, so treat the old values as compromised. Update
them in the Portainer stack env and in GitHub Actions secrets.
- [ ] Use a **production** Clerk instance, with the domain, redirect URLs and
webhook endpoint pointing at the public origin, and **email verification
required at signup** — the code runner refuses unverified accounts.
- [ ] Confirm `CLERK_ISSUER_URL` on the Convex deployment matches that instance.
- [ ] Rotate `STREAM_SECRET_KEY`. An earlier version of
`convex/observability.ts` logged it to Convex function logs on every
developer-dashboard load, so treat the old value as compromised. Update it
in the Portainer stack env and in GitHub Actions secrets.
- [ ] Rotate the OAuth client secrets, `AUTH_SECRET`, `AUTH_ADAPTER_SECRET` and
`INTERNAL_API_KEY`, and bump `AUTH_JWT_KID` when you replace the keypair —
the kid names the key in both halves, so changing the key without changing
the kid leaves cached JWKS entries rejecting valid tokens.
- [ ] Point the Google and GitHub OAuth apps' authorised redirect URIs at the
public origin: `https://<origin>/api/auth/callback/{google,github}`.
- [ ] Confirm `SITE_URL` and `AUTH_ADAPTER_SECRET` on the Convex deployment match
the app, and that `/api/health` reports `auth: true` — email verification
is required before the code runner will run anything.
- [ ] Set `NEXT_PUBLIC_APP_URL` to the public origin so invitation links resolve.
- [ ] Restore-test one backup zip from the `backup-data` volume.
- [ ] Set `OWNER_EMAILS` on the Convex deployment to your own address, then sign
Expand Down
61 changes: 26 additions & 35 deletions convex/auth.config.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,40 @@
/**
* Identity providers Convex will accept tokens from.
*
* Both are listed on purpose during the Auth.js migration. Convex uses the
* first provider whose issuer and applicationID match the presented token, and
* Clerk's tokens carry Clerk's issuer while ours carry SITE_URL, so the two
* never collide. Keeping both means a half-migrated deployment authenticates
* users on either system instead of locking everyone out at the moment the
* config changes. The Clerk entry is removed in the final task of the migration
* (see docs/superpowers/plans/2026-09-12-custom-auth.md).
* One provider now. Clerk's entry was removed with the rest of Clerk, so a
* token from it is no longer accepted by this deployment — which is the point
* of removing it rather than leaving it configured and unused.
*
* `SITE_URL` is required, and Convex enforces that statically: any environment
* variable named in this file must be set on the deployment or `convex deploy`
* fails, whether or not the code path that reads it runs. Set it with
* `npx convex env set SITE_URL https://commit.kunjdeveloper.com`.
*/

const clerkIssuerUrl = process.env.CLERK_ISSUER_URL;

// Trailing slashes are stripped because `issuer` must match the token's `iss`
// claim exactly, and an operator pasting the URL with one would produce a
// mismatch whose only symptom is that every token is rejected.
// mismatch whose only symptom is that every token is rejected — which surfaces
// as "you must be signed in" shown to people who are.
const siteUrl = process.env.SITE_URL?.trim().replace(/\/+$/, "");

if (!clerkIssuerUrl && !siteUrl) {
if (!siteUrl) {
throw new Error(
"No auth provider configured. Set SITE_URL (Auth.js) and/or CLERK_ISSUER_URL " +
"in the Convex environment, for example with " +
"SITE_URL is not set on this Convex deployment, so no auth provider is " +
"configured and every request would be unauthenticated. Set it with " +
"`npx convex env set SITE_URL https://commit.kunjdeveloper.com`.",
);
}

const providers = [];

if (siteUrl) {
providers.push({
// customJwt rather than OIDC: it needs only a JWKS endpoint, where OIDC
// would additionally require us to serve /.well-known/openid-configuration.
type: "customJwt",
applicationID: "convex",
issuer: siteUrl,
jwks: `${siteUrl}/.well-known/jwks.json`,
algorithm: "RS256",
});
}

if (clerkIssuerUrl) {
providers.push({
domain: clerkIssuerUrl,
applicationID: "convex",
});
}

export default { providers };
export default {
providers: [
{
// customJwt rather than OIDC: it needs only a JWKS endpoint, where OIDC
// would additionally require us to serve /.well-known/openid-configuration.
type: "customJwt",
applicationID: "convex",
issuer: siteUrl,
jwks: `${siteUrl}/.well-known/jwks.json`,
algorithm: "RS256",
},
],
};
17 changes: 10 additions & 7 deletions convex/authAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,14 @@ export const createUser = mutation({
// Public sign-up produces candidates. Elevating anyone beyond that stays
// an explicit, audited action, exactly as it was under Clerk.
role: "candidate",
// clerkId is still required by the schema and read in 137 places across
// convex/. Rather than churn all of them mid-migration, a user created by
// Auth.js becomes its own id here — which is what the field always meant
// semantically, and what identity.subject now carries. It is dropped in
// the final task of the migration.
// clerkId is no longer an authentication identifier — nothing verifies a
// token against it any more. It stays because it is the *internal user id*
// that interviews.candidateId, interviews.interviewerIds and
// auditLogs.actorClerkId hold, as plain v.string(), in hundreds of places.
// Renaming it is a data migration of its own, not part of removing Clerk.
//
// A user created by Auth.js becomes its own id here, which is what the
// field always meant semantically and what identity.subject now carries.
//
// A Convex id does not exist until the row does, so this is written twice:
// a placeholder that cannot collide, then the real id. The placeholder is
Expand Down Expand Up @@ -343,8 +346,8 @@ export const setCredential = mutation({
*
* `source` is "server" rather than "auth": the schema's union does not include
* an auth source, and widening it for this would be a migration for no gain.
* `provider: "authjs"` is what distinguishes these rows, mirroring the
* `provider: "clerk"` the webhook path already writes.
* `provider: "authjs"` is what distinguishes these rows from every other
* telemetry row sharing that source.
*/
export const recordAuthEvent = mutation({
args: {
Expand Down
Loading
Loading