Add Swagger docs at /docs and /openapi.json; frontend and backend updates
Made-with: Cursor
This commit is contained in:
79
backend/package-lock.json
generated
79
backend/package-lock.json
generated
@@ -15,7 +15,9 @@
|
|||||||
"express-rate-limit": "^7.4.1",
|
"express-rate-limit": "^7.4.1",
|
||||||
"nostr-tools": "^2.4.4",
|
"nostr-tools": "^2.4.4",
|
||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
"uuid": "^10.0.0"
|
"swagger-ui-express": "^5.0.1",
|
||||||
|
"uuid": "^10.0.0",
|
||||||
|
"ws": "^8.19.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.11",
|
"@types/better-sqlite3": "^7.6.11",
|
||||||
@@ -23,7 +25,9 @@
|
|||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/node": "^22.9.0",
|
"@types/node": "^22.9.0",
|
||||||
"@types/pg": "^8.11.10",
|
"@types/pg": "^8.11.10",
|
||||||
|
"@types/swagger-ui-express": "^4.1.8",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.6.3"
|
||||||
}
|
}
|
||||||
@@ -509,6 +513,13 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"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": {
|
"node_modules/@scure/base": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
|
||||||
@@ -695,6 +706,17 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/uuid": {
|
||||||
"version": "10.0.0",
|
"version": "10.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||||
@@ -702,6 +724,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/accepts": {
|
||||||
"version": "1.3.8",
|
"version": "1.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
@@ -2135,6 +2167,30 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/tar-fs": {
|
||||||
"version": "2.1.4",
|
"version": "2.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||||
@@ -2290,6 +2346,27 @@
|
|||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|||||||
@@ -17,7 +17,9 @@
|
|||||||
"express-rate-limit": "^7.4.1",
|
"express-rate-limit": "^7.4.1",
|
||||||
"nostr-tools": "^2.4.4",
|
"nostr-tools": "^2.4.4",
|
||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
"uuid": "^10.0.0"
|
"swagger-ui-express": "^5.0.1",
|
||||||
|
"uuid": "^10.0.0",
|
||||||
|
"ws": "^8.19.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.11",
|
"@types/better-sqlite3": "^7.6.11",
|
||||||
@@ -25,7 +27,9 @@
|
|||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/node": "^22.9.0",
|
"@types/node": "^22.9.0",
|
||||||
"@types/pg": "^8.11.10",
|
"@types/pg": "^8.11.10",
|
||||||
|
"@types/swagger-ui-express": "^4.1.8",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.6.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 express from "express";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import rateLimit from "express-rate-limit";
|
import rateLimit from "express-rate-limit";
|
||||||
|
import swaggerUi from "swagger-ui-express";
|
||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
import { getDb } from "./db/index.js";
|
import { getDb } from "./db/index.js";
|
||||||
|
import { buildOpenApiSpec } from "./openapi/index.js";
|
||||||
import { startLnbitsDepositSync } from "./services/syncLnbitsDeposits.js";
|
import { startLnbitsDepositSync } from "./services/syncLnbitsDeposits.js";
|
||||||
import publicRoutes from "./routes/public.js";
|
import publicRoutes from "./routes/public.js";
|
||||||
import authRoutes from "./routes/auth.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("/", publicRoutes);
|
||||||
app.use("/auth", authRoutes);
|
app.use("/auth", authRoutes);
|
||||||
app.use(
|
app.use(
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export async function nip98Auth(req: Request, res: Response, next: NextFunction)
|
|||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
code: "invalid_nip98",
|
code: "invalid_nip98",
|
||||||
message: "NIP-98 'u' tag does not match request URL.",
|
message: "NIP-98 'u' tag does not match request URL.",
|
||||||
|
details: `signed=${u} server=${absoluteUrl}`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
20
backend/src/openapi/base.ts
Normal file
20
backend/src/openapi/base.ts
Normal file
@@ -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;
|
||||||
41
backend/src/openapi/index.ts
Normal file
41
backend/src/openapi/index.ts
Normal file
@@ -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<string, unknown> {
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
92
backend/src/openapi/paths/auth.ts
Normal file
92
backend/src/openapi/paths/auth.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/** Auth API paths */
|
||||||
|
const paths: Record<string, Record<string, unknown>> = {
|
||||||
|
"/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;
|
||||||
148
backend/src/openapi/paths/claim.ts
Normal file
148
backend/src/openapi/paths/claim.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
/** Claim API paths (auth required: JWT or NIP-98) */
|
||||||
|
const paths: Record<string, Record<string, unknown>> = {
|
||||||
|
"/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;
|
||||||
130
backend/src/openapi/paths/public.ts
Normal file
130
backend/src/openapi/paths/public.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/** Public API paths (no auth) */
|
||||||
|
const paths: Record<string, Record<string, unknown>> = {
|
||||||
|
"/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;
|
||||||
48
backend/src/openapi/paths/user.ts
Normal file
48
backend/src/openapi/paths/user.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/** User API paths (auth required: JWT or NIP-98) */
|
||||||
|
const paths: Record<string, Record<string, unknown>> = {
|
||||||
|
"/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;
|
||||||
153
backend/src/openapi/schemas.ts
Normal file
153
backend/src/openapi/schemas.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
/** Reusable OpenAPI component schemas */
|
||||||
|
const schemas: Record<string, { type: string; properties?: Record<string, unknown>; 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;
|
||||||
@@ -96,7 +96,10 @@ export async function checkEligibility(pubkey: string, ipHash: string): Promise<
|
|||||||
const profile = await fetchAndScorePubkey(pubkey);
|
const profile = await fetchAndScorePubkey(pubkey);
|
||||||
const minAgeSec = config.minAccountAgeDays * SECONDS_PER_DAY;
|
const minAgeSec = config.minAccountAgeDays * SECONDS_PER_DAY;
|
||||||
const cutoff = now - minAgeSec;
|
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) {
|
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 {
|
return {
|
||||||
eligible: false,
|
eligible: false,
|
||||||
denialCode: "account_too_new",
|
denialCode: "account_too_new",
|
||||||
|
|||||||
@@ -19,6 +19,27 @@ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<number | null> {
|
||||||
|
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.
|
* 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.
|
* 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[][] }[] = [];
|
// Run the main activity query and the account-age probe in parallel.
|
||||||
try {
|
// The age probe uses pool.get (resolves on first relay hit) so it's fast.
|
||||||
const result = await withTimeout(
|
const ageCutoff = nowSec - config.minAccountAgeDays * 86400;
|
||||||
|
|
||||||
|
const mainQueryPromise = withTimeout(
|
||||||
pool.querySync(config.nostrRelays, { kinds: [0, 1, 3], authors: [pubkey], limit: config.maxEventsFetch }),
|
pool.querySync(config.nostrRelays, { kinds: [0, 1, 3], authors: [pubkey], limit: config.maxEventsFetch }),
|
||||||
config.relayTimeoutMs
|
config.relayTimeoutMs
|
||||||
);
|
).then((r) => (Array.isArray(r) ? r : []))
|
||||||
events = Array.isArray(result) ? result : [];
|
.catch((): { kind: number; created_at: number; content?: string; tags: string[][] }[] => []);
|
||||||
} catch (_) {
|
|
||||||
// Timeout or relay error: use cache if any; otherwise upsert minimal user so /refresh-profile returns a row
|
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) {
|
if (cached) {
|
||||||
return {
|
return {
|
||||||
nostrFirstSeenAt: cached.nostr_first_seen_at,
|
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 kind1 = events.filter((e) => e.kind === 1);
|
||||||
const kind3 = events.filter((e) => e.kind === 3);
|
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))
|
? Math.min(...events.map((e) => e.created_at))
|
||||||
: null;
|
: 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 lookbackSince = nowSec - config.activityLookbackDays * 86400;
|
||||||
const notesInLookback = kind1.filter((e) => e.created_at >= lookbackSince).length;
|
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) {
|
if (kind0.length > 0 && kind0[0].content) {
|
||||||
try {
|
try {
|
||||||
const meta = JSON.parse(kind0[0].content) as Record<string, unknown>;
|
const meta = JSON.parse(kind0[0].content) as Record<string, unknown>;
|
||||||
// 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) {
|
for (const key of ["lud16", "lightning", "ln_address", "nip05"] as const) {
|
||||||
const v = meta[key];
|
const v = meta[key];
|
||||||
if (typeof v === "string") {
|
if (typeof v === "string") {
|
||||||
@@ -129,6 +170,8 @@ export async function fetchAndScorePubkey(pubkey: string, forceRefreshProfile =
|
|||||||
const nostrFirstSeenAt = earliestCreatedAt;
|
const nostrFirstSeenAt = earliestCreatedAt;
|
||||||
const lastMetadataFetchAt = Math.floor(Date.now() / 1000);
|
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({
|
await db.upsertUser({
|
||||||
pubkey,
|
pubkey,
|
||||||
nostr_first_seen_at: nostrFirstSeenAt,
|
nostr_first_seen_at: nostrFirstSeenAt,
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
# Backend API URL (required in dev when frontend runs on different port)
|
# Backend API URL (required in dev when frontend runs on different port)
|
||||||
# Leave empty if frontend is served from same origin as API
|
# Leave empty if frontend is served from same origin as API
|
||||||
VITE_API_URL=http://localhost:3001
|
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
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ export default function App() {
|
|||||||
setLoginMethod(pk ? (method ?? "nip98") : null);
|
setLoginMethod(pk ? (method ?? "nip98") : null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleLogout = useCallback(() => {
|
||||||
|
clearToken();
|
||||||
|
setPubkey(null);
|
||||||
|
setLoginMethod(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleClaimSuccess = useCallback(() => {
|
const handleClaimSuccess = useCallback(() => {
|
||||||
setStatsRefetchTrigger((t) => t + 1);
|
setStatsRefetchTrigger((t) => t + 1);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -56,7 +62,7 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<Header />
|
<Header pubkey={pubkey} onLogout={handleLogout} />
|
||||||
<div className="topbar" />
|
<div className="topbar" />
|
||||||
<div className="app-body">
|
<div className="app-body">
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|||||||
@@ -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 { postUserRefreshProfile, type UserProfile, type LoginMethod } from "../api";
|
||||||
import { useClaimFlow } from "../hooks/useClaimFlow";
|
import { useClaimFlow } from "../hooks/useClaimFlow";
|
||||||
|
import { useNostrProfile } from "../hooks/useNostrProfile";
|
||||||
import { StepIndicator } from "./StepIndicator";
|
import { StepIndicator } from "./StepIndicator";
|
||||||
import { ConnectStep } from "./ConnectStep";
|
import { ConnectStep } from "./ConnectStep";
|
||||||
import { EligibilityStep } from "./EligibilityStep";
|
import { EligibilityStep } from "./EligibilityStep";
|
||||||
import { ConfirmStep } from "./ConfirmStep";
|
import { ConfirmStep } from "./ConfirmStep";
|
||||||
import { SuccessStep } from "./SuccessStep";
|
import { SuccessStep } from "./SuccessStep";
|
||||||
|
import { ClaimDenialPanel } from "./ClaimDenialPanel";
|
||||||
|
import { Modal } from "./Modal";
|
||||||
|
import { ELIGIBILITY_PROGRESS_STEPS } from "../hooks/useClaimFlow";
|
||||||
|
|
||||||
const LIGHTNING_ADDRESS_REGEX = /^[^@]+@[^@]+$/;
|
const LIGHTNING_ADDRESS_REGEX = /^[^@]+@[^@]+$/;
|
||||||
|
|
||||||
@@ -13,16 +17,6 @@ function isValidLightningAddress(addr: string): boolean {
|
|||||||
return LIGHTNING_ADDRESS_REGEX.test(addr.trim());
|
return LIGHTNING_ADDRESS_REGEX.test(addr.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWizardStep(
|
|
||||||
hasPubkey: boolean,
|
|
||||||
claimState: ReturnType<typeof useClaimFlow>["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 {
|
interface ClaimWizardProps {
|
||||||
pubkey: string | null;
|
pubkey: string | null;
|
||||||
loginMethod: LoginMethod | null;
|
loginMethod: LoginMethod | null;
|
||||||
@@ -34,41 +28,60 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces
|
|||||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||||
const [lightningAddress, setLightningAddress] = useState("");
|
const [lightningAddress, setLightningAddress] = useState("");
|
||||||
const [lightningAddressTouched, setLightningAddressTouched] = useState(false);
|
const [lightningAddressTouched, setLightningAddressTouched] = useState(false);
|
||||||
|
const [claimModalOpen, setClaimModalOpen] = useState(false);
|
||||||
const wizardRef = useRef<HTMLDivElement>(null);
|
const wizardRef = useRef<HTMLDivElement>(null);
|
||||||
|
const autoCheckRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const claim = useClaimFlow();
|
const claim = useClaimFlow();
|
||||||
|
const nostrProfile = useNostrProfile(pubkey);
|
||||||
|
|
||||||
const currentStep = useMemo(
|
const isConnected = !!pubkey;
|
||||||
() => getWizardStep(!!pubkey, claim.claimState),
|
const isBusy = claim.loading !== "idle";
|
||||||
[pubkey, claim.claimState]
|
const hasResult = !!claim.quote || !!claim.denial || !!claim.success || !!claim.confirmError;
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pubkey) {
|
if (!pubkey) {
|
||||||
setProfile(null);
|
setProfile(null);
|
||||||
setLightningAddress("");
|
setLightningAddress("");
|
||||||
|
autoCheckRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
postUserRefreshProfile()
|
postUserRefreshProfile()
|
||||||
.then((p) => {
|
.then((p) => {
|
||||||
setProfile(p);
|
setProfile(p);
|
||||||
const addr = (p.lightning_address ?? "").trim();
|
const addr = (p.lightning_address ?? "").trim();
|
||||||
setLightningAddress(addr);
|
if (addr) setLightningAddress(addr);
|
||||||
})
|
})
|
||||||
.catch(() => setProfile(null));
|
.catch(() => setProfile(null));
|
||||||
}, [pubkey]);
|
}, [pubkey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentStep === 2 && pubkey && wizardRef.current) {
|
if (!nostrProfile?.lud16 || !pubkey) return;
|
||||||
wizardRef.current.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
const relayAddr = nostrProfile.lud16.trim();
|
||||||
|
if (relayAddr && isValidLightningAddress(relayAddr)) {
|
||||||
|
setLightningAddress(relayAddr);
|
||||||
}
|
}
|
||||||
}, [currentStep, pubkey]);
|
}, [nostrProfile, pubkey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentStep === 4 && wizardRef.current) {
|
if (
|
||||||
wizardRef.current.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
!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 = () => {
|
const handleDisconnect = () => {
|
||||||
onPubkeyChange(null);
|
onPubkeyChange(null);
|
||||||
@@ -77,10 +90,12 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces
|
|||||||
claim.resetSuccess();
|
claim.resetSuccess();
|
||||||
claim.clearDenial();
|
claim.clearDenial();
|
||||||
claim.clearConfirmError();
|
claim.clearConfirmError();
|
||||||
|
setClaimModalOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDone = () => {
|
const handleDone = () => {
|
||||||
claim.resetSuccess();
|
claim.resetSuccess();
|
||||||
|
setClaimModalOpen(false);
|
||||||
onClaimSuccess?.();
|
onClaimSuccess?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -89,26 +104,59 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces
|
|||||||
claim.clearDenial();
|
claim.clearDenial();
|
||||||
claim.clearConfirmError();
|
claim.clearConfirmError();
|
||||||
setLightningAddressTouched(false);
|
setLightningAddressTouched(false);
|
||||||
// Stay on step 2 (eligibility)
|
setClaimModalOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCheckEligibility = () => {
|
const handleCheckEligibility = () => {
|
||||||
|
setClaimModalOpen(true);
|
||||||
claim.checkEligibility(lightningAddress);
|
claim.checkEligibility(lightningAddress);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelQuote = () => {
|
const handleCancelQuote = () => {
|
||||||
claim.cancelQuote();
|
claim.cancelQuote();
|
||||||
claim.clearConfirmError();
|
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 =
|
const lightningAddressInvalid =
|
||||||
lightningAddressTouched && lightningAddress.trim() !== "" && !isValidLightningAddress(lightningAddress);
|
lightningAddressTouched && lightningAddress.trim() !== "" && !isValidLightningAddress(lightningAddress);
|
||||||
const fromProfile =
|
const fromProfile =
|
||||||
Boolean(profile?.lightning_address) &&
|
(Boolean(profile?.lightning_address) &&
|
||||||
lightningAddress.trim() === (profile?.lightning_address ?? "").trim();
|
lightningAddress.trim() === (profile?.lightning_address ?? "").trim()) ||
|
||||||
|
(Boolean(nostrProfile?.lud16) &&
|
||||||
|
lightningAddress.trim() === (nostrProfile?.lud16 ?? "").trim());
|
||||||
const quoteExpired =
|
const quoteExpired =
|
||||||
claim.quote != null && claim.quote.expires_at <= Math.floor(Date.now() / 1000);
|
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 (
|
return (
|
||||||
<div className="content claim-wizard-content" ref={wizardRef}>
|
<div className="content claim-wizard-content" ref={wizardRef}>
|
||||||
<div className="ClaimWizard claim-wizard-root">
|
<div className="ClaimWizard claim-wizard-root">
|
||||||
@@ -126,20 +174,18 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<StepIndicator currentStep={currentStep} />
|
<StepIndicator currentStep={isConnected ? 2 : 1} />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="claim-wizard-body">
|
<div className="claim-wizard-body">
|
||||||
{currentStep === 1 && (
|
{!isConnected ? (
|
||||||
<ConnectStep
|
<ConnectStep
|
||||||
pubkey={pubkey}
|
pubkey={pubkey}
|
||||||
displayName={profile?.name}
|
displayName={profile?.name}
|
||||||
onConnect={(pk, method) => onPubkeyChange(pk, method)}
|
onConnect={(pk, method) => onPubkeyChange(pk, method)}
|
||||||
onDisconnect={handleDisconnect}
|
onDisconnect={handleDisconnect}
|
||||||
/>
|
/>
|
||||||
)}
|
) : (
|
||||||
|
|
||||||
{currentStep === 2 && (
|
|
||||||
<EligibilityStep
|
<EligibilityStep
|
||||||
lightningAddress={lightningAddress}
|
lightningAddress={lightningAddress}
|
||||||
onLightningAddressChange={setLightningAddress}
|
onLightningAddressChange={setLightningAddress}
|
||||||
@@ -149,17 +195,37 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces
|
|||||||
fromProfile={fromProfile}
|
fromProfile={fromProfile}
|
||||||
readOnly={loginMethod === "npub"}
|
readOnly={loginMethod === "npub"}
|
||||||
loading={claim.loading === "quote"}
|
loading={claim.loading === "quote"}
|
||||||
eligibilityProgressStep={claim.eligibilityProgressStep}
|
|
||||||
denial={claim.denial}
|
|
||||||
onCheckEligibility={handleCheckEligibility}
|
onCheckEligibility={handleCheckEligibility}
|
||||||
onClearDenial={claim.clearDenial}
|
/>
|
||||||
onCheckAgain={() => {
|
)}
|
||||||
claim.clearDenial();
|
</div>
|
||||||
}}
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={claimModalOpen}
|
||||||
|
onClose={handleCloseModal}
|
||||||
|
title={modalTitle}
|
||||||
|
preventClose={claim.loading !== "idle"}
|
||||||
|
>
|
||||||
|
<div className="claim-modal-body">
|
||||||
|
{claim.loading === "quote" && (
|
||||||
|
<div className="claim-modal-progress" role="status" aria-live="polite">
|
||||||
|
<div className="claim-wizard-progress-bar" />
|
||||||
|
<p className="claim-modal-progress-text">
|
||||||
|
{ELIGIBILITY_PROGRESS_STEPS[claim.eligibilityProgressStep ?? 0]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{claim.denial && (
|
||||||
|
<ClaimDenialPanel
|
||||||
|
denial={claim.denial}
|
||||||
|
onDismiss={handleDismissDenial}
|
||||||
|
onCheckAgain={handleCheckAgain}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentStep === 3 && claim.quote && (
|
{(claim.claimState === "quote_ready" || claim.claimState === "confirming" || claim.claimState === "error") && claim.quote && (
|
||||||
<ConfirmStep
|
<ConfirmStep
|
||||||
quote={claim.quote}
|
quote={claim.quote}
|
||||||
lightningAddress={lightningAddress}
|
lightningAddress={lightningAddress}
|
||||||
@@ -172,7 +238,7 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentStep === 4 && claim.success && (
|
{claim.success && (
|
||||||
<SuccessStep
|
<SuccessStep
|
||||||
result={claim.success}
|
result={claim.success}
|
||||||
onDone={handleDone}
|
onDone={handleDone}
|
||||||
@@ -180,7 +246,7 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ClaimDenialPanel } from "./ClaimDenialPanel";
|
|
||||||
import { ELIGIBILITY_PROGRESS_STEPS } from "../hooks/useClaimFlow";
|
|
||||||
import type { DenialState } from "../hooks/useClaimFlow";
|
|
||||||
|
|
||||||
interface EligibilityStepProps {
|
interface EligibilityStepProps {
|
||||||
lightningAddress: string;
|
lightningAddress: string;
|
||||||
@@ -12,11 +9,7 @@ interface EligibilityStepProps {
|
|||||||
fromProfile: boolean;
|
fromProfile: boolean;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
eligibilityProgressStep: number | null;
|
|
||||||
denial: DenialState | null;
|
|
||||||
onCheckEligibility: () => void;
|
onCheckEligibility: () => void;
|
||||||
onClearDenial: () => void;
|
|
||||||
onCheckAgain?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const LIGHTNING_ADDRESS_REGEX = /^[^@]+@[^@]+$/;
|
const LIGHTNING_ADDRESS_REGEX = /^[^@]+@[^@]+$/;
|
||||||
@@ -30,11 +23,7 @@ export function EligibilityStep({
|
|||||||
fromProfile,
|
fromProfile,
|
||||||
readOnly,
|
readOnly,
|
||||||
loading,
|
loading,
|
||||||
eligibilityProgressStep,
|
|
||||||
denial,
|
|
||||||
onCheckEligibility,
|
onCheckEligibility,
|
||||||
onClearDenial,
|
|
||||||
onCheckAgain,
|
|
||||||
}: EligibilityStepProps) {
|
}: EligibilityStepProps) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const canCheck = !loading && lightningAddress.trim() !== "" && LIGHTNING_ADDRESS_REGEX.test(lightningAddress.trim());
|
const canCheck = !loading && lightningAddress.trim() !== "" && LIGHTNING_ADDRESS_REGEX.test(lightningAddress.trim());
|
||||||
@@ -94,14 +83,7 @@ export function EligibilityStep({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{noAddressForNpub ? null : loading ? (
|
{!noAddressForNpub && (
|
||||||
<div className="claim-wizard-progress" role="status" aria-live="polite">
|
|
||||||
<div className="claim-wizard-progress-bar" />
|
|
||||||
<p className="claim-wizard-progress-text">
|
|
||||||
{ELIGIBILITY_PROGRESS_STEPS[eligibilityProgressStep ?? 0]}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="claim-wizard-step-actions">
|
<div className="claim-wizard-step-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -109,20 +91,10 @@ export function EligibilityStep({
|
|||||||
onClick={onCheckEligibility}
|
onClick={onCheckEligibility}
|
||||||
disabled={!canCheck}
|
disabled={!canCheck}
|
||||||
>
|
>
|
||||||
Check eligibility
|
{loading ? "Checking…" : "Check eligibility"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{denial && (
|
|
||||||
<div className="claim-wizard-denial-wrap">
|
|
||||||
<ClaimDenialPanel
|
|
||||||
denial={denial}
|
|
||||||
onDismiss={onClearDenial}
|
|
||||||
onCheckAgain={onCheckAgain}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,50 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
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 location = useLocation();
|
||||||
|
const profile = useNostrProfile(pubkey);
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(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 (
|
return (
|
||||||
<header className="site-header">
|
<header className="site-header">
|
||||||
@@ -9,6 +52,7 @@ export function Header() {
|
|||||||
<Link to="/" className="site-logo">
|
<Link to="/" className="site-logo">
|
||||||
<span className="site-logo-text">Sats Faucet</span>
|
<span className="site-logo-text">Sats Faucet</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
<div className="site-header-right">
|
||||||
<nav className="site-nav" aria-label="Main navigation">
|
<nav className="site-nav" aria-label="Main navigation">
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
@@ -23,6 +67,52 @@ export function Header() {
|
|||||||
Transactions
|
Transactions
|
||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
|
{pubkey && (
|
||||||
|
<div className="header-user" ref={menuRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="header-user-trigger"
|
||||||
|
onClick={handleToggle}
|
||||||
|
aria-expanded={menuOpen}
|
||||||
|
aria-haspopup="true"
|
||||||
|
>
|
||||||
|
{profile?.picture ? (
|
||||||
|
<img
|
||||||
|
src={profile.picture}
|
||||||
|
alt=""
|
||||||
|
className="header-user-avatar"
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="header-user-avatar header-user-avatar--placeholder">
|
||||||
|
{(displayName?.[0] ?? "?").toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="header-user-name">{displayName}</span>
|
||||||
|
<svg className={`header-user-chevron${menuOpen ? " open" : ""}`} width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden>
|
||||||
|
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{menuOpen && (
|
||||||
|
<div className="header-user-menu" role="menu">
|
||||||
|
<div className="header-user-menu-info">
|
||||||
|
<span className="header-user-menu-name">{profile?.display_name || profile?.name || "Nostr User"}</span>
|
||||||
|
<span className="header-user-menu-npub">{truncatedNpub(pubkey)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="header-user-menu-divider" />
|
||||||
|
<button type="button" className="header-user-menu-item" role="menuitem" onClick={handleLogout}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
|
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4" />
|
||||||
|
<polyline points="16 17 21 12 16 7" />
|
||||||
|
<line x1="21" y1="12" x2="9" y2="12" />
|
||||||
|
</svg>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
62
frontend/src/hooks/useNostrProfile.ts
Normal file
62
frontend/src/hooks/useNostrProfile.ts
Normal file
@@ -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<string, NostrProfile>();
|
||||||
|
|
||||||
|
export function useNostrProfile(pubkey: string | null): NostrProfile | null {
|
||||||
|
const [profile, setProfile] = useState<NostrProfile | null>(() =>
|
||||||
|
pubkey ? cache.get(pubkey) ?? null : null,
|
||||||
|
);
|
||||||
|
const poolRef = useRef<SimplePool | null>(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;
|
||||||
|
}
|
||||||
@@ -102,6 +102,149 @@ body {
|
|||||||
background: rgba(249, 115, 22, 0.1);
|
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 {
|
.topbar {
|
||||||
background: linear-gradient(90deg, var(--accent) 0%, #fb923c 100%);
|
background: linear-gradient(90deg, var(--accent) 0%, #fb923c 100%);
|
||||||
height: 4px;
|
height: 4px;
|
||||||
@@ -2221,6 +2364,10 @@ h1 {
|
|||||||
.site-header { padding: 0 16px; }
|
.site-header { padding: 0 16px; }
|
||||||
.site-header-inner { height: 52px; }
|
.site-header-inner { height: 52px; }
|
||||||
.site-nav-link { padding: 8px 12px; font-size: 13px; }
|
.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 { padding: 20px 16px 24px; }
|
||||||
.site-footer-nav { flex-wrap: wrap; justify-content: center; gap: 16px; }
|
.site-footer-nav { flex-wrap: wrap; justify-content: center; gap: 16px; }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user