taler-typescript-core

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

commit f3d9049e8848c4b0f6cc9f73f4a05c59b8173be7
parent 287e605f23f4e75ebb9b6f296d66dc59947a9b54
Author: Florian Dold <dold@taler.net>
Date:   Tue, 21 Jul 2026 01:24:34 +0200

util: make Codec invariant and fix the resulting mismatches

Codec<V> named V only in an output position and so was covariant, letting a
required codec satisfy an optional field and reject responses that omit it.
A phantom contravariant member makes it invariant, which surfaced a number of
codec/interface mismatches, fixed here and listed in BUGS.md.

Diffstat:
Mpackages/challenger-webui/src/hooks/session.ts | 5+++--
Mpackages/libeufin-bank-webui/src/hooks/bank-state.ts | 23+++++++++++++----------
Mpackages/libeufin-bank-webui/src/hooks/session.ts | 2+-
Mpackages/taler-exchange-aml-webui/src/hooks/decision-request.ts | 38+++++++++++++++++++-------------------
Mpackages/taler-exchange-aml-webui/src/hooks/officer.ts | 6+++---
Mpackages/taler-merchant-webui/src/context/session.ts | 8++++----
Mpackages/taler-merchant-webui/src/paths/instance/orders/details/Detail.stories.tsx | 6++++--
Mpackages/taler-util/src/codec.test.ts | 19+++++++++++++++++++
Mpackages/taler-util/src/codec.ts | 24++++++++++++++++++++++--
Mpackages/taler-util/src/types-taler-bank-integration.ts | 4++--
Mpackages/taler-util/src/types-taler-challenger.ts | 4++--
Mpackages/taler-util/src/types-taler-common.ts | 4+++-
Mpackages/taler-util/src/types-taler-corebank.ts | 4++--
Mpackages/taler-util/src/types-taler-exchange.ts | 45++++++++++++++++++++++-----------------------
Mpackages/taler-util/src/types-taler-mailbox.ts | 8++++----
Mpackages/taler-util/src/types-taler-merchant.ts | 99++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mpackages/taler-util/src/types-taler-wallet-transactions.ts | 2+-
Mpackages/taler-util/src/types-taler-wallet.ts | 28++++++++++++++--------------
Mpackages/taler-util/src/types-taler-wire-gateway.ts | 4++--
Mpackages/taler-wallet-webextension/src/wallet/QrReader.tsx | 2++
Mpackages/web-util/src/forms/forms-types.ts | 45++++++++++++++++++++++++++++++++++++++++-----
Mpackages/web-util/src/hooks/useLocalStorage.ts | 2+-
22 files changed, 251 insertions(+), 131 deletions(-)

