253 lines
9.8 KiB
JavaScript
253 lines
9.8 KiB
JavaScript
// 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=<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);
|
||
}
|