Admin API key auth, account_too_new fixes, clear-all-users cache

- Add ADMIN_API_KEY to config; accept via X-Admin-Key or Bearer (constant-time compare)
- Never serve cached null for nostr_first_seen_at; admin clear-cache and override-age
- Add clearAllUsersCache() and POST /admin/users/clear-cache for all users
- Update .env.example with admin API key and pubkeys comments

Made-with: Cursor
This commit is contained in:
Michilis
2026-03-16 19:53:05 +00:00
parent f43f0bc501
commit 5d02d1396f
7 changed files with 115 additions and 7 deletions

View File

@@ -58,7 +58,8 @@ DEPOSIT_LNURLP=https://yourdomain.com/.well-known/lnurlp/faucet
BASE_SPONSOR_PRICE_PER_DAY=200
SPONSOR_MAX_ACTIVE_PER_USER=5
SPONSOR_MAX_VISIBLE=6
# Comma-separated hex pubkeys for admin API
# Admin API: shared secret or Nostr pubkeys
# ADMIN_API_KEY=your-secure-random-key-min-32-chars
# ADMIN_PUBKEYS=abc123...,def456...
# Public API URL for LNbits webhook (e.g. https://api.example.com)
# PUBLIC_API_URL=https://api.example.com

View File

@@ -82,6 +82,7 @@ export const config = {
sponsorMaxActivePerUser: envInt("SPONSOR_MAX_ACTIVE_PER_USER", 5),
sponsorMaxVisible: envInt("SPONSOR_MAX_VISIBLE", 6),
adminPubkeys: (process.env.ADMIN_PUBKEYS ?? "").split(",").map((s) => s.trim()).filter(Boolean),
adminApiKey: (process.env.ADMIN_API_KEY ?? "").trim(),
/** Public API base URL for LNbits webhook (e.g. https://api.example.com). Required for sponsor payments. */
publicApiUrl: (process.env.PUBLIC_API_URL ?? process.env.FRONTEND_URL ?? "").replace(/\/$/, ""),
};

View File