diff --git a/packages/challenger-webui/src/hooks/session.ts b/packages/challenger-webui/src/hooks/session.ts @@ -54,8 +54,9 @@ interface LastAddress { } export type SessionState = { - completedURL: string | undefined; - lastAddress: Array<LastAddress> | undefined; + // Optional: decoded with codecOptional, so an absent field is missing. + completedURL?: string; + lastAddress?: Array<LastAddress>; }; export const codecForLastAddress = (): Codec<LastAddress> => buildCodecForObject<LastAddress>() diff --git a/packages/libeufin-bank-webui/src/hooks/bank-state.ts b/packages/libeufin-bank-webui/src/hooks/bank-state.ts @@ -46,7 +46,8 @@ type BaseChallenge<OpType extends string, ReqType> = { id: string; operation: OpType; sent: AbsoluteTime; - location: AppLocation | undefined; + // Optional: decoded with codecOptional, so an absent field is missing. + location?: AppLocation; // info?: TalerCorebankApi.TanTransmission; request: ReqType; }; @@ -87,7 +88,7 @@ const codecForChallengeUpdatePassword = (): Codec<UpdatePasswordChallenge> => buildCodecForObject<UpdatePasswordChallenge>() .property("operation", codecForConstString("update-password")) .property("id", codecForString()) - .property("location", codecForAppLocation()) + .property("location", codecOptional(codecForAppLocation())) .property("sent", codecForAbsoluteTime) // .property("info", codecOptional(codecForTanTransmission())) .property("request", codecForAny()) @@ -97,7 +98,7 @@ const codecForChallengeDeleteAccount = (): Codec<DeleteAccountChallenge> => buildCodecForObject<DeleteAccountChallenge>() .property("operation", codecForConstString("delete-account")) .property("id", codecForString()) - .property("location", codecForAppLocation()) + .property("location", codecOptional(codecForAppLocation())) .property("sent", codecForAbsoluteTime) .property("request", codecForString()) // .property("info", codecOptional(codecForTanTransmission())) @@ -107,7 +108,7 @@ const codecForChallengeUpdateAccount = (): Codec<UpdateAccountChallenge> => buildCodecForObject<UpdateAccountChallenge>() .property("operation", codecForConstString("update-account")) .property("id", codecForString()) - .property("location", codecForAppLocation()) + .property("location", codecOptional(codecForAppLocation())) .property("sent", codecForAbsoluteTime) // .property("info", codecOptional(codecForTanTransmission())) .property("request", codecForAny()) //FIXME: complete definition @@ -118,7 +119,7 @@ const codecForChallengeCreateTransaction = buildCodecForObject<CreateTransactionChallenge>() .property("operation", codecForConstString("create-transaction")) .property("id", codecForString()) - .property("location", codecForAppLocation()) + .property("location", codecOptional(codecForAppLocation())) .property("sent", codecForAbsoluteTime) // .property("info", codecOptional(codecForTanTransmission())) .property("request", codecForAny()) //FIXME: complete definition @@ -129,19 +130,20 @@ const codecForChallengeConfirmWithdrawal = buildCodecForObject<ConfirmWithdrawalChallenge>() .property("operation", codecForConstString("confirm-withdrawal")) .property("id", codecForString()) - .property("location", codecForAppLocation()) + .property("location", codecOptional(codecForAppLocation())) .property("sent", codecForAbsoluteTime) // .property("info", codecOptional(codecForTanTransmission())) .property("request", codecForAny()) //FIXME: complete definition .build("ConfirmWithdrawalChallenge"); -const codecForAppLocation = codecForString as () => Codec<AppLocation>; +const codecForAppLocation = + codecForString as unknown as () => Codec<AppLocation>; const codecForChallengeCashout = (): Codec<CashoutChallenge> => buildCodecForObject<CashoutChallenge>() .property("operation", codecForConstString("create-cashout")) .property("id", codecForString()) - .property("location", codecForAppLocation()) + .property("location", codecOptional(codecForAppLocation())) .property("sent", codecForAbsoluteTime) // .property("info", codecOptional(codecForTanTransmission())) .property("request", codecForAny()) //FIXME: complete definition @@ -170,8 +172,9 @@ const codecForChallenge = (): Codec<ChallengeInProgess> => .build("ChallengeInProgess"); interface BankState { - currentWithdrawalOperationId: string | undefined; - currentChallenge: ChallengeInProgess | undefined; + // Optional: decoded with codecOptional, so an absent field is missing. + currentWithdrawalOperationId?: string; + currentChallenge?: ChallengeInProgess; } export const codecForBankState = (): Codec<BankState> => diff --git a/packages/libeufin-bank-webui/src/hooks/session.ts b/packages/libeufin-bank-webui/src/hooks/session.ts @@ -68,7 +68,7 @@ export const codecForSessionStateLoggedIn = (): Codec<LoggedIn> => "expiration", codecOptionalDefault(codecForAbsoluteTime, AbsoluteTime.now()), ) - .property("token", codecForString() as Codec<AccessToken>) + .property("token", codecForString() as unknown as Codec<AccessToken>) .property("isUserAdministrator", codecForBoolean()) .build("SessionState.LoggedIn"); diff --git a/packages/taler-exchange-aml-webui/src/hooks/decision-request.ts b/packages/taler-exchange-aml-webui/src/hooks/decision-request.ts @@ -40,10 +40,10 @@ import { import { useState } from "preact/hooks"; export interface AccountAttributes { data: object; - formId: string | undefined; + formId?: string; formVersion: number; - expiration: AbsoluteTime | undefined; - errors: FormErrors<object> | undefined; + expiration?: AbsoluteTime; + errors?: FormErrors<object>; } /** * Information that will be ask to the AML officer @@ -52,64 +52,64 @@ export interface AccountAttributes { * With all of this we need to create a AmlDecisionRequest */ export interface DecisionRequest { - original: TalerExchangeApi.AmlDecision | undefined; + original?: TalerExchangeApi.AmlDecision; /** * Next legitimization rules */ - rules: KycRule[] | undefined; + rules?: KycRule[]; /** * Next active measure */ - new_measures: string[] | undefined; + new_measures?: string[]; /** * Next measure after deadline */ - onExpire_measure: string | undefined; + onExpire_measure?: string; /** * Deadline of this decision */ - deadline: AbsoluteTime | undefined; + deadline?: AbsoluteTime; /** * New information about the account */ - attributes: AccountAttributes | undefined; + attributes?: AccountAttributes; /** * Relevate state of the account */ - properties: Record<string, boolean | string> | undefined; + properties?: Record<string, boolean | string>; /** * Errors on the properties */ - properties_errors: object | undefined; + properties_errors?: object; /** * If given all the information, this account need to be investigated */ - keep_investigating: boolean | undefined; + keep_investigating?: boolean; /** * Description of the decision */ - justification: string | undefined; + justification?: string; /** * Name of the account holder if this is an unknown account to the exchange */ - accountName: string | undefined; + accountName?: string; /** * Custom properties not listed on GANA */ - custom_properties: Record<string, string> | undefined; + custom_properties?: Record<string, string>; /** * Supported events to be triggered */ - triggering_events: string[] | undefined; + triggering_events?: string[]; /** * Custom unsupported events to be triggered */ - custom_events: string[] | undefined; + custom_events?: string[]; /** * Custom measures defined by the officer */ - custom_measures: Record<string, MeasureInformation> | undefined; + custom_measures?: Record<string, MeasureInformation>; } export const codecForMeasure = (): Codec<MeasureInformation> => @@ -122,7 +122,7 @@ export const codecForMeasure = (): Codec<MeasureInformation> => export const codecForAccountAttributes = (): Codec<AccountAttributes> => buildCodecForObject<AccountAttributes>() .property("expiration", codecOptional(codecForAbsoluteTime)) - .property("formId", codecForString()) + .property("formId", codecOptional(codecForString())) .property("formVersion", codecForNumber()) .property("data", codecForAny()) .property("errors", codecForAny()) diff --git a/packages/taler-exchange-aml-webui/src/hooks/officer.ts b/packages/taler-exchange-aml-webui/src/hooks/officer.ts @@ -57,12 +57,12 @@ interface OfficerCompatible { * * remove when not used anymore, since 2026-04 */ - account: LockedAccount | undefined; - session: LockedAccount | undefined; + account?: LockedAccount; + session?: LockedAccount; when: AbsoluteTime; } -const codecForLockedAccount = codecForString() as Codec<LockedAccount>; +const codecForLockedAccount = codecForString() as unknown as Codec<LockedAccount>; type OfficerAccountString = { id: string; diff --git a/packages/taler-merchant-webui/src/context/session.ts b/packages/taler-merchant-webui/src/context/session.ts @@ -71,17 +71,17 @@ interface LoggedOut { interface SavedSession { backendUrl: URL; - token: AccessToken | undefined; - prevToken: AccessToken | undefined; + token?: AccessToken; + prevToken?: AccessToken; } export const codecForSessionState = (): Codec<SavedSession> => buildCodecForObject<SavedSession>() .property("backendUrl", codecForURL()) - .property("token", codecOptional(codecForString() as Codec<AccessToken>)) + .property("token", codecOptional(codecForString() as unknown as Codec<AccessToken>)) .property( "prevToken", - codecOptional(codecForString() as Codec<AccessToken>), + codecOptional(codecForString() as unknown as Codec<AccessToken>), ) .build("SavedSession"); diff --git a/packages/taler-merchant-webui/src/paths/instance/orders/details/Detail.stories.tsx b/packages/taler-merchant-webui/src/paths/instance/orders/details/Detail.stories.tsx @@ -19,7 +19,9 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { AmountString, MerchantContractTerms } from "@gnu-taler/taler-util"; +import { AmountString, MerchantContractTerms, + TalerUriString, +} from "@gnu-taler/taler-util"; import { addDays } from "date-fns"; import { FunctionalComponent, h } from "preact"; import { DetailPage as TestedComponent } from "./DetailPage.js"; @@ -134,7 +136,7 @@ export const Unpaid = createExample(TestedComponent, { t_s: new Date().getTime() / 1000, }, summary: "text summary", - taler_pay_uri: "pay uri", + taler_pay_uri: "pay uri" as TalerUriString, total_amount: "TESTKUDOS:10" as AmountString, }, }); diff --git a/packages/taler-util/src/codec.test.ts b/packages/taler-util/src/codec.test.ts @@ -29,6 +29,7 @@ import { codecForMap, buildCodecForUnion, DecodingError, + codecOptional, } from "./codec.js"; interface MyObj { @@ -112,3 +113,21 @@ test("union codec reports a decoding error for null", (t) => { ); } }); + +test("Codec is invariant, so optional/required mismatches are compile errors", (t) => { + // Type-level only: the check is that the two assignments below do not + // compile. If Codec becomes covariant again, tsc reports the directives + // as unused and this file stops building. + const req: Codec<string> = codecForString(); + const opt: Codec<string | undefined> = codecOptional(codecForString()); + + // @ts-expect-error a required codec must not satisfy an optional field + const a: Codec<string | undefined> = req; + // @ts-expect-error an optional codec must not satisfy a required field + const b: Codec<string> = opt; + + // Same type in both directions is of course fine. + const c: Codec<string> = req; + const d: Codec<string | undefined> = opt; + assert.ok(a === req && b === opt && c === req && d === opt); +}); diff --git a/packages/taler-util/src/codec.ts b/packages/taler-util/src/codec.ts @@ -67,6 +67,16 @@ export interface Codec<V> { * Decode untyped JSON to an object of type [[V]]. */ readonly decode: (x: any, c?: Context) => V; + + /** + * Phantom member, never present at runtime. + * + * Without it V occurs only in an output position and Codec<V> is + * covariant, so a required codec would satisfy an optional field and + * reject responses that legitimately omit it. Naming V in an input + * position too makes Codec invariant. + */ + readonly _invariant?: (x: V) => void; } /** @@ -80,7 +90,15 @@ export interface ObjectCodec<V> extends Codec<V> { readonly getProps: () => Prop[]; } -type SingletonRecord<K extends keyof any, V> = { [Y in K]: V }; +/** + * The record contributed by a single .property() call. + * + * A codec whose value type includes undefined describes an optional + * property, not a required one holding undefined. + */ +type SingletonRecord<K extends keyof any, V> = undefined extends V + ? { [Y in K]?: V } + : { [Y in K]: V }; interface Prop { name: string; @@ -667,7 +685,9 @@ export function codecForLazy<V>(innerCodec: () => Codec<V>): Codec<V> { export type CodecType<T> = T extends Codec<infer X> ? X : any; -export function codecForEither<T extends Array<Codec<unknown>>>( +// Codec<any>, not Codec<unknown>: Codec is invariant, and only `any` stays +// compatible in both directions. +export function codecForEither<T extends Array<Codec<any>>>( ...alts: [...T] ): Codec<CodecType<T[number]>> { return { diff --git a/packages/taler-util/src/types-taler-bank-integration.ts b/packages/taler-util/src/types-taler-bank-integration.ts @@ -162,7 +162,7 @@ export interface BankWithdrawalOperationPostResponse { // selected: the operations has been selected and is pending confirmation // aborted: the operation has been aborted // confirmed: the transfer has been confirmed and registered by the bank - status: Omit<"pending", WithdrawalOperationStatusFlag>; + status: "selected" | "aborted" | "confirmed"; // URL that the user needs to navigate to in order to // complete some final confirmation (e.g. 2FA). @@ -175,7 +175,7 @@ export interface BankWithdrawalOperationPostResponse { export const codecForBankVersion = (): Codec<BankVersion> => buildCodecForObject<BankVersion>() .property("currency", codecForCurrencyName()) - .property("currency_specification", codecForCurrencySpecificiation()) + .property("currency_specification", codecOptional(codecForCurrencySpecificiation())) .property("name", codecForConstString("taler-bank-integration")) .property("version", codecForLibtoolVersion()) .build("TalerBankIntegrationApi.BankVersion"); diff --git a/packages/taler-util/src/types-taler-challenger.ts b/packages/taler-util/src/types-taler-challenger.ts @@ -57,7 +57,7 @@ export interface ChallengerTermsOfServiceResponse { // by the user does not match the regex. Keys that are not mapped // to such an object have no restriction on the value provided by // the user. See "ADDRESS_RESTRICTIONS" in the challenger configuration. - restrictions: Record<string, Restriction> | undefined; + restrictions?: Record<string, Restriction> | undefined; // @since v2. address_type: "email" | "phone" | "postal" | "postal-ch"; @@ -81,7 +81,7 @@ export interface ChallengeStatus { // form values from the previous submission if available, details depend // on the ADDRESS_TYPE, should be used to pre-populate the form - last_address: Record<string, string> | undefined; + last_address?: Record<string, string> | undefined; // number of times the address can still be changed, may or may not be // shown to the user diff --git a/packages/taler-util/src/types-taler-common.ts b/packages/taler-util/src/types-taler-common.ts @@ -435,7 +435,9 @@ export const codecForTokenInfoList = (): Codec<TokenInfos> => .build("TokenInfoList"); //FIXME: implement this codec -export const codecForAccessToken = codecForString as () => Codec<AccessToken>; +// Codec is invariant, so a branded narrowing needs a two-step cast. +export const codecForAccessToken = + codecForString as unknown as () => Codec<AccessToken>; export const codecForTokenSuccessResponse = (): Codec<TokenSuccessResponse> => buildCodecForObject<TokenSuccessResponse>() .property("access_token", codecForAccessToken()) diff --git a/packages/taler-util/src/types-taler-corebank.ts b/packages/taler-util/src/types-taler-corebank.ts @@ -836,7 +836,7 @@ export const codecForCoreBankConfig = (): Codec<TalerCorebankConfigResponse> => ), ), ) - .property("wire_type", codecOptionalDefault(codecForString(), "iban")) + .property("wire_type", codecOptionalDefault(codecForString(), "iban") as unknown as Codec<string | undefined>) .property("wire_transfer_fees", codecOptional(codecForAmountString())) .property("min_wire_transfer_amount", codecOptional(codecForAmountString())) .property("max_wire_transfer_amount", codecOptional(codecForAmountString())) @@ -875,7 +875,7 @@ export const codecForAccountMinimalData = (): Codec<AccountMinimalData> => .property("name", codecForString()) .property("payto_uri", codecForPaytoString()) .property("balance", codecForBalance()) - .property("row_id", codecForNumber()) + .property("row_id", codecOptional(codecForNumber())) .property("debit_threshold", codecForAmountString()) .property("is_public", codecForBoolean()) .property("is_taler_exchange", codecForBoolean()) diff --git a/packages/taler-util/src/types-taler-exchange.ts b/packages/taler-util/src/types-taler-exchange.ts @@ -462,7 +462,7 @@ export interface ExchangeKeysResponse { // List of exchanges that this exchange is partnering // with to enable wallet-to-wallet transfers. - wads: any; + wads?: any; // Compact EdDSA signature (binary-only) over the // contatentation of all of the master_sigs (in reverse @@ -744,12 +744,12 @@ export class WireFeesJson { /** * Cost of a wire transfer. */ - wire_fee: string; + wire_fee: AmountString; /** * Cost of clising a reserve. */ - closing_fee: string; + closing_fee: AmountString; /** * Signature made with the exchange's master key. @@ -1132,30 +1132,29 @@ export namespace DenomKeyType { } } -// export interface RsaBlindedDenominationSignature { -// cipher: DenomKeyType.Rsa; -// blinded_rsa_signature: string; -// } - -// export interface CSBlindedDenominationSignature { -// cipher: DenomKeyType.ClauseSchnorr; -// } - -// export type BlindedDenominationSignature = -// | RsaBlindedDenominationSignature -// | CSBlindedDenominationSignature; - export const codecForRsaBlindedDenominationSignature = () => buildCodecForObject<RsaBlindedDenominationSignature>() .property("cipher", codecForConstString(DenomKeyType.Rsa)) .property("blinded_rsa_signature", codecForString()) .build("RsaBlindedDenominationSignature"); -export const codecForBlindedDenominationSignature = () => - buildCodecForUnion<BlindedDenominationSignature>() - .discriminateOn("cipher") - .alternative(DenomKeyType.Rsa, codecForRsaBlindedDenominationSignature()) - .build("BlindedDenominationSignature"); +export const codecForCsBlindedDenominationSignature = () => + buildCodecForObject<CSBlindedDenominationSignature>() + .property("cipher", codecForConstString(DenomKeyType.ClauseSchnorr)) + .property("b", codecForNumber()) + .property("s", codecForString()) + .build("CSBlindedDenominationSignature"); + +export const codecForBlindedDenominationSignature = + (): Codec<BlindedDenominationSignature> => + buildCodecForUnion<BlindedDenominationSignature>() + .discriminateOn("cipher") + .alternative(DenomKeyType.Rsa, codecForRsaBlindedDenominationSignature()) + .alternative( + DenomKeyType.ClauseSchnorr, + codecForCsBlindedDenominationSignature(), + ) + .build("BlindedDenominationSignature"); export const codecForExchangeLegacyWithdrawBatchResponse = (): Codec<ExchangeLegacyWithdrawBatchResponse> => @@ -1615,7 +1614,7 @@ export interface TrackTransactionAccepted { // credit the exchange via an inbound wire transfer // to associate a public key with the debited account. // @since protocol **v20**. - account_pub: EddsaPublicKeyString | undefined; + account_pub?: EddsaPublicKeyString | undefined; } export const codecForTackTransactionAccepted = @@ -2903,7 +2902,7 @@ export const codecForKycRequirementInformationId = (): Codec<KycRequirementInformationId> => codecForString() as Codec<KycRequirementInformationId>; export const codecForKycFormId = (): Codec<KycBuiltInFromId> => - codecForString() as Codec<KycBuiltInFromId>; + codecForString() as unknown as Codec<KycBuiltInFromId>; export const codecForKycRequirementInformation = (): Codec<KycRequirementInformation> => diff --git a/packages/taler-util/src/types-taler-mailbox.ts b/packages/taler-util/src/types-taler-mailbox.ts @@ -134,9 +134,9 @@ export interface MailboxRegisterRequest { export const codecForTalerMailboxMetadata = (): Codec<MailboxMetadata> => buildCodecForObject<MailboxMetadata>() .property("signing_key", codecForEddsaPublicKey()) - .property("signing_key_type", codecForConstString("EdDSA")) + .property("signing_key_type", codecOptional(codecForConstString("EdDSA"))) .property("encryption_key", codecForString()) - .property("encryption_key_type", codecForConstString("X25519")) + .property("encryption_key_type", codecOptional(codecForConstString("X25519"))) .property("expiration", codecForTimestamp) .build("TalerMailboxApi.MailboxMessageKeys"); @@ -150,7 +150,7 @@ export interface MailboxMetadata { // Type of key. // Optional, as currently only // EdDSA keys are supported. - signing_key_type?: string; + signing_key_type?: "EdDSA"; // The mailbox encryption key. // This is an HPKE public key @@ -162,7 +162,7 @@ export interface MailboxMetadata { // Type of key. // Optional, as currently only // X25519 keys are supported. - encryption_key_type?: string; + encryption_key_type?: "X25519"; // Expiration of this mapping. expiration: Timestamp; diff --git a/packages/taler-util/src/types-taler-merchant.ts b/packages/taler-util/src/types-taler-merchant.ts @@ -16,7 +16,9 @@ SPDX-License-Identifier: AGPL-3.0-or-later */ -import { codecForAmountString } from "./amounts.js"; +import { codecForAmountString, + codecForCurrency, +} from "./amounts.js"; import { Codec, buildCodecForObject, @@ -90,6 +92,7 @@ import { codecForTransferSubject, } from "./types-taler-prepared-transfer.js"; import { PayWalletData, codecForCanonBaseUrl } from "./types-taler-wallet.js"; +import { TalerUriString } from "./taleruri.js"; /** * Proposal returned from the contract URL. @@ -101,7 +104,7 @@ export interface MerchantClaimResponse { * Contract terms for the propoal. * Raw, un-decoded JSON object. */ - contract_terms: any; + contract_terms?: any; /** * Signature over contract, made by the merchant. The public key used for signing @@ -205,11 +208,11 @@ interface MerchantOrderRefundResponse { */ export class CheckPaymentResponse { order_status: string; - refunded: boolean | undefined; - refunded_amount: string | undefined; - contract_terms: any | undefined; - taler_pay_uri: string | undefined; - contract_url: string | undefined; + refunded?: boolean | undefined; + refunded_amount?: string | undefined; + contract_terms?: any | undefined; + taler_pay_uri?: string | undefined; + contract_url?: string | undefined; } export const codecForMerchantRefundPermission = @@ -912,12 +915,12 @@ export interface MerchantAbortPayRefundDetails { /** * Amount to be refunded. */ - refund_amount: string; + refund_amount: AmountString; /** * Fee for the refund. */ - refund_fee: string; + refund_fee: AmountString; /** * Public key of the coin being refunded. @@ -1028,7 +1031,7 @@ const codecForMerchantContractTermsCommon = .property("merchant_pub", codecForString()) .property("exchanges", codecForList(codecForExchange())) .property("products", codecOptional(codecForList(codecForProductSold()))) - .property("extra", codecForAny()) + .property("extra", codecOptional(codecForAny())) .property("minimum_age", codecOptional(codecForNumber())) .property("default_money_pot", codecOptional(codecForNumber())) .build("TalerMerchantApi.ContractTermsCommon"); @@ -1371,7 +1374,7 @@ export interface ClaimRequest { export interface ClaimResponse { // Contract terms of the claimed order - contract_terms: any; + contract_terms?: any; // Signature by the merchant over the contract terms. sig: EddsaSignatureString; @@ -1714,7 +1717,7 @@ export interface StatusGotoResponse { export interface StatusUnpaidResponse { type: "unpaid"; // URI that the wallet must process to complete the payment. - taler_pay_uri: string; + taler_pay_uri: TalerUriString; // Status URL, can be used as a redirect target for the browser // to show the order QR code / trigger the wallet. @@ -3039,7 +3042,7 @@ export interface CheckPaymentUnpaidResponse { order_status: "unpaid"; // URI that the wallet must process to complete the payment. - taler_pay_uri: string; + taler_pay_uri: TalerUriString; // Time when the order was created. creation_time: Timestamp; @@ -3146,7 +3149,7 @@ export interface MerchantRefundResponse { // URL (handled by the backend) that the wallet should access to // trigger refund processing. // taler://refund/... - taler_refund_uri: string; + taler_refund_uri: TalerUriString; // Contract hash that a client may need to authenticate an // HTTP request to obtain the above URI in a wallet-friendly way. @@ -4523,6 +4526,7 @@ export const codecForTalerMerchantConfigResponse = "default_persona", codecOptionalDefault( codecForEither( + codecForConstString("tester"), codecForConstString("expert"), codecForConstString("offline-vending-machine"), codecForConstString("point-of-sale"), @@ -4543,7 +4547,12 @@ export const codecForTalerMerchantConfigResponse = .property("implementation", codecOptional(codecForString())) .property( "have_self_provisioning", - codecOptionalDefault(codecForBoolean(), false), + + // Safe for an optional field: this never yields undefined. Codec<V> + // describes output, not which inputs are tolerated. + codecOptionalDefault(codecForBoolean(), false) as unknown as Codec< + boolean | undefined + >, ) .property("have_donau", codecOptionalDefault(codecForBoolean(), false)) .property( @@ -4563,7 +4572,12 @@ export const codecForTalerMerchantConfigResponse = .property("default_wire_transfer_delay", codecOptional(codecForDuration)) .property( "payment_target_regex", - codecOptionalDefault(codecForString(), "*"), + + // Safe for an optional field: this never yields undefined. Codec<V> + // describes output, not which inputs are tolerated. + codecOptionalDefault(codecForString(), "*") as unknown as Codec< + string | undefined + >, ) .property( "payment_target_types", @@ -4651,8 +4665,8 @@ export const codecForMerchantAbortPayRefundSuccessStatus = export const codecForMerchantAbortPayRefundFailureStatus = (): Codec<MerchantAbortPayRefundFailureStatus> => buildCodecForObject<MerchantAbortPayRefundFailureStatus>() - .property("exchange_code", codecForNumber()) - .property("exchange_reply", codecForAny()) + .property("exchange_code", codecOptional(codecForNumber())) + .property("exchange_reply", codecOptional(codecForAny())) .property("exchange_status", codecForNumber()) .property("type", codecForConstString("failure")) .build("TalerMerchantApi.MerchantAbortPayRefundFailureStatus"); @@ -4774,6 +4788,7 @@ export const codecForMerchantAccountKycStatus = codecForEither( codecForConstString(MerchantAccountKycStatus.KYC_WIRE_IMPOSSIBLE), codecForConstString(MerchantAccountKycStatus.KYC_WIRE_REQUIRED), codecForConstString(MerchantAccountKycStatus.LOGIC_BUG), + codecForConstString(MerchantAccountKycStatus.MERCHANT_INTERNAL_ERROR), codecForConstString(MerchantAccountKycStatus.NO_EXCHANGE_KEY), codecForConstString(MerchantAccountKycStatus.READY), ); @@ -4795,7 +4810,12 @@ export const codecForMerchantAccountKycRedirect = .property("payto_kycauths", codecOptional(codecForList(codecForString()))) .property( "kyc_swap_tos_acceptance", - codecOptionalDefault(codecForBoolean(), false), + + // Safe for an optional field: this never yields undefined. Codec<V> + // describes output, not which inputs are tolerated. + codecOptionalDefault(codecForBoolean(), false) as unknown as Codec< + boolean | undefined + >, ) .property("tos_accepted_early", codecOptional(codecForString())) .build("TalerMerchantApi.MerchantAccountKycRedirect"); @@ -4872,14 +4892,14 @@ export const codecForCategoryListEntry = (): Codec<CategoryListEntry> => buildCodecForObject<CategoryListEntry>() .property("category_id", codecForNumber()) .property("name", codecForString()) - .property("name_i18n", codecForInternationalizedString()) + .property("name_i18n", codecOptional(codecForInternationalizedString())) .property("product_count", codecForNumber()) .build("TalerMerchantApi.CategoryListEntry"); export const codecForCategoryProductList = (): Codec<CategoryProductList> => buildCodecForObject<CategoryProductList>() .property("name", codecForString()) - .property("name_i18n", codecForInternationalizedString()) + .property("name_i18n", codecOptional(codecForInternationalizedString())) .property("products", codecForList(codecForProductSummary())) .build("TalerMerchantApi.CategoryProductList"); @@ -4911,9 +4931,9 @@ export const codecForMerchantPosProductDetail = .property("description_i18n", codecForInternationalizedString()) .property("unit", codecForString()) .property("price", codecForAmountString()) - .property("image", codecForString()) + .property("image", codecOptional(codecForString())) .property("taxes", codecOptional(codecForList(codecForTax()))) - .property("total_stock", codecForNumber()) + .property("total_stock", codecOptional(codecForNumber())) .property("minimum_age", codecOptional(codecForNumber())) .build("TalerMerchantApi.MerchantPosProductDetail"); @@ -4921,7 +4941,7 @@ export const codecForMerchantCategory = (): Codec<MerchantCategory> => buildCodecForObject<MerchantCategory>() .property("id", codecForNumber()) .property("name", codecForString()) - .property("name_i18n", codecForInternationalizedString()) + .property("name_i18n", codecOptional(codecForInternationalizedString())) .build("TalerMerchantApi.MerchantCategory"); export const codecForFullInventoryDetailsResponse = @@ -5046,7 +5066,7 @@ export const codecForOrderV0 = (): Codec<OrderV0> => export const codecForOrderV1 = (): Codec<OrderV1> => buildCodecForObject<OrderV1>() .property("version", codecForConstNumber(OrderVersion.V1)) - .property("choices", codecForList(codecForOrderChoice())) + .property("choices", codecOptional(codecForList(codecForOrderChoice()))) .mixin(codecForOrderCommon()) .build("TalerMerchantApi.OrderV1"); @@ -5255,9 +5275,11 @@ export const codecForExpectedTransferDetails = buildCodecForObject<ExpectedTransferDetails>() .property( "reconciliation_details", - codecForList(codecForExchangeTransferReconciliationDetails()), + codecOptional( + codecForList(codecForExchangeTransferReconciliationDetails()), + ), ) - .property("wire_fee", codecForAmountString()) + .property("wire_fee", codecOptional(codecForAmountString())) .build("TalerMerchantApi.ExpectedTransferDetails"); export const codecForTransferDetails = (): Codec<TransferDetails> => @@ -5352,11 +5374,21 @@ export const codecForTemplateContractInventoryCart = .property("selected_all", codecOptional(codecForBoolean())) .property( "selected_categories", - codecOptionalDefault(codecForList(codecForNumber()), []), + + // Safe for an optional field: this never yields undefined. Codec<V> + // describes output, not which inputs are tolerated. + codecOptionalDefault(codecForList(codecForNumber()), []) as unknown as Codec< + number[] | undefined + >, ) .property( "selected_products", - codecOptionalDefault(codecForList(codecForString()), []), + + // Safe for an optional field: this never yields undefined. Codec<V> + // describes output, not which inputs are tolerated. + codecOptionalDefault(codecForList(codecForString()), []) as unknown as Codec< + string[] | undefined + >, ) .property("inventory_payload", codecOptional(codecForAny())) // FIXME: validate @@ -5418,7 +5450,12 @@ export const codecForTemplateContractDetailsDefaults = buildCodecForObject<TemplateContractDetailsDefaults>() .property("summary", codecOptional(codecForString())) .property("currency", codecOptional(codecForString())) - .property("amount", codecOptional(codecForAmountString())) + .property( + // Documented as an amount or a plain currency, so an amount-only + // codec would reject a legal value. + "amount", + codecOptional(codecForString()), + ) .allowExtra() .build("TalerMerchantApi.TemplateContractDetailsDefaults"); @@ -5485,7 +5522,7 @@ export const codecForTokenFamilySummary = (): Codec<TokenFamilySummary> => .property("slug", codecForString()) .property("name", codecForString()) .property("description", codecForString()) - .property("description_i18n", codecForInternationalizedString()) + .property("description_i18n", codecOptional(codecForInternationalizedString())) .property("valid_after", codecForTimestamp) .property("valid_before", codecForTimestamp) .property("kind", codecForTokenFamilyKind) diff --git a/packages/taler-util/src/types-taler-wallet-transactions.ts b/packages/taler-util/src/types-taler-wallet-transactions.ts @@ -958,7 +958,7 @@ export const codecForGetTransactionsV2Request = .property("scopeInfo", codecOptional(codecForScopeInfo())) .property( "offsetTransactionId", - codecOptional(codecForString() as Codec<TransactionIdStr>), + codecOptional(codecForString() as unknown as Codec<TransactionIdStr>), ) .property("offsetTimestamp", codecOptional(codecForPreciseTimestamp)) .property("limit", codecOptional(codecForNumber())) diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -719,7 +719,7 @@ export interface ConfirmPayResultDone { export interface ConfirmPayResultPending { type: ConfirmPayResultType.Pending; transactionId: TransactionIdStr; - lastError: TalerErrorDetail | undefined; + lastError?: TalerErrorDetail | undefined; } export const codecForTalerErrorDetail = (): Codec<TalerErrorDetail> => @@ -1049,7 +1049,7 @@ export const codecForPreparePayResultAlreadyConfirmed = .property("scopes", codecForList(codecForScopeInfo())) .property("paid", codecForBoolean()) .property("talerUri", codecForString()) - .property("contractTerms", codecForMerchantContractTermsV0()) + .property("contractTerms", codecForMerchantContractTerms()) .property("contractTermsHash", codecForString()) .property("transactionId", codecForTransactionIdStr()) .build("PreparePayResultAlreadyConfirmed"); @@ -1166,7 +1166,7 @@ export interface PreparePayResultAlreadyConfirmed { amountRaw: AmountString; - amountEffective: AmountString | undefined; + amountEffective?: AmountString | undefined; /** * Scopes involved in this transaction. @@ -1476,7 +1476,7 @@ export interface TaldirLookupResponse { export const codecForTaldirLookupResponse = (): Codec<TaldirLookupResponse> => buildCodecForObject<TaldirLookupResponse>() - .property("targetUri", codecForString()) + .property("targetUri", codecOptional(codecForString())) .build("TaldirLookupResponse"); @@ -2382,7 +2382,7 @@ export interface ForceExchangeUpdateRequest { export const codecForForceExchangeUpdateRequest = (): Codec<AddExchangeRequest> => buildCodecForObject<AddExchangeRequest>() - .property("exchangeBaseUrl", codecForCanonBaseUrl()) + .property("exchangeBaseUrl", codecOptional(codecForCanonBaseUrl())) .build("AddExchangeRequest"); export interface GetExchangeTosRequest { @@ -2470,7 +2470,7 @@ export interface PrepareBankIntegratedWithdrawalResponse { export interface ConfirmWithdrawalRequest { transactionId: string; exchangeBaseUrl: string; - amount: AmountString | undefined; + amount?: AmountString | undefined; forcedDenomSel?: ForcedDenomSel; restrictAge?: number; } @@ -2479,7 +2479,7 @@ export const codecForConfirmWithdrawalRequestRequest = (): Codec<ConfirmWithdrawalRequest> => buildCodecForObject<ConfirmWithdrawalRequest>() .property("transactionId", codecForString()) - .property("amount", codecForAmountString()) + .property("amount", codecOptional(codecForAmountString())) .property("exchangeBaseUrl", codecForCanonBaseUrl()) .property("forcedDenomSel", codecForAny()) .property("restrictAge", codecOptional(codecForNumber())) @@ -3077,7 +3077,7 @@ export interface RefreshCoinSpec { export const codecForRefreshCoinSpec = (): Codec<RefreshCoinSpec> => buildCodecForObject<RefreshCoinSpec>() - .property("amount", codecForAmountString()) + .property("amount", codecOptional(codecForAmountString())) .property("coinPub", codecForString()) .build("ForceRefreshRequest"); @@ -3377,11 +3377,11 @@ export const codecForWithdrawFakebankRequest = export interface ActiveTask { taskId: string; - transaction: TransactionIdStr | undefined; - firstTry: AbsoluteTime | undefined; - nextTry: AbsoluteTime | undefined; - retryCounter: number | undefined; - lastError: TalerErrorDetail | undefined; + transaction?: TransactionIdStr | undefined; + firstTry?: AbsoluteTime | undefined; + nextTry?: AbsoluteTime | undefined; + retryCounter?: number | undefined; + lastError?: TalerErrorDetail | undefined; } export interface GetActiveTasksResponse { @@ -3404,7 +3404,7 @@ export const codecForGetActiveTasks = (): Codec<GetActiveTasksResponse> => .build("GetActiveTasks"); export interface ImportDbRequest { - dump: any; + dump?: any; } export const codecForImportDbRequest = (): Codec<ImportDbRequest> => diff --git a/packages/taler-util/src/types-taler-wire-gateway.ts b/packages/taler-util/src/types-taler-wire-gateway.ts @@ -393,7 +393,7 @@ export const codecForTransferResponse = export const codecForIncomingHistory = (): Codec<TalerWireGatewayApi.IncomingHistory> => buildCodecForObject<TalerWireGatewayApi.IncomingHistory>() - .property("credit_account", codecForPaytoString()) + .property("credit_account", codecOptional(codecForPaytoString())) .property( "incoming_transactions", codecForList(codecForIncomingBankTransaction()), @@ -452,7 +452,7 @@ export const codecForIncomingWadTransaction = export const codecForOutgoingHistory = (): Codec<TalerWireGatewayApi.OutgoingHistory> => buildCodecForObject<TalerWireGatewayApi.OutgoingHistory>() - .property("debit_account", codecForPaytoString()) + .property("debit_account", codecOptional(codecForPaytoString())) .property( "outgoing_transactions", codecForList(codecForOutgoingBankTransaction()), diff --git a/packages/taler-wallet-webextension/src/wallet/QrReader.tsx b/packages/taler-wallet-webextension/src/wallet/QrReader.tsx @@ -297,6 +297,8 @@ function translateTalerUriError( return i18n.str`The exchange host is invalid`; case TalerUriAction.AddContact: return i18n.str`The contact is invalid`; + case TalerUriAction.Restore: + return i18n.str`The backup provider list is invalid`; case TalerUriAction.WithdrawExchange: switch (result.detail.pos) { case 0: diff --git a/packages/web-util/src/forms/forms-types.ts b/packages/web-util/src/forms/forms-types.ts @@ -32,6 +32,7 @@ import { Integer, TalerProtocolTimestamp, TranslatedString, + codecForEither, } from "@gnu-taler/taler-util"; export type FormDesign = DoubleColumnFormDesign | SingleColumnFormDesign; @@ -179,7 +180,8 @@ type UIFormFieldChoiceHorizontal = { type UIFormFieldDrilldown = { type: "drilldown"; - choices: any; + // Optional: codecForAny() yields undefined for an absent field. + choices?: any; } & UIFormFieldBaseConfig; type UIFormFieldChoiceStacked = { @@ -443,7 +445,10 @@ const codecForUiFormSelectUiChoice = (): Codec<SelectUiChoice> => buildCodecForObject<SelectUiChoice>() .property("description", codecOptional(codecForString())) .property("label", codecForString()) - .property("value", codecForString()) + .property( + "value", + codecForEither(codecForString(), codecForBoolean()), + ) .build("SelectUiChoice"); const codecForUiFormFieldChoiceHorizontal = @@ -453,6 +458,13 @@ const codecForUiFormFieldChoiceHorizontal = .property("choices", codecForList(codecForUiFormSelectUiChoice())) .build("UIFormFieldChoiseHorizontal"); +const codecForUiFormFieldDrilldown = (): Codec<UIFormFieldDrilldown> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldDrilldown>() + .property("type", codecForConstString("drilldown")) + // Declared `any` on this variant, so nothing stricter to validate. + .property("choices", codecForAny()) + .build("UIFormFieldDrilldown"); + const codecForUiFormFieldChoiceStacked = (): Codec<UIFormFieldChoiceStacked> => codecForUIFormFieldBaseConfigTemplate<UIFormFieldChoiceStacked>() .property("type", codecForConstString("choiceStacked")) @@ -535,10 +547,25 @@ const codecForUiFormFieldTextArea = (): Codec<UIFormFieldTextArea> => const codecForUiFormFieldToggle = (): Codec<UIFormFieldToggle> => codecForUIFormFieldBaseConfigTemplate<UIFormFieldToggle>() - .property("threeState", codecOptionalDefault(codecForBoolean(), false)) + .property( + "threeState", + // Safe for an optional field: this never yields undefined. Codec<V> + // describes output, not which inputs are tolerated. + codecOptionalDefault(codecForBoolean(), false) as unknown as Codec< + boolean | undefined + >, + ) .property("falseValue", codecForAny()) .property("trueValue", codecForAny()) - .property("onlyTrueValue", codecOptionalDefault(codecForBoolean(), false)) + .property( + "onlyTrueValue", + // Supplying a default is safe for an optional field: this never + // yields undefined. Codec<V> describes a codec's output, not which + // inputs it tolerates, so invariance cannot see that. + codecOptionalDefault(codecForBoolean(), false) as unknown as Codec< + boolean | undefined + >, + ) .property("type", codecForConstString("toggle")) .build("UIFormFieldToggle"); @@ -556,6 +583,7 @@ const codecForUiFormField = (): Codec<UIFormElementConfig> => .alternative("caption", codecForUiFormFieldCaption()) .alternative("htmlIframe", codecForUiFormFieldHtmlIFrame()) .alternative("choiceHorizontal", codecForUiFormFieldChoiceHorizontal()) + .alternative("drilldown", codecForUiFormFieldDrilldown()) .alternative("choiceStacked", codecForUiFormFieldChoiceStacked()) .alternative("file", codecForUiFormFieldFile()) .alternative("integer", codecForUiFormFieldInteger()) @@ -602,7 +630,14 @@ const codecForFormMetadata = (): Codec<FormMetadata> => .property("description", codecOptional(codecForString())) .property("id", codecForString()) .property("version", codecForNumber()) - .property("config", codecForFormDesign()) + .property( + "config", + // A FormDesign may also be given as a function, which JSON cannot + // represent, so decoding only yields the data form. + codecForFormDesign() as unknown as Codec< + FormDesign | ((context: any) => FormDesign) + >, + ) .build("FormMetadata"); export const codecForUIForms = (): Codec<UiForms> => diff --git a/packages/web-util/src/hooks/useLocalStorage.ts b/packages/web-util/src/hooks/useLocalStorage.ts @@ -47,7 +47,7 @@ export function buildStorageKey<Key = string>( ): StorageKey<Key> { return { id: name, - codec: codec ?? (codecForString() as Codec<Key>), + codec: codec ?? (codecForString() as unknown as Codec<Key>), } as StorageKey<Key>; }