SkateSpots/src/lib/skateNip05.ts
SkateSpots c79373ed05 feat(offline): the map survives a dead relay (and says so) + skater@skate-spots.org
Relay outages no longer look like 'no spots'. querySkateContent swallowed every
relay error and returned [], so a dead relay would blank the map AND overwrite
good cached data with an empty list (then persist it). Now:

- querySkateContent/queryCanonical take an opt-in 'requireAny' that throws when
  NO relay answered. Only the map's own feeds use it, so the blast radius is
  small (79 other call sites keep today's behaviour).
- useSkateSpots/useSkateSpot fall back to the local IndexedDB cache on failure,
  so the map still works at the park; they only error when nothing is saved.
- The persister refuses to persist an empty array, so an outage can't wipe the
  last-known map on the next cold start.
- MapPage shows an honest 'Offline · showing saved spots' banner (reusing the
  existing relayProbe/useRelayStatus) and a 'Can't reach the relays' card
  instead of falsely claiming 'No spots mapped yet'.

Also seeds skater@skate-spots.org for the second admin (caba497f…) and reserves
the name so nobody else can claim it.
2026-09-28 12:48:54 +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', 'skater',
]);
// 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' : '';
}