# Clerk — moving owner sign-in from DEVELOPMENT to PRODUCTION

> **Renamed เฟรนเดย์ / Frenday on 2026-09-18; public host `frenday.xyz`** (a temporary TLD; `paiduay.6326638.xyz` 301s to it). "Paiduay" below is the legacy name and stays as the internal identifier — the repo folder, the tenant slug, env names, and the existing object names in the Clerk and LINE consoles. Anything a seller reads — the Clerk application name, the LINE channel name, the sending domain — is เฟรนเดย์ / Frenday.
> **เปลี่ยนชื่อเป็น เฟรนเดย์ เมื่อ 2026-09-18 โฮสต์สาธารณะคือ `frenday.xyz`** ชื่อ "Paiduay" ยังใช้เป็นชื่อภายในเท่านั้น

2026-09-17 · for James · companion to `CLERK-SETUP.md` (which describes one instance; this describes the *switch*). Blocker **B2** in `docs/paiduay/plans/next-sprint.md` §2. Facts checked against Clerk's docs on 2026-09-17; anything unconfirmed is marked **UNVERIFIED**.

> **2026-09-19 — done with the Clerk CLI, not the dashboard.** The original Clerk account died with its keys; James made a new account (login `clerk@5256000.xyz`) and app **Frenday** `app_3JXSLNZhUoG50wUWhNa9f2adujt`. Dev instance `ins_3JXSLINZP2yp05c2UGybBNdrEut` (`pk_test_cHJl…`), production instance `ins_3JXUTo6eDTs4cFmVBv9Vt7eTA01` (`pk_live_Y2xl…`) on **frenday.xyz** — James chose to bind production to `.xyz` now rather than wait for the keeper TLD; moving later = `change_domain` + new DNS. Five Clerk CNAMEs live in the Cloudflare zone, DNS-only. Username OFF and Google OAuth OFF on both instances (`clerk config patch`); custom session lengths need a paid plan (Clerk default 7-day lifetime stands). The CLI (`npm i -g clerk`; link lives under `apps/paiduay`, run commands from there): `clerk auth login` (browser), `clerk deploy` (interactive, human terminal only), `clerk deploy status`, `clerk env pull --instance prod --file <scratch>` (never into the repo), `clerk config pull|patch --instance prod`. No CLI/API route creates a webhook endpoint — that stays a dashboard click.

**Verdict:** ~30 dashboard minutes + DNS + one redeploy. **No code change** — `proxy.ts`, `owner-auth.ts` and `app/layout.tsx` read the keys at runtime and behave identically on `pk_live_`. The cost is **people**: every seller who signed up on dev gets a *new* Clerk user id and must be re-bound by hand. Do it **before** the tweet.

---

## 1. Prerequisites

1. **A domain you control DNS for.** The production instance binds to it and validates it by DNS check; the records come from the dashboard's **Domains** page. Subdomains are supported — Clerk's *Change domain* dialog offers **Primary application** (app on `app.example.com`, Clerk on the root: `clerk.example.com`) or **Secondary** (both on the subdomain: `clerk.app.example.com`). The public host today is `frenday.xyz`, an apex domain Clerk takes in the ordinary way — the number host `paiduay.6326638.xyz` is gone from the question, it only 301s here. **B1 still comes first**: `launch-runbook.md` §1 is explicit — *never a production instance on a temporary host* — and `frenday.xyz` is a temporary TLD taken to launch on, not the name James intends to keep. Moving later means a second migration (`POST /v1/instance/change_domain`) and a second round of DNS. Buy the name you will keep, then do this.
2. **Clerk forbids**: Cloudflare **proxying** on its records (§3), and shared OAuth credentials in production — "In development, for most social providers, Clerk provides you with a set of shared OAuth credentials. In production, these are not secure and you will need to provide your own." LINE therefore needs a real LINE Login channel.
3. **Dev instances are capped at 100 users** and **"user data can not be transferred between instances"** (Clerk docs) — this supersedes the runbook's older "historically 500".
4. `ssh james@92.118.206.89`; secrets at `~/apps/ai-new-business/.env` (0600).

## 2. Dashboard, in order

Dashboard → the Paiduay application → environment switcher → **create the production instance**. Clerk offers to clone the development settings; take it, then verify each item below — cloning does not carry users.

