/** * 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; 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 = { 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]}` : ''}`; }