diff --git a/AGENTS.md b/AGENTS.md index c68771d..2bb45d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -490,6 +490,31 @@ session with **no other memory of this project**. So: `public/images/screenshots/{map,spot,marketplace}.png` (1280×800, quantised — captured with headless Chromium; regenerate with a CDP harness if the UI changes materially). +- **NIP-05 names — `you@skate-spots.org` (2026-09-28):** SkateSpots hands out + free handles so a new skater has a human name from minute one. The registry is + the Netlify Function **`netlify/functions/nip05.mjs`** + (`/.well-known/nostr.json` + `/api/nip05/{check,claim,release}`), backed by + **Netlify Blobs** (`@netlify/blobs`, store `nip05`, one key per name) — no new + vendor, no new secret. Rules: **non-gating** (a handle is a badge, never a key + — the app works without one), **proof by signature** (a claim carries a NIP-98 + kind-27235 event in the `x-nip05-auth` header, signed by the pubkey that will + own the name), and `onlyIfNew` writes so two claims can't clobber each other. + Platform names are seeded in code (`skate-spots`, `skatespots` → owner pubkey) + and cannot be claimed/released by users; a small `RESERVED` list blocks + impersonation words. Client: `src/lib/skateNip05.ts` (shared validation, + `deriveSkateNip05Handle`, `parseSkateNip05`, `skateApiBase()` for the native + absolute-URL rule), `src/hooks/useSkateNip05.ts` (check/claim/release), and + `src/components/SkateNip05Input.tsx` (debounced availability + Claim). + Wired into **signup** (`SignupDialog` profile step adds a "SkateSpots name" + field, prefilled from the display name until edited; the handle is claimed + best-effort *before* the kind-0 publish, and **a failure never blocks signup** — + a profile only advertises `nip05` once the registry confirms it) and into + **Edit Profile** (`Nip05Field` gains a "claim a free handle" block). + `Settings → NIP-05` links to it. ⚠️ `config.path` DOES take precedence over the + `/*` SPA redirect for `/.well-known/*` (verified on a draft deploy). Test + functions with a free draft (`netlify deploy --dir=dist`), not a prod deploy. + Verified end-to-end on a draft (2026-09-28): claim → resolves → a second key + gets 409 → release → gone. - **Mints directory**: `/mints` page lists Cashu mints from the community **MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* → @@ -509,7 +534,7 @@ session with **no other memory of this project**. So: instead of showing the error boundary. - **Baselines** (verified 2026-09-27): `npm test` → **0 errors, 0 warnings**, **101 tests**, build ok. The gate also type-checks the CVM server - (`tsc -p cvm-server/tsconfig.json`). `package.json` version **1.4.5**. + (`tsc -p cvm-server/tsconfig.json`). `package.json` version **1.5.4**. - ✅ Lint is **clean**: `no-explicit-any` = 0 · `react-hooks/exhaustive-deps` = 0 · `no-unused-vars` = 0 · `react-refresh` = 0. Non-component exports were **split into their own modules** (`contexts/language.ts`, `lib/sortOptions.ts`, diff --git a/netlify/functions/nip05.mjs b/netlify/functions/nip05.mjs new file mode 100644 index 0000000..094f3cf --- /dev/null +++ b/netlify/functions/nip05.mjs @@ -0,0 +1,220 @@ +// Netlify Function: the SkateSpots NIP-05 name registry. +// +// NIP-05 maps `name@skate-spots.org` → a Nostr pubkey by serving JSON at +// https://skate-spots.org/.well-known/nostr.json?name=. This function is +// that endpoint, plus the tiny API the app uses to check availability and +// claim/hand over a name. +// +// Deliberate design rules: +// • NON-GATING. A handle is a badge, never a key to the door. If this function +// is unreachable the app still works and every user keeps their npub — the +// client only ever adds `nip05` to a profile after a claim has succeeded. +// • PROOF BY SIGNATURE. A claim must be signed by the pubkey that will own the +// name (a NIP-98 kind-27235 event in the `x-nip05-auth` header), so nobody +// can claim a name for someone else. No accounts, no passwords, no PII. +// • SMALL BLAST RADIUS. Storage is Netlify Blobs (first-party — no new vendor, +// no new secret), one key per name, so two simultaneous claims can't clobber +// each other. Platform names are seeded in code, not storage. +import { getStore } from '@netlify/blobs'; +import { verifyEvent } from 'nostr-tools'; + +const DOMAIN = 'skate-spots.org'; +const STORE_NAME = 'nip05'; +const keyFor = (name) => `n:${name}`; + +// Owner pubkeys (hex). Keep in sync with src/lib/admins.ts and ai.mjs. +const PLATFORM_PUBKEY = '20e7c953439e66f3882ab96fe0faa8ba246d6fb12c03b0cb4e2dc4623b8bcb21'; +const OWNERS = new Set([ + PLATFORM_PUBKEY, + 'caba497f7beda0650d6b78ab211216e4d2ebabeec9ca6eb2a05cee8e55c6871b', +]); + +// Names that answer even with no stored entry — the platform's own identity. +// Claimable by nobody; released by nobody. +const SEED = { + 'skate-spots': PLATFORM_PUBKEY, + 'skatespots': PLATFORM_PUBKEY, +}; + +// Never claimable by users (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', +]); + +// 3–30 chars, starts and ends alphanumeric, dots/dashes/underscores inside. +const NAME_RE = /^[a-z0-9][a-z0-9._-]{1,28}[a-z0-9]$/; + +function normalizeName(raw) { + return String(raw ?? '').trim().toLowerCase().replace(/^@/, ''); +} + +function validateName(name) { + if (!name) return 'Enter a name.'; + if (name.length < 3) return 'Use at least 3 characters.'; + if (name.length > 30) return 'Use at most 30 characters.'; + if (!NAME_RE.test(name)) { + return 'Letters, numbers, dots, dashes and underscores only — start and end with a letter or number.'; + } + if (RESERVED.has(name) || SEED[name]) return 'That name is reserved.'; + return null; +} + +function store() { + return getStore({ name: STORE_NAME, consistency: 'strong' }); +} + +// ── HTTP helpers ────────────────────────────────────────────────────────────── +const CORS = { + // NIP-05 lookups come from every Nostr client on the web, so this endpoint is + // open. Auth is by signature, never by cookie, so `*` is safe. + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, x-nip05-auth', +}; + +function json(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { ...CORS, 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, + }); +} + +function decodeB64Json(value) { + try { + return JSON.parse(Buffer.from(value, 'base64').toString('utf8')); + } catch { + return null; + } +} + +function tagVal(tags, name) { + return tags?.find((t) => t[0] === name)?.[1]; +} + +/** Verify a NIP-98 auth header for `action` → { pubkey, name } | null. */ +function verifyAuth(header, action) { + const ev = decodeB64Json(header); + if (!ev || ev.kind !== 27235) return null; + const method = tagVal(ev.tags, 'method'); + if (method && method !== 'POST') return null; + const act = tagVal(ev.tags, 'action'); + if (act && act !== action) return null; + // A generous window lets one signature work for a while; the mapping is + // idempotent, so replay is harmless. + if (Math.abs(Date.now() / 1000 - ev.created_at) > 12 * 3600) return null; + try { + if (!verifyEvent(ev)) return null; + } catch { + return null; + } + return { pubkey: ev.pubkey, name: normalizeName(tagVal(ev.tags, 'name')) }; +} + +// ── Handlers ────────────────────────────────────────────────────────────────── + +/** GET /.well-known/nostr.json[?name=x] — the NIP-05 document itself. */ +async function handleWellKnown(url) { + const name = normalizeName(url.searchParams.get('name')); + const names = {}; + if (name) { + if (SEED[name]) { + names[name] = SEED[name]; + } else { + try { + const pubkey = await store().get(keyFor(name)); + if (pubkey) names[name] = pubkey; + } catch { + // Storage unavailable → still answer the platform's own names. + } + } + } else { + // No name given: return only the platform names rather than enumerating the + // whole registry (cheap, and doesn't hand out a scrapeable list). + Object.assign(names, SEED); + } + return json({ names }); +} + +/** GET /api/nip05/check?name=x — availability for live form feedback. */ +async function handleCheck(url) { + const name = normalizeName(url.searchParams.get('name')); + const reason = validateName(name); + if (reason) return json({ name, available: false, reason }); + try { + const existing = await store().get(keyFor(name)); + if (existing) return json({ name, available: false, reason: 'That name is taken.' }); + } catch { + return json({ name, available: false, reason: 'The name service is unavailable right now.' }); + } + return json({ name, available: true, reason: null }); +} + +/** POST /api/nip05/claim — reserve `name` for the signing pubkey. */ +async function handleClaim(request) { + const auth = verifyAuth(request.headers.get('x-nip05-auth'), 'claim'); + if (!auth) return json({ error: 'Signature required.' }, 401); + const { name, pubkey } = auth; + const reason = validateName(name); + if (reason) return json({ error: reason, code: 'invalid' }, 400); + + try { + const s = store(); + const existing = await s.get(keyFor(name)); + if (existing && existing !== pubkey) return json({ error: 'That name is taken.', code: 'taken' }, 409); + if (!existing) { + try { + await s.set(keyFor(name), pubkey, { onlyIfNew: true }); + } catch { + // Lost a race — re-read to see who won. Re-claiming your own name is fine. + const now = await s.get(keyFor(name)); + if (now && now !== pubkey) return json({ error: 'That name is taken.', code: 'taken' }, 409); + } + } + } catch { + return json({ error: 'The name service is unavailable right now.' }, 503); + } + return json({ ok: true, name, nip05: `${name}@${DOMAIN}` }); +} + +/** POST /api/nip05/release — remove a name (its owner, or a platform owner). */ +async function handleRelease(request) { + const auth = verifyAuth(request.headers.get('x-nip05-auth'), 'release'); + if (!auth) return json({ error: 'Signature required.' }, 401); + const { name, pubkey } = auth; + if (!name) return json({ error: 'Missing name.' }, 400); + if (SEED[name]) return json({ error: 'Platform names cannot be released.' }, 400); + try { + const s = store(); + const existing = await s.get(keyFor(name)); + if (!existing) return json({ ok: true }); + if (existing !== pubkey && !OWNERS.has(pubkey)) return json({ error: 'That name is not yours.' }, 403); + await s.delete(keyFor(name)); + } catch { + return json({ error: 'The name service is unavailable right now.' }, 503); + } + return json({ ok: true }); +} + +export const config = { + path: [ + '/.well-known/nostr.json', + '/api/nip05/check', + '/api/nip05/claim', + '/api/nip05/release', + ], +}; + +export default async function handler(request) { + const url = new URL(request.url); + const path = url.pathname; + + if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: CORS }); + + if (path === '/.well-known/nostr.json') return handleWellKnown(url); + if (path === '/api/nip05/check') return handleCheck(url); + if (path === '/api/nip05/claim' && request.method === 'POST') return handleClaim(request); + if (path === '/api/nip05/release' && request.method === 'POST') return handleRelease(request); + return json({ error: 'Not found' }, 404); +} diff --git a/package-lock.json b/package-lock.json index 08b8dd5..e56a722 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@getalby/sdk": "^5.1.1", "@hookform/resolvers": "^3.9.0", "@modelcontextprotocol/sdk": "^1.29.0", + "@netlify/blobs": "^11.1.1", "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", "@nostrify/nostrify": "^0.47.1", @@ -794,6 +795,19 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@envelop/instrumentation": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@envelop/instrumentation/-/instrumentation-1.0.1.tgz", + "integrity": "sha512-FuexE2qd3Yc1Kh1DjTliwGxb9kyyv4ySBNnfJOxnQasVmGdcqX6AkkO5sIMxLWFRftp8PErOcIn2dQW/1RwytQ==", + "license": "MIT", + "dependencies": { + "@whatwg-node/promise-helpers": "^1.2.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", @@ -1390,6 +1404,12 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fastify/busboy": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.2.tgz", + "integrity": "sha512-yXSS27qPExaXeuLvMRMXOLtpipzfQYNjG3FkunDWKGfMYjKuhFXko9CVzqxm8jcF+lmtS9Fd89QNdh9XDjnbNg==", + "license": "MIT" + }, "node_modules/@floating-ui/core": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.0.tgz", @@ -1994,6 +2014,107 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/@netlify/blobs": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/@netlify/blobs/-/blobs-11.1.1.tgz", + "integrity": "sha512-99H0BJlieiWLkTYQ4ACbw5aGlkbpgyJ3TYF1Hb76wU43eO57LnnP/m3cnMZWNWeAwXhiTEjvUTH7ghJTBoSjFQ==", + "license": "MIT", + "dependencies": { + "@netlify/dev-utils": "6.0.1", + "@netlify/otel": "^7.0.2", + "@netlify/runtime-utils": "3.0.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@netlify/dev-utils": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@netlify/dev-utils/-/dev-utils-6.0.1.tgz", + "integrity": "sha512-q5g70dO3q/M/XTBQz7uIECJxVlJoS8UBlzpjf22DV6q61UzXa8EONTPXPmvisrFcUi1I3AUKplPcHGQYZZj+Vw==", + "license": "MIT", + "dependencies": { + "@whatwg-node/server": "^0.11.0", + "ansis": "^4.1.0", + "atomically": "^2.0.3", + "chokidar": "^5.0.0", + "decache": "^4.6.2", + "dettle": "^1.0.5", + "dot-prop": "9.0.0", + "empathic": "^2.0.0", + "env-paths": "^3.0.0", + "parse-gitignore": "^2.0.0", + "semver": "^7.7.2" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@netlify/dev-utils/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@netlify/dev-utils/node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@netlify/dev-utils/node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@netlify/otel": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@netlify/otel/-/otel-7.0.2.tgz", + "integrity": "sha512-MHEsRJTZtOCrLa2fR+Y3GN51rvgxUHazuRVxuWuG7R1qzwynXAZB20Ufwp7BxKSTKcp7LNVEnKAZ/k2ytVmYvQ==", + "license": "MIT", + "dependencies": { + "@opentelemetry/api": "1.9.1", + "@opentelemetry/core": "2.10.0", + "@opentelemetry/instrumentation": "^0.221.0", + "@opentelemetry/resources": "2.10.0", + "@opentelemetry/sdk-trace-node": "2.10.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@netlify/runtime-utils": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@netlify/runtime-utils/-/runtime-utils-3.0.0.tgz", + "integrity": "sha512-OHo40+V3QZkR+UrDjqhN+LekrAby/dSSJxsvyYs9i88E/mSl3goB/ysoL+VYROm190egxmQyRYlHK7vL4GeHBQ==", + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/@noble/ciphers": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz", @@ -2127,6 +2248,148 @@ "resolved": "https://registry.npmjs.org/@nostrify/types/-/types-0.36.7.tgz", "integrity": "sha512-Jdf2eWdIzonMzic/st4ltre7XIcXVNTjmcbKpw4xGEfNmoq2Y4wj8KcSB6s0TUqxcLEyiXnyGP8EDx/gEVri9A==" }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.221.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.221.0.tgz", + "integrity": "sha512-OlanaW1vv7ufTqQ3/fPLI4arGt5ZoM+P8abOMki6uEYnpRazepSWDwDnnw+la7kE26SHVC18//SMccrDvLKOXQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/context-async-hooks": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.10.0.tgz", + "integrity": "sha512-bvyMcgLEkozzSzpEEEo1OMoeQ97bxj6Qs2uN3mPrSdDvObMI1myffD/BPqcLlzZO9//d1SqQA/WPw7Cz2AiqhA==", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.10.0.tgz", + "integrity": "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.221.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.221.0.tgz", + "integrity": "sha512-cCk80Z/iRDf/5gfsKMB4f74LqVA5yKETB/9ojPzVW/6/f70iu89nJvGxsFCxx4XfSohaOofkU19kiYm84AiAlw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.221.0", + "import-in-the-middle": "^3.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.10.0.tgz", + "integrity": "sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.10.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.10.0.tgz", + "integrity": "sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.10.0", + "@opentelemetry/resources": "2.10.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.10.0.tgz", + "integrity": "sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.10.0", + "@opentelemetry/resources": "2.10.0", + "@opentelemetry/sdk-trace": "2.10.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-node": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.10.0.tgz", + "integrity": "sha512-GZK/G6oZyBLGlH1pUgeDch7D91KoHd2uotUGIkWCPi9GI5T9X0p4L7nNAMDR1BQjkRYoDqo+ddfVx9t5Uhys+Q==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/context-async-hooks": "2.10.0", + "@opentelemetry/core": "2.10.0", + "@opentelemetry/sdk-trace-base": "2.10.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -4884,6 +5147,75 @@ "url": "lightning:hello@getalby.com" } }, + "node_modules/@whatwg-node/disposablestack": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@whatwg-node/disposablestack/-/disposablestack-0.0.6.tgz", + "integrity": "sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw==", + "license": "MIT", + "dependencies": { + "@whatwg-node/promise-helpers": "^1.0.0", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@whatwg-node/fetch": { + "version": "0.10.13", + "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.10.13.tgz", + "integrity": "sha512-b4PhJ+zYj4357zwk4TTuF2nEe0vVtOrwdsrNo5hL+u1ojXNhh1FgJ6pg1jzDlwlT4oBdzfSwaBwMCtFCsIWg8Q==", + "license": "MIT", + "dependencies": { + "@whatwg-node/node-fetch": "^0.8.3", + "urlpattern-polyfill": "^10.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@whatwg-node/node-fetch": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.8.6.tgz", + "integrity": "sha512-BDMdYFcerLQkwA2RTldxOqRCs6ZQD1S7UgP3pUdGUkcbgTrP/V5ko77ZkCww9DHmC4lpoYuwigGfQYj285gMvA==", + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^3.1.1", + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/promise-helpers": "^1.3.2", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@whatwg-node/promise-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz", + "integrity": "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@whatwg-node/server": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/server/-/server-0.11.0.tgz", + "integrity": "sha512-VSdkwnJRr8Yv9UgB2aXB3VUPWwd6Oqnn0hycFwhg9pZgWxJXb7JmhsiXe9tmpMwjHFxli12PGcz9aI63YYloGQ==", + "license": "MIT", + "dependencies": { + "@envelop/instrumentation": "^1.0.0", + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/fetch": "^0.10.13", + "@whatwg-node/promise-helpers": "^1.3.2", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.9.12", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.12.tgz", @@ -5023,6 +5355,15 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/ansis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.4.0.tgz", + "integrity": "sha512-9k3v7xcHwgdO/DruxGIg4HtjvlAZlcnsX/mzqUb1t3NkYnl9kK2UJ+Gq0io+vQf7iT//BD/HB/NBkUR1LWxoeA==", + "license": "ISC", + "engines": { + "node": ">=14" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -5450,6 +5791,16 @@ "node": ">=8.0.0" } }, + "node_modules/atomically": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.1.tgz", + "integrity": "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==", + "license": "MIT", + "dependencies": { + "stubborn-fs": "^2.0.0", + "when-exit": "^2.1.4" + } + }, "node_modules/autoprefixer": { "version": "10.4.21", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", @@ -5864,6 +6215,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/callsite": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/callsite/-/callsite-1.0.0.tgz", + "integrity": "sha512-0vdNRFXn5q+dtOqjfFtmtlI9N2eVZ7LMyEV2iKC5mEEFvSg/69Ml6b/WU2qF8W1nLRa0wiSrDT3Y5jOHZCwKPQ==", + "engines": { + "node": "*" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -6024,6 +6383,12 @@ "node": ">=18" } }, + "node_modules/cjs-module-lexer": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.1.tgz", + "integrity": "sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==", + "license": "MIT" + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -6476,6 +6841,15 @@ } } }, + "node_modules/decache": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/decache/-/decache-4.6.2.tgz", + "integrity": "sha512-2LPqkLeu8XWHU8qNCS3kcF6sCcb5zIzvWaAHYSvPfwhdd7mHuah29NssMzrTYyHN4F5oFy2ko9OBYxegtU0FEw==", + "license": "MIT", + "dependencies": { + "callsite": "^1.0.0" + } + }, "node_modules/decamelize": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", @@ -6580,6 +6954,12 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/dettle": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/dettle/-/dettle-1.0.5.tgz", + "integrity": "sha512-ZVyjhAJ7sCe1PNXEGveObOH9AC8QvMga3HJIghHawtG7mE4K5pW9nz/vDGAr/U7a3LWgdOzEE7ac9MURnyfaTA==", + "license": "MIT" + }, "node_modules/dexie": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.2.1.tgz", @@ -6623,6 +7003,21 @@ "csstype": "^3.0.2" } }, + "node_modules/dot-prop": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", + "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^4.18.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -6754,6 +7149,15 @@ "dev": true, "license": "MIT" }, + "node_modules/empathic": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.1.0.tgz", + "integrity": "sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -7872,6 +8276,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-in-the-middle": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.5.1.tgz", + "integrity": "sha512-mPKuL8bPQzecui2KK6Gb+M8JvJoHnhS1FeYGa22QopBmlevF5F0FE6ued/B5EgHDeIoMTONIpDWWFKUPOG0DBQ==", + "license": "Apache-2.0", + "dependencies": { + "cjs-module-lexer": "^2.2.0", + "es-module-lexer": "^3.0.2", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/import-in-the-middle/node_modules/es-module-lexer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-3.0.2.tgz", + "integrity": "sha512-BuIB67FngDSyQ/dpQNOZybwdEBDUGJQvOqwWr4ha/ufYiqzuEwPkKO2zLhRAgay28tStRIHUeWmszZAJo3GCOg==", + "license": "MIT" + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -8586,6 +9010,12 @@ "ufo": "^1.5.4" } }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -9005,6 +9435,15 @@ "node": ">=6" } }, + "node_modules/parse-gitignore": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/parse-gitignore/-/parse-gitignore-2.0.0.tgz", + "integrity": "sha512-RmVuCHWsfu0QPNW+mraxh/xjQVw/lhUCUru8Zni3Ctq3AoMhpDTq0OVdKS6iesd6Kqb7viCV3isAL43dciOSog==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -9972,6 +10411,19 @@ "node": ">=0.10.0" } }, + "node_modules/require-in-the-middle": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" + }, + "engines": { + "node": ">=9.3.0 || >=8.10.0 <9.0.0" + } + }, "node_modules/require-main-filename": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", @@ -10524,7 +10976,6 @@ "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -10964,6 +11415,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stubborn-fs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", + "integrity": "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==", + "license": "MIT", + "dependencies": { + "stubborn-utils": "^1.0.1" + } + }, + "node_modules/stubborn-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz", + "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", + "license": "MIT" + }, "node_modules/sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", @@ -11872,6 +12338,18 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-is": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", @@ -12088,6 +12566,12 @@ "punycode": "^2.1.0" } }, + "node_modules/urlpattern-polyfill": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz", + "integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==", + "license": "MIT" + }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", @@ -12511,6 +12995,12 @@ "node": ">=18" } }, + "node_modules/when-exit": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", + "integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 3034ad4..a8fa53c 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@getalby/sdk": "^5.1.1", "@hookform/resolvers": "^3.9.0", "@modelcontextprotocol/sdk": "^1.29.0", + "@netlify/blobs": "^11.1.1", "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", "@nostrify/nostrify": "^0.47.1", diff --git a/src/components/EditProfileForm.tsx b/src/components/EditProfileForm.tsx index c9e2826..870837e 100644 --- a/src/components/EditProfileForm.tsx +++ b/src/components/EditProfileForm.tsx @@ -23,6 +23,8 @@ import { NSchema as n, type NostrMetadata } from '@nostrify/nostrify'; import { useQueryClient } from '@tanstack/react-query'; import { useUploadFile } from '@/hooks/useUploadFile'; import { useCrewNip05 } from '@/hooks/useCrewNip05'; +import { SkateNip05Input } from '@/components/SkateNip05Input'; +import { SKATE_NIP05_DOMAIN, parseSkateNip05 } from '@/lib/skateNip05'; import { isValidNoffer, isValidSilentPayment, isValidLightningAddress } from '@/lib/types'; export const EditProfileForm: React.FC = () => { @@ -625,6 +627,14 @@ const Nip05Field: React.FC<{ // Only hit the network once it's a plausible address. const { data: result, isLoading } = useCrewNip05(looksValid ? trimmed : undefined, pubkey); + // Free SkateSpots handles (`name@skate-spots.org`). A local draft the skater + // can claim; on success it fills the field above and verifies normally. + const skateHandle = parseSkateNip05(value); + const [draft, setDraft] = useState(''); + useEffect(() => { + if (skateHandle) setDraft(skateHandle); + }, [skateHandle]); + let status: { kind: 'hint' | 'ok' | 'error' | 'warn' | 'checking'; text: string }; if (!trimmed) { status = { kind: 'hint', text: 'A verified handle like you@yourdomain.com — Nostr’s version of a blue check. Shows a ✓ badge on your profile and unlocks the “Verified” filter in the Skaters directory.' }; @@ -704,6 +714,24 @@ const Nip05Field: React.FC<{ ) : (

