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.
This commit is contained in:
SkateSpots 2026-09-28 12:13:34 +07:00
parent 5b770dc79b
commit 3c7c8bd87e
10 changed files with 1225 additions and 2 deletions

View file

@ -490,6 +490,31 @@ session with **no other memory of this project**. So:
`public/images/screenshots/{map,spot,marketplace}.png` (1280×800, quantised — `public/images/screenshots/{map,spot,marketplace}.png` (1280×800, quantised —
captured with headless Chromium; regenerate with a CDP harness if the UI captured with headless Chromium; regenerate with a CDP harness if the UI
changes materially). 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 - **Mints directory**: `/mints` page lists Cashu mints from the community
**MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin **MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin
at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* → 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. instead of showing the error boundary.
- **Baselines** (verified 2026-09-27): `npm test` → **0 errors, 0 warnings**, - **Baselines** (verified 2026-09-27): `npm test` → **0 errors, 0 warnings**,
**101 tests**, build ok. The gate also type-checks the CVM server **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 - ✅ Lint is **clean**: `no-explicit-any` = 0 · `react-hooks/exhaustive-deps` = 0
· `no-unused-vars` = 0 · `react-refresh` = 0. Non-component exports were · `no-unused-vars` = 0 · `react-refresh` = 0. Non-component exports were
**split into their own modules** (`contexts/language.ts`, `lib/sortOptions.ts`, **split into their own modules** (`contexts/language.ts`, `lib/sortOptions.ts`,

220
netlify/functions/nip05.mjs Normal file
View file

@ -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=<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);
}

492
package-lock.json generated
View file

@ -22,6 +22,7 @@
"@getalby/sdk": "^5.1.1", "@getalby/sdk": "^5.1.1",
"@hookform/resolvers": "^3.9.0", "@hookform/resolvers": "^3.9.0",
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",
"@netlify/blobs": "^11.1.1",
"@noble/curves": "^2.0.1", "@noble/curves": "^2.0.1",
"@noble/hashes": "^2.0.1", "@noble/hashes": "^2.0.1",
"@nostrify/nostrify": "^0.47.1", "@nostrify/nostrify": "^0.47.1",
@ -794,6 +795,19 @@
"dev": true, "dev": true,
"license": "Apache-2.0" "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": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.5", "version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", "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": "^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": { "node_modules/@floating-ui/core": {
"version": "1.7.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.0.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.0.tgz",
@ -1994,6 +2014,107 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT" "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": { "node_modules/@noble/ciphers": {
"version": "0.5.3", "version": "0.5.3",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz", "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", "resolved": "https://registry.npmjs.org/@nostrify/types/-/types-0.36.7.tgz",
"integrity": "sha512-Jdf2eWdIzonMzic/st4ltre7XIcXVNTjmcbKpw4xGEfNmoq2Y4wj8KcSB6s0TUqxcLEyiXnyGP8EDx/gEVri9A==" "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": { "node_modules/@pinojs/redact": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@ -4884,6 +5147,75 @@
"url": "lightning:hello@getalby.com" "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": { "node_modules/@xmldom/xmldom": {
"version": "0.9.12", "version": "0.9.12",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.12.tgz", "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" "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": { "node_modules/any-promise": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@ -5450,6 +5791,16 @@
"node": ">=8.0.0" "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": { "node_modules/autoprefixer": {
"version": "10.4.21", "version": "10.4.21",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
@ -5864,6 +6215,14 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/callsites": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@ -6024,6 +6383,12 @@
"node": ">=18" "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": { "node_modules/class-variance-authority": {
"version": "0.7.1", "version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", "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": { "node_modules/decamelize": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
@ -6580,6 +6954,12 @@
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT" "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": { "node_modules/dexie": {
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/dexie/-/dexie-4.2.1.tgz", "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.2.1.tgz",
@ -6623,6 +7003,21 @@
"csstype": "^3.0.2" "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": { "node_modules/dotenv": {
"version": "17.4.2", "version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
@ -6754,6 +7149,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/encodeurl": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@ -7872,6 +8276,26 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/imurmurhash": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
@ -8586,6 +9010,12 @@
"ufo": "^1.5.4" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -9005,6 +9435,15 @@
"node": ">=6" "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": { "node_modules/parse5": {
"version": "7.3.0", "version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
@ -9972,6 +10411,19 @@
"node": ">=0.10.0" "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": { "node_modules/require-main-filename": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
@ -10524,7 +10976,6 @@
"version": "7.7.2", "version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
@ -10964,6 +11415,21 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/sucrase": {
"version": "3.35.0", "version": "3.35.0",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
@ -11872,6 +12338,18 @@
"node": ">= 0.8.0" "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": { "node_modules/type-is": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
@ -12088,6 +12566,12 @@
"punycode": "^2.1.0" "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": { "node_modules/use-callback-ref": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
@ -12511,6 +12995,12 @@
"node": ">=18" "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": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View file

@ -34,6 +34,7 @@
"@getalby/sdk": "^5.1.1", "@getalby/sdk": "^5.1.1",
"@hookform/resolvers": "^3.9.0", "@hookform/resolvers": "^3.9.0",
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",
"@netlify/blobs": "^11.1.1",
"@noble/curves": "^2.0.1", "@noble/curves": "^2.0.1",
"@noble/hashes": "^2.0.1", "@noble/hashes": "^2.0.1",
"@nostrify/nostrify": "^0.47.1", "@nostrify/nostrify": "^0.47.1",

View file

@ -23,6 +23,8 @@ import { NSchema as n, type NostrMetadata } from '@nostrify/nostrify';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useUploadFile } from '@/hooks/useUploadFile'; import { useUploadFile } from '@/hooks/useUploadFile';
import { useCrewNip05 } from '@/hooks/useCrewNip05'; 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'; import { isValidNoffer, isValidSilentPayment, isValidLightningAddress } from '@/lib/types';
export const EditProfileForm: React.FC = () => { export const EditProfileForm: React.FC = () => {
@ -625,6 +627,14 @@ const Nip05Field: React.FC<{
// Only hit the network once it's a plausible address. // Only hit the network once it's a plausible address.
const { data: result, isLoading } = useCrewNip05(looksValid ? trimmed : undefined, pubkey); 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 }; let status: { kind: 'hint' | 'ok' | 'error' | 'warn' | 'checking'; text: string };
if (!trimmed) { 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.' }; 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<{
) : ( ) : (
<p className="text-xs text-muted-foreground">{status.text}</p> <p className="text-xs text-muted-foreground">{status.text}</p>
)} )}
{/* Free SkateSpots handles — claim one here and it fills the field above. */}
<div className="space-y-2 rounded-lg border border-dashed border-muted-foreground/25 p-3">
<p className="text-xs text-muted-foreground">
No domain of your own? Claim a free{' '}
<span className="font-semibold text-foreground">@{SKATE_NIP05_DOMAIN}</span> handle from SkateSpots.
</p>
<SkateNip05Input
id="skate-nip05-claim"
handle={draft}
onHandleChange={(h) => {
setDraft(h);
if (skateHandle) onChange('');
}}
claimed={value}
onClaimed={(nip05) => onChange(nip05)}
/>
</div>
</div> </div>
); );
}; };

View file

@ -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<SkateNip05InputProps> = ({
handle,
onHandleChange,
claimed,
onClaimed,
disabled,
autoFocus,
id = 'skate-nip05',
className,
}) => {
const { checkAvailability, claim } = useSkateNip05();
const [status, setStatus] = useState<FieldStatus>('idle');
const [message, setMessage] = useState<string | null>(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 (
<div className={cn('space-y-2', className)}>
<div
className={cn(
'flex items-stretch overflow-hidden rounded-lg border border-input bg-background focus-within:ring-1 focus-within:ring-ring',
(disabled || claiming) && 'opacity-60',
)}
>
<input
id={id}
value={handle}
onChange={(e) => 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"
/>
<span className="flex select-none items-center gap-1 border-l border-input bg-muted/40 px-2.5 text-xs font-medium text-muted-foreground">
<AtSign className="h-3 w-3" />
{SKATE_NIP05_DOMAIN}
</span>
</div>
{status === 'claimed' ? (
<p className="flex items-start gap-1.5 text-xs text-green-600 dark:text-green-400">
<BadgeCheck className="h-3.5 w-3.5 shrink-0 mt-px" />
<span>
<span className="font-semibold">{claimedHandle}@{SKATE_NIP05_DOMAIN}</span> is yours — save your profile to publish it.
</span>
</p>
) : status === 'checking' ? (
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Checking availability…
</p>
) : status === 'available' ? (
<div className="flex items-center justify-between gap-2">
<p className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400">
<CheckCircle className="h-3.5 w-3.5" />
{validation.name}@{SKATE_NIP05_DOMAIN} is available
</p>
<Button
type="button"
size="sm"
onClick={handleClaim}
disabled={claiming || disabled}
className="h-7 rounded-full bg-[#ccff00] px-3 text-xs font-bold text-black hover:bg-[#ccff00]/90"
>
{claiming ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Claim it'}
</Button>
</div>
) : status === 'invalid' || status === 'taken' || status === 'error' ? (
<p className="flex items-start gap-1.5 text-xs text-red-500">
<AlertCircle className="h-3.5 w-3.5 shrink-0 mt-px" />
{message}
</p>
) : (
<p className="text-xs text-muted-foreground">
Letters, numbers, dashes. Free, and shown as a ✓ badge on your profile.
</p>
)}
</div>
);
};

View file

@ -16,6 +16,9 @@ import { useUploadFile } from '@/hooks/useUploadFile';
import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useLocalStorage } from '@/hooks/useLocalStorage';
import { generateSecretKey, nip19 } from 'nostr-tools'; import { generateSecretKey, nip19 } from 'nostr-tools';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { SkateNip05Input } from '@/components/SkateNip05Input';
import { useSkateNip05 } from '@/hooks/useSkateNip05';
import { deriveSkateNip05Handle, validateSkateNip05Name } from '@/lib/skateNip05';
interface SignupDialogProps { interface SignupDialogProps {
isOpen: boolean; isOpen: boolean;
@ -41,9 +44,15 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
about: '', about: '',
picture: '' 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<string | null>(null);
const login = useLoginActions(); const login = useLoginActions();
const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish(); const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { claim } = useSkateNip05();
const avatarFileInputRef = useRef<HTMLInputElement>(null); const avatarFileInputRef = useRef<HTMLInputElement>(null);
// Shared source of truth: once the recovery code is saved here, mark backup // 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. // confirmed so the gentle post-signup reminder never nags a safe user.
@ -173,12 +182,33 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
localStorage.setItem('signup_completed', Date.now().toString()); localStorage.setItem('signup_completed', Date.now().toString());
try { 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 // Publish profile if user provided information
if (!skipProfile && (trimmedName || profileData.about || profileData.picture)) { if (!skipProfile && (trimmedName || profileData.about || profileData.picture)) {
const metadata: Record<string, string> = {}; const metadata: Record<string, string> = {};
if (trimmedName) metadata.name = trimmedName; if (trimmedName) metadata.name = trimmedName;
if (profileData.about) metadata.about = profileData.about; if (profileData.about) metadata.about = profileData.about;
if (profileData.picture) metadata.picture = profileData.picture; if (profileData.picture) metadata.picture = profileData.picture;
if (nip05) metadata.nip05 = nip05;
await publishEvent({ await publishEvent({
kind: 0, kind: 0,
@ -248,9 +278,20 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
setKeySecured('none'); setKeySecured('none');
setShowNsec(false); setShowNsec(false);
setProfileData({ name: '', about: '', picture: '' }); setProfileData({ name: '', about: '', picture: '' });
setNip05Handle('');
setNip05HandleEdited(false);
setClaimedNip05(null);
} }
}, [isOpen]); }, [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. // Create the account (generate key + log in), then advance to profile setup.
const handleCreateAccount = () => { const handleCreateAccount = () => {
if (createAccountSilently()) { if (createAccountSilently()) {
@ -429,6 +470,27 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
</p> </p>
</div> </div>
<div className='space-y-2'>
<label htmlFor='skate-nip05' className='text-sm font-medium'>
Your SkateSpots name
</label>
<SkateNip05Input
id='skate-nip05'
handle={nip05Handle}
onHandleChange={(h) => {
setNip05HandleEdited(true);
setNip05Handle(h);
setClaimedNip05(null);
}}
claimed={claimedNip05}
onClaimed={(nip05) => setClaimedNip05(nip05)}
disabled={isPublishing}
/>
<p className='text-xs text-muted-foreground'>
Free and optional — a verified handle that shows a ✓ on your profile.
</p>
</div>
<div className='space-y-2'> <div className='space-y-2'>
<label htmlFor='profile-about' className='text-sm font-medium'> <label htmlFor='profile-about' className='text-sm font-medium'>
Bio Bio

120
src/hooks/useSkateNip05.ts Normal file
View file

@ -0,0 +1,120 @@
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 };
}

88
src/lib/skateNip05.ts Normal file
View file

@ -0,0 +1,88 @@
// SkateSpots NIP-05 names — `you@skate-spots.org`.
//
// This is a *convenience* namespace we hand out for free so a new skater has a
// human handle from the first minute. It is deliberately NOT a gate: NIP-05 is a
// public label (a domain maps a name → pubkey), never an account, and the app
// works fine without one. Users are always free to bring a handle from any other
// domain instead.
//
// The registry itself lives server-side (netlify/functions/nip05.mjs); this
// module holds the shared, client-side rules so the form can validate before it
// ever talks to the network.
import { isNativePlatform } from '@/lib/platform';
/** The domain SkateSpots issues handles under. */
export const SKATE_NIP05_DOMAIN = 'skate-spots.org';
/** Names the registry refuses (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',
'skate-spots', 'skatespots',
]);
// Keep in sync with NAME_RE in netlify/functions/nip05.mjs.
const NAME_RE = /^[a-z0-9][a-z0-9._-]{1,28}[a-z0-9]$/;
export interface SkateNip05Validation {
ok: boolean;
/** Normalized (lowercased, trimmed, leading @ stripped) name. */
name: string;
reason?: string;
}
/** Lowercase, trim, and drop a leading `@` so `@Tony` and `tony` agree. */
export function normalizeSkateNip05Name(raw: string): string {
return (raw ?? '').trim().toLowerCase().replace(/^@/, '');
}
/** Validate a local part (the bit before `@skate-spots.org`). */
export function validateSkateNip05Name(raw: string): SkateNip05Validation {
const name = normalizeSkateNip05Name(raw);
if (!name) return { ok: false, name, reason: 'Enter a name.' };
if (name.length < 3) return { ok: false, name, reason: 'Use at least 3 characters.' };
if (name.length > 30) return { ok: false, name, reason: 'Use at most 30 characters.' };
if (!NAME_RE.test(name)) {
return {
ok: false,
name,
reason: 'Letters, numbers, dots, dashes and underscores only — start and end with a letter or number.',
};
}
if (RESERVED.has(name)) return { ok: false, name, reason: 'That name is reserved.' };
return { ok: true, name };
}
/** Suggest a handle from a display name: "Tony Hawk!" → "tony-hawk". */
export function deriveSkateNip05Handle(displayName: string): string {
return (displayName ?? '')
.trim()
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^[-._]+|[-._]+$/g, '')
.slice(0, 30);
}
/** `tony` → `tony@skate-spots.org`. */
export function buildSkateNip05(name: string): string {
return `${normalizeSkateNip05Name(name)}@${SKATE_NIP05_DOMAIN}`;
}
/** If `nip05` is on our domain, return the handle; otherwise null. */
export function parseSkateNip05(nip05: string | undefined | null): string | null {
const value = (nip05 ?? '').trim().toLowerCase();
const suffix = `@${SKATE_NIP05_DOMAIN}`;
return value.endsWith(suffix) ? value.slice(0, -suffix.length) : null;
}
/**
* Absolute base for `/api` calls. Relative paths break inside the native shell
* (it is served from `https://localhost`), so native must hit the real origin.
*/
export function skateApiBase(): string {
return isNativePlatform() ? 'https://skate-spots.org' : '';
}

View file

@ -26,6 +26,7 @@ import { useCitrine } from '@/hooks/useCitrine';
import { toast } from '@/hooks/useToast'; import { toast } from '@/hooks/useToast';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { NIP05_PROVIDERS } from '@/lib/nip05Providers'; import { NIP05_PROVIDERS } from '@/lib/nip05Providers';
import { SKATE_NIP05_DOMAIN } from '@/lib/skateNip05';
import { User, Radio, Wallet, Wrench, RotateCcw, Shield, ExternalLink, Languages, RefreshCw, AlertCircle, Database, Package, ChevronRight, ChevronLeft, Smartphone, Download, Zap, WifiOff } from 'lucide-react'; import { User, Radio, Wallet, Wrench, RotateCcw, Shield, ExternalLink, Languages, RefreshCw, AlertCircle, Database, Package, ChevronRight, ChevronLeft, Smartphone, Download, Zap, WifiOff } from 'lucide-react';
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@ -205,6 +206,23 @@ function Nip05Screen({ onBack }: { onBack: () => void }) {
<p className="text-sm text-muted-foreground leading-relaxed"> <p className="text-sm text-muted-foreground leading-relaxed">
Get a verified Nostr address (like name@domain.com) to build trust. Required for creating events, coaching profiles, and shops. Get a verified Nostr address (like name@domain.com) to build trust. Required for creating events, coaching profiles, and shops.
</p> </p>
<div className="rounded-2xl border border-[#ccff00]/25 bg-[#ccff00]/5 p-4 space-y-2">
<p className="text-sm font-bold text-white">Free from SkateSpots</p>
<p className="text-xs text-muted-foreground">
Claim a handle like{' '}
<span className="font-mono text-foreground">you@{SKATE_NIP05_DOMAIN}</span> — no signup,
no cost, no domain to run. Claim it in the Profile tab, then save.
</p>
<Link to="/settings?section=profile">
<Button
size="sm"
variant="outline"
className="border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10"
>
Claim in Profile
</Button>
</Link>
</div>
<div className="rounded-2xl border border-white/8 overflow-hidden divide-y divide-white/5"> <div className="rounded-2xl border border-white/8 overflow-hidden divide-y divide-white/5">
{providers.map((p) => ( {providers.map((p) => (
<a <a