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