feat(auth): GitHub OAuth login + SMS one-time-code login
Some checks failed
Deploy to Production / deploy (push) Failing after 1m8s

GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.

SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
  rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
  user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
  per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
  (number -> 6-digit code with one-time-code autofill).

SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marco Sadjadi 2026-05-21 22:59:58 +02:00
parent f5107922a0
commit cc3c5ad444
9 changed files with 710 additions and 115 deletions

View File

@ -11,10 +11,18 @@ BETTER_AUTH_URL=http://localhost:3001
NEXT_PUBLIC_APP_URL=http://localhost:3001 NEXT_PUBLIC_APP_URL=http://localhost:3001
NEXT_PUBLIC_API_URL=http://localhost:4000 NEXT_PUBLIC_API_URL=http://localhost:4000
# ---- GitHub OAuth (optional in dev) ---- # ---- GitHub OAuth ("Continue with GitHub") ----
# Create at https://github.com/settings/applications/new
# Authorized callback URL: <CONTROL_PLANE_PUBLIC_URL>/v1/auth/github/callback
GITHUB_OAUTH_ID= GITHUB_OAUTH_ID=
GITHUB_OAUTH_SECRET= GITHUB_OAUTH_SECRET=
# ---- Twilio SMS (phone one-time-code login) ----
# Credentials + a verified sender number from the Twilio console.
TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=
TWILIO_SMS_FROM=
# ---- Google OAuth (optional — "Continue with Google") ---- # ---- Google OAuth (optional — "Continue with Google") ----
# Create at https://console.cloud.google.com/apis/credentials # Create at https://console.cloud.google.com/apis/credentials
# Authorized redirect URI must be: <CONTROL_PLANE_PUBLIC_URL>/v1/auth/google/callback # Authorized redirect URI must be: <CONTROL_PLANE_PUBLIC_URL>/v1/auth/google/callback

View File

