From 381597c96faf70ec7917bf8cb7548b82875cff56 Mon Sep 17 00:00:00 2001
From: SatsFaucet
Date: Sun, 1 Mar 2026 01:24:51 +0100
Subject: [PATCH] Add Swagger docs at /docs and /openapi.json; frontend and
backend updates
Made-with: Cursor
---
backend/package-lock.json | 79 +++++++++-
backend/package.json | 6 +-
backend/src/index.ts | 10 ++
backend/src/middleware/nip98.ts | 1 +
backend/src/openapi/base.ts | 20 +++
backend/src/openapi/index.ts | 41 ++++++
backend/src/openapi/paths/auth.ts | 92 ++++++++++++
backend/src/openapi/paths/claim.ts | 148 +++++++++++++++++++
backend/src/openapi/paths/public.ts | 130 +++++++++++++++++
backend/src/openapi/paths/user.ts | 48 ++++++
backend/src/openapi/schemas.ts | 153 ++++++++++++++++++++
backend/src/services/eligibility.ts | 3 +
backend/src/services/nostr.ts | 65 +++++++--
frontend/.env.example | 3 +
frontend/src/App.tsx | 8 +-
frontend/src/components/ClaimWizard.tsx | 144 +++++++++++++-----
frontend/src/components/EligibilityStep.tsx | 32 +---
frontend/src/components/Header.tsx | 120 +++++++++++++--
frontend/src/hooks/useNostrProfile.ts | 62 ++++++++
frontend/src/styles/global.css | 147 +++++++++++++++++++
20 files changed, 1214 insertions(+), 98 deletions(-)
create mode 100644 backend/src/openapi/base.ts
create mode 100644 backend/src/openapi/index.ts
create mode 100644 backend/src/openapi/paths/auth.ts
create mode 100644 backend/src/openapi/paths/claim.ts
create mode 100644 backend/src/openapi/paths/public.ts
create mode 100644 backend/src/openapi/paths/user.ts
create mode 100644 backend/src/openapi/schemas.ts
create mode 100644 frontend/src/hooks/useNostrProfile.ts
diff --git a/backend/package-lock.json b/backend/package-lock.json
index b72e3b8..2434165 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -15,7 +15,9 @@
"express-rate-limit": "^7.4.1",
"nostr-tools": "^2.4.4",
"pg": "^8.13.1",
- "uuid": "^10.0.0"
+ "swagger-ui-express": "^5.0.1",
+ "uuid": "^10.0.0",
+ "ws": "^8.19.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.11",
@@ -23,7 +25,9 @@
"@types/express": "^4.17.21",
"@types/node": "^22.9.0",
"@types/pg": "^8.11.10",
+ "@types/swagger-ui-express": "^4.1.8",
"@types/uuid": "^10.0.0",
+ "@types/ws": "^8.18.1",
"tsx": "^4.19.2",
"typescript": "^5.6.3"
}
@@ -509,6 +513,13 @@
"url": "https://paulmillr.com/funding/"
}
},
+ "node_modules/@scarf/scarf": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
+ "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0"
+ },
"node_modules/@scure/base": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
@@ -695,6 +706,17 @@
"@types/node": "*"
}
},
+ "node_modules/@types/swagger-ui-express": {
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz",
+ "integrity": "sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/express": "*",
+ "@types/serve-static": "*"
+ }
+ },
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
@@ -702,6 +724,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/ws": {
+ "version": "8.18.1",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -2135,6 +2167,30 @@
"node": ">=0.10.0"
}
},
+ "node_modules/swagger-ui-dist": {
+ "version": "5.32.0",
+ "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.0.tgz",
+ "integrity": "sha512-nKZB0OuDvacB0s/lC2gbge+RigYvGRGpLLMWMFxaTUwfM+CfndVk9Th2IaTinqXiz6Mn26GK2zriCpv6/+5m3Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@scarf/scarf": "=1.4.0"
+ }
+ },
+ "node_modules/swagger-ui-express": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz",
+ "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==",
+ "license": "MIT",
+ "dependencies": {
+ "swagger-ui-dist": ">=5.0.0"
+ },
+ "engines": {
+ "node": ">= v0.10.32"
+ },
+ "peerDependencies": {
+ "express": ">=4.0.0 || >=5.0.0-beta"
+ }
+ },
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
@@ -2290,6 +2346,27 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
+ "node_modules/ws": {
+ "version": "8.19.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
+ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
diff --git a/backend/package.json b/backend/package.json
index 8722af2..009e127 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -17,7 +17,9 @@
"express-rate-limit": "^7.4.1",
"nostr-tools": "^2.4.4",
"pg": "^8.13.1",
- "uuid": "^10.0.0"
+ "swagger-ui-express": "^5.0.1",
+ "uuid": "^10.0.0",
+ "ws": "^8.19.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.11",
@@ -25,7 +27,9 @@
"@types/express": "^4.17.21",
"@types/node": "^22.9.0",
"@types/pg": "^8.11.10",
+ "@types/swagger-ui-express": "^4.1.8",
"@types/uuid": "^10.0.0",
+ "@types/ws": "^8.18.1",
"tsx": "^4.19.2",
"typescript": "^5.6.3"
}
diff --git a/backend/src/index.ts b/backend/src/index.ts
index d2f6e27..3c26f38 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -1,8 +1,14 @@
+import WebSocket from "ws";
+// @ts-expect-error Node 20 lacks global WebSocket; nostr-tools needs it
+globalThis.WebSocket = WebSocket;
+
import express from "express";
import cors from "cors";
import rateLimit from "express-rate-limit";
+import swaggerUi from "swagger-ui-express";
import { config } from "./config.js";
import { getDb } from "./db/index.js";
+import { buildOpenApiSpec } from "./openapi/index.js";
import { startLnbitsDepositSync } from "./services/syncLnbitsDeposits.js";
import publicRoutes from "./routes/public.js";
import authRoutes from "./routes/auth.js";
@@ -39,6 +45,10 @@ async function main() {
})
);
+ const openapiUrl = config.publicBasePath ? `/${config.publicBasePath}/openapi.json` : "/openapi.json";
+ app.get("/openapi.json", (_req, res) => res.json(buildOpenApiSpec()));
+ app.use("/docs", swaggerUi.serve, swaggerUi.setup(null, { swaggerUrl: openapiUrl }));
+
app.use("/", publicRoutes);
app.use("/auth", authRoutes);
app.use(
diff --git a/backend/src/middleware/nip98.ts b/backend/src/middleware/nip98.ts
index f551b42..a6bc780 100644
--- a/backend/src/middleware/nip98.ts
+++ b/backend/src/middleware/nip98.ts
@@ -93,6 +93,7 @@ export async function nip98Auth(req: Request, res: Response, next: NextFunction)
res.status(401).json({
code: "invalid_nip98",
message: "NIP-98 'u' tag does not match request URL.",
+ details: `signed=${u} server=${absoluteUrl}`,
});
return;
}
diff --git a/backend/src/openapi/base.ts b/backend/src/openapi/base.ts
new file mode 100644
index 0000000..f7e2b99
--- /dev/null
+++ b/backend/src/openapi/base.ts
@@ -0,0 +1,20 @@
+/** OpenAPI 3.0 base spec (info, servers, tags) */
+const base = {
+ openapi: "3.0.3",
+ info: {
+ title: "SatsFaucet API",
+ version: "1.0.0",
+ description:
+ "Bitcoin Lightning faucet API. Claim sats to your Lightning address after authenticating with Nostr (NIP-98 or npub).",
+ },
+ servers: [{ url: "/", description: "API server" }],
+ tags: [
+ { name: "Public", description: "Public endpoints (no auth)" },
+ { name: "Auth", description: "Authentication" },
+ { name: "Claim", description: "Claim sats flow" },
+ { name: "User", description: "User profile" },
+ ],
+ security: [],
+};
+
+export default base;
diff --git a/backend/src/openapi/index.ts b/backend/src/openapi/index.ts
new file mode 100644
index 0000000..ad6650d
--- /dev/null
+++ b/backend/src/openapi/index.ts
@@ -0,0 +1,41 @@
+import { config } from "../config.js";
+import base from "./base.js";
+import schemas from "./schemas.js";
+import publicPaths from "./paths/public.js";
+import authPaths from "./paths/auth.js";
+import claimPaths from "./paths/claim.js";
+import userPaths from "./paths/user.js";
+
+/** Build the full OpenAPI 3.0 spec by merging split files */
+export function buildOpenApiSpec(): Record {
+ const basePath = config.publicBasePath ? `/${config.publicBasePath.replace(/^\//, "")}` : "";
+ const serverUrl = basePath || "/";
+
+ return {
+ ...base,
+ servers: [{ url: serverUrl, description: "API server" }],
+ paths: {
+ ...publicPaths,
+ ...authPaths,
+ ...claimPaths,
+ ...userPaths,
+ },
+ components: {
+ schemas,
+ securitySchemes: {
+ Nostr: {
+ type: "http",
+ scheme: "Nostr",
+ description:
+ "NIP-98 HTTP Auth: base64-encoded signed Nostr event (kind 27235) with 'u' and 'method' tags matching the request URL and method.",
+ },
+ BearerAuth: {
+ type: "http",
+ scheme: "bearer",
+ bearerFormat: "JWT",
+ description: "JWT obtained from /auth/login or /auth/login-npub",
+ },
+ },
+ },
+ };
+}
diff --git a/backend/src/openapi/paths/auth.ts b/backend/src/openapi/paths/auth.ts
new file mode 100644
index 0000000..b385b7b
--- /dev/null
+++ b/backend/src/openapi/paths/auth.ts
@@ -0,0 +1,92 @@
+/** Auth API paths */
+const paths: Record> = {
+ "/auth/login": {
+ post: {
+ tags: ["Auth"],
+ summary: "Sign in with NIP-98",
+ description:
+ "Authenticate using NIP-98 (Nostr HTTP Auth). Send a signed kind 27235 event in the Authorization header. Returns a JWT for subsequent requests.",
+ security: [{ Nostr: [] }],
+ responses: {
+ "200": {
+ description: "Login success",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/LoginResponse" },
+ },
+ },
+ },
+ "401": {
+ description: "Invalid NIP-98 (missing, malformed, or failed verification)",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
+ },
+ },
+ },
+ },
+ },
+ },
+ "/auth/login-npub": {
+ post: {
+ tags: ["Auth"],
+ summary: "Sign in with npub",
+ description:
+ "Authenticate with just an npub (no signature). Limited: cannot change Lightning address; payouts use profile address only.",
+ requestBody: {
+ required: true,
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/LoginNpubRequest" },
+ },
+ },
+ },
+ responses: {
+ "200": {
+ description: "Login success",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/LoginResponse" },
+ },
+ },
+ },
+ "400": {
+ description: "Invalid or missing npub",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
+ },
+ },
+ },
+ },
+ },
+ },
+ "/auth/me": {
+ get: {
+ tags: ["Auth"],
+ summary: "Current user",
+ description: "Return current user from JWT (Bearer only). Used to restore session.",
+ security: [{ BearerAuth: [] }],
+ responses: {
+ "200": {
+ description: "OK",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/MeResponse" },
+ },
+ },
+ },
+ "401": {
+ description: "Missing or invalid Bearer token",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
+ },
+ },
+ },
+ },
+ },
+ },
+};
+
+export default paths;
diff --git a/backend/src/openapi/paths/claim.ts b/backend/src/openapi/paths/claim.ts
new file mode 100644
index 0000000..7860d26
--- /dev/null
+++ b/backend/src/openapi/paths/claim.ts
@@ -0,0 +1,148 @@
+/** Claim API paths (auth required: JWT or NIP-98) */
+const paths: Record> = {
+ "/claim/quote": {
+ post: {
+ tags: ["Claim"],
+ summary: "Get payout quote",
+ description:
+ "Get a quote for a payout. Requires JWT or NIP-98. If logged in with npub, lightning_address is taken from profile; otherwise provide it in the body.",
+ security: [{ BearerAuth: [] }, { Nostr: [] }],
+ requestBody: {
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/QuoteRequest" },
+ },
+ },
+ },
+ responses: {
+ "200": {
+ description: "Quote created",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/QuoteResponse" },
+ },
+ },
+ },
+ "400": {
+ description: "Invalid lightning_address or no profile address (npub login)",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
+ },
+ },
+ },
+ "401": {
+ description: "Unauthorized",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
+ },
+ },
+ },
+ "403": {
+ description: "Not eligible or address locked (npub)",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/EligibilityDeniedResponse" },
+ },
+ },
+ },
+ "429": {
+ description: "Rate limited",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/RateLimitedResponse" },
+ },
+ },
+ },
+ },
+ },
+ },
+ "/claim/confirm": {
+ post: {
+ tags: ["Claim"],
+ summary: "Confirm payout",
+ description:
+ "Confirm and execute the payout using a quote_id from /claim/quote. Pays to the Lightning address from the quote.",
+ security: [{ BearerAuth: [] }, { Nostr: [] }],
+ requestBody: {
+ required: true,
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/ConfirmRequest" },
+ },
+ },
+ },
+ responses: {
+ "200": {
+ description: "Payout success or already consumed",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/ConfirmResponse" },
+ },
+ },
+ },
+ "400": {
+ description: "Invalid quote_id or quote expired",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
+ },
+ },
+ },
+ "401": {
+ description: "Unauthorized",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
+ },
+ },
+ },
+ "403": {
+ description: "Quote does not belong to this pubkey",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
+ },
+ },
+ },
+ "404": {
+ description: "Quote not found or expired",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
+ },
+ },
+ },
+ "429": {
+ description: "Rate limited",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/RateLimitedResponse" },
+ },
+ },
+ },
+ "502": {
+ description: "Lightning payment failed",
+ content: {
+ "application/json": {
+ schema: {
+ allOf: [
+ { $ref: "#/components/schemas/ErrorResponse" },
+ {
+ properties: {
+ code: { example: "payout_failed" },
+ details: { type: "string" },
+ },
+ },
+ ],
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+};
+
+export default paths;
diff --git a/backend/src/openapi/paths/public.ts b/backend/src/openapi/paths/public.ts
new file mode 100644
index 0000000..164e56d
--- /dev/null
+++ b/backend/src/openapi/paths/public.ts
@@ -0,0 +1,130 @@
+/** Public API paths (no auth) */
+const paths: Record> = {
+ "/health": {
+ get: {
+ tags: ["Public"],
+ summary: "Health check",
+ description: "Returns API health status.",
+ responses: {
+ "200": {
+ description: "OK",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/HealthResponse" },
+ },
+ },
+ },
+ },
+ },
+ },
+ "/config": {
+ get: {
+ tags: ["Public"],
+ summary: "Get faucet config",
+ description: "Returns public faucet configuration (enabled, limits, eligibility thresholds).",
+ responses: {
+ "200": {
+ description: "OK",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/ConfigResponse" },
+ },
+ },
+ },
+ },
+ },
+ },
+ "/stats": {
+ get: {
+ tags: ["Public"],
+ summary: "Get stats",
+ description: "Returns faucet stats: balance, total paid, claims count, recent payouts and deposits.",
+ responses: {
+ "200": {
+ description: "OK",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/StatsResponse" },
+ },
+ },
+ },
+ "500": {
+ description: "Internal error",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
+ },
+ },
+ },
+ },
+ },
+ },
+ "/deposit": {
+ get: {
+ tags: ["Public"],
+ summary: "Get deposit info",
+ description: "Returns Lightning address and LNURLp for depositing to the faucet.",
+ responses: {
+ "200": {
+ description: "OK",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/DepositInfoResponse" },
+ },
+ },
+ },
+ },
+ },
+ },
+ "/deposit/redeem-cashu": {
+ post: {
+ tags: ["Public"],
+ summary: "Redeem Cashu token",
+ description: "Redeem a Cashu token to the faucet's Lightning address. Records deposit on success.",
+ requestBody: {
+ required: true,
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/CashuRedeemRequest" },
+ },
+ },
+ },
+ responses: {
+ "200": {
+ description: "Redeem success",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/CashuRedeemResponse" },
+ },
+ },
+ },
+ "400": {
+ description: "Invalid token or redeem failed",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/CashuRedeemResponse" },
+ },
+ },
+ },
+ "502": {
+ description: "Redeem API error",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
+ },
+ },
+ },
+ "503": {
+ description: "Deposit Lightning address not configured",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
+ },
+ },
+ },
+ },
+ },
+ },
+};
+
+export default paths;
diff --git a/backend/src/openapi/paths/user.ts b/backend/src/openapi/paths/user.ts
new file mode 100644
index 0000000..3bc5e03
--- /dev/null
+++ b/backend/src/openapi/paths/user.ts
@@ -0,0 +1,48 @@
+/** User API paths (auth required: JWT or NIP-98) */
+const paths: Record> = {
+ "/user/refresh-profile": {
+ post: {
+ tags: ["User"],
+ summary: "Refresh Nostr profile",
+ description:
+ "Fetch Nostr profile (kind 0) and return cached lightning_address and name. Pre-fills the frontend and stores in DB.",
+ security: [{ BearerAuth: [] }, { Nostr: [] }],
+ responses: {
+ "200": {
+ description: "Profile refreshed",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/RefreshProfileResponse" },
+ },
+ },
+ },
+ "401": {
+ description: "Unauthorized",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
+ },
+ },
+ },
+ "429": {
+ description: "Rate limited",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/RateLimitedResponse" },
+ },
+ },
+ },
+ "500": {
+ description: "Profile fetch failed",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
+ },
+ },
+ },
+ },
+ },
+ },
+};
+
+export default paths;
diff --git a/backend/src/openapi/schemas.ts b/backend/src/openapi/schemas.ts
new file mode 100644
index 0000000..c658914
--- /dev/null
+++ b/backend/src/openapi/schemas.ts
@@ -0,0 +1,153 @@
+/** Reusable OpenAPI component schemas */
+const schemas: Record; required?: string[] }> = {
+ ErrorResponse: {
+ type: "object",
+ properties: {
+ code: { type: "string", description: "Error code" },
+ message: { type: "string", description: "Human-readable message" },
+ details: { type: "string", description: "Additional details (optional)" },
+ },
+ required: ["code", "message"],
+ },
+ RateLimitedResponse: {
+ type: "object",
+ properties: {
+ code: { type: "string", example: "rate_limited" },
+ message: { type: "string", example: "Too many requests." },
+ },
+ required: ["code", "message"],
+ },
+ HealthResponse: {
+ type: "object",
+ properties: { status: { type: "string", example: "ok" } },
+ required: ["status"],
+ },
+ ConfigResponse: {
+ type: "object",
+ properties: {
+ faucetEnabled: { type: "boolean" },
+ emergencyStop: { type: "boolean" },
+ cooldownDays: { type: "integer" },
+ minAccountAgeDays: { type: "integer" },
+ minActivityScore: { type: "integer" },
+ faucetMinSats: { type: "integer" },
+ faucetMaxSats: { type: "integer" },
+ },
+ required: ["faucetEnabled", "emergencyStop", "cooldownDays", "minAccountAgeDays", "minActivityScore", "faucetMinSats", "faucetMaxSats"],
+ },
+ StatsResponse: {
+ type: "object",
+ properties: {
+ balanceSats: { type: "number" },
+ totalPaidSats: { type: "number" },
+ totalClaims: { type: "number" },
+ claimsLast24h: { type: "number" },
+ dailyBudgetSats: { type: "number" },
+ spentTodaySats: { type: "number" },
+ recentPayouts: { type: "array", items: { type: "object" } },
+ recentDeposits: { type: "array", items: { type: "object" } },
+ },
+ required: ["balanceSats", "totalPaidSats", "totalClaims", "claimsLast24h", "dailyBudgetSats", "spentTodaySats", "recentPayouts", "recentDeposits"],
+ },
+ DepositInfoResponse: {
+ type: "object",
+ properties: {
+ lightningAddress: { type: "string", nullable: true },
+ lnurlp: { type: "string", nullable: true },
+ },
+ required: ["lightningAddress", "lnurlp"],
+ },
+ CashuRedeemRequest: {
+ type: "object",
+ properties: { token: { type: "string", description: "Cashu token (cashuA... or cashuB...)" } },
+ required: ["token"],
+ },
+ CashuRedeemResponse: {
+ type: "object",
+ properties: {
+ success: { type: "boolean" },
+ paid: { type: "boolean" },
+ amount: { type: "number" },
+ invoiceAmount: { type: "number" },
+ netAmount: { type: "number" },
+ to: { type: "string" },
+ message: { type: "string" },
+ error: { type: "string" },
+ errorType: { type: "string" },
+ },
+ required: ["success"],
+ },
+ LoginNpubRequest: {
+ type: "object",
+ properties: { npub: { type: "string", description: "NIP-19 npub-encoded public key" } },
+ required: ["npub"],
+ },
+ LoginResponse: {
+ type: "object",
+ properties: {
+ token: { type: "string", description: "JWT for subsequent requests" },
+ pubkey: { type: "string" },
+ method: { type: "string", enum: ["nip98", "npub"] },
+ },
+ required: ["token", "pubkey", "method"],
+ },
+ MeResponse: {
+ type: "object",
+ properties: {
+ pubkey: { type: "string" },
+ method: { type: "string", enum: ["nip98", "npub"] },
+ },
+ required: ["pubkey", "method"],
+ },
+ QuoteRequest: {
+ type: "object",
+ properties: {
+ lightning_address: { type: "string", description: "user@domain (optional if npub login with profile address)" },
+ },
+ },
+ QuoteResponse: {
+ type: "object",
+ properties: {
+ quote_id: { type: "string" },
+ payout_sats: { type: "number" },
+ expires_at: { type: "number", description: "Unix timestamp" },
+ },
+ required: ["quote_id", "payout_sats", "expires_at"],
+ },
+ ConfirmRequest: {
+ type: "object",
+ properties: { quote_id: { type: "string" } },
+ required: ["quote_id"],
+ },
+ ConfirmResponse: {
+ type: "object",
+ properties: {
+ success: { type: "boolean" },
+ payout_sats: { type: "number" },
+ payment_hash: { type: "string" },
+ next_eligible_at: { type: "number", description: "Unix timestamp" },
+ already_consumed: { type: "boolean" },
+ message: { type: "string" },
+ },
+ required: ["success"],
+ },
+ RefreshProfileResponse: {
+ type: "object",
+ properties: {
+ lightning_address: { type: "string", nullable: true },
+ name: { type: "string", nullable: true },
+ },
+ required: ["lightning_address", "name"],
+ },
+ EligibilityDeniedResponse: {
+ type: "object",
+ properties: {
+ code: { type: "string" },
+ message: { type: "string" },
+ next_eligible_at: { type: "number", nullable: true },
+ },
+ required: ["code", "message"],
+ },
+};
+
+export default schemas;
diff --git a/backend/src/services/eligibility.ts b/backend/src/services/eligibility.ts
index 1bb6b6d..ee6cd24 100644
--- a/backend/src/services/eligibility.ts
+++ b/backend/src/services/eligibility.ts
@@ -96,7 +96,10 @@ export async function checkEligibility(pubkey: string, ipHash: string): Promise<
const profile = await fetchAndScorePubkey(pubkey);
const minAgeSec = config.minAccountAgeDays * SECONDS_PER_DAY;
const cutoff = now - minAgeSec;
+ console.log(`[eligibility] pubkey=${pubkey.slice(0, 8)}… firstSeen=${profile.nostrFirstSeenAt} cutoff=${cutoff} score=${profile.activityScore}`);
if (profile.nostrFirstSeenAt === null || profile.nostrFirstSeenAt > cutoff) {
+ const ageDays = profile.nostrFirstSeenAt ? Math.floor((now - profile.nostrFirstSeenAt) / SECONDS_PER_DAY) : "null";
+ console.log(`[eligibility] DENIED account_too_new: firstSeen age=${ageDays} days, required=${config.minAccountAgeDays}`);
return {
eligible: false,
denialCode: "account_too_new",
diff --git a/backend/src/services/nostr.ts b/backend/src/services/nostr.ts
index 7a99c7a..6e6bd5e 100644
--- a/backend/src/services/nostr.ts
+++ b/backend/src/services/nostr.ts
@@ -19,6 +19,27 @@ function withTimeout(promise: Promise, ms: number): Promise {
]);
}
+/**
+ * pool.get resolves as soon as ONE relay responds (or null if none do).
+ * Much faster than querySync which waits for EOSE from ALL relays.
+ */
+async function probeOldEvent(pubkey: string, untilTimestamp: number): Promise {
+ try {
+ const ev = await withTimeout(
+ pool.get(config.nostrRelays, {
+ kinds: [0, 1, 3],
+ authors: [pubkey],
+ until: untilTimestamp,
+ limit: 1,
+ }),
+ config.relayTimeoutMs
+ );
+ return ev ? ev.created_at : null;
+ } catch {
+ return null;
+ }
+}
+
/**
* Fetch events from relays in parallel (kinds 0, 1, 3), compute metrics, optionally cache.
* When forceRefreshProfile is true, always fetch from relays (skip cache) so kind 0 is parsed and lightning_address/name updated.
@@ -40,15 +61,22 @@ export async function fetchAndScorePubkey(pubkey: string, forceRefreshProfile =
};
}
- let events: { kind: number; created_at: number; content?: string; tags: string[][] }[] = [];
- try {
- const result = await withTimeout(
- pool.querySync(config.nostrRelays, { kinds: [0, 1, 3], authors: [pubkey], limit: config.maxEventsFetch }),
- config.relayTimeoutMs
- );
- events = Array.isArray(result) ? result : [];
- } catch (_) {
- // Timeout or relay error: use cache if any; otherwise upsert minimal user so /refresh-profile returns a row
+ // Run the main activity query and the account-age probe in parallel.
+ // The age probe uses pool.get (resolves on first relay hit) so it's fast.
+ const ageCutoff = nowSec - config.minAccountAgeDays * 86400;
+
+ const mainQueryPromise = withTimeout(
+ pool.querySync(config.nostrRelays, { kinds: [0, 1, 3], authors: [pubkey], limit: config.maxEventsFetch }),
+ config.relayTimeoutMs
+ ).then((r) => (Array.isArray(r) ? r : []))
+ .catch((): { kind: number; created_at: number; content?: string; tags: string[][] }[] => []);
+
+ const ageProbePromise = probeOldEvent(pubkey, ageCutoff);
+
+ const [events, oldEventTimestamp] = await Promise.all([mainQueryPromise, ageProbePromise]);
+
+ if (events.length === 0 && oldEventTimestamp === null) {
+ // Both queries failed or returned nothing
if (cached) {
return {
nostrFirstSeenAt: cached.nostr_first_seen_at,
@@ -83,10 +111,24 @@ export async function fetchAndScorePubkey(pubkey: string, forceRefreshProfile =
const kind1 = events.filter((e) => e.kind === 1);
const kind3 = events.filter((e) => e.kind === 3);
- const earliestCreatedAt = events.length
+ // Determine earliest known timestamp from all sources
+ let earliestCreatedAt: number | null = events.length
? Math.min(...events.map((e) => e.created_at))
: null;
+ if (oldEventTimestamp !== null) {
+ earliestCreatedAt = earliestCreatedAt === null
+ ? oldEventTimestamp
+ : Math.min(earliestCreatedAt, oldEventTimestamp);
+ }
+
+ // Never overwrite a known-older timestamp with a newer one
+ if (cached?.nostr_first_seen_at != null) {
+ earliestCreatedAt = earliestCreatedAt === null
+ ? cached.nostr_first_seen_at
+ : Math.min(earliestCreatedAt, cached.nostr_first_seen_at);
+ }
+
const lookbackSince = nowSec - config.activityLookbackDays * 86400;
const notesInLookback = kind1.filter((e) => e.created_at >= lookbackSince).length;
@@ -110,7 +152,6 @@ export async function fetchAndScorePubkey(pubkey: string, forceRefreshProfile =
if (kind0.length > 0 && kind0[0].content) {
try {
const meta = JSON.parse(kind0[0].content) as Record;
- // NIP-19 / common: lud16 is the Lightning address (user@domain). Fallbacks for other clients.
for (const key of ["lud16", "lightning", "ln_address", "nip05"] as const) {
const v = meta[key];
if (typeof v === "string") {
@@ -129,6 +170,8 @@ export async function fetchAndScorePubkey(pubkey: string, forceRefreshProfile =
const nostrFirstSeenAt = earliestCreatedAt;
const lastMetadataFetchAt = Math.floor(Date.now() / 1000);
+ console.log(`[nostr] pubkey=${pubkey.slice(0, 8)}… events=${events.length} ageProbe=${oldEventTimestamp} firstSeen=${nostrFirstSeenAt} score=${score}`);
+
await db.upsertUser({
pubkey,
nostr_first_seen_at: nostrFirstSeenAt,
diff --git a/frontend/.env.example b/frontend/.env.example
index 768805a..8cea093 100644
--- a/frontend/.env.example
+++ b/frontend/.env.example
@@ -1,3 +1,6 @@
# Backend API URL (required in dev when frontend runs on different port)
# Leave empty if frontend is served from same origin as API
VITE_API_URL=http://localhost:3001
+
+# Nostr relays for fetching user profile metadata (comma-separated)
+VITE_NOSTR_RELAYS=wss://relay.damus.io,wss://relay.nostr.band,wss://nos.lol
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 3bac0cf..4f91538 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -49,6 +49,12 @@ export default function App() {
setLoginMethod(pk ? (method ?? "nip98") : null);
}, []);
+ const handleLogout = useCallback(() => {
+ clearToken();
+ setPubkey(null);
+ setLoginMethod(null);
+ }, []);
+
const handleClaimSuccess = useCallback(() => {
setStatsRefetchTrigger((t) => t + 1);
}, []);
@@ -56,7 +62,7 @@ export default function App() {
return (
-
+
diff --git a/frontend/src/components/ClaimWizard.tsx b/frontend/src/components/ClaimWizard.tsx
index ba947e7..35a6185 100644
--- a/frontend/src/components/ClaimWizard.tsx
+++ b/frontend/src/components/ClaimWizard.tsx
@@ -1,11 +1,15 @@
-import { useState, useEffect, useRef, useMemo } from "react";
+import { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { postUserRefreshProfile, type UserProfile, type LoginMethod } from "../api";
import { useClaimFlow } from "../hooks/useClaimFlow";
+import { useNostrProfile } from "../hooks/useNostrProfile";
import { StepIndicator } from "./StepIndicator";
import { ConnectStep } from "./ConnectStep";
import { EligibilityStep } from "./EligibilityStep";
import { ConfirmStep } from "./ConfirmStep";
import { SuccessStep } from "./SuccessStep";
+import { ClaimDenialPanel } from "./ClaimDenialPanel";
+import { Modal } from "./Modal";
+import { ELIGIBILITY_PROGRESS_STEPS } from "../hooks/useClaimFlow";
const LIGHTNING_ADDRESS_REGEX = /^[^@]+@[^@]+$/;
@@ -13,16 +17,6 @@ function isValidLightningAddress(addr: string): boolean {
return LIGHTNING_ADDRESS_REGEX.test(addr.trim());
}
-function getWizardStep(
- hasPubkey: boolean,
- claimState: ReturnType["claimState"]
-): 1 | 2 | 3 | 4 {
- if (!hasPubkey) return 1;
- if (claimState === "success") return 4;
- if (claimState === "quote_ready" || claimState === "confirming" || claimState === "error") return 3;
- return 2;
-}
-
interface ClaimWizardProps {
pubkey: string | null;
loginMethod: LoginMethod | null;
@@ -34,41 +28,60 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces
const [profile, setProfile] = useState(null);
const [lightningAddress, setLightningAddress] = useState("");
const [lightningAddressTouched, setLightningAddressTouched] = useState(false);
+ const [claimModalOpen, setClaimModalOpen] = useState(false);
const wizardRef = useRef(null);
+ const autoCheckRef = useRef(null);
const claim = useClaimFlow();
+ const nostrProfile = useNostrProfile(pubkey);
- const currentStep = useMemo(
- () => getWizardStep(!!pubkey, claim.claimState),
- [pubkey, claim.claimState]
- );
+ const isConnected = !!pubkey;
+ const isBusy = claim.loading !== "idle";
+ const hasResult = !!claim.quote || !!claim.denial || !!claim.success || !!claim.confirmError;
useEffect(() => {
if (!pubkey) {
setProfile(null);
setLightningAddress("");
+ autoCheckRef.current = null;
return;
}
postUserRefreshProfile()
.then((p) => {
setProfile(p);
const addr = (p.lightning_address ?? "").trim();
- setLightningAddress(addr);
+ if (addr) setLightningAddress(addr);
})
.catch(() => setProfile(null));
}, [pubkey]);
useEffect(() => {
- if (currentStep === 2 && pubkey && wizardRef.current) {
- wizardRef.current.scrollIntoView({ behavior: "smooth", block: "nearest" });
+ if (!nostrProfile?.lud16 || !pubkey) return;
+ const relayAddr = nostrProfile.lud16.trim();
+ if (relayAddr && isValidLightningAddress(relayAddr)) {
+ setLightningAddress(relayAddr);
}
- }, [currentStep, pubkey]);
+ }, [nostrProfile, pubkey]);
useEffect(() => {
- if (currentStep === 4 && wizardRef.current) {
- wizardRef.current.scrollIntoView({ behavior: "smooth", block: "nearest" });
+ if (
+ !pubkey ||
+ autoCheckRef.current === pubkey ||
+ claim.loading !== "idle" ||
+ claim.claimState !== "connected_idle"
+ ) return;
+ const addr = lightningAddress.trim();
+ if (!addr || !isValidLightningAddress(addr)) return;
+ autoCheckRef.current = pubkey;
+ setClaimModalOpen(true);
+ claim.checkEligibility(addr);
+ }, [pubkey, lightningAddress, claim.loading, claim.claimState, claim.checkEligibility]);
+
+ useEffect(() => {
+ if (isBusy || hasResult) {
+ setClaimModalOpen(true);
}
- }, [currentStep]);
+ }, [isBusy, hasResult]);
const handleDisconnect = () => {
onPubkeyChange(null);
@@ -77,10 +90,12 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces
claim.resetSuccess();
claim.clearDenial();
claim.clearConfirmError();
+ setClaimModalOpen(false);
};
const handleDone = () => {
claim.resetSuccess();
+ setClaimModalOpen(false);
onClaimSuccess?.();
};
@@ -89,26 +104,59 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces
claim.clearDenial();
claim.clearConfirmError();
setLightningAddressTouched(false);
- // Stay on step 2 (eligibility)
+ setClaimModalOpen(false);
};
const handleCheckEligibility = () => {
+ setClaimModalOpen(true);
claim.checkEligibility(lightningAddress);
};
const handleCancelQuote = () => {
claim.cancelQuote();
claim.clearConfirmError();
+ setClaimModalOpen(false);
+ };
+
+ const handleCloseModal = useCallback(() => {
+ if (claim.loading !== "idle") return;
+ claim.cancelQuote();
+ claim.clearDenial();
+ claim.clearConfirmError();
+ claim.resetSuccess();
+ setClaimModalOpen(false);
+ }, [claim]);
+
+ const handleDismissDenial = () => {
+ claim.clearDenial();
+ setClaimModalOpen(false);
+ };
+
+ const handleCheckAgain = () => {
+ claim.clearDenial();
+ setClaimModalOpen(false);
};
const lightningAddressInvalid =
lightningAddressTouched && lightningAddress.trim() !== "" && !isValidLightningAddress(lightningAddress);
const fromProfile =
- Boolean(profile?.lightning_address) &&
- lightningAddress.trim() === (profile?.lightning_address ?? "").trim();
+ (Boolean(profile?.lightning_address) &&
+ lightningAddress.trim() === (profile?.lightning_address ?? "").trim()) ||
+ (Boolean(nostrProfile?.lud16) &&
+ lightningAddress.trim() === (nostrProfile?.lud16 ?? "").trim());
const quoteExpired =
claim.quote != null && claim.quote.expires_at <= Math.floor(Date.now() / 1000);
+ const modalTitle = useMemo(() => {
+ if (claim.success) return "Sats sent!";
+ if (claim.confirmError) return "Something went wrong";
+ if (claim.loading === "confirm") return "Sending sats…";
+ if (claim.quote) return "Confirm payout";
+ if (claim.denial) return "Not eligible";
+ if (claim.loading === "quote") return "Checking eligibility";
+ return "Claim";
+ }, [claim.success, claim.confirmError, claim.loading, claim.quote, claim.denial]);
+
return (
@@ -126,20 +174,18 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces
)}
-
+
- {currentStep === 1 && (
+ {!isConnected ? (
onPubkeyChange(pk, method)}
onDisconnect={handleDisconnect}
/>
- )}
-
- {currentStep === 2 && (
+ ) : (
{
- claim.clearDenial();
- }}
+ />
+ )}
+
+
+
+
+
+ {claim.loading === "quote" && (
+
+
+
+ {ELIGIBILITY_PROGRESS_STEPS[claim.eligibilityProgressStep ?? 0]}
+
+
+ )}
+
+ {claim.denial && (
+
)}
- {currentStep === 3 && claim.quote && (
+ {(claim.claimState === "quote_ready" || claim.claimState === "confirming" || claim.claimState === "error") && claim.quote && (
)}
- {currentStep === 4 && claim.success && (
+ {claim.success && (
)}
-
+
);
}
diff --git a/frontend/src/components/EligibilityStep.tsx b/frontend/src/components/EligibilityStep.tsx
index 2f15f86..2ede70f 100644
--- a/frontend/src/components/EligibilityStep.tsx
+++ b/frontend/src/components/EligibilityStep.tsx
@@ -1,7 +1,4 @@
import { useState } from "react";
-import { ClaimDenialPanel } from "./ClaimDenialPanel";
-import { ELIGIBILITY_PROGRESS_STEPS } from "../hooks/useClaimFlow";
-import type { DenialState } from "../hooks/useClaimFlow";
interface EligibilityStepProps {
lightningAddress: string;
@@ -12,11 +9,7 @@ interface EligibilityStepProps {
fromProfile: boolean;
readOnly?: boolean;
loading: boolean;
- eligibilityProgressStep: number | null;
- denial: DenialState | null;
onCheckEligibility: () => void;
- onClearDenial: () => void;
- onCheckAgain?: () => void;
}
const LIGHTNING_ADDRESS_REGEX = /^[^@]+@[^@]+$/;
@@ -30,11 +23,7 @@ export function EligibilityStep({
fromProfile,
readOnly,
loading,
- eligibilityProgressStep,
- denial,
onCheckEligibility,
- onClearDenial,
- onCheckAgain,
}: EligibilityStepProps) {
const [editing, setEditing] = useState(false);
const canCheck = !loading && lightningAddress.trim() !== "" && LIGHTNING_ADDRESS_REGEX.test(lightningAddress.trim());
@@ -94,14 +83,7 @@ export function EligibilityStep({
)}
- {noAddressForNpub ? null : loading ? (
-
-
-
- {ELIGIBILITY_PROGRESS_STEPS[eligibilityProgressStep ?? 0]}
-
-
- ) : (
+ {!noAddressForNpub && (
)}
-
- {denial && (
-
-
-
- )}
);
}
diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx
index 37f301c..72a3ca2 100644
--- a/frontend/src/components/Header.tsx
+++ b/frontend/src/components/Header.tsx
@@ -1,7 +1,50 @@
+import { useState, useRef, useEffect, useCallback } from "react";
import { Link, useLocation } from "react-router-dom";
+import { useNostrProfile } from "../hooks/useNostrProfile";
+import { nip19 } from "nostr-tools";
-export function Header() {
+interface HeaderProps {
+ pubkey: string | null;
+ onLogout?: () => void;
+}
+
+function truncatedNpub(pubkey: string): string {
+ const npub = nip19.npubEncode(pubkey);
+ return npub.slice(0, 12) + "..." + npub.slice(-4);
+}
+
+export function Header({ pubkey, onLogout }: HeaderProps) {
const location = useLocation();
+ const profile = useNostrProfile(pubkey);
+ const [menuOpen, setMenuOpen] = useState(false);
+ const menuRef = useRef(null);
+
+ const displayName = profile?.display_name || profile?.name || (pubkey ? truncatedNpub(pubkey) : null);
+
+ const handleToggle = useCallback(() => setMenuOpen((o) => !o), []);
+
+ useEffect(() => {
+ if (!menuOpen) return;
+ function onClickOutside(e: MouseEvent) {
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
+ setMenuOpen(false);
+ }
+ }
+ function onEscape(e: KeyboardEvent) {
+ if (e.key === "Escape") setMenuOpen(false);
+ }
+ document.addEventListener("mousedown", onClickOutside);
+ document.addEventListener("keydown", onEscape);
+ return () => {
+ document.removeEventListener("mousedown", onClickOutside);
+ document.removeEventListener("keydown", onEscape);
+ };
+ }, [menuOpen]);
+
+ const handleLogout = () => {
+ setMenuOpen(false);
+ onLogout?.();
+ };
return (
@@ -9,20 +52,67 @@ export function Header() {
Sats Faucet
-
+
+
+ {pubkey && (
+
+
+ {menuOpen && (
+
+
+ {profile?.display_name || profile?.name || "Nostr User"}
+ {truncatedNpub(pubkey)}
+
+
+
+
+ )}
+
+ )}
+
);
diff --git a/frontend/src/hooks/useNostrProfile.ts b/frontend/src/hooks/useNostrProfile.ts
new file mode 100644
index 0000000..0060ed6
--- /dev/null
+++ b/frontend/src/hooks/useNostrProfile.ts
@@ -0,0 +1,62 @@
+import { useState, useEffect, useRef } from "react";
+import { SimplePool } from "nostr-tools";
+
+export interface NostrProfile {
+ name?: string;
+ display_name?: string;
+ picture?: string;
+ about?: string;
+ nip05?: string;
+ lud16?: string;
+}
+
+const RELAYS = (import.meta.env.VITE_NOSTR_RELAYS as string || "wss://relay.damus.io,wss://nos.lol")
+ .split(",")
+ .map((r: string) => r.trim())
+ .filter(Boolean);
+
+const cache = new Map();
+
+export function useNostrProfile(pubkey: string | null): NostrProfile | null {
+ const [profile, setProfile] = useState(() =>
+ pubkey ? cache.get(pubkey) ?? null : null,
+ );
+ const poolRef = useRef(null);
+
+ useEffect(() => {
+ if (!pubkey) {
+ setProfile(null);
+ return;
+ }
+
+ const cached = cache.get(pubkey);
+ if (cached) {
+ setProfile(cached);
+ return;
+ }
+
+ const pool = new SimplePool();
+ poolRef.current = pool;
+ let cancelled = false;
+
+ pool
+ .get(RELAYS, { kinds: [0], authors: [pubkey], limit: 1 })
+ .then((ev) => {
+ if (cancelled || !ev) return;
+ try {
+ const meta = JSON.parse(ev.content) as NostrProfile;
+ cache.set(pubkey, meta);
+ setProfile(meta);
+ } catch { /* malformed content */ }
+ })
+ .catch(() => {});
+
+ return () => {
+ cancelled = true;
+ pool.close(RELAYS);
+ poolRef.current = null;
+ };
+ }, [pubkey]);
+
+ return profile;
+}
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index bceb98e..353bcf2 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -102,6 +102,149 @@ body {
background: rgba(249, 115, 22, 0.1);
}
+/* Header layout: right side groups nav + user */
+.site-header-right {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+.header-user {
+ position: relative;
+ padding-left: 16px;
+ border-left: 1px solid var(--border);
+}
+.header-user-trigger {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 4px 8px;
+ border-radius: 8px;
+ transition: background 0.15s;
+ color: var(--text);
+}
+.header-user-trigger:hover {
+ background: var(--bg-card-hover);
+}
+.header-user-avatar {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ object-fit: cover;
+ flex-shrink: 0;
+ border: 2px solid var(--border);
+}
+.header-user-avatar--placeholder {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--bg-card-hover);
+ color: var(--text-muted);
+ font-size: 14px;
+ font-weight: 600;
+}
+.header-user-name {
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--text);
+ max-width: 160px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.header-user-chevron {
+ color: var(--text-muted);
+ transition: transform 0.2s;
+ flex-shrink: 0;
+}
+.header-user-chevron.open {
+ transform: rotate(180deg);
+}
+
+/* User dropdown menu */
+.header-user-menu {
+ position: absolute;
+ top: calc(100% + 8px);
+ right: 0;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
+ min-width: 200px;
+ z-index: 100;
+ overflow: hidden;
+ animation: menu-in 0.15s ease-out;
+}
+@keyframes menu-in {
+ from { opacity: 0; transform: translateY(-4px) scale(0.98); }
+ to { opacity: 1; transform: translateY(0) scale(1); }
+}
+.header-user-menu-info {
+ padding: 12px 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+.header-user-menu-name {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text);
+}
+.header-user-menu-npub {
+ font-size: 12px;
+ color: var(--text-muted);
+ font-family: monospace;
+}
+.header-user-menu-divider {
+ height: 1px;
+ background: var(--border);
+}
+.header-user-menu-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ padding: 10px 16px;
+ background: none;
+ border: none;
+ color: var(--text-muted);
+ font-size: 14px;
+ cursor: pointer;
+ transition: background 0.15s, color 0.15s;
+}
+.header-user-menu-item:hover {
+ background: var(--bg-card-hover);
+ color: var(--error);
+}
+
+/* Claim modal body */
+.claim-modal-body {
+ padding: 24px;
+}
+.claim-modal-body .claim-wizard-step {
+ padding: 0;
+}
+.claim-modal-body .claim-denial-panel {
+ margin: 0;
+ border: none;
+ box-shadow: none;
+ background: transparent;
+ padding: 0;
+}
+.claim-modal-progress {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 16px;
+ padding: 24px 0;
+}
+.claim-modal-progress-text {
+ font-size: 14px;
+ color: var(--text-muted);
+}
+
.topbar {
background: linear-gradient(90deg, var(--accent) 0%, #fb923c 100%);
height: 4px;
@@ -2221,6 +2364,10 @@ h1 {
.site-header { padding: 0 16px; }
.site-header-inner { height: 52px; }
.site-nav-link { padding: 8px 12px; font-size: 13px; }
+ .header-user-name { display: none; }
+ .header-user-chevron { display: none; }
+ .header-user { padding-left: 12px; }
+ .header-user-trigger { gap: 0; padding: 4px; }
.site-footer { padding: 20px 16px 24px; }
.site-footer-nav { flex-wrap: wrap; justify-content: center; gap: 16px; }