feat(domains): P5.1 — OpenSRS registration + Cloud DNS + Coolify attach

Adds end-to-end custom apex domain support: workspace-scoped
registration via OpenSRS (Tucows), authoritative DNS via Google
Cloud DNS, and one-call attach that wires registrar nameservers,
DNS records, and Coolify app routing in a single transactional
flow.

Schema (additive, idempotent — run /api/admin/migrate after deploy)
  - vibn_workspaces.dns_provider TEXT DEFAULT 'cloud_dns'
      Per-workspace DNS backend choice. Future: 'cira_dzone' for
      strict CA-only residency on .ca.
  - vibn_domains
      One row per registered/intended apex. Tracks status
      (pending|active|failed|expired), registrar order id, encrypted
      registrar manage-user creds (AES-256-GCM, VIBN_SECRETS_KEY),
      period, dates, dns_provider/zone_id/nameservers, and a
      created_by audit field.
  - vibn_domain_events
      Append-only lifecycle audit (register.attempt/success/fail,
      attach.success, ns.update, lock.toggle, etc).
  - vibn_billing_ledger
      Workspace-scoped money ledger (CAD by default) with
      ref_type/ref_id back to the originating row.

OpenSRS XML client (lib/opensrs.ts)
  - Mode-gated host/key (OPENSRS_MODE=test → horizon sandbox,
    rejectUnauthorized:false; live → rr-n1-tor, strict TLS).
  - MD5 double-hash signature.
  - Pure Node https module (no undici dep).
  - Verbs: lookupDomain, getDomainPrice, checkDomain, registerDomain,
    updateDomainNameservers, setDomainLock, getResellerBalance.
  - TLD policy: minPeriodFor() bumps .ai to 2y; CPR/legalType
    plumbed through for .ca; registrations default to UNLOCKED so
    immediate NS updates succeed without a lock toggle.

DNS provider abstraction (lib/dns/{provider,cloud-dns}.ts)
  - DnsProvider interface (createZone/getZone/setRecords/deleteZone)
    so the workspace residency knob can swap backends later.
  - cloudDnsProvider implementation against Google Cloud DNS using
    the existing vibn-workspace-provisioner SA (roles/dns.admin).
  - Idempotent zone creation, additions+deletions diff for rrsets.

Shared GCP auth (lib/gcp-auth.ts)
  - Single getGcpAccessToken() helper used by Cloud DNS today and
    future GCP integrations. Prefers GOOGLE_SERVICE_ACCOUNT_KEY_B64,
    falls back to ADC.

Workspace-scoped helpers (lib/domains.ts)
  - listDomainsForWorkspace, getDomainForWorkspace, createDomainIntent,
    markDomainRegistered, markDomainFailed, markDomainAttached,
    recordDomainEvent, recordLedgerEntry.

Attach orchestrator (lib/domain-attach.ts)
  Single function attachDomain() reused by REST + MCP. For one
  apex it:
    1. Resolves target → Coolify app uuid OR raw IP OR CNAME.
    2. Ensures Cloud DNS managed zone exists.
    3. Writes A / CNAME records (apex + requested subdomains).
    4. Updates registrar nameservers, with auto unlock-retry-relock
       fallback for TLDs that reject NS changes while locked.
    5. PATCHes the Coolify application's domain list so Traefik
       routes the new hostname.
    6. Persists dns_provider/zone_id/nameservers and emits an
       attach.success domain_event.
  AttachError carries a stable .tag + http status so the caller
  can map registrar/dns/coolify failures cleanly.

REST endpoints
  - POST   /api/workspaces/[slug]/domains/search
  - GET    /api/workspaces/[slug]/domains
  - POST   /api/workspaces/[slug]/domains
  - GET    /api/workspaces/[slug]/domains/[domain]
  - POST   /api/workspaces/[slug]/domains/[domain]/attach
  All routes go through requireWorkspacePrincipal (session OR
  Authorization: Bearer vibn_sk_...). Register is idempotent:
  re-issuing for an existing intent re-attempts at OpenSRS without
  duplicating the row or charging twice.

MCP bridge (app/api/mcp/route.ts → version 2.2.0)
  Adds five tools backed by the same library code:
    - domains.search    (batch availability + pricing)
    - domains.list      (workspace-owned)
    - domains.get       (single + recent events)
    - domains.register  (idempotent OpenSRS register)
    - domains.attach    (full Cloud DNS + registrar + Coolify)

Sandbox smoke tests (scripts/smoke-opensrs-*.ts)
  Standalone Node scripts validating each new opensrs.ts call against
  horizon.opensrs.net: balance + lookup + check, TLD policy
  (.ca/.ai/.io/.com), full register flow, NS update with systemdns
  nameservers, and the lock/unlock toggle that backs the attach
  fallback path.

Post-deploy checklist
  1. POST https://vibnai.com/api/admin/migrate
       -H "x-admin-secret: $ADMIN_MIGRATE_SECRET"
  2. Set OPENSRS_* env vars on the vibn-frontend Coolify app
     (RESELLER_USERNAME, API_KEY_LIVE, API_KEY_TEST, HOST_LIVE,
     HOST_TEST, PORT, MODE). Without them, only domains.list/get
     work; search/register/attach return 500.
  3. GCP_PROJECT_ID is read from env or defaults to master-ai-484822.
  4. Live attach end-to-end against a real apex is queued as a
     follow-up — sandbox path is fully proven.

