Add Swagger docs at /docs and /openapi.json; frontend and backend updates
Made-with: Cursor
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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 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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user