2026-05-21 00:26:44 +02:00
|
|
|
import crypto from 'node:crypto';
|
feat(admin): password-auth admin panel with 8 pages + 15 API endpoints
Schema migrations:
- users.is_admin boolean
- users.password_hash text (scrypt N=16384, 16-byte salt)
- users.last_login_at timestamp
- organizations.suspended + suspended_reason
- admin_settings table (DB-stored prompt override + future settings)
Auth (@bmm/auth):
- hashPassword + verifyPassword via node:crypto scrypt (no extra dep)
- loginWithPassword: scrypt-verifies, issues 30-day session, updates last_login_at
- seedAdmin: idempotent upsert keyed on email; creates org + membership on first run
- AuthedUser now carries isAdmin flag
API:
- POST /v1/auth/admin/login (email + password) — 300ms throttle on failure
- requireAdmin preHandler — 401 if no session, 403 if non-admin
- Bootstrap: api on boot calls seedAdmin(ADMIN_EMAIL, ADMIN_PASSWORD, ADMIN_NAME)
if env present. Idempotent.
Admin API routes (all gated by requireAdmin):
- GET /v1/admin/overview (totals, trends 7d, server-status breakdown, builds 24h, recent activity)
- GET /v1/admin/users (search, per-row org + plan + serverCount)
- PATCH /v1/admin/users/:id (isAdmin, name)
- DELETE /v1/admin/users/:id (self-delete blocked)
- GET /v1/admin/orgs (member + server counts)
- PATCH /v1/admin/orgs/:id (plan, quota, suspended; cascades to mcp_servers.status=paused on suspend)
- GET /v1/admin/servers (cross-org with status filter)
- POST /v1/admin/servers/:id/rebuild (re-queues build using last prompt)
- DELETE /v1/admin/servers/:id
- GET /v1/admin/builds (status filter, error messages, prompt previews)
- GET /v1/admin/builds/:id/logs
- GET /v1/admin/audit (system-wide with user email join)
- GET /v1/admin/system (DB ping, Redis ping, BullMQ queue depth, docker ps count)
- GET /v1/admin/prompt (builtin + override + updatedAt)
- PATCH /v1/admin/prompt (value: string | null) — saves DB override or drops it
UI (apps/web/app/admin/*):
- /admin/login — password form, separate from /login magic-link
- AdminLayout — Linear-style sidebar (8 nav items), bottom panel with user email +
'user view' shortcut + logout, client-side requireAdmin guard with redirect
- /admin — overview dashboard with 4 metric cards, 2 panels (status + 24h builds),
recent activity table linking to full audit
- /admin/users — search + admin toggle + delete (self-delete blocked)
- /admin/orgs — plan/quota/suspend actions via prompts
- /admin/servers — cross-org table with rebuild + delete actions, status filter
- /admin/builds — every build cross-fleet with error vs prompt preview
- /admin/audit — system-wide log + CSV export + filter dropdowns
- /admin/system — auto-refreshing 5s health probes for Postgres, Redis, queue, Docker
- /admin/prompt — live editor for the LLM system prompt with built-in baseline,
override-state badge, drop-override action, diff preview, save-as-override
End-to-end verified: login as marco.frangiskatos@gmail.com + Melusa112233.*, every
admin page returns 200, admin login + overview tested via screenshot, docker probe
returns true count of running MCP containers.
2026-05-19 23:01:26 +02:00
|
|
|
import {
|
|
|
|
|
consumeMagicLink,
|
feat(auth): GitHub OAuth login + SMS one-time-code login
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>
2026-05-21 22:59:58 +02:00
|
|
|
consumeSmsCode,
|
feat(admin): password-auth admin panel with 8 pages + 15 API endpoints
Schema migrations:
- users.is_admin boolean
- users.password_hash text (scrypt N=16384, 16-byte salt)
- users.last_login_at timestamp
- organizations.suspended + suspended_reason
- admin_settings table (DB-stored prompt override + future settings)
Auth (@bmm/auth):
- hashPassword + verifyPassword via node:crypto scrypt (no extra dep)
- loginWithPassword: scrypt-verifies, issues 30-day session, updates last_login_at
- seedAdmin: idempotent upsert keyed on email; creates org + membership on first run
- AuthedUser now carries isAdmin flag
API:
- POST /v1/auth/admin/login (email + password) — 300ms throttle on failure
- requireAdmin preHandler — 401 if no session, 403 if non-admin
- Bootstrap: api on boot calls seedAdmin(ADMIN_EMAIL, ADMIN_PASSWORD, ADMIN_NAME)
if env present. Idempotent.
Admin API routes (all gated by requireAdmin):
- GET /v1/admin/overview (totals, trends 7d, server-status breakdown, builds 24h, recent activity)
- GET /v1/admin/users (search, per-row org + plan + serverCount)
- PATCH /v1/admin/users/:id (isAdmin, name)
- DELETE /v1/admin/users/:id (self-delete blocked)
- GET /v1/admin/orgs (member + server counts)
- PATCH /v1/admin/orgs/:id (plan, quota, suspended; cascades to mcp_servers.status=paused on suspend)
- GET /v1/admin/servers (cross-org with status filter)
- POST /v1/admin/servers/:id/rebuild (re-queues build using last prompt)
- DELETE /v1/admin/servers/:id
- GET /v1/admin/builds (status filter, error messages, prompt previews)
- GET /v1/admin/builds/:id/logs
- GET /v1/admin/audit (system-wide with user email join)
- GET /v1/admin/system (DB ping, Redis ping, BullMQ queue depth, docker ps count)
- GET /v1/admin/prompt (builtin + override + updatedAt)
- PATCH /v1/admin/prompt (value: string | null) — saves DB override or drops it
UI (apps/web/app/admin/*):
- /admin/login — password form, separate from /login magic-link
- AdminLayout — Linear-style sidebar (8 nav items), bottom panel with user email +
'user view' shortcut + logout, client-side requireAdmin guard with redirect
- /admin — overview dashboard with 4 metric cards, 2 panels (status + 24h builds),
recent activity table linking to full audit
- /admin/users — search + admin toggle + delete (self-delete blocked)
- /admin/orgs — plan/quota/suspend actions via prompts
- /admin/servers — cross-org table with rebuild + delete actions, status filter
- /admin/builds — every build cross-fleet with error vs prompt preview
- /admin/audit — system-wide log + CSV export + filter dropdowns
- /admin/system — auto-refreshing 5s health probes for Postgres, Redis, queue, Docker
- /admin/prompt — live editor for the LLM system prompt with built-in baseline,
override-state badge, drop-override action, diff preview, save-as-override
End-to-end verified: login as marco.frangiskatos@gmail.com + Melusa112233.*, every
admin page returns 200, admin login + overview tested via screenshot, docker probe
returns true count of running MCP containers.
2026-05-19 23:01:26 +02:00
|
|
|
destroySession,
|
|
|
|
|
getSession,
|
|
|
|
|
issueMagicLink,
|
feat(auth): GitHub OAuth login + SMS one-time-code login
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>
2026-05-21 22:59:58 +02:00
|
|
|
issueSmsCode,
|
feat(admin): password-auth admin panel with 8 pages + 15 API endpoints
Schema migrations:
- users.is_admin boolean
- users.password_hash text (scrypt N=16384, 16-byte salt)
- users.last_login_at timestamp
- organizations.suspended + suspended_reason
- admin_settings table (DB-stored prompt override + future settings)
Auth (@bmm/auth):
- hashPassword + verifyPassword via node:crypto scrypt (no extra dep)
- loginWithPassword: scrypt-verifies, issues 30-day session, updates last_login_at
- seedAdmin: idempotent upsert keyed on email; creates org + membership on first run
- AuthedUser now carries isAdmin flag
API:
- POST /v1/auth/admin/login (email + password) — 300ms throttle on failure
- requireAdmin preHandler — 401 if no session, 403 if non-admin
- Bootstrap: api on boot calls seedAdmin(ADMIN_EMAIL, ADMIN_PASSWORD, ADMIN_NAME)
if env present. Idempotent.
Admin API routes (all gated by requireAdmin):
- GET /v1/admin/overview (totals, trends 7d, server-status breakdown, builds 24h, recent activity)
- GET /v1/admin/users (search, per-row org + plan + serverCount)
- PATCH /v1/admin/users/:id (isAdmin, name)
- DELETE /v1/admin/users/:id (self-delete blocked)
- GET /v1/admin/orgs (member + server counts)
- PATCH /v1/admin/orgs/:id (plan, quota, suspended; cascades to mcp_servers.status=paused on suspend)
- GET /v1/admin/servers (cross-org with status filter)
- POST /v1/admin/servers/:id/rebuild (re-queues build using last prompt)
- DELETE /v1/admin/servers/:id
- GET /v1/admin/builds (status filter, error messages, prompt previews)
- GET /v1/admin/builds/:id/logs
- GET /v1/admin/audit (system-wide with user email join)
- GET /v1/admin/system (DB ping, Redis ping, BullMQ queue depth, docker ps count)
- GET /v1/admin/prompt (builtin + override + updatedAt)
- PATCH /v1/admin/prompt (value: string | null) — saves DB override or drops it
UI (apps/web/app/admin/*):
- /admin/login — password form, separate from /login magic-link
- AdminLayout — Linear-style sidebar (8 nav items), bottom panel with user email +
'user view' shortcut + logout, client-side requireAdmin guard with redirect
- /admin — overview dashboard with 4 metric cards, 2 panels (status + 24h builds),
recent activity table linking to full audit
- /admin/users — search + admin toggle + delete (self-delete blocked)
- /admin/orgs — plan/quota/suspend actions via prompts
- /admin/servers — cross-org table with rebuild + delete actions, status filter
- /admin/builds — every build cross-fleet with error vs prompt preview
- /admin/audit — system-wide log + CSV export + filter dropdowns
- /admin/system — auto-refreshing 5s health probes for Postgres, Redis, queue, Docker
- /admin/prompt — live editor for the LLM system prompt with built-in baseline,
override-state badge, drop-override action, diff preview, save-as-override
End-to-end verified: login as marco.frangiskatos@gmail.com + Melusa112233.*, every
admin page returns 200, admin login + overview tested via screenshot, docker probe
returns true count of running MCP containers.
2026-05-19 23:01:26 +02:00
|
|
|
loginWithPassword,
|
2026-05-21 00:26:44 +02:00
|
|
|
upsertOAuthLogin,
|
feat(admin): password-auth admin panel with 8 pages + 15 API endpoints
Schema migrations:
- users.is_admin boolean
- users.password_hash text (scrypt N=16384, 16-byte salt)
- users.last_login_at timestamp
- organizations.suspended + suspended_reason
- admin_settings table (DB-stored prompt override + future settings)
Auth (@bmm/auth):
- hashPassword + verifyPassword via node:crypto scrypt (no extra dep)
- loginWithPassword: scrypt-verifies, issues 30-day session, updates last_login_at
- seedAdmin: idempotent upsert keyed on email; creates org + membership on first run
- AuthedUser now carries isAdmin flag
API:
- POST /v1/auth/admin/login (email + password) — 300ms throttle on failure
- requireAdmin preHandler — 401 if no session, 403 if non-admin
- Bootstrap: api on boot calls seedAdmin(ADMIN_EMAIL, ADMIN_PASSWORD, ADMIN_NAME)
if env present. Idempotent.
Admin API routes (all gated by requireAdmin):
- GET /v1/admin/overview (totals, trends 7d, server-status breakdown, builds 24h, recent activity)
- GET /v1/admin/users (search, per-row org + plan + serverCount)
- PATCH /v1/admin/users/:id (isAdmin, name)
- DELETE /v1/admin/users/:id (self-delete blocked)
- GET /v1/admin/orgs (member + server counts)
- PATCH /v1/admin/orgs/:id (plan, quota, suspended; cascades to mcp_servers.status=paused on suspend)
- GET /v1/admin/servers (cross-org with status filter)
- POST /v1/admin/servers/:id/rebuild (re-queues build using last prompt)
- DELETE /v1/admin/servers/:id
- GET /v1/admin/builds (status filter, error messages, prompt previews)
- GET /v1/admin/builds/:id/logs
- GET /v1/admin/audit (system-wide with user email join)
- GET /v1/admin/system (DB ping, Redis ping, BullMQ queue depth, docker ps count)
- GET /v1/admin/prompt (builtin + override + updatedAt)
- PATCH /v1/admin/prompt (value: string | null) — saves DB override or drops it
UI (apps/web/app/admin/*):
- /admin/login — password form, separate from /login magic-link
- AdminLayout — Linear-style sidebar (8 nav items), bottom panel with user email +
'user view' shortcut + logout, client-side requireAdmin guard with redirect
- /admin — overview dashboard with 4 metric cards, 2 panels (status + 24h builds),
recent activity table linking to full audit
- /admin/users — search + admin toggle + delete (self-delete blocked)
- /admin/orgs — plan/quota/suspend actions via prompts
- /admin/servers — cross-org table with rebuild + delete actions, status filter
- /admin/builds — every build cross-fleet with error vs prompt preview
- /admin/audit — system-wide log + CSV export + filter dropdowns
- /admin/system — auto-refreshing 5s health probes for Postgres, Redis, queue, Docker
- /admin/prompt — live editor for the LLM system prompt with built-in baseline,
override-state badge, drop-override action, diff preview, save-as-override
End-to-end verified: login as marco.frangiskatos@gmail.com + Melusa112233.*, every
admin page returns 200, admin login + overview tested via screenshot, docker probe
returns true count of running MCP containers.
2026-05-19 23:01:26 +02:00
|
|
|
} from '@bmm/auth';
|
2026-05-21 00:26:44 +02:00
|
|
|
import type { FastifyInstance } from 'fastify';
|
|
|
|
|
import { z } from 'zod';
|
2026-05-19 00:24:47 +02:00
|
|
|
import { config } from '../config.js';
|
2026-05-21 00:26:44 +02:00
|
|
|
import { audit } from '../lib/audit.js';
|
feat: tiered LLM (GLM free / Claude paid) + rate limits + quota enforcement
The free tier was hemorrhaging Anthropic cost with no abuse cap (no rate
limit on /preview, Opus default in the build worker, 5-min cache TTL that
made cache-miss the common case). This switches free users to GLM, paid
users to Claude tiers, and tightens every leak found in the audit.
Backend:
- @bmm/llm: GLM provider via Zhipu's OpenAI-compatible endpoint, pickPreviewModel
+ pickBuildModel helpers, plan-aware ModelChoice
- preview-cache TTL 5min -> 24h (kills the cache-miss path)
- /v1/servers/preview: picks model from caller's plan, returns model name to UI
- /v1/servers POST: enforces SERVER_LIMITS per plan (402), rate-limits builds
- daily rate-limit on preview (5/40/150/1000) and build (3/20/100/500)
- /v1/auth/me returns plan so the wizard can show the right model name
- generator worker: GLM default, Anthropic Sonnet fallback if GLM errors
Frontend:
- Wizard fetches plan, shows "<model> is drafting the tool spec" pre-emptively,
upgrade hint for hobby users, friendly errors for 402 / 429
- Pricing page: AI-model line per tier (Open-tier / Haiku / Sonnet / Opus),
Team €149 -> €199, Enterprise €499 -> €999, daily-preview limit per tier
- Privacy + Security: explicit subprocessor disclosure for Anthropic (US) /
Zhipu (CN) and which tier uses which
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 23:50:00 +02:00
|
|
|
import { getOrgPlan } from '../lib/plan.js';
|
security: sovereign-audit Phase 2 fixes — trustProxy, Docker hardening, banned-pattern overhaul
Five confirmed findings from the sovereign-audit pass, ordered by severity:
Z3-001 CRITICAL — Fastify now trustProxy:true so req.ip resolves to the
real visitor IP via X-Forwarded-For instead of always being the nginx /
docker-bridge peer. Every per-IP rate-limit in the codebase was silently
collapsed into one global counter; this restores them.
Z1-001 CRITICAL — runner container hardening flags (--read-only,
--cap-drop=ALL, --security-opt=no-new-privileges:true, --pids-limit=100,
--memory=512m, --cpus=0.5, tmpfs /tmp) were sitting commented-out as a
TODO despite /security promising them. Now applied unconditionally on
production/staging; opt-out flag RUNNER_DISABLE_HARDENING=1 for Win-dev.
Z2-001 + Z2-002 CRITICAL / MEDIUM — banned-pattern blacklist tightened
(Function(...) without `new`, process.binding, process.dlopen,
.constructor.constructor, _load, vm.runIn*Context, globalThis['..'],
"system prompt override"). scanForInjection now also walks tool.name and
every inputSchema property description, not only implementation +
description — closes the prompt-injection-into-AI-client surface that
downstream clients (Claude Desktop, Cursor) read verbatim. The duplicate
BANNED_PATTERNS in apps/api/src/routes/servers.ts deleted in favour of
the single shared scanForInjection export from @bmm/llm.
Z4-001 HIGH — /v1/auth/magic-link gained the two-axis daily rate-limit
the SMS endpoint already had: 10/IP/day + 5/email/day. Combined with the
trustProxy fix above these are now real per-visitor limits.
Z4-002 MEDIUM — magic-link callback URL no longer printed to stdout in
production. In dev it still prints (so devs can click the link); in
production we log only "issued, URL withheld" and a loud error if no
email sender is wired (Resend integration is the actual launch
blocker — left as a TODO).
Z6-001 MEDIUM — /v1/builds/:id/stream WebSocket now refuses cross-origin
upgrades. SameSite=Lax already mitigates in modern browsers; this is the
defense-in-depth against browser bugs and non-browser clients.
FALSE POSITIVES dismissed: slug path-traversal (schema regex
^[a-z][a-z0-9-]*$ in @bmm/types catches it); session-after-promote
(getSession re-fetches isAdmin from DB on every request).
DEFERRED (not blockers, tracked):
- Z1-002 generated-server HTTPS — needs nginx wildcard subdomain TLS
- Z1-003 docker image cleanup cron
- Z2-001 v2 — real sandbox runtime (multi-week refactor)
- Z3-002 rawBody-per-request memory — branch on webhook path only
- Z5-001 multi-user org RBAC for billing — gated on Team feature
- Email sender integration (Resend) — launch blocker
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 18:02:59 +02:00
|
|
|
import { checkDailyLimit } from '../lib/rate-limit.js';
|
feat(auth): GitHub OAuth login + SMS one-time-code login
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>
2026-05-21 22:59:58 +02:00
|
|
|
import { sendSms, smsConfigured } from '../lib/sms.js';
|
2026-05-19 00:24:47 +02:00
|
|
|
|
|
|
|
|
const SESSION_COOKIE = 'bmm_session';
|
2026-05-21 00:26:44 +02:00
|
|
|
const OAUTH_STATE_COOKIE = 'bmm_oauth_state';
|
|
|
|
|
|
|
|
|
|
const GoogleClaims = z.object({
|
|
|
|
|
iss: z.string(),
|
|
|
|
|
aud: z.string(),
|
|
|
|
|
exp: z.number(),
|
|
|
|
|
email: z.string().email(),
|
|
|
|
|
email_verified: z.union([z.boolean(), z.string()]).optional(),
|
|
|
|
|
name: z.string().optional(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Decode (NOT signature-verify) a Google ID token payload. Signature verification
|
|
|
|
|
* is unnecessary here because the token is fetched directly from Google's token
|
|
|
|
|
* endpoint over TLS, authenticated with our client secret — an intermediary-free
|
|
|
|
|
* channel, per Google's own guidance. We still validate iss / aud / exp / email
|
|
|
|
|
* below as defense-in-depth.
|
|
|
|
|
*/
|
|
|
|
|
function decodeGoogleIdToken(idToken: string): z.infer<typeof GoogleClaims> {
|
|
|
|
|
const parts = idToken.split('.');
|
|
|
|
|
if (parts.length !== 3 || !parts[1]) throw new Error('malformed_id_token');
|
|
|
|
|
const json = Buffer.from(parts[1], 'base64url').toString('utf8');
|
|
|
|
|
return GoogleClaims.parse(JSON.parse(json));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function googleRedirectUri(): string {
|
|
|
|
|
return `${config.CONTROL_PLANE_PUBLIC_URL}/v1/auth/google/callback`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function googleConfigured(): boolean {
|
|
|
|
|
return Boolean(config.GOOGLE_OAUTH_ID && config.GOOGLE_OAUTH_SECRET);
|
|
|
|
|
}
|
2026-05-19 00:24:47 +02:00
|
|
|
|
feat(auth): GitHub OAuth login + SMS one-time-code login
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>
2026-05-21 22:59:58 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 00:24:47 +02:00
|
|
|
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|
|
|
|
app.post('/v1/auth/magic-link', async (req, reply) => {
|
|
|
|
|
const Body = z.object({ email: z.string().email() });
|
|
|
|
|
const parsed = Body.safeParse(req.body);
|
|
|
|
|
if (!parsed.success) return reply.code(400).send({ error: 'invalid_email' });
|
security: sovereign-audit Phase 2 fixes — trustProxy, Docker hardening, banned-pattern overhaul
Five confirmed findings from the sovereign-audit pass, ordered by severity:
Z3-001 CRITICAL — Fastify now trustProxy:true so req.ip resolves to the
real visitor IP via X-Forwarded-For instead of always being the nginx /
docker-bridge peer. Every per-IP rate-limit in the codebase was silently
collapsed into one global counter; this restores them.
Z1-001 CRITICAL — runner container hardening flags (--read-only,
--cap-drop=ALL, --security-opt=no-new-privileges:true, --pids-limit=100,
--memory=512m, --cpus=0.5, tmpfs /tmp) were sitting commented-out as a
TODO despite /security promising them. Now applied unconditionally on
production/staging; opt-out flag RUNNER_DISABLE_HARDENING=1 for Win-dev.
Z2-001 + Z2-002 CRITICAL / MEDIUM — banned-pattern blacklist tightened
(Function(...) without `new`, process.binding, process.dlopen,
.constructor.constructor, _load, vm.runIn*Context, globalThis['..'],
"system prompt override"). scanForInjection now also walks tool.name and
every inputSchema property description, not only implementation +
description — closes the prompt-injection-into-AI-client surface that
downstream clients (Claude Desktop, Cursor) read verbatim. The duplicate
BANNED_PATTERNS in apps/api/src/routes/servers.ts deleted in favour of
the single shared scanForInjection export from @bmm/llm.
Z4-001 HIGH — /v1/auth/magic-link gained the two-axis daily rate-limit
the SMS endpoint already had: 10/IP/day + 5/email/day. Combined with the
trustProxy fix above these are now real per-visitor limits.
Z4-002 MEDIUM — magic-link callback URL no longer printed to stdout in
production. In dev it still prints (so devs can click the link); in
production we log only "issued, URL withheld" and a loud error if no
email sender is wired (Resend integration is the actual launch
blocker — left as a TODO).
Z6-001 MEDIUM — /v1/builds/:id/stream WebSocket now refuses cross-origin
upgrades. SameSite=Lax already mitigates in modern browsers; this is the
defense-in-depth against browser bugs and non-browser clients.
FALSE POSITIVES dismissed: slug path-traversal (schema regex
^[a-z][a-z0-9-]*$ in @bmm/types catches it); session-after-promote
(getSession re-fetches isAdmin from DB on every request).
DEFERRED (not blockers, tracked):
- Z1-002 generated-server HTTPS — needs nginx wildcard subdomain TLS
- Z1-003 docker image cleanup cron
- Z2-001 v2 — real sandbox runtime (multi-week refactor)
- Z3-002 rawBody-per-request memory — branch on webhook path only
- Z5-001 multi-user org RBAC for billing — gated on Team feature
- Email sender integration (Resend) — launch blocker
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 18:02:59 +02:00
|
|
|
|
|
|
|
|
// Two-axis rate-limit: per-IP (prevents IP-flooding the endpoint) and
|
|
|
|
|
// per-email (prevents inbox-flooding a specific target). Both required
|
|
|
|
|
// because the IP cap protects us, the email cap protects the recipient.
|
|
|
|
|
const ipOk = await checkDailyLimit('magic_ip', req.ip, 10);
|
|
|
|
|
if (!ipOk.ok) {
|
|
|
|
|
return reply.code(429).send({
|
|
|
|
|
error: 'rate_limited',
|
|
|
|
|
detail: 'Too many magic-link requests from this IP. Try again tomorrow.',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
const emailOk = await checkDailyLimit('magic_email', parsed.data.email.toLowerCase(), 5);
|
|
|
|
|
if (!emailOk.ok) {
|
|
|
|
|
return reply.code(429).send({
|
|
|
|
|
error: 'rate_limited',
|
|
|
|
|
detail: 'Too many magic-link requests for this email. Try again tomorrow.',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 00:24:47 +02:00
|
|
|
try {
|
|
|
|
|
const { token, expiresAt } = await issueMagicLink(parsed.data.email);
|
|
|
|
|
const callbackUrl = `${config.NEXT_PUBLIC_APP_URL}/login/callback?token=${token}`;
|
security: sovereign-audit Phase 2 fixes — trustProxy, Docker hardening, banned-pattern overhaul
Five confirmed findings from the sovereign-audit pass, ordered by severity:
Z3-001 CRITICAL — Fastify now trustProxy:true so req.ip resolves to the
real visitor IP via X-Forwarded-For instead of always being the nginx /
docker-bridge peer. Every per-IP rate-limit in the codebase was silently
collapsed into one global counter; this restores them.
Z1-001 CRITICAL — runner container hardening flags (--read-only,
--cap-drop=ALL, --security-opt=no-new-privileges:true, --pids-limit=100,
--memory=512m, --cpus=0.5, tmpfs /tmp) were sitting commented-out as a
TODO despite /security promising them. Now applied unconditionally on
production/staging; opt-out flag RUNNER_DISABLE_HARDENING=1 for Win-dev.
Z2-001 + Z2-002 CRITICAL / MEDIUM — banned-pattern blacklist tightened
(Function(...) without `new`, process.binding, process.dlopen,
.constructor.constructor, _load, vm.runIn*Context, globalThis['..'],
"system prompt override"). scanForInjection now also walks tool.name and
every inputSchema property description, not only implementation +
description — closes the prompt-injection-into-AI-client surface that
downstream clients (Claude Desktop, Cursor) read verbatim. The duplicate
BANNED_PATTERNS in apps/api/src/routes/servers.ts deleted in favour of
the single shared scanForInjection export from @bmm/llm.
Z4-001 HIGH — /v1/auth/magic-link gained the two-axis daily rate-limit
the SMS endpoint already had: 10/IP/day + 5/email/day. Combined with the
trustProxy fix above these are now real per-visitor limits.
Z4-002 MEDIUM — magic-link callback URL no longer printed to stdout in
production. In dev it still prints (so devs can click the link); in
production we log only "issued, URL withheld" and a loud error if no
email sender is wired (Resend integration is the actual launch
blocker — left as a TODO).
Z6-001 MEDIUM — /v1/builds/:id/stream WebSocket now refuses cross-origin
upgrades. SameSite=Lax already mitigates in modern browsers; this is the
defense-in-depth against browser bugs and non-browser clients.
FALSE POSITIVES dismissed: slug path-traversal (schema regex
^[a-z][a-z0-9-]*$ in @bmm/types catches it); session-after-promote
(getSession re-fetches isAdmin from DB on every request).
DEFERRED (not blockers, tracked):
- Z1-002 generated-server HTTPS — needs nginx wildcard subdomain TLS
- Z1-003 docker image cleanup cron
- Z2-001 v2 — real sandbox runtime (multi-week refactor)
- Z3-002 rawBody-per-request memory — branch on webhook path only
- Z5-001 multi-user org RBAC for billing — gated on Team feature
- Email sender integration (Resend) — launch blocker
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 18:02:59 +02:00
|
|
|
// In dev we print the link to stdout so the developer can click it.
|
|
|
|
|
// In production we must NEVER log the full token — anyone with
|
|
|
|
|
// `docker logs` access would silently impersonate any user.
|
|
|
|
|
if (config.NODE_ENV !== 'production') {
|
|
|
|
|
app.log.info({ to: parsed.data.email, expiresAt }, `[magic-link] -> ${callbackUrl}`);
|
|
|
|
|
console.log(`\n[magic-link] ${parsed.data.email} ->\n ${callbackUrl}\n`);
|
|
|
|
|
} else {
|
|
|
|
|
app.log.info(
|
|
|
|
|
{ to: parsed.data.email, expiresAt },
|
|
|
|
|
'[magic-link] issued (URL withheld from logs)',
|
|
|
|
|
);
|
|
|
|
|
// TODO(launch): hook up Resend / SES here. Until then, production
|
|
|
|
|
// magic-link is effectively dead — fail loud rather than silent.
|
|
|
|
|
app.log.error('magic-link email sender not configured — link cannot reach user');
|
|
|
|
|
}
|
2026-05-19 00:24:47 +02:00
|
|
|
return reply.send({ ok: true });
|
|
|
|
|
} catch (e) {
|
|
|
|
|
app.log.error(e);
|
|
|
|
|
return reply.code(400).send({ error: 'magic_link_failed' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.post('/v1/auth/verify', async (req, reply) => {
|
|
|
|
|
const Body = z.object({ token: z.string().min(10) });
|
|
|
|
|
const parsed = Body.safeParse(req.body);
|
|
|
|
|
if (!parsed.success) return reply.code(400).send({ error: 'invalid_token' });
|
|
|
|
|
try {
|
|
|
|
|
const session = await consumeMagicLink(parsed.data.token, {
|
|
|
|
|
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,
|
|
|
|
|
});
|
feat(api,generator): preview endpoint + spec cache + audit-log writes
- POST /v1/servers/preview runs Claude synchronously, validates output, caches spec
in Redis under preview:<id> with 5min TTL, returns previewId+spec+detectedSecrets.
- POST /v1/servers accepts optional previewId; worker reuses the cached spec if
the entry is still present, otherwise regenerates fresh. Skips the second
Claude round-trip (~30s saved on the demoable path).
- audit() helper writes auth.login, auth.logout, server.create, server.iterate,
server.delete to audit_log with ip, metadata, resourceId.
- GET /v1/me/org returns organization + members list for the settings page.
- GET /v1/audit?limit=&action=&resourceType= returns scoped audit entries.
2026-05-19 18:08:29 +02:00
|
|
|
await audit({
|
|
|
|
|
orgId: session.orgId,
|
|
|
|
|
userId: session.userId,
|
|
|
|
|
action: 'auth.login',
|
|
|
|
|
resourceType: 'session',
|
|
|
|
|
metadata: { email: session.email },
|
|
|
|
|
ipAddress: req.ip,
|
|
|
|
|
});
|
2026-05-19 00:24:47 +02:00
|
|
|
return reply.send({
|
|
|
|
|
ok: true,
|
|
|
|
|
user: { id: session.userId, email: session.email, orgId: session.orgId },
|
|
|
|
|
});
|
|
|
|
|
} catch (e) {
|
|
|
|
|
app.log.warn({ err: e }, 'magic link verify failed');
|
|
|
|
|
return reply.code(400).send({ error: 'invalid_or_expired_token' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.get('/v1/auth/me', async (req, reply) => {
|
|
|
|
|
const token = req.cookies[SESSION_COOKIE];
|
|
|
|
|
const session = await getSession(token);
|
|
|
|
|
if (!session) return reply.code(401).send({ error: 'unauthorized' });
|
feat: tiered LLM (GLM free / Claude paid) + rate limits + quota enforcement
The free tier was hemorrhaging Anthropic cost with no abuse cap (no rate
limit on /preview, Opus default in the build worker, 5-min cache TTL that
made cache-miss the common case). This switches free users to GLM, paid
users to Claude tiers, and tightens every leak found in the audit.
Backend:
- @bmm/llm: GLM provider via Zhipu's OpenAI-compatible endpoint, pickPreviewModel
+ pickBuildModel helpers, plan-aware ModelChoice
- preview-cache TTL 5min -> 24h (kills the cache-miss path)
- /v1/servers/preview: picks model from caller's plan, returns model name to UI
- /v1/servers POST: enforces SERVER_LIMITS per plan (402), rate-limits builds
- daily rate-limit on preview (5/40/150/1000) and build (3/20/100/500)
- /v1/auth/me returns plan so the wizard can show the right model name
- generator worker: GLM default, Anthropic Sonnet fallback if GLM errors
Frontend:
- Wizard fetches plan, shows "<model> is drafting the tool spec" pre-emptively,
upgrade hint for hobby users, friendly errors for 402 / 429
- Pricing page: AI-model line per tier (Open-tier / Haiku / Sonnet / Opus),
Team €149 -> €199, Enterprise €499 -> €999, daily-preview limit per tier
- Privacy + Security: explicit subprocessor disclosure for Anthropic (US) /
Zhipu (CN) and which tier uses which
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 23:50:00 +02:00
|
|
|
// Plan is on the org, not the session — look it up fresh so a Stripe
|
|
|
|
|
// upgrade is reflected without forcing a re-login.
|
|
|
|
|
const plan = await getOrgPlan(session.orgId);
|
|
|
|
|
return reply.send({ user: { ...session, plan } });
|
2026-05-19 00:24:47 +02:00
|
|
|
});
|
|
|
|
|
|
feat(admin): password-auth admin panel with 8 pages + 15 API endpoints
Schema migrations:
- users.is_admin boolean
- users.password_hash text (scrypt N=16384, 16-byte salt)
- users.last_login_at timestamp
- organizations.suspended + suspended_reason
- admin_settings table (DB-stored prompt override + future settings)
Auth (@bmm/auth):
- hashPassword + verifyPassword via node:crypto scrypt (no extra dep)
- loginWithPassword: scrypt-verifies, issues 30-day session, updates last_login_at
- seedAdmin: idempotent upsert keyed on email; creates org + membership on first run
- AuthedUser now carries isAdmin flag
API:
- POST /v1/auth/admin/login (email + password) — 300ms throttle on failure
- requireAdmin preHandler — 401 if no session, 403 if non-admin
- Bootstrap: api on boot calls seedAdmin(ADMIN_EMAIL, ADMIN_PASSWORD, ADMIN_NAME)
if env present. Idempotent.
Admin API routes (all gated by requireAdmin):
- GET /v1/admin/overview (totals, trends 7d, server-status breakdown, builds 24h, recent activity)
- GET /v1/admin/users (search, per-row org + plan + serverCount)
- PATCH /v1/admin/users/:id (isAdmin, name)
- DELETE /v1/admin/users/:id (self-delete blocked)
- GET /v1/admin/orgs (member + server counts)
- PATCH /v1/admin/orgs/:id (plan, quota, suspended; cascades to mcp_servers.status=paused on suspend)
- GET /v1/admin/servers (cross-org with status filter)
- POST /v1/admin/servers/:id/rebuild (re-queues build using last prompt)
- DELETE /v1/admin/servers/:id
- GET /v1/admin/builds (status filter, error messages, prompt previews)
- GET /v1/admin/builds/:id/logs
- GET /v1/admin/audit (system-wide with user email join)
- GET /v1/admin/system (DB ping, Redis ping, BullMQ queue depth, docker ps count)
- GET /v1/admin/prompt (builtin + override + updatedAt)
- PATCH /v1/admin/prompt (value: string | null) — saves DB override or drops it
UI (apps/web/app/admin/*):
- /admin/login — password form, separate from /login magic-link
- AdminLayout — Linear-style sidebar (8 nav items), bottom panel with user email +
'user view' shortcut + logout, client-side requireAdmin guard with redirect
- /admin — overview dashboard with 4 metric cards, 2 panels (status + 24h builds),
recent activity table linking to full audit
- /admin/users — search + admin toggle + delete (self-delete blocked)
- /admin/orgs — plan/quota/suspend actions via prompts
- /admin/servers — cross-org table with rebuild + delete actions, status filter
- /admin/builds — every build cross-fleet with error vs prompt preview
- /admin/audit — system-wide log + CSV export + filter dropdowns
- /admin/system — auto-refreshing 5s health probes for Postgres, Redis, queue, Docker
- /admin/prompt — live editor for the LLM system prompt with built-in baseline,
override-state badge, drop-override action, diff preview, save-as-override
End-to-end verified: login as marco.frangiskatos@gmail.com + Melusa112233.*, every
admin page returns 200, admin login + overview tested via screenshot, docker probe
returns true count of running MCP containers.
2026-05-19 23:01:26 +02:00
|
|
|
app.post('/v1/auth/admin/login', async (req, reply) => {
|
|
|
|
|
const Body = z.object({
|
|
|
|
|
email: z.string().email(),
|
|
|
|
|
password: z.string().min(8),
|
|
|
|
|
});
|
|
|
|
|
const parsed = Body.safeParse(req.body);
|
|
|
|
|
if (!parsed.success) {
|
|
|
|
|
return reply.code(400).send({ error: 'invalid_input' });
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const session = await loginWithPassword(parsed.data.email, parsed.data.password, {
|
|
|
|
|
ipAddress: req.ip,
|
|
|
|
|
userAgent: req.headers['user-agent'],
|
|
|
|
|
});
|
|
|
|
|
if (!session.isAdmin) {
|
|
|
|
|
await destroySession(session.sessionToken);
|
|
|
|
|
return reply.code(403).send({ error: 'not_admin' });
|
|
|
|
|
}
|
|
|
|
|
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: 'admin.login',
|
|
|
|
|
resourceType: 'session',
|
|
|
|
|
metadata: { email: session.email },
|
|
|
|
|
ipAddress: req.ip,
|
|
|
|
|
});
|
|
|
|
|
return reply.send({
|
|
|
|
|
ok: true,
|
|
|
|
|
user: { id: session.userId, email: session.email, orgId: session.orgId, isAdmin: true },
|
|
|
|
|
});
|
|
|
|
|
} catch (err) {
|
|
|
|
|
app.log.warn({ err }, 'admin login failed');
|
|
|
|
|
// Constant-time-ish to discourage username enumeration
|
|
|
|
|
await new Promise((r) => setTimeout(r, 300));
|
|
|
|
|
return reply.code(401).send({ error: 'invalid_credentials' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-19 00:24:47 +02:00
|
|
|
app.post('/v1/auth/logout', async (req, reply) => {
|
|
|
|
|
const token = req.cookies[SESSION_COOKIE];
|
feat(api,generator): preview endpoint + spec cache + audit-log writes
- POST /v1/servers/preview runs Claude synchronously, validates output, caches spec
in Redis under preview:<id> with 5min TTL, returns previewId+spec+detectedSecrets.
- POST /v1/servers accepts optional previewId; worker reuses the cached spec if
the entry is still present, otherwise regenerates fresh. Skips the second
Claude round-trip (~30s saved on the demoable path).
- audit() helper writes auth.login, auth.logout, server.create, server.iterate,
server.delete to audit_log with ip, metadata, resourceId.
- GET /v1/me/org returns organization + members list for the settings page.
- GET /v1/audit?limit=&action=&resourceType= returns scoped audit entries.
2026-05-19 18:08:29 +02:00
|
|
|
const session = token ? await getSession(token) : null;
|
2026-05-19 00:24:47 +02:00
|
|
|
if (token) await destroySession(token);
|
|
|
|
|
reply.clearCookie(SESSION_COOKIE, { path: '/' });
|
feat(api,generator): preview endpoint + spec cache + audit-log writes
- POST /v1/servers/preview runs Claude synchronously, validates output, caches spec
in Redis under preview:<id> with 5min TTL, returns previewId+spec+detectedSecrets.
- POST /v1/servers accepts optional previewId; worker reuses the cached spec if
the entry is still present, otherwise regenerates fresh. Skips the second
Claude round-trip (~30s saved on the demoable path).
- audit() helper writes auth.login, auth.logout, server.create, server.iterate,
server.delete to audit_log with ip, metadata, resourceId.
- GET /v1/me/org returns organization + members list for the settings page.
- GET /v1/audit?limit=&action=&resourceType= returns scoped audit entries.
2026-05-19 18:08:29 +02:00
|
|
|
if (session) {
|
|
|
|
|
await audit({
|
|
|
|
|
orgId: session.orgId,
|
|
|
|
|
userId: session.userId,
|
|
|
|
|
action: 'auth.logout',
|
|
|
|
|
resourceType: 'session',
|
|
|
|
|
ipAddress: req.ip,
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-05-19 00:24:47 +02:00
|
|
|
return reply.send({ ok: true });
|
|
|
|
|
});
|
2026-05-21 00:26:44 +02:00
|
|
|
|
|
|
|
|
// 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.
|
|
|
|
|
app.get('/v1/auth/providers', async (_req, reply) => {
|
feat(auth): GitHub OAuth login + SMS one-time-code login
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>
2026-05-21 22:59:58 +02:00
|
|
|
return reply.send({
|
|
|
|
|
google: googleConfigured(),
|
|
|
|
|
github: githubConfigured(),
|
|
|
|
|
sms: smsConfigured(),
|
|
|
|
|
});
|
2026-05-21 00:26:44 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Step 1: hand the browser off to Google's consent screen.
|
|
|
|
|
app.get('/v1/auth/google', async (_req, reply) => {
|
|
|
|
|
if (!config.GOOGLE_OAUTH_ID || !config.GOOGLE_OAUTH_SECRET) {
|
|
|
|
|
return reply.code(503).send({ error: 'google_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://accounts.google.com/o/oauth2/v2/auth');
|
|
|
|
|
url.searchParams.set('client_id', config.GOOGLE_OAUTH_ID);
|
|
|
|
|
url.searchParams.set('redirect_uri', googleRedirectUri());
|
|
|
|
|
url.searchParams.set('response_type', 'code');
|
|
|
|
|
url.searchParams.set('scope', 'openid email profile');
|
|
|
|
|
url.searchParams.set('state', state);
|
|
|
|
|
url.searchParams.set('access_type', 'online');
|
|
|
|
|
url.searchParams.set('prompt', 'select_account');
|
|
|
|
|
return reply.redirect(url.toString());
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Step 2: Google redirects back here with an auth code. Exchange it, verify
|
|
|
|
|
// the ID token, mint a session, drop the user on the dashboard.
|
|
|
|
|
app.get('/v1/auth/google/callback', async (req, reply) => {
|
|
|
|
|
const loginUrl = `${config.NEXT_PUBLIC_APP_URL}/login`;
|
|
|
|
|
const Query = z.object({
|
|
|
|
|
code: z.string().min(10).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=google_failed`);
|
|
|
|
|
}
|
|
|
|
|
// CSRF: the state echoed back by Google must match the one we set.
|
|
|
|
|
// Length-check first — timingSafeEqual throws on a length mismatch.
|
|
|
|
|
if (
|
|
|
|
|
!cookieState ||
|
|
|
|
|
cookieState.length !== q.data.state.length ||
|
|
|
|
|
!crypto.timingSafeEqual(Buffer.from(cookieState), Buffer.from(q.data.state))
|
|
|
|
|
) {
|
|
|
|
|
return reply.redirect(`${loginUrl}?error=google_state`);
|
|
|
|
|
}
|
|
|
|
|
if (!config.GOOGLE_OAUTH_ID || !config.GOOGLE_OAUTH_SECRET) {
|
|
|
|
|
return reply.redirect(`${loginUrl}?error=google_failed`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
|
|
|
body: new URLSearchParams({
|
|
|
|
|
code: q.data.code,
|
|
|
|
|
client_id: config.GOOGLE_OAUTH_ID,
|
|
|
|
|
client_secret: config.GOOGLE_OAUTH_SECRET,
|
|
|
|
|
redirect_uri: googleRedirectUri(),
|
|
|
|
|
grant_type: 'authorization_code',
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
if (!tokenRes.ok) throw new Error(`token_exchange_${tokenRes.status}`);
|
|
|
|
|
const tokens = (await tokenRes.json()) as { id_token?: string };
|
|
|
|
|
if (!tokens.id_token) throw new Error('no_id_token');
|
|
|
|
|
|
|
|
|
|
const claims = decodeGoogleIdToken(tokens.id_token);
|
|
|
|
|
if (claims.iss !== 'accounts.google.com' && claims.iss !== 'https://accounts.google.com') {
|
|
|
|
|
throw new Error('bad_iss');
|
|
|
|
|
}
|
|
|
|
|
if (claims.aud !== config.GOOGLE_OAUTH_ID) throw new Error('bad_aud');
|
|
|
|
|
if (claims.exp * 1000 < Date.now()) throw new Error('token_expired');
|
|
|
|
|
const verified = claims.email_verified === true || claims.email_verified === 'true';
|
|
|
|
|
if (!verified) throw new Error('email_unverified');
|
|
|
|
|
|
|
|
|
|
const session = await upsertOAuthLogin(
|
|
|
|
|
{ email: claims.email, name: claims.name ?? 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: 'google' },
|
|
|
|
|
ipAddress: req.ip,
|
|
|
|
|
});
|
|
|
|
|
return reply.redirect(`${config.NEXT_PUBLIC_APP_URL}/dashboard`);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
app.log.warn({ err }, 'google oauth callback failed');
|
|
|
|
|
return reply.redirect(`${loginUrl}?error=google_failed`);
|
|
|
|
|
}
|
|
|
|
|
});
|
feat(auth): GitHub OAuth login + SMS one-time-code login
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>
2026-05-21 22:59:58 +02:00
|
|
|
|
|
|
|
|
// ---- 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' });
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-05-19 00:24:47 +02:00
|
|
|
}
|