// SkateSpots NIP-05 names — `you@skate-spots.org`. // // This is a *convenience* namespace we hand out for free so a new skater has a // human handle from the first minute. It is deliberately NOT a gate: NIP-05 is a // public label (a domain maps a name → pubkey), never an account, and the app // works fine without one. Users are always free to bring a handle from any other // domain instead. // // The registry itself lives server-side (netlify/functions/nip05.mjs); this // module holds the shared, client-side rules so the form can validate before it // ever talks to the network. import { isNativePlatform } from '@/lib/platform'; /** The domain SkateSpots issues handles under. */ export const SKATE_NIP05_DOMAIN = 'skate-spots.org'; /** Names the registry refuses (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', 'skate-spots', 'skatespots', ]); // Keep in sync with NAME_RE in netlify/functions/nip05.mjs. const NAME_RE = /^[a-z0-9][a-z0-9._-]{1,28}[a-z0-9]$/; export interface SkateNip05Validation { ok: boolean; /** Normalized (lowercased, trimmed, leading @ stripped) name. */ name: string; reason?: string; } /** Lowercase, trim, and drop a leading `@` so `@Tony` and `tony` agree. */ export function normalizeSkateNip05Name(raw: string): string { return (raw ?? '').trim().toLowerCase().replace(/^@/, ''); } /** Validate a local part (the bit before `@skate-spots.org`). */ export function validateSkateNip05Name(raw: string): SkateNip05Validation { const name = normalizeSkateNip05Name(raw); if (!name) return { ok: false, name, reason: 'Enter a name.' }; if (name.length < 3) return { ok: false, name, reason: 'Use at least 3 characters.' }; if (name.length > 30) return { ok: false, name, reason: 'Use at most 30 characters.' }; if (!NAME_RE.test(name)) { return { ok: false, name, reason: 'Letters, numbers, dots, dashes and underscores only — start and end with a letter or number.', }; } if (RESERVED.has(name)) return { ok: false, name, reason: 'That name is reserved.' }; return { ok: true, name }; } /** Suggest a handle from a display name: "Tony Hawk!" → "tony-hawk". */ export function deriveSkateNip05Handle(displayName: string): string { return (displayName ?? '') .trim() .toLowerCase() .normalize('NFKD') .replace(/[\u0300-\u036f]/g, '') .replace(/[^a-z0-9._-]+/g, '-') .replace(/^[-._]+|[-._]+$/g, '') .slice(0, 30); } /** `tony` → `tony@skate-spots.org`. */ export function buildSkateNip05(name: string): string { return `${normalizeSkateNip05Name(name)}@${SKATE_NIP05_DOMAIN}`; } /** If `nip05` is on our domain, return the handle; otherwise null. */ export function parseSkateNip05(nip05: string | undefined | null): string | null { const value = (nip05 ?? '').trim().toLowerCase(); const suffix = `@${SKATE_NIP05_DOMAIN}`; return value.endsWith(suffix) ? value.slice(0, -suffix.length) : null; } /** * Absolute base for `/api` calls. Relative paths break inside the native shell * (it is served from `https://localhost`), so native must hit the real origin. */ export function skateApiBase(): string { return isNativePlatform() ? 'https://skate-spots.org' : ''; }