From 1f31444171aceb6ba19bb57a950e5c98a423057d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Apr 2026 08:30:23 +0100 Subject: [PATCH] feat(names): pre-registration claim system for soft launch Adds claim/listClaims endpoints so visitors can reserve .lthn names before chain registration is fully automated. Claims are stored with email for notification when approved. Admin endpoint lists all claims. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/Mod/Names/Controllers/NamesController.php | 72 +++++++++++++++++++ app/Mod/Names/Routes/api.php | 4 ++ app/Mod/Names/Views/register.blade.php | 42 ++++++++--- 3 files changed, 110 insertions(+), 8 deletions(-) diff --git a/app/Mod/Names/Controllers/NamesController.php b/app/Mod/Names/Controllers/NamesController.php index 8cf7fdf..b933154 100644 --- a/app/Mod/Names/Controllers/NamesController.php +++ b/app/Mod/Names/Controllers/NamesController.php @@ -535,6 +535,78 @@ class NamesController extends Controller ]); } + /** + * 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 + { + $name = strtolower(trim((string) $request->input('name'))); + $email = trim((string) $request->input('email')); + + if (! $this->isValidName($name)) { + return response()->json(['error' => 'Invalid name. Use 6+ lowercase alphanumeric characters.'], 422); + } + + if (strlen($name) < 6) { + return response()->json(['error' => 'Name must be at least 6 characters.'], 422); + } + + if (empty($email) || ! filter_var($email, FILTER_VALIDATE_EMAIL)) { + return response()->json(['error' => 'Valid email required for claim notification.'], 422); + } + + // Check not already registered + $alias = $this->rpc->getAliasByName($name); + if ($alias !== null) { + return response()->json(['error' => 'Name already registered.', 'name' => $name], 409); + } + + // Check not already claimed + $claims = Cache::get('name_claims', []); + foreach ($claims as $claim) { + if ($claim['name'] === $name) { + return response()->json(['error' => 'Name already claimed. Awaiting approval.', 'name' => $name], 409); + } + } + + // Store claim + $claimId = bin2hex(random_bytes(6)); + $claims[] = [ + 'id' => $claimId, + 'name' => $name, + 'email' => $email, + 'status' => 'pending', + 'created_at' => now()->toIso8601String(), + ]; + Cache::put('name_claims', $claims, 86400 * 30); // 30 day retention + + return response()->json([ + 'claim_id' => $claimId, + 'name' => $name, + 'fqdn' => "{$name}.lthn", + 'status' => 'pending', + 'message' => 'Your claim has been submitted. We will notify you at ' . $email . ' when approved.', + ], 201); + } + + /** + * GET /v1/names/claims + * + * List all pending claims (admin only). + */ + public function listClaims(): JsonResponse + { + $claims = Cache::get('name_claims', []); + + return response()->json([ + 'claims' => $claims, + 'total' => count($claims), + 'pending' => count(array_filter($claims, fn ($c) => $c['status'] === 'pending')), + ]); + } + /** * 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). diff --git a/app/Mod/Names/Routes/api.php b/app/Mod/Names/Routes/api.php index b188fff..cf2c262 100644 --- a/app/Mod/Names/Routes/api.php +++ b/app/Mod/Names/Routes/api.php @@ -18,3 +18,7 @@ Route::get('/health', [NamesController::class, 'health']); // Sunrise domain verification Route::get('/sunrise/verify/{name}', [NamesController::class, 'sunriseVerify']); Route::get('/sunrise/check/{name}', [NamesController::class, 'sunriseCheck']); + +// Pre-registration claims (soft launch) +Route::post('/claim', [NamesController::class, 'claim'])->middleware('throttle:10,1'); +Route::get('/claims', [NamesController::class, 'listClaims'])->middleware('auth.api'); diff --git a/app/Mod/Names/Views/register.blade.php b/app/Mod/Names/Views/register.blade.php index 83db6b9..b9cf839 100644 --- a/app/Mod/Names/Views/register.blade.php +++ b/app/Mod/Names/Views/register.blade.php @@ -76,14 +76,40 @@ if (data.available && !data.reserved) { var wrap = el('div', {}); wrap.appendChild(el('p', {color: '#34d399', fontWeight: '600', fontSize: '1.1rem', marginBottom: '0.75rem'}, name + '.lthn is available!')); - var link = el('a', { - display: 'inline-block', padding: '0.75rem 2rem', - background: '#34d399', color: '#fff', borderRadius: '0.5rem', - textDecoration: 'none', fontWeight: '600', fontSize: '1rem' - }, 'Register ' + name + '.lthn now'); - link.href = 'https://order.lthn.ai/order/main/packages/domains/?group_id=1'; - link.target = '_blank'; - wrap.appendChild(link); + + var emailInput = el('input', { + padding: '0.5rem 1rem', background: '#0a0e17', border: '1px solid #1f2937', + borderRadius: '0.5rem', color: '#e5e7eb', fontSize: '0.9rem', width: '100%', marginBottom: '0.5rem' + }); + emailInput.type = 'email'; + emailInput.placeholder = 'Your email address'; + emailInput.id = 'claim-email'; + wrap.appendChild(emailInput); + + var claimBtn = el('button', { + padding: '0.75rem 2rem', background: '#34d399', color: '#fff', + borderRadius: '0.5rem', border: 'none', fontWeight: '600', fontSize: '1rem', cursor: 'pointer', width: '100%' + }, 'Claim ' + name + '.lthn'); + claimBtn.addEventListener('click', function() { + var email = document.getElementById('claim-email').value; + if (!email) { return; } + claimBtn.disabled = true; + claimBtn.textContent = 'Submitting...'; + fetch('/v1/names/claim', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'Accept': 'application/json'}, + body: JSON.stringify({name: name, email: email}) + }).then(function(r) { return r.json(); }).then(function(d) { + result.textContent = ''; + if (d.claim_id) { + result.appendChild(el('p', {color: '#34d399', fontWeight: '600', fontSize: '1rem'}, 'Claim submitted!')); + result.appendChild(el('p', {color: '#9ca3af', fontSize: '0.85rem'}, 'Claim ID: ' + d.claim_id + '. We will email ' + email + ' when approved.')); + } else { + result.appendChild(el('p', {color: '#fbbf24', fontSize: '0.9rem'}, d.error || 'Something went wrong.')); + } + }); + }); + wrap.appendChild(claimBtn); result.appendChild(wrap); } else if (data.reserved) { var p = el('p', {color: '#fbbf24', fontSize: '0.9rem'}, name + '.lthn is reserved during the sunrise period. ');