K1: card purchases on the server (schema 4, Enable Banking adapter, E-36 to E-44)
Build Kvitt images / build (push) Failing after 8m17s

Contract (SPEC §14.8): migration 0004_card_purchases.sql (server schema 4, byte-identical
contract and server copies), E-36 to E-44 in contracts/openapi.yaml with the four new error
codes and a 502 response, examples for every card body and error code, the
match_card_purchase vectors (17 cases) with their Python reference in tools/contracts-check.py
(157 checks), web/src/api/schema.d.ts regenerated; web/src/lib/vectors.test.ts lists
match_card_purchase as not ported until K3 ports it (orchestrator's allowance).

Server: src/cards/ with the provider registry (CP-1), the CardSource trait and its
Enable Banking adapter (JWT RS256 kid = application id, /aspsps, /auth, /sessions,
transactions with PSU headers for user-present fetches, CP-14 error mapping, CP-7 mapping),
the windowed fetch (30-day windows newest first, split at the 100-row cap down to a day,
de-duplication by provider_ref, continuation keys, WRONG_TRANSACTIONS_PERIOD ends the walk),
CP-8 matching with the secondary rule, the background poller (06:45, 11:45, 16:45, 21:45,
4 per 24 hours, 2-hour skip, no backlog), the bilingual callback page (E-39), E-36 to E-38
and E-40 to E-44, link clearing in sync push step 7, attempt pruning at 04:00, the card_fetch
log line with aggregates only, KVITT_EB_* configuration. Tests against a local stub
(tests/cards_api/, 34 tests); the real API is never called.

Infra: Pi compose mounts secrets/cards read-only and passes the two application ids;
.env.example keys; dev stack stages infra/secrets/cards/; NPM block with the /cards/callback
gate line and unlogged location (kvitt.conf and README step 4); README "Card purchases".

Pre-commit hook bypassed (--no-verify, orchestrator's decision): the Android fast tests fail
in :domain VectorFilesTest (and the full check in :data ExampleRoundTripTest) because they pin
the contract's vector and example sets; matchCardPurchase and the card DTOs are K2's by design
(contract-first landing; K2 lands next). Server, contracts-check and web gates are green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-24 16:43:26 +02:00
parent ae73f58079
commit 671c00d0d8
67 changed files with 9515 additions and 47 deletions
@@ -0,0 +1,349 @@
//! A local stub of the Enable Banking API (`/aspsps`, `/auth`, `/sessions`, `/accounts/{uid}/transactions`,
//! `DELETE /sessions/{id}`), shaped after K0's field-presence findings (D-179): at most 100 transactions per response
//! and no continuation key unless a test asks for one. Plus a throwaway RSA key generated per test (no key is
//! committed) and the public key to verify the JWTs it signs.
use std::collections::VecDeque;
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
use aws_lc_rs::encoding::AsDer;
use aws_lc_rs::rsa::{KeyPair, KeySize};
use aws_lc_rs::signature::KeyPair as _;
use axum::Router;
use axum::body::Bytes;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, post};
use base64::Engine;
use chrono::NaiveDate;
use serde_json::{Value, json};
use std::collections::HashMap;
/// The SJ Prio bank as K0 found it in `GET /aspsps?country=SE`.
pub fn sj_prio_aspsp(required: &[&str]) -> Value {
json!({"name": "SJ Prio Mastercard", "country": "SE", "beta": false, "maximum_consent_validity": 15552000,
"psu_types": ["personal"], "auth_methods": [{"approach": "REDIRECT"}], "required_psu_headers": required})
}
/// One request the stub received.
#[derive(Debug, Clone)]
pub struct Received {
pub method: String,
pub path: String,
pub query: HashMap<String, String>,
pub headers: HeaderMap,
pub body: Bytes,
}
impl Received {
pub fn json(&self) -> Value {
serde_json::from_slice(&self.body).unwrap_or(Value::Null)
}
pub fn header(&self, name: &str) -> Option<String> {
self.headers
.get(name)
.map(|v| v.to_str().unwrap().to_owned())
}
/// The PSU headers sent, lower case, sorted.
pub fn psu_headers(&self) -> Vec<String> {
let mut names: Vec<String> = self
.headers
.keys()
.map(|k| k.as_str().to_owned())
.filter(|k| k.starts_with("psu-"))
.collect();
names.sort();
names
}
}
pub struct StubState {
pub requests: Mutex<Vec<Received>>,
pub aspsps: Mutex<Value>,
/// The session `POST /sessions` returns.
pub session: Mutex<Value>,
/// The bank's transactions; each may carry `_bank_date`, the date the stub filters on (default: booking or
/// value date), removed before answering.
pub transactions: Mutex<Vec<Value>>,
/// Answer windows with a continuation key, 100 rows per page, instead of cutting at 100.
pub paginate: Mutex<bool>,
/// `WRONG_TRANSACTIONS_PERIOD` for a window whose `date_to` is before this day.
pub history_from: Mutex<Option<NaiveDate>>,
/// Scripted answers of `GET /accounts/{uid}/transactions`, used in order before the data.
pub transaction_script: Mutex<VecDeque<(u16, Value)>>,
pub auth_script: Mutex<VecDeque<(u16, Value)>>,
pub session_script: Mutex<VecDeque<(u16, Value)>>,
pub delete_script: Mutex<VecDeque<(u16, Value)>>,
/// Delay of each transactions answer, in milliseconds.
pub delay_ms: Mutex<u64>,
}
impl Default for StubState {
fn default() -> Self {
Self {
requests: Mutex::new(Vec::new()),
aspsps: Mutex::new(json!({"aspsps": [sj_prio_aspsp(&["psu-ip-address"])]})),
session: Mutex::new(json!({
"session_id": "sess-secret-1",
"accounts": [{"uid": "acc-secret-1", "cash_account_type": "CARD", "currency": "SEK",
"account_id": {"other": {"identification": "5299 **** **** 4711", "scheme_name": "CPAN"}}}],
"aspsp": {"name": "SJ Prio Mastercard", "country": "SE"},
"psu_type": "personal",
"access": {"valid_until": "2027-04-15T11:55:00.000000+00:00"}
})),
transactions: Mutex::new(Vec::new()),
paginate: Mutex::new(false),
history_from: Mutex::new(None),
transaction_script: Mutex::new(VecDeque::new()),
auth_script: Mutex::new(VecDeque::new()),
session_script: Mutex::new(VecDeque::new()),
delete_script: Mutex::new(VecDeque::new()),
delay_ms: Mutex::new(0),
}
}
}
pub struct Stub {
pub addr: SocketAddr,
pub state: Arc<StubState>,
}
impl Stub {
pub async fn start() -> Self {
let state = Arc::new(StubState::default());
let app = Router::new()
.route("/aspsps", get(aspsps))
.route("/auth", post(auth))
.route("/sessions", post(sessions))
.route("/sessions/{id}", delete(delete_session))
.route("/accounts/{uid}/transactions", get(transactions))
.with_state(state.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
Self { addr, state }
}
pub fn url(&self) -> String {
format!("http://{}", self.addr)
}
pub fn requests(&self) -> Vec<Received> {
self.state.requests.lock().unwrap().clone()
}
pub fn requests_to(&self, path_prefix: &str) -> Vec<Received> {
self.requests()
.into_iter()
.filter(|r| r.path.starts_with(path_prefix))
.collect()
}
pub fn transaction_requests(&self) -> Vec<Received> {
self.requests_to("/accounts/")
}
pub fn clear(&self) {
self.state.requests.lock().unwrap().clear();
}
pub fn set_transactions(&self, txs: Vec<Value>) {
*self.state.transactions.lock().unwrap() = txs;
}
pub fn script_transactions(&self, answers: &[(u16, Value)]) {
self.state
.transaction_script
.lock()
.unwrap()
.extend(answers.iter().cloned());
}
/// The `state` the server sent with its last `POST /auth`.
pub fn last_state(&self) -> String {
self.requests_to("/auth").last().unwrap().json()["state"]
.as_str()
.unwrap()
.to_owned()
}
}
fn record(
state: &StubState,
method: &str,
path: String,
query: HashMap<String, String>,
headers: HeaderMap,
body: Bytes,
) {
state.requests.lock().unwrap().push(Received {
method: method.to_owned(),
path,
query,
headers,
body,
});
}
fn answer(status: u16, body: Value) -> Response {
(StatusCode::from_u16(status).unwrap(), axum::Json(body)).into_response()
}
fn error(status: u16, code: &str) -> Value {
json!({"code": status, "error": code, "message": "stub", "detail": "a body the server must never log"})
}
pub fn eb_error(status: u16, code: &str) -> (u16, Value) {
(status, error(status, code))
}
async fn aspsps(
State(s): State<Arc<StubState>>,
Query(q): Query<HashMap<String, String>>,
headers: HeaderMap,
) -> Response {
record(&s, "GET", "/aspsps".into(), q, headers, Bytes::new());
let body = s.aspsps.lock().unwrap().clone();
answer(200, body)
}
async fn auth(State(s): State<Arc<StubState>>, headers: HeaderMap, body: Bytes) -> Response {
record(&s, "POST", "/auth".into(), HashMap::new(), headers, body);
if let Some((status, body)) = s.auth_script.lock().unwrap().pop_front() {
return answer(status, body);
}
answer(
200,
json!({"url": "https://bank.example.invalid/authorize?session=example", "authorization_id": "auth-1", "psu_id_hash": "h"}),
)
}
async fn sessions(State(s): State<Arc<StubState>>, headers: HeaderMap, body: Bytes) -> Response {
record(
&s,
"POST",
"/sessions".into(),
HashMap::new(),
headers,
body,
);
if let Some((status, body)) = s.session_script.lock().unwrap().pop_front() {
return answer(status, body);
}
let body = s.session.lock().unwrap().clone();
answer(200, body)
}
async fn delete_session(
State(s): State<Arc<StubState>>,
Path(id): Path<String>,
headers: HeaderMap,
) -> Response {
record(
&s,
"DELETE",
format!("/sessions/{id}"),
HashMap::new(),
headers,
Bytes::new(),
);
if let Some((status, body)) = s.delete_script.lock().unwrap().pop_front() {
return answer(status, body);
}
answer(200, json!({"message": "OK"}))
}
fn bank_date(t: &Value) -> NaiveDate {
let text = t["_bank_date"]
.as_str()
.or_else(|| t["booking_date"].as_str())
.or_else(|| t["value_date"].as_str())
.unwrap();
NaiveDate::parse_from_str(text, "%Y-%m-%d").unwrap()
}
async fn transactions(
State(s): State<Arc<StubState>>,
Path(uid): Path<String>,
Query(q): Query<HashMap<String, String>>,
headers: HeaderMap,
) -> Response {
record(
&s,
"GET",
format!("/accounts/{uid}/transactions"),
q.clone(),
headers,
Bytes::new(),
);
let delay = *s.delay_ms.lock().unwrap();
if delay > 0 {
tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
}
if let Some((status, body)) = s.transaction_script.lock().unwrap().pop_front() {
return answer(status, body);
}
let date = |key: &str| {
q.get(key)
.map(|d| NaiveDate::parse_from_str(d, "%Y-%m-%d").unwrap())
};
let (Some(from), Some(to)) = (date("date_from"), date("date_to")) else {
return answer(422, error(422, "WRONG_REQUEST_PARAMETERS"));
};
if let Some(first) = *s.history_from.lock().unwrap()
&& to < first
{
return answer(422, error(422, "WRONG_TRANSACTIONS_PERIOD"));
}
let mut rows: Vec<Value> = s
.transactions
.lock()
.unwrap()
.iter()
.filter(|t| (from..=to).contains(&bank_date(t)))
.cloned()
.collect();
rows.sort_by_key(|t| std::cmp::Reverse(bank_date(t)));
for row in &mut rows {
row.as_object_mut().unwrap().remove("_bank_date");
}
if *s.paginate.lock().unwrap() {
let start: usize = q.get("continuation_key").map_or(0, |k| k.parse().unwrap());
let end = (start + 100).min(rows.len());
let next = (end < rows.len()).then(|| end.to_string());
return answer(
200,
json!({"transactions": rows[start..end], "continuation_key": next}),
);
}
rows.truncate(100);
answer(200, json!({"transactions": rows, "continuation_key": null}))
}
/// A generated RSA key as PEM (PKCS#8) and the public key (DER) to verify the JWTs.
pub struct TestKey {
pub pem: String,
pub public_der: Vec<u8>,
}
pub fn test_key() -> TestKey {
let pair = KeyPair::generate(KeySize::Rsa2048).unwrap();
let der = AsDer::<aws_lc_rs::encoding::Pkcs8V1Der>::as_der(&pair).unwrap();
let b64 = base64::engine::general_purpose::STANDARD.encode(der.as_ref());
let lines: Vec<&str> = b64
.as_bytes()
.chunks(64)
.map(|c| std::str::from_utf8(c).unwrap())
.collect();
TestKey {
pem: format!(
"-----BEGIN PRIVATE KEY-----\n{}\n-----END PRIVATE KEY-----\n",
lines.join("\n")
),
public_der: pair.public_key().as_ref().to_vec(),
}
}