---
name: Store account identity
overview: Replace DeviceID as the durable identity with Sign in with Apple (iOS) and Google Sign-In (Android), keep guest/anonymous bootstrap, merge guest profiles on link, and keep RevenueCat appUserID as the profile UUID so subscriptions and likes restore on a new device.
todos:
  - id: schema-identity
    content: Add Profile.appleSub / googleSub uniques + Prisma migration; keep optional deviceId for guests
    status: pending
  - id: server-identity-routes
    content: Implement POST /api/auth/apple and /api/auth/google with token verify + guest merge + JWT mint
    status: pending
  - id: client-signin
    content: Add Capacitor SIWA + Google Sign-In, useAuth methods, Profile UI guest vs linked
    status: pending
  - id: rc-after-link
    content: "After link: Purchases.logIn(durableId), restorePurchases, iap-client-sync; logOut on full sign-out"
    status: pending
  - id: docs-env-legal
    content: Document Apple/Google console setup + droplet env; update privacy/terms copy for cross-device restore
    status: pending
isProject: false
---

# Store-linked accounts (replace DeviceID as durable identity)

## Problem

Today identity is a **local DeviceID** (`onlynails.device_id` → unique `profiles.device_id` → JWT → RevenueCat `appUserID` = profile UUID). A new phone or “Sign out” creates a **new** profile, so likes/tier do not follow the user.

**Important constraint:** Apple/Google never expose App Store / Play **passwords**. The correct binding is:

- **Sign in with Apple** (iOS) and **Google Sign-In** (Android) → stable `sub` we store and verify server-side  
- **StoreKit / Play Billing restore** (already present) re-attaches the **subscription receipt** for whoever is signed into the store on that device  
- Together: same Apple/Google person → same OnlyNails profile + `Purchases.logIn(profileId)` + restore → subscription and app data on the new device

## Goals (from your choices)

- **Full account:** Apple (iOS) + Google (Android) as durable identity; subscription **and** likes/profile follow that account  
- **Guest OK:** Device-bound anonymous bootstrap remains for first use; **link** when the user signs in  
- **Web:** keep current anonymous DeviceID path (no store-account login required on the website)

```mermaid
sequenceDiagram
  participant App as Native app
  participant Guest as Guest profile deviceId
  participant IdP as Apple or Google
  participant API as OnlyNails API
  participant RC as RevenueCat

  App->>API: POST anonymous deviceId
  API-->>App: JWT guest profileId
  App->>IdP: Sign in with Apple or Google
  IdP-->>App: identity token
  App->>API: POST auth identity plus guest JWT
  API->>API: Verify token find or create by appleSub googleSub
  API->>API: Merge guest data if needed
  API-->>App: JWT durable profileId
  App->>RC: logIn profileId
  App->>RC: restorePurchases
  App->>API: iap-client-sync
```

## Data model

Extend [`server/prisma/schema.prisma`](server/prisma/schema.prisma) `Profile`:

- `appleSub String? @unique` — Sign in with Apple `sub`  
- `googleSub String? @unique` — Google `sub`  
- `email` already exists; fill when IdP returns it (Apple may hide email)  
- Keep `deviceId` **optional unique** for guests only (first open, web)

Migration: nullable columns; no backfill needed.

## Server auth

**Keep** [`POST /api/auth/anonymous`](server/src/app/api/auth/anonymous/route.ts) for guests/web.

**Add** identity routes (new files under `server/src/app/api/auth/`):

| Route | Body | Behavior |
|--------|------|----------|
| `POST /api/auth/apple` | `{ identityToken, fullName? }` | Verify Apple JWT (JWKS `appleid.apple.com`), require valid `aud` = iOS bundle `com.onlynails.ios`. Lookup `appleSub` or create profile; if request has Bearer **guest**, **merge** guest → durable (see below). Mint JWT for durable profile. |
| `POST /api/auth/google` | `{ idToken }` | Verify Google token (`aud` = Android OAuth client id). Same find/create/merge on `googleSub`. |

**Merge rules** (guest JWT present + sign-in):

1. **IdP row exists** (returning user on a new device): re-issue JWT for that **durable** profile; re-key guest-only rows (`UserSwipe`, `UserSeenImage`, credits ledger if free, etc.) onto durable id where there is no unique conflict; discard guest profile or null its `deviceId`. Call site will `Purchases.logIn(durableId)` + restore so tier attaches to durable id (webhook already keys off profile UUID).  
2. **IdP first time:** write `appleSub`/`googleSub` (+ email) onto the **current guest** profile — no data move; that profile becomes durable.  
3. Never trust client-supplied `sub` without verifying the IdP token.

