taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 80e1b2669fc32fedb13673da5238f7d26badfb77
parent 53c79a44c78f35068f3895765bff47e90646b4d9
Author: Florian Dold <dold@taler.net>
Date:   Fri, 31 Jul 2026 11:54:16 +0200

wallet-core: complete exchange base URLs over the network

Issue: https://bugs.taler.net/n/10777

Diffstat:
Mpackages/taler-harness/src/integrationtests/test-exchange-management.ts | 7++++---
Mpackages/taler-util/src/types-taler-wallet.ts | 11+++++++++++
Apackages/taler-wallet-core/src/exchange-base-url.test.ts | 242+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-core/src/exchange-base-url.ts | 329+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/requests.ts | 245++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
5 files changed, 823 insertions(+), 11 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-exchange-management.ts b/packages/taler-harness/src/integrationtests/test-exchange-management.ts @@ -107,10 +107,11 @@ export async function runExchangeManagementTest( }); }); console.log(j2s(err)); - // Tries to complete to https://, but we use http in the local test. + // Tries to complete to https://, but we use http in the local test. The + // completion validates /config, so it fails before the exchange is added. t.assertDeepEqual( - (err.errorDetail as any).innerError.requestUrl, - "https://localhost:8081/keys", + (err.errorDetail as any).requestUrl, + "https://localhost:8081/config", ); } } diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -4358,12 +4358,15 @@ export const codecForImportDbFromFileRequest = export interface CompleteBaseUrlRequest { url: string; + + progressToken?: string; } export const codecForCompleteBaseUrlRequest = (): Codec<CompleteBaseUrlRequest> => buildCodecForObject<CompleteBaseUrlRequest>() .property("url", codecForString()) + .property("progressToken", codecOptional(codecForString())) .build("CompleteBaseUrlRequest"); export type CompleteBaseUrlResult = @@ -4384,6 +4387,14 @@ export type CompleteBaseUrlResult = status: "bad-syntax" | "bad-network" | "bad-exchange"; /** Error details in case status is not "ok" */ error: TalerErrorDetail; + + /** + * Base URLs of exchanges known to the wallet whose host looks like what + * the user meant to type, most likely first. + * + * Absent when the wallet does not know anything similar. + */ + suggestions?: string[]; }; export interface SetDonauRequest { diff --git a/packages/taler-wallet-core/src/exchange-base-url.test.ts b/packages/taler-wallet-core/src/exchange-base-url.test.ts @@ -0,0 +1,242 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import assert from "node:assert"; +import { test } from "node:test"; +import { + cleanUpExchangeBaseUrl, + exchangeBaseUrlCandidates, + findExchangeSuggestions, + levenshteinDistance, + stringSimilarity, +} from "./exchange-base-url.js"; + +/** + * Clean up @a input with HTTPS enforced, and return the base URL. + */ +function cleanStrict(input: string): string | undefined { + const res = cleanUpExchangeBaseUrl(input, { allowHttp: false }); + return res.status === "ok" ? res.baseUrl : undefined; +} + +/** + * Clean up @a input with plain HTTP allowed, and return the base URL. + */ +function cleanRelaxed(input: string): string | undefined { + const res = cleanUpExchangeBaseUrl(input, { allowHttp: true }); + return res.status === "ok" ? res.baseUrl : undefined; +} + +test("cleanup adds the scheme and the trailing slash", (t) => { + assert.strictEqual( + cleanStrict("exchange.example.com"), + "https://exchange.example.com/", + ); + assert.strictEqual(cleanStrict("example.com"), "https://example.com/"); + assert.strictEqual( + cleanStrict("https://exchange.example.com/"), + "https://exchange.example.com/", + ); + // Starts with the letters "http" but still needs a scheme. + assert.strictEqual(cleanStrict("httpbin.org"), "https://httpbin.org/"); +}); + +test("cleanup keeps a path below the root", (t) => { + assert.strictEqual( + cleanStrict("alice.example.com/exchange"), + "https://alice.example.com/exchange/", + ); +}); + +test("cleanup trims surrounding whitespace", (t) => { + assert.strictEqual( + cleanStrict(" exchange.example.com \n"), + "https://exchange.example.com/", + ); +}); + +test("cleanup lowercases the host", (t) => { + assert.strictEqual( + cleanStrict("Exchange.EXAMPLE.com"), + "https://exchange.example.com/", + ); +}); + +test("cleanup strips query and fragment", (t) => { + assert.strictEqual( + cleanStrict("https://exchange.example.com/?foo=bar#frag"), + "https://exchange.example.com/", + ); +}); + +test("cleanup rejects a non-HTTPS scheme", (t) => { + const res = cleanUpExchangeBaseUrl("http://exchange.example.com", { + allowHttp: false, + }); + assert.strictEqual(res.status, "bad-syntax"); + const ftp = cleanUpExchangeBaseUrl("ftp://exchange.example.com", { + allowHttp: false, + }); + assert.strictEqual(ftp.status, "bad-syntax"); +}); + +test("cleanup rejects a non-HTTP scheme even when http is allowed", (t) => { + const res = cleanUpExchangeBaseUrl("ftp://exchange.example.com", { + allowHttp: true, + }); + assert.strictEqual(res.status, "bad-syntax"); +}); + +test("cleanup rejects empty and unparsable input", (t) => { + assert.strictEqual( + cleanUpExchangeBaseUrl(" ", { allowHttp: false }).status, + "bad-syntax", + ); + assert.strictEqual( + cleanUpExchangeBaseUrl("https://", { allowHttp: false }).status, + "bad-syntax", + ); + assert.strictEqual( + cleanUpExchangeBaseUrl("exchange example.com", { allowHttp: false }).status, + "bad-syntax", + ); +}); + +test("cleanup rejects credentials in the URL", (t) => { + const res = cleanUpExchangeBaseUrl( + "https://exchange.example.com@evil.example.com/", + { allowHttp: false }, + ); + assert.strictEqual(res.status, "bad-syntax"); +}); + +test("cleanup drops the port when HTTPS is enforced", (t) => { + assert.strictEqual( + cleanStrict("https://exchange.example.com:8081/"), + "https://exchange.example.com/", + ); +}); + +test("cleanup keeps http and the port for local development", (t) => { + assert.strictEqual( + cleanRelaxed("http://localhost:8081/"), + "http://localhost:8081/", + ); + assert.strictEqual(cleanRelaxed("localhost:8081"), "https://localhost:8081/"); +}); + +test("a bare domain gets an exchange subdomain candidate", (t) => { + assert.deepStrictEqual(exchangeBaseUrlCandidates("https://example.com/"), [ + "https://example.com/", + "https://exchange.example.com/", + ]); +}); + +test("a host that is already an exchange subdomain is used directly", (t) => { + assert.deepStrictEqual( + exchangeBaseUrlCandidates("https://exchange.example.com/"), + ["https://exchange.example.com/"], + ); +}); + +test("no subdomain candidate where it could not name anything", (t) => { + // A URL with a path. + assert.deepStrictEqual( + exchangeBaseUrlCandidates("https://example.com/exchange/"), + ["https://example.com/exchange/"], + ); + // A host without a dot. + assert.deepStrictEqual(exchangeBaseUrlCandidates("http://localhost:8081/"), [ + "http://localhost:8081/", + ]); + // IP literals. + assert.deepStrictEqual(exchangeBaseUrlCandidates("https://192.0.2.1/"), [ + "https://192.0.2.1/", + ]); + assert.deepStrictEqual(exchangeBaseUrlCandidates("https://[2001:db8::1]/"), [ + "https://[2001:db8::1]/", + ]); +}); + +test("levenshtein distance counts single-character edits", (t) => { + assert.strictEqual(levenshteinDistance("", ""), 0); + assert.strictEqual(levenshteinDistance("", "abc"), 3); + assert.strictEqual(levenshteinDistance("abc", ""), 3); + assert.strictEqual(levenshteinDistance("abc", "abc"), 0); + assert.strictEqual(levenshteinDistance("kitten", "sitting"), 3); + assert.strictEqual(levenshteinDistance("flaw", "lawn"), 2); +}); + +test("similarity is symmetric and bounded", (t) => { + assert.strictEqual(stringSimilarity("", ""), 1); + assert.strictEqual(stringSimilarity("abc", "abc"), 1); + assert.strictEqual(stringSimilarity("abcd", "wxyz"), 0); + assert.strictEqual( + stringSimilarity("taler-ops.ch", "taler-opss.ch"), + stringSimilarity("taler-opss.ch", "taler-ops.ch"), + ); +}); + +test("a typo is suggested a known exchange", (t) => { + const known = [ + "https://exchange.demo.taler.net/", + "https://exchange.taler-ops.ch/", + ]; + assert.deepStrictEqual( + findExchangeSuggestions("https://exchange.taler-opss.ch/", known), + ["https://exchange.taler-ops.ch/"], + ); +}); + +test("a bare domain is suggested its exchange subdomain", (t) => { + const known = ["https://exchange.taler-ops.ch/"]; + assert.deepStrictEqual( + findExchangeSuggestions("https://taler-ops.ch/", known), + ["https://exchange.taler-ops.ch/"], + ); +}); + +test("an unrelated host gets no suggestion", (t) => { + const known = [ + "https://exchange.demo.taler.net/", + "https://exchange.taler-ops.ch/", + ]; + assert.deepStrictEqual( + findExchangeSuggestions("https://totally-different.example/", known), + [], + ); +}); + +test("the input itself is never suggested back", (t) => { + const known = ["https://exchange.taler-ops.ch/"]; + assert.deepStrictEqual( + findExchangeSuggestions("https://exchange.taler-ops.ch/", known), + [], + ); +}); + +test("part matches sort ahead of fuzzy matches", (t) => { + const known = [ + // Only similar enough to clear the threshold. + "https://taler-opsx.ch/", + // Contains the searched-for host. + "https://exchange.taler-ops.ch/", + ]; + assert.deepStrictEqual( + findExchangeSuggestions("https://taler-ops.ch/", known), + ["https://exchange.taler-ops.ch/", "https://taler-opsx.ch/"], + ); +}); diff --git a/packages/taler-wallet-core/src/exchange-base-url.ts b/packages/taler-wallet-core/src/exchange-base-url.ts @@ -0,0 +1,329 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Network-free part of turning user-provided input into a canonical exchange + * base URL: cleanup, candidate generation and fuzzy matching against the + * exchanges the wallet already knows. + * + * The network probe on top of this lives in requests.ts. + */ + +/** + * Imports. + */ +import { URL } from "@gnu-taler/taler-util"; + +/** + * Characters that may legally occur in the URL a user typed. + * + * Anything outside this set is rejected before parsing, so that a stray + * character does not silently end up percent-encoded into a host name. + */ +const urlCharRegex = /^[a-zA-Z0-9\-_.~!*'();:@&=+$,/?%#[\]]+$/; + +/** + * Highest fraction of the longer hostname that may differ for two hostnames + * to still be considered a possible typo of one another. + */ +const defaultSimilarityThreshold = 0.7; + +/** + * Parse @a url, or return undefined if it is not a URL at all. + * + * @param url string to parse + * @returns the parsed URL, or undefined + */ +// The return type is inferred on purpose: taler-util exports the URL +// constructor as a value, but not the interface it produces, so there is no +// name to write here. +function tryParseUrl(url: string) { + try { + return new URL(url); + } catch { + return undefined; + } +} + +export interface ExchangeBaseUrlCleanupOptions { + /** + * Accept "http://" input instead of rejecting it, and keep an explicit port + * number in the result. + * + * Both relaxations exist for the same reason: a wallet configured for local + * development or for the test harness talks to exchanges that are reachable + * only as "http://localhost:<port>/". + */ + allowHttp: boolean; +} + +export type ExchangeBaseUrlCleanupResult = + | { + status: "ok"; + /** Cleaned-up base URL, with a scheme and a trailing slash. */ + baseUrl: string; + } + | { + status: "bad-syntax"; + /** Human-readable reason, for the error detail of the API response. */ + detail: string; + }; + +/** + * Bring user-provided input into the shape of a base URL, without contacting + * anyone: add the default scheme, enforce HTTPS, drop the port, query and + * fragment, and make sure the path ends in a slash. + * + * The path is deliberately kept: an exchange may be hosted below the root of + * its host, and dropping the path would make such an exchange impossible to + * reach through this API. + * + * @param input raw string as typed by the user + * @param options relaxations for local development + * @returns the cleaned-up base URL, or why the input cannot be one + */ +export function cleanUpExchangeBaseUrl( + input: string, + options: ExchangeBaseUrlCleanupOptions, +): ExchangeBaseUrlCleanupResult { + const trimmed = input.trim(); + + if (trimmed === "") { + return { status: "bad-syntax", detail: "empty URL" }; + } + + if (!urlCharRegex.test(trimmed)) { + return { + status: "bad-syntax", + detail: "URL contains characters that are not allowed in a URL", + }; + } + + // Test for a scheme, not for the letters "http": "httpbin.org" starts with + // them but still needs one prepended. + const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) + ? trimmed + : `https://${trimmed}`; + + const parsed = tryParseUrl(withScheme); + if (parsed == null) { + return { status: "bad-syntax", detail: "URL cannot be parsed" }; + } + + if (parsed.protocol !== "https:") { + if (parsed.protocol !== "http:" || !options.allowHttp) { + return { + status: "bad-syntax", + detail: `scheme "${parsed.protocol}" is not supported, expected "https:"`, + }; + } + } + + if (parsed.hostname === "") { + return { status: "bad-syntax", detail: "URL has no host" }; + } + + // Credentials in a base URL are never meaningful for an exchange, and + // "https://exchange.example.com@evil.example.com/" reads to a human as if it + // pointed at the first of the two hosts. + if (parsed.username !== "" || parsed.password !== "") { + return { + status: "bad-syntax", + detail: "URL must not contain credentials", + }; + } + + // Assembled from the parsed components instead of mutating the parsed URL: + // the setters of our URL implementation do not round-trip (assigning + // hostname turns a path of "/" into "//"), and building the string drops + // query and fragment without a further step. + // + // The parser leaves the case of the host alone, but a base URL that differs + // from another only in case is the same base URL. + const host = parsed.hostname.toLowerCase(); + const port = options.allowHttp ? parsed.port : ""; + const authority = port === "" ? host : `${host}:${port}`; + const path = parsed.pathname.endsWith("/") + ? parsed.pathname + : `${parsed.pathname}/`; + + return { status: "ok", baseUrl: `${parsed.protocol}//${authority}${path}` }; +} + +/** + * True if @a hostname is an IPv4 or IPv6 literal rather than a domain name. + */ +function isIpLiteral(hostname: string): boolean { + // The URL parser wraps IPv6 literals in square brackets. + if (hostname.startsWith("[")) { + return true; + } + return /^[0-9.]+$/.test(hostname); +} + +/** + * List the base URLs to probe for a cleaned-up input, most likely first. + * + * A bare domain gets "exchange.<domain>" as a second candidate, since that is + * where an exchange is conventionally deployed. The extra candidate only makes + * sense for a domain name at the root of its host: prefixing a subdomain onto + * an IP literal, onto a name without a dot, or onto a URL that already carries + * a path would not name anything. + * + * @param baseUrl cleaned-up base URL from {@link cleanUpExchangeBaseUrl} + * @returns candidate base URLs, without duplicates + */ +export function exchangeBaseUrlCandidates(baseUrl: string): string[] { + const candidates = [baseUrl]; + const parsed = new URL(baseUrl); + + if ( + parsed.pathname === "/" && + !parsed.hostname.startsWith("exchange.") && + parsed.hostname.includes(".") && + !isIpLiteral(parsed.hostname) + ) { + // Assembled rather than assigned through the hostname setter, which + // re-parses and would leave the path doubled up. + candidates.push(`${parsed.protocol}//exchange.${parsed.host}/`); + } + + return candidates; +} + +/** + * Levenshtein edit distance between @a a and @a b. + * + * @param a first string + * @param b second string + * @returns number of single-character edits that turn @a a into @a b + */ +export function levenshteinDistance(a: string, b: string): number { + if (a === b) { + return 0; + } + if (a.length === 0) { + return b.length; + } + if (b.length === 0) { + return a.length; + } + + // Only the previous row of the edit matrix is ever read, so keep just that. + let prevRow = new Array<number>(b.length + 1); + let curRow = new Array<number>(b.length + 1); + for (let j = 0; j <= b.length; j++) { + prevRow[j] = j; + } + + for (let i = 1; i <= a.length; i++) { + curRow[0] = i; + for (let j = 1; j <= b.length; j++) { + const substitutionCost = a[i - 1] === b[j - 1] ? 0 : 1; + curRow[j] = Math.min( + curRow[j - 1] + 1, + prevRow[j] + 1, + prevRow[j - 1] + substitutionCost, + ); + } + const tmp = prevRow; + prevRow = curRow; + curRow = tmp; + } + + return prevRow[b.length]; +} + +/** + * Similarity of two strings, as one minus the share of the longer string that + * would have to be edited to obtain the other. + * + * @param a first string + * @param b second string + * @returns a value in [0,1], where 1 means the strings are equal + */ +export function stringSimilarity(a: string, b: string): number { + const longest = Math.max(a.length, b.length); + if (longest === 0) { + return 1; + } + return 1 - levenshteinDistance(a, b) / longest; +} + +export interface ExchangeSuggestionOptions { + /** + * Minimum similarity of two hostnames for one to be suggested for the other. + */ + threshold?: number; +} + +/** + * Pick the base URLs among @a knownBaseUrls whose host looks like what the + * user meant to type, so that a failed completion can still offer a way + * forward. + * + * A known host matches when one hostname contains the other — typing + * "taler-ops.ch" for "exchange.taler-ops.ch" is a completion, not a typo — or + * when the two hostnames are similar enough to be a typo of one another. + * + * @param baseUrl cleaned-up base URL the user asked for + * @param knownBaseUrls base URLs of the exchanges the wallet knows + * @param options matching options + * @returns matching entries of @a knownBaseUrls, most similar first + */ +export function findExchangeSuggestions( + baseUrl: string, + knownBaseUrls: string[], + options: ExchangeSuggestionOptions = {}, +): string[] { + const threshold = options.threshold ?? defaultSimilarityThreshold; + + const wanted = tryParseUrl(baseUrl); + if (wanted == null) { + return []; + } + const wantedHost = wanted.hostname; + + const scored: { baseUrl: string; similarity: number }[] = []; + const seen = new Set<string>(); + + for (const knownBaseUrl of knownBaseUrls) { + if (knownBaseUrl === baseUrl || seen.has(knownBaseUrl)) { + continue; + } + const known = tryParseUrl(knownBaseUrl); + if (known == null) { + continue; + } + const knownHost = known.hostname; + const similarity = stringSimilarity(wantedHost, knownHost); + const partMatch = + knownHost.includes(wantedHost) || wantedHost.includes(knownHost); + if (!partMatch && similarity < threshold) { + continue; + } + seen.add(knownBaseUrl); + // A part match is a stronger signal than an edit distance that happens to + // clear the threshold, so it sorts ahead of one. + scored.push({ + baseUrl: knownBaseUrl, + similarity: partMatch ? 1 : similarity, + }); + } + + scored.sort((a, b) => b.similarity - a.similarity); + return scored.map((x) => x.baseUrl); +} diff --git a/packages/taler-wallet-core/src/requests.ts b/packages/taler-wallet-core/src/requests.ts @@ -84,6 +84,7 @@ import { GetQrCodesForPaytoResponse, HintNetworkAvailabilityRequest, HostPortPath, + HttpStatusCode, ImportDbFromFileRequest, ImportDbRequest, InitRequest, @@ -119,6 +120,8 @@ import { TalerBankIntegrationHttpClient, TalerError, TalerErrorCode, + TalerErrorDetail, + TalerExchangeHttpClient, TalerProtocolTimestamp, TalerUriAction, TalerUris, @@ -133,6 +136,7 @@ import { TestingWaitExchangeReadyRequest, TransactionType, TransactionsResponse, + URL, UpdateExchangeEntryRequest, ValidateIbanRequest, ValidateIbanResponse, @@ -141,6 +145,7 @@ import { WireTypeDetails, WithdrawTestBalanceRequest, canonicalizeBaseUrl, + carefullyParseConfig, checkDbInvariant, codecForAbortTransaction, codecForAcceptBankIntegratedWithdrawalRequest, @@ -177,6 +182,7 @@ import { codecForDeleteSubscriptionRequest, codecForDeleteTransactionRequest, codecForEmptyObject, + codecForExchangeConfig, codecForExportDbToFileRequest, codecForFailTransactionRequest, codecForForceRefreshRequest, @@ -263,6 +269,7 @@ import { setGlobalLogLevelFromString, validateIban, } from "@gnu-taler/taler-util"; +import { HttpResponse } from "@gnu-taler/taler-util/http"; import { getBalanceDetail, getBalances } from "./balance.js"; import { getMaxDepositAmount, @@ -292,6 +299,11 @@ import { handleSetDonau, } from "./donau.js"; import { + cleanUpExchangeBaseUrl, + exchangeBaseUrlCandidates, + findExchangeSuggestions, +} from "./exchange-base-url.js"; +import { acceptExchangeTermsOfService, deleteEphemeralExchanges, deleteExchange, @@ -789,28 +801,245 @@ async function handleRecoverStoredBackup( return {}; } -const urlCharRegex = /^[a-zA-Z0-9\-_.~!*'();:@&=+$,/?%#[\]]+$/; +/** + * Highest number of HTTP redirects followed while probing one candidate base + * URL, so that a redirect loop cannot keep the completion running forever. + */ +const maxCompletionRedirects = 5; + +type ExchangeProbeResult = + | { + status: "ok"; + /** Base URL that the validated /config response was served under. */ + baseUrl: string; + } + | { + status: "bad-network" | "bad-exchange"; + error: TalerErrorDetail; + }; + +/** + * True for the status codes that carry a "location" header we should follow. + */ +function isRedirectStatus(status: number): boolean { + switch (status) { + case HttpStatusCode.MovedPermanently: + case HttpStatusCode.Found: + case HttpStatusCode.SeeOther: + case HttpStatusCode.TemporaryRedirect: + case HttpStatusCode.PermanentRedirect: + return true; + default: + return false; + } +} + +/** + * Decide whether a failed request never reached a server, or reached one that + * answered with something an exchange would not answer. + */ +function classifyProbeError( + error: TalerErrorDetail, +): "bad-network" | "bad-exchange" { + switch (error.code) { + case TalerErrorCode.WALLET_NETWORK_ERROR: + case TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT: + case TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED: + return "bad-network"; + default: + return "bad-exchange"; + } +} + +/** + * Check whether an exchange is served under @a baseUrl by requesting its + * /config and validating the response. + * + * Redirects are followed here instead of by the HTTP layer, because the base + * URL to report back is the one the final response came from, and the HTTP + * layer does not expose it. The qtart runtime follows redirects natively and + * ignores the "manual" setting, so on that platform a redirected exchange + * validates but is reported under the base URL we asked for. + * + * @param wex wallet execution context + * @param baseUrl candidate base URL, already cleaned up + * @param allowHttp whether a plain-HTTP redirect target is acceptable + * @returns the base URL that answered, or why the candidate was rejected + */ +async function probeExchangeBaseUrl( + wex: WalletExecutionContext, + baseUrl: string, + allowHttp: boolean, +): Promise<ExchangeProbeResult> { + let configUrl = new URL("config", baseUrl).href; + + for (let redirectCount = 0; ; redirectCount++) { + let resp: HttpResponse; + try { + resp = await wex.http.fetch(configUrl, { + redirect: "manual", + cancellationToken: wex.cancellationToken, + }); + } catch (e) { + const error = getErrorDetailFromException(e); + return { status: classifyProbeError(error), error }; + } + + if (isRedirectStatus(resp.status)) { + const location = resp.headers.get("location"); + if (location == null) { + return { + status: "bad-exchange", + error: { + code: TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, + requestUrl: configUrl, + httpStatusCode: resp.status, + detail: "redirect without a location header", + }, + }; + } + if (redirectCount >= maxCompletionRedirects) { + return { + status: "bad-exchange", + error: { + code: TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, + requestUrl: configUrl, + httpStatusCode: resp.status, + detail: `too many redirects (more than ${maxCompletionRedirects})`, + }, + }; + } + let target: string; + try { + target = new URL(location, configUrl).href; + } catch (e) { + return { + status: "bad-exchange", + error: { + code: TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, + requestUrl: configUrl, + httpStatusCode: resp.status, + detail: `redirect to an unparsable location (${location})`, + }, + }; + } + const targetProtocol = new URL(target).protocol; + if ( + targetProtocol !== "https:" && + !(targetProtocol === "http:" && allowHttp) + ) { + return { + status: "bad-exchange", + error: { + code: TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, + requestUrl: configUrl, + httpStatusCode: resp.status, + detail: `redirect to a non-HTTPS location (${target})`, + }, + }; + } + configUrl = target; + continue; + } + + try { + await carefullyParseConfig( + "taler-exchange", + TalerExchangeHttpClient.SUPPORTED_EXCHANGE_PROTOCOL_VERSION, + resp, + codecForExchangeConfig(), + ); + } catch (e) { + return { + status: "bad-exchange", + error: getErrorDetailFromException(e), + }; + } + + // We can only vouch for a base URL whose /config we actually read, so a + // server that redirects /config somewhere else entirely gets rejected + // rather than reported under a base URL we never validated. + const parsedConfigUrl = new URL(configUrl); + if (!parsedConfigUrl.pathname.endsWith("/config")) { + return { + status: "bad-exchange", + error: { + code: TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, + requestUrl: configUrl, + httpStatusCode: resp.status, + detail: "config was served under a path that is not <base>/config", + }, + }; + } + + return { status: "ok", baseUrl: new URL(".", configUrl).href }; + } +} export async function handleCompleteExchangeBaseUrl( wex: WalletExecutionContext, req: CompleteBaseUrlRequest, ): Promise<CompleteBaseUrlResult> { - const trimmedUrl = req.url.trim(); + const allowHttp = wex.ws.config.features.allowHttp; - if (!urlCharRegex.test(trimmedUrl)) { + const cleanup = cleanUpExchangeBaseUrl(req.url, { allowHttp }); + if (cleanup.status !== "ok") { return { status: "bad-syntax", error: { code: TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + detail: cleanup.detail, }, }; } - // FIXME: Do completion via network. - return { - completion: canonicalizeBaseUrl(trimmedUrl), - status: "ok", - }; + // Probing every candidate can take as long as the network takes to time + // out, so a client that passes a progress token gets the "delayed"/ + // "stalled" phases and can cancel. There is deliberately no retry loop + // around the probe: an exchange that cannot be reached is the answer this + // request exists to give, not a failure to retry until it goes away. + return await runWithMaybeProgressContext( + wex, + "completeExchangeBaseUrl", + req.progressToken, + async () => { + const candidates = exchangeBaseUrlCandidates(cleanup.baseUrl); + let lastFailure: Extract< + ExchangeProbeResult, + { status: "bad-network" | "bad-exchange" } + > = { + status: "bad-network", + error: { + code: TalerErrorCode.WALLET_NETWORK_ERROR, + detail: "no candidate base URL could be probed", + }, + }; + + for (const candidate of candidates) { + const probeResult = await probeExchangeBaseUrl( + wex, + candidate, + allowHttp, + ); + if (probeResult.status === "ok") { + return { status: "ok", completion: probeResult.baseUrl }; + } + lastFailure = probeResult; + } + + const knownExchanges = await listExchanges(wex, {}); + const suggestions = findExchangeSuggestions( + cleanup.baseUrl, + knownExchanges.exchanges.map((x) => x.exchangeBaseUrl), + ); + + return { + status: lastFailure.status, + error: lastFailure.error, + ...(suggestions.length > 0 ? { suggestions } : {}), + }; + }, + ); } async function handleSetWalletRunConfig(