From 3c7c8bd87e3df3f69905997ed87905a2f2c759c4 Mon Sep 17 00:00:00 2001
From: SkateSpots
Date: Mon, 28 Sep 2026 12:13:34 +0700
Subject: [PATCH] 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.
---
AGENTS.md | 27 +-
netlify/functions/nip05.mjs | 220 ++++++++++++
package-lock.json | 492 ++++++++++++++++++++++++++-
package.json | 1 +
src/components/EditProfileForm.tsx | 28 ++
src/components/SkateNip05Input.tsx | 171 ++++++++++
src/components/auth/SignupDialog.tsx | 62 ++++
src/hooks/useSkateNip05.ts | 120 +++++++
src/lib/skateNip05.ts | 88 +++++
src/pages/SettingsPage.tsx | 18 +
10 files changed, 1225 insertions(+), 2 deletions(-)
create mode 100644 netlify/functions/nip05.mjs
create mode 100644 src/components/SkateNip05Input.tsx
create mode 100644 src/hooks/useSkateNip05.ts
create mode 100644 src/lib/skateNip05.ts
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.
+
+
+