# Superadmin (Platform Admin) access

The `/admin` console is the **seller/operator** side of CoreDeskAI. It reads and
writes across every tenant organization, so access is deliberately narrow.

There is no superadmin username or password, and no superadmin table. Identity
comes from Clerk; being a superadmin is an **allowlist of Clerk user ids** held
in a Convex environment variable. You do not create a superadmin account, you
promote an existing Clerk user.

---

## How the check works

`requirePlatformAdmin` in `packages/backend/convex/lib/auth.ts` runs inside every
admin Convex function. Either of these grants access:

1. The JWT carries a `role` claim containing `platform_admin`, or
2. The caller's Clerk user id appears in `PLATFORM_ADMIN_USER_IDS`, a
   comma-separated Convex environment variable.

The id is matched against the token's `subject` and `tokenIdentifier` (including
the portion after the `|` separator).

### Platform roles are not tenant roles

Two unrelated things are both called "admin", and confusing them is the easiest
mistake to make here:

| | Meaning | Grants console access |
|---|---|---|
| `org:admin`, `org:member` | Clerk **organization** role. Someone's power inside one customer organization. | No |
| `superadmin`, `manager`, `viewer` | **Platform** role, above the tenant boundary. | Yes |

There is deliberately no platform role called "admin". A customer who
administers their own organization is an `org:admin` and has no platform access
whatsoever.

Two properties worth knowing:

- **It is enforced server-side.** The UI hides tabs for non-admins, but the
  guard that matters runs in Convex and cannot be bypassed from a client.
- **A tenant's own org-admin is NOT a platform admin.** This is intentional. If
  org-admins were treated as platform admins, any customer could reach the
  seller console and the cross-tenant functions behind it.

---

## Granting access

### 1. Get the Clerk user id

The person must already have signed up for the app (a Clerk **dashboard**
account is not the same thing as an app user).

The easy way: have them sign in and open `/admin`. The "Not authorized" screen
prints their exact user id, ready to copy. That page exists for this purpose.

Otherwise, from the Clerk dashboard: **Users** -> click the user -> copy
**User ID** (`user_...`).

Or from the API:

```bash
curl -s "https://api.clerk.com/v1/users?limit=20" \
  -H "Authorization: Bearer $CLERK_SECRET_KEY" \
  | python3 -c "import json,sys; [print(u['id'], (u.get('email_addresses') or [{}])[0].get('email_address')) for u in json.load(sys.stdin)]"
```

### 2. Set the variable on the Convex deployment

```bash
cd packages/backend
npx convex env set PLATFORM_ADMIN_USER_IDS "user_ABC123"
```

Several admins are comma-separated. **Setting replaces the whole list**, so
include everyone who should keep access:

```bash
npx convex env set PLATFORM_ADMIN_USER_IDS "user_ABC123,user_DEF456"
```

Target a specific deployment explicitly when it matters:

```bash
npx convex env set --prod PLATFORM_ADMIN_USER_IDS "user_ABC123"
```

Verify, then reload `/admin`:

```bash
npx convex env list | grep PLATFORM_ADMIN_USER_IDS
```

### 3. Revoking

Re-set the variable without that id, or drop it entirely:

```bash
npx convex env remove PLATFORM_ADMIN_USER_IDS
```

Revocation takes effect on the user's next request. Deleting or banning the
Clerk user also works, since the token stops being issued.

---

## Granting superadmin from Clerk (role claim)

The second root path, and usually the nicer one: mark the user in Clerk and they
become a superadmin on their next token refresh, with no `convex env set` and no
deploy.

**This is not an organization role.** Clerk organization roles (`org:admin`,
`org:member`) are tenant-scoped: they describe someone's power inside their own
customer organization and grant no platform access at all. Creating an
organization role called "superadmin" would be a security hole, because any
customer who administers an organization could assign it. The mechanism below is
user-level and can only be set by someone with Clerk dashboard or Backend API
access.

### 1. Add the role claim to the `convex` JWT template

Clerk dashboard -> **JWT Templates** -> `convex` -> Claims, add:

