Introduce lnurl as a first-class mint type with probe/announcement fields and shared helpers the API and web can both rely on. Co-authored-by: Cursor <cursoragent@cursor.com>
209 lines
9.1 KiB
TypeScript
209 lines
9.1 KiB
TypeScript
/**
|
|
* On-demand indexing: the contract `POST /api/index` speaks.
|
|
*
|
|
* Both ends of that request are in this repository — the API answers it, the 404
|
|
* resolver and the review-by-URL dialog send it — so the shapes live here rather than
|
|
* being written out twice and drifting. The reason codes in particular are the whole
|
|
* point of this file: the API decides *what happened*, the browser decides *what to
|
|
* say about it*, and a typo in a string literal must not be the thing that silently
|
|
* turns a precise sentence into a generic one.
|
|
*
|
|
* The endpoint itself is documented in BACKEND.md. What is here is only what both
|
|
* sides need to agree on: which types are indexable, what a valid submission looks
|
|
* like before any network call, and what comes back.
|
|
*/
|
|
import { federationIdFromInviteCode, isInviteCode } from './fedimint.js';
|
|
import { normalizeMintUrl } from './normalize.js';
|
|
import type { MintDetail, MintInfo } from './types.js';
|
|
|
|
/** The three ecosystems a reader can hand this site an identifier for. */
|
|
export const INDEXABLE_TYPES = ['cashu', 'fedimint', 'lnurl'] as const;
|
|
export type IndexType = (typeof INDEXABLE_TYPES)[number];
|
|
|
|
export function isIndexType(value: unknown): value is IndexType {
|
|
return typeof value === 'string' && (INDEXABLE_TYPES as readonly string[]).includes(value);
|
|
}
|
|
|
|
/**
|
|
* Why a submission did not become a row.
|
|
*
|
|
* Every one of these is a different sentence to a reader, which is why they are not
|
|
* collapsed into a generic failure:
|
|
*
|
|
* `bad_type` the `type` field was not one of the three
|
|
* `bad_input` the input is not a URL (or an invite code) at all
|
|
* `blocked_host` it resolves somewhere this server will not fetch from
|
|
* `wrong_type` the host answered, as a *different* ecosystem's mint
|
|
* `invalid_response` the host answered, as nothing this site recognises
|
|
* `invalid_invite` the invite code does not decode
|
|
* `unverifiable` nothing answered, and Nostr has never heard of it either
|
|
* `rate_limited` too many submissions from one address this hour
|
|
*/
|
|
export type IndexFailureReason =
|
|
| 'bad_type'
|
|
| 'bad_input'
|
|
| 'blocked_host'
|
|
| 'wrong_type'
|
|
| 'invalid_response'
|
|
| 'invalid_invite'
|
|
| 'unverifiable'
|
|
| 'rate_limited';
|
|
|
|
/** How a row that did not already exist came to be written. */
|
|
export type IndexSource = 'probe' | 'announcement' | 'invite';
|
|
|
|
export interface IndexFailure {
|
|
error: IndexFailureReason;
|
|
/** English, for a log or a `curl`. The browser renders its own translated copy. */
|
|
message: string;
|
|
/**
|
|
* The ecosystem this address *does* look like, when the answer said so.
|
|
*
|
|
* Only ever set beside `wrong_type`, and it is what lets the dialog offer "this looks
|
|
* like a Cashu mint, review it there instead" with a button rather than making the
|
|
* reader work out which page they wanted.
|
|
*/
|
|
detected_type?: IndexType;
|
|
/** Seconds until the next submission is accepted. Only beside `rate_limited`. */
|
|
retry_after?: number;
|
|
}
|
|
|
|
/** 200 or 201: the same payload `GET /api/mints/:host` returns, plus how it got there. */
|
|
export type IndexSuccess = MintDetail & {
|
|
/** True when the identifier was already indexed and nothing was probed. */
|
|
existing: boolean;
|
|
/** Absent on an `existing` hit: nothing was written, so nothing wrote it. */
|
|
indexed_from?: IndexSource;
|
|
};
|
|
|
|
export type IndexResponse = IndexSuccess | IndexFailure;
|
|
|
|
export function isIndexFailure(body: IndexResponse): body is IndexFailure {
|
|
return typeof (body as IndexFailure).error === 'string';
|
|
}
|
|
|
|
/**
|
|
* The client-side pre-check, run before any network call.
|
|
*
|
|
* Deliberately shallow: it answers "could this possibly be an address of this kind?"
|
|
* and nothing more. Whether the mint exists, answers, or is what it claims is the
|
|
* server's question, and asking the browser to guess would only produce a second
|
|
* opinion to disagree with. What it does catch is the common typo — an empty box, a
|
|
* sentence, a `fed1…` pasted into the Cashu field — before a request goes out.
|
|
*
|
|
* Returns the value to submit, which is the input with the scheme the normalizer would
|
|
* add, so the dialog can show the reader what it is about to check.
|
|
*/
|
|
export function checkIndexInput(
|
|
type: IndexType,
|
|
input: string,
|
|
): { ok: true; value: string } | { ok: false; reason: 'empty' | 'bad_url' | 'bad_invite' } {
|
|
const raw = input.trim();
|
|
if (!raw) return { ok: false, reason: 'empty' };
|
|
|
|
if (type === 'fedimint') {
|
|
return isInviteCode(raw) && federationIdFromInviteCode(raw) !== null
|
|
? { ok: true, value: raw.toLowerCase() }
|
|
: { ok: false, reason: 'bad_invite' };
|
|
}
|
|
|
|
// A pasted invite code in a URL field is a wrong-field mistake, not a malformed URL,
|
|
// and saying "that is an invite code" is more use than "that is not a URL".
|
|
if (isInviteCode(raw)) return { ok: false, reason: 'bad_invite' };
|
|
|
|
const normalized = normalizeMintUrl(raw);
|
|
return normalized ? { ok: true, value: normalized.url } : { ok: false, reason: 'bad_url' };
|
|
}
|
|
|
|
/**
|
|
* Is this body a NUT-06 mint info document?
|
|
*
|
|
* The probe needs a test that a Cashu mint passes and an arbitrary JSON endpoint fails,
|
|
* because `/v1/info` on a host that is not a mint is very often a 200 with *something*
|
|
* on it — an API index, a health check, a framework's error object. NUT-06 makes every
|
|
* field optional, so the test is "does it carry any of the things only a mint has":
|
|
* a `nuts` object, or a mint pubkey, or the name/version pair a mint's info always has.
|
|
*
|
|
* Kept here rather than in the prober because the wrong-type detection on the LNURL
|
|
* path runs the same test against the same document, and two spellings of "is this a
|
|
* Cashu mint" is exactly how a submission ends up filed under both ecosystems.
|
|
*/
|
|
export function isNut06Info(body: unknown): body is MintInfo {
|
|
if (!body || typeof body !== 'object' || Array.isArray(body)) return false;
|
|
const info = body as Record<string, unknown>;
|
|
|
|
const nuts = info['nuts'];
|
|
if (nuts && typeof nuts === 'object' && !Array.isArray(nuts) && Object.keys(nuts).length > 0) {
|
|
return true;
|
|
}
|
|
// A mint pubkey is 33 compressed bytes, exactly as an LNURL mint's is.
|
|
if (typeof info['pubkey'] === 'string' && /^0[23][0-9a-f]{64}$/i.test(info['pubkey'])) {
|
|
return true;
|
|
}
|
|
return typeof info['name'] === 'string' && typeof info['version'] === 'string';
|
|
}
|
|
|
|
/**
|
|
* The route a page for this listing lives at, given its type and routing slug.
|
|
*
|
|
* One table, because three pages, the 404 resolver, the review dialog and the sitemap
|
|
* all have to agree on it, and the failure mode of disagreeing is a link to a page that
|
|
* does not exist. Locale prefixing is the caller's job (`localePath`).
|
|
*/
|
|
export const MINT_ROUTES: Record<IndexType, string> = {
|
|
cashu: '/mint',
|
|
fedimint: '/fedimint',
|
|
lnurl: '/lnurl-mint',
|
|
};
|
|
|
|
/** `/mint/mint.example.com`, unprefixed. */
|
|
export function mintPath(type: string, host: string): string {
|
|
const base = MINT_ROUTES[type as IndexType] ?? MINT_ROUTES.cashu;
|
|
return `${base}/${host}`;
|
|
}
|
|
|
|
/**
|
|
* Which ecosystem a page path belongs to, or null when it is not a listing page.
|
|
*
|
|
* The 404 resolver's first question: the reader asked for *something*, and whether
|
|
* this build has any business indexing it on their behalf is decided entirely by the
|
|
* shape of the path they used.
|
|
*/
|
|
export function typeForPath(path: string): { type: IndexType; host: string } | null {
|
|
const match = /^\/(mint|fedimint|lnurl-mint)\/([^/]+)\/?$/.exec(path);
|
|
if (!match?.[1] || !match[2]) return null;
|
|
|
|
const type: IndexType =
|
|
match[1] === 'fedimint' ? 'fedimint' : match[1] === 'lnurl-mint' ? 'lnurl' : 'cashu';
|
|
return { type, host: decodeURIComponent(match[2]) };
|
|
}
|
|
|
|
/**
|
|
* The address a `/mint/{slug}` or `/lnurl-mint/{slug}` deep link implies, or null when
|
|
* the slug cannot be turned back into one.
|
|
*
|
|
* Routing slugs are deterministic but not reversible: `mint.example.com/Bitcoin` becomes
|
|
* `mint.example.com-bitcoin`, and so would a mint at `mint.example.com-bitcoin` if one
|
|
* existed. Looking a row up by slug is unaffected — that is what the column is for — but
|
|
* *indexing* from a slug means fetching an address, and guessing which of two readings a
|
|
* hyphen had would mean probing the wrong host and possibly indexing it.
|
|
*
|
|
* So only the unambiguous shape is derived: a plain hostname, optionally with the port
|
|
* suffix the slug spells `-3338`. Everything else returns null, and the caller says it
|
|
* cannot look this one up from the link alone and offers the dialog, where the reader
|
|
* can paste the address including its path.
|
|
*
|
|
* The `lnurl-` collision prefix is deliberately *not* stripped. A slug only takes that
|
|
* prefix at insert time, so a link carrying one describes a row that exists and never
|
|
* reaches this function; a slug that merely starts with those characters is far more
|
|
* likely to be a mint whose hostname begins `lnurl-`, and turning it into a different
|
|
* host would mean probing — and possibly indexing — somebody else's server.
|
|
*/
|
|
export function addressFromSlug(slug: string): string | null {
|
|
const value = slug.trim().toLowerCase();
|
|
const match = /^([a-z0-9-]+(?:\.[a-z0-9-]+)*\.[a-z]{2,})(?:-(\d{2,5}))?$/.exec(value);
|
|
if (!match?.[1]) return null;
|
|
|
|
return `https://${match[1]}${match[2] ? `:${match[2]}` : ''}`;
|
|
}
|