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:
@@ -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/*',
|
||||
|
||||
Reference in New Issue
Block a user