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) <noreply@anthropic.com>
This commit is contained in:
Claude 2026-04-04 08:30:23 +01:00
parent 9b4b6d5264
commit 1f31444171
No known key found for this signature in database
GPG key ID: AF404715446AEB41
3 changed files with 110 additions and 8 deletions

View file

@ -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).

View file

@ -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');

View file

@ -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. ');