{status.text}

)} + + {/* Free SkateSpots handles — claim one here and it fills the field above. */} +
+

+ No domain of your own? Claim a free{' '} + @{SKATE_NIP05_DOMAIN} handle from SkateSpots. +

+ { + setDraft(h); + if (skateHandle) onChange(''); + }} + claimed={value} + onClaimed={(nip05) => onChange(nip05)} + /> +
); }; diff --git a/src/components/SkateNip05Input.tsx b/src/components/SkateNip05Input.tsx new file mode 100644 index 0000000..b7a4aec --- /dev/null +++ b/src/components/SkateNip05Input.tsx @@ -0,0 +1,171 @@ +import React, { useEffect, useState } from 'react'; +import { AlertCircle, AtSign, BadgeCheck, CheckCircle, Loader2 } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { useSkateNip05 } from '@/hooks/useSkateNip05'; +import { + SKATE_NIP05_DOMAIN, + parseSkateNip05, + validateSkateNip05Name, +} from '@/lib/skateNip05'; +import { cn } from '@/lib/utils'; + +type FieldStatus = 'idle' | 'checking' | 'available' | 'taken' | 'invalid' | 'error' | 'claimed'; + +interface SkateNip05InputProps { + /** The local part being edited (no `@domain`). */ + handle: string; + onHandleChange: (handle: string) => void; + /** The full `name@skate-spots.org` already claimed, if any. */ + claimed?: string | null; + /** Fired once a claim succeeds. */ + onClaimed?: (nip05: string) => void; + disabled?: boolean; + autoFocus?: boolean; + id?: string; + className?: string; +} + +/** + * Input for claiming a free `name@skate-spots.org` handle. + * + * Self-contained: it validates locally, checks availability against the registry + * (debounced), and claims on demand. A failure is never fatal — the parent simply + * ends up without a handle, which is a perfectly usable account. + */ +export const SkateNip05Input: React.FC = ({ + handle, + onHandleChange, + claimed, + onClaimed, + disabled, + autoFocus, + id = 'skate-nip05', + className, +}) => { + const { checkAvailability, claim } = useSkateNip05(); + const [status, setStatus] = useState('idle'); + const [message, setMessage] = useState(null); + const [claiming, setClaiming] = useState(false); + + const validation = validateSkateNip05Name(handle); + const claimedHandle = parseSkateNip05(claimed); + const isClaimed = !!claimedHandle && claimedHandle === validation.name; + + // Debounced availability check — mirrors the server's rules so we rarely make + // a pointless request, and aborts an in-flight one when the input changes. + useEffect(() => { + if (isClaimed) { + setStatus('claimed'); + setMessage(null); + return; + } + if (!handle.trim()) { + setStatus('idle'); + setMessage(null); + return; + } + if (!validation.ok) { + setStatus('invalid'); + setMessage(validation.reason ?? null); + return; + } + + setStatus('checking'); + setMessage(null); + const controller = new AbortController(); + const timer = setTimeout(async () => { + const res = await checkAvailability(validation.name, controller.signal); + if (controller.signal.aborted) return; + setStatus(res.available ? 'available' : 'taken'); + setMessage(res.reason); + }, 400); + + return () => { + clearTimeout(timer); + controller.abort(); + }; + }, [handle, isClaimed, validation.ok, validation.name, validation.reason, checkAvailability]); + + const handleClaim = async () => { + if (!validation.ok) return; + setClaiming(true); + const result = await claim(validation.name); + setClaiming(false); + if (result.ok) { + setStatus('claimed'); + setMessage(null); + onClaimed?.(result.nip05); + } else { + setStatus('error'); + setMessage(result.error); + } + }; + + return ( +
+
+ onHandleChange(e.target.value)} + placeholder="yourname" + spellCheck={false} + autoCapitalize="none" + autoComplete="off" + disabled={disabled || claiming} + autoFocus={autoFocus} + className="min-w-0 flex-1 bg-transparent px-3 py-2 text-sm outline-none placeholder:text-muted-foreground" + /> + + + {SKATE_NIP05_DOMAIN} + +
+ + {status === 'claimed' ? ( +

+ + + {claimedHandle}@{SKATE_NIP05_DOMAIN} is yours — save your profile to publish it. + +

+ ) : status === 'checking' ? ( +

+ + Checking availability… +

+ ) : status === 'available' ? ( +
+

+ + {validation.name}@{SKATE_NIP05_DOMAIN} is available +

+ +
+ ) : status === 'invalid' || status === 'taken' || status === 'error' ? ( +

+ + {message} +

+ ) : ( +

+ Letters, numbers, dashes. Free, and shown as a ✓ badge on your profile. +

+ )} +
+ ); +}; diff --git a/src/components/auth/SignupDialog.tsx b/src/components/auth/SignupDialog.tsx index 889dacb..8d49b89 100644 --- a/src/components/auth/SignupDialog.tsx +++ b/src/components/auth/SignupDialog.tsx @@ -16,6 +16,9 @@ import { useUploadFile } from '@/hooks/useUploadFile'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { generateSecretKey, nip19 } from 'nostr-tools'; import { cn } from '@/lib/utils'; +import { SkateNip05Input } from '@/components/SkateNip05Input'; +import { useSkateNip05 } from '@/hooks/useSkateNip05'; +import { deriveSkateNip05Handle, validateSkateNip05Name } from '@/lib/skateNip05'; interface SignupDialogProps { isOpen: boolean; @@ -41,9 +44,15 @@ const SignupDialog: React.FC = ({ isOpen, onClose, onComplete about: '', picture: '' }); + // The skater's chosen `name@skate-spots.org` handle. Claimed separately from + // the profile publish (and never required — a failure just means no handle). + const [nip05Handle, setNip05Handle] = useState(''); + const [nip05HandleEdited, setNip05HandleEdited] = useState(false); + const [claimedNip05, setClaimedNip05] = useState(null); const login = useLoginActions(); const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish(); const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); + const { claim } = useSkateNip05(); const avatarFileInputRef = useRef(null); // Shared source of truth: once the recovery code is saved here, mark backup // confirmed so the gentle post-signup reminder never nags a safe user. @@ -173,12 +182,33 @@ const SignupDialog: React.FC = ({ isOpen, onClose, onComplete localStorage.setItem('signup_completed', Date.now().toString()); try { + // Reserve the chosen SkateSpots handle first (best-effort). A profile only + // advertises a handle once the registry has confirmed it, so other clients + // never show a broken ✓ — and a failure here never blocks the signup. + let nip05 = claimedNip05 ?? undefined; + if (!skipProfile && !nip05) { + const candidate = validateSkateNip05Name(nip05Handle); + if (candidate.ok) { + const claimResult = await claim(candidate.name); + if (claimResult.ok) { + nip05 = claimResult.nip05; + setClaimedNip05(claimResult.nip05); + } else { + toast({ + title: "Couldn't reserve that name", + description: `${claimResult.error} You can claim one later in Settings.`, + }); + } + } + } + // Publish profile if user provided information if (!skipProfile && (trimmedName || profileData.about || profileData.picture)) { const metadata: Record = {}; if (trimmedName) metadata.name = trimmedName; if (profileData.about) metadata.about = profileData.about; if (profileData.picture) metadata.picture = profileData.picture; + if (nip05) metadata.nip05 = nip05; await publishEvent({ kind: 0, @@ -248,9 +278,20 @@ const SignupDialog: React.FC = ({ isOpen, onClose, onComplete setKeySecured('none'); setShowNsec(false); setProfileData({ name: '', about: '', picture: '' }); + setNip05Handle(''); + setNip05HandleEdited(false); + setClaimedNip05(null); } }, [isOpen]); + // Offer a handle derived from the display name until the skater edits it + // themselves (or has already claimed one). + useEffect(() => { + if (!nip05HandleEdited && !claimedNip05) { + setNip05Handle(deriveSkateNip05Handle(profileData.name)); + } + }, [profileData.name, nip05HandleEdited, claimedNip05]); + // Create the account (generate key + log in), then advance to profile setup. const handleCreateAccount = () => { if (createAccountSilently()) { @@ -429,6 +470,27 @@ const SignupDialog: React.FC = ({ isOpen, onClose, onComplete

+
+ + { + setNip05HandleEdited(true); + setNip05Handle(h); + setClaimedNip05(null); + }} + claimed={claimedNip05} + onClaimed={(nip05) => setClaimedNip05(nip05)} + disabled={isPublishing} + /> +

+ Free and optional — a verified handle that shows a ✓ on your profile. +

+
+