Not in this commit (deliberate)
  - The 100+ unrelated in-flight files (mvp-setup wizard, justine
    homepage rework, BuildLivePlanPanel, etc) — kept local to keep
    blast radius minimal.

Made-with: Cursor
This commit is contained in:
2026-04-21 16:30:39 -07:00
parent de1cd96ec2
commit d6c87a052e
17 changed files with 2239 additions and 1 deletions

144
lib/dns/cloud-dns.ts Normal file
View File

@@ -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<Response> {
const token = await getGcpAccessToken();
const headers: Record<string, string> = {
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<T>(res: Response, context: string): Promise<T> {
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<CloudDnsManagedZone>(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<CloudDnsManagedZone>(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');
},
};

48
lib/dns/provider.ts Normal file
View File

@@ -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<DnsZone>;
getZone(apex: string): Promise<DnsZone | null>;
setRecords(apex: string, records: DnsRecord[]): Promise<void>;
deleteZone(apex: string): Promise<void>;
}
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';
}
}

260
lib/domain-attach.ts Normal file
View File

@@ -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<string, unknown>,
) {
super(message);
this.name = 'AttachError';
}
}
const DEFAULT_SUBS = ['@', 'www'];
export async function attachDomain(
workspace: VibnWorkspace,
domainRow: VibnDomain,
input: AttachInput,
): Promise<AttachResult> {
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<DnsRecord>(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<T>(xs: T[]): T[] {
return Array.from(new Set(xs));
}

221
lib/domains.ts Normal file
View File

@@ -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<VibnDomain[]> {
return query<VibnDomain>(
`SELECT * FROM vibn_domains
WHERE workspace_id = $1
ORDER BY created_at DESC`,
[workspaceId],
);
}
export async function getDomainForWorkspace(
workspaceId: string,
domain: string,
): Promise<VibnDomain | null> {
const row = await queryOne<VibnDomain>(
`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<VibnDomain> {
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<VibnDomain>(
`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<VibnDomain> {
const encPw = encryptSecret(input.registrarPassword);
const rows = await query<VibnDomain>(
`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<VibnDomain> {
const rows = await query<VibnDomain>(
`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<void> {
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<string, unknown>;
}
export async function recordDomainEvent(input: RecordDomainEventInput): Promise<void> {
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<void> {
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,
],
);
}

37
lib/gcp-auth.ts Normal file
View File

@@ -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<string> {
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';

600
lib/opensrs.ts Normal file
View File

@@ -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<string, string>;
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>): 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<string, unknown>,
cfg: OpenSrsConfig,
): Promise<ParsedResponse> {
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, unknown>): string {
return `<?xml version='1.0' encoding='UTF-8' standalone='no'?>
<!DOCTYPE OPS_envelope SYSTEM 'ops.dtd'>
<OPS_envelope>
<header><version>0.9</version></header>
<body>
<data_block>
<dt_assoc>
<item key="protocol">XCP</item>
<item key="action">${xmlEsc(action)}</item>
<item key="object">${xmlEsc(object)}</item>
<item key="attributes">${renderValue(attrs)}</item>
</dt_assoc>
</data_block>
</body>
</OPS_envelope>`;
}
function renderValue(value: unknown): string {
if (value === null || value === undefined) return '';
if (Array.isArray(value)) {
const items = value
.map((v, i) => `<item key="${i}">${renderValue(v)}</item>`)
.join('');
return `<dt_array>${items}</dt_array>`;
}
if (typeof value === 'object') {
const items = Object.entries(value as Record<string, unknown>)
.map(([k, v]) => `<item key="${xmlEsc(k)}">${renderValue(v)}</item>`)
.join('');
return `<dt_assoc>${items}</dt_assoc>`;
}
return xmlEsc(String(value));
}
function xmlEsc(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
interface ParsedResponse {
is_success: string;
response_code: string;
response_text: string;
attributes: Record<string, string>;
/** 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
* `<dt_assoc>` 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 `<item key="attributes">`)
const attrs: Record<string, string> = {};
const attrBlock = body.match(/key="attributes">\s*<dt_assoc>([\s\S]*?)<\/dt_assoc>/i);
if (attrBlock) {
const inner = attrBlock[1];
const re = /<item\s+key="([^"]+)">([\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 (!/<dt_(assoc|array)>/.test(v)) {
attrs[k] = v.replace(/^<!\[CDATA\[|\]\]>$/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<OpenSrsConfig>,
): Promise<DomainAvailability> {
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<OpenSrsConfig>,
): Promise<DomainPrice> {
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<OpenSrsConfig>,
): Promise<DomainAvailability & Partial<DomainPrice>> {
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<OpenSrsConfig>,
): Promise<RegistrationResult> {
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<string, unknown> = {
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<OpenSrsConfig>,
): 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<OpenSrsConfig>,
): 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<OpenSrsConfig>,
): Promise<ResellerBalance> {
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;
}