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
subwe 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)
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 Profile:
appleSub String? @unique— Sign in with ApplesubgoogleSub String? @unique— Googlesubemailalready exists; fill when IdP returns it (Apple may hide email)- Keep
deviceIdoptional unique for guests only (first open, web)
Migration: nullable columns; no backfill needed.
Server auth
Keep POST /api/auth/anonymous 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):
- 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 itsdeviceId. Call site willPurchases.logIn(durableId)+ restore so tier attaches to durable id (webhook already keys off profile UUID). - IdP first time: write
appleSub/googleSub(+ email) onto the current guest profile — no data move; that profile becomes durable. - Never trust client-supplied
subwithout 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-author@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/hooks/useAuth.tsx: addsignInWithApple,signInWithGoogle, state likeaccountLinked/email; guestensureAuthunchanged.- After successful identity API:
setToken;initPurchases(profileId)+Purchases.logIn; autorestorePurchases()thensyncIapEntitlements. - 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):
- 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
logIndurable UUID (already pattern insrc/lib/iap.ts). - After restore: client sync + webhook continue via
app_user_id. - Optional later: store RC
original_app_user_idon 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
- Schema migration + identity verify/mint routes + merge helper
- Client plugins + Profile sign-in / sign-out + RC logIn/restore after link
- Droplet/env docs (
docs/REVENUECAT.mdor newdocs/IDENTITY.md): Apple capability, Google OAuth clients, env vars - 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 + migrate |
| Auth | anonymous/route.ts, new apple/google routes, server/src/lib/auth.ts |
| IAP | src/lib/iap.ts, src/hooks/useAuth.tsx |
| UI | src/pages/Profile.tsx |
| Caps | capacitor.config.ts, ios/ capability, Android Google config |