@@ -154,6 +154,28 @@ export function createPgDb(connectionString: string): Db {
);
},
async clearUserCache(pubkey: string): Promise<void> {
await pool.query("UPDATE users SET last_metadata_fetch_at = 0 WHERE pubkey = $1", [pubkey]);
},
async clearAllUsersCache(): Promise<number> {
const res = await pool.query("UPDATE users SET last_metadata_fetch_at = 0");
return res.rowCount ?? 0;
},
async updateUserNostrFirstSeenAt(pubkey: string, nostr_first_seen_at: number): Promise<void> {
const now = Math.floor(Date.now() / 1000);
await pool.query(
`INSERT INTO users (pubkey, nostr_first_seen_at, notes_count, followers_count, following_count, activity_score, last_metadata_fetch_at, lightning_address, name, created_at, updated_at)
VALUES ($1, $2, 0, 0, 0, 0, $3, NULL, NULL, $3, $3)
ON CONFLICT(pubkey) DO UPDATE SET
nostr_first_seen_at = EXCLUDED.nostr_first_seen_at,
last_metadata_fetch_at = EXCLUDED.last_metadata_fetch_at,
updated_at = EXCLUDED.updated_at`,
[pubkey, nostr_first_seen_at, now]
);
},
async getLastSuccessfulClaimByPubkey(pubkey: string): Promise<ClaimRow | null> {
const res = await pool.query(
"SELECT * FROM claims WHERE pubkey = $1 AND status = 'paid' ORDER BY claimed_at DESC LIMIT 1",

View File

@@ -112,6 +112,27 @@ export function createSqliteDb(path: string): Db {
);
},
async clearUserCache(pubkey: string): Promise<void> {
db.prepare("UPDATE users SET last_metadata_fetch_at = 0 WHERE pubkey = ?").run(pubkey);
},
async clearAllUsersCache(): Promise<number> {
const stmt = db.prepare("UPDATE users SET last_metadata_fetch_at = 0");
return stmt.run().changes;
},
async updateUserNostrFirstSeenAt(pubkey: string, nostr_first_seen_at: number): Promise<void> {
const now = Math.floor(Date.now() / 1000);
db.prepare(
`INSERT INTO users (pubkey, nostr_first_seen_at, notes_count, followers_count, following_count, activity_score, last_metadata_fetch_at, lightning_address, name, created_at, updated_at)
VALUES (?, ?, 0, 0, 0, 0, ?, NULL, NULL, ?, ?)
ON CONFLICT(pubkey) DO UPDATE SET
nostr_first_seen_at = excluded.nostr_first_seen_at,
last_metadata_fetch_at = excluded.last_metadata_fetch_at,
updated_at = excluded.updated_at`
).run(pubkey, nostr_first_seen_at, now, now, now);
},
async getLastSuccessfulClaimByPubkey(pubkey: string): Promise<ClaimRow | null> {
const row = db
.prepare("SELECT * FROM claims WHERE pubkey = ? AND status = 'paid' ORDER BY claimed_at DESC LIMIT 1")

View File

@@ -90,6 +90,9 @@ export interface Db {
last_metadata_fetch_at: number;
}
): Promise<void>;
clearUserCache(pubkey: string): Promise<void>;
clearAllUsersCache(): Promise<number>;
updateUserNostrFirstSeenAt(pubkey: string, nostr_first_seen_at: number): Promise<void>;
getLastSuccessfulClaimByPubkey(pubkey: string): Promise<ClaimRow | null>;
getLastClaimByIpHash(ipHash: string): Promise<ClaimRow | null>;

View File

@@ -1,3 +1,4 @@
import { timingSafeEqual } from "crypto";
import { Router, Request, Response, NextFunction } from "express";
import { config } from "../config.js";
import { getDb } from "../db/index.js";
@@ -5,15 +6,29 @@ import { verifyJwt } from "../auth/jwt.js";
const router = Router();
function constantTimeCompare(a: string, b: string): boolean {
if (a.length !== b.length) return false;
const bufA = Buffer.from(a, "utf8");
const bufB = Buffer.from(b, "utf8");
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}
function adminAuth(req: Request, res: Response, next: NextFunction): void {
if (config.adminPubkeys.length === 0) {
const adminEnabled = config.adminPubkeys.length > 0 || config.adminApiKey.length > 0;
if (!adminEnabled) {
res.status(503).json({ code: "admin_disabled", message: "Admin API not configured" });
return;
}
const auth = req.headers.authorization;
const adminKey = req.headers["x-admin-key"];
let pubkey: string | null = null;
const providedKey = typeof adminKey === "string" ? adminKey.trim() : auth?.startsWith("Bearer ") ? auth.slice(7).trim() : null;
if (config.adminApiKey.length > 0 && providedKey && constantTimeCompare(providedKey, config.adminApiKey)) {
next();
return;
}
let pubkey: string | null = null;
if (auth?.startsWith("Bearer ")) {
const token = auth.slice(7).trim();
const payload = verifyJwt(token);
@@ -22,11 +37,11 @@ function adminAuth(req: Request, res: Response, next: NextFunction): void {
if (!pubkey && typeof adminKey === "string") {
pubkey = adminKey.trim();
}
if (!pubkey || !config.adminPubkeys.includes(pubkey)) {
res.status(403).json({ code: "forbidden", message: "Admin access required" });
if (pubkey && config.adminPubkeys.includes(pubkey)) {
next();
return;
}
next();
res.status(403).json({ code: "forbidden", message: "Admin access required" });
}
router.use(adminAuth);
@@ -115,4 +130,49 @@ router.delete("/sponsors/:id", async (req: Request, res: Response) => {
res.status(200).json({ ok: true });
});
const NOSTR_EPOCH = 1609459200; // 2021-01-01 UTC
router.post("/users/clear-cache", async (req: Request, res: Response) => {
const db = getDb();
const clearedCount = await db.clearAllUsersCache();
res.json({
ok: true,
clearedCount,
message: `Cache cleared for ${clearedCount} user(s). Next claims will re-fetch from relays.`,
});
});
router.post("/users/:pubkey/clear-cache", async (req: Request, res: Response) => {
const pubkey = req.params.pubkey?.trim();
if (!pubkey || pubkey.length !== 64 || !/^[a-fA-F0-9]+$/.test(pubkey)) {
res.status(400).json({ code: "invalid_pubkey", message: "Valid 64-char hex pubkey required" });
return;
}
const db = getDb();
await db.clearUserCache(pubkey);
res.json({ ok: true, message: "User cache cleared. Next claim will re-fetch from relays." });
});
router.post("/users/:pubkey/override-age", async (req: Request, res: Response) => {
const pubkey = req.params.pubkey?.trim();
if (!pubkey || pubkey.length !== 64 || !/^[a-fA-F0-9]+$/.test(pubkey)) {
res.status(400).json({ code: "invalid_pubkey", message: "Valid 64-char hex pubkey required" });
return;
}
const ts = typeof (req.body as { nostr_first_seen_at?: number }).nostr_first_seen_at === "number"
? (req.body as { nostr_first_seen_at: number }).nostr_first_seen_at
: NaN;
const now = Math.floor(Date.now() / 1000);
if (!Number.isFinite(ts) || ts >= now || ts < NOSTR_EPOCH) {
res.status(400).json({
code: "invalid_timestamp",
message: "nostr_first_seen_at must be a Unix timestamp in the past (between 2021-01-01 and now)",
});
return;
}
const db = getDb();
await db.updateUserNostrFirstSeenAt(pubkey, ts);
res.json({ ok: true, message: "Account age overridden. User can claim on next attempt." });
});
export default router;

View File

@@ -59,7 +59,7 @@ export async function fetchAndScorePubkey(pubkey: string, forceRefreshProfile =
cached.nostr_first_seen_at >= ageCutoffSec &&
cached.nostr_first_seen_at <= ageCutoffSec + borderlineWindowSec;
if (!forceRefreshProfile && cached && cacheValidUntil > nowSec && !isBorderline) {
if (!forceRefreshProfile && cached && cacheValidUntil > nowSec && !isBorderline && cached.nostr_first_seen_at != null) {
return {
nostrFirstSeenAt: cached.nostr_first_seen_at,
notesCount: cached.notes_count,