Files
vibn-frontend/lib/opensrs.ts
Mark Henderson 651ddf1e11 Rip out Theia, ship P5.1 attach E2E + Justine UI work-in-progress
Theia rip-out:
- Delete app/api/theia-auth/route.ts (Traefik ForwardAuth shim)
- Delete app/api/projects/[projectId]/workspace/route.ts and
  app/api/projects/prewarm/route.ts (Cloud Run Theia provisioning)
- Delete lib/cloud-run-workspace.ts and lib/coolify-workspace.ts
- Strip provisionTheiaWorkspace + theiaWorkspaceUrl/theiaAppUuid/
  theiaError from app/api/projects/create/route.ts response
- Remove Theia callbackUrl branch in app/auth/page.tsx
- Drop "Open in Theia" button + xterm/Theia PTY copy in build/page.tsx
- Drop theiaWorkspaceUrl from deployment/page.tsx Project type
- Strip Theia IDE line + theia-code-os from advisor + agent-chat
  context strings
- Scrub Theia mention from lib/auth/workspace-auth.ts comment

P5.1 (custom apex domains + DNS):
- lib/coolify.ts + lib/opensrs.ts: nameserver normalization, OpenSRS
  XML auth, Cloud DNS plumbing
- scripts/smoke-attach-e2e.ts: full prod GCP + sandbox OpenSRS +
  prod Coolify smoke covering register/zone/A/NS/PATCH/cleanup

In-progress (Justine onboarding/build, MVP setup, agent telemetry):
- New (justine)/stories, project (home) layouts, mvp-setup, run, tasks
  routes + supporting components
- Project shell + sidebar + nav refactor for the Stackless palette
- Agent session API hardening (sessions, events, stream, approve,
  retry, stop) + atlas-chat, advisor, design-surfaces refresh
- New scripts/sync-db-url-from-coolify.mjs +
  scripts/prisma-db-push.mjs + docker-compose.local-db.yml for
  local Prisma workflows
- lib/dev-bypass.ts, lib/chat-context-refs.ts, lib/prd-sections.ts
- Misc: stories CSS, debug/prisma route, modal-theme, BuildLivePlanPanel

Made-with: Cursor
2026-04-22 18:05:01 -07:00

606 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, '&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)
).map(ns => ns.trim().replace(/\.+$/, '').toLowerCase());
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');
}
// OpenSRS rejects FQDN-style nameservers (with trailing dot). Cloud DNS
// returns NS records as `ns-cloud-eX.googledomains.com.` so we must strip
// the trailing dot before handing them to the registrar.
const normalized = nameservers.map(ns => ns.trim().replace(/\.+$/, '').toLowerCase());
const cfg = configFromEnv(overrides);
const parsed = await rawCall(
'advanced_update_nameservers',
'domain',
{
domain,
op_type: 'assign',
assign_ns: normalized,
},
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;
}