SkateSpots/src/lib/skateNip05.ts
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

88 lines
3.4 KiB
TypeScript

// 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' : '';
}