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.
120 lines
4.3 KiB
TypeScript
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 };
|
|
}
|