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>
140 lines
5.5 KiB
TypeScript
140 lines
5.5 KiB
TypeScript
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();
|
|
|
|
// Public read-only API. There is nothing to protect and every client is a browser.
|
|
app.use('/api/*', cors());
|
|
app.use('/icons/*', cors());
|
|
|
|
app.get('/api/health', async (c) => {
|
|
const health = await getHealth();
|
|
c.header('Cache-Control', 'no-store');
|
|
return c.json(health, health.status === 'ok' ? 200 : 503);
|
|
});
|
|
|
|
app.get('/api/stats', async (c) => c.json(await getStats()));
|
|
|
|
/*
|
|
* The whole index, every ecosystem, each item carrying its `type`.
|
|
*
|
|
* `?type=cashu` / `?type=fedimint` narrows it, which is what the two list pages ask
|
|
* for at build time. Unknown values are passed through rather than rejected: they
|
|
* return an empty list, which is the honest answer to "show me the ecosystem this
|
|
* build does not have".
|
|
*/
|
|
app.get('/api/mints', async (c) => {
|
|
const raw = c.req.query('limit');
|
|
const limit = raw ? Number.parseInt(raw, 10) : undefined;
|
|
const type = c.req.query('type')?.trim() || undefined;
|
|
return c.json(await listMints(Number.isFinite(limit) ? limit : undefined, type));
|
|
});
|
|
|
|
app.get('/api/mints/:host', async (c) => {
|
|
const detail = await getMintDetail(c.req.param('host'));
|
|
if (!detail) return c.json({ error: 'not_found', message: 'No mint with that host' }, 404);
|
|
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/*',
|
|
serveStatic({
|
|
root: path.relative(process.cwd(), config.iconDir) || '.',
|
|
rewriteRequestPath: (p) => p.replace(/^\/icons/, ''),
|
|
onFound: (_p, c) => {
|
|
c.header('Cache-Control', 'public, max-age=86400');
|
|
// These files came from mint operators. nosniff pins the served type, and the
|
|
// sandbox neutralizes anything script-capable (an SVG cached before icons.ts
|
|
// stopped accepting them) when the file is opened directly on this origin.
|
|
c.header('X-Content-Type-Options', 'nosniff');
|
|
c.header('Content-Security-Policy', 'sandbox');
|
|
},
|
|
}),
|
|
);
|
|
|
|
app.notFound((c) => c.json({ error: 'not_found' }, 404));
|
|
|
|
return app;
|
|
}
|