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:
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;