SkateSpots/netlify/functions/nip05.mjs

253 lines
9.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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);
}