# Charming auth

> The Charming server is a Bearer-secured HTTP API. Three token families authorise three scopes: per-app, per-user, and per-render. Agents typically authenticate either by holding the `bld_app_*` token returned at anonymous create (one-app scope) or by completing the device-authorization (RFC 8628-shaped) pairing flow to obtain a `bld_user_*` (account scope). When an anonymous `bld_app_*` token bounces against a now-claimed app, the 401 carries a self-contained `recovery` block that walks the agent through pairing — see [Errors](#errors). Discovery is wired up at the `.well-known` paths so an MCP client or OAuth-aware agent can bootstrap with zero config.

## Discover

Charming exposes machine-readable discovery so an agent never has to hardcode endpoints.

- `GET https://charm.ing/.well-known/openapi.json` — the OpenAPI 3.1 contract. Lists every route, request/response shape, error `kind` enum, and security scheme (`appToken`, `userToken`, `renderToken`, `renderTokenQuery`).
- `GET https://charm.ing/.well-known/oauth-authorization-server` — RFC 8414 authorization-server metadata, served by BetterAuth. Tells an OAuth Dynamic Client Registration-aware client where to issue requests for the browser-based sign-in flow. The document also carries an `agent_auth` extension block (WorkOS-style) that points an agent at Charming's pairing entry point (`register_uri` → `/api/pair/start`), claim entry point (`claim_uri` → `/app/{id}/claim`), revocation entry point (`revocation_uri` → `/api/token/{id}`), and this skill file (`skill` → `https://usecharming.com/auth.md`). `identity_types_supported` lists `anonymous` only today; `verified_email` lights up with the email-OTP claim flow.
- `GET https://charm.ing/.well-known/oauth-protected-resource` — RFC 9728 protected-resource metadata. Names this API as the resource so an MCP client can resolve the matching authorization server.
- `GET https://charm.ing/.well-known/http-message-signatures-directory` — RFC 9421 / Web Bot Auth signing-key directory. A single-entry JWK Set advertising the Ed25519 public key Charming will use to sign outbound HTTP messages, with `kid`, `nbf`, and `exp` so a verifier can pin and rotate.
- `GET https://charm.ing/.well-known/openai-apps-challenge` — ChatGPT Apps MCP host challenge; used during ChatGPT connector registration, not for general auth.

The OpenAPI document is the canonical schema; this Markdown narrates it. When in doubt, fetch the JSON.

## Pick a method

Pick the smallest scope that fits the work. Three credential families, mintable three ways:

**1. Anonymous app token (`bld_app_*`).** The cheapest path to start. `POST /app` with the app source and no `Authorization` header returns a freshly minted app row plus a one-time `bld_app_*` token (51 chars, base64url). The token authorises mutations and `/api/*` calls on that one app id only — it does not list other apps or mint user tokens. The anonymous row expires 7 days from creation unless claimed (`POST /app/{id}/claim`, see [Claim](#claim)). Pair this flow with `pair: true` in the same `POST /app` body and the server mints a bound `device_code` alongside the app token; the human claim auto-approves the pairing, so the agent ends a single round-trip away from holding both an app token and a `bld_user_*`. The legacy `tokenPrefix` is the first 12 characters of the token — agents and the dashboard surface it for human identification but it never authorises on its own.

**2. Personal access token (`bld_user_*`).** Account-scoped. Two ways to obtain:

- **Browser-minted.** Sign in at `https://charm.ing/auth/login` (BetterAuth session cookie), then `POST /api/token` with an optional `label`. The plaintext is returned exactly once; only `tokenPrefix` and `id` are recoverable later. Default TTL is 7 days from issuance — `lastUsedAt` updates every time the token authenticates but does not extend `expiresAt`. The legacy `/token` path 308-redirects to `/api/token`.
- **Device-paired.** Agent posts to `POST /api/pair/start` with an optional `label` and receives a `device_code` (agent-private, base64url-shaped `bld_pair_*`) and a `user_code` (`BLDY-XXXXXX`, Crockford-ish alphabet with no `0/O/1/I`). The agent shows the `user_code` to the human, points them at `verification_url` (`https://charm.ing/pair`), and polls `POST /api/pair/poll` with `{ device_code }`. While unapproved, polls return `{ status: "pending" }`; on approval the next poll returns `{ status: "approved", token: "bld_user_..." }` exactly once. Pairing grants live 10 minutes by default, extend on active polling up to a 60-minute absolute lifetime, and paired tokens carry a 30-day TTL — longer than the 7-day `/api/token` mint because the agent is expected to keep working between human sessions. The flow is RFC 8628-shaped (device authorization grant) — agents already wired up for OAuth device-flow can reuse their machinery; the only Charming-specific knob is the `BLDY-` prefix and the 32-character Crockford-ish alphabet on `user_code`.

**3. Render token (`bld_render_*`).** App-scoped, server-minted, 7-day TTL (`RENDER_TOKEN_TTL_MS`). The runtime injects one into every rendered `/app/{id}` HTML closure so the in-page UI can call back into its own `/api/<op>` without cookies (null-origin iframes inside Claude Desktop, ChatGPT Apps, and any sandboxed embed cannot attach `Authorization` headers from JS). Agents do not mint these — they exist solely so the app's UI can talk to its handler. A render token also fits as a `?t=` query parameter when an iframe can't set headers at all (the initial `GET /app/{id}` load). Re-rendering rotates the token; a short grace window (`render_token_grace`, `RENDER_TOKEN_GRACE_MS` ≈ 15 minutes) keeps the previous hash live so an open editing session doesn't 401 mid-write.

The keywords below appear in this file verbatim so a `grep` for canonical terms hits it: **Bearer**, **device authorization**, **RFC 8628**, **device_code**, **user_code**, **token revocation**, **OAuth Dynamic Client Registration**, **OAuth protected resource**, **personal access token**, **token rotation**.

## Register

A user gets onto Charming by signing in — there is no separate "register the agent" step, just normal sign-in followed by token issuance.

- `https://charm.ing/auth/login` — BetterAuth sign-in page. Email + password or OAuth provider.
- `https://charm.ing/auth/signup` — first-time signup.
- `https://charm.ing/auth/consent` — OAuth-style consent screen (used when a third-party client requests scopes).
- `https://charm.ing/auth/logout` — clears the session cookie.

Once signed in, the browser carries a `better-auth.session_token` cookie (or its `__Secure-` prefixed form in production). The cookie is interchangeable with a `bld_user_*` bearer for browser-driven flows — every endpoint that accepts `userToken` also accepts the session cookie. Agents never use the cookie directly; they use the cookie to mint a token (`POST /api/token`) or to approve a pairing.

## Claim

Anonymous apps belong to no user. The `bld_app_*` token is the only thing that can mutate them, and the row is reaped 7 days after creation if no one claims it.

**Single-call create + pair.** Pass `pair: true` in the anonymous `POST /app` body and the server mints a device-pairing grant bound to the new app id (`boundAppId`). The agent gets back an app token AND the pairing block in the same response:

```json
{
  "id": "…",
  "token": "bld_app_…",
  "claimed": false,
  "expiresAt": "...",
  "pairing": {
    "device_code": "bld_pair_…",
    "user_code": "BLDY-XXXXXX",
    "verification_url": "https://charm.ing/pair",
    "polling_interval": 5,
    "expires_in": 600
  }
}
```

The agent shows the `user_code` to the human, the human signs in and approves at `verification_url`, and the agent's next `POST /api/pair/poll` returns the freshly-minted `bld_user_*`. Because the pairing is bound to the new app id, the human's approval **also auto-claims the app** in the same transaction — the row's `userId` flips from null to the human, the `bld_app_*` is revoked, and `approveBoundPair` mints the paired `bld_user_*` token all atomically. No separate `POST /app/{id}/claim` call is needed when `pair: true` was used.

**Two-call claim (no prior pairing).** If the agent already has a `bld_user_*` and wants to claim an anonymous app on the user's behalf:

```sh
curl -sS -X POST https://charm.ing/app/<id>/claim \
  -H "Authorization: Bearer <bld_user_*>" \
  -H "Content-Type: application/json" \
  -d '{ "token": "<bld_app_*>" }'
```

The body must carry the app's `bld_app_*` token (proof of possession). On success the row's `userId` is set, the `bld_app_*` is revoked (its `appTokenHash` nulled while `appTokenPrefix` is retained as audit history), and the response is `{ ok: true, id, manifestId, claimedAt }`. Failure modes:

- `400 invalid_request` — body is not JSON, or `token` is missing.
- `401 unauthorized` — user-scoped auth is required to claim (a `bld_app_*` cannot claim itself).
- `404 not_found` — the app id doesn't exist.
- `409 already_claimed` — someone (possibly the caller) already owns it.
- `409 duplicate_remix` — the claiming user already owns a remix of the same template manifest; the response carries `existingAppId` pointing at that remix so the caller can resume editing it instead of minting a duplicate.

## Use

Every authenticated request carries `Authorization: Bearer <token>` (or, for null-origin iframes, `?t=<token>` in the query string for `GET /app/{id}` and `GET /app/{id}/events`).

| Token | Scope | What it can do |
| --- | --- | --- |
| `bld_user_*` | Account-wide | Create apps, list owned + collaborator apps, claim anonymous apps, mint/revoke tokens, mutate any owned app, read/write secrets, manage shares |
| `bld_app_*` | One app | Mutate that app (`PUT`/`PATCH` source, `DELETE`, `POST /api/<op>`), read its diag/activity, upload assets — but not list other apps |
| `bld_render_*` | One app, run-only | Call `POST /app/{id}/api/<op>`, fetch its render-time diag/activity, write feedback, upload assets — bound to one app id and minted server-side |

Example minimal mutating call with a user token:

```sh
curl -sS -X PUT https://charm.ing/app/<id> \
  -H "Authorization: Bearer <bld_user_*>" \
  -H "Content-Type: application/json" \
  -H 'If-Match: "v3"' \
  -d '{ "module": "export const manifest = { id: \"todo\" }; export default { async fetch(req){...} };" }'
```

`If-Match` is the optimistic-concurrency primitive: a write only succeeds if the supplied `"v<N>"` equals the current `appModules.currentVersion`. A stale value returns 412 with the current ETag in the response header. Omitting `If-Match` retains pre-existing PUT semantics ("last writer wins"), so the gate is opt-in for callers that care.

Mid-app calls from the in-page UI use the render token automatically via the runtime closure (`fetch('/api/save', ...)` inside the iframe). External callers (an agent driving the app from outside the iframe) supply `bld_app_*` or `bld_user_*` instead — see [build-http.md](https://usecharming.com/build-http.md).

## Errors

The 401 response is structured. Branch on `error.kind` for the broad category (always `"unauthorized"` for token failures) and on `error.reason` for the precise failure mode. The full enum is in the OpenAPI spec.

```json
{
  "ok": false,
  "error": {
    "kind": "unauthorized",
    "message": "...",
    "reason": "token_revoked_post_claim",
    "tokenIdPrefix": "bld_app_abc1",
    "recovery": {
      "kind": "pair",
      "start": "https://charm.ing/api/pair/start",
      "verification_url": "https://charm.ing/pair",
      "instructions": "..."
    }
  }
}
```

`reason` enum:

- `token_malformed` — the bearer doesn't have a recognised `bld_*` prefix or is too short. Likely a copy-paste or truncation bug; do not retry the same value.
- `token_unknown` — the shape is valid but no row matches. Fabricated, mistyped past the 12-char prefix, or claimed-and-purged. Sign in to recover.
- `token_revoked` — a row matched but the token hash was nulled. Defensive today; the live `bld_render_*` path also surfaces this when a rotated token's grace window lapses (re-render the app to get a fresh one).
- `token_revoked_post_claim` — the post-#1284 surface. A `bld_app_*` matched an anonymous app that has since been claimed by its owner. The 401 carries a self-contained `recovery` block with `kind: "pair"`, the `start` URL to POST to, the `verification_url` to show the user, and a one-paragraph instruction string. Follow the block exactly — do **not** silently fall back to creating a new app.

The recovery worked example:

```sh
# 1. Hit the post-claim 401:
curl -sS -X PUT https://charm.ing/app/<id> \
  -H "Authorization: Bearer <stale_bld_app_*>" \
  -H "Content-Type: application/json" -d '{"module":"..."}'
# → 401 with error.recovery.kind = "pair" and error.recovery.start

# 2. Start a pairing grant:
curl -sS -X POST https://charm.ing/api/pair/start \
  -H "Content-Type: application/json" -d '{"label":"my agent"}'
# → { device_code, user_code: "BLDY-XXXXXX", verification_url, polling_interval, expires_in }

# 3. Show user_code to the human and ask them to visit verification_url.

# 4. Poll until status flips:
curl -sS -X POST https://charm.ing/api/pair/poll \
  -H "Content-Type: application/json" -d '{"device_code":"<from step 2>"}'
# → eventually { status: "approved", token: "bld_user_..." } — exactly once.

# 5. Retry the original PUT with the new token:
curl -sS -X PUT https://charm.ing/app/<id> \
  -H "Authorization: Bearer <bld_user_*>" \
  -H "Content-Type: application/json" -d '{"module":"..."}'
```

403 vs 401: 401 means "no valid credential"; 403 (`forbidden` / `forbidden_write`) means "the credential is valid for a different app or scope." Do not retry a 403 against the same endpoint without changing the credential.

Other auth-relevant error shapes:

- `429 rate_limited` — bursts on `/api/pair/start`, `/api/pair/poll`, `/api/pair/approve`. `/api/pair/start` and `/api/pair/poll` are keyed per-IP; `/api/pair/approve` is keyed per signed-in `userId` (so a shared IP can't cap legitimate users sharing wifi). See [pricing.md](https://usecharming.com/pricing.md#rate-limits).
- `401 sse_cookie_expired` — anonymous SSE caller's path-scoped `buildy_sse_<id>` cookie elapsed; reload `/app/{id}` so the bridge re-issues one.
- `401 token_expired` — `bld_user_*` past its TTL; mint a new one.

## Revocation

Tokens are revocable from two surfaces.

**Programmatic.** `DELETE /api/token/{id}` with a user-scoped credential. Idempotent; revoking a token nulls its hash so subsequent uses 401. `lastUsedAt` is preserved on the row for audit. The legacy `/token/{id}` path 308s to `/api/token/{id}`.

```sh
curl -sS -X DELETE https://charm.ing/api/token/<token_uuid> \
  -H "Authorization: Bearer <bld_user_*>"
# → { ok: true }
```

**Human.** The signed-in user can list and revoke at `https://charm.ing/account/agents`. The page shows `tokenPrefix`, `label`, `expiresAt`, and `lastUsedAt` for every token; the same data is available programmatically via `GET /api/token`.

App-claim revocation is implicit: claiming an anonymous app revokes its `bld_app_*` token in the same transaction. The 401 with `reason: "token_revoked_post_claim"` is the agent's signal to enter the pairing flow.

Render tokens (`bld_render_*`) are never directly revocable — they rotate on every re-render of the app. A still-open iframe holding the previous token gets a ~15-minute grace window via the `render_token_grace` table; after that the next call surfaces `token_revoked` and the UI reloads to pick up the fresh token.

There is no audit log of every authentication event, but `lastUsedAt` on the token row is the user's primary signal that something is using a given token. Combine `GET /api/token` with `/account/agents` to spot suspicious activity, and `DELETE /api/token/{id}` to cut a stale agent off.

---

Related guides: [pricing.md](https://usecharming.com/pricing.md) for limits and TTLs · [build-http.md](https://usecharming.com/build-http.md) for full HTTP-API worked examples · [openapi.json.md](https://usecharming.com/openapi.json.md) for the route catalogue · [llms-full.txt](https://usecharming.com/llms-full.txt) for the narrative build manual.
