Appearance
Auth Server — Reference
apps/auth-server runs Better Auth on Fastify (port 3673). It's the identity layer for the platform: email/password sign-up, JWT issuance with JWKS for downstream verification, and SIWE wallet linking. Tables live in a dedicated better_auth Postgres schema.
Three-Layer Identity Model
public.users ← canonical UUID — every downstream FK references this
↓ 1:1
public.user_auth ← bridge: { user_id, auth_provider_user_id, auth_provider }
↓
better_auth.user ← Better Auth owned, in dedicated Postgres schema
better_auth.session
better_auth.account
better_auth.verification
better_auth.jwks
better_auth.walletAddressThe JWT sub claim is our users.id UUID, not Better Auth's text id. This means downstream services (game-server, evm-bank, poker app) never need to know what an "account row" looks like in Better Auth's schema — they get an opaque user id and use it as a foreign key.
Swapping auth providers later means rewriting user_auth rows; downstream queries never change.
Privy Auth Bridge
Privy is the auth provider (headless — the brat-themed login UI is ours; Privy runs the OAuth). Login is invite-code-gated social sign-in. The bridge slots Privy into the three-layer model: it verifies a Privy session server-side, maps the Privy user id (sub) to our users.id through user_auth, and mints our existing JWKS-signed JWT — so game-server / evm-bank verification is unchanged.
The mint reuses Better Auth's server-only signJWT (it signs an arbitrary payload with the same JWKS keypair /api/auth/jwks publishes — there is no public /api/auth/sign-jwt route, so no arbitrary-mint surface). No Better Auth session is involved.
Failure branches (each emits an auth.privy.bridge span with error.code): privy_token_missing (400), privy_token_invalid (401), invite_required (403, first sign-in with no code), invite_invalid (403, code expired / exhausted / unknown). A refused invite creates no account — the consume and the user-create share one transaction.
The Privy bridge is the ONLY public account-creation path
Registration is invite-gated, so every other way to create an account is closed at the network edge. A single onRequest chokepoint returns 403 account_creation_disabled for:
/api/auth/sign-up*— email/password signup (all path variants, e.g. a trailing slash)/api/auth/siwe/verify— Better Auth's SIWE anonymous sign-in creates a user, bypassing the invite gate/api/auth/sign-in/social+/api/auth/callback*— Better Auth's own OAuth (itssocialProvidersare also removed; Privy owns OAuth now)
What stays open (these don't create accounts): /api/auth/sign-in/email (admin login — one auth-server serves the poker-Privy and admin-BetterAuth apps; a poker user can never obtain a Better Auth account to sign into), /api/auth/siwe/nonce (the custom wallet-link route uses it), /api/auth/token, /jwks, /get-session. A blocked request logs at WARN (auth.blocked_path) so bypass probing is visible.
The test-token harness is fail-CLOSED
In dev/test, the bridge accepts a test-privy-token:<sub> value (and POST /api/dev/mint-jwt exists) so the auth flow is CI-testable without real OAuth. This is total identity forgery if it ever activates in production, so it fails closed: it is permitted ONLY when ENABLE_TEST_AUTH=true and NODE_ENV is an explicit dev value (development or test). Unset / unknown / production → off. Boot refuses to start if ENABLE_TEST_AUTH=true with a non-dev NODE_ENV (fail-loud).
Operator note: every environment must set
NODE_ENV—testfor the test profile,developmentfor local,productionfor prod (or simply never setENABLE_TEST_AUTHin prod). The harness is safe-by-default whenENABLE_TEST_AUTHis unset.
Public email/password signup is disabled — POST /api/auth/sign-up/email returns 403. (The in-process auth.api.signUpEmail used by the test harness is unaffected.) The password-recovery flow is retired alongside it.
Embedded wallets + fiat on-ramp are deferred to a post-Solana, Solana-native phase. This first cut is social login + invite-code registration only.
JWT Claims Schema
typescript
interface NumeroJwtClaims {
sub: string; // public.users.id (UUID)
email: string; // primary email from Better Auth
walletAddress: string | null; // primary linked wallet (EIP-55 checksum), or null
authMethod: string; // 'email' | 'siwe' | 'google' | 'apple'
iat: number;
exp: number;
iss: string; // baseURL of the issuing auth-server
aud: string; // baseURL of the issuing auth-server
}Algorithm: EdDSA / Ed25519. Verifiable with jose via the JWKS endpoint.
sub override: the JWT plugin's definePayload cannot override sub — Better Auth always sets it from getSubject (defaults to the Better Auth user.id). To put our users.id in sub, the plugin is configured with a custom getSubject that joins through user_auth (apps/auth-server/src/auth.ts).
Endpoints
Health
GET /health
→ { ok: true, latency: 12 }DB ping. Returns { ok: false, error } if Postgres is unreachable.
Better Auth Routes
All Better Auth routes are mounted under /api/auth/*. Documented at better-auth.com. Notable ones:
| Method | Path | Purpose |
|---|---|---|
POST | /api/auth/sign-up/email | Email/password signup. Triggers databaseHooks.user.create.after which inserts public.users + public.user_auth atomically. |
POST | /api/auth/sign-in/email | Email/password sign-in. Returns session token + sets session cookie. |
POST | /api/auth/sign-out | End session. |
GET | /api/auth/get-session | Returns the current session if any. |
GET | /api/auth/token | Issues a fresh JWT for the current session. |
GET | /api/auth/jwks | JWKS endpoint — public keys for downstream verification. |
POST | /api/auth/siwe/nonce | Issues a nonce for a wallet address + chain id. Used by /api/link-wallet (below). |
Avoid /api/auth/siwe/verify. It is "sign in with a wallet," not "link a wallet to my account." It always replaces the current session with the wallet's user. For linking, use POST /api/link-wallet instead.
POST /api/link-wallet
Custom route. Links a wallet to the currently signed-in user's account.
POST /api/link-wallet
Cookie: better-auth.session_token=... (or Authorization: Bearer <token>)
Origin: <one of trustedOrigins>
Content-Type: application/json
{
"message": "<SIWE-formatted message>",
"signature": "0x...",
"walletAddress": "0xABC...",
"chainId": 84532
}Flow:
- Reads the session via
auth.api.getSession({ headers }). 401 if not signed in. - Validates body. Checksums address with viem
getAddress. - Looks up the nonce from
better_auth.verification(identifiersiwe:{checksum}:{chainId}). Must have been issued by/api/auth/siwe/nonce. 401 if missing or expired. - Asserts the signed message contains the issued nonce (replay protection).
- Verifies the signature with viem
verifyMessage. - Sybil check: 409 if the wallet exists with a different
userId. - Idempotent: if the wallet exists with the current user, returns
{ ok: true, alreadyLinked: true }. - First wallet for the user becomes
isPrimary = true. - Inserts into
better_auth.walletAddress, deletes the verification row (one-time nonce).
Responses:
| Status | Body |
|---|---|
| 200 | { ok: true, walletAddress, chainId, isPrimary } |
| 200 (idempotent) | { ok: true, alreadyLinked: true, walletAddress, chainId, isPrimary } |
| 400 | { error: 'invalid walletAddress' } |
| 401 | { error: 'not signed in' } / { error: 'no nonce' } / { error: 'invalid signature' } / { error: 'message does not contain issued nonce' } |
| 409 | { error: 'wallet already linked to another account' } |
GET /api/wallets
List the current user's wallets.
GET /api/wallets
→ { wallets: [{ address, chainId, isPrimary, createdAt }, ...] }Session-scoped — users only see their own wallets. Empty array if not linked.
POST /api/wallets/set-primary
Atomically swap which wallet is primary.
POST /api/wallets/set-primary
{ "walletAddress": "0xABC..." }Behavior:
- 404 if the wallet doesn't belong to the current user (no information leak about whether the wallet exists at all).
- 200 +
{ ok: true, primary, alreadyPrimary: true }if it's already primary. - Otherwise: in a single transaction, sets all of the user's wallets to
isPrimary = falseand the target wallet toisPrimary = true. The system never observes a state with zero or two primaries.
After this call, the next JWT issued will carry the new primary in walletAddress.
DELETE /api/wallets/:address
Unlink a wallet.
DELETE /api/wallets/0xABC...Behavior:
- 404 if the wallet doesn't belong to the current user.
- Deletes the row.
- If the deleted wallet was primary AND the user has other wallets, the most-recently-created remaining wallet is auto-promoted to primary in the same transaction.
- Unlinking the user's only wallet is allowed. Subsequent JWTs will return
walletAddress: null.
Response: { ok: true, unlinked, wasPrimary }.
Solana wallet linkage (chain discriminator)
better_auth.walletAddress carries a chain column (evm | solana, default evm; migration 0069). It lets one table hold both EVM addresses and base58 Solana pubkeys with chain-isolated linkage reads:
| Endpoint | Returns |
|---|---|
GET /api/internal/user-wallets/:publicUserId | EVM wallets only (WHERE chain='evm') |
GET /api/internal/user-wallets-solana/:publicUserId | Solana wallets only (WHERE chain='solana') → { wallets: [{ address }] } |
Both are service-key gated (an x-service-key header). The Solana endpoint is consumed by the Solana cage's lib/linkage.ts (isSolanaWalletLinked) before crediting a deposit or paying a withdraw/redeem — the same trust model as the EVM linkage read.
Why the split, not one endpoint: EVM addresses are case-insensitive, but base58 Solana pubkeys are case-sensitive. The walletAddress_address_lower_uq unique index (lower(address)) is therefore partial — WHERE chain='evm'; lowercasing a base58 pubkey could false-merge two distinct wallets. Solana rows store the pubkey verbatim, chainId=0 (sentinel — Solana has no numeric chain id), and rely on the column-level case-sensitive address.unique().
The Solana link-write is linkSolanaWallet() (in routes/wallets.ts, span auth.wallet.solana_linked), called by the SIWS-verified link flow. There is no service-key internal link endpoint — that would let any backend assert linkage without ownership proof, a fund-safety hole; linking always requires a wallet signature.
POST /admin/invite-codes
Mint a registration invite code. Service-key auth (an x-service-key header). Not user-facing; the full operator dashboard is Privy's hosted console. This is the minimal seam to seed codes.
POST /admin/invite-codes
x-service-key: <service-key>
{ "maxUses": 1, "expiresInSeconds": 604800, "createdBy": "ops" }Body (all optional):
maxUses— total consumptions allowed. Default1(single-use).expiresInSeconds— seconds until expiry; omit for "never expires". A negative value mints an already-expired code (a test affordance; otherwise harmless — an expired code only ever refuses).createdBy— free-form minter identifier for audit. Default"admin".
Behavior:
401ifx-service-keyis missing/wrong;503if no service key is configured.201with{ code, maxUses, expiresAt, createdAt }on success.- Emits one
auth.invite.mintspan (otel.category=auth;error.code=invite_mint_failed+ ERROR status on the failure leg).
Mint a code with the mint-invite-code helper or directly:
bash
curl -sX POST "$AUTH_BASE_URL/admin/invite-codes" \
-H "x-service-key: <service-key>" \
-H 'content-type: application/json' \
-d '{"maxUses":1}'Registration gate
Registration is invite-code-gated. Codes live in the invite_codes table (a reused legacy faucet allowlist — no migration). At the first Privy sign-in for a new identity, the bridge calls consumeInviteCode(code) from @numero/db:
- The consume is atomic — the cap (
current_uses < max_uses) and expiry predicate live in the UPDATE'sWHERE, so two concurrent consumes of a single-use code can never both succeed. - A refused consume (
invalid/expired/exhausted) creates no account — the gate holds on the failure branch. - Returning users are recognized by their existing
user_authrow and need no code.
Configuration
| Env var | Required | Notes |
|---|---|---|
AUTH_SECRET | yes | Better Auth signing secret. Generate with openssl rand -base64 32. |
AUTH_BASE_URL | yes | The URL Better Auth uses as iss/aud and for cookie domain. Inside Docker this is the auth-server's internal service URL; on the host it's http://localhost:3673. |
AUTH_DOMAIN | yes | SIWE message domain. localhost in dev, skillspoker.com in prod. |
DATABASE_URL | yes | Postgres URL. The auth-server reads/writes both public.* (users, user_auth) and better_auth.*. |
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET | no | If both set, Google OAuth is enabled. Otherwise the provider is inert. |
APPLE_CLIENT_ID / APPLE_CLIENT_SECRET | no | Same gating as Google. |
PORT | no | Defaults to 3673. |
Trusted origins. Better Auth's CSRF middleware rejects requests whose Origin header doesn't match baseURL. Auth-server is configured with a trustedOrigins list covering localhost:3673, the poker/dealer/admin app ports, and the production domains. Add new origins in apps/auth-server/src/auth.ts.
Verifying JWTs Downstream
Other services verify JWTs independently — they call JWKS once and cache it locally. No callback to auth-server per request.
typescript
import { createRemoteJWKSet, jwtVerify } from 'jose';
const jwks = createRemoteJWKSet(new URL(process.env.AUTH_JWKS_URL!));
export async function verifyJwt(token: string) {
const { payload } = await jwtVerify(token, jwks);
return {
userId: payload.sub as string, // public.users.id UUID
email: payload.email as string,
walletAddress: (payload.walletAddress as string | null) ?? null,
authMethod: payload.authMethod as string,
};
}AUTH_JWKS_URL should resolve to the auth-server's /api/auth/jwks endpoint — the internal service URL inside Docker, http://localhost:3673/api/auth/jwks for host scripts, or https://auth.skillspoker.com/api/auth/jwks in production.
Password Recovery
Self-service reset.
Forgot password? → POST /api/auth/request-password-reset { email, redirectTo }
→ email (Resend) or console link (local)
→ {app}/reset-password?token=…
→ POST /api/auth/reset-password { newPassword, token }Transports (apps/auth-server/src/email/reset-password.ts):
| Env | Behavior |
|---|---|
| Email API key set | Real email via the transport provider's HTTP API (no-reply@skillspoker.com) |
| unset, non-production | Reset URL logged with the [password-reset-link] marker |
| unset, production | Boot assert fails — no silent no-transport state |
Rules baked in: unknown emails return the identical success response, and the response is the SAME for every email even during a transport outage (no account enumeration) — a transport failure is loud in telemetry (an ERROR auth.password_reset.requested span), never in the HTTP status (an HTTP-status loud failure was an enumeration oracle, since Better Auth only sends for known emails). The forgot flow passes an absolute redirectTo (window.location.origin/reset-password) so the emailed link redirects to the app origin, not the auth-server's. Expired and reused tokens are coded errors with a request-a-new-link path; a successful reset revokes every other session (revokeSessionsOnPasswordReset).
Token TTL: AUTH_RESET_TOKEN_TTL_SECONDS (default 3600; test profile 4 so the expiry leg stays fast). Spans: auth.password_reset.requested (reset.transport, error.code) + auth.password_reset.completed — the service's first telemetry contract, tripwire-enforced.
The "Forgot password?" affordance lives on every sign-in surface (admin + poker via the shared SignInCard, poker-next's DashboardAuth), and each app serves /reset-password OUTSIDE its auth gate.
Observability
Auth-server is fully wired into OTel via @numero/telemetry:
bootstrap.tscallsinitTelemetry('auth-server')before any other module loads. ES module hoisting requires this — it must be a separate entry point that uses dynamicimport('./index.js').service.name = auth-server,service.namespace = auth-server,service.instance.id = <hostname>.- Visible in Dash0 service catalog under the
devdataset. - Pino HTTP request logs stream automatically.
link-walletand wallet management routes log structured events:Wallet linked,Primary wallet changed,Wallet unlinked. Sybil rejections and signature failures log atwarnlevel.
Local Development
Two ways to run auth-server:
Host process (fast iteration, what tests run against):
bash
cd apps/auth-server
DATABASE_URL=postgresql://postgres@localhost:5432/numero \
AUTH_BASE_URL=http://localhost:3673 \
AUTH_SECRET=$(openssl rand -base64 32) \
pnpm devDocker (matches production wiring, runs as part of the local stack):
bash
pnpm env:pull local # once, or after a Doppler change
docker compose up -dThe container is named numero-auth-server-local-1, exposes port 3673, and uses the same better_auth schema as the host process. Don't run both at the same time — they'll fight for the port.
Test Suite
apps/auth-server/test-siwe.mjs is an end-to-end script that exercises the full auth + linking + wallet management flow. It signs SIWE messages with viem privateKeyToAccount and asserts JWT claims via jose. 24 assertions, all passing as of Phase 2:
| Section | Cases |
|---|---|
| A. Happy path link | walletA linked, primary, JWT claim correct |
| B. Sybil rejection | user2 cannot link walletA → 409, user2 JWT stays clean |
| C. Multi-wallet | walletB linked non-primary, walletA stays primary |
| D. JWKS verify | final JWT verifies cryptographically with jose |
| E. List | endpoint returns array, user2 isolation |
| F. Set-primary | swap works, JWT updates, cross-user 404 |
| G. Unlink primary | walletA auto-promotes, cross-user 404 |
| H. Unlink last wallet | walletAddress returns to null |
Run against either host or Docker auth-server:
bash
cd apps/auth-server
AUTH_BASE_URL=http://localhost:3673 node test-siwe.mjs