commit f56f5fca858b4d358f173db07fe9b974867fa5ee
parent 3da51c5bbce507e5098050d425415eaded00083d
Author: Florian Dold <dold@taler.net>
Date: Wed, 19 Aug 2026 18:16:43 +0200
taler-util: share merchant management helpers
Diffstat:
9 files changed, 148 insertions(+), 1 deletion(-)
diff --git a/packages/taler-util/src/http-client/merchant-management.test.ts b/packages/taler-util/src/http-client/merchant-management.test.ts
@@ -66,6 +66,20 @@ test("management client lists instances and decodes complete details", async ()
assert.strictEqual(http.lastRequest?.headers?.Authorization, "Bearer secret-token:admin");
});
+test("management client can probe an empty backend without authorization", async () => {
+ const http = new FakeHttpLib().on(
+ "GET",
+ "/management/instances",
+ ok({ instances: [] }),
+ );
+ const client = new TalerMerchantManagementHttpClient(baseUrl, http);
+
+ const listed = await client.listInstances();
+ assert.strictEqual(listed.type, "ok");
+ assert.deepStrictEqual(listed.body.instances, []);
+ assert.strictEqual(http.lastRequest?.headers?.Authorization, undefined);
+});
+
test("management writes support login-token and challenge responses", async () => {
const challenge = {
challenges: [{ challenge_id: "c1", tan_channel: "email", tan_info: "a***@example.com" }],
diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts
@@ -4045,7 +4045,7 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp
/**
* https://docs.taler.net/core/api-merchant.html#get--management-instances
*/
- async listInstances(token: AccessToken, params?: PaginationParams) {
+ async listInstances(token?: AccessToken, params?: PaginationParams) {
const url = new URL(`management/instances`, this.baseUrl);
const headers: Record<string, string> = {};
diff --git a/packages/taler-util/src/index.ts b/packages/taler-util/src/index.ts
@@ -41,6 +41,9 @@ export { sha256, HashSha256 } from "./sha256.js";
export * from "./libtool-version.js";
export * from "./logging.js";
+export * from "./merchant-identifiers.js";
+export * from "./merchant-pos.js";
+export * from "./merchant-payout.js";
export * from "./longpoll-queue.js";
export {
crypto_sign_keyPair_fromSeed,
diff --git a/packages/taler-util/src/merchant-identifiers.test.ts b/packages/taler-util/src/merchant-identifiers.test.ts
@@ -0,0 +1,25 @@
+import assert from "node:assert";
+import test from "node:test";
+import {
+ isValidMerchantInstanceId,
+ isValidMerchantSlug,
+ normalizeMerchantInstanceId,
+ normalizeMerchantSlug,
+} from "./merchant-identifiers.js";
+
+test("merchant instance identifiers use the backend alphabet", () => {
+ for (const value of ["a", "Shop-1", "shop_1", "shop.1", "shop:1", "A0_.:-"]) {
+ assert.strictEqual(isValidMerchantInstanceId(value), true, value);
+ }
+ for (const value of ["", ".", "..", "shop name", "shop/1", "shop@1", "ümlaut"]) {
+ assert.strictEqual(isValidMerchantInstanceId(value), false, value);
+ }
+ assert.strictEqual(normalizeMerchantInstanceId(" Shop:ONE "), "shop:one");
+});
+
+test("merchant slugs are normalized and validated after normalization", () => {
+ assert.strictEqual(normalizeMerchantSlug(" Breakfast & Drinks "), "breakfast_drinks");
+ assert.strictEqual(normalizeMerchantSlug("!!!"), "");
+ assert.strictEqual(isValidMerchantSlug("breakfast_drinks"), true);
+ assert.strictEqual(isValidMerchantSlug(""), false);
+});
diff --git a/packages/taler-util/src/merchant-identifiers.ts b/packages/taler-util/src/merchant-identifiers.ts
@@ -0,0 +1,34 @@
+/*
+ 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.
+*/
+
+/** Alphabet accepted for merchant instance identifiers by the backend. */
+export const merchantInstanceIdPattern = /^(?!\.\.?$)[A-Za-z0-9_.:-]+$/;
+
+export function isValidMerchantInstanceId(value: string): boolean {
+ return merchantInstanceIdPattern.test(value);
+}
+
+/** Canonical spelling used by merchant instance lookup and creation. */
+export function normalizeMerchantInstanceId(value: string): string {
+ return value.trim().toLowerCase();
+}
+
+/** Normalize report/group/pot identifiers before validating or transmitting. */
+export function normalizeMerchantSlug(value: string): string {
+ return value
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9_-]+/g, "_")
+ .replace(/_+/g, "_")
+ .replace(/^_+|_+$/g, "");
+}
+
+export function isValidMerchantSlug(value: string): boolean {
+ return value.length > 0 && /^[a-z0-9][a-z0-9_-]*$/.test(value);
+}
diff --git a/packages/taler-util/src/merchant-payout.test.ts b/packages/taler-util/src/merchant-payout.test.ts
@@ -0,0 +1,12 @@
+import assert from "node:assert";
+import test from "node:test";
+import { makeAccountAddDetails, PAYOUT_CODE_PATTERN } from "./merchant-payout.js";
+
+test("merchant payout metadata follows the backend grammar", () => {
+ assert.equal(PAYOUT_CODE_PATTERN.test("SHOP-1:POS.2"), true);
+ assert.deepEqual(makeAccountAddDetails({ paytoUri: "payto://iban/CH4431999123000889012", userReference: "SHOP-1" }), {
+ payto_uri: "payto://iban/CH4431999123000889012",
+ extra_wire_subject_metadata: "SHOP-1",
+ });
+ assert.throws(() => makeAccountAddDetails({ paytoUri: "payto://iban/CH4431999123000889012", userReference: "SHOP 1" }));
+});
diff --git a/packages/taler-util/src/merchant-payout.ts b/packages/taler-util/src/merchant-payout.ts
@@ -0,0 +1,16 @@
+import type { PaytoString } from "./payto.js";
+import type { AccountAddDetails } from "./types-taler-merchant.js";
+
+export const PAYOUT_CODE_PATTERN = /^[A-Za-z0-9.:-]{1,40}$/;
+
+export function makeAccountAddDetails(data: {
+ paytoUri: string;
+ userReference?: string;
+}): AccountAddDetails {
+ const body: AccountAddDetails = { payto_uri: data.paytoUri as PaytoString };
+ if (data.userReference !== undefined && data.userReference !== "") {
+ if (!PAYOUT_CODE_PATTERN.test(data.userReference)) throw new Error("Payout code must match [A-Za-z0-9.:-]{1,40}.");
+ body.extra_wire_subject_metadata = data.userReference;
+ }
+ return body;
+}
diff --git a/packages/taler-util/src/merchant-pos.test.ts b/packages/taler-util/src/merchant-pos.test.ts
@@ -0,0 +1,9 @@
+import assert from "node:assert";
+import test from "node:test";
+import { checkPairingBackend, makePosPairingUri, pairingDuration } from "./merchant-pos.js";
+
+test("POS pairing serializes only canonical HTTPS roots", () => {
+ assert.equal(makePosPairingUri("https://merchant.example/", "cafe counter", "secret/token"), "taler-pos://merchant.example/instances/cafe%20counter#secret%2Ftoken");
+ assert.deepEqual(checkPairingBackend("https://merchant.example:8443/"), { compatible: false, reason: "port" });
+ assert.deepEqual(pairingDuration("never"), { d_us: "forever" });
+});
diff --git a/packages/taler-util/src/merchant-pos.ts b/packages/taler-util/src/merchant-pos.ts
@@ -0,0 +1,34 @@
+import type { TalerProtocolDuration } from "./time.js";
+
+export type PairingLifetime = "10d" | "30d" | "90d" | "365d" | "never";
+export interface PairingRequest { deviceName: string; password: string; lifetime: PairingLifetime; refreshable: boolean; }
+export interface IssuedPairingCredential { deviceName: string; pairingUri: string; expirationText: string; }
+export type PairingBackendCompatibility =
+ | { compatible: true; host: string }
+ | { compatible: false; reason: "invalid" | "https" | "port" | "path" | "query" | "fragment" };
+
+export function checkPairingBackend(backendUrl: string | URL): PairingBackendCompatibility {
+ let url: URL;
+ try { url = typeof backendUrl === "string" ? new URL(backendUrl) : backendUrl; }
+ catch { return { compatible: false, reason: "invalid" }; }
+ if (url.protocol !== "https:") return { compatible: false, reason: "https" };
+ if (url.port !== "") return { compatible: false, reason: "port" };
+ if (url.pathname !== "/") return { compatible: false, reason: "path" };
+ if (url.search !== "") return { compatible: false, reason: "query" };
+ if (url.hash !== "") return { compatible: false, reason: "fragment" };
+ if (!url.hostname) return { compatible: false, reason: "invalid" };
+ return { compatible: true, host: url.hostname };
+}
+
+export function makePosPairingUri(backendUrl: string | URL, instance: string, accessToken: string): string {
+ const compatibility = checkPairingBackend(backendUrl);
+ if (!compatibility.compatible) throw new Error(`Backend URL cannot be represented in a taler-pos URI: ${compatibility.reason}`);
+ if (!instance) throw new Error("A merchant instance is required for PoS pairing.");
+ if (!accessToken) throw new Error("An access token is required for PoS pairing.");
+ return `taler-pos://${compatibility.host}/instances/${encodeURIComponent(instance)}#${encodeURIComponent(accessToken)}`;
+}
+
+export function pairingDuration(lifetime: PairingLifetime): TalerProtocolDuration {
+ if (lifetime === "never") return { d_us: "forever" };
+ return { d_us: Number.parseInt(lifetime, 10) * 86_400 * 1_000_000 };
+}