```json
{ "role": "{{user.public_metadata.role}}" }
```

Keep every existing claim; this is one extra key. Users without the metadata
simply get no `role` claim, so nothing changes for them.

Same thing over the API:

```bash
curl -X PATCH "https://api.clerk.com/v1/jwt_templates/<template_id>" \
  -H "Authorization: Bearer $CLERK_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"convex","claims":{ ...existing claims..., "role":"{{user.public_metadata.role}}" }}'
```

`PATCH` replaces the whole claims object, so fetch the current one first with
`GET /v1/jwt_templates` and add to it rather than sending only the new key.

### 2. Mark the user

Clerk dashboard -> **Users** -> the user -> **Metadata** -> Public:

```json
{ "role": "platform_admin" }
```

Or over the API:

```bash
curl -X PATCH "https://api.clerk.com/v1/users/<user_id>/metadata" \
  -H "Authorization: Bearer $CLERK_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"public_metadata":{"role":"platform_admin"}}'
```

The check is `directRole.includes("platform_admin")` in `lib/auth.ts`, so
`platform-admin` also matches.

### 3. Confirm it worked

Decode a `convex` token for that user and look for the claim:

```bash
# role should be "platform_admin"; for an ordinary user it is absent
```

Then call `admin/admins:myAccess`, which should return
`isAdmin: true, isRoot: true, role: "superadmin"`.

### Which method to use

Both are ROOT: each grants every privilege, and neither can be revoked from
inside the console. Having both configured is deliberate, not redundant. If
Clerk metadata is wiped or the JWT template is edited, the env allowlist still
lets you in, and if a deployment loses its environment variables, the Clerk
claim still does. Neither path can lock you out on its own.

| | Env allowlist | Clerk role claim |
|---|---|---|
| Where it lives | Convex deployment env | Clerk user metadata |
| To change | `npx convex env set`, per deployment | Clerk dashboard, instant |
| Scope | One deployment | One Clerk instance |
| Good for | Bootstrapping the first operator | Day-to-day operator management |

For anything **less** than full control, use neither. Add the person through the
console's Operators tab as a `manager` or `viewer`, which is what the privilege
system exists for.

### Per-instance, not global

A JWT template and user metadata belong to **one Clerk instance**. Configuring
them on your development instance does nothing for production: both steps must
be repeated against the production instance, where the user ids are different
too.

---

## Clerk issuers must be trusted by Convex

Signing in is not enough. Convex only accepts tokens from issuers listed in
`packages/backend/convex/auth.config.ts`:

```
https://enabling-goat-79.clerk.accounts.dev
https://fun-guppy-70.clerk.accounts.dev
https://clerk.coddeskai.com
```

If the app points at a Clerk instance that is not in that list, sign-in appears
to succeed and then every Convex call fails with `NoAuthProvider`, naming the
issuers it does accept. The fix is to use one of the trusted instances.

**Do not add a Clerk development instance to that file.** Development instances
have weak signup protection, so trusting one from production would let anyone
who can sign up there mint a token production accepts.

Note also that Convex **statically requires** every environment variable
referenced in `auth.config.ts` to be set on the deployment, even inside a
conditional. So `process.env.SOMETHING` in that file makes the variable
mandatory everywhere, and a deployment missing it fails to push with
`AuthConfigMissingEnvironmentVariable`. That rules out the obvious
"env-gate the dev issuer" pattern.

---

## Required JWT claims (every Clerk instance)

Trusting the issuer is only half the setup. The `convex` JWT template must also
carry the claims the backend reads, or the app signs in successfully and then
fails on every organization-scoped query.

### The claims

| Claim | Shortcode | Read by |
|---|---|---|
| `orgId` | `{{org.id}}` | `identity.orgId` in `getOrgId()` and `requireOrgMembership` |
| `orgRole` | `{{org.role}}` | `identity.orgRole`, for the admin/member check |
| `orgSlug` | `{{org.slug}}` | display only |
| `role` | `{{user.public_metadata.role}}` | platform-admin check (see above) |