Helpers: `server/src/lib/auth-apple.ts`, `server/src/lib/auth-google.ts`, shared merge in `server/src/lib/account-merge.ts`.

**Env (server):**

- Apple: team id / services id or bundle id for `aud`, optional Keys only if verifying with server secret (prefer identity-token JWKS path — no private key if client-only SIWA).  
- Google: `GOOGLE_ANDROID_CLIENT_ID` (and optional web client id for token verify).

Wire secrets on droplet via existing env scripts; never commit them.

## Client (Capacitor)

**Plugins** (add to root `package.json` + `npx cap sync`):

- Sign in with Apple: e.g. `@capacitor-community/apple-sign-in`  
- Google Sign-In: e.g. `@codetrix-studio/capacitor-google-auth` or `@capgo/capacitor-social-login`  
- Configure in Xcode (Sign in with Apple capability) and Google Cloud OAuth Android client (`com.onlynails.android` + SHA-1 from upload keystore)

**Auth surface**

- [`src/lib/api.ts`](src/lib/api.ts) / [`src/hooks/useAuth.tsx`](src/hooks/useAuth.tsx): add `signInWithApple`, `signInWithGoogle`, state like `accountLinked` / `email`; guest `ensureAuth` unchanged.  
- After successful identity API: `setToken`; `initPurchases(profileId)` + `Purchases.logIn`; auto `restorePurchases()` then `syncIapEntitlements`.  
- **Sign out:** clear local JWT + deviceId as today for guest reset; if account-linked, clear token, create new guest (or return to guest JWT), call `Purchases.logOut()` so the next person on the device does not inherit RC subscriber identity.

**UI** ([`src/pages/Profile.tsx`](src/pages/Profile.tsx)):

- Guest: show “Sign in with Apple” (iOS) / “Sign in with Google” (Android) + short copy: *Save likes and subscription across devices.*  
- Linked: show email or “Apple / Google account”, hide Device hash primary label.  
- Keep **Restore purchases** for edge cases (reinstall already signed in, delayed store propagation).  
- Prefer a soft prompt after upgrade/purchase if still guest: “Sign in to use this plan on other devices.”

## RevenueCat (minimal change)

Keep **app user id = `profiles.id`**. Change is **identity stability**, not RC product ids:

- After link: always `logIn` durable UUID (already pattern in [`src/lib/iap.ts`](src/lib/iap.ts)).  
- After restore: client sync + webhook continue via `app_user_id`.  
- Optional later: store RC `original_app_user_id` on profile for debugging — not required for v1.

## Legal / copy

Update privacy/terms lines that say subscription may not transfer: they **do** transfer when signed in with Apple/Google or restored with the same store account. Align Profile + any marketing strings.

## What we are not doing

- No collection of App Store / Play passwords  
- No email/password auth product  
- No required login on web for this phase  
- No drop of anonymous guest path for first-run friction  
- Store CI automation (previous plan) is orthogonal and unchanged

## Implementation order

1. Schema migration + identity verify/mint routes + merge helper  
2. Client plugins + Profile sign-in / sign-out + RC logIn/restore after link  
3. Droplet/env docs (`docs/REVENUECAT.md` or new `docs/IDENTITY.md`): Apple capability, Google OAuth clients, env vars  
4. Manual test matrix: guest → purchase → sign in → second device sign in + restore → likes + tier present; sign out isolation

## Main files

| Area | Files |
|------|--------|
| Schema | [`server/prisma/schema.prisma`](server/prisma/schema.prisma) + migrate |
| Auth | [`anonymous/route.ts`](server/src/app/api/auth/anonymous/route.ts), new `apple`/`google` routes, [`server/src/lib/auth.ts`](server/src/lib/auth.ts) |
| IAP | [`src/lib/iap.ts`](src/lib/iap.ts), [`src/hooks/useAuth.tsx`](src/hooks/useAuth.tsx) |
| UI | [`src/pages/Profile.tsx`](src/pages/Profile.tsx) |
| Caps | `capacitor.config.ts`, `ios/` capability, Android Google config |