Index, probe, and announce LNURL mints in the API.

Wire discovery and probing for LNURL mints, add rate-limited POST /api/index
for user submissions, and optionally announce confirmed state to relays.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
michilis
2026-08-22 03:44:35 +02:00
co-authored by Cursor
parent c97b44018d
commit 2a9444942b
19 changed files with 4383 additions and 72 deletions
+75
View File
@@ -1,9 +1,41 @@
import { Hono } from 'hono';
import type { Context } from 'hono';
import { cors } from 'hono/cors';
import { serveStatic } from '@hono/node-server/serve-static';
import path from 'node:path';
import { config } from './config.ts';
import { indexSubmission } from './index-mint.ts';
import { getHealth, getMintDetail, getStats, listMints } from './queries.ts';
import { RATE_LIMIT, takeToken } from './rate-limit.ts';
/** The largest `POST /api/index` body read. A JSON object with two short strings. */
const MAX_BODY_BYTES = 8 * 1024;
/**
* Who is submitting, for the rate limiter.
*
* The socket's peer address, unless that peer is the loopback interface — in which case
* this process is behind the reverse proxy the README documents, every request has the
* same peer, and `X-Forwarded-For` is the only thing that tells two visitors apart.
*
* The *last* entry of that header, not the first. nginx's `$proxy_add_x_forwarded_for`
* appends the peer it actually saw to whatever the client sent, so the first entry is a
* value a client can write for itself — a free way around the limit — and the last is
* the one the proxy vouched for. When the peer is not loopback the header is ignored
* entirely, because then there is no proxy to have vouched for anything.
*/
function clientKey(c: Context): string {
const socket = (c.env as { incoming?: { socket?: { remoteAddress?: string } } } | undefined)
?.incoming?.socket;
const peer = socket?.remoteAddress ?? '';
const loopback = peer === '' || peer === '::1' || peer === '127.0.0.1' || peer.startsWith('::ffff:127.');
if (!loopback) return peer;
const forwarded = c.req.header('x-forwarded-for') ?? '';
const hops = forwarded.split(',').map((hop) => hop.trim()).filter(Boolean);
return hops[hops.length - 1] ?? peer ?? 'unknown';
}
export function createApp(): Hono {
const app = new Hono();
@@ -41,6 +73,49 @@ export function createApp(): Hono {
return c.json(detail);
});
/*
* The one endpoint that writes: index a mint nobody has announced yet.
*
* It is a POST because it creates a row, and it is rate limited because it is the
* only route that makes this server fetch an address somebody else chose. Everything
* it actually does lives in `index-mint.ts`; what is here is the shape of the request
* and the two things that can only be decided at the edge — who is asking, and how
* much body to read from them.
*/
app.post('/api/index', async (c) => {
const verdict = takeToken(clientKey(c));
if (!verdict.ok) {
c.header('Retry-After', String(verdict.retryAfter));
return c.json(
{
error: 'rate_limited',
message: `At most ${RATE_LIMIT} submissions an hour from one address`,
retry_after: verdict.retryAfter,
},
429,
);
}
// An invite code runs to a few hundred characters; nothing legitimate is near this.
const declared = Number(c.req.header('content-length') ?? '0');
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
return c.json({ error: 'bad_input', message: 'Request body too large' }, 422);
}
let body: { type?: unknown; input?: unknown };
try {
body = (await c.req.json()) as { type?: unknown; input?: unknown };
} catch {
return c.json({ error: 'bad_input', message: 'Body must be JSON' }, 422);
}
const outcome = await indexSubmission(String(body?.type ?? ''), body?.input);
if (outcome.status === 429 && 'retry_after' in outcome.body) {
c.header('Retry-After', String(outcome.body.retry_after ?? 30));
}
return c.json(outcome.body, outcome.status);
});
// Cached mint icons, so an offline mint keeps its icon.
app.use(
'/icons/*',