ladder-auth-service
Auth Service — Documentación técnica
Section titled “Auth Service — Documentación técnica”1. Rol dentro de la arquitectura
Section titled “1. Rol dentro de la arquitectura”auth-service es la única fuente de verdad de identidad para todos los proyectos (LADDER Dev, CNM Cigars, Plataforma Pintores, y las APIs nuevas que vengan). Posee la DB de auth en Postgres. No conoce nada de la data de negocio de cada proyecto (eso vive en cada API por separado).
2. Dominio público, independiente del gateway
Section titled “2. Dominio público, independiente del gateway”Cloudflare ├── auth.ladderdev.com → auth-service (público, independiente) └── api.ladderdev.com → gateway (público, independiente)No va detrás del gateway. Razones:
- El gateway necesita consultarle el JWKS para verificar JWTs — si estuviera detrás del gateway habría una dependencia circular (el gateway dependería de algo que él mismo tendría que enrutar primero).
- Login/signup no tienen JWT todavía — es donde se generan. No tiene sentido pasar por una capa cuyo trabajo es “verificar JWT” para llegar al servicio que los crea.
- Es la capa de más bajo nivel de la infra. Si el gateway tuviera un bug o se cae, nadie debería perder la capacidad de loguearse — por eso
auth-serviceno depende de él.
El browser habla directo con auth-service para todo lo relacionado a sesión (login, signup, refresh de JWT, passkey). El browser habla con el gateway para todo lo relacionado a data de negocio de cada proyecto.
3. Modelo de datos
Section titled “3. Modelo de datos”user: una fila por persona real, email único globalmente (no por proyecto).organization: una fila por proyecto (ladder-api,cnm-cigars,plataforma-pintores, …).member: tabla puenteuser_id <-> organization_idcon rol — evita duplicar usuarios entre proyectos.ssoProvider: proveedores OAuth vinculados a unorganizationId— cada proyecto controla si ofrece Google u otro.passkey: credenciales WebAuthn ligadas auser, no a un proyecto — sirve para todos los proyectos del mismo usuario.serviceFlag: kill-switch/feature-flags (route:<org>,module:<org>:<modulo>), consultado porgatewayy por las APIs.
user ──< member >── organization ──< ssoProvider │ ├──< passkey └──< account, session (better-auth core)Schema (auth-service/src/db/schema.ts)
Section titled “Schema (auth-service/src/db/schema.ts)”import { pgTable, text, boolean, integer, timestamp, index } from 'drizzle-orm/pg-core'
export const user = pgTable('user', { id: text('id').primaryKey(), name: text('name'), email: text('email').notNull().unique(), // único GLOBAL, no por proyecto emailVerified: boolean('email_verified').notNull().default(false), image: text('image'), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(),})
export const session = pgTable('session', { id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), token: text('token').notNull().unique(), activeOrganizationId: text('active_organization_id'), expiresAt: timestamp('expires_at').notNull(), ipAddress: text('ip_address'), userAgent: text('user_agent'),})
export const account = pgTable('account', { id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), providerId: text('provider_id').notNull(), // 'credential' | 'google' | ... accountId: text('account_id').notNull(), password: text('password'), accessToken: text('access_token'), refreshToken: text('refresh_token'),})
export const organization = pgTable('organization', { id: text('id').primaryKey(), // slug: 'ladder-api', 'cnm-cigars', ... name: text('name').notNull(), createdAt: timestamp('created_at').notNull().defaultNow(),})
export const member = pgTable('member', { id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), organizationId: text('organization_id').notNull().references(() => organization.id, { onDelete: 'cascade' }), role: text('role').notNull().default('member'), createdAt: timestamp('created_at').notNull().defaultNow(),}, (table) => ({ orgUserIdx: index('member_org_user_idx').on(table.organizationId, table.userId),}))
export const ssoProvider = pgTable('sso_provider', { id: text('id').primaryKey(), organizationId: text('organization_id').notNull().references(() => organization.id, { onDelete: 'cascade' }), providerId: text('provider_id').notNull(), clientId: text('client_id').notNull(), clientSecret: text('client_secret').notNull(),})
export const passkey = pgTable('passkey', { id: text('id').primaryKey(), name: text('name'), publicKey: text('public_key').notNull(), userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), credentialID: text('credential_id').notNull(), counter: integer('counter').notNull(), deviceType: text('device_type').notNull(), backedUp: boolean('backed_up').notNull(), transports: text('transports'), aaguid: text('aaguid'), createdAt: timestamp('created_at').defaultNow(),})
export const serviceFlag = pgTable('service_flag', { key: text('key').primaryKey(), // 'route:cnm-cigars' | 'module:ladder-api:billing' enabled: boolean('enabled').notNull().default(true), reason: text('reason'), updatedAt: timestamp('updated_at').notNull().defaultNow(),})Better Auth genera la mayoría de este schema vía
npx @better-auth/cli generateuna vez configurados los plugins — úsalo como checklist. El índice compuesto demembery la tablaserviceFlagsí son manuales, añádelos tras generar.
4. Config (auth-service/src/auth.ts)
Section titled “4. Config (auth-service/src/auth.ts)”npm install better-auth @better-auth/passkey drizzle-orm pg hono @hono/node-serverimport { betterAuth } from 'better-auth'import { organization, sso, jwt } from 'better-auth/plugins'import { passkey } from '@better-auth/passkey'import { drizzleAdapter } from 'better-auth/adapters/drizzle'import { db } from './db/client'
export const auth = betterAuth({ database: drizzleAdapter(db, { provider: 'pg' }),
emailAndPassword: { enabled: true, // global — cada proyecto decide si lo muestra en su UI },
plugins: [ organization({ allowUserToCreateOrganization: false, // tú creas los proyectos, no el usuario final }), sso(), // Google/OIDC registrable por organizationId
passkey({ rpID: 'ladderdev.com', rpName: 'LADDER Dev', origin: 'https://auth.ladderdev.com', }),
jwt({ jwt: { expirationTime: '10m' }, // sesión stateless — el gateway verifica sin tocar esta DB }), ],
session: { additionalFields: { activeOrganizationId: { type: 'string', required: false }, }, },})5. Endpoints expuestos
Section titled “5. Endpoints expuestos”5.1 Better Auth core (/api/auth/*)
Section titled “5.1 Better Auth core (/api/auth/*)”import { Hono } from 'hono'import { serve } from '@hono/node-server'import { auth } from './auth'import { flagsRoute } from './routes/flags'
const app = new Hono()app.on(['GET', 'POST'], '/api/auth/*', (c) => auth.handler(c.req.raw))app.route('/', flagsRoute)
serve({ fetch: app.fetch, port: 4000 })Cubre: sign-up/sign-in email+password, OAuth/SSO por org, passkey (registro/login), refresh de sesión/JWT, /api/auth/jwks (consultado por el gateway).
5.2 Flags (/api/flags) — kill switch sin Redis
Section titled “5.2 Flags (/api/flags) — kill switch sin Redis”import { Hono } from 'hono'import { db } from '../db/client'import { serviceFlag } from '../db/schema'
export const flagsRoute = new Hono()
flagsRoute.get('/api/flags', async (c) => { const flags = await db.select().from(serviceFlag) return c.json(Object.fromEntries(flags.map(f => [f.key, f.enabled])))})
flagsRoute.post('/api/flags', async (c) => { if (c.req.header('X-Admin-Key') !== process.env.ADMIN_KEY) { return c.json({ error: 'unauthorized' }, 401) } const { key, enabled, reason } = await c.req.json() await db.insert(serviceFlag) .values({ key, enabled, reason, updatedAt: new Date() }) .onConflictDoUpdate({ target: serviceFlag.key, set: { enabled, reason, updatedAt: new Date() } }) return c.json({ ok: true })})Uso: curl -X POST https://auth.ladderdev.com/api/flags -H 'X-Admin-Key: ...' -d '{"key":"route:cnm-cigars","enabled":false}'
6. Cliente (frontend)
Section titled “6. Cliente (frontend)”import { createAuthClient } from 'better-auth/client'import { passkeyClient } from '@better-auth/passkey/client'
export const authClient = createAuthClient({ baseURL: 'https://auth.ladderdev.com', plugins: [passkeyClient()],})
await authClient.passkey.addPasskey({ name: 'Mi laptop' })await authClient.signIn.passkey()7. Crear un proyecto (organización) + SSO por proyecto
Section titled “7. Crear un proyecto (organización) + SSO por proyecto”await auth.api.createOrganization({ id: 'ladder-api', name: 'LADDER Dev API' })await auth.api.createOrganization({ id: 'cnm-cigars', name: 'CNM Cigars' })await auth.api.createOrganization({ id: 'plataforma-pintores', name: 'Plataforma Pintores' })
await auth.api.registerSSOProvider({ organizationId: 'cnm-cigars', providerId: 'google', clientId: process.env.CNM_GOOGLE_CLIENT_ID!, clientSecret: process.env.CNM_GOOGLE_CLIENT_SECRET!,})8. Offboarding — cliente que migra y se lleva su data
Section titled “8. Offboarding — cliente que migra y se lleva su data”const ORG_ID = 'cnm-cigars'const members = await db.select().from(member).where(eq(member.organizationId, ORG_ID))const userIds = members.map(m => m.userId)const users = await db.select().from(user).where(inArray(user.id, userIds))const accounts = await db.select().from(account).where(inArray(account.userId, userIds))fs.writeFileSync(`export-${ORG_ID}.json`, JSON.stringify({ users, members, accounts }, null, 2))// scripts/offboard-org.ts — cuidado con usuarios compartidos entre proyectosconst ORG_ID = 'cnm-cigars'const members = await db.select().from(member).where(eq(member.organizationId, ORG_ID))
for (const m of members) { await db.delete(member).where(eq(member.id, m.id)) // 1. quita acceso a ESTE proyecto
const remaining = await db.select().from(member).where(eq(member.userId, m.userId)) if (remaining.length === 0) { // 2. solo borra el user si no queda en ningún otro proyecto await db.delete(account).where(eq(account.userId, m.userId)) await db.delete(user).where(eq(user.id, m.userId)) }}9. Migración de la auth actual de ladder-api
Section titled “9. Migración de la auth actual de ladder-api”- Levantar
auth-servicecon su propia DB Postgres. - Crear la organización
ladder-api. - Migrar filas
ladder-api.user→auth-service.user(mismoid). - Crear
memberconorganizationId = 'ladder-api'por cada usuario migrado. - Migrar
account(passwords);sessiones opcional — forzar re-login es más simple. - Apagar el Better Auth local de
ladder-api, dejar solo el middleware que leeX-User-Iddel gateway. - Apuntar
ladder-webaauth-servicepara login/signup, y algatewaypara las llamadas a la API.
const ORG_ID = 'ladder-api'const users = await oldDb.select().from(oldUser)
for (const u of users) { await newDb.insert(newUser).values({ id: u.id, name: u.name, email: u.email, emailVerified: u.emailVerified, image: u.image, createdAt: u.createdAt, updatedAt: u.updatedAt, }).onConflictDoNothing() // si el email ya existe (otro proyecto lo migró antes), no duplica
await newDb.insert(member).values({ id: randomUUID(), userId: u.id, organizationId: ORG_ID, role: 'member' })}
const accounts = await oldDb.select().from(oldAccount)for (const a of accounts) { await newDb.insert(newAccount).values({ id: a.id, userId: a.userId, providerId: a.providerId, accountId: a.accountId, password: a.password, }).onConflictDoNothing()}Correr en entorno controlado, con backup de ambas DBs antes.
Checklist de corte
Section titled “Checklist de corte”-
auth-servicedesplegado con dominio público propio (auth.ladderdev.com) - Organización
ladder-apicreada - Script de migración corrido y verificado
-
gatewaycon ruta/api/ladderapuntando aladder-api -
ladder-apien red interna, sin puerto público - Middleware de
ladder-apileyendoX-User-Id+ validandoX-Gateway-Secret -
ladder-webapuntando login/signup aauth-service, resto de llamadas algateway - Better Auth local de
ladder-apidesinstalado - Sesiones viejas invalidadas (forzar re-login una vez)
10. Variables de entorno
Section titled “10. Variables de entorno”Nombres reales (ver src/env.ts, .env.example, better-auth.config.ts) — todas vía Infisical en producción, nunca hardcodeadas:
PORT=4000DATABASE_URL=postgres://...BETTER_AUTH_SECRET=BETTER_AUTH_URL=https://auth.ladderdev.comCORS_ORIGIN=https://gateway.ladderdev.com,https://ladderdev.comADMIN_API_KEY= # para POST /api/flags, header X-Admin-KeyGATEWAY_SHARED_SECRET= # mismo valor que en ladder-gateway, valida origen del proxyCOOKIE_DOMAIN=.ladderdev.com # cross-subdomain cookie, para que ladder-web reciba la sesiónGOOGLE_CLIENT_ID=GOOGLE_CLIENT_SECRET=GLITCHTIP_DSN= # opcional, error tracking (no-op si vacío)