@ -18,6 +18,11 @@ const Env = z.object({
ADMIN_NAME: z.string().optional(), ADMIN_NAME: z.string().optional(),
GOOGLE_OAUTH_ID: z.string().optional(), GOOGLE_OAUTH_ID: z.string().optional(),
GOOGLE_OAUTH_SECRET: z.string().optional(), GOOGLE_OAUTH_SECRET: z.string().optional(),
GITHUB_OAUTH_ID: z.string().optional(),
GITHUB_OAUTH_SECRET: z.string().optional(),
TWILIO_ACCOUNT_SID: z.string().optional(),
TWILIO_AUTH_TOKEN: z.string().optional(),
TWILIO_SMS_FROM: z.string().optional(),
}); });
export const config = Env.parse({ export const config = Env.parse({
@ -35,6 +40,11 @@ export const config = Env.parse({
ADMIN_NAME: process.env.ADMIN_NAME, ADMIN_NAME: process.env.ADMIN_NAME,
GOOGLE_OAUTH_ID: process.env.GOOGLE_OAUTH_ID, GOOGLE_OAUTH_ID: process.env.GOOGLE_OAUTH_ID,
GOOGLE_OAUTH_SECRET: process.env.GOOGLE_OAUTH_SECRET, GOOGLE_OAUTH_SECRET: process.env.GOOGLE_OAUTH_SECRET,
GITHUB_OAUTH_ID: process.env.GITHUB_OAUTH_ID,
GITHUB_OAUTH_SECRET: process.env.GITHUB_OAUTH_SECRET,
TWILIO_ACCOUNT_SID: process.env.TWILIO_ACCOUNT_SID,
TWILIO_AUTH_TOKEN: process.env.TWILIO_AUTH_TOKEN,
TWILIO_SMS_FROM: process.env.TWILIO_SMS_FROM,
}); });
// INFRA-001: refuse to boot in production with the placeholder encryption key. // INFRA-001: refuse to boot in production with the placeholder encryption key.

30
apps/api/src/lib/sms.ts Normal file
View File

@ -0,0 +1,30 @@
import { config } from '../config.js';
/** True when Twilio credentials + a sender number are all configured. */
export function smsConfigured(): boolean {
return Boolean(config.TWILIO_ACCOUNT_SID && config.TWILIO_AUTH_TOKEN && config.TWILIO_SMS_FROM);
}
/** Send an SMS via the Twilio REST API (no SDK — a single authenticated POST). */
export async function sendSms(to: string, body: string): Promise<void> {
const { TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_SMS_FROM } = config;
if (!TWILIO_ACCOUNT_SID || !TWILIO_AUTH_TOKEN || !TWILIO_SMS_FROM) {
throw new Error('sms_not_configured');
}
const auth = Buffer.from(`${TWILIO_ACCOUNT_SID}:${TWILIO_AUTH_TOKEN}`).toString('base64');
const res = await fetch(
`https://api.twilio.com/2010-04-01/Accounts/${TWILIO_ACCOUNT_SID}/Messages.json`,
{
method: 'POST',
headers: {
authorization: `Basic ${auth}`,
'content-type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ To: to, From: TWILIO_SMS_FROM, Body: body }),
},
);
if (!res.ok) {
const detail = await res.text().catch(() => '');
throw new Error(`twilio_${res.status}: ${detail.slice(0, 180)}`);
}
}

View File

@ -1,6 +1,4 @@
import type { FastifyInstance } from 'fastify';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { z } from 'zod';
import { import {
adminSettings, adminSettings,
auditLog, auditLog,
@ -20,11 +18,13 @@ import {
users, users,
} from '@bmm/db'; } from '@bmm/db';
import { SYSTEM_PROMPT } from '@bmm/llm'; import { SYSTEM_PROMPT } from '@bmm/llm';
import { requireAdmin } from '../plugins/session.js'; import type { FastifyInstance } from 'fastify';
import { getRedis } from '../lib/redis.js'; import { z } from 'zod';
import { getBuildQueue } from '../lib/queue.js';
import { audit } from '../lib/audit.js'; import { audit } from '../lib/audit.js';
import { encryptionStatus, rotateKeys } from '../lib/crypto.js'; import { encryptionStatus, rotateKeys } from '../lib/crypto.js';
import { getBuildQueue } from '../lib/queue.js';
import { getRedis } from '../lib/redis.js';
import { requireAdmin } from '../plugins/session.js';
const db = createDb(); const db = createDb();
@ -47,11 +47,26 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
newUsersLast7d, newUsersLast7d,
newServersLast7d, newServersLast7d,
] = await Promise.all([ ] = await Promise.all([
db.select({ c: count() }).from(users).then((r) => Number(r[0]?.c ?? 0)), db
db.select({ c: count() }).from(organizations).then((r) => Number(r[0]?.c ?? 0)), .select({ c: count() })
db.select({ c: count() }).from(mcpServers).then((r) => Number(r[0]?.c ?? 0)), .from(users)
db.select({ c: count() }).from(builds).then((r) => Number(r[0]?.c ?? 0)), .then((r) => Number(r[0]?.c ?? 0)),
db.select({ c: count() }).from(toolCallMetrics).then((r) => Number(r[0]?.c ?? 0)), db
.select({ c: count() })
.from(organizations)
.then((r) => Number(r[0]?.c ?? 0)),
db
.select({ c: count() })
.from(mcpServers)
.then((r) => Number(r[0]?.c ?? 0)),
db
.select({ c: count() })
.from(builds)
.then((r) => Number(r[0]?.c ?? 0)),
db
.select({ c: count() })
.from(toolCallMetrics)
.then((r) => Number(r[0]?.c ?? 0)),
db db
.select({ c: count() }) .select({ c: count() })
.from(mcpServers) .from(mcpServers)
@ -81,11 +96,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
.groupBy(mcpServers.status); .groupBy(mcpServers.status);
// Recent activity from audit log // Recent activity from audit log
const recent = await db const recent = await db.select().from(auditLog).orderBy(desc(auditLog.createdAt)).limit(15);
.select()
.from(auditLog)
.orderBy(desc(auditLog.createdAt))
.limit(15);
// Builds in last 24h with status // Builds in last 24h with status
const recentBuilds = await db const recentBuilds = await db
@ -123,12 +134,20 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
const parsed = Query.safeParse(req.query); const parsed = Query.safeParse(req.query);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_query' }); if (!parsed.success) return reply.code(400).send({ error: 'invalid_query' });
const rows = await db.select().from(users).orderBy(desc(users.createdAt)).limit(parsed.data.limit); const rows = await db
.select()
.from(users)
.orderBy(desc(users.createdAt))
.limit(parsed.data.limit);
const filtered = parsed.data.search const filtered = parsed.data.search
? rows.filter((u) => ? rows.filter((u) => {
u.email.toLowerCase().includes(parsed.data.search!.toLowerCase()) || const q = parsed.data.search!.toLowerCase();
(u.name?.toLowerCase().includes(parsed.data.search!.toLowerCase()) ?? false), return (
) (u.email?.toLowerCase().includes(q) ?? false) ||
(u.phone?.toLowerCase().includes(q) ?? false) ||
(u.name?.toLowerCase().includes(q) ?? false)
);
})
: rows; : rows;
// attach org + server count // attach org + server count
@ -280,7 +299,12 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
const rows = await db const rows = await db
.select({ .select({
server: mcpServers, server: mcpServers,
org: { id: organizations.id, name: organizations.name, slug: organizations.slug, plan: organizations.plan }, org: {
id: organizations.id,
name: organizations.name,
slug: organizations.slug,
plan: organizations.plan,
},
}) })
.from(mcpServers) .from(mcpServers)
.innerJoin(organizations, eq(organizations.id, mcpServers.orgId)) .innerJoin(organizations, eq(organizations.id, mcpServers.orgId))
@ -299,7 +323,11 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
const p = Params.safeParse(req.params); const p = Params.safeParse(req.params);
if (!p.success) return reply.code(400).send({ error: 'invalid_id' }); if (!p.success) return reply.code(400).send({ error: 'invalid_id' });
const [server] = await db.select().from(mcpServers).where(eq(mcpServers.id, p.data.id)).limit(1); const [server] = await db
.select()
.from(mcpServers)
.where(eq(mcpServers.id, p.data.id))
.limit(1);
if (!server) return reply.code(404).send({ error: 'not_found' }); if (!server) return reply.code(404).send({ error: 'not_found' });
// Get last build's prompt // Get last build's prompt
@ -357,7 +385,11 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
const p = Params.safeParse(req.params); const p = Params.safeParse(req.params);
if (!p.success) return reply.code(400).send({ error: 'invalid_id' }); if (!p.success) return reply.code(400).send({ error: 'invalid_id' });
const [server] = await db.select().from(mcpServers).where(eq(mcpServers.id, p.data.id)).limit(1); const [server] = await db
.select()
.from(mcpServers)
.where(eq(mcpServers.id, p.data.id))
.limit(1);
if (!server) return reply.code(404).send({ error: 'not_found' }); if (!server) return reply.code(404).send({ error: 'not_found' });
await db.delete(mcpServers).where(eq(mcpServers.id, server.id)); await db.delete(mcpServers).where(eq(mcpServers.id, server.id));
@ -468,10 +500,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
redisOk = pong === 'PONG'; redisOk = pong === 'PONG';
const q = getBuildQueue(); const q = getBuildQueue();
const counts = await q.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'); const counts = await q.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed');
queueDepth = queueDepth = (counts.waiting ?? 0) + (counts.active ?? 0) + (counts.delayed ?? 0);
(counts.waiting ?? 0) +
(counts.active ?? 0) +
(counts.delayed ?? 0);
} catch { } catch {
// remains false // remains false
} }

View File

@ -1,9 +1,11 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { import {
consumeMagicLink, consumeMagicLink,
consumeSmsCode,
destroySession, destroySession,
getSession, getSession,
issueMagicLink, issueMagicLink,
issueSmsCode,
loginWithPassword, loginWithPassword,
upsertOAuthLogin, upsertOAuthLogin,
} from '@bmm/auth'; } from '@bmm/auth';
@ -11,6 +13,7 @@ import type { FastifyInstance } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import { config } from '../config.js'; import { config } from '../config.js';
import { audit } from '../lib/audit.js'; import { audit } from '../lib/audit.js';
import { sendSms, smsConfigured } from '../lib/sms.js';
const SESSION_COOKIE = 'bmm_session'; const SESSION_COOKIE = 'bmm_session';
const OAUTH_STATE_COOKIE = 'bmm_oauth_state'; const OAUTH_STATE_COOKIE = 'bmm_oauth_state';
@ -46,6 +49,29 @@ function googleConfigured(): boolean {
return Boolean(config.GOOGLE_OAUTH_ID && config.GOOGLE_OAUTH_SECRET); return Boolean(config.GOOGLE_OAUTH_ID && config.GOOGLE_OAUTH_SECRET);
} }
function githubConfigured(): boolean {
return Boolean(config.GITHUB_OAUTH_ID && config.GITHUB_OAUTH_SECRET);
}
function githubRedirectUri(): string {
return `${config.CONTROL_PLANE_PUBLIC_URL}/v1/auth/github/callback`;
}
// In-memory per-IP throttle for SMS-code requests — SMS costs money per send,
// so cap how often one IP can trigger a send regardless of which number.
const smsIpHits = new Map<string, number[]>();
function smsIpRateOk(ip: string, max = 5, windowMs = 10 * 60 * 1000): boolean {
const now = Date.now();
const hits = (smsIpHits.get(ip) ?? []).filter((t) => now - t < windowMs);
if (hits.length >= max) {
smsIpHits.set(ip, hits);
return false;
}
hits.push(now);
smsIpHits.set(ip, hits);
return true;
}
export async function authRoutes(app: FastifyInstance): Promise<void> { export async function authRoutes(app: FastifyInstance): Promise<void> {
app.post('/v1/auth/magic-link', async (req, reply) => { app.post('/v1/auth/magic-link', async (req, reply) => {
const Body = z.object({ email: z.string().email() }); const Body = z.object({ email: z.string().email() });
@ -170,7 +196,11 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
// Which third-party login providers are configured. Lets the UI hide the // Which third-party login providers are configured. Lets the UI hide the
// Google button when no credentials are set, instead of showing a dead button. // Google button when no credentials are set, instead of showing a dead button.
app.get('/v1/auth/providers', async (_req, reply) => { app.get('/v1/auth/providers', async (_req, reply) => {
return reply.send({ google: googleConfigured() }); return reply.send({
google: googleConfigured(),
github: githubConfigured(),
sms: smsConfigured(),
});
}); });
// Step 1: hand the browser off to Google's consent screen. // Step 1: hand the browser off to Google's consent screen.
@ -276,4 +306,177 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
return reply.redirect(`${loginUrl}?error=google_failed`); return reply.redirect(`${loginUrl}?error=google_failed`);
} }
}); });
// ---- GitHub OAuth ----
app.get('/v1/auth/github', async (_req, reply) => {
if (!config.GITHUB_OAUTH_ID || !config.GITHUB_OAUTH_SECRET) {
return reply.code(503).send({ error: 'github_oauth_not_configured' });
}
const state = crypto.randomBytes(16).toString('base64url');
reply.setCookie(OAUTH_STATE_COOKIE, state, {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: config.NODE_ENV === 'production',
maxAge: 600,
});
const url = new URL('https://github.com/login/oauth/authorize');
url.searchParams.set('client_id', config.GITHUB_OAUTH_ID);
url.searchParams.set('redirect_uri', githubRedirectUri());
url.searchParams.set('scope', 'read:user user:email');
url.searchParams.set('state', state);
return reply.redirect(url.toString());
});
app.get('/v1/auth/github/callback', async (req, reply) => {
const loginUrl = `${config.NEXT_PUBLIC_APP_URL}/login`;
const Query = z.object({
code: z.string().min(8).optional(),
state: z.string().min(8).optional(),
error: z.string().optional(),
});
const q = Query.safeParse(req.query);
const cookieState = req.cookies[OAUTH_STATE_COOKIE];
reply.clearCookie(OAUTH_STATE_COOKIE, { path: '/' });
if (!q.success || q.data.error || !q.data.code || !q.data.state) {
return reply.redirect(`${loginUrl}?error=github_failed`);
}
if (
!cookieState ||
cookieState.length !== q.data.state.length ||
!crypto.timingSafeEqual(Buffer.from(cookieState), Buffer.from(q.data.state))
) {
return reply.redirect(`${loginUrl}?error=github_state`);
}
if (!config.GITHUB_OAUTH_ID || !config.GITHUB_OAUTH_SECRET) {
return reply.redirect(`${loginUrl}?error=github_failed`);
}
try {
const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: config.GITHUB_OAUTH_ID,
client_secret: config.GITHUB_OAUTH_SECRET,
code: q.data.code,
redirect_uri: githubRedirectUri(),
}),
});
if (!tokenRes.ok) throw new Error(`token_exchange_${tokenRes.status}`);
const tokens = (await tokenRes.json()) as { access_token?: string };
if (!tokens.access_token) throw new Error('no_access_token');
// GitHub's API rejects requests without a User-Agent header.
const ghHeaders = {
authorization: `Bearer ${tokens.access_token}`,
accept: 'application/vnd.github+json',
'user-agent': 'BuildMyMCPServer',
};
const userRes = await fetch('https://api.github.com/user', { headers: ghHeaders });
if (!userRes.ok) throw new Error(`user_fetch_${userRes.status}`);
const ghUser = (await userRes.json()) as { name?: string; login?: string };
// /user omits the email when it is private — /user/emails always lists it.
const emailRes = await fetch('https://api.github.com/user/emails', { headers: ghHeaders });
if (!emailRes.ok) throw new Error(`email_fetch_${emailRes.status}`);
const emails = (await emailRes.json()) as Array<{
email: string;
primary: boolean;
verified: boolean;
}>;
const primary = emails.find((e) => e.primary && e.verified) ?? emails.find((e) => e.verified);
if (!primary) throw new Error('no_verified_email');
const session = await upsertOAuthLogin(
{ email: primary.email, name: ghUser.name ?? ghUser.login ?? null },
{ ipAddress: req.ip, userAgent: req.headers['user-agent'] },
);
reply.setCookie(SESSION_COOKIE, session.sessionToken, {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: config.NODE_ENV === 'production',
maxAge: 30 * 24 * 60 * 60,
});
await audit({
orgId: session.orgId,
userId: session.userId,
action: 'auth.login',
resourceType: 'session',
metadata: { email: session.email, provider: 'github' },
ipAddress: req.ip,
});
return reply.redirect(`${config.NEXT_PUBLIC_APP_URL}/dashboard`);
} catch (err) {
app.log.warn({ err }, 'github oauth callback failed');
return reply.redirect(`${loginUrl}?error=github_failed`);
}
});
// ---- SMS one-time-code login ----
app.post('/v1/auth/sms/request', async (req, reply) => {
if (!smsConfigured()) return reply.code(503).send({ error: 'sms_not_configured' });
const Body = z.object({ phone: z.string().min(8).max(24) });
const parsed = Body.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_phone' });
if (!smsIpRateOk(req.ip)) return reply.code(429).send({ error: 'rate_limited' });
try {
const { phone, code } = await issueSmsCode(parsed.data.phone);
await sendSms(phone, `${code} is your BuildMyMCPServer login code. Valid for 10 minutes.`);
return reply.send({ ok: true });
} catch (e) {
const msg = (e as Error).message;
if (msg === 'invalid_phone') return reply.code(400).send({ error: 'invalid_phone' });
if (msg === 'rate_limited') return reply.code(429).send({ error: 'rate_limited' });
app.log.warn({ err: e }, 'sms request failed');
return reply.code(400).send({ error: 'sms_request_failed' });
}
});
app.post('/v1/auth/sms/verify', async (req, reply) => {
const Body = z.object({
phone: z.string().min(8).max(24),
code: z.string().regex(/^\d{6}$/),
});
const parsed = Body.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
try {
const session = await consumeSmsCode(parsed.data.phone, parsed.data.code, {
ipAddress: req.ip,
userAgent: req.headers['user-agent'],
});
reply.setCookie(SESSION_COOKIE, session.sessionToken, {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: config.NODE_ENV === 'production',
maxAge: 30 * 24 * 60 * 60,
});
await audit({
orgId: session.orgId,
userId: session.userId,
action: 'auth.login',
resourceType: 'session',
metadata: { provider: 'sms' },
ipAddress: req.ip,
});
return reply.send({ ok: true, user: { id: session.userId, orgId: session.orgId } });
} catch (e) {
const msg = (e as Error).message;
const status: Record<string, number> = {
invalid_or_expired_code: 400,
invalid_code: 400,
too_many_attempts: 429,
invalid_phone: 400,
};
if (status[msg]) return reply.code(status[msg]).send({ error: msg });
app.log.warn({ err: e }, 'sms verify failed');
return reply.code(400).send({ error: 'sms_verify_failed' });
}
});
} }

