diff --git a/app/api/admin/migrate/route.ts b/app/api/admin/migrate/route.ts index 3de244a..2cb4e22 100644 --- a/app/api/admin/migrate/route.ts +++ b/app/api/admin/migrate/route.ts @@ -215,6 +215,74 @@ export async function POST(req: NextRequest) { `ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS coolify_environment_name TEXT NOT NULL DEFAULT 'production'`, `ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS coolify_private_key_uuid TEXT`, `ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS gitea_bot_ssh_key_id INT`, + + // ── Phase 5.1: domains (OpenSRS) + DNS + billing ledger ────────── + // + // vibn_domains — owned domains + their registration lifecycle + // vibn_domain_events — audit trail (register, attach, renew, expire) + // vibn_billing_ledger — money in/out at the workspace level + // + // Reg credentials for a domain (OpenSRS manage-user password) are + // encrypted at rest with AES-256-GCM using VIBN_SECRETS_KEY. + // + // Workspace residency preference for DNS: + // dns_provider = 'cloud_dns' (default, public records) + // dns_provider = 'cira_dzone' (strict Canadian residency, future) + `ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS dns_provider TEXT NOT NULL DEFAULT 'cloud_dns'`, + + `CREATE TABLE IF NOT EXISTS vibn_domains ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES vibn_workspaces(id) ON DELETE CASCADE, + domain TEXT NOT NULL, + tld TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + registrar TEXT NOT NULL DEFAULT 'opensrs', + registrar_order_id TEXT, + registrar_username TEXT, + registrar_password_enc TEXT, + period_years INT NOT NULL DEFAULT 1, + whois_privacy BOOLEAN NOT NULL DEFAULT TRUE, + auto_renew BOOLEAN NOT NULL DEFAULT FALSE, + registered_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + dns_provider TEXT, + dns_zone_id TEXT, + dns_nameservers JSONB, + last_reconciled_at TIMESTAMPTZ, + price_paid_cents INT, + price_currency TEXT, + created_by TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (domain) + )`, + `CREATE INDEX IF NOT EXISTS vibn_domains_workspace_idx ON vibn_domains (workspace_id)`, + `CREATE INDEX IF NOT EXISTS vibn_domains_status_idx ON vibn_domains (status)`, + `CREATE INDEX IF NOT EXISTS vibn_domains_expires_idx ON vibn_domains (expires_at) WHERE expires_at IS NOT NULL`, + + `CREATE TABLE IF NOT EXISTS vibn_domain_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + domain_id UUID NOT NULL REFERENCES vibn_domains(id) ON DELETE CASCADE, + workspace_id UUID NOT NULL REFERENCES vibn_workspaces(id) ON DELETE CASCADE, + type TEXT NOT NULL, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`, + `CREATE INDEX IF NOT EXISTS vibn_domain_events_domain_idx ON vibn_domain_events (domain_id, created_at DESC)`, + + `CREATE TABLE IF NOT EXISTS vibn_billing_ledger ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES vibn_workspaces(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + amount_cents INT NOT NULL, + currency TEXT NOT NULL DEFAULT 'CAD', + ref_type TEXT, + ref_id TEXT, + note TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`, + `CREATE INDEX IF NOT EXISTS vibn_billing_ledger_workspace_idx ON vibn_billing_ledger (workspace_id, created_at DESC)`, + `CREATE INDEX IF NOT EXISTS vibn_billing_ledger_ref_idx ON vibn_billing_ledger (ref_type, ref_id)`, ]; for (const stmt of statements) { diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index 139e460..9057623 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -65,7 +65,7 @@ const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com'; export async function GET() { return NextResponse.json({ name: 'vibn-mcp', - version: '2.1.0', + version: '2.2.0', authentication: { scheme: 'Bearer', tokenPrefix: 'vibn_sk_', @@ -101,6 +101,11 @@ export async function GET() { 'auth.list', 'auth.create', 'auth.delete', + 'domains.search', + 'domains.list', + 'domains.get', + 'domains.register', + 'domains.attach', ], }, }, @@ -191,6 +196,17 @@ export async function POST(request: Request) { case 'auth.delete': return await toolAuthDelete(principal, params); + case 'domains.search': + return await toolDomainsSearch(principal, params); + case 'domains.list': + return await toolDomainsList(principal); + case 'domains.get': + return await toolDomainsGet(principal, params); + case 'domains.register': + return await toolDomainsRegister(principal, params); + case 'domains.attach': + return await toolDomainsAttach(principal, params); + default: return NextResponse.json( { error: `Unknown tool "${action}"` }, @@ -819,3 +835,212 @@ async function toolAuthDelete(principal: Principal, params: Record) result: { ok: true, deleted: { uuid, name: svc.name, volumesKept: !deleteVolumes } }, }); } + +// ────────────────────────────────────────────────── +// Phase 5.1: domains (OpenSRS) +// ────────────────────────────────────────────────── + +async function toolDomainsSearch(principal: Principal, params: Record) { + const namesIn = Array.isArray(params.names) ? params.names : [params.name]; + const names = namesIn + .filter((x: unknown): x is string => typeof x === 'string' && x.trim().length > 0) + .map((s: string) => s.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/+$/, '')); + if (names.length === 0) { + return NextResponse.json({ error: 'Params { names: string[] } or { name: string } required' }, { status: 400 }); + } + const period = typeof params.period === 'number' && params.period > 0 ? params.period : 1; + const { checkDomain } = await import('@/lib/opensrs'); + const results = await Promise.all(names.map(async (name: string) => { + try { + const r = await checkDomain(name, period); + return { + domain: name, + available: r.available, + price: r.price ?? null, + currency: r.currency ?? (process.env.OPENSRS_CURRENCY ?? 'CAD'), + period: r.period ?? period, + }; + } catch (err) { + return { domain: name, available: false, error: err instanceof Error ? err.message : String(err) }; + } + })); + return NextResponse.json({ result: { mode: process.env.OPENSRS_MODE ?? 'test', results } }); +} + +async function toolDomainsList(principal: Principal) { + const { listDomainsForWorkspace } = await import('@/lib/domains'); + const rows = await listDomainsForWorkspace(principal.workspace.id); + return NextResponse.json({ + result: rows.map(r => ({ + id: r.id, + domain: r.domain, + tld: r.tld, + status: r.status, + registeredAt: r.registered_at, + expiresAt: r.expires_at, + periodYears: r.period_years, + dnsProvider: r.dns_provider, + })), + }); +} + +async function toolDomainsGet(principal: Principal, params: Record) { + const name = String(params.domain ?? params.name ?? '').trim().toLowerCase(); + if (!name) return NextResponse.json({ error: 'Param "domain" is required' }, { status: 400 }); + const { getDomainForWorkspace } = await import('@/lib/domains'); + const row = await getDomainForWorkspace(principal.workspace.id, name); + if (!row) return NextResponse.json({ error: 'Domain not found in this workspace' }, { status: 404 }); + return NextResponse.json({ + result: { + id: row.id, + domain: row.domain, + tld: row.tld, + status: row.status, + registrarOrderId: row.registrar_order_id, + periodYears: row.period_years, + registeredAt: row.registered_at, + expiresAt: row.expires_at, + dnsProvider: row.dns_provider, + dnsZoneId: row.dns_zone_id, + dnsNameservers: row.dns_nameservers, + }, + }); +} + +async function toolDomainsRegister(principal: Principal, params: Record) { + const raw = String(params.domain ?? '').toLowerCase().trim() + .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 }); + } + if (!params.contact || typeof params.contact !== 'object') { + return NextResponse.json({ error: '`contact` object is required (see /api/workspaces/[slug]/domains POST schema)' }, { status: 400 }); + } + const { domainTld: tldOf, minPeriodFor, registerDomain, OpenSrsError } = await import('@/lib/opensrs'); + const tld = tldOf(raw); + if (tld === 'ca' && !params.ca) { + return NextResponse.json({ error: '.ca requires `ca.cprCategory` and `ca.legalType`' }, { status: 400 }); + } + const period = minPeriodFor(tld, typeof params.period === 'number' ? params.period : 1); + + const { + createDomainIntent, getDomainForWorkspace, markDomainFailed, + markDomainRegistered, recordDomainEvent, + } = await import('@/lib/domains'); + + let intent = await getDomainForWorkspace(principal.workspace.id, raw); + if (intent && intent.status === 'active') { + return NextResponse.json({ error: `Domain ${raw} is already registered`, domainId: intent.id }, { status: 409 }); + } + if (!intent) { + intent = await createDomainIntent({ + workspaceId: principal.workspace.id, + domain: raw, + createdBy: principal.userId, + periodYears: period, + whoisPrivacy: params.whoisPrivacy ?? true, + }); + } + await recordDomainEvent({ + domainId: intent.id, + workspaceId: principal.workspace.id, + type: 'register.attempt', + payload: { period, via: 'mcp', mode: process.env.OPENSRS_MODE ?? 'test' }, + }); + + try { + const result = await registerDomain({ + domain: raw, + period, + contact: params.contact, + nameservers: params.nameservers, + whoisPrivacy: params.whoisPrivacy ?? true, + ca: params.ca, + }); + const updated = await markDomainRegistered({ + domainId: intent.id, + registrarOrderId: result.orderId, + registrarUsername: result.regUsername, + registrarPassword: result.regPassword, + periodYears: period, + pricePaidCents: null, + priceCurrency: process.env.OPENSRS_CURRENCY ?? 'CAD', + registeredAt: new Date(), + expiresAt: new Date(Date.now() + period * 365 * 24 * 60 * 60 * 1000), + }); + await recordDomainEvent({ + domainId: intent.id, + workspaceId: principal.workspace.id, + type: 'register.success', + payload: { orderId: result.orderId, period, via: 'mcp' }, + }); + return NextResponse.json({ + result: { + ok: true, + mode: process.env.OPENSRS_MODE ?? 'test', + domain: { + id: updated.id, + domain: updated.domain, + status: updated.status, + registrarOrderId: updated.registrar_order_id, + expiresAt: updated.expires_at, + }, + }, + }); + } 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 }); + } +} + +async function toolDomainsAttach(principal: Principal, params: Record) { + const apex = String(params.domain ?? params.name ?? '').trim().toLowerCase(); + if (!apex) return NextResponse.json({ error: 'Param "domain" is required' }, { status: 400 }); + + const { getDomainForWorkspace } = await import('@/lib/domains'); + const row = await getDomainForWorkspace(principal.workspace.id, apex); + if (!row) return NextResponse.json({ error: 'Domain not found in this workspace' }, { status: 404 }); + + const { attachDomain, AttachError } = await import('@/lib/domain-attach'); + try { + const result = await attachDomain(principal.workspace, row, { + appUuid: typeof params.appUuid === 'string' ? params.appUuid : undefined, + ip: typeof params.ip === 'string' ? params.ip : undefined, + cname: typeof params.cname === 'string' ? params.cname : undefined, + subdomains: Array.isArray(params.subdomains) ? params.subdomains : undefined, + updateRegistrarNs: params.updateRegistrarNs !== false, + }); + return NextResponse.json({ + result: { + ok: true, + domain: { + id: result.domain.id, + domain: result.domain.domain, + dnsProvider: result.domain.dns_provider, + dnsZoneId: result.domain.dns_zone_id, + dnsNameservers: result.domain.dns_nameservers, + }, + zone: result.zone, + records: result.records, + registrarNsUpdate: result.registrarNsUpdate, + coolifyUpdate: result.coolifyUpdate, + }, + }); + } catch (err) { + if (err instanceof AttachError) { + return NextResponse.json( + { error: err.message, tag: err.tag, ...(err.extra ?? {}) }, + { status: err.status }, + ); + } + console.error('[mcp domains.attach] unexpected', err); + return NextResponse.json( + { error: 'Attach failed', details: err instanceof Error ? err.message : String(err) }, + { status: 500 }, + ); + } +} diff --git a/app/api/workspaces/[slug]/domains/[domain]/attach/route.ts b/app/api/workspaces/[slug]/domains/[domain]/attach/route.ts new file mode 100644 index 0000000..46289c1 --- /dev/null +++ b/app/api/workspaces/[slug]/domains/[domain]/attach/route.ts @@ -0,0 +1,77 @@ +/** + * POST /api/workspaces/[slug]/domains/[domain]/attach + * + * Wires a registered domain up to a Coolify app (or arbitrary IP/CNAME) + * in one call. Idempotent — safe to retry. + * + * The heavy lifting lives in `lib/domain-attach.ts` so the MCP tool of the + * same name executes the same workflow. + * + * Body: + * { + * appUuid?: string, + * ip?: string, + * cname?: string, + * subdomains?: string[] // default ["@", "www"] + * updateRegistrarNs?: boolean // default true + * } + */ + +import { NextResponse } from 'next/server'; +import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth'; +import { attachDomain, AttachError, type AttachInput } from '@/lib/domain-attach'; +import { getDomainForWorkspace } from '@/lib/domains'; + +export async function POST( + request: Request, + { params }: { params: Promise<{ slug: string; domain: string }> }, +) { + const { slug, domain: domainRaw } = await params; + const principal = await requireWorkspacePrincipal(request, { targetSlug: slug }); + if (principal instanceof NextResponse) return principal; + + const ws = principal.workspace; + const apex = decodeURIComponent(domainRaw).toLowerCase().trim(); + + const row = await getDomainForWorkspace(ws.id, apex); + if (!row) { + return NextResponse.json({ error: 'Domain not found in this workspace' }, { status: 404 }); + } + + let body: AttachInput = {}; + try { + body = await request.json(); + } catch { + // empty body is acceptable for a no-op attach check + } + + try { + const result = await attachDomain(ws, row, body); + return NextResponse.json({ + ok: true, + domain: { + id: result.domain.id, + domain: result.domain.domain, + dnsProvider: result.domain.dns_provider, + dnsZoneId: result.domain.dns_zone_id, + dnsNameservers: result.domain.dns_nameservers, + }, + zone: result.zone, + records: result.records, + registrarNsUpdate: result.registrarNsUpdate, + coolifyUpdate: result.coolifyUpdate, + }); + } catch (err) { + if (err instanceof AttachError) { + return NextResponse.json( + { error: err.message, tag: err.tag, ...(err.extra ?? {}) }, + { status: err.status }, + ); + } + console.error('[domains.attach] unexpected', err); + return NextResponse.json( + { error: 'Attach failed', details: err instanceof Error ? err.message : String(err) }, + { status: 500 }, + ); + } +} diff --git a/app/api/workspaces/[slug]/domains/[domain]/route.ts b/app/api/workspaces/[slug]/domains/[domain]/route.ts new file mode 100644 index 0000000..f4344a3 --- /dev/null +++ b/app/api/workspaces/[slug]/domains/[domain]/route.ts @@ -0,0 +1,63 @@ +/** + * GET /api/workspaces/[slug]/domains/[domain] + * + * Returns the full domain record (sans encrypted registrar password) plus + * recent lifecycle events. Used by the UI and agents to check status after + * a register call. + */ + +import { NextResponse } from 'next/server'; +import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth'; +import { query } from '@/lib/db-postgres'; +import { getDomainForWorkspace } from '@/lib/domains'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ slug: string; domain: string }> }, +) { + const { slug, domain } = await params; + const principal = await requireWorkspacePrincipal(request, { targetSlug: slug }); + if (principal instanceof NextResponse) return principal; + + const row = await getDomainForWorkspace(principal.workspace.id, decodeURIComponent(domain)); + if (!row) { + return NextResponse.json({ error: 'Domain not found in this workspace' }, { status: 404 }); + } + + const events = await query<{ + id: string; + type: string; + payload: Record; + created_at: Date; + }>( + `SELECT id, type, payload, created_at + FROM vibn_domain_events + WHERE domain_id = $1 + ORDER BY created_at DESC + LIMIT 20`, + [row.id], + ); + + return NextResponse.json({ + id: row.id, + domain: row.domain, + tld: row.tld, + status: row.status, + registrar: row.registrar, + registrarOrderId: row.registrar_order_id, + registrarUsername: row.registrar_username, + periodYears: row.period_years, + whoisPrivacy: row.whois_privacy, + autoRenew: row.auto_renew, + registeredAt: row.registered_at, + expiresAt: row.expires_at, + dnsProvider: row.dns_provider, + dnsZoneId: row.dns_zone_id, + dnsNameservers: row.dns_nameservers, + pricePaidCents: row.price_paid_cents, + priceCurrency: row.price_currency, + createdAt: row.created_at, + updatedAt: row.updated_at, + events, + }); +} diff --git a/app/api/workspaces/[slug]/domains/route.ts b/app/api/workspaces/[slug]/domains/route.ts new file mode 100644 index 0000000..6c7fe8e --- /dev/null +++ b/app/api/workspaces/[slug]/domains/route.ts @@ -0,0 +1,238 @@ +/** + * 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; +} diff --git a/app/api/workspaces/[slug]/domains/search/route.ts b/app/api/workspaces/[slug]/domains/search/route.ts new file mode 100644 index 0000000..3d4ecdf --- /dev/null +++ b/app/api/workspaces/[slug]/domains/search/route.ts @@ -0,0 +1,94 @@ +/** + * POST /api/workspaces/[slug]/domains/search + * + * Checks availability + pricing for one or more candidate domains against + * OpenSRS. Stateless; doesn't touch the DB. + * + * Body: { names: string[], period?: number } + * - names: up to 25 fully-qualified names (e.g. "vibnai.com", "vibn.io") + * - period: desired registration period in years (default 1). Auto-bumped + * to the registry minimum for quirky TLDs (e.g. .ai = 2 yrs). + */ + +import { NextResponse } from 'next/server'; +import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth'; +import { checkDomain, OpenSrsError } from '@/lib/opensrs'; + +const MAX_NAMES = 25; + +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: { names?: unknown; period?: unknown } = {}; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + const names = Array.isArray(body.names) + ? (body.names as unknown[]).filter((x): x is string => typeof x === 'string' && x.trim().length > 0) + : []; + if (names.length === 0) { + return NextResponse.json( + { error: 'Body must contain { names: string[] } with at least one domain' }, + { status: 400 }, + ); + } + if (names.length > MAX_NAMES) { + return NextResponse.json( + { error: `Too many names (max ${MAX_NAMES})` }, + { status: 400 }, + ); + } + const period = typeof body.period === 'number' && body.period > 0 ? body.period : 1; + + const results = await Promise.all( + names.map(async raw => { + const name = raw.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/+$/, ''); + try { + const r = await checkDomain(name, period); + return { + domain: name, + available: r.available, + price: r.price ?? null, + currency: r.currency ?? (process.env.OPENSRS_CURRENCY ?? 'CAD'), + period: r.period ?? period, + responseCode: r.responseCode, + responseText: r.responseText, + }; + } catch (err) { + if (err instanceof OpenSrsError) { + return { + domain: name, + available: false, + price: null, + currency: process.env.OPENSRS_CURRENCY ?? 'CAD', + period, + error: err.message, + responseCode: err.code, + }; + } + return { + domain: name, + available: false, + price: null, + currency: process.env.OPENSRS_CURRENCY ?? 'CAD', + period, + error: err instanceof Error ? err.message : String(err), + }; + } + }), + ); + + return NextResponse.json({ + workspace: { slug: principal.workspace.slug }, + mode: process.env.OPENSRS_MODE ?? 'test', + results, + }); +} diff --git a/lib/dns/cloud-dns.ts b/lib/dns/cloud-dns.ts new file mode 100644 index 0000000..16368af --- /dev/null +++ b/lib/dns/cloud-dns.ts @@ -0,0 +1,144 @@ +/** + * Google Cloud DNS driver. + * + * Auth uses the shared vibn-workspace-provisioner SA (which we granted + * roles/dns.admin). Zones are created as PUBLIC managed zones in the + * configured GCP project. Visibility is global (anycast) — see + * `AI_CAPABILITIES_ROADMAP.md#P5.1` for the residency note. + * + * Every read/write goes through https://dns.googleapis.com/dns/v1. + */ + +import { getGcpAccessToken, GCP_PROJECT_ID } from '@/lib/gcp-auth'; +import type { DnsProvider, DnsRecord, DnsZone } from './provider'; + +const API = `https://dns.googleapis.com/dns/v1/projects/${GCP_PROJECT_ID}`; + +function zoneName(apex: string): string { + // Cloud DNS managed-zone names must match [a-z0-9-]+ and start with a letter. + const slug = apex.toLowerCase().replace(/[^a-z0-9-]/g, '-'); + return `vibn-${slug}`; +} + +async function authedFetch( + method: 'GET' | 'POST' | 'DELETE' | 'PATCH', + path: string, + body?: unknown, +): Promise { + const token = await getGcpAccessToken(); + const headers: Record = { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }; + if (body) headers['Content-Type'] = 'application/json'; + return fetch(`${API}${path}`, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); +} + +async function parseOrThrow(res: Response, context: string): Promise { + const text = await res.text(); + if (!res.ok) { + throw new Error(`[cloud-dns ${context} ${res.status}] ${text.slice(0, 500)}`); + } + return text ? JSON.parse(text) : ({} as T); +} + +interface CloudDnsManagedZone { + name: string; + dnsName: string; + nameServers?: string[]; + creationTime?: string; + visibility?: string; +} + +interface CloudDnsRrSet { + name: string; + type: string; + ttl?: number; + rrdatas: string[]; +} + +interface CloudDnsChange { + additions?: CloudDnsRrSet[]; + deletions?: CloudDnsRrSet[]; + kind: 'dns#change'; +} + +export const cloudDnsProvider: DnsProvider = { + id: 'cloud_dns', + + async createZone(apex) { + const name = zoneName(apex); + const dnsName = apex.endsWith('.') ? apex : `${apex}.`; + // Idempotent: if the zone already exists, return it unchanged. + const existing = await cloudDnsProvider.getZone(apex); + if (existing) return existing; + + const res = await authedFetch('POST', `/managedZones`, { + name, + dnsName, + description: `Vibn-managed zone for ${apex}`, + visibility: 'public', + }); + const zone = await parseOrThrow(res, 'createZone'); + return { + apex, + zoneId: zone.name, + nameservers: zone.nameServers ?? [], + createdAt: zone.creationTime, + }; + }, + + async getZone(apex) { + const name = zoneName(apex); + const res = await authedFetch('GET', `/managedZones/${name}`); + if (res.status === 404) return null; + const zone = await parseOrThrow(res, 'getZone'); + return { + apex, + zoneId: zone.name, + nameservers: zone.nameServers ?? [], + createdAt: zone.creationTime, + }; + }, + + /** + * Replaces the record set for each (name,type) pair in `records`. Other + * record sets on the zone are untouched. + */ + async setRecords(apex, records) { + const name = zoneName(apex); + const dnsName = apex.endsWith('.') ? apex : `${apex}.`; + // Fetch existing rrsets for these (name,type) pairs so we can delete + // them as part of the atomic change. + const existingRes = await authedFetch('GET', `/managedZones/${name}/rrsets`); + const existingJson = await parseOrThrow<{ rrsets?: CloudDnsRrSet[] }>(existingRes, 'listRrsets'); + const existing = existingJson.rrsets ?? []; + + const rel = (nm: string) => (nm === '@' ? dnsName : `${nm}.${dnsName}`); + + const additions: CloudDnsRrSet[] = records.map(r => ({ + name: rel(r.name), + type: r.type, + ttl: r.ttl ?? 300, + rrdatas: r.rrdatas, + })); + + const wantPairs = new Set(additions.map(a => `${a.name}|${a.type}`)); + const deletions = existing.filter(e => wantPairs.has(`${e.name}|${e.type}`)); + + const change: CloudDnsChange = { kind: 'dns#change', additions, deletions }; + const res = await authedFetch('POST', `/managedZones/${name}/changes`, change); + await parseOrThrow(res, 'setRecords'); + }, + + async deleteZone(apex) { + const name = zoneName(apex); + const res = await authedFetch('DELETE', `/managedZones/${name}`); + if (res.status === 404) return; + await parseOrThrow(res, 'deleteZone'); + }, +}; diff --git a/lib/dns/provider.ts b/lib/dns/provider.ts new file mode 100644 index 0000000..4cb2eae --- /dev/null +++ b/lib/dns/provider.ts @@ -0,0 +1,48 @@ +/** + * DNS provider interface. + * + * Vibn supports multiple authoritative DNS backends so a workspace can opt + * into stricter residency (CIRA D-Zone) without changing the REST/MCP + * surface. All providers implement the same contract. + * + * Default provider today: cloud-dns (Google Cloud DNS). Global anycast, + * configuration replicated inside Google's infrastructure. Acceptable for + * public records; not Canadian-pinned at the config layer. + * + * Future provider: cira-dzone (CIRA D-Zone, Canadian-operated). Activated + * per-workspace via `dns_provider = 'cira_dzone'`. + */ + +export interface DnsRecord { + /** Relative name (e.g. "@", "www", "app"). */ + name: string; + type: 'A' | 'AAAA' | 'CNAME' | 'TXT' | 'MX' | 'NS' | 'CAA'; + /** RRDATA lines — e.g. ["1.2.3.4"] or ["10 mail.example.com."]. */ + rrdatas: string[]; + ttl?: number; +} + +export interface DnsZone { + apex: string; // "example.com" + zoneId: string; // provider-side zone identifier + nameservers: string[]; // delegation set the registrant should set at the registrar + createdAt?: string; +} + +export interface DnsProvider { + readonly id: 'cloud_dns' | 'cira_dzone'; + createZone(apex: string): Promise; + getZone(apex: string): Promise; + setRecords(apex: string, records: DnsRecord[]): Promise; + deleteZone(apex: string): Promise; +} + +export class DnsNotConfiguredError extends Error { + constructor(providerId: string) { + super( + `DNS provider "${providerId}" is not configured. ` + + `Check that the service account has required permissions and env vars are set.`, + ); + this.name = 'DnsNotConfiguredError'; + } +} diff --git a/lib/domain-attach.ts b/lib/domain-attach.ts new file mode 100644 index 0000000..b4de41f --- /dev/null +++ b/lib/domain-attach.ts @@ -0,0 +1,260 @@ +/** + * Shared domain-attach workflow used by the REST endpoint and the MCP tool. + * + * Extracted from the route handler so there's exactly one code path that + * writes DNS, updates the registrar, mutates Coolify, and persists the + * vibn_domains row. All failures are `AttachError` with a status + tag + * the caller can map to an HTTP response. + */ + +import { + getApplicationInProject, + listServers, + setApplicationDomains, + TenantError, +} from '@/lib/coolify'; +import { cloudDnsProvider } from '@/lib/dns/cloud-dns'; +import type { DnsRecord } from '@/lib/dns/provider'; +import { + markDomainAttached, + recordDomainEvent, + type VibnDomain, +} from '@/lib/domains'; +import { setDomainLock, updateDomainNameservers, OpenSrsError } from '@/lib/opensrs'; +import { parseDomainsString } from '@/lib/naming'; +import type { VibnWorkspace } from '@/lib/workspaces'; + +export interface AttachInput { + appUuid?: string; + ip?: string; + cname?: string; + subdomains?: string[]; + updateRegistrarNs?: boolean; +} + +export interface AttachResult { + domain: VibnDomain; + zone: { apex: string; zoneId: string; nameservers: string[] }; + records: Array<{ name: string; type: string; rrdatas: string[] }>; + registrarNsUpdate: { responseCode: string; responseText: string } | null; + coolifyUpdate: { appUuid: string; domains: string[] } | null; +} + +/** + * Status codes follow the HTTP convention so callers can pass them straight + * to NextResponse. + */ +export class AttachError extends Error { + constructor( + public readonly status: number, + public readonly tag: string, + message: string, + public readonly extra?: Record, + ) { + super(message); + this.name = 'AttachError'; + } +} + +const DEFAULT_SUBS = ['@', 'www']; + +export async function attachDomain( + workspace: VibnWorkspace, + domainRow: VibnDomain, + input: AttachInput, +): Promise { + if (domainRow.status !== 'active') { + throw new AttachError(409, 'not_active', `Domain is not active (status=${domainRow.status})`); + } + if (!input.appUuid && !input.ip && !input.cname) { + throw new AttachError(400, 'no_target', 'One of `appUuid`, `ip`, or `cname` is required'); + } + + const apex = domainRow.domain; + const subs = normalizeSubdomains(input.subdomains); + if (subs.length === 0) { + throw new AttachError(400, 'no_subdomains', '`subdomains` must contain at least one entry'); + } + + // Resolve the upstream target + let targetIps: string[] | null = null; + let targetCname: string | null = null; + let coolifyAppUuid: string | null = null; + + if (input.appUuid) { + if (!workspace.coolify_project_uuid) { + throw new AttachError(503, 'no_project', 'Workspace has no Coolify project yet'); + } + try { + await getApplicationInProject(input.appUuid, workspace.coolify_project_uuid); + } catch (err) { + if (err instanceof TenantError) { + throw new AttachError(403, 'tenant_mismatch', err.message); + } + throw new AttachError(404, 'app_not_found', 'Coolify app not found in this workspace'); + } + coolifyAppUuid = input.appUuid; + + const serverUuid = workspace.coolify_server_uuid; + if (!serverUuid) { + throw new AttachError(503, 'no_server', 'Workspace has no Coolify server — cannot resolve public IP'); + } + const servers = await listServers(); + const server = servers.find(s => s.uuid === serverUuid); + if (!server || !server.ip) { + throw new AttachError(502, 'server_ip_unknown', `Coolify server ${serverUuid} has no public IP`); + } + targetIps = [server.ip]; + } else if (input.ip) { + if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(input.ip)) { + throw new AttachError(400, 'bad_ip', '`ip` must be a valid IPv4 address'); + } + targetIps = [input.ip]; + } else if (input.cname) { + const cname = input.cname.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/+$/, ''); + if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(cname)) { + throw new AttachError(400, 'bad_cname', '`cname` must be a valid hostname'); + } + targetCname = cname.endsWith('.') ? cname : `${cname}.`; + if (subs.includes('@')) { + throw new AttachError(400, 'cname_apex', 'CNAME on apex (@) is not allowed — use `ip` for the apex'); + } + } + + // 1. Ensure Cloud DNS zone exists + let zone; + try { + zone = await cloudDnsProvider.createZone(apex); + } catch (err) { + throw new AttachError(502, 'zone_create_failed', err instanceof Error ? err.message : String(err)); + } + + // 2. Write rrsets + const records: DnsRecord[] = subs.flatMap(sub => { + if (targetIps) return [{ name: sub, type: 'A', rrdatas: targetIps, ttl: 300 }]; + if (targetCname) return [{ name: sub, type: 'CNAME', rrdatas: [targetCname], ttl: 300 }]; + return []; + }); + + try { + await cloudDnsProvider.setRecords(apex, records); + } catch (err) { + throw new AttachError(502, 'rrset_write_failed', err instanceof Error ? err.message : String(err)); + } + + // 3. Registrar-side nameserver update + // + // Some TLDs reject NS changes while the domain is locked (response 405). + // We unlock → update NS → relock so the end state is "locked with the + // right nameservers". If the initial NS update returns 405 we retry once + // with the unlock sandwich. + let registrarNsUpdate: { responseCode: string; responseText: string } | null = null; + const updateNs = input.updateRegistrarNs !== false; + if (updateNs && zone.nameservers.length >= 2) { + try { + registrarNsUpdate = await updateDomainNameservers(apex, zone.nameservers); + } catch (err) { + const locked405 = + err instanceof OpenSrsError && + err.code === '405' && + /status prohibits operation/i.test(err.message); + if (!locked405) { + if (err instanceof OpenSrsError) { + throw new AttachError( + 502, + 'registrar_ns_failed', + `Registrar NS update failed (${err.code}): ${err.message}`, + { zone: { apex, zoneId: zone.zoneId, nameservers: zone.nameservers } }, + ); + } + throw new AttachError(502, 'registrar_ns_failed', err instanceof Error ? err.message : String(err)); + } + // Locked → unlock, update, relock. + try { + await setDomainLock(apex, false); + registrarNsUpdate = await updateDomainNameservers(apex, zone.nameservers); + } catch (retryErr) { + throw new AttachError( + 502, + 'registrar_ns_failed', + `Registrar NS retry failed: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`, + { zone: { apex, zoneId: zone.zoneId, nameservers: zone.nameservers } }, + ); + } finally { + // Best-effort relock — if this fails we log but don't fail the attach. + try { + await setDomainLock(apex, true); + } catch (relockErr) { + console.warn('[attach] relock failed for', apex, relockErr); + } + } + } + } + + // 4. Coolify domain list + let coolifyUpdate: { appUuid: string; domains: string[] } | null = null; + if (coolifyAppUuid && workspace.coolify_project_uuid) { + try { + const app = await getApplicationInProject(coolifyAppUuid, workspace.coolify_project_uuid); + const current = parseDomainsString(app.domains ?? app.fqdn ?? ''); + const toAdd = subs.map(s => (s === '@' ? apex : `${s}.${apex}`)); + const merged = uniq([...current, ...toAdd]); + await setApplicationDomains(coolifyAppUuid, merged, { forceOverride: true }); + coolifyUpdate = { appUuid: coolifyAppUuid, domains: merged }; + } catch (err) { + throw new AttachError( + 502, + 'coolify_domains_failed', + `DNS is wired but Coolify domain-list update failed: ${err instanceof Error ? err.message : String(err)}`, + { zone: { apex, zoneId: zone.zoneId, nameservers: zone.nameservers } }, + ); + } + } + + // 5. Persist + event + const updated = await markDomainAttached({ + domainId: domainRow.id, + dnsProvider: cloudDnsProvider.id, + dnsZoneId: zone.zoneId, + dnsNameservers: zone.nameservers, + }); + + await recordDomainEvent({ + domainId: domainRow.id, + workspaceId: workspace.id, + type: 'attach.success', + payload: { + zone: zone.zoneId, + nameservers: zone.nameservers, + records: records.map(r => ({ name: r.name, type: r.type, rrdatas: r.rrdatas })), + coolifyAppUuid, + registrarNsUpdate, + }, + }); + + return { + domain: updated, + zone, + records: records.map(r => ({ name: r.name, type: r.type, rrdatas: r.rrdatas })), + registrarNsUpdate, + coolifyUpdate, + }; +} + +function normalizeSubdomains(input?: string[]): string[] { + const raw = input && input.length > 0 ? input : DEFAULT_SUBS; + const out: string[] = []; + for (const s of raw) { + if (typeof s !== 'string') continue; + const clean = s.trim().toLowerCase(); + if (!clean) continue; + if (clean === '@' || /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/.test(clean)) { + out.push(clean); + } + } + return uniq(out); +} + +function uniq(xs: T[]): T[] { + return Array.from(new Set(xs)); +} diff --git a/lib/domains.ts b/lib/domains.ts new file mode 100644 index 0000000..12eae61 --- /dev/null +++ b/lib/domains.ts @@ -0,0 +1,221 @@ +/** + * Workspace-scoped domain helpers. + * + * Thin DB accessor layer on top of vibn_domains / vibn_domain_events / + * vibn_billing_ledger. All inserts are idempotent by `(workspace_id, domain)` + * so an accidental double-POST from an agent can't charge twice. + */ + +import { query, queryOne } from '@/lib/db-postgres'; +import { encryptSecret } from '@/lib/auth/secret-box'; +import { domainTld } from '@/lib/opensrs'; + +export type DomainStatus = + | 'pending' // intent recorded, not yet sent to registrar + | 'registering' // OpenSRS call in flight + | 'active' // registered, on our books + | 'failed' // registrar rejected + | 'expired' + | 'deleted'; + +export interface VibnDomain { + id: string; + workspace_id: string; + domain: string; + tld: string; + status: DomainStatus; + registrar: string; + registrar_order_id: string | null; + registrar_username: string | null; + /** Encrypted; call `getDomainRegistrarPassword` to decrypt. */ + registrar_password_enc: string | null; + period_years: number; + whois_privacy: boolean; + auto_renew: boolean; + registered_at: Date | null; + expires_at: Date | null; + dns_provider: string | null; + dns_zone_id: string | null; + dns_nameservers: string[] | null; + price_paid_cents: number | null; + price_currency: string | null; + created_by: string; + created_at: Date; + updated_at: Date; +} + +export async function listDomainsForWorkspace(workspaceId: string): Promise { + return query( + `SELECT * FROM vibn_domains + WHERE workspace_id = $1 + ORDER BY created_at DESC`, + [workspaceId], + ); +} + +export async function getDomainForWorkspace( + workspaceId: string, + domain: string, +): Promise { + const row = await queryOne( + `SELECT * FROM vibn_domains + WHERE workspace_id = $1 AND domain = $2 + LIMIT 1`, + [workspaceId, domain.toLowerCase()], + ); + return row ?? null; +} + +export interface CreateDomainIntentInput { + workspaceId: string; + domain: string; + createdBy: string; + periodYears?: number; + whoisPrivacy?: boolean; +} + +export async function createDomainIntent(input: CreateDomainIntentInput): Promise { + const normalized = input.domain.toLowerCase().trim(); + const tld = domainTld(normalized); + const existing = await getDomainForWorkspace(input.workspaceId, normalized); + if (existing) return existing; + + const rows = await query( + `INSERT INTO vibn_domains + (workspace_id, domain, tld, status, registrar, period_years, whois_privacy, created_by) + VALUES ($1, $2, $3, 'pending', 'opensrs', $4, $5, $6) + RETURNING *`, + [ + input.workspaceId, + normalized, + tld, + input.periodYears ?? 1, + input.whoisPrivacy ?? true, + input.createdBy, + ], + ); + return rows[0]; +} + +export interface MarkRegisteredInput { + domainId: string; + registrarOrderId: string; + registrarUsername: string; + registrarPassword: string; + periodYears: number; + pricePaidCents: number | null; + priceCurrency: string | null; + registeredAt?: Date; + /** Days from registeredAt to expiry (365*years). */ + expiresAt?: Date; +} + +export async function markDomainRegistered(input: MarkRegisteredInput): Promise { + const encPw = encryptSecret(input.registrarPassword); + const rows = await query( + `UPDATE vibn_domains + SET status = 'active', + registrar_order_id = $2, + registrar_username = $3, + registrar_password_enc = $4, + period_years = $5, + price_paid_cents = $6, + price_currency = $7, + registered_at = COALESCE($8, now()), + expires_at = $9, + updated_at = now() + WHERE id = $1 + RETURNING *`, + [ + input.domainId, + input.registrarOrderId, + input.registrarUsername, + encPw, + input.periodYears, + input.pricePaidCents, + input.priceCurrency, + input.registeredAt ?? null, + input.expiresAt ?? null, + ], + ); + return rows[0]; +} + +export interface AttachDomainDnsInput { + domainId: string; + dnsProvider: string; + dnsZoneId: string; + dnsNameservers: string[]; +} + +export async function markDomainAttached(input: AttachDomainDnsInput): Promise { + const rows = await query( + `UPDATE vibn_domains + SET dns_provider = $2, + dns_zone_id = $3, + dns_nameservers = $4::jsonb, + updated_at = now() + WHERE id = $1 + RETURNING *`, + [input.domainId, input.dnsProvider, input.dnsZoneId, JSON.stringify(input.dnsNameservers)], + ); + return rows[0]; +} + +export async function markDomainFailed(domainId: string, reason: string): Promise { + const row = await queryOne<{ workspace_id: string }>( + `UPDATE vibn_domains SET status = 'failed', updated_at = now() + WHERE id = $1 + RETURNING workspace_id`, + [domainId], + ); + if (!row) return; + await recordDomainEvent({ + domainId, + workspaceId: row.workspace_id, + type: 'register.failed', + payload: { reason }, + }); +} + +export interface RecordDomainEventInput { + domainId: string; + workspaceId: string; + type: string; + payload?: Record; +} + +export async function recordDomainEvent(input: RecordDomainEventInput): Promise { + await query( + `INSERT INTO vibn_domain_events (domain_id, workspace_id, type, payload) + VALUES ($1, $2, $3, $4::jsonb)`, + [input.domainId, input.workspaceId, input.type, JSON.stringify(input.payload ?? {})], + ); +} + +export interface RecordLedgerInput { + workspaceId: string; + kind: 'debit' | 'credit'; + amountCents: number; + currency?: string; + refType?: string; + refId?: string; + note?: string; +} + +export async function recordLedgerEntry(input: RecordLedgerInput): Promise { + await query( + `INSERT INTO vibn_billing_ledger + (workspace_id, kind, amount_cents, currency, ref_type, ref_id, note) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + input.workspaceId, + input.kind, + input.amountCents, + input.currency ?? 'CAD', + input.refType ?? null, + input.refId ?? null, + input.note ?? null, + ], + ); +} diff --git a/lib/gcp-auth.ts b/lib/gcp-auth.ts new file mode 100644 index 0000000..09db3d3 --- /dev/null +++ b/lib/gcp-auth.ts @@ -0,0 +1,37 @@ +/** + * Shared GCP access-token helper. + * + * Prefers an explicit service-account key in `GOOGLE_SERVICE_ACCOUNT_KEY_B64` + * (base64-encoded JSON) — this is how the Vibn app is deployed on Coolify. + * Falls back to ADC for local dev and GCE. + * + * The same token grants every role on the underlying SA + * (currently: vibn-workspace-provisioner with roles/run.admin + + * roles/artifactregistry.reader + roles/dns.admin). Callers that need a + * narrower scope should ask for one explicitly. + */ + +import { GoogleAuth, JWT } from 'google-auth-library'; + +const CLOUD_PLATFORM = 'https://www.googleapis.com/auth/cloud-platform'; + +export async function getGcpAccessToken(scopes: string[] = [CLOUD_PLATFORM]): Promise { + const keyB64 = process.env.GOOGLE_SERVICE_ACCOUNT_KEY_B64; + if (keyB64) { + const key = JSON.parse(Buffer.from(keyB64, 'base64').toString('utf-8')) as { + client_email: string; + private_key: string; + }; + const jwt = new JWT({ email: key.client_email, key: key.private_key, scopes }); + const token = await jwt.getAccessToken(); + if (!token.token) throw new Error('GCP auth: JWT access token was empty'); + return token.token as string; + } + const auth = new GoogleAuth({ scopes }); + const client = await auth.getClient(); + const token = await client.getAccessToken(); + if (!token.token) throw new Error('GCP auth: ADC access token was empty'); + return token.token; +} + +export const GCP_PROJECT_ID = process.env.GCP_PROJECT_ID ?? 'master-ai-484822'; diff --git a/lib/opensrs.ts b/lib/opensrs.ts new file mode 100644 index 0000000..8f3e877 --- /dev/null +++ b/lib/opensrs.ts @@ -0,0 +1,600 @@ +/** + * OpenSRS (Tucows) XML-RPC client. + * + * https://domains.opensrs.guide/ + * + * Auth: MD5 double-hash over `xml + api_key`, per OpenSRS docs. + * sig = md5( md5(xml + key) + key ) + * headers: X-Username, X-Signature, Content-Type: text/xml + * + * TLS: + * - live (rr-n1-tor.opensrs.net) — publicly-trusted cert, strict verify. + * - test (horizon.opensrs.net) — self-signed, relaxed verify. This is + * intentional, documented OpenSRS behavior. We only relax in `test` mode + * and never in `live`. + * + * Registry quirks baked in: + * - `.ai` requires period >= 2 years (registry rule) + * - `.ca` requires CPR fields (reg_type + legal_type on contact) — caller + * supplies them; we validate presence before the call. + * + * Nameservers: + * - Sandbox registrations use ns1.systemdns.com / ns2.systemdns.com + * (OpenSRS test requirement — Horizon won't accept arbitrary nameservers). + * - Live registrations use the workspace's attach-time nameservers or the + * Vibn default set if none given. + */ + +import { createHash } from 'crypto'; +import { request as httpsRequest } from 'https'; + +export type OpenSrsMode = 'test' | 'live'; + +export interface OpenSrsConfig { + resellerUsername: string; + apiKey: string; + mode: OpenSrsMode; + host?: string; // override (defaults inferred from mode) + port?: number; + /** Called once per raw HTTP response — useful for logging/tests. */ + onResponse?: (info: { action: string; object: string; ms: number; httpStatus: number }) => void; +} + +function defaultHost(mode: OpenSrsMode): string { + return mode === 'live' ? 'rr-n1-tor.opensrs.net' : 'horizon.opensrs.net'; +} + +export class OpenSrsError extends Error { + constructor( + public readonly code: string, + public readonly action: string, + message: string, + public readonly body?: string, + ) { + super(`[opensrs ${action} ${code}] ${message}`); + this.name = 'OpenSrsError'; + } +} + +// ────────────────────────────────────────────────── +// Low-level transport +// ────────────────────────────────────────────────── + +function signature(xml: string, apiKey: string): string { + const first = createHash('md5').update(xml + apiKey).digest('hex'); + return createHash('md5').update(first + apiKey).digest('hex'); +} + +interface HttpsPostArgs { + host: string; + port: number; + path: string; + body: string; + headers: Record; + rejectUnauthorized: boolean; +} + +function httpsPost(args: HttpsPostArgs): Promise<{ text: string; status: number }> { + return new Promise((resolve, reject) => { + const req = httpsRequest( + { + host: args.host, + port: args.port, + path: args.path, + method: 'POST', + headers: args.headers, + rejectUnauthorized: args.rejectUnauthorized, + }, + res => { + const chunks: Buffer[] = []; + res.on('data', c => chunks.push(c)); + res.on('end', () => { + resolve({ + text: Buffer.concat(chunks).toString('utf8'), + status: res.statusCode ?? 0, + }); + }); + }, + ); + req.on('error', reject); + req.write(args.body); + req.end(); + }); +} + +function configFromEnv(overrides?: Partial): OpenSrsConfig { + const mode = (overrides?.mode ?? (process.env.OPENSRS_MODE as OpenSrsMode) ?? 'test') as OpenSrsMode; + const resellerUsername = + overrides?.resellerUsername ?? + process.env.OPENSRS_RESELLER_USERNAME ?? + ''; + const apiKey = + overrides?.apiKey ?? + (mode === 'live' + ? process.env.OPENSRS_API_KEY_LIVE ?? '' + : process.env.OPENSRS_API_KEY_TEST ?? ''); + if (!resellerUsername || !apiKey) { + throw new Error( + `OpenSRS ${mode} credentials missing (OPENSRS_RESELLER_USERNAME / OPENSRS_API_KEY_${mode.toUpperCase()})`, + ); + } + return { + resellerUsername, + apiKey, + mode, + host: overrides?.host ?? defaultHost(mode), + port: overrides?.port ?? 55443, + onResponse: overrides?.onResponse, + }; +} + +/** + * Low-level call. Returns parsed attribute map on success. Throws + * OpenSrsError on API-level failure (is_success != 1). + */ +async function rawCall( + action: string, + object: string, + attrs: Record, + cfg: OpenSrsConfig, +): Promise { + const xml = buildEnvelope(action, object, attrs); + const sig = signature(xml, cfg.apiKey); + + // Use the native https module so we can disable cert verification for the + // test endpoint (Horizon uses a self-signed cert by design) without the + // process-wide NODE_TLS_REJECT_UNAUTHORIZED hack. + const started = Date.now(); + const { text, status } = await httpsPost({ + host: cfg.host!, + port: cfg.port ?? 55443, + path: '/', + body: xml, + headers: { + 'Content-Type': 'text/xml', + 'X-Username': cfg.resellerUsername, + 'X-Signature': sig, + 'Content-Length': String(Buffer.byteLength(xml)), + }, + rejectUnauthorized: cfg.mode === 'live', + }); + cfg.onResponse?.({ action, object, ms: Date.now() - started, httpStatus: status }); + + if (status !== 200) { + throw new OpenSrsError(String(status), action, `HTTP ${status}`, text); + } + + const parsed = parseEnvelope(text); + if (parsed.is_success !== '1') { + throw new OpenSrsError( + parsed.response_code || 'unknown', + action, + parsed.response_text || 'OpenSRS call failed', + text, + ); + } + return parsed; +} + +// ────────────────────────────────────────────────── +// XML envelope helpers +// ────────────────────────────────────────────────── + +function buildEnvelope(action: string, object: string, attrs: Record): string { + return ` + + +
0.9
+ + + + XCP + ${xmlEsc(action)} + ${xmlEsc(object)} + ${renderValue(attrs)} + + + +
`; +} + +function renderValue(value: unknown): string { + if (value === null || value === undefined) return ''; + if (Array.isArray(value)) { + const items = value + .map((v, i) => `${renderValue(v)}`) + .join(''); + return `${items}`; + } + if (typeof value === 'object') { + const items = Object.entries(value as Record) + .map(([k, v]) => `${renderValue(v)}`) + .join(''); + return `${items}`; + } + return xmlEsc(String(value)); +} + +function xmlEsc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +interface ParsedResponse { + is_success: string; + response_code: string; + response_text: string; + attributes: Record; + /** Raw body for debugging. */ + _raw: string; +} + +/** + * Tiny, purpose-built parser for OpenSRS XML responses. + * + * OpenSRS responses are deterministic and flat enough that a small + * regex-based extractor is sufficient. If we ever need nested + * `` traversal (eg. domain all_info), swap to a real parser. + */ +function parseEnvelope(body: string): ParsedResponse { + const grab = (key: string) => { + const re = new RegExp(`key="${key}">([^<]*)<`, 'i'); + const m = body.match(re); + return m ? m[1] : ''; + }; + // Flatten top-level attribute keys (one level deep under ``) + const attrs: Record = {}; + const attrBlock = body.match(/key="attributes">\s*([\s\S]*?)<\/dt_assoc>/i); + if (attrBlock) { + const inner = attrBlock[1]; + const re = /([\s\S]*?)<\/item>/g; + let m: RegExpExecArray | null; + while ((m = re.exec(inner)) !== null) { + const k = m[1]; + const v = m[2].trim(); + // Leaf values only; nested dt_assoc/dt_array are left as raw XML. + if (!//.test(v)) { + attrs[k] = v.replace(/^$/g, ''); + } else { + attrs[k] = v; + } + } + } + return { + is_success: grab('is_success'), + response_code: grab('response_code'), + response_text: grab('response_text'), + attributes: attrs, + _raw: body, + }; +} + +// ────────────────────────────────────────────────── +// High-level API +// ────────────────────────────────────────────────── + +export interface DomainAvailability { + domain: string; + available: boolean; + responseCode: string; + responseText: string; +} + +export async function lookupDomain( + domain: string, + overrides?: Partial, +): Promise { + const cfg = configFromEnv(overrides); + const parsed = await rawCall('lookup', 'domain', { domain }, cfg); + const status = parsed.attributes.status || ''; + return { + domain, + available: status.toLowerCase() === 'available', + responseCode: parsed.response_code, + responseText: parsed.response_text, + }; +} + +export interface DomainPrice { + domain: string; + period: number; + price: string | null; + currency: string; +} + +export async function getDomainPrice( + domain: string, + period: number, + overrides?: Partial, +): Promise { + const tld = domainTld(domain); + const effectivePeriod = minPeriodFor(tld, period); + const cfg = configFromEnv(overrides); + const parsed = await rawCall( + 'get_price', + 'domain', + { domain, period: effectivePeriod }, + cfg, + ); + return { + domain, + period: effectivePeriod, + price: parsed.attributes.price ?? null, + currency: process.env.OPENSRS_CURRENCY ?? 'CAD', + }; +} + +/** + * Combined availability + price check. Used by `domains.search`. + * Swallows price-lookup errors — an unavailable-or-expensive TLD should still + * return a useful availability row. + */ +export async function checkDomain( + domain: string, + period = 1, + overrides?: Partial, +): Promise> { + const avail = await lookupDomain(domain, overrides); + if (!avail.available) return avail; + try { + const price = await getDomainPrice(domain, period, overrides); + return { ...avail, ...price }; + } catch (err) { + if (err instanceof OpenSrsError) { + return { ...avail, price: null, period: minPeriodFor(domainTld(domain), period), currency: process.env.OPENSRS_CURRENCY ?? 'CAD' }; + } + throw err; + } +} + +export interface RegistrationContact { + first_name: string; + last_name: string; + org_name?: string; + address1: string; + address2?: string; + city: string; + state: string; // province for .ca + country: string; // ISO 2-letter (e.g. "CA") + postal_code: string; + phone: string; // E.164-ish, e.g. +1.4165551234 + fax?: string; + email: string; +} + +export interface RegistrationInput { + domain: string; + period?: number; + contact: RegistrationContact; + nameservers?: string[]; + /** Workspace-scoped manage-user credentials. Generated if omitted. */ + regUsername?: string; + regPassword?: string; + whoisPrivacy?: boolean; + /** Caller IP for the OpenSRS audit log; uses Vibn server IP if unset. */ + registrantIp?: string; + + /** Required for .ca — CIRA Presence Requirements. */ + ca?: { + /** CCT, CCO, RES, ABO, LGR, GOV, EDU, HOP, … */ + cprCategory: string; + /** CCT (Canadian citizen), CCO (Canadian corp), etc. */ + legalType: string; + wantsCiraMemberDiscount?: boolean; + }; +} + +export interface RegistrationResult { + domain: string; + orderId: string; + responseCode: string; + responseText: string; + regUsername: string; + regPassword: string; +} + +/** + * Register a domain. Assumes reseller funds cover the purchase (OpenSRS + * debits the reseller balance — caller should verify balance upstream). + * + * Returns the OpenSRS order id. Actual domain activation is usually + * immediate (cctlds) or async for some TLDs — callers should poll + * `getOrderStatus` or use OpenSRS events to confirm. + */ +export async function registerDomain( + input: RegistrationInput, + overrides?: Partial, +): Promise { + const tld = domainTld(input.domain); + const period = minPeriodFor(tld, input.period ?? 1); + + if (tld === 'ca' && !input.ca) { + throw new Error('.ca registration requires ca.cprCategory and ca.legalType'); + } + + const cfg = configFromEnv(overrides); + const regUsername = input.regUsername ?? generateHandle(input.domain); + const regPassword = input.regPassword ?? generateRandomPassword(); + + const nameservers = + input.nameservers && input.nameservers.length >= 2 + ? input.nameservers + : defaultNameservers(cfg.mode); + + const contactSet = { + owner: input.contact, + admin: input.contact, + billing: input.contact, + tech: input.contact, + }; + + const attrs: Record = { + domain: input.domain, + period, + reg_username: regUsername, + reg_password: regPassword, + reg_type: 'new', + auto_renew: 0, + // Intentionally register UNLOCKED. Locking immediately would block the + // first nameserver update that the attach flow needs. Vibn calls + // `setDomainLock(domain, true)` after DNS is fully wired (see + // lib/domain-attach.ts for the sequence). + f_lock_domain: 0, + f_whois_privacy: input.whoisPrivacy === false ? 0 : 1, + handle: 'process', + contact_set: contactSet, + nameserver_list: nameservers.map((host, i) => ({ + sortorder: i + 1, + name: host, + })), + custom_nameservers: input.nameservers ? 1 : 0, + custom_tech_contact: 0, + }; + + if (input.ca) { + attrs.tld_data = { + ca_owner: { + legal_type: input.ca.legalType, + lang: 'en', + cpr: input.ca.cprCategory, + }, + }; + } + + const parsed = await rawCall('sw_register', 'domain', attrs, cfg); + return { + domain: input.domain, + orderId: parsed.attributes.id || parsed.attributes.order_id || '', + responseCode: parsed.response_code, + responseText: parsed.response_text, + regUsername, + regPassword, + }; +} + +/** + * Replace the registrar-side nameserver list for a domain. Use this after + * creating a Cloud DNS zone so the registry points at the right NS set. + * + * Note: OpenSRS requires the new nameservers to already be registered as + * host records under the registrar, OR to be resolvable in DNS (Cloud DNS + * nameservers are always resolvable, so this works out of the box for us). + */ +export async function updateDomainNameservers( + domain: string, + nameservers: string[], + overrides?: Partial, +): Promise<{ responseCode: string; responseText: string }> { + if (!nameservers || nameservers.length < 2) { + throw new Error('At least two nameservers are required'); + } + const cfg = configFromEnv(overrides); + const parsed = await rawCall( + 'advanced_update_nameservers', + 'domain', + { + domain, + op_type: 'assign', + assign_ns: nameservers, + }, + cfg, + ); + return { responseCode: parsed.response_code, responseText: parsed.response_text }; +} + +/** + * Lock/unlock a domain at the registrar. Locking prevents transfers away + * and is Vibn's default post-attach state. Unlocking is required briefly + * for nameserver changes on TLDs that gate NS updates behind the lock + * (eg. some gTLDs return 405 "Object status prohibits operation"). + */ +export async function setDomainLock( + domain: string, + locked: boolean, + overrides?: Partial, +): Promise<{ responseCode: string; responseText: string }> { + const cfg = configFromEnv(overrides); + const parsed = await rawCall( + 'modify', + 'domain', + { + domain_name: domain, + affect_domains: 0, + data: 'status', + lock_state: locked ? 1 : 0, + }, + cfg, + ); + return { responseCode: parsed.response_code, responseText: parsed.response_text }; +} + +// ────────────────────────────────────────────────── +// Reseller balance + misc +// ────────────────────────────────────────────────── + +export interface ResellerBalance { + balance: string; + holdBalance: string; + currency: string; +} + +export async function getResellerBalance( + overrides?: Partial, +): Promise { + const cfg = configFromEnv(overrides); + const parsed = await rawCall('get_balance', 'balance', {}, cfg); + return { + balance: parsed.attributes.balance || '0', + holdBalance: parsed.attributes.hold_balance || '0', + currency: process.env.OPENSRS_CURRENCY ?? 'CAD', + }; +} + +// ────────────────────────────────────────────────── +// Utilities +// ────────────────────────────────────────────────── + +export function domainTld(domain: string): string { + const parts = domain.toLowerCase().trim().split('.'); + return parts[parts.length - 1] ?? ''; +} + +/** + * Registry-minimum period enforcement. Add entries here as we see them. + * - .ai = 2 years + * - .au = 2 years (if we ever enable) + */ +export function minPeriodFor(tld: string, requested: number): number { + if (tld === 'ai') return Math.max(2, requested); + return Math.max(1, requested); +} + +function defaultNameservers(mode: OpenSrsMode): string[] { + if (mode === 'test') return ['ns1.systemdns.com', 'ns2.systemdns.com']; + // Live: use Cloud DNS names once a zone is attached. Until then we fall back + // to systemdns so registration succeeds — records can be updated later. + return ( + process.env.OPENSRS_DEFAULT_NS?.split(',').map(s => s.trim()).filter(Boolean) ?? [ + 'ns1.systemdns.com', + 'ns2.systemdns.com', + ] + ); +} + +function generateHandle(domain: string): string { + const base = domain.replace(/[^a-z0-9]/gi, '').slice(0, 10).toLowerCase(); + const suffix = Math.random().toString(36).slice(2, 8); + return `vibn${base}${suffix}`; +} + +function generateRandomPassword(): string { + // Alphanumeric only — OpenSRS has been touchy about some punctuation. + const alpha = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789'; + let out = ''; + for (let i = 0; i < 18; i++) out += alpha[Math.floor(Math.random() * alpha.length)]; + return out; +} diff --git a/scripts/smoke-opensrs-lock.ts b/scripts/smoke-opensrs-lock.ts new file mode 100644 index 0000000..788c420 --- /dev/null +++ b/scripts/smoke-opensrs-lock.ts @@ -0,0 +1,21 @@ +/** + * Smoke: register unlocked, lock it, then unlock. Proves setDomainLock(). + */ +import { registerDomain, setDomainLock, type RegistrationContact } from '../lib/opensrs'; + +const CONTACT: RegistrationContact = { + first_name: 'Mark', last_name: 'Henderson', org_name: 'Get Acquired Inc', + address1: '123 King St W', city: 'Toronto', state: 'ON', + country: 'CA', postal_code: 'M5H 1A1', + phone: '+1.4165551234', email: 'mark@getacquired.com', +}; + +async function main() { + const domain = `vibnai-lock-${Date.now()}.com`; + const reg = await registerDomain({ domain, period: 1, contact: CONTACT }); + console.log('[lock-smoke] registered', domain, 'order=', reg.orderId); + console.log('[lock-smoke] lock ->', await setDomainLock(domain, true)); + console.log('[lock-smoke] unlock ->', await setDomainLock(domain, false)); +} + +main().catch(err => { console.error('[lock-smoke] FAILED:', err); process.exit(1); }); diff --git a/scripts/smoke-opensrs-ns-update.ts b/scripts/smoke-opensrs-ns-update.ts new file mode 100644 index 0000000..c63c4e4 --- /dev/null +++ b/scripts/smoke-opensrs-ns-update.ts @@ -0,0 +1,45 @@ +/** + * Smoke: register a sandbox domain, then update its nameservers to a + * fake-but-valid set. Proves the full attach-time registrar flow. + * + * Usage: source .opensrs.env && npx tsx scripts/smoke-opensrs-ns-update.ts + */ + +import { + registerDomain, + updateDomainNameservers, + type RegistrationContact, +} from '../lib/opensrs'; + +const CONTACT: RegistrationContact = { + first_name: 'Mark', + last_name: 'Henderson', + org_name: 'Get Acquired Inc', + address1: '123 King St W', + city: 'Toronto', + state: 'ON', + country: 'CA', + postal_code: 'M5H 1A1', + phone: '+1.4165551234', + email: 'mark@getacquired.com', +}; + +async function main() { + const domain = `vibnai-ns-${Date.now()}.com`; + console.log('[ns-smoke] register', domain); + const reg = await registerDomain({ domain, period: 1, contact: CONTACT }); + console.log('[ns-smoke] registered. order=', reg.orderId); + + // Cloud DNS nameservers follow this format — use real-looking values. + // Horizon only knows about nameservers that already exist at the registry + // it talks to. Its built-in ones are ns1/ns2.systemdns.com, and they're + // the ones every sandbox domain registers with by default. In production + // we'll point at the Cloud DNS nameservers returned from createZone, which + // are publicly resolvable and accepted by every upstream registry. + const ns = ['ns1.systemdns.com', 'ns2.systemdns.com', 'ns3.systemdns.com', 'ns4.systemdns.com']; + console.log('[ns-smoke] updating NS to', ns); + const upd = await updateDomainNameservers(domain, ns); + console.log('[ns-smoke] NS update response:', upd); +} + +main().catch(err => { console.error('[ns-smoke] FAILED:', err); process.exit(1); }); diff --git a/scripts/smoke-opensrs-register.ts b/scripts/smoke-opensrs-register.ts new file mode 100644 index 0000000..8823d4e --- /dev/null +++ b/scripts/smoke-opensrs-register.ts @@ -0,0 +1,38 @@ +/** + * Live sandbox registration smoke. + * + * Runs sw_register against Horizon (test mode) with the Vibn corporate + * contact. Horizon doesn't actually allocate a name at any registry — it's + * purely a protocol simulator — so this is safe to run repeatedly. + * + * Usage: source .opensrs.env && npx tsx scripts/smoke-opensrs-register.ts + */ + +import { registerDomain, type RegistrationContact } from '../lib/opensrs'; + +const CONTACT: RegistrationContact = { + first_name: 'Mark', + last_name: 'Henderson', + org_name: 'Get Acquired Inc', + address1: '123 King St W', + city: 'Toronto', + state: 'ON', + country: 'CA', + postal_code: 'M5H 1A1', + phone: '+1.4165551234', + email: 'mark@getacquired.com', +}; + +async function main() { + const domain = `vibnai-smoke-${Date.now()}.com`; + console.log('[register-smoke] registering', domain); + const result = await registerDomain({ + domain, + period: 1, + contact: CONTACT, + whoisPrivacy: true, + }); + console.log('[register-smoke] result:', result); +} + +main().catch(err => { console.error('[register-smoke] FAILED:', err); process.exit(1); }); diff --git a/scripts/smoke-opensrs-tlds.ts b/scripts/smoke-opensrs-tlds.ts new file mode 100644 index 0000000..0c99238 --- /dev/null +++ b/scripts/smoke-opensrs-tlds.ts @@ -0,0 +1,28 @@ +/** + * TLD-quirk smoke test: verify that .ca, .ai, .io all return sensible + * availability + price through lib/opensrs.ts. + * + * Usage: source .opensrs.env && npx tsx scripts/smoke-opensrs-tlds.ts + */ + +import { checkDomain } from '../lib/opensrs'; + +async function main() { + const stamp = Date.now(); + const cases = [ + { domain: `vibnai-${stamp}.ca`, period: 1 }, + { domain: `vibnai-${stamp}.ai`, period: 1 }, // should auto-bump to 2y + { domain: `vibnai-${stamp}.io`, period: 1 }, + { domain: `vibnai-${stamp}.com`, period: 1 }, + ]; + for (const c of cases) { + try { + const r = await checkDomain(c.domain, c.period); + console.log(c.domain.padEnd(32), '->', JSON.stringify(r)); + } catch (err) { + console.log(c.domain.padEnd(32), '-> ERR', err instanceof Error ? err.message : err); + } + } +} + +main().catch(err => { console.error(err); process.exit(1); }); diff --git a/scripts/smoke-opensrs.ts b/scripts/smoke-opensrs.ts new file mode 100644 index 0000000..674fb1b --- /dev/null +++ b/scripts/smoke-opensrs.ts @@ -0,0 +1,31 @@ +/** + * Smoke test for lib/opensrs.ts against the OpenSRS Horizon sandbox. + * + * Usage (from repo root): + * source .opensrs.env + * cd vibn-frontend && npx tsx scripts/smoke-opensrs.ts vibnai-smoke-$(date +%s).com + * + * - Prints availability + price for the given domain. + * - Does NOT attempt registration (that belongs to a separate manual test). + */ + +import { checkDomain, getResellerBalance, lookupDomain } from '../lib/opensrs'; + +async function main() { + const domain = process.argv[2] ?? `vibnai-smoke-${Date.now()}.com`; + console.log(`[smoke-opensrs] mode=${process.env.OPENSRS_MODE ?? 'test'} domain=${domain}`); + + const balance = await getResellerBalance(); + console.log('[smoke-opensrs] reseller balance:', balance); + + const avail = await lookupDomain(domain); + console.log('[smoke-opensrs] lookup:', avail); + + const check = await checkDomain(domain, 1); + console.log('[smoke-opensrs] check (availability + price):', check); +} + +main().catch(err => { + console.error('[smoke-opensrs] FAILED:', err); + process.exit(1); +});