// Netlify Function: the SkateSpots NIP-05 name registry. // // NIP-05 maps `name@skate-spots.org` → a Nostr pubkey by serving JSON at // https://skate-spots.org/.well-known/nostr.json?name=. This function is // that endpoint, plus the tiny API the app uses to check availability and // claim/hand over a name. // // Deliberate design rules: // • NON-GATING. A handle is a badge, never a key to the door. If this function // is unreachable the app still works and every user keeps their npub — the // client only ever adds `nip05` to a profile after a claim has succeeded. // • PROOF BY SIGNATURE. A claim must be signed by the pubkey that will own the // name (a NIP-98 kind-27235 event in the `x-nip05-auth` header), so nobody // can claim a name for someone else. No accounts, no passwords, no PII. // • SMALL BLAST RADIUS. Storage is Netlify Blobs (first-party — no new vendor, // no new secret), one key per name, so two simultaneous claims can't clobber // each other. Platform names are seeded in code, not storage. import { getStore } from '@netlify/blobs'; import { verifyEvent } from 'nostr-tools'; const DOMAIN = 'skate-spots.org'; const STORE_NAME = 'nip05'; const keyFor = (name) => `n:${name}`; // Owner pubkeys (hex). Keep in sync with src/lib/admins.ts and ai.mjs. const PLATFORM_PUBKEY = '20e7c953439e66f3882ab96fe0faa8ba246d6fb12c03b0cb4e2dc4623b8bcb21'; const OWNERS = new Set([ PLATFORM_PUBKEY, 'caba497f7beda0650d6b78ab211216e4d2ebabeec9ca6eb2a05cee8e55c6871b', ]); // Names that answer even with no stored entry — the platform's own identities. // Claimable by nobody; released by nobody. Keep in sync with src/lib/admins.ts. const SEED = { 'skate-spots': PLATFORM_PUBKEY, 'skatespots': PLATFORM_PUBKEY, // Second admin — npub1e2ayjlmmaksx2rtt0z4jzyskunfwh2lwe89xav4qtnhgu4wxsudsp98a4l 'skater': 'caba497f7beda0650d6b78ab211216e4d2ebabeec9ca6eb2a05cee8e55c6871b', }; // Never claimable by users (impersonation / infrastructure words). const RESERVED = new Set([ 'admin', 'administrator', 'abuse', 'api', 'bot', 'help', 'mail', 'mod', 'moderator', 'official', 'postmaster', 'root', 'security', 'staff', 'support', 'system', 'test', 'undefined', 'null', 'webmaster', 'www', ]); // 3–30 chars, starts and ends alphanumeric, dots/dashes/underscores inside. const NAME_RE = /^[a-z0-9][a-z0-9._-]{1,28}[a-z0-9]$/; function normalizeName(raw) { return String(raw ?? '').trim().toLowerCase().replace(/^@/, ''); } function validateName(name) { if (!name) return 'Enter a name.'; if (name.length < 3) return 'Use at least 3 characters.'; if (name.length > 30) return 'Use at most 30 characters.'; if (!NAME_RE.test(name)) { return 'Letters, numbers, dots, dashes and underscores only — start and end with a letter or number.'; } if (RESERVED.has(name) || SEED[name]) return 'That name is reserved.'; return null; } function store() { return getStore({ name: STORE_NAME, consistency: 'strong' }); } // ── HTTP helpers ────────────────────────────────────────────────────────────── const CORS = { // NIP-05 lookups come from every Nostr client on the web, so this endpoint is // open. Auth is by signature, never by cookie, so `*` is safe. 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, x-nip05-auth', }; function json(body, status = 200) { return new Response(JSON.stringify(body), { status, headers: { ...CORS, 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, }); } function decodeB64Json(value) { try { return JSON.parse(Buffer.from(value, 'base64').toString('utf8')); } catch { return null; } } function tagVal(tags, name) { return tags?.find((t) => t[0] === name)?.[1]; } /** Verify a NIP-98 auth header for `action` → { pubkey, name } | null. */ function verifyAuth(header, action) { const ev = decodeB64Json(header); if (!ev || ev.kind !== 27235) return null; // Bind the proof to THIS action and endpoint — a missing tag is rejected, so a // signature for one action/URL can't be replayed for another. if (tagVal(ev.tags, 'method') !== 'POST') return null; if (tagVal(ev.tags, 'action') !== action) return null; const u = tagVal(ev.tags, 'u'); if (!u) return null; try { const url = new URL(u); if (url.hostname !== DOMAIN) return null; if (!url.pathname.endsWith(`/api/nip05/${action}`)) return null; } catch { return null; } // A generous window lets one signature work for a while; the mapping is // idempotent, so replay is harmless. if (Math.abs(Date.now() / 1000 - ev.created_at) > 12 * 3600) return null; try { if (!verifyEvent(ev)) return null; } catch { return null; } return { pubkey: ev.pubkey, name: normalizeName(tagVal(ev.tags, 'name')) }; } // Best-effort in-memory throttle for the write endpoints. Serverless instances // are ephemeral, so this is a blunt abuse damper (mass name-squatting), not a // hard guarantee — the real gate is the signature on every claim. const WINDOW_MS = 60_000; const LIMIT = 12; const hits = new Map(); function throttled(request) { const ip = request.headers.get('x-nf-client-connection-ip') || request.headers.get('x-forwarded-for') || 'unknown'; const now = Date.now(); const recent = (hits.get(ip) ?? []).filter((t) => now - t < WINDOW_MS); if (recent.length >= LIMIT) return true; recent.push(now); hits.set(ip, recent); return false; } // ── Handlers ────────────────────────────────────────────────────────────────── /** GET /.well-known/nostr.json[?name=x] — the NIP-05 document itself. */ async function handleWellKnown(url) { const name = normalizeName(url.searchParams.get('name')); const names = {}; if (name) { if (SEED[name]) { names[name] = SEED[name]; } else { try { const pubkey = await store().get(keyFor(name)); if (pubkey) names[name] = pubkey; } catch { // Storage unavailable → still answer the platform's own names. } } } else { // No name given: return only the platform names rather than enumerating the // whole registry (cheap, and doesn't hand out a scrapeable list). Object.assign(names, SEED); } return json({ names }); } /** GET /api/nip05/check?name=x — availability for live form feedback. */ async function handleCheck(url) { const name = normalizeName(url.searchParams.get('name')); const reason = validateName(name); if (reason) return json({ name, available: false, reason }); try { const existing = await store().get(keyFor(name)); if (existing) return json({ name, available: false, reason: 'That name is taken.' }); } catch { return json({ name, available: false, reason: 'The name service is unavailable right now.' }); } return json({ name, available: true, reason: null }); } /** POST /api/nip05/claim — reserve `name` for the signing pubkey. */ async function handleClaim(request) { if (throttled(request)) return json({ error: 'Too many requests.' }, 429); const auth = verifyAuth(request.headers.get('x-nip05-auth'), 'claim'); if (!auth) return json({ error: 'Signature required.' }, 401); const { name, pubkey } = auth; const reason = validateName(name); if (reason) return json({ error: reason, code: 'invalid' }, 400); try { const s = store(); const existing = await s.get(keyFor(name)); if (existing && existing !== pubkey) return json({ error: 'That name is taken.', code: 'taken' }, 409); if (!existing) { try { await s.set(keyFor(name), pubkey, { onlyIfNew: true }); } catch { // Lost a race — re-read to see who won. Re-claiming your own name is fine. const now = await s.get(keyFor(name)); if (now && now !== pubkey) return json({ error: 'That name is taken.', code: 'taken' }, 409); } } } catch { return json({ error: 'The name service is unavailable right now.' }, 503); } return json({ ok: true, name, nip05: `${name}@${DOMAIN}` }); } /** POST /api/nip05/release — remove a name (its owner, or a platform owner). */ async function handleRelease(request) { if (throttled(request)) return json({ error: 'Too many requests.' }, 429); const auth = verifyAuth(request.headers.get('x-nip05-auth'), 'release'); if (!auth) return json({ error: 'Signature required.' }, 401); const { name, pubkey } = auth; if (!name) return json({ error: 'Missing name.' }, 400); if (SEED[name]) return json({ error: 'Platform names cannot be released.' }, 400); try { const s = store(); const existing = await s.get(keyFor(name)); if (!existing) return json({ ok: true }); if (existing !== pubkey && !OWNERS.has(pubkey)) return json({ error: 'That name is not yours.' }, 403); await s.delete(keyFor(name)); } catch { return json({ error: 'The name service is unavailable right now.' }, 503); } return json({ ok: true }); } export const config = { path: [ '/.well-known/nostr.json', '/api/nip05/check', '/api/nip05/claim', '/api/nip05/release', ], }; export default async function handler(request) { const url = new URL(request.url); const path = url.pathname; if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: CORS }); if (path === '/.well-known/nostr.json') return handleWellKnown(url); if (path === '/api/nip05/check') return handleCheck(url); if (path === '/api/nip05/claim' && request.method === 'POST') return handleClaim(request); if (path === '/api/nip05/release' && request.method === 'POST') return handleRelease(request); return json({ error: 'Not found' }, 404); }