**The names must be camelCase.** The backend reads `identity.orgId`, not
`identity.org_id`. Naming them `org_id` / `org_role`, which is the more
conventional shape, silently produces the same failure as omitting them.

### Symptom when they are missing

Every org-scoped query throws:

```
UNAUTHORIZED: Identity not found
```

That message is misleading. The identity IS present and the token IS valid;
only the organization claim is absent, so `getOrgId()` returns null and the
handler rejects the request. If you see this on a fresh Clerk instance, check
the JWT template before anything else.

### Adding them

Clerk dashboard -> **JWT Templates** -> `convex` -> Claims. Keep every existing
claim and add:

```json
{
  "orgId": "{{org.id}}",
  "orgRole": "{{org.role}}",
  "orgSlug": "{{org.slug}}"
}
```

Over the API, remembering that `PATCH` **replaces the whole claims object**, so
fetch the current one first and add to it:

```bash
# 1. read the existing template
curl -s "https://api.clerk.com/v1/jwt_templates" \
  -H "Authorization: Bearer $CLERK_SECRET_KEY"

# 2. PATCH back the SAME claims plus the org ones
curl -X PATCH "https://api.clerk.com/v1/jwt_templates/<template_id>" \
  -H "Authorization: Bearer $CLERK_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"convex","claims":{ ...existing..., "orgId":"{{org.id}}", "orgRole":"{{org.role}}", "orgSlug":"{{org.slug}}" }}'
```

### Verifying

Decode a `convex` token for a user who has an organization selected and confirm
the claims are populated, not just present:

```bash
# orgId should be an org_... value, not null
```

Then call any org-scoped query. It should return data instead of
`Identity not found`.

### This is per-instance

Like the role claim, a JWT template belongs to ONE Clerk instance. Configuring
development does nothing for production. **Check this on the production
instance before any release that touches auth**, because the failure is total:
no org-scoped screen works at all.

---

## What a superadmin can do

At `/admin`:

| Tab | Capability |
|---|---|
| Overview | Every org with conversation, channel, campaign and token counts; suspend an org; attach an operator note |
| Plans | Create, edit, reorder, activate and delete plans, including EN/AR translations |
| Payments | Record payments, filter by status, and view revenue analytics (collected, MRR, pending, per-plan) |
| Feature flags | Global rollout and per-org overrides |

Billing is manual: recording a payment writes a record of money already
received. Nothing here charges a customer, and no payment provider is involved.

---

## Troubleshooting

**"Not authorized" with a user id shown.** Expected before promotion. Copy the
id and follow step 2.

**`NoAuthProvider` errors.** The Clerk instance issuing the token is not in
`auth.config.ts`. The error message lists the issuers that are accepted.

**Set the variable but still refused.** `npx convex env set` targets the
deployment your CLI is pointed at. Confirm with `npx convex env list` that you
set it where the app actually reads from, and use `--prod` for production.

**Access works locally but not in production.** They are separate deployments
with separate environment variables, and separate Clerk instances. Both roots
are per-environment: `PLATFORM_ADMIN_USER_IDS` must be set on each deployment,
and the JWT template plus user metadata must be configured on each Clerk
instance. User ids differ between Clerk instances too.

**Set the Clerk metadata but still refused.** The token is cached until it
refreshes. Sign out and back in, then confirm the `role` claim is actually in
the token; if it is missing, the `convex` JWT template has no `role` claim, or
the metadata went into `private_metadata` rather than `public_metadata`.

**A customer can see the console.** Should be impossible: check whether someone
put `platform_admin` into that user's public metadata, or added their id to
`PLATFORM_ADMIN_USER_IDS`. An organization role can never cause this.

**Every org-scoped query fails with "Identity not found".** The `convex` JWT
template is missing the organization claims, or they are named `org_id` rather
than `orgId`. See "Required JWT claims" above. The identity is fine; only the
org claim is absent.

**It works in development but every screen is empty in production.** Same
cause, different instance. The JWT template is configured per Clerk instance.
