SkateSpots/src/lib/profileHandles.ts
SkateSpots 127819e3da feat(nip05): multiple handles for profiles, crews and shops
A NIP-05 name maps to one key, so "who owns it" decides where it lives:
- Crew: repeatable nip05 tags (up to 5), verified against the founder key.
- Shop: same on kind 30017, verified against the owner key (new field).
- Profile: kind-0 has ONE standard nip05; extra personal handles live in a
  separate NIP-78 record (kind 30078, d=skatespots:profile-handles) so another
  client re-saving the profile can't wipe them.

Shared pieces:
- components/Nip05Badges.tsx — generic verify-and-render chip row (any owner).
- components/Nip05HandlesEditor.tsx — shared add/remove editor with live
  verified/mismatch status; used by profile, crew and shop forms.
- CrewNip05Badge now delegates to Nip05Badges; CrewForm uses the shared editor.
- Profile page shows primary + extra handles, and each crew/shop chips its own
  verified handle.
- lib/profileHandles.ts + hooks/useProfileHandles.ts (read/publish the record).
- types: SkateShop gains nip05/nip05s; parseSkateShop parses repeatable nip05s.
- NIP.md + AGENTS.md + i18n (EN/ES) updated.
2026-10-03 17:26:59 +07:00

51 lines
2.1 KiB
TypeScript

/**
* Extra personal NIP-05 handles for a profile.
*
* A kind-0 profile has ONE standard `nip05` field, and other clients editing it
* may drop anything non-standard. So the extra handles live in a **separate
* NIP-78 record** (kind 30078, `d=skatespots:profile-handles`) authored by the
* user — the primary stays in `nip05` (works everywhere), the extras ride here
* (SkateSpots-only, but durable against another client re-saving the profile).
*
* Each handle must resolve (via `/.well-known/nostr.json`) to the **owner's own**
* pubkey — verified the same way as a crew handle, just against the profile key.
*/
import { APP_DATA_KIND } from '@/lib/types';
export { APP_DATA_KIND };
/** `d` identifier for the per-user extra-handles record. */
export const PROFILE_HANDLES_D = 'skatespots:profile-handles';
export const MAX_PROFILE_HANDLES = 5;
/** `local@domain.tld` per NIP-05 (local part is `[a-z0-9-_.]`). */
export const NIP05_HANDLE_RE = /^[a-z0-9\-_.]+@[a-z0-9.-]+\.[a-z]{2,}$/;
/** Normalise a raw handle; returns null when it isn't a valid `local@domain`. */
export function normalizeHandle(raw: string): string | null {
const value = raw.trim().toLowerCase();
return NIP05_HANDLE_RE.test(value) ? value : null;
}
/** All extra handles stored on a profile-handles event (deduped, ordered, capped). */
export function parseProfileHandles(event: { tags: string[][] }): string[] {
const handles = event.tags
.filter(([name, value]) => name === 'nip05' && !!value?.trim())
.map(([, value]) => value.trim().toLowerCase())
.filter((value) => NIP05_HANDLE_RE.test(value));
return [...new Set(handles)].slice(0, MAX_PROFILE_HANDLES);
}
/** Tags for the addressable record. An empty list clears the extras (replace). */
export function profileHandlesTags(handles: string[]): string[][] {
const clean = [
...new Set(handles.map((h) => h.trim().toLowerCase()).filter((h) => NIP05_HANDLE_RE.test(h))),
].slice(0, MAX_PROFILE_HANDLES);
const tags: string[][] = [
['d', PROFILE_HANDLES_D],
['alt', 'SkateSpots profile handles'],
];
for (const handle of clean) tags.push(['nip05', handle]);
return tags;
}