Files
CashuMints.space/web/src/lib/index-client.ts
T
michilisandCursor c74c7fc187 Ship LNURL mint pages, indexing UI, and reviews rewrite.
Add lnurl list/detail routes, OG fixtures, i18n strings, and the write/
index client flows so the site surfaces the new mint type end to end.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 03:44:43 +02:00

173 lines
6.3 KiB
TypeScript

/**
* The browser half of on-demand indexing.
*
* One request — `POST /api/index` — with the two callers that make it: the 404
* resolver, which asks on the reader's behalf when they open a deep link this build has
* never heard of, and the review-by-URL dialog, where they ask deliberately.
*
* Both need the same three things and neither should reimplement them: the request, a
* plain sentence for every way it can fail, and the `ReviewSubject` the review dialog
* publishes against. The failure vocabulary itself is `@cashumints/shared`, so the API
* and this file cannot drift about what `wrong_type` means.
*/
import {
fedimintSubject,
lnurlSubject,
mintSubject,
type ReviewSubject,
} from './review-subject';
import { apiBase } from './client';
import type { Translator } from '../i18n/translate';
import {
isIndexFailure,
type IndexFailure,
type IndexSuccess,
type IndexType,
type FedimintDetail,
type LnurlDetail,
type MintDetail,
} from '@cashumints/shared';
/**
* What came back.
*
* The HTTP status is kept beside the body rather than collapsed into a boolean, because
* 200 and 201 mean visibly different things to a reader — "already listed" against "we
* just checked it for you" — and the dialog says so.
*/
export type IndexResult =
| { ok: true; status: number; mint: IndexSuccess }
| { ok: false; status: number; failure: IndexFailure };
/** A network failure, shaped like every other failure so callers have one path. */
function offline(): IndexResult {
return {
ok: false,
status: 0,
failure: { error: 'invalid_response', message: 'the API could not be reached' },
};
}
/**
* Ask the API to index an address, and wait for the answer.
*
* There is deliberately no client-side timeout. The endpoint bounds itself — one probe
* at the standard five seconds, then at most three more for the relay lookup — and a
* shorter timer here would abandon requests that are about to succeed while leaving the
* server doing the work anyway. The caller shows a "checking" state and means it.
*/
export async function requestIndex(type: IndexType, input: string): Promise<IndexResult> {
let res: Response;
try {
res = await fetch(`${apiBase}/api/index`, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({ type, input }),
});
} catch {
return offline();
}
let body: unknown;
try {
body = (await res.json()) as unknown;
} catch {
return offline();
}
if (!body || typeof body !== 'object') return offline();
if (res.ok && !isIndexFailure(body as IndexSuccess | IndexFailure)) {
return { ok: true, status: res.status, mint: body as IndexSuccess };
}
return { ok: false, status: res.status, failure: body as IndexFailure };
}
/**
* The sentence a reader gets for a failure.
*
* Plain words, and specific: "nothing responded at this address and no trace of it
* exists on Nostr yet" is a different situation from "that address answered, but not as
* an LNURL mint", and a reader can act on the difference — the first means checking the
* spelling, the second means picking a different page. A generic "could not be added"
* would hide both.
*
* `wrong_type` is handled by the caller rather than here, because the useful response to
* it is a button, not a sentence.
*/
export function indexErrorMessage(result: { status: number; failure: IndexFailure }, t: Translator): string {
const { failure, status } = result;
switch (failure.error) {
case 'bad_input':
return t('reviews.byUrl.error.badInput');
case 'bad_type':
return t('reviews.byUrl.error.badInput');
case 'blocked_host':
return t('reviews.byUrl.error.blocked');
case 'invalid_invite':
return t('reviews.byUrl.error.badInvite');
case 'invalid_response':
return status === 0
? t('reviews.byUrl.error.network')
: t('reviews.byUrl.error.notAMint');
case 'unverifiable':
return t('reviews.byUrl.error.unverifiable');
case 'rate_limited':
return t('reviews.byUrl.error.rateLimited', { n: retryMinutes(failure) });
case 'wrong_type':
return t('reviews.byUrl.error.wrongType');
default:
return t('reviews.byUrl.error.network');
}
}
/** A `retry_after` in whole minutes, at least one, for the "try again in…" sentence. */
function retryMinutes(failure: IndexFailure): number {
return Math.max(1, Math.ceil((failure.retry_after ?? 3600) / 60));
}
/**
* The review subject for an indexed mint, whichever ecosystem it belongs to.
*
* The same three builders the three page templates use, chosen by the payload's own
* `type` rather than by what the caller asked for — a submission can come back as a
* different ecosystem than it went out as (that is what `detected_type` is for), and
* the row that exists is the one the review has to point at.
*/
export function subjectFor(mint: MintDetail): ReviewSubject | null {
if (mint.type === 'fedimint') return fedimintSubject(mint as FedimintDetail);
if (mint.type === 'lnurl') return lnurlSubject(mint as LnurlDetail);
if (mint.type === 'cashu') return mintSubject(mint);
// A type this build has no review kind for. Nothing here can point a review at it.
return null;
}
/**
* The name to show for a mint, falling back the way every card does.
*
* A federation usually has one from its announcement; one submitted as a bare invite
* code has nothing but its id, and its shortened id is then both its name and its
* address. Callers that render both lines drop the second when they match, rather than
* printing the same string twice.
*/
export function mintDisplayName(mint: MintDetail): string {
const domain = displayAddress(mint);
return mint.name ?? domain.split('/')[0] ?? domain;
}
/**
* The address to print under that name.
*
* A federation has no URL — its key is `fedimint:<id>` — so it shows its id instead,
* shortened the way the federation pages shorten it. Everything else shows the address
* a reader would type.
*/
export function displayAddress(mint: MintDetail): string {
if (mint.type === 'fedimint') {
const id = mint.federation_id ?? mint.host;
return id.length > 24 ? `${id.slice(0, 16)}…${id.slice(-6)}` : id;
}
return mint.url.replace(/^lnurl:/, '').replace(/^https?:\/\//, '').replace(/\/+$/, '');
}