taler-typescript-core

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

commit 6cbffd2928d49394950bf8c5d95fd0d2351c6b2a
parent fcc64886961bef05f44ddb158212fe5112d1eb31
Author: Florian Dold <dold@taler.net>
Date:   Tue, 28 Jul 2026 19:31:15 +0200

util: send Accept weights so the ToS format preference reaches the exchange

Position in an Accept list carries no meaning in HTTP, so the order of
acceptedFormat was dropped on the wire and the exchange chose for us.

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

Diffstat:
Apackages/taler-util/src/http-client/accept-header.test.ts | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/http-client/exchange-client.ts | 25++++++++++++++++++++++++-
2 files changed, 74 insertions(+), 1 deletion(-)

diff --git a/packages/taler-util/src/http-client/accept-header.test.ts b/packages/taler-util/src/http-client/accept-header.test.ts @@ -0,0 +1,50 @@ +/* + 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 { test } from "node:test"; +import assert from "node:assert"; +import { formatAcceptHeader } from "./exchange-client.js"; + +test("single format needs no weight", (t) => { + assert.strictEqual(formatAcceptHeader(["text/plain"]), "text/plain"); +}); + +test("later formats get descending weights", (t) => { + assert.strictEqual( + formatAcceptHeader(["text/markdown", "text/html"]), + "text/markdown, text/html;q=0.5", + ); + assert.strictEqual( + formatAcceptHeader(["text/plain", "text/markdown", "text/html"]), + "text/plain, text/markdown;q=0.667, text/html;q=0.333", + ); +}); + +test("weights stay valid qvalues", (t) => { + const formats: string[] = []; + for (let i = 0; i < 50; i++) { + formats.push(`text/x-${i}`); + } + const parts = formatAcceptHeader(formats).split(", "); + assert.strictEqual(parts.length, formats.length); + for (let i = 1; i < parts.length; i++) { + const weight = parts[i].split(";q=")[1]; + // At most three digits after the decimal point, and never zero, + // which would mean "not acceptable". + assert.match(weight, /^0\.\d{1,3}$/); + assert.ok(Number(weight) > 0); + } +}); diff --git a/packages/taler-util/src/http-client/exchange-client.ts b/packages/taler-util/src/http-client/exchange-client.ts @@ -165,6 +165,29 @@ export enum TalerExchangeCacheEviction { } /** + * Render @a formats, most preferred first, as an "Accept" header value. + * + * Position in the list carries no meaning in HTTP: entries without a weight + * all default to q=1, which makes them equally acceptable and leaves the + * choice to the server. Preference has to be expressed as descending weights + * (RFC 9110, section 12.5.1). + */ +export function formatAcceptHeader(formats: string[]): string { + return formats + .map((format, idx) => { + if (0 === idx) { + // Most preferred, q defaults to 1. + return format; + } + // Weights must stay above 0, as q=0 means "not acceptable", and + // carry at most three digits after the decimal point. + const weight = Math.max(0.001, (formats.length - idx) / formats.length); + return `${format};q=${weight.toFixed(3).replace(/0+$/, "")}`; + }) + .join(", "); +} + +/** * Client library for the GNU Taler exchange service. */ export class TalerExchangeHttpClient { @@ -309,7 +332,7 @@ export class TalerExchangeHttpClient { ): Promise<OperationOk<{ text: string }>> { const acceptFormats = args.acceptFormats ?? ["text/plain"]; const headers = { - Accept: acceptFormats.join(", "), + Accept: formatAcceptHeader(acceptFormats), "Accept-Language": args.acceptLanguage, }; const resp = await this.fetch("terms", {