SkateSpots/netlify/functions/nip05.mjs
SkateSpots 3c7c8bd87e feat(nip05): free name@skate-spots.org handles, claimed at signup
SkateSpots now runs its own NIP-05 registry, so a new skater gets a human
handle from minute one instead of an npub.

- netlify/functions/nip05.mjs: /.well-known/nostr.json plus
  /api/nip05/{check,claim,release}, backed by Netlify Blobs (first-party,
  no new vendor). Claims are NIP-98 signed and written onlyIfNew, so only
  the owner can claim a name for themselves. Platform names are seeded in
  code and cannot be taken or released.
- src/lib/skateNip05.ts + src/hooks/useSkateNip05.ts +
  src/components/SkateNip05Input.tsx: shared validation, debounced
  availability, and the claim control.
- SignupDialog: a 'SkateSpots name' field, prefilled from the display
  name. Claimed best-effort before the profile publish; a failure never
  blocks signup, and the profile only advertises nip05 once confirmed.
- EditProfileForm: claim a free handle from the NIP-05 field.

Verified on a draft deploy: claim -> resolves -> a second key gets 409 ->
release -> gone. tsc/eslint/101 tests green.
2026-09-28 12:13:34 +07:00

220 lines
8.5 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 identity.
// Claimable by nobody; released by nobody.
const SEED = {
'skate-spots': PLATFORM_PUBKEY,
'skatespots': PLATFORM_PUBKEY,
};
// 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;
const method = tagVal(ev.tags, 'method');
if (method && method !== 'POST') return null;
const act = tagVal(ev.tags, 'action');
if (act && act !== action) 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')) };
}
// ── 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) {
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) {
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);
}