SkateSpots/src/hooks/useSkateNip05.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

120 lines
4.3 KiB
TypeScript

import { useCallback } from 'react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import {
SKATE_NIP05_DOMAIN,
buildSkateNip05,
normalizeSkateNip05Name,
skateApiBase,
} from '@/lib/skateNip05';
export interface SkateNip05Availability {
available: boolean;
reason: string | null;
}
export type SkateNip05ClaimResult =
| { ok: true; nip05: string }
| { ok: false; error: string };
/** UTF-8 safe base64 (the NIP-98 event travels as base64 JSON in a header). */
function b64(value: string): string {
const bytes = new TextEncoder().encode(value);
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
/**
* Claim a free `name@skate-spots.org` handle.
*
* The claim is proven with a NIP-98 (kind 27235) event signed by the account
* that will own the name, so only the owner can claim a name for themselves.
* Nothing here blocks the app: callers treat a failure as "no handle yet".
*/
export function useSkateNip05() {
const { user } = useCurrentUser();
const checkAvailability = useCallback(
async (name: string, signal?: AbortSignal): Promise<SkateNip05Availability> => {
const clean = normalizeSkateNip05Name(name);
if (!clean) return { available: false, reason: null };
try {
const res = await fetch(
`${skateApiBase()}/api/nip05/check?name=${encodeURIComponent(clean)}`,
{ signal },
);
if (!res.ok) return { available: false, reason: 'Could not check right now.' };
const data = (await res.json()) as { available?: boolean; reason?: string | null };
return { available: !!data.available, reason: data.reason ?? null };
} catch {
return { available: false, reason: 'Could not check right now.' };
}
},
[],
);
const claim = useCallback(
async (name: string): Promise<SkateNip05ClaimResult> => {
const clean = normalizeSkateNip05Name(name);
if (!user) return { ok: false, error: 'Log in first.' };
if (!clean) return { ok: false, error: 'Enter a name.' };
try {
const auth = await user.signer.signEvent({
kind: 27235,
content: '',
created_at: Math.floor(Date.now() / 1000),
tags: [
['u', `https://${SKATE_NIP05_DOMAIN}/api/nip05/claim`],
['method', 'POST'],
['action', 'claim'],
['name', clean],
],
});
const res = await fetch(`${skateApiBase()}/api/nip05/claim`, {
method: 'POST',
headers: { 'x-nip05-auth': b64(JSON.stringify(auth)) },
});
const data = (await res.json().catch(() => ({}))) as { ok?: boolean; nip05?: string; error?: string };
if (res.ok && data.ok) return { ok: true, nip05: data.nip05 ?? buildSkateNip05(clean) };
return { ok: false, error: data.error || 'Could not claim that name.' };
} catch {
return { ok: false, error: 'Could not reach the name service.' };
}
},
[user],
);
/** Hand a name back (owner, or a platform admin). Rarely used from the UI. */
const release = useCallback(
async (name: string): Promise<{ ok: true } | { ok: false; error: string }> => {
const clean = normalizeSkateNip05Name(name);
if (!user) return { ok: false, error: 'Log in first.' };
try {
const auth = await user.signer.signEvent({
kind: 27235,
content: '',
created_at: Math.floor(Date.now() / 1000),
tags: [
['u', `https://${SKATE_NIP05_DOMAIN}/api/nip05/release`],
['method', 'POST'],
['action', 'release'],
['name', clean],
],
});
const res = await fetch(`${skateApiBase()}/api/nip05/release`, {
method: 'POST',
headers: { 'x-nip05-auth': b64(JSON.stringify(auth)) },
});
const data = (await res.json().catch(() => ({}))) as { ok?: boolean; error?: string };
if (res.ok && data.ok) return { ok: true };
return { ok: false, error: data.error || 'Could not release that name.' };
} catch {
return { ok: false, error: 'Could not reach the name service.' };
}
},
[user],
);
return { domain: SKATE_NIP05_DOMAIN, checkAvailability, claim, release, canClaim: !!user };
}