View File

@ -1,6 +1,5 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import type { FastifyInstance } from 'fastify'; import { getSession } from '@bmm/auth';
import { z } from 'zod';
import { import {
and, and,
builds, builds,
@ -16,12 +15,13 @@ import {
users, users,
} from '@bmm/db'; } from '@bmm/db';
import { GeneratorSpec } from '@bmm/types'; import { GeneratorSpec } from '@bmm/types';
import { getSession } from '@bmm/auth'; import type { FastifyInstance } from 'fastify';
import { requireAuth, requireAdmin } from '../plugins/session.js'; import { z } from 'zod';
import { audit } from '../lib/audit.js'; import { audit } from '../lib/audit.js';
import { cacheSpec, cachePrebuiltCode } from '../lib/preview-cache.js';
import { getRedis } from '../lib/redis.js';
import { stopContainer } from '../lib/docker.js'; import { stopContainer } from '../lib/docker.js';
import { cachePrebuiltCode, cacheSpec } from '../lib/preview-cache.js';
import { getRedis } from '../lib/redis.js';
import { requireAdmin, requireAuth } from '../plugins/session.js';
const db = createDb(); const db = createDb();
@ -163,7 +163,11 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
let slug = baseSlug || `template-${crypto.randomBytes(3).toString('hex')}`; let slug = baseSlug || `template-${crypto.randomBytes(3).toString('hex')}`;
let attempt = 0; let attempt = 0;
while (true) { while (true) {
const existing = await db.select({ id: templates.id }).from(templates).where(eq(templates.slug, slug)).limit(1); const existing = await db
.select({ id: templates.id })
.from(templates)
.where(eq(templates.slug, slug))
.limit(1);
if (existing.length === 0) break; if (existing.length === 0) break;
attempt++; attempt++;
slug = `${baseSlug}-${crypto.randomBytes(2).toString('hex')}`; slug = `${baseSlug}-${crypto.randomBytes(2).toString('hex')}`;
@ -364,7 +368,7 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
.where(and(eq(mcpServers.templateId, t.id), eq(mcpServers.status, 'live'))); .where(and(eq(mcpServers.templateId, t.id), eq(mcpServers.status, 'live')));
return { return {
...t, ...t,
ownerName: user.email.split('@')[0], ownerName: user.email?.split('@')[0] ?? user.phone ?? 'you',
ownerOrgName: null, ownerOrgName: null,
activeDeployments: Number(active?.c ?? 0), activeDeployments: Number(active?.c ?? 0),
}; };
@ -468,7 +472,9 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
const validation = GeneratorSpec.safeParse(fullSpec); const validation = GeneratorSpec.safeParse(fullSpec);
if (!validation.success) { if (!validation.success) {
return reply.code(500).send({ error: 'template_spec_invalid', detail: validation.error.flatten() }); return reply
.code(500)
.send({ error: 'template_spec_invalid', detail: validation.error.flatten() });
} }
const previewId = await cacheSpec(validation.data); const previewId = await cacheSpec(validation.data);
// Persist the pre-rendered code under the same previewId so the worker uses it // Persist the pre-rendered code under the same previewId so the worker uses it
@ -550,7 +556,11 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
if (fork.containerId) { if (fork.containerId) {
const result = await stopContainer(fork.containerId); const result = await stopContainer(fork.containerId);
if (result.ok) stoppedContainers++; if (result.ok) stoppedContainers++;
else app.log.warn({ containerId: fork.containerId, detail: result.detail }, 'takedown: stop failed'); else
app.log.warn(
{ containerId: fork.containerId, detail: result.detail },
'takedown: stop failed',
);
} }
} }
await db await db

View File

@ -10,60 +10,123 @@ import { useEffect, useState } from 'react';
const ERROR_COPY: Record<string, string> = { const ERROR_COPY: Record<string, string> = {
google_failed: 'Google sign-in could not be completed. Please try again.', google_failed: 'Google sign-in could not be completed. Please try again.',
google_state: 'Google sign-in expired or was interrupted. Please try again.', google_state: 'Google sign-in expired or was interrupted. Please try again.',
github_failed: 'GitHub sign-in could not be completed. Please try again.',
github_state: 'GitHub sign-in expired or was interrupted. Please try again.',
invalid_phone: 'Enter your number in international format, e.g. +41 79 123 45 67.',
rate_limited: 'Too many requests. Wait a few minutes and try again.',
sms_request_failed: 'Could not send the SMS. Check the number and try again.',
invalid_or_expired_code: 'That code has expired. Request a new one.',
invalid_code: 'Wrong code. Check the SMS and try again.',
too_many_attempts: 'Too many wrong attempts. Request a new code.',
sms_verify_failed: 'Could not verify the code. Try again.',
}; };
function errCode(err: unknown): string {
const detail = (err as { detail?: { error?: string } }).detail;
return detail?.error ?? (err as Error).message ?? 'unknown';
}
export default function LoginPage() { export default function LoginPage() {
const [email, setEmail] = useState(''); const [providers, setProviders] = useState({ google: false, github: false, sms: false });
const [state, setState] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle'); const [method, setMethod] = useState<'email' | 'phone'>('email');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [googleEnabled, setGoogleEnabled] = useState(false);
// Email magic-link
const [email, setEmail] = useState('');
const [emailState, setEmailState] = useState<'idle' | 'sending' | 'sent'>('idle');
// SMS one-time code
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
const [smsStep, setSmsStep] = useState<'phone' | 'code'>('phone');
const [smsBusy, setSmsBusy] = useState(false);
useEffect(() => { useEffect(() => {
apiFetch<{ google: boolean }>('/v1/auth/providers') apiFetch<{ google: boolean; github: boolean; sms: boolean }>('/v1/auth/providers')
.then((r) => setGoogleEnabled(r.google)) .then(setProviders)
.catch(() => setGoogleEnabled(false)); .catch(() => undefined);
const err = new URLSearchParams(window.location.search).get('error');
const params = new URLSearchParams(window.location.search); if (err) setError(ERROR_COPY[err] ?? 'Sign-in failed. Please try again.');
const err = params.get('error');
if (err && ERROR_COPY[err]) setError(ERROR_COPY[err]);
}, []); }, []);
async function submit(e: React.FormEvent) { async function sendMagicLink(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setState('sending'); setEmailState('sending');
setError(null); setError(null);
try { try {
await apiFetch('/v1/auth/magic-link', { await apiFetch('/v1/auth/magic-link', { method: 'POST', body: JSON.stringify({ email }) });
method: 'POST', setEmailState('sent');
body: JSON.stringify({ email }),
});
setState('sent');
} catch (err) { } catch (err) {
setState('error'); setEmailState('idle');
setError((err as Error).message); setError(ERROR_COPY[errCode(err)] ?? 'Could not send the link.');
} }
} }
async function requestSmsCode(e: React.FormEvent) {
e.preventDefault();
setSmsBusy(true);
setError(null);
try {
await apiFetch('/v1/auth/sms/request', { method: 'POST', body: JSON.stringify({ phone }) });
setSmsStep('code');
} catch (err) {
setError(ERROR_COPY[errCode(err)] ?? 'Could not send the SMS.');
} finally {
setSmsBusy(false);
}
}
async function verifySmsCode(e: React.FormEvent) {
e.preventDefault();
setSmsBusy(true);
setError(null);
try {
await apiFetch('/v1/auth/sms/verify', {
method: 'POST',
body: JSON.stringify({ phone, code }),
});
window.location.href = '/dashboard';
} catch (err) {
setError(ERROR_COPY[errCode(err)] ?? 'Could not verify the code.');
setSmsBusy(false);
}
}
const hasOAuth = providers.google || providers.github;
return ( return (
<div className="flex min-h-screen items-center justify-center px-6"> <div className="flex min-h-screen items-center justify-center px-6">
<div className="w-full max-w-sm"> <div className="w-full max-w-sm">
<Logo className="mb-10" /> <Logo className="mb-10" />
<h1 className="text-[20px] font-semibold tracking-tight">Sign in to your workspace</h1> <h1 className="text-[20px] font-semibold tracking-tight">Sign in to your workspace</h1>
<p className="mt-1 text-[13px] text-[--color-fg-muted]"> <p className="mt-1 text-[13px] text-[--color-fg-muted]">
Continue with Google, or get a magic link by email. Passwordless pick whichever is easiest.
</p> </p>
{state !== 'sent' ? ( {hasOAuth && (
<> <div className="mt-7 space-y-2">
{googleEnabled && ( {providers.google && (
<>
<a <a
href={apiUrl('/v1/auth/google')} href={apiUrl('/v1/auth/google')}
className="mt-7 flex h-10 w-full items-center justify-center gap-2.5 rounded-md border border-[--color-border] bg-[--color-bg-elevated] text-[13px] font-medium text-[--color-fg] transition-colors duration-200 hover:border-[--color-border-strong]" className="flex h-10 w-full items-center justify-center gap-2.5 rounded-md border border-[--color-border] bg-[--color-bg-elevated] text-[13px] font-medium text-[--color-fg] transition-colors duration-200 hover:border-[--color-border-strong]"
> >
<GoogleIcon /> <GoogleIcon />
Continue with Google Continue with Google
</a> </a>
)}
{providers.github && (
<a
href={apiUrl('/v1/auth/github')}
className="flex h-10 w-full items-center justify-center gap-2.5 rounded-md border border-[--color-border] bg-[--color-bg-elevated] text-[13px] font-medium text-[--color-fg] transition-colors duration-200 hover:border-[--color-border-strong]"
>
<GitHubIcon />
Continue with GitHub
</a>
)}
</div>
)}
{hasOAuth && (
<div className="my-5 flex items-center gap-3"> <div className="my-5 flex items-center gap-3">
<span className="h-px flex-1 bg-[--color-border]" /> <span className="h-px flex-1 bg-[--color-border]" />
<span className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]"> <span className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">
@ -71,9 +134,35 @@ export default function LoginPage() {
</span> </span>
<span className="h-px flex-1 bg-[--color-border]" /> <span className="h-px flex-1 bg-[--color-border]" />
</div> </div>
</>
)} )}
<form onSubmit={submit} className={googleEnabled ? 'space-y-3' : 'mt-7 space-y-3'}>
{providers.sms && (
<div
className={`flex gap-1 rounded-md border border-[--color-border] p-1 ${hasOAuth ? '' : 'mt-7'}`}
>
{(['email', 'phone'] as const).map((m) => (
<button
key={m}
type="button"
onClick={() => {
setMethod(m);
setError(null);
}}
className={`h-7 flex-1 rounded text-[12px] font-medium transition-colors ${
method === m
? 'bg-[--color-bg-subtle] text-[--color-fg]'
: 'text-[--color-fg-muted] hover:text-[--color-fg]'
}`}
>
{m === 'email' ? 'Email' : 'Phone'}
</button>
))}
</div>
)}
<div className={providers.sms ? 'mt-4' : hasOAuth ? '' : 'mt-7'}>
{method === 'email' && emailState !== 'sent' && (
<form onSubmit={sendMagicLink} className="space-y-3">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="email">Email</Label> <Label htmlFor="email">Email</Label>
<Input <Input
@ -91,24 +180,95 @@ export default function LoginPage() {
variant="primary" variant="primary"
size="lg" size="lg"
className="w-full" className="w-full"
disabled={state === 'sending'} disabled={emailState === 'sending'}
> >
{state === 'sending' ? 'Sending…' : 'Send magic link'} {emailState === 'sending' ? 'Sending…' : 'Send magic link'}
</Button> </Button>
{error && <p className="text-[12px] text-[--color-danger]">{error}</p>}
</form> </form>
</> )}
) : (
<div className="panel mt-7 p-4"> {method === 'email' && emailState === 'sent' && (
<div className="panel p-4">
<p className="text-[13px]"> <p className="text-[13px]">
Magic link sent to <span className="mono">{email}</span>. Magic link sent to <span className="mono">{email}</span>.
</p> </p>
<p className="mt-1.5 text-[12px] text-[--color-fg-muted]"> <p className="mt-1.5 text-[12px] text-[--color-fg-muted]">
In dev mode the link is printed to the API console output. Check the terminal. Open it on this device to finish signing in.
</p> </p>
</div> </div>
)} )}
{method === 'phone' && smsStep === 'phone' && (
<form onSubmit={requestSmsCode} className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="phone">Phone number</Label>
<Input
id="phone"
type="tel"
inputMode="tel"
required
autoComplete="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="+41 79 123 45 67"
/>
</div>
<Button
type="submit"
variant="primary"
size="lg"
className="w-full"
disabled={smsBusy}
>
{smsBusy ? 'Sending…' : 'Send code'}
</Button>
</form>
)}
{method === 'phone' && smsStep === 'code' && (
<form onSubmit={verifySmsCode} className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="code" hint={`sent to ${phone}`}>
6-digit code
</Label>
<Input
id="code"
inputMode="numeric"
autoComplete="one-time-code"
required
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
placeholder="123456"
className="mono tracking-[0.3em]"
/>
</div>
<Button
type="submit"
variant="primary"
size="lg"
className="w-full"
disabled={smsBusy || code.length !== 6}
>
{smsBusy ? 'Verifying…' : 'Verify & sign in'}
</Button>
<button
type="button"
onClick={() => {
setSmsStep('phone');
setCode('');
setError(null);
}}
className="w-full text-[12px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg]"
>
Use a different number
</button>
</form>
)}
{error && <p className="mt-3 text-[12px] text-[--color-danger]">{error}</p>}
</div>
<div className="mt-8 text-[12px] text-[--color-fg-subtle]"> <div className="mt-8 text-[12px] text-[--color-fg-subtle]">
<Link href="/" className="transition-colors hover:text-[--color-fg]"> <Link href="/" className="transition-colors hover:text-[--color-fg]">
Back to home Back to home
@ -141,3 +301,11 @@ function GoogleIcon() {
</svg> </svg>
); );
} }
function GitHubIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82a7.6 7.6 0 0 1 2-.27c.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
</svg>
);
}

View File

@ -1,5 +1,18 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { and, createDb, eq, gt, type Database, magicLinks, memberships, organizations, sessions, users } from '@bmm/db'; import {
type Database,
and,
createDb,
desc,
eq,
gt,
magicLinks,
memberships,
organizations,
sessions,
smsCodes,
users,
} from '@bmm/db';
const MAGIC_LINK_TTL_MS = 15 * 60 * 1000; // 15 min const MAGIC_LINK_TTL_MS = 15 * 60 * 1000; // 15 min
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
@ -16,9 +29,7 @@ function randomToken(bytes = 32): string {
export function hashPassword(password: string): string { export function hashPassword(password: string): string {
const salt = crypto.randomBytes(16).toString('hex'); const salt = crypto.randomBytes(16).toString('hex');
const derived = crypto const derived = crypto.scryptSync(password, salt, SCRYPT_KEYLEN, { N: SCRYPT_N }).toString('hex');
.scryptSync(password, salt, SCRYPT_KEYLEN, { N: SCRYPT_N })
.toString('hex');
return `scrypt$${SCRYPT_N}$${salt}$${derived}`; return `scrypt$${SCRYPT_N}$${salt}$${derived}`;
} }
@ -36,11 +47,13 @@ export function verifyPassword(password: string, stored: string): boolean {
} }
function slugify(input: string): string { function slugify(input: string): string {
return input return (
input
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9]+/g, '-') .replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '') .replace(/(^-|-$)/g, '')
.slice(0, 48) || 'org'; .slice(0, 48) || 'org'
);
} }
export interface MagicLinkIssued { export interface MagicLinkIssued {
@ -48,7 +61,10 @@ export interface MagicLinkIssued {
expiresAt: Date; expiresAt: Date;
} }
export async function issueMagicLink(email: string, db: Database = createDb()): Promise<MagicLinkIssued> { export async function issueMagicLink(
email: string,
db: Database = createDb(),
): Promise<MagicLinkIssued> {
const lower = email.trim().toLowerCase(); const lower = email.trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(lower)) { if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(lower)) {
throw new Error('invalid_email'); throw new Error('invalid_email');
@ -64,7 +80,7 @@ export interface ConsumedSession {
sessionToken: string; sessionToken: string;
userId: string; userId: string;
orgId: string; orgId: string;
email: string; email: string | null;
} }
export async function consumeMagicLink( export async function consumeMagicLink(
@ -86,10 +102,7 @@ export async function consumeMagicLink(
// Get or create user + default org // Get or create user + default org
let user = (await db.select().from(users).where(eq(users.email, row.email)).limit(1))[0]; let user = (await db.select().from(users).where(eq(users.email, row.email)).limit(1))[0];
if (!user) { if (!user) {
[user] = await db [user] = await db.insert(users).values({ email: row.email, emailVerified: true }).returning();
.insert(users)
.values({ email: row.email, emailVerified: true })
.returning();
const orgSlug = `${slugify(row.email.split('@')[0] ?? 'me')}-${randomToken(3).toLowerCase()}`; const orgSlug = `${slugify(row.email.split('@')[0] ?? 'me')}-${randomToken(3).toLowerCase()}`;
const [org] = await db const [org] = await db
.insert(organizations) .insert(organizations)
@ -177,10 +190,109 @@ export async function upsertOAuthLogin(
return { sessionToken, userId: resolved.id, orgId: membership.orgId, email: resolved.email }; return { sessionToken, userId: resolved.id, orgId: membership.orgId, email: resolved.email };
} }
// ---- SMS one-time-code login ----
const SMS_CODE_TTL_MS = 10 * 60 * 1000; // 10 min
const SMS_RATE_WINDOW_MS = 15 * 60 * 1000;
const SMS_MAX_PER_WINDOW = 3; // codes issued per phone per window
const SMS_MAX_ATTEMPTS = 5; // wrong-code guesses per code
/** Normalise to strict E.164 (+ and 8-15 digits). Throws on anything else. */
function normalizePhone(raw: string): string {
const p = raw.replace(/[\s\-().]/g, '');
if (!/^\+[1-9]\d{7,14}$/.test(p)) throw new Error('invalid_phone');
return p;
}
export interface SmsCodeIssued {
phone: string;
code: string;
expiresAt: Date;
}
/** Generate a 6-digit code for a phone number, store it hashed, rate-limited. */
export async function issueSmsCode(
phoneRaw: string,
db: Database = createDb(),
): Promise<SmsCodeIssued> {
const phone = normalizePhone(phoneRaw);
const since = new Date(Date.now() - SMS_RATE_WINDOW_MS);
const recent = await db
.select({ id: smsCodes.id })
.from(smsCodes)
.where(and(eq(smsCodes.phone, phone), gt(smsCodes.createdAt, since)));
if (recent.length >= SMS_MAX_PER_WINDOW) throw new Error('rate_limited');
const code = String(crypto.randomInt(0, 1_000_000)).padStart(6, '0');
const expiresAt = new Date(Date.now() + SMS_CODE_TTL_MS);
await db.insert(smsCodes).values({ phone, codeHash: sha256(`${phone}:${code}`), expiresAt });
return { phone, code, expiresAt };
}
/** Verify a code, then get-or-create the phone's user and mint a session. */
export async function consumeSmsCode(
phoneRaw: string,
code: string,
meta: { ipAddress?: string; userAgent?: string } = {},
db: Database = createDb(),
): Promise<ConsumedSession> {
const phone = normalizePhone(phoneRaw);
const [row] = await db
.select()
.from(smsCodes)
.where(and(eq(smsCodes.phone, phone), gt(smsCodes.expiresAt, new Date())))
.orderBy(desc(smsCodes.createdAt))
.limit(1);
if (!row || row.consumedAt) throw new Error('invalid_or_expired_code');
if (row.attempts >= SMS_MAX_ATTEMPTS) throw new Error('too_many_attempts');
if (sha256(`${phone}:${code}`) !== row.codeHash) {
await db
.update(smsCodes)
.set({ attempts: row.attempts + 1 })
.where(eq(smsCodes.id, row.id));
throw new Error('invalid_code');
}
await db.update(smsCodes).set({ consumedAt: new Date() }).where(eq(smsCodes.id, row.id));
let user = (await db.select().from(users).where(eq(users.phone, phone)).limit(1))[0];
if (!user) {
[user] = await db.insert(users).values({ phone }).returning();
if (!user) throw new Error('user_create_failed');
const orgSlug = `phone-${randomToken(4).toLowerCase()}`;
const [org] = await db
.insert(organizations)
.values({ slug: orgSlug, name: 'My workspace' })
.returning();
if (!org) throw new Error('org_create_failed');
await db.insert(memberships).values({ orgId: org.id, userId: user.id, role: 'owner' });
}
const resolved = user;
const [membership] = await db
.select()
.from(memberships)
.where(eq(memberships.userId, resolved.id))
.limit(1);
if (!membership) throw new Error('no_org_membership');
const sessionToken = randomToken(32);
await db.insert(sessions).values({
userId: resolved.id,
tokenHash: sha256(sessionToken),
expiresAt: new Date(Date.now() + SESSION_TTL_MS),
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, resolved.id));
return { sessionToken, userId: resolved.id, orgId: membership.orgId, email: resolved.email };
}
export interface AuthedUser { export interface AuthedUser {
userId: string; userId: string;
orgId: string; orgId: string;
email: string; email: string | null;
phone: string | null;
role: string; role: string;
isAdmin: boolean; isAdmin: boolean;
} }
@ -196,6 +308,7 @@ export async function getSession(
userId: sessions.userId, userId: sessions.userId,
expiresAt: sessions.expiresAt, expiresAt: sessions.expiresAt,
email: users.email, email: users.email,
phone: users.phone,
isAdmin: users.isAdmin, isAdmin: users.isAdmin,
}) })
.from(sessions) .from(sessions)
@ -213,12 +326,16 @@ export async function getSession(
userId: row.userId, userId: row.userId,
orgId: membership.orgId, orgId: membership.orgId,
email: row.email, email: row.email,
phone: row.phone,
role: membership.role, role: membership.role,
isAdmin: row.isAdmin, isAdmin: row.isAdmin,
}; };
} }
export async function destroySession(sessionToken: string, db: Database = createDb()): Promise<void> { export async function destroySession(
sessionToken: string,
db: Database = createDb(),
): Promise<void> {
const hash = sha256(sessionToken); const hash = sha256(sessionToken);
await db.delete(sessions).where(eq(sessions.tokenHash, hash)); await db.delete(sessions).where(eq(sessions.tokenHash, hash));
} }
@ -227,7 +344,7 @@ export interface PasswordLoginResult {
sessionToken: string; sessionToken: string;
userId: string; userId: string;
orgId: string; orgId: string;
email: string; email: string | null;
isAdmin: boolean; isAdmin: boolean;
} }

