/** * 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; }