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:
@@ -215,6 +215,74 @@ export async function POST(req: NextRequest) {
|
||||
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS coolify_environment_name TEXT NOT NULL DEFAULT 'production'`,
|
||||
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS coolify_private_key_uuid TEXT`,
|
||||
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS gitea_bot_ssh_key_id INT`,
|
||||
|
||||
// ── Phase 5.1: domains (OpenSRS) + DNS + billing ledger ──────────
|
||||
//
|
||||
// vibn_domains — owned domains + their registration lifecycle
|
||||
// vibn_domain_events — audit trail (register, attach, renew, expire)
|
||||
// vibn_billing_ledger — money in/out at the workspace level
|
||||
//
|
||||
// Reg credentials for a domain (OpenSRS manage-user password) are
|
||||
// encrypted at rest with AES-256-GCM using VIBN_SECRETS_KEY.
|
||||
//
|
||||
// Workspace residency preference for DNS:
|
||||
// dns_provider = 'cloud_dns' (default, public records)
|
||||
// dns_provider = 'cira_dzone' (strict Canadian residency, future)
|
||||
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS dns_provider TEXT NOT NULL DEFAULT 'cloud_dns'`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS vibn_domains (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES vibn_workspaces(id) ON DELETE CASCADE,
|
||||
domain TEXT NOT NULL,
|
||||
tld TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
registrar TEXT NOT NULL DEFAULT 'opensrs',
|
||||
registrar_order_id TEXT,
|
||||
registrar_username TEXT,
|
||||
registrar_password_enc TEXT,
|
||||
period_years INT NOT NULL DEFAULT 1,
|
||||
whois_privacy BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
registered_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ,
|
||||
dns_provider TEXT,
|
||||
dns_zone_id TEXT,
|
||||
dns_nameservers JSONB,
|
||||
last_reconciled_at TIMESTAMPTZ,
|
||||
price_paid_cents INT,
|
||||
price_currency TEXT,
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (domain)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS vibn_domains_workspace_idx ON vibn_domains (workspace_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS vibn_domains_status_idx ON vibn_domains (status)`,
|
||||
`CREATE INDEX IF NOT EXISTS vibn_domains_expires_idx ON vibn_domains (expires_at) WHERE expires_at IS NOT NULL`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS vibn_domain_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
domain_id UUID NOT NULL REFERENCES vibn_domains(id) ON DELETE CASCADE,
|
||||
workspace_id UUID NOT NULL REFERENCES vibn_workspaces(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL,
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS vibn_domain_events_domain_idx ON vibn_domain_events (domain_id, created_at DESC)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS vibn_billing_ledger (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES vibn_workspaces(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL,
|
||||
amount_cents INT NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'CAD',
|
||||
ref_type TEXT,
|
||||
ref_id TEXT,
|
||||
note TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS vibn_billing_ledger_workspace_idx ON vibn_billing_ledger (workspace_id, created_at DESC)`,
|
||||
`CREATE INDEX IF NOT EXISTS vibn_billing_ledger_ref_idx ON vibn_billing_ledger (ref_type, ref_id)`,
|
||||
];
|
||||
|
||||
for (const stmt of statements) {
|
||||
|
||||
@@ -65,7 +65,7 @@ const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com';
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
name: 'vibn-mcp',
|
||||
version: '2.1.0',
|
||||
version: '2.2.0',
|
||||
authentication: {
|
||||
scheme: 'Bearer',
|
||||
tokenPrefix: 'vibn_sk_',
|
||||
@@ -101,6 +101,11 @@ export async function GET() {
|
||||
'auth.list',
|
||||
'auth.create',
|
||||
'auth.delete',
|
||||
'domains.search',
|
||||
'domains.list',
|
||||
'domains.get',
|
||||
'domains.register',
|
||||
'domains.attach',
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -191,6 +196,17 @@ export async function POST(request: Request) {
|
||||
case 'auth.delete':
|
||||
return await toolAuthDelete(principal, params);
|
||||
|
||||
case 'domains.search':
|
||||
return await toolDomainsSearch(principal, params);
|
||||
case 'domains.list':
|
||||
return await toolDomainsList(principal);
|
||||
case 'domains.get':
|
||||
return await toolDomainsGet(principal, params);
|
||||
case 'domains.register':
|
||||
return await toolDomainsRegister(principal, params);
|
||||
case 'domains.attach':
|
||||
return await toolDomainsAttach(principal, params);
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ error: `Unknown tool "${action}"` },
|
||||
@@ -819,3 +835,212 @@ async function toolAuthDelete(principal: Principal, params: Record<string, any>)
|
||||
result: { ok: true, deleted: { uuid, name: svc.name, volumesKept: !deleteVolumes } },
|
||||
});
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Phase 5.1: domains (OpenSRS)
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
async function toolDomainsSearch(principal: Principal, params: Record<string, any>) {
|
||||
const namesIn = Array.isArray(params.names) ? params.names : [params.name];
|
||||
const names = namesIn
|
||||
.filter((x: unknown): x is string => typeof x === 'string' && x.trim().length > 0)
|
||||
.map((s: string) => s.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/+$/, ''));
|
||||
if (names.length === 0) {
|
||||
return NextResponse.json({ error: 'Params { names: string[] } or { name: string } required' }, { status: 400 });
|
||||
}
|
||||
const period = typeof params.period === 'number' && params.period > 0 ? params.period : 1;
|
||||
const { checkDomain } = await import('@/lib/opensrs');
|
||||
const results = await Promise.all(names.map(async (name: string) => {
|
||||
try {
|
||||
const r = await checkDomain(name, period);
|
||||
return {
|
||||
domain: name,
|
||||
available: r.available,
|
||||
price: r.price ?? null,
|
||||
currency: r.currency ?? (process.env.OPENSRS_CURRENCY ?? 'CAD'),
|
||||
period: r.period ?? period,
|
||||
};
|
||||
} catch (err) {
|
||||
return { domain: name, available: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}));
|
||||
return NextResponse.json({ result: { mode: process.env.OPENSRS_MODE ?? 'test', results } });
|
||||
}
|
||||
|
||||
async function toolDomainsList(principal: Principal) {
|
||||
const { listDomainsForWorkspace } = await import('@/lib/domains');
|
||||
const rows = await listDomainsForWorkspace(principal.workspace.id);
|
||||
return NextResponse.json({
|
||||
result: rows.map(r => ({
|
||||
id: r.id,
|
||||
domain: r.domain,
|
||||
tld: r.tld,
|
||||
status: r.status,
|
||||
registeredAt: r.registered_at,
|
||||
expiresAt: r.expires_at,
|
||||
periodYears: r.period_years,
|
||||
dnsProvider: r.dns_provider,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
async function toolDomainsGet(principal: Principal, params: Record<string, any>) {
|
||||
const name = String(params.domain ?? params.name ?? '').trim().toLowerCase();
|
||||
if (!name) return NextResponse.json({ error: 'Param "domain" is required' }, { status: 400 });
|
||||
const { getDomainForWorkspace } = await import('@/lib/domains');
|
||||
const row = await getDomainForWorkspace(principal.workspace.id, name);
|
||||
if (!row) return NextResponse.json({ error: 'Domain not found in this workspace' }, { status: 404 });
|
||||
return NextResponse.json({
|
||||
result: {
|
||||
id: row.id,
|
||||
domain: row.domain,
|
||||
tld: row.tld,
|
||||
status: row.status,
|
||||
registrarOrderId: row.registrar_order_id,
|
||||
periodYears: row.period_years,
|
||||
registeredAt: row.registered_at,
|
||||
expiresAt: row.expires_at,
|
||||
dnsProvider: row.dns_provider,
|
||||
dnsZoneId: row.dns_zone_id,
|
||||
dnsNameservers: row.dns_nameservers,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function toolDomainsRegister(principal: Principal, params: Record<string, any>) {
|
||||
const raw = String(params.domain ?? '').toLowerCase().trim()
|
||||
.replace(/^https?:\/\//, '').replace(/\/+$/, '');
|
||||
if (!raw || !/^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(raw)) {
|
||||
return NextResponse.json({ error: '`domain` is required and must be a valid hostname' }, { status: 400 });
|
||||
}
|
||||
if (!params.contact || typeof params.contact !== 'object') {
|
||||
return NextResponse.json({ error: '`contact` object is required (see /api/workspaces/[slug]/domains POST schema)' }, { status: 400 });
|
||||
}
|
||||
const { domainTld: tldOf, minPeriodFor, registerDomain, OpenSrsError } = await import('@/lib/opensrs');
|
||||
const tld = tldOf(raw);
|
||||
if (tld === 'ca' && !params.ca) {
|
||||
return NextResponse.json({ error: '.ca requires `ca.cprCategory` and `ca.legalType`' }, { status: 400 });
|
||||
}
|
||||
const period = minPeriodFor(tld, typeof params.period === 'number' ? params.period : 1);
|
||||
|
||||
const {
|
||||
createDomainIntent, getDomainForWorkspace, markDomainFailed,
|
||||
markDomainRegistered, recordDomainEvent,
|
||||
} = await import('@/lib/domains');
|
||||
|
||||
let intent = await getDomainForWorkspace(principal.workspace.id, raw);
|
||||
if (intent && intent.status === 'active') {
|
||||
return NextResponse.json({ error: `Domain ${raw} is already registered`, domainId: intent.id }, { status: 409 });
|
||||
}
|
||||
if (!intent) {
|
||||
intent = await createDomainIntent({
|
||||
workspaceId: principal.workspace.id,
|
||||
domain: raw,
|
||||
createdBy: principal.userId,
|
||||
periodYears: period,
|
||||
whoisPrivacy: params.whoisPrivacy ?? true,
|
||||
});
|
||||
}
|
||||
await recordDomainEvent({
|
||||
domainId: intent.id,
|
||||
workspaceId: principal.workspace.id,
|
||||
type: 'register.attempt',
|
||||
payload: { period, via: 'mcp', mode: process.env.OPENSRS_MODE ?? 'test' },
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await registerDomain({
|
||||
domain: raw,
|
||||
period,
|
||||
contact: params.contact,
|
||||
nameservers: params.nameservers,
|
||||
whoisPrivacy: params.whoisPrivacy ?? true,
|
||||
ca: params.ca,
|
||||
});
|
||||
const updated = await markDomainRegistered({
|
||||
domainId: intent.id,
|
||||
registrarOrderId: result.orderId,
|
||||
registrarUsername: result.regUsername,
|
||||
registrarPassword: result.regPassword,
|
||||
periodYears: period,
|
||||
pricePaidCents: null,
|
||||
priceCurrency: process.env.OPENSRS_CURRENCY ?? 'CAD',
|
||||
registeredAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + period * 365 * 24 * 60 * 60 * 1000),
|
||||
});
|
||||
await recordDomainEvent({
|
||||
domainId: intent.id,
|
||||
workspaceId: principal.workspace.id,
|
||||
type: 'register.success',
|
||||
payload: { orderId: result.orderId, period, via: 'mcp' },
|
||||
});
|
||||
return NextResponse.json({
|
||||
result: {
|
||||
ok: true,
|
||||
mode: process.env.OPENSRS_MODE ?? 'test',
|
||||
domain: {
|
||||
id: updated.id,
|
||||
domain: updated.domain,
|
||||
status: updated.status,
|
||||
registrarOrderId: updated.registrar_order_id,
|
||||
expiresAt: updated.expires_at,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await markDomainFailed(intent.id, message);
|
||||
if (err instanceof OpenSrsError) {
|
||||
return NextResponse.json({ error: 'Registration failed', registrarCode: err.code, details: err.message }, { status: 502 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Registration failed', details: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function toolDomainsAttach(principal: Principal, params: Record<string, any>) {
|
||||
const apex = String(params.domain ?? params.name ?? '').trim().toLowerCase();
|
||||
if (!apex) return NextResponse.json({ error: 'Param "domain" is required' }, { status: 400 });
|
||||
|
||||
const { getDomainForWorkspace } = await import('@/lib/domains');
|
||||
const row = await getDomainForWorkspace(principal.workspace.id, apex);
|
||||
if (!row) return NextResponse.json({ error: 'Domain not found in this workspace' }, { status: 404 });
|
||||
|
||||
const { attachDomain, AttachError } = await import('@/lib/domain-attach');
|
||||
try {
|
||||
const result = await attachDomain(principal.workspace, row, {
|
||||
appUuid: typeof params.appUuid === 'string' ? params.appUuid : undefined,
|
||||
ip: typeof params.ip === 'string' ? params.ip : undefined,
|
||||
cname: typeof params.cname === 'string' ? params.cname : undefined,
|
||||
subdomains: Array.isArray(params.subdomains) ? params.subdomains : undefined,
|
||||
updateRegistrarNs: params.updateRegistrarNs !== false,
|
||||
});
|
||||
return NextResponse.json({
|
||||
result: {
|
||||
ok: true,
|
||||
domain: {
|
||||
id: result.domain.id,
|
||||
domain: result.domain.domain,
|
||||
dnsProvider: result.domain.dns_provider,
|
||||
dnsZoneId: result.domain.dns_zone_id,
|
||||
dnsNameservers: result.domain.dns_nameservers,
|
||||
},
|
||||
zone: result.zone,
|
||||
records: result.records,
|
||||
registrarNsUpdate: result.registrarNsUpdate,
|
||||
coolifyUpdate: result.coolifyUpdate,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof AttachError) {
|
||||
return NextResponse.json(
|
||||
{ error: err.message, tag: err.tag, ...(err.extra ?? {}) },
|
||||
{ status: err.status },
|
||||
);
|
||||
}
|
||||
console.error('[mcp domains.attach] unexpected', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Attach failed', details: err instanceof Error ? err.message : String(err) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
77
app/api/workspaces/[slug]/domains/[domain]/attach/route.ts
Normal file
77
app/api/workspaces/[slug]/domains/[domain]/attach/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* POST /api/workspaces/[slug]/domains/[domain]/attach
|
||||
*
|
||||
* Wires a registered domain up to a Coolify app (or arbitrary IP/CNAME)
|
||||
* in one call. Idempotent — safe to retry.
|
||||
*
|
||||
* The heavy lifting lives in `lib/domain-attach.ts` so the MCP tool of the
|
||||
* same name executes the same workflow.
|
||||
*
|
||||
* Body:
|
||||
* {
|
||||
* appUuid?: string,
|
||||
* ip?: string,
|
||||
* cname?: string,
|
||||
* subdomains?: string[] // default ["@", "www"]
|
||||
* updateRegistrarNs?: boolean // default true
|
||||
* }
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||
import { attachDomain, AttachError, type AttachInput } from '@/lib/domain-attach';
|
||||
import { getDomainForWorkspace } from '@/lib/domains';
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ slug: string; domain: string }> },
|
||||
) {
|
||||
const { slug, domain: domainRaw } = await params;
|
||||
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||
if (principal instanceof NextResponse) return principal;
|
||||
|
||||
const ws = principal.workspace;
|
||||
const apex = decodeURIComponent(domainRaw).toLowerCase().trim();
|
||||
|
||||
const row = await getDomainForWorkspace(ws.id, apex);
|
||||
if (!row) {
|
||||
return NextResponse.json({ error: 'Domain not found in this workspace' }, { status: 404 });
|
||||
}
|
||||
|
||||
let body: AttachInput = {};
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
// empty body is acceptable for a no-op attach check
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await attachDomain(ws, row, body);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
domain: {
|
||||
id: result.domain.id,
|
||||
domain: result.domain.domain,
|
||||
dnsProvider: result.domain.dns_provider,
|
||||
dnsZoneId: result.domain.dns_zone_id,
|
||||
dnsNameservers: result.domain.dns_nameservers,
|
||||
},
|
||||
zone: result.zone,
|
||||
records: result.records,
|
||||
registrarNsUpdate: result.registrarNsUpdate,
|
||||
coolifyUpdate: result.coolifyUpdate,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof AttachError) {
|
||||
return NextResponse.json(
|
||||
{ error: err.message, tag: err.tag, ...(err.extra ?? {}) },
|
||||
{ status: err.status },
|
||||
);
|
||||
}
|
||||
console.error('[domains.attach] unexpected', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Attach failed', details: err instanceof Error ? err.message : String(err) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
63
app/api/workspaces/[slug]/domains/[domain]/route.ts
Normal file
63
app/api/workspaces/[slug]/domains/[domain]/route.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* GET /api/workspaces/[slug]/domains/[domain]
|
||||
*
|
||||
* Returns the full domain record (sans encrypted registrar password) plus
|
||||
* recent lifecycle events. Used by the UI and agents to check status after
|
||||
* a register call.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||
import { query } from '@/lib/db-postgres';
|
||||
import { getDomainForWorkspace } from '@/lib/domains';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ slug: string; domain: string }> },
|
||||
) {
|
||||
const { slug, domain } = await params;
|
||||
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||
if (principal instanceof NextResponse) return principal;
|
||||
|
||||
const row = await getDomainForWorkspace(principal.workspace.id, decodeURIComponent(domain));
|
||||
if (!row) {
|
||||
return NextResponse.json({ error: 'Domain not found in this workspace' }, { status: 404 });
|
||||
}
|
||||
|
||||
const events = await query<{
|
||||
id: string;
|
||||
type: string;
|
||||
payload: Record<string, unknown>;
|
||||
created_at: Date;
|
||||
}>(
|
||||
`SELECT id, type, payload, created_at
|
||||
FROM vibn_domain_events
|
||||
WHERE domain_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 20`,
|
||||
[row.id],
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
id: row.id,
|
||||
domain: row.domain,
|
||||
tld: row.tld,
|
||||
status: row.status,
|
||||
registrar: row.registrar,
|
||||
registrarOrderId: row.registrar_order_id,
|
||||
registrarUsername: row.registrar_username,
|
||||
periodYears: row.period_years,
|
||||
whoisPrivacy: row.whois_privacy,
|
||||
autoRenew: row.auto_renew,
|
||||
registeredAt: row.registered_at,
|
||||
expiresAt: row.expires_at,
|
||||
dnsProvider: row.dns_provider,
|
||||
dnsZoneId: row.dns_zone_id,
|
||||
dnsNameservers: row.dns_nameservers,
|
||||
pricePaidCents: row.price_paid_cents,
|
||||
priceCurrency: row.price_currency,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
events,
|
||||
});
|
||||
}
|
||||
238
app/api/workspaces/[slug]/domains/route.ts
Normal file
238
app/api/workspaces/[slug]/domains/route.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Workspace-owned domains.
|
||||
*
|
||||
* GET /api/workspaces/[slug]/domains — list domains owned by the workspace
|
||||
* POST /api/workspaces/[slug]/domains — register a domain through OpenSRS
|
||||
*
|
||||
* POST body:
|
||||
* {
|
||||
* domain: "example.com",
|
||||
* period?: 1,
|
||||
* whoisPrivacy?: true,
|
||||
* contact: {
|
||||
* first_name, last_name, org_name?,
|
||||
* address1, address2?, city, state, country, postal_code,
|
||||
* phone, fax?, email
|
||||
* },
|
||||
* nameservers?: string[],
|
||||
* ca?: { cprCategory, legalType } // required for .ca
|
||||
* }
|
||||
*
|
||||
* Safety rails:
|
||||
* - `OPENSRS_MODE=test` is strongly recommended until we've verified live
|
||||
* registration end-to-end. The handler reads the current mode from env
|
||||
* and echoes it in the response so agents can tell.
|
||||
* - We guard against duplicate POSTs by reusing an existing `pending` row
|
||||
* for the same (workspace, domain) pair — caller can retry safely.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||
import {
|
||||
domainTld,
|
||||
registerDomain,
|
||||
OpenSrsError,
|
||||
minPeriodFor,
|
||||
type RegistrationContact,
|
||||
} from '@/lib/opensrs';
|
||||
import {
|
||||
createDomainIntent,
|
||||
getDomainForWorkspace,
|
||||
listDomainsForWorkspace,
|
||||
markDomainFailed,
|
||||
markDomainRegistered,
|
||||
recordDomainEvent,
|
||||
recordLedgerEntry,
|
||||
} from '@/lib/domains';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ slug: string }> },
|
||||
) {
|
||||
const { slug } = await params;
|
||||
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||
if (principal instanceof NextResponse) return principal;
|
||||
|
||||
const rows = await listDomainsForWorkspace(principal.workspace.id);
|
||||
return NextResponse.json({
|
||||
workspace: { slug: principal.workspace.slug },
|
||||
mode: process.env.OPENSRS_MODE ?? 'test',
|
||||
domains: rows.map(r => ({
|
||||
id: r.id,
|
||||
domain: r.domain,
|
||||
tld: r.tld,
|
||||
status: r.status,
|
||||
registrar: r.registrar,
|
||||
periodYears: r.period_years,
|
||||
whoisPrivacy: r.whois_privacy,
|
||||
autoRenew: r.auto_renew,
|
||||
registeredAt: r.registered_at,
|
||||
expiresAt: r.expires_at,
|
||||
dnsProvider: r.dns_provider,
|
||||
dnsNameservers: r.dns_nameservers,
|
||||
pricePaidCents: r.price_paid_cents,
|
||||
priceCurrency: r.price_currency,
|
||||
createdAt: r.created_at,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
interface PostBody {
|
||||
domain?: string;
|
||||
period?: number;
|
||||
whoisPrivacy?: boolean;
|
||||
contact?: RegistrationContact;
|
||||
nameservers?: string[];
|
||||
ca?: { cprCategory: string; legalType: string };
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ slug: string }> },
|
||||
) {
|
||||
const { slug } = await params;
|
||||
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||
if (principal instanceof NextResponse) return principal;
|
||||
|
||||
let body: PostBody = {};
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const raw = (body.domain ?? '').toString().trim().toLowerCase()
|
||||
.replace(/^https?:\/\//, '').replace(/\/+$/, '');
|
||||
if (!raw || !/^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(raw)) {
|
||||
return NextResponse.json({ error: '`domain` is required and must be a valid hostname' }, { status: 400 });
|
||||
}
|
||||
const contactValidation = validateContact(body.contact);
|
||||
if (contactValidation) return contactValidation;
|
||||
|
||||
const tld = domainTld(raw);
|
||||
if (tld === 'ca' && !body.ca) {
|
||||
return NextResponse.json(
|
||||
{ error: '.ca registration requires { ca: { cprCategory, legalType } }' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const period = minPeriodFor(tld, typeof body.period === 'number' ? body.period : 1);
|
||||
|
||||
// Reuse an existing pending intent to keep POSTs idempotent.
|
||||
let intent = await getDomainForWorkspace(principal.workspace.id, raw);
|
||||
if (intent && intent.status === 'active') {
|
||||
return NextResponse.json(
|
||||
{ error: `Domain ${raw} is already registered in this workspace`, domainId: intent.id },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
if (!intent) {
|
||||
intent = await createDomainIntent({
|
||||
workspaceId: principal.workspace.id,
|
||||
domain: raw,
|
||||
createdBy: principal.userId,
|
||||
periodYears: period,
|
||||
whoisPrivacy: body.whoisPrivacy ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
await recordDomainEvent({
|
||||
domainId: intent.id,
|
||||
workspaceId: principal.workspace.id,
|
||||
type: 'register.attempt',
|
||||
payload: { period, mode: process.env.OPENSRS_MODE ?? 'test' },
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await registerDomain({
|
||||
domain: raw,
|
||||
period,
|
||||
contact: body.contact as RegistrationContact,
|
||||
nameservers: body.nameservers,
|
||||
whoisPrivacy: body.whoisPrivacy ?? true,
|
||||
ca: body.ca,
|
||||
});
|
||||
|
||||
const priceCents: number | null = null; // registrar price is captured at search time; later we'll pull the real reseller debit from get_balance_changes
|
||||
const currency = process.env.OPENSRS_CURRENCY ?? 'CAD';
|
||||
|
||||
const updated = await markDomainRegistered({
|
||||
domainId: intent.id,
|
||||
registrarOrderId: result.orderId,
|
||||
registrarUsername: result.regUsername,
|
||||
registrarPassword: result.regPassword,
|
||||
periodYears: period,
|
||||
pricePaidCents: priceCents,
|
||||
priceCurrency: currency,
|
||||
registeredAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + period * 365 * 24 * 60 * 60 * 1000),
|
||||
});
|
||||
|
||||
if (priceCents) {
|
||||
await recordLedgerEntry({
|
||||
workspaceId: principal.workspace.id,
|
||||
kind: 'debit',
|
||||
amountCents: priceCents,
|
||||
currency,
|
||||
refType: 'domain.register',
|
||||
refId: intent.id,
|
||||
note: `Register ${raw} (${period}y)`,
|
||||
});
|
||||
}
|
||||
|
||||
await recordDomainEvent({
|
||||
domainId: intent.id,
|
||||
workspaceId: principal.workspace.id,
|
||||
type: 'register.success',
|
||||
payload: { orderId: result.orderId, period, mode: process.env.OPENSRS_MODE ?? 'test' },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
mode: process.env.OPENSRS_MODE ?? 'test',
|
||||
domain: {
|
||||
id: updated.id,
|
||||
domain: updated.domain,
|
||||
tld: updated.tld,
|
||||
status: updated.status,
|
||||
periodYears: updated.period_years,
|
||||
registeredAt: updated.registered_at,
|
||||
expiresAt: updated.expires_at,
|
||||
registrarOrderId: updated.registrar_order_id,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await markDomainFailed(intent.id, message);
|
||||
if (err instanceof OpenSrsError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Registration failed', registrarCode: err.code, details: err.message },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'Registration failed', details: message },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateContact(c?: RegistrationContact): NextResponse | null {
|
||||
if (!c) return NextResponse.json({ error: '`contact` is required' }, { status: 400 });
|
||||
const required: (keyof RegistrationContact)[] = [
|
||||
'first_name', 'last_name', 'address1', 'city', 'state', 'country', 'postal_code', 'phone', 'email',
|
||||
];
|
||||
for (const k of required) {
|
||||
if (!c[k] || typeof c[k] !== 'string' || !(c[k] as string).trim()) {
|
||||
return NextResponse.json({ error: `contact.${k} is required` }, { status: 400 });
|
||||
}
|
||||
}
|
||||
if (!/^[A-Z]{2}$/.test(c.country)) {
|
||||
return NextResponse.json({ error: 'contact.country must be an ISO 3166-1 alpha-2 code' }, { status: 400 });
|
||||
}
|
||||
if (!/@/.test(c.email)) {
|
||||
return NextResponse.json({ error: 'contact.email must be a valid email' }, { status: 400 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
94
app/api/workspaces/[slug]/domains/search/route.ts
Normal file
94
app/api/workspaces/[slug]/domains/search/route.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* POST /api/workspaces/[slug]/domains/search
|
||||
*
|
||||
* Checks availability + pricing for one or more candidate domains against
|
||||
* OpenSRS. Stateless; doesn't touch the DB.
|
||||
*
|
||||
* Body: { names: string[], period?: number }
|
||||
* - names: up to 25 fully-qualified names (e.g. "vibnai.com", "vibn.io")
|
||||
* - period: desired registration period in years (default 1). Auto-bumped
|
||||
* to the registry minimum for quirky TLDs (e.g. .ai = 2 yrs).
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||
import { checkDomain, OpenSrsError } from '@/lib/opensrs';
|
||||
|
||||
const MAX_NAMES = 25;
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ slug: string }> },
|
||||
) {
|
||||
const { slug } = await params;
|
||||
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||
if (principal instanceof NextResponse) return principal;
|
||||
|
||||
let body: { names?: unknown; period?: unknown } = {};
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const names = Array.isArray(body.names)
|
||||
? (body.names as unknown[]).filter((x): x is string => typeof x === 'string' && x.trim().length > 0)
|
||||
: [];
|
||||
if (names.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Body must contain { names: string[] } with at least one domain' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (names.length > MAX_NAMES) {
|
||||
return NextResponse.json(
|
||||
{ error: `Too many names (max ${MAX_NAMES})` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const period = typeof body.period === 'number' && body.period > 0 ? body.period : 1;
|
||||
|
||||
const results = await Promise.all(
|
||||
names.map(async raw => {
|
||||
const name = raw.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/+$/, '');
|
||||
try {
|
||||
const r = await checkDomain(name, period);
|
||||
return {
|
||||
domain: name,
|
||||
available: r.available,
|
||||
price: r.price ?? null,
|
||||
currency: r.currency ?? (process.env.OPENSRS_CURRENCY ?? 'CAD'),
|
||||
period: r.period ?? period,
|
||||
responseCode: r.responseCode,
|
||||
responseText: r.responseText,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof OpenSrsError) {
|
||||
return {
|
||||
domain: name,
|
||||
available: false,
|
||||
price: null,
|
||||
currency: process.env.OPENSRS_CURRENCY ?? 'CAD',
|
||||
period,
|
||||
error: err.message,
|
||||
responseCode: err.code,
|
||||
};
|
||||
}
|
||||
return {
|
||||
domain: name,
|
||||
available: false,
|
||||
price: null,
|
||||
currency: process.env.OPENSRS_CURRENCY ?? 'CAD',
|
||||
period,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
workspace: { slug: principal.workspace.slug },
|
||||
mode: process.env.OPENSRS_MODE ?? 'test',
|
||||
results,
|
||||
});
|
||||
}
|
||||
144
lib/dns/cloud-dns.ts
Normal file
144
lib/dns/cloud-dns.ts
Normal 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
48
lib/dns/provider.ts
Normal 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
260
lib/domain-attach.ts
Normal 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
221
lib/domains.ts
Normal 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
37
lib/gcp-auth.ts
Normal 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
600
lib/opensrs.ts
Normal 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, '&')
|
||||
.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;
|
||||
}
|
||||
21
scripts/smoke-opensrs-lock.ts
Normal file
21
scripts/smoke-opensrs-lock.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Smoke: register unlocked, lock it, then unlock. Proves setDomainLock().
|
||||
*/
|
||||
import { registerDomain, setDomainLock, type RegistrationContact } from '../lib/opensrs';
|
||||
|
||||
const CONTACT: RegistrationContact = {
|
||||
first_name: 'Mark', last_name: 'Henderson', org_name: 'Get Acquired Inc',
|
||||
address1: '123 King St W', city: 'Toronto', state: 'ON',
|
||||
country: 'CA', postal_code: 'M5H 1A1',
|
||||
phone: '+1.4165551234', email: 'mark@getacquired.com',
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const domain = `vibnai-lock-${Date.now()}.com`;
|
||||
const reg = await registerDomain({ domain, period: 1, contact: CONTACT });
|
||||
console.log('[lock-smoke] registered', domain, 'order=', reg.orderId);
|
||||
console.log('[lock-smoke] lock ->', await setDomainLock(domain, true));
|
||||
console.log('[lock-smoke] unlock ->', await setDomainLock(domain, false));
|
||||
}
|
||||
|
||||
main().catch(err => { console.error('[lock-smoke] FAILED:', err); process.exit(1); });
|
||||
45
scripts/smoke-opensrs-ns-update.ts
Normal file
45
scripts/smoke-opensrs-ns-update.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Smoke: register a sandbox domain, then update its nameservers to a
|
||||
* fake-but-valid set. Proves the full attach-time registrar flow.
|
||||
*
|
||||
* Usage: source .opensrs.env && npx tsx scripts/smoke-opensrs-ns-update.ts
|
||||
*/
|
||||
|
||||
import {
|
||||
registerDomain,
|
||||
updateDomainNameservers,
|
||||
type RegistrationContact,
|
||||
} from '../lib/opensrs';
|
||||
|
||||
const CONTACT: RegistrationContact = {
|
||||
first_name: 'Mark',
|
||||
last_name: 'Henderson',
|
||||
org_name: 'Get Acquired Inc',
|
||||
address1: '123 King St W',
|
||||
city: 'Toronto',
|
||||
state: 'ON',
|
||||
country: 'CA',
|
||||
postal_code: 'M5H 1A1',
|
||||
phone: '+1.4165551234',
|
||||
email: 'mark@getacquired.com',
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const domain = `vibnai-ns-${Date.now()}.com`;
|
||||
console.log('[ns-smoke] register', domain);
|
||||
const reg = await registerDomain({ domain, period: 1, contact: CONTACT });
|
||||
console.log('[ns-smoke] registered. order=', reg.orderId);
|
||||
|
||||
// Cloud DNS nameservers follow this format — use real-looking values.
|
||||
// Horizon only knows about nameservers that already exist at the registry
|
||||
// it talks to. Its built-in ones are ns1/ns2.systemdns.com, and they're
|
||||
// the ones every sandbox domain registers with by default. In production
|
||||
// we'll point at the Cloud DNS nameservers returned from createZone, which
|
||||
// are publicly resolvable and accepted by every upstream registry.
|
||||
const ns = ['ns1.systemdns.com', 'ns2.systemdns.com', 'ns3.systemdns.com', 'ns4.systemdns.com'];
|
||||
console.log('[ns-smoke] updating NS to', ns);
|
||||
const upd = await updateDomainNameservers(domain, ns);
|
||||
console.log('[ns-smoke] NS update response:', upd);
|
||||
}
|
||||
|
||||
main().catch(err => { console.error('[ns-smoke] FAILED:', err); process.exit(1); });
|
||||
38
scripts/smoke-opensrs-register.ts
Normal file
38
scripts/smoke-opensrs-register.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Live sandbox registration smoke.
|
||||
*
|
||||
* Runs sw_register against Horizon (test mode) with the Vibn corporate
|
||||
* contact. Horizon doesn't actually allocate a name at any registry — it's
|
||||
* purely a protocol simulator — so this is safe to run repeatedly.
|
||||
*
|
||||
* Usage: source .opensrs.env && npx tsx scripts/smoke-opensrs-register.ts
|
||||
*/
|
||||
|
||||
import { registerDomain, type RegistrationContact } from '../lib/opensrs';
|
||||
|
||||
const CONTACT: RegistrationContact = {
|
||||
first_name: 'Mark',
|
||||
last_name: 'Henderson',
|
||||
org_name: 'Get Acquired Inc',
|
||||
address1: '123 King St W',
|
||||
city: 'Toronto',
|
||||
state: 'ON',
|
||||
country: 'CA',
|
||||
postal_code: 'M5H 1A1',
|
||||
phone: '+1.4165551234',
|
||||
email: 'mark@getacquired.com',
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const domain = `vibnai-smoke-${Date.now()}.com`;
|
||||
console.log('[register-smoke] registering', domain);
|
||||
const result = await registerDomain({
|
||||
domain,
|
||||
period: 1,
|
||||
contact: CONTACT,
|
||||
whoisPrivacy: true,
|
||||
});
|
||||
console.log('[register-smoke] result:', result);
|
||||
}
|
||||
|
||||
main().catch(err => { console.error('[register-smoke] FAILED:', err); process.exit(1); });
|
||||
28
scripts/smoke-opensrs-tlds.ts
Normal file
28
scripts/smoke-opensrs-tlds.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* TLD-quirk smoke test: verify that .ca, .ai, .io all return sensible
|
||||
* availability + price through lib/opensrs.ts.
|
||||
*
|
||||
* Usage: source .opensrs.env && npx tsx scripts/smoke-opensrs-tlds.ts
|
||||
*/
|
||||
|
||||
import { checkDomain } from '../lib/opensrs';
|
||||
|
||||
async function main() {
|
||||
const stamp = Date.now();
|
||||
const cases = [
|
||||
{ domain: `vibnai-${stamp}.ca`, period: 1 },
|
||||
{ domain: `vibnai-${stamp}.ai`, period: 1 }, // should auto-bump to 2y
|
||||
{ domain: `vibnai-${stamp}.io`, period: 1 },
|
||||
{ domain: `vibnai-${stamp}.com`, period: 1 },
|
||||
];
|
||||
for (const c of cases) {
|
||||
try {
|
||||
const r = await checkDomain(c.domain, c.period);
|
||||
console.log(c.domain.padEnd(32), '->', JSON.stringify(r));
|
||||
} catch (err) {
|
||||
console.log(c.domain.padEnd(32), '-> ERR', err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err); process.exit(1); });
|
||||
31
scripts/smoke-opensrs.ts
Normal file
31
scripts/smoke-opensrs.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Smoke test for lib/opensrs.ts against the OpenSRS Horizon sandbox.
|
||||
*
|
||||
* Usage (from repo root):
|
||||
* source .opensrs.env
|
||||
* cd vibn-frontend && npx tsx scripts/smoke-opensrs.ts vibnai-smoke-$(date +%s).com
|
||||
*
|
||||
* - Prints availability + price for the given domain.
|
||||
* - Does NOT attempt registration (that belongs to a separate manual test).
|
||||
*/
|
||||
|
||||
import { checkDomain, getResellerBalance, lookupDomain } from '../lib/opensrs';
|
||||
|
||||
async function main() {
|
||||
const domain = process.argv[2] ?? `vibnai-smoke-${Date.now()}.com`;
|
||||
console.log(`[smoke-opensrs] mode=${process.env.OPENSRS_MODE ?? 'test'} domain=${domain}`);
|
||||
|
||||
const balance = await getResellerBalance();
|
||||
console.log('[smoke-opensrs] reseller balance:', balance);
|
||||
|
||||
const avail = await lookupDomain(domain);
|
||||
console.log('[smoke-opensrs] lookup:', avail);
|
||||
|
||||
const check = await checkDomain(domain, 1);
|
||||
console.log('[smoke-opensrs] check (availability + price):', check);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('[smoke-opensrs] FAILED:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user