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.
88 lines
3.4 KiB
TypeScript
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' : '';
|
|
}
|