lthn.io/app/Mod/Names/Controllers/NamesController.php
Claude 1ef6ed1c7b
refactor: move DNS tickets from Cache to DnsTicket database model
DNS change tickets now persisted in MariaDB via DnsTicket model.
Survives cache clears and container rebuilds. Model has open(),
confirm(), fail() methods and pending/queued scopes. Controller
updateRecords and ticket endpoints refactored. RetryDnsTickets
command queries model instead of cache.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 12:06:00 +01:00

532 lines
18 KiB
PHP

<?php
declare(strict_types=1);
namespace Mod\Names\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Mod\Chain\Services\DaemonRpc;
use Mod\Chain\Services\WalletRpc;
use Mod\Names\Actions;
use Mod\Names\Models;
use Mod\Names\Models\NameClaim;
use Mod\Names\Resources;
/**
* .lthn TLD registrar API.
*
* GET /v1/names/available/{name} — check if name is available
* GET /v1/names/lookup/{name} — look up a registered name
* GET /v1/names/search?q={query} — search names
* POST /v1/names/register — request name registration
* GET /v1/names/directory — list all names grouped by type
*/
class NamesController extends Controller
{
public function __construct(
private readonly DaemonRpc $rpc,
private readonly WalletRpc $wallet,
) {}
/**
* GET /v1/names/available/myname
*/
public function available(string $name): JsonResponse
{
return response()->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
{
$name = strtolower(trim($name));
$records = $request->input('records', []);
// Per-name lock prevents concurrent edits
$editLock = "dns_edit_lock:{$name}";
if (Cache::has($editLock)) {
return response()->json([
'error' => 'A DNS update for this name is already pending.',
'name' => $name,
], 409);
}
$alias = $this->rpc->getAliasByName($name);
if (! $alias) {
return response()->json(['error' => 'Name not registered'], 404);
}
$address = $alias['address'] ?? '';
// Build dns= comment
$dnsEntries = [];
foreach ($records as $record) {
$type = $record['type'] ?? 'A';
$host = $record['host'] ?? '@';
$value = $record['value'] ?? '';
if ($value) {
$dnsEntries[] = "{$type}:{$host}:{$value}";
}
}
$comment = 'v=lthn1;type=user';
if ($dnsEntries) {
$comment .= ';dns=' . implode('|', $dnsEntries);
}
$result = $this->wallet->updateAlias($name, $address, $comment);
if (isset($result['tx_id'])) {
Cache::put($editLock, true, 300);
$ticket = Models\DnsTicket::open($name, $records, $result['tx_id'], $address, $comment);
return response()->json([
'name' => $name,
'ticket' => $ticket->ticket_id,
'tx_id' => $result['tx_id'],
'status' => 'pending',
]);
}
// Chain busy — queue for retry
$ticket = Models\DnsTicket::open($name, $records, null, $address, $comment);
return response()->json([
'name' => $name,
'ticket' => $ticket->ticket_id,
'status' => 'queued',
'message' => 'Queued for next block.',
], 202);
}
/**
* 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).
*/
private function isValidName(string $name): bool
{
return (bool) preg_match('/^[a-z0-9][a-z0-9.\-]{0,254}$/', $name);
}
}