taler-typescript-core

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

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:
Mpackages/taler-util/src/http-client/merchant-management.test.ts | 14++++++++++++++
Mpackages/taler-util/src/http-client/merchant.ts | 2+-
Mpackages/taler-util/src/index.ts | 3+++
Apackages/taler-util/src/merchant-identifiers.test.ts | 25+++++++++++++++++++++++++
Apackages/taler-util/src/merchant-identifiers.ts | 34++++++++++++++++++++++++++++++++++
Apackages/taler-util/src/merchant-payout.test.ts | 12++++++++++++
Apackages/taler-util/src/merchant-payout.ts | 16++++++++++++++++
Apackages/taler-util/src/merchant-pos.test.ts | 9+++++++++
Apackages/taler-util/src/merchant-pos.ts | 34++++++++++++++++++++++++++++++++++
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 }; +}