taler-typescript-core

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

commit 41f412168791ff02bcb4a55ec8ac3dfbbace6844
parent d01af30894ef2c43baa22212537dc69f2dacb868
Author: Florian Dold <dold@taler.net>
Date:   Fri, 31 Jul 2026 15:45:01 +0200

util: add a helper to canonicalize a merchant base URL's instance segment

Merchant instance IDs are case-insensitive, so a base URL derived from a
taler:// URI can name the same instance as the one in the contract terms and
still differ as a string.

Diffstat:
Mpackages/taler-util/src/taleruri.test.ts | 59++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/taler-util/src/taleruri.ts | 46++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 104 insertions(+), 1 deletion(-)

diff --git a/packages/taler-util/src/taleruri.test.ts b/packages/taler-util/src/taleruri.test.ts @@ -19,7 +19,11 @@ import { test } from "node:test"; // import { AmountString } from "./types-taler-common.js"; import { HostPortPath } from "./payto.js"; import { Result } from "./result.js"; -import { TalerUriAction, TalerUris } from "./taleruri.js"; +import { + canonicalizeMerchantInstanceUrl, + TalerUriAction, + TalerUris, +} from "./taleruri.js"; import { AmountString } from "./types-taler-common.js"; { @@ -777,3 +781,56 @@ test("parseAddContact keeps the mailbox sub-path", () => { assert.deepStrictEqual(r.mailboxIdentity, "SOMEHASHOFPUBKEY"); assert.deepStrictEqual(r.mailboxBaseUri, "https://mailbox.example.com/mb/"); }); + +test("canonicalizeMerchantInstanceUrl folds the instance segment", () => { + const canonical = "https://shop.example.com/instances/myshop/"; + for (const variant of [ + "https://shop.example.com/instances/MyShop/", + "https://shop.example.com/instances/MYSHOP/", + "https://shop.example.com/instances/myshop/", + ]) { + assert.strictEqual(canonicalizeMerchantInstanceUrl(variant), canonical); + } +}); + +test("canonicalizeMerchantInstanceUrl leaves the proxy prefix alone", () => { + // Leading path segments can come from X-Forwarded-Prefix, where case is + // significant, so only the instance segment may be folded. + assert.strictEqual( + canonicalizeMerchantInstanceUrl( + "https://example.com/Shop/API/instances/MyShop/", + ), + "https://example.com/Shop/API/instances/myshop/", + ); +}); + +test("canonicalizeMerchantInstanceUrl passes through what it cannot fold", () => { + for (const unchanged of [ + // The default instance has no instance segment at all. + "https://shop.example.com/", + // "instances" is not the final segment, so this is not an instance URL. + "https://shop.example.com/instances/myshop/orders/", + // Host casing is not ours to fix, and a string that is not a URL at all + // is left for whoever consumes it to reject. + "https://SHOP.example.com/", + "not a url at all", + ]) { + assert.strictEqual(canonicalizeMerchantInstanceUrl(unchanged), unchanged); + } +}); + +test("canonicalizeMerchantInstanceUrl agrees with the pay URI parser", () => { + // What the wallet derives from a taler:// URI must fold to what the + // merchant puts into the contract terms. + const parsed = Result.orUndefined( + TalerUris.parseRestricted( + "taler://pay/shop.example.com/instances/MyShop/ORDER/", + TalerUriAction.Pay, + ), + ); + assert.ok(parsed, "pay URI should parse"); + assert.strictEqual( + canonicalizeMerchantInstanceUrl(parsed.merchantBaseUrl), + "https://shop.example.com/instances/myshop/", + ); +}); diff --git a/packages/taler-util/src/taleruri.ts b/packages/taler-util/src/taleruri.ts @@ -155,6 +155,52 @@ export enum TalerUriParseError { UNEXPECTED_ACTION, } +/** + * A merchant base URL ends in the instance segment, e.g. + * "https://shop.example.com/instances/myshop/". + */ +const MERCHANT_INSTANCE_SEGMENT_RE = /\/instances\/([^/]+)\/?$/; + +/** + * Fold the instance segment of a merchant base URL to lower case. + * + * Merchant instance IDs are case-insensitive: the backend folds them when the + * instance is created and again on every lookup, and only ever hands out the + * lower-case spelling itself. So a base URL that a wallet derived from a + * taler:// URI can name the same instance as the one in the contract terms + * and still differ as a string, which matters wherever the base URL is used + * as an identity rather than just as a request target. + * + * Only the instance segment is folded. The host is already case-insensitive + * per RFC 3986, but any leading path segments can come from a reverse-proxy + * prefix, where case is significant. + */ +export function canonicalizeMerchantInstanceUrl(baseUrl: string): string { + let url: URL; + try { + url = new URL(baseUrl); + } catch { + // Not our problem here; whoever consumes the URL will report it. + return baseUrl; + } + const match = MERCHANT_INSTANCE_SEGMENT_RE.exec(url.pathname); + if (!match) { + return baseUrl; + } + // Instance IDs are slugs (ASCII alphanumerics plus "-_.:"), so a plain + // toLowerCase matches the backend's GNUNET_STRINGS_utf8_tolower. + const canonicalId = match[1].toLowerCase(); + if (canonicalId === match[1]) { + return baseUrl; + } + const trailingSlash = url.pathname.endsWith("/") ? "/" : ""; + url.pathname = + url.pathname.slice(0, match.index) + + `/instances/${canonicalId}` + + trailingSlash; + return url.href; +} + export namespace TalerUris { export type URI = TalerUri;