View File

@ -97,7 +97,10 @@ export const templates = pgTable(
export const users = pgTable('users', { export const users = pgTable('users', {
id: uuid('id').defaultRandom().primaryKey(), id: uuid('id').defaultRandom().primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(), // Nullable: a user identifies via email OR phone. Postgres treats NULLs as
// distinct, so multiple phone-only users (email NULL) coexist fine.
email: varchar('email', { length: 255 }).unique(),
phone: varchar('phone', { length: 32 }).unique(),
name: varchar('name', { length: 128 }), name: varchar('name', { length: 128 }),
avatarUrl: text('avatar_url'), avatarUrl: text('avatar_url'),
emailVerified: boolean('email_verified').default(false).notNull(), emailVerified: boolean('email_verified').default(false).notNull(),
@ -134,6 +137,23 @@ export const magicLinks = pgTable('magic_links', {
createdAt: timestamp('created_at').defaultNow().notNull(), createdAt: timestamp('created_at').defaultNow().notNull(),
}); });
// Short-lived 6-digit SMS one-time codes for phone login.
export const smsCodes = pgTable(
'sms_codes',
{
id: uuid('id').defaultRandom().primaryKey(),
phone: varchar('phone', { length: 32 }).notNull(),
codeHash: text('code_hash').notNull(),
attempts: integer('attempts').default(0).notNull(),
expiresAt: timestamp('expires_at').notNull(),
consumedAt: timestamp('consumed_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(t) => ({
phoneIdx: index('idx_sms_codes_phone').on(t.phone, t.createdAt),
}),
);
export const memberships = pgTable('memberships', { export const memberships = pgTable('memberships', {
id: uuid('id').defaultRandom().primaryKey(), id: uuid('id').defaultRandom().primaryKey(),
orgId: uuid('org_id') orgId: uuid('org_id')