/** * Workspace-owned domains. * * GET /api/workspaces/[slug]/domains — list domains owned by the workspace * POST /api/workspaces/[slug]/domains — register a domain through OpenSRS * * POST body: * { * domain: "example.com", * period?: 1, * whoisPrivacy?: true, * contact: { * first_name, last_name, org_name?, * address1, address2?, city, state, country, postal_code, * phone, fax?, email * }, * nameservers?: string[], * ca?: { cprCategory, legalType } // required for .ca * } * * Safety rails: * - `OPENSRS_MODE=test` is strongly recommended until we've verified live * registration end-to-end. The handler reads the current mode from env * and echoes it in the response so agents can tell. * - We guard against duplicate POSTs by reusing an existing `pending` row * for the same (workspace, domain) pair — caller can retry safely. */ import { NextResponse } from 'next/server'; import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth'; import { domainTld, registerDomain, OpenSrsError, minPeriodFor, type RegistrationContact, } from '@/lib/opensrs'; import { createDomainIntent, getDomainForWorkspace, listDomainsForWorkspace, markDomainFailed, markDomainRegistered, recordDomainEvent, recordLedgerEntry, } from '@/lib/domains'; 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 rows = await listDomainsForWorkspace(principal.workspace.id); return NextResponse.json({ workspace: { slug: principal.workspace.slug }, mode: process.env.OPENSRS_MODE ?? 'test', domains: rows.map(r => ({ id: r.id, domain: r.domain, tld: r.tld, status: r.status, registrar: r.registrar, periodYears: r.period_years, whoisPrivacy: r.whois_privacy, autoRenew: r.auto_renew, registeredAt: r.registered_at, expiresAt: r.expires_at, dnsProvider: r.dns_provider, dnsNameservers: r.dns_nameservers, pricePaidCents: r.price_paid_cents, priceCurrency: r.price_currency, createdAt: r.created_at, })), }); } interface PostBody { domain?: string; period?: number; whoisPrivacy?: boolean; contact?: RegistrationContact; nameservers?: string[]; ca?: { cprCategory: string; legalType: string }; } 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; let body: PostBody = {}; try { body = await request.json(); } catch { return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); } const raw = (body.domain ?? '').toString().trim().toLowerCase() .replace(/^https?:\/\//, '').replace(/\/+$/, ''); if (!raw || !/^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(raw)) { return NextResponse.json({ error: '`domain` is required and must be a valid hostname' }, { status: 400 }); } const contactValidation = validateContact(body.contact); if (contactValidation) return contactValidation; const tld = domainTld(raw); if (tld === 'ca' && !body.ca) { return NextResponse.json( { error: '.ca registration requires { ca: { cprCategory, legalType } }' }, { status: 400 }, ); } const period = minPeriodFor(tld, typeof body.period === 'number' ? body.period : 1); // Reuse an existing pending intent to keep POSTs idempotent. let intent = await getDomainForWorkspace(principal.workspace.id, raw); if (intent && intent.status === 'active') { return NextResponse.json( { error: `Domain ${raw} is already registered in this workspace`, domainId: intent.id }, { status: 409 }, ); } if (!intent) { intent = await createDomainIntent({ workspaceId: principal.workspace.id, domain: raw, createdBy: principal.userId, periodYears: period, whoisPrivacy: body.whoisPrivacy ?? true, }); } await recordDomainEvent({ domainId: intent.id, workspaceId: principal.workspace.id, type: 'register.attempt', payload: { period, mode: process.env.OPENSRS_MODE ?? 'test' }, }); try { const result = await registerDomain({ domain: raw, period, contact: body.contact as RegistrationContact, nameservers: body.nameservers, whoisPrivacy: body.whoisPrivacy ?? true, ca: body.ca, }); const priceCents: number | null = null; // registrar price is captured at search time; later we'll pull the real reseller debit from get_balance_changes const currency = process.env.OPENSRS_CURRENCY ?? 'CAD'; const updated = await markDomainRegistered({ domainId: intent.id, registrarOrderId: result.orderId, registrarUsername: result.regUsername, registrarPassword: result.regPassword, periodYears: period, pricePaidCents: priceCents, priceCurrency: currency, registeredAt: new Date(), expiresAt: new Date(Date.now() + period * 365 * 24 * 60 * 60 * 1000), }); if (priceCents) { await recordLedgerEntry({ workspaceId: principal.workspace.id, kind: 'debit', amountCents: priceCents, currency, refType: 'domain.register', refId: intent.id, note: `Register ${raw} (${period}y)`, }); } await recordDomainEvent({ domainId: intent.id, workspaceId: principal.workspace.id, type: 'register.success', payload: { orderId: result.orderId, period, mode: process.env.OPENSRS_MODE ?? 'test' }, }); return NextResponse.json({ ok: true, mode: process.env.OPENSRS_MODE ?? 'test', domain: { id: updated.id, domain: updated.domain, tld: updated.tld, status: updated.status, periodYears: updated.period_years, registeredAt: updated.registered_at, expiresAt: updated.expires_at, registrarOrderId: updated.registrar_order_id, }, }); } catch (err) { const message = err instanceof Error ? err.message : String(err); await markDomainFailed(intent.id, message); if (err instanceof OpenSrsError) { return NextResponse.json( { error: 'Registration failed', registrarCode: err.code, details: err.message }, { status: 502 }, ); } return NextResponse.json( { error: 'Registration failed', details: message }, { status: 500 }, ); } } function validateContact(c?: RegistrationContact): NextResponse | null { if (!c) return NextResponse.json({ error: '`contact` is required' }, { status: 400 }); const required: (keyof RegistrationContact)[] = [ 'first_name', 'last_name', 'address1', 'city', 'state', 'country', 'postal_code', 'phone', 'email', ]; for (const k of required) { if (!c[k] || typeof c[k] !== 'string' || !(c[k] as string).trim()) { return NextResponse.json({ error: `contact.${k} is required` }, { status: 400 }); } } if (!/^[A-Z]{2}$/.test(c.country)) { return NextResponse.json({ error: 'contact.country must be an ISO 3166-1 alpha-2 code' }, { status: 400 }); } if (!/@/.test(c.email)) { return NextResponse.json({ error: 'contact.email must be a valid email' }, { status: 400 }); } return null; }