Add Swagger docs at /docs and /openapi.json; frontend and backend updates

Made-with: Cursor
This commit is contained in:
SatsFaucet
2026-03-01 01:24:51 +01:00
parent bdb4892014
commit 381597c96f
20 changed files with 1214 additions and 98 deletions

View File

@@ -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(

View File

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

View 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;

View 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",
},
},
},
};
}

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -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",

View File

@@ -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.
* 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<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) {
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,