1. **Domain** → the app domain (the name you will keep — `frenday.xyz` only if James decides to stay on it); Primary/Secondary per §1. Keep the page open — it prints the DNS records and later the **Verified** labels.
2. **User & Authentication → Email, phone, username**: email **ON** (identifier), password ON, email verification code ON, phone **OFF**, **username OFF** — this is half of B2: production asks her to invent a username that is not her handle (PROD-PROOF). Name optional.
3. **Sessions**: inactivity 30 days, max lifetime 90 days.
4. **Restrictions**: sign-ups OPEN. "Block disposable email domains" only if abuse appears.
5. **Attack protection**: bot protection ON.
6. **Paths**: `/sign-in`, `/sign-up`, after sign-in/up `/me`, after sign-out `/`. The code passes these explicitly, so the dashboard values matter only for Clerk's own **Account Portal** — set them so nothing lands on `accounts.dev`.
7. **Allowed origins**: nothing beyond the domain. Clerk exposes `allowed_origins` on the Backend API (`PATCH https://api.clerk.com/v1/instance`, `Authorization: Bearer sk_live_…`); whether the dashboard surfaces the same field is **UNVERIFIED**. Do not carry over `http://localhost:*` — that belongs to dev.
8. **Branding / Emails**: application name **เฟรนเดย์** (Clerk's emails and Account Portal print the dashboard value; `ownerLocalization()` masks it only inside our own `<SignIn>`/`<SignUp>`). Add the sending domain so the code mail is not from `clerk.com`; Clerk prints SPF/DKIM records.
9. **Social → LINE** (optional, sprint item #1): enable, toggle **Use custom credentials**, save the **Callback URL** Clerk shows; in the LINE Developers Console create a LINE Login channel named เฟรนเดย์, paste its Channel ID + secret back into Clerk and Clerk's callback into the channel, publish.
10. **Webhooks → Add Endpoint** — production has **its own** endpoint and **a new signing secret**: `https://<domain>/api/clerk/webhook`, event **`user.deleted`** only. Copy the `whsec_…`.
11. **API keys**: copy `pk_live_…` and `sk_live_…`.

## 3. DNS at Cloudflare

Clerk does not publish the record list — "To see what DNS records you need, navigate to the **Domains** page in the Clerk Dashboard." Add exactly what that page prints. (Our earlier note named `clerk.`, `accounts.`, `clkmail.` plus two `_domainkey` CNAMEs; treat that shape as **UNVERIFIED** and trust the dashboard.)

**The rule that bites:** every Clerk record must be **DNS only (grey cloud)**. Clerk's troubleshooting: *"Clerk uses a DNS check to validate this CNAME record. If this subdomain is reverse proxied behind a service that points to generic hostnames, such as Cloudflare, the DNS check will fail. Set the DNS record for this subdomain to a 'DNS only' mode."* Independent of whether you later turn the **app's own** record orange (`public-flip.md` §2) — Clerk's stay grey either way. Propagation: up to 48 h.

Check from the Mac:

```bash
dig +short clerk.<domain> CNAME          # expect Clerk's target, not a Cloudflare IP
dig +short accounts.<domain> CNAME
curl -sI https://clerk.<domain>/v1/environment | head -3   # 200/valid cert once issued
```

Clerk issues its own certificates for these hostnames; Caddy must **not** claim them.

## 4. Repo and server changes

**Code: none.** Verified by reading:

- `lib/owner-auth.ts` — reads through `const env = process.env` (a binding), so the publishable key is **not** inlined by the bundler; `clerkConfigured()` only checks both keys are non-empty and the *prefix* is never inspected.
- `proxy.ts` — matcher, route lists and `publicOrigin()` are domain-agnostic; `publishableKey` is passed per-request. The deliberate omission of `secretKey` as a middleware option stays (passing it demands `CLERK_ENCRYPTION_KEY`).
- `app/layout.tsx` — `<ClerkProvider>` mounts only when both keys exist, key passed explicitly.
- `app/api/clerk/webhook/route.ts` sits **outside** the Clerk matcher by design; it needs only the new secret.
- Keep `ownerLocalization()` — not a dev workaround: it guarantees the brand in our own walls whatever the dashboard says, and rewrites the start titles for someone who has no card yet.

**Server `.env`** (`~/apps/ai-new-business/.env`) — three lines change:

```
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_…
CLERK_SECRET_KEY=sk_live_…
CLERK_WEBHOOK_SIGNING_SECRET=whsec_…      # the PRODUCTION endpoint's, not dev's
```

If the domain also changed: `NEXT_PUBLIC_SITE_URL`, `WIDGET_ALLOWED_ORIGINS`, the `deploy/Caddyfile` block.

**Apply.** The key is read at runtime from compose's `env_file`, so no rebuild is needed for the keys alone:

```bash
ssh james@92.118.206.89
vi ~/apps/ai-new-business/.env
cd ~/apps/ai-new-business/repo/deploy && cp ~/apps/ai-new-business/.env ./.env && chmod 600 ./.env
docker compose up -d --force-recreate paiduay
```

`deploy/deploy.sh` (build → migrate → up) also works and is the safer default when anything else changed with it.

**Prove it:** `/sign-up` shows **no orange "Development mode"** and no username field; the verification mail comes from our domain; the webhook endpoint's *Testing* tab sends `user.deleted` and gets **200** `{"ok":true,…,"action":"ignored","cards":0}` (503 = the secret never reached the process).

## 5. The data question — the owners do not come with you

Clerk cannot transfer users between instances. On the first production visit every seller is a stranger: she signs up again, gets a **new** `user_…`, and her card — keyed by `Card.ownerUserId` — stops listing under `/me`. The card, its handle, photos and public URL are untouched; only the binding is stale.

Affected today: the `+clerk_test@` proof users (delete them from the dev instance's **Users**; nothing of value is lost) plus **any real seller who signed up before the switch**. The two seeded demo cards are fictional and own nothing.

Operator path, ~5 minutes each: she signs up again and tells you her email → dashboard **Users** → copy her `user_…` → `/admin/card` → her card → **Re-bind owner** → paste → Re-bind → ask her to load `/me`. (`rebindOwnerAction` → `rebindOwner` in `lib/cards/forget.ts`: refuses a forgotten card, unbinds the old owner party, rewrites `ownerUserId`, re-adds the `owner` party via `upsertPartyByContact("clerk", userId)`, writes a `card.owner_rebound` audit event; needs `card.manage`.)

Find who needs it: `sudo -u postgres psql -d ai_factory -Atc "select data->>'handle', data->>'ownerUserId' from kernel.records where type='card' and data->>'ownerUserId' is not null"` — every id listed is a dev id until re-bound.

## 6. Rollback

Put the three `pk_test_`/`sk_test_`/dev `whsec_` lines back and `docker compose up -d --force-recreate paiduay`. Nothing else is stateful — but any card already re-bound to a production id would then need re-binding *back*, so roll back only in the first hours and otherwise fix forward. The Clerk DNS records can stay; they harm nothing while dev keys are live. Emergency shutter is unchanged: `respond "ปิดชั่วคราว" 503` in the paiduay Caddy block.

## 7. Checklist

- [ ] 1. B1 done — the real domain is bought and its zone is at Cloudflare
- [ ] 2. `_dmarc` TXT (`p=reject`) added before creating the instance
- [ ] 3. Production instance created (settings cloned from development)
- [ ] 4. Domain entered; Primary vs Secondary chosen
- [ ] 5. **Username OFF**; email + password + code ON; phone OFF
- [ ] 6. Sessions 30 / 90 days
- [ ] 7. Sign-ups open; bot protection ON
- [ ] 8. Paths set (`/sign-in`, `/sign-up`, `/me`, `/`)
- [ ] 9. Application name **เฟรนเดย์**; sending domain added
- [ ] 10. Dev-instance `localhost` origins NOT carried over
- [ ] 11. DNS records added exactly as the Domains page prints them
- [ ] 12. Every Clerk record is **grey cloud / DNS only**
- [ ] 13. `dig` + `curl` clean; dashboard shows **Verified**
- [ ] 14. Webhook endpoint re-added on production; `user.deleted` only; new `whsec_`
- [ ] 15. Three keys written into `~/apps/ai-new-business/.env`
- [ ] 16. `NEXT_PUBLIC_SITE_URL` / Caddy block updated if the domain moved
- [ ] 17. `docker compose up -d --force-recreate paiduay` (or `deploy.sh`)
- [ ] 18. `/sign-up`: no "Development mode", no username field, mail from our domain
- [ ] 19. Webhook *Testing* → `user.deleted` → **200**
- [ ] 20. Every pre-switch seller re-bound at `/admin/card`; dev test users deleted

---

**UNVERIFIED (3):** (a) the exact DNS record names and CNAME targets — Clerk's docs deliberately defer to the Domains page; (b) whether production exposes an allowed-origins field in the dashboard UI, as opposed to only `allowed_origins` on the Backend API; (c) whether the **dev** instance's allowed origins can be set to include `https://frenday.xyz` from the dashboard, or only through `allowed_origins` on the Backend API — the public host runs on dev keys until this switch happens, so that field is what lets clerk-js load there at all.
