taler-typescript-core

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

commit 8b928a2b699a99ed856966a2754009826ca62ef9
parent be382b2eabb840f0bfa48cee04206b7c888c0a45
Author: Florian Dold <dold@taler.net>
Date:   Thu, 20 Aug 2026 19:06:42 +0200

wallet-core: preserve SQLite routing and reject incompatible schemas

Diffstat:
Mpackages/taler-wallet-core/src/db-sqlite-migrations.test.ts | 53++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/taler-wallet-core/src/db-sqlite-schema.ts | 12+++++++++++-
Mpackages/taler-wallet-core/src/dbtx-conformance-cases.ts | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/dbtx-sqlite.ts | 57++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
4 files changed, 172 insertions(+), 3 deletions(-)

diff --git a/packages/taler-wallet-core/src/db-sqlite-migrations.test.ts b/packages/taler-wallet-core/src/db-sqlite-migrations.test.ts @@ -28,7 +28,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; -import { SchemaMigration, schemaMigrations } from "./db-sqlite-schema.js"; +import { + SchemaMigration, + SQLITE_SCHEMA_VERSION, + schemaMigrations, +} from "./db-sqlite-schema.js"; import { initSqliteWalletDb } from "./dbtx-sqlite.js"; /** @@ -310,3 +314,50 @@ test("migration versions must strictly increase", async () => { cleanup(); } }); + +test("a newer sqlite schema is rejected without being relabeled", async () => { + const { path, cleanup } = withTempDb(); + try { + let db = await openRaw(path); + await initSqliteWalletDb(db); + const futureVersion = SQLITE_SCHEMA_VERSION + 1; + await db.exec(`PRAGMA user_version = ${futureVersion}`); + await db.close(); + + db = await openRaw(path); + await assert.rejects( + initSqliteWalletDb(db), + /newer.*schema|schema.*newer|version/i, + ); + const version = await queryAll(db, "PRAGMA user_version"); + assert.strictEqual(Number(version[0].user_version), futureVersion); + await db.close(); + } finally { + cleanup(); + } +}); + +test("a mismatched schema migration name is rejected", async () => { + const { path, cleanup } = withTempDb(); + try { + let db = await openRaw(path); + await initSqliteWalletDb(db); + await ( + await db.prepare( + "UPDATE schema_migrations SET name = 'impostor' WHERE version = 9", + ) + ).run({}); + await db.close(); + + db = await openRaw(path); + await assert.rejects(initSqliteWalletDb(db), /expected wallet-query-indexes/); + const rows = await queryAll( + db, + "SELECT name FROM schema_migrations WHERE version = 9", + ); + assert.strictEqual(rows[0].name, "impostor"); + await db.close(); + } finally { + cleanup(); + } +}); diff --git a/packages/taler-wallet-core/src/db-sqlite-schema.ts b/packages/taler-wallet-core/src/db-sqlite-schema.ts @@ -85,7 +85,7 @@ * * Bump this when adding a migration to {@link schemaMigrations}. */ -export const SQLITE_SCHEMA_VERSION = 9; +export const SQLITE_SCHEMA_VERSION = 10; /** * Tables of the IndexedDB emulation, children before parents. @@ -1339,6 +1339,16 @@ export const schemaMigrations: SchemaMigration[] = [ "CREATE INDEX coins_by_master_pub_denom_age_status_pub ON coins (exchange_master_pub, denom_pub_hash, max_age, status, coin_pub)", ], }, + { + version: 10, + name: "unique-peer-payment-capabilities", + statements: [ + "DROP INDEX peer_push_credit_by_exchange_and_contract_priv", + "CREATE UNIQUE INDEX peer_push_credit_by_exchange_and_contract_priv ON peer_push_credit (exchange_base_url, contract_priv)", + "DROP INDEX peer_pull_debit_by_exchange_and_contract_priv", + "CREATE UNIQUE INDEX peer_pull_debit_by_exchange_and_contract_priv ON peer_pull_debit (exchange_base_url, contract_priv)", + ], + }, ]; /** Native tables that contain wallet records (not schema bookkeeping). */ diff --git a/packages/taler-wallet-core/src/dbtx-conformance-cases.ts b/packages/taler-wallet-core/src/dbtx-conformance-cases.ts @@ -802,6 +802,11 @@ export const conformanceCases: ConformanceCase[] = [ ); t.equal(shared[0].denomPubHash, ckh("dph-a")); t.equal( + shared[0].exchangeBaseUrl, + "https://e2/", + "upserting a denomination through a new URL must update its routing hint", + ); + t.equal( other.length, 1, "the same hash under another key must be its own row", @@ -3762,6 +3767,31 @@ export const conformanceCases: ConformanceCase[] = [ }, { + name: "peer push credit: exchange and contract private key are unique", + async run(t, runner) { + const first = makePeerPushCredit("ppc-unique-1"); + first.contractPriv = ck("shared-push-contract-priv"); + const duplicate = makePeerPushCredit("ppc-unique-2"); + duplicate.contractPriv = first.contractPriv; + await runner.runReadWriteTx((tx) => tx.upsertPeerPushCredit(first)); + let rejected = false; + try { + await runner.runReadWriteTx((tx) => + tx.upsertPeerPushCredit(duplicate), + ); + } catch { + rejected = true; + } + t.ok(rejected, "a duplicate payment capability must be rejected"); + const records = await runner.runReadWriteTx((tx) => + tx.listAllPeerPushCredits(), + ); + t.equal(records.length, 1, "the original payment must be retained"); + t.equal(records[0].peerPushCreditId, first.peerPushCreditId); + }, + }, + + { name: "peer pull debit: round trip, contract-priv lookup, active range", async run(t, runner) { const rec = makePeerPullDebit("ppld-1"); @@ -3800,6 +3830,29 @@ export const conformanceCases: ConformanceCase[] = [ }, { + name: "peer pull debit: exchange and contract private key are unique", + async run(t, runner) { + const first = makePeerPullDebit("ppld-unique-1"); + first.contractPriv = ck("shared-pull-contract-priv"); + const duplicate = makePeerPullDebit("ppld-unique-2"); + duplicate.contractPriv = first.contractPriv; + await runner.runReadWriteTx((tx) => tx.upsertPeerPullDebit(first)); + let rejected = false; + try { + await runner.runReadWriteTx((tx) => tx.upsertPeerPullDebit(duplicate)); + } catch { + rejected = true; + } + t.ok(rejected, "a duplicate payment capability must be rejected"); + const records = await runner.runReadWriteTx((tx) => + tx.listAllPeerPullDebits(), + ); + t.equal(records.length, 1, "the original payment must be retained"); + t.equal(records[0].peerPullDebitId, first.peerPullDebitId); + }, + }, + + { name: "peer pull credit: round trip and active range", async run(t, runner) { const rec = makePeerPullCredit("pplc-1"); diff --git a/packages/taler-wallet-core/src/dbtx-sqlite.ts b/packages/taler-wallet-core/src/dbtx-sqlite.ts @@ -221,6 +221,37 @@ function validateSchemaMigrations(migrations: SchemaMigration[]): void { } } +function validateAppliedSchemaMigrations( + applied: ResultRow[], + extraMigrations: SchemaMigration[], +): void { + const expected = new Map<number, string>([[1, "baseline"]]); + for (const migration of [...schemaMigrations, ...extraMigrations]) { + const previous = expected.get(migration.version); + if (previous !== undefined && previous !== migration.name) { + throw Error( + `schema migration ${migration.version} has conflicting names` + + ` (${previous} and ${migration.name})`, + ); + } + expected.set(migration.version, migration.name); + } + for (const row of applied) { + const version = Number(row.version); + const name = String(row.name); + const expectedName = expected.get(version); + if (expectedName === undefined) { + throw Error(`database records unknown schema migration ${version}`); + } + if (name !== expectedName) { + throw Error( + `database schema migration ${version} is named ${name},` + + ` expected ${expectedName}`, + ); + } + } +} + /** * Open the database and bring its schema up to date. * @@ -239,6 +270,28 @@ export async function initSqliteWalletDb( validateSchemaMigrations(migrations); await db.exec("PRAGMA foreign_keys = ON"); await db.exec(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`); + const versionRows = await ( + await db.prepare("PRAGMA user_version") + ).getAll(); + const databaseVersion = Number(versionRows[0]?.user_version ?? 0); + if (databaseVersion > SQLITE_SCHEMA_VERSION) { + throw Error( + `database schema version ${databaseVersion} is newer than this wallet` + + ` (version ${SQLITE_SCHEMA_VERSION})`, + ); + } + const migrationTable = await ( + await db.prepare( + "SELECT 1 AS present FROM sqlite_master" + + " WHERE type = 'table' AND name = 'schema_migrations'", + ) + ).getAll(); + if (migrationTable.length !== 0) { + const recorded = await ( + await db.prepare("SELECT version, name FROM schema_migrations") + ).getAll(); + validateAppliedSchemaMigrations(recorded, migrations); + } // WAL: readers do not block the writer, and a commit appends to the log // instead of fsyncing the whole database. // @@ -276,8 +329,9 @@ export async function initSqliteWalletDb( }); const applied = await ( - await db.prepare("SELECT version FROM schema_migrations") + await db.prepare("SELECT version, name FROM schema_migrations") ).getAll(); + validateAppliedSchemaMigrations(applied, migrations); const have = new Set(applied.map((r) => Number(r.version))); for (const mig of migrations) { @@ -896,6 +950,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { $verification_status ) ON CONFLICT(exchange_master_pub, denom_pub_hash) DO UPDATE SET + exchange_base_url = excluded.exchange_base_url, denom_pub = excluded.denom_pub, exchange_master_pub = excluded.exchange_master_pub, currency = excluded.currency,