import Figure from '../components/Figure.astro';
import InfoBox from '../components/InfoBox.astro';

When player accounts first shipped to sync daily challenges and duels across devices, [Google sign-in was the only identity provider available](/posts/no-email-required#no-email-no-sync-code). That setup satisfied the immediate goal of syncing scores without maintaining custom password infrastructure, but it left the entire account model strictly dependent on a Google identity. That limitation ended today. WebAuthn passkeys now arrive as a parallel, independent credential type on the same underlying player account, allowing players to register, authenticate, and manage cross-device profiles using device biometrics or platform credentials without needing a Google account.

## Parallel credentials, not a replacement

Rather than treating passkeys as a secondary feature layered on top of Google authentication, the architecture treats both credentials as equals. A player can register an account using only a passkey, attach additional passkeys over time, or optionally link a Google account later. Storing credentials required an additive schema change: a dedicated credentials table tracking credential identifiers, device labels derived from user agent headers, signature counters for clone detection, and public key bytes. In a schema where every previous binary blob had been serialized as text, storing the raw public key as a binary column marks a deliberate first exception following WebAuthn storage conventions. The database continues to require at least one active credential per account, enforced atomically inside a row-locking transaction so concurrent credential removals cannot leave an orphan account behind.

<InfoBox title="Enforcing invariants under concurrency" variant="note">
Allowing players to remove passkeys introduces a risk: if an account has no Google identity, removing its final passkey would strand the account forever with no valid login path. A transaction takes an explicit row lock on the account before evaluating credential counts, ensuring that two simultaneous deletion requests from separate tabs cannot race past the check and leave an account with zero credentials.
</InfoBox>

## Three throwaway prototypes for one modal

Integrating passkeys immediately forced a rethink of the user interface. The initial implementation simply dropped new buttons into the settings page, creating a confusing four-button stack alongside existing game preferences. Clicking a sign-in prompt from the main menu or a post-game recap transported the player to the bottom of a busy settings screen with zero framing. To resolve this, a prototype branch explored three distinct directions: reshaped settings with progressive disclosure, a dedicated sign-in page on its own route, and a lightweight in-page modal dialog. Testing the variations on a live development server confirmed that the in-page modal felt the most focused, keeping the player directly in their current context rather than navigating them across pages.

<Figure caption="The account modal stays open across state transitions, swapping views in place rather than dismissing.">

```mermaid
flowchart TD
    accTitle: Account modal view transitions
    accDescr: Flowchart illustrating that the modal switches internally between signed-out tabs and signed-in credential management without closing itself.
    SO["Signed-out view<br/>(Sign in / Create tabs)"] -->|Passkey ceremony or OAuth callback| SI["Signed-in view<br/>(Stats and credentials)"]
    SI -->|Sign out or delete| SO
    SI -->|Add passkey or link Google| SI
    class SI accent
```

</Figure>

## Separate tabs for separate ceremonies

The resulting modal component handles both initial onboarding and ongoing credential management while adhering to a deliberate rule: it never dismisses itself on actions that alter authentication state. Creating a passkey-only account swaps the modal view directly to signed-in credential management, while signing out immediately displays the login options again. Furthermore, WebAuthn ceremonies cannot infer player intent from a single click, since registration and authentication require different browser calls. To prevent empty password manager prompts when an unregistered visitor clicks to sign in, the signed-out interface splits into separate "Sign in" and "Create account" tabs. Google sign-in appears on both tabs under identical wording because OAuth handles both outcomes in one flow, whereas passkey actions are separated by intention.

## Surviving the OAuth redirect

Handling external redirects required bridging the modal with OAuth's departure from the page. While WebAuthn ceremonies resolve entirely client-side without page reloads, Google authentication leaves the application for a third-party consent screen, destroying in-memory dialog state. The OAuth callback now redirects directly to the main menu with a validated query parameter that reopens the modal automatically, presenting the resulting status without attempting to reconstruct complex originating history. Review passes across the implementation also caught critical edge cases: driver error handling was updated to unwrap underlying database errors when verifying uniqueness constraints, and dismissed authenticator prompts are handled gracefully without locking interactive buttons.

```typescript
// src/lib/server/db/pgErrors.ts
export function isUniqueViolation(err: unknown): boolean {
	const codeOf = (e: unknown) => (e as { code?: unknown } | null | undefined)?.code;
	return codeOf(err) === '23505' || codeOf((err as { cause?: unknown } | null | undefined)?.cause) === '23505';
}
```

With the dual-credential foundation and consolidated modal in place, the account system no longer presumes a single identity provider. The next phase will focus on observing real-world authenticator behavior across different operating systems and browsers, verifying how credential backup flags and multi-device passkey synchronization behave in everyday play.