json(Actions\CheckAvailability::run($name)); } /** * GET /v1/names/lookup/charon */ public function lookup(string $name): JsonResponse { $alias = $this->rpc->getAliasByName(strtolower(trim($name))); if (! $alias) { return response()->json(['error' => 'Name not registered'], 404); } $alias['name'] = $name; return (new Resources\NameResource($alias))->response(); } /** * GET /v1/names/search?q=gate */ public function search(Request $request): JsonResponse { $query = strtolower(trim((string) $request->get('q'))); $result = $this->rpc->getAllAliases(); $aliases = $result['aliases'] ?? []; $matches = array_filter($aliases, function ($alias) use ($query) { return str_contains($alias['alias'] ?? '', $query) || str_contains($alias['comment'] ?? '', $query); }); return response()->json([ 'query' => $query, 'results' => array_values($matches), 'count' => count($matches), ]); } /** * GET /v1/names/directory */ public function directory(): JsonResponse { $result = $this->rpc->getAllAliases(); $aliases = $result['aliases'] ?? []; // Group by type from comment metadata $grouped = ['gateway' => [], 'service' => [], 'exit' => [], 'reserved' => [], 'user' => []]; foreach ($aliases as $alias) { $comment = $alias['comment'] ?? ''; if (str_contains($comment, 'type=gateway')) { $grouped['gateway'][] = $alias; } elseif (str_contains($comment, 'type=exit')) { $grouped['exit'][] = $alias; } elseif (str_contains($comment, 'type=service')) { $grouped['service'][] = $alias; } elseif (str_contains($comment, 'type=reserved')) { $grouped['reserved'][] = $alias; } else { $grouped['user'][] = $alias; } } return response()->json([ 'total' => count($aliases), 'directory' => $grouped, ]); } /** * POST /v1/names/register {"name": "mysite", "address": "iTHN..."} */ public function register(Request $request): JsonResponse { $name = strtolower(trim((string) $request->input('name'))); $address = trim((string) $request->input('address')); $comment = trim((string) $request->input('comment', 'v=lthn1;type=user')); if (! $this->isValidName($name)) { return response()->json([ 'error' => 'Invalid name. Use 1-63 lowercase alphanumeric characters.', ], 422); } if (empty($address) || ! str_starts_with($address, 'iTHN')) { return response()->json([ 'error' => 'Invalid Lethean address.', ], 422); } // Pre-flight: check wallet has funds $balance = $this->wallet->getBalance(); $unlocked = ($balance['unlocked_balance'] ?? 0) / 1e12; if ($unlocked < 0.01) { return response()->json([ 'error' => 'Registrar wallet has insufficient funds. Please try again later.', 'name' => $name, ], 503); } // Check availability on chain + reservation lock $existing = $this->rpc->getAliasByName($name); if ($existing !== null) { return response()->json([ 'error' => 'Name already registered.', 'name' => $name, ], 409); } // Atomic reservation — prevent race condition $lockKey = "name_lock:{$name}"; if (! Cache::add($lockKey, true, 600)) { return response()->json([ 'error' => 'This name is being registered by another customer. Please try a different name.', 'name' => $name, ], 409); } // Register via wallet RPC $result = $this->wallet->registerAlias($name, $address, $comment); if (isset($result['code']) || isset($result['error'])) { $message = $result['message'] ?? ($result['error'] ?? 'Unknown error'); $code = 502; if (str_contains($message, 'NOT_ENOUGH_MONEY')) { $message = 'Registrar wallet has insufficient funds. Please try again later.'; $code = 503; } // Release lock on permanent failure so name can be retried Cache::forget($lockKey); return response()->json([ 'error' => $message, 'name' => $name, ], $code); } if (isset($result['tx_id'])) { return response()->json([ 'name' => $name, 'fqdn' => "{$name}.lthn", 'address' => $address, 'tx_id' => $result['tx_id'], 'status' => 'pending', ], 201); } return response()->json([ 'error' => 'Unexpected response from chain', 'details' => $result, ], 500); } /** * GET /v1/names/records/charon * * Reads DNS records from the LNS sidechain. */ public function records(string $name): JsonResponse { $name = strtolower(trim($name)); $lnsUrl = config('chain.lns_url', 'http://127.0.0.1:5553'); $records = []; foreach (['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'SRV'] as $type) { try { $response = Http::timeout(3)->get("{$lnsUrl}/resolve", ['name' => $name, 'type' => $type]); $data = $response->successful() ? $response->json() : null; } catch (\Throwable) { $data = null; } if ($data) { if (! empty($data[$type])) { foreach ($data[$type] as $value) { $records[] = [ 'type' => $type, 'host' => '@', 'value' => $value, 'ttl' => 3600, ]; } } } } return response()->json([ 'name' => $name, 'fqdn' => "{$name}.lthn", 'records' => $records, ]); } /** * POST /v1/names/records/charon {"records": [{"type":"A","host":"@","value":"1.2.3.4"}]} * * Updates DNS records by calling update_alias on the wallet RPC. * Encodes records into the alias comment field for LNS to parse. */ public function updateRecords(Request $request, string $name): JsonResponse { $ticket = Actions\UpdateDnsRecords::run($name, $request->input('records', [])); return response()->json([ 'name' => $ticket->name, 'ticket' => $ticket->ticket_id, 'tx_id' => $ticket->tx_id, 'status' => $ticket->status, ], $ticket->status === 'queued' ? 202 : 200); } /** * GET /v1/names/ticket/{id} * * Check the status of a DNS change ticket. */ public function ticket(string $id): JsonResponse { $ticket = Models\DnsTicket::where('ticket_id', $id)->first(); if (! $ticket) { return response()->json(['error' => 'Ticket not found'], 404); } // If pending, check if the tx has confirmed on chain if ($ticket->status === 'pending' && $ticket->tx_id) { $alias = $this->rpc->getAliasByName($ticket->name); if ($alias && str_contains($alias['comment'] ?? '', 'dns=')) { $ticket->confirm(); Cache::forget("dns_edit_lock:{$ticket->name}"); } } return response()->json([ 'ticket' => $ticket->ticket_id, 'name' => $ticket->name, 'status' => $ticket->status, 'tx_id' => $ticket->tx_id, 'created_at' => $ticket->created_at?->toIso8601String(), ]); } /** * GET /v1/names/health * * Registrar health — wallet balance, chain status, readiness. */ public function health(): JsonResponse { $balance = $this->wallet->getBalance(); $info = $this->rpc->getInfo(); $walletOffline = isset($balance['error']); $daemonOffline = isset($info['_offline']); $daemonStale = isset($info['_stale']); $unlocked = ($balance['unlocked_balance'] ?? 0) / 1e12; $fee = 0.01; $registrationsRemaining = (int) floor($unlocked / $fee); $lowFunds = $registrationsRemaining < 10; $criticalFunds = $registrationsRemaining < 2; $status = 'healthy'; if ($daemonOffline || $walletOffline) { $status = 'offline'; } elseif ($criticalFunds) { $status = 'critical'; } elseif ($lowFunds) { $status = 'low_funds'; } elseif ($daemonStale) { $status = 'degraded'; } $httpCode = match ($status) { 'offline' => 503, 'critical' => 503, 'degraded' => 200, default => 200, }; return response()->json([ 'status' => $status, 'registrar' => [ 'balance' => round($unlocked, 4), 'registrations_remaining' => $registrationsRemaining, 'low_funds' => $lowFunds, 'wallet_online' => ! $walletOffline, ], 'chain' => [ 'height' => $info['height'] ?? 0, 'aliases' => $info['alias_count'] ?? 0, 'pool_size' => $info['tx_pool_size'] ?? 0, 'synced' => ($info['daemon_network_state'] ?? 0) == 2, 'daemon_online' => ! $daemonOffline, 'stale' => $daemonStale, ], ], $httpCode); } /** * GET /v1/names/sunrise/verify/{name} * * Generate a verification token for a sunrise claim. * The brand adds this as a DNS TXT record to prove domain ownership. */ public function sunriseVerify(string $name): JsonResponse { $name = strtolower(trim($name)); // Check name is reserved $alias = $this->rpc->getAliasByName($name); if (! $alias || ! str_contains($alias['comment'] ?? '', 'type=reserved')) { return response()->json(['error' => 'Name is not in the sunrise reservation list'], 404); } // Generate a deterministic verification token $token = 'lthn-verify=' . substr(hash('sha256', $name . config('chain.api_token', 'lthn')), 0, 32); return response()->json([ 'name' => $name, 'fqdn' => "{$name}.lthn", 'verification' => [ 'method' => 'dns-txt', 'instruction' => "Add a TXT record to your domain's DNS to prove ownership", 'record_host' => "_lthn-verify.{$name}.com", 'record_type' => 'TXT', 'record_value' => $token, 'example' => "_lthn-verify.{$name}.com. IN TXT \"{$token}\"", 'check_url' => "/v1/names/sunrise/check/{$name}", ], 'alternative_domains' => [ "{$name}.com", "{$name}.org", "{$name}.net", "{$name}.io", "{$name}.co.uk", ], 'claim_process' => [ 'step_1' => 'Add DNS TXT record to verify domain ownership', 'step_2' => 'Call check endpoint to confirm verification', 'step_3' => 'Purchase the name via https://order.lthn.ai', 'step_4' => 'Name transferred to your wallet with full DNS control', ], 'ownership_tiers' => [ 'free' => 'Registry holds private key. Limited DNS records. No wallet transfer.', 'paid' => 'Your wallet, your key. Expanded DNS record limits. Full sovereignty.', ], 'purchase_url' => 'https://order.lthn.ai/order/', ]); } /** * GET /v1/names/sunrise/check/{name} * * Check if a sunrise verification TXT record has been added. * Looks up _lthn-verify.{name}.com for the expected token. */ public function sunriseCheck(string $name): JsonResponse { $name = strtolower(trim($name)); $expectedToken = 'lthn-verify=' . substr(hash('sha256', $name . config('chain.api_token', 'lthn')), 0, 32); $verified = false; $checkedDomains = []; foreach (['.com', '.org', '.net', '.io', '.co.uk'] as $tld) { $host = "_lthn-verify.{$name}{$tld}"; $records = @dns_get_record($host, DNS_TXT) ?: []; $found = false; foreach ($records as $record) { if (($record['txt'] ?? '') === $expectedToken) { $found = true; $verified = true; break; } } $checkedDomains[] = [ 'domain' => "{$name}{$tld}", 'host' => $host, 'verified' => $found, ]; } return response()->json([ 'name' => $name, 'fqdn' => "{$name}.lthn", 'verified' => $verified, 'expected_token' => $expectedToken, 'checked_domains' => $checkedDomains, 'status' => $verified ? 'verified' : 'pending', 'next_steps' => $verified ? 'Domain ownership verified. Complete your claim by purchasing at https://order.lthn.ai — your name will be transferred to your wallet with full DNS control.' : 'TXT record not found. Add the record and allow DNS propagation (up to 48h).', 'purchase_url' => $verified ? 'https://order.lthn.ai/order/' : null, ]); } /** * POST /v1/names/claim {"name": "mysite", "email": "user@example.com"} * * Pre-register a name claim. Queued for manual approval during soft launch. */ public function claim(Request $request): JsonResponse { $claim = Actions\SubmitClaim::run($request->only(['name', 'email'])); return (new Resources\ClaimResource($claim)) ->additional(['message' => "Your claim has been submitted. We will notify you at {$claim->email} when approved."]) ->response() ->setStatusCode(201); } /** * GET /v1/names/claims * * List all pending claims (admin only). */ public function listClaims(): JsonResponse { $claims = NameClaim::orderByDesc('created_at')->get(); return response()->json([ 'claims' => $claims, 'total' => $claims->count(), 'pending' => NameClaim::pending()->count(), ]); } /** * Matches daemon's validate_alias_name: a-z, 0-9, dash, dot. Max 255 chars. * We additionally require at least 1 char (daemon allows empty but we don't). */ /** * POST /v1/names/claims/{id}/approve */ public function approveClaim(string $id): JsonResponse { $claim = NameClaim::where('claim_id', $id)->first(); if (! $claim) { return response()->json(['error' => 'Claim not found'], 404); } if ($claim->status !== 'pending') { return response()->json(['error' => "Claim already {$claim->status}"], 409); } $claim->approve(); Models\NameActivity::log($claim->name, 'claim_approved', [ 'claim_id' => $claim->claim_id, 'email' => $claim->email, ]); return response()->json([ 'claim_id' => $claim->claim_id, 'name' => $claim->name, 'status' => 'approved', ]); } /** * POST /v1/names/claims/{id}/reject */ public function rejectClaim(string $id): JsonResponse { $claim = NameClaim::where('claim_id', $id)->first(); if (! $claim) { return response()->json(['error' => 'Claim not found'], 404); } if ($claim->status !== 'pending') { return response()->json(['error' => "Claim already {$claim->status}"], 409); } $claim->reject(); Models\NameActivity::log($claim->name, 'claim_rejected', [ 'claim_id' => $claim->claim_id, ]); return response()->json([ 'claim_id' => $claim->claim_id, 'name' => $claim->name, 'status' => 'rejected', ]); } private function isValidName(string $name): bool { return (bool) preg_match('/^[a-z0-9][a-z0-9.\-]{0,254}$/', $name); } }