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
601 lines
19 KiB
TypeScript
601 lines
19 KiB
TypeScript
/**
|
|
* 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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
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;
|
|
}
|