/** * Workspace authentication providers. * * GET /api/workspaces/[slug]/auth — list auth-provider services * POST /api/workspaces/[slug]/auth — provision one of the vetted providers * * AI-callers can only create providers from an allowlist — we deliberately * skip the rest of Coolify's ~300 one-click templates so this endpoint * stays focused on "auth for my app". The allowlist: * * pocketbase — lightweight (SQLite-backed) auth + data * authentik — feature-rich self-hosted IDP * keycloak — industry-standard OIDC/SAML * keycloak-with-postgres * pocket-id — passkey-first OIDC * pocket-id-with-postgresql * logto — dev-first IDP * supertokens-with-postgresql — session/auth backend * * (Zitadel is not on Coolify's service catalog — callers that ask for * it get a descriptive 400 so the AI knows to pick a supported one.) * * POST body: * { provider: "pocketbase", name?: "auth" } */ import { NextResponse } from 'next/server'; import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth'; import { listServicesInProject, createService, getService, projectUuidOf, } from '@/lib/coolify'; import { slugify } from '@/lib/naming'; /** * Vetted auth-provider service ids. Keys are what callers pass as * `provider`; values are the Coolify service-template slugs. */ const AUTH_PROVIDERS: Record = { pocketbase: 'pocketbase', authentik: 'authentik', keycloak: 'keycloak', 'keycloak-with-postgres': 'keycloak-with-postgres', 'pocket-id': 'pocket-id', 'pocket-id-with-postgresql': 'pocket-id-with-postgresql', logto: 'logto', 'supertokens-with-postgresql': 'supertokens-with-postgresql', }; /** Anything in this set is Coolify-supported but not an auth provider (used for filtering the list view). */ const AUTH_PROVIDER_SLUGS = new Set(Object.values(AUTH_PROVIDERS)); export async function GET( request: Request, { params }: { params: Promise<{ slug: string }> } ) { const { slug } = await params; const principal = await requireWorkspacePrincipal(request, { targetSlug: slug }); if (principal instanceof NextResponse) return principal; const ws = principal.workspace; if (!ws.coolify_project_uuid) { return NextResponse.json( { error: 'Workspace has no Coolify project yet', providers: [] }, { status: 503 } ); } try { const all = await listServicesInProject(ws.coolify_project_uuid); // Coolify's list endpoint only returns summaries (no service_type) so // we fetch each service individually to classify it by template slug. // This is O(n) in services-per-workspace — acceptable at single-digit // scales — and avoids name-based heuristics that break on custom names. const detailed = await Promise.all(all.map(s => getService(s.uuid).catch(() => s))); return NextResponse.json({ workspace: { slug: ws.slug, coolifyProjectUuid: ws.coolify_project_uuid }, providers: detailed .filter(s => { const t = resolveProviderSlug(s); return !!t && AUTH_PROVIDER_SLUGS.has(t); }) .map(s => ({ uuid: s.uuid, name: s.name, status: s.status ?? null, provider: resolveProviderSlug(s), projectUuid: projectUuidOf(s), })), allowedProviders: Object.keys(AUTH_PROVIDERS), }); } catch (err) { return NextResponse.json( { error: 'Coolify request failed', details: err instanceof Error ? err.message : String(err) }, { status: 502 } ); } } export async function POST( request: Request, { params }: { params: Promise<{ slug: string }> } ) { const { slug } = await params; const principal = await requireWorkspacePrincipal(request, { targetSlug: slug }); if (principal instanceof NextResponse) return principal; const ws = principal.workspace; if (!ws.coolify_project_uuid) { return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 }); } let body: { provider?: string; name?: string; description?: string; instantDeploy?: boolean } = {}; try { body = await request.json(); } catch { return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); } const providerKey = (body.provider ?? '').toLowerCase().trim(); const coolifyType = AUTH_PROVIDERS[providerKey]; if (!coolifyType) { return NextResponse.json( { error: `Unsupported provider "${providerKey}". Allowed: ${Object.keys(AUTH_PROVIDERS).join(', ')}`, hint: 'Zitadel is not on Coolify v4 service catalog — use keycloak or authentik instead.', }, { status: 400 } ); } const name = slugify(body.name ?? providerKey); try { const created = await createService({ projectUuid: ws.coolify_project_uuid, type: coolifyType, name, description: body.description ?? `AI-provisioned ${providerKey} for ${ws.slug}`, serverUuid: ws.coolify_server_uuid ?? undefined, environmentName: ws.coolify_environment_name, destinationUuid: ws.coolify_destination_uuid ?? undefined, instantDeploy: body.instantDeploy ?? true, }); const svc = await getService(created.uuid); return NextResponse.json( { uuid: svc.uuid, name: svc.name, provider: providerKey, status: svc.status ?? null, }, { status: 201 } ); } catch (err) { return NextResponse.json( { error: 'Coolify create failed', details: err instanceof Error ? err.message : String(err) }, { status: 502 } ); } } /** * Authoritative: Coolify stores the template slug on `service_type`. * Fall back to a name-prefix match so services created before that field * existed still classify correctly. */ function resolveProviderSlug(svc: { name: string; service_type?: string }): string { if (svc.service_type && AUTH_PROVIDER_SLUGS.has(svc.service_type)) return svc.service_type; const candidates = Object.values(AUTH_PROVIDERS).sort((a, b) => b.length - a.length); for (const slug of candidates) { if (svc.name === slug || svc.name.startsWith(`${slug}-`) || svc.name.startsWith(`${slug}_`)) { return slug; } } return ''; }