open-standards/openpay/crates/op-wasm/src/error.rs
Transaction Science 70ecd6caa0 Initial import — three open standards under one shelf
Transaction Science open standards monorepo. Each subdirectory is a
self-contained reference protocol with its own README and licence.

- openpay/    Apache-2.0  payment-acceptance stack (Rust, 26 crates)
- smart-byte/ CC-BY-4.0   content-addressed value carrier (spec)
- eoc/        CC-BY-4.0   energy-optimised compute (spec)

The wire format and the right to fork are public. The optional hosted
services are the offer; the protocols are not.
2026-05-23 07:58:09 -04:00

379 lines
12 KiB
Rust

//! FFI-safe error type for the JavaScript bridge.
//!
//! Discriminants are byte-identical to `op-ffi-swift::FfiError` and
//! `op-ffi-jni::FfiError` (Phases 8 and 9). A single observability
//! backend can correlate failures by code across iOS, Android, and
//! the web.
//!
//! ## Surface
//!
//! Rust-side errors are returned as `Result<T, JsValue>`. The
//! `JsValue` is an instance of the JS-visible class
//! [`OpenPayError`], which carries:
//!
//! - `.code` — i32 discriminant (0..=9), matching the other bridges.
//! - `.kind` — string name of the variant (e.g. `"VaultLookupFailed"`).
//! - `.message` — short human-readable description; never includes
//! sensitive data.
//!
//! Idiomatic JS callers:
//!
//! ```text
//! try {
//! vault.detokenize(token);
//! } catch (e) {
//! if (e.code === 2) { ... } // numeric switch
//! if (e.kind === "VaultLookupFailed") { ... } // by name
//! }
//! ```
use thiserror::Error;
use wasm_bindgen::prelude::*;
/// Internal error variant, mirroring Phase 8/9.
#[derive(Copy, Clone, Debug, Eq, PartialEq, Error)]
#[repr(i32)]
pub enum FfiError {
/// Success sentinel.
#[error("ok")]
Ok = 0,
/// Input data is malformed.
#[error("invalid input")]
InvalidInput = 1,
/// Vault couldn't resolve a token. Collapses NotFound | AuthFailed |
/// InvalidToken for oracle discipline.
#[error("vault lookup failed")]
VaultLookupFailed = 2,
/// Token has expired.
#[error("token expired")]
TokenExpired = 3,
/// Single-use token was already consumed.
#[error("token already consumed")]
TokenAlreadyConsumed = 4,
/// Fraud scorer rejected the request.
#[error("fraud declined")]
FraudDeclined = 5,
/// Fraud scorer flagged for human review.
#[error("fraud review required")]
FraudReviewRequired = 6,
/// Backend (vault / rail / scorer) opaque failure.
#[error("backend error")]
Backend = 7,
/// FFI-internal.
#[error("internal error")]
Internal = 8,
/// Rate-limit or capacity exhaustion.
#[error("rate limit or capacity")]
Capacity = 9,
}
impl FfiError {
/// Cast back from an `i32`. Unknown values map to `Internal`.
#[must_use]
pub const fn from_i32(v: i32) -> Self {
match v {
0 => Self::Ok,
1 => Self::InvalidInput,
2 => Self::VaultLookupFailed,
3 => Self::TokenExpired,
4 => Self::TokenAlreadyConsumed,
5 => Self::FraudDeclined,
6 => Self::FraudReviewRequired,
7 => Self::Backend,
8 => Self::Internal,
9 => Self::Capacity,
_ => Self::Internal,
}
}
/// Discriminant as i32.
#[must_use]
pub const fn as_i32(self) -> i32 {
self as i32
}
/// Short kind name (PascalCase) for the JS-visible `kind` field.
#[must_use]
pub const fn kind(self) -> &'static str {
match self {
Self::Ok => "Ok",
Self::InvalidInput => "InvalidInput",
Self::VaultLookupFailed => "VaultLookupFailed",
Self::TokenExpired => "TokenExpired",
Self::TokenAlreadyConsumed => "TokenAlreadyConsumed",
Self::FraudDeclined => "FraudDeclined",
Self::FraudReviewRequired => "FraudReviewRequired",
Self::Backend => "Backend",
Self::Internal => "Internal",
Self::Capacity => "Capacity",
}
}
}
impl From<op_vault::Error> for FfiError {
fn from(e: op_vault::Error) -> Self {
match e {
op_vault::Error::NotFound
| op_vault::Error::AuthFailed
| op_vault::Error::InvalidToken => Self::VaultLookupFailed,
op_vault::Error::Expired => Self::TokenExpired,
op_vault::Error::AlreadyConsumed => Self::TokenAlreadyConsumed,
op_vault::Error::InvalidCard(_) => Self::InvalidInput,
op_vault::Error::Capacity => Self::Capacity,
op_vault::Error::Backend(_) => Self::Backend,
}
}
}
impl From<op_fraud::Error> for FfiError {
fn from(e: op_fraud::Error) -> Self {
match e {
op_fraud::Error::Features(_) => Self::InvalidInput,
op_fraud::Error::ModelLoad(_)
| op_fraud::Error::ModelOutput(_)
| op_fraud::Error::Backend(_)
| op_fraud::Error::ScoreOutOfRange(_)
| op_fraud::Error::Core(_) => Self::Backend,
}
}
}
/// JS-visible error class. Instances are constructed Rust-side and
/// returned as the `Err` arm of `Result<T, JsValue>`. wasm-bindgen
/// converts the wrapper into a JS object that the bindgen runtime
/// throws on the JS side.
#[wasm_bindgen]
pub struct OpenPayError {
code: i32,
kind: String,
message: String,
}
#[wasm_bindgen]
impl OpenPayError {
/// i32 discriminant, identical to the codes returned by the iOS
/// and Android bridges.
#[wasm_bindgen(getter)]
pub fn code(&self) -> i32 {
self.code
}
/// Variant name, e.g. `"VaultLookupFailed"`.
#[wasm_bindgen(getter)]
pub fn kind(&self) -> String {
self.kind.clone()
}
/// Short human-readable description. Does not include sensitive
/// data (no PAN, no token, no vault internals).
#[wasm_bindgen(getter)]
pub fn message(&self) -> String {
self.message.clone()
}
/// `toString()`-style summary for JS callers that just want to log.
#[wasm_bindgen(js_name = "toString")]
pub fn to_string_js(&self) -> String {
format!(
"OpenPayError [{}/{}]: {}",
self.code, self.kind, self.message
)
}
}
impl OpenPayError {
/// Construct from an [`FfiError`] discriminant and a contextual
/// message. The message is never user-supplied; only crate-local
/// constants and `FfiError::to_string()` outputs flow in here.
pub fn from_ffi(e: FfiError, message: impl Into<String>) -> Self {
Self {
code: e.as_i32(),
kind: e.kind().to_owned(),
message: message.into(),
}
}
}
/// Convert an `FfiError` directly into a `JsValue` for return paths.
///
/// Calling sites use this with `?`:
///
/// ```text
/// fn detokenize(...) -> Result<CardData, JsValue> {
/// let vref = ...;
/// let card = vault.detokenize(&vref).map_err(jsify_vault_err)?;
/// Ok(card)
/// }
/// ```
pub(crate) fn jsify_vault_err(e: op_vault::Error) -> JsValue {
let ffi: FfiError = e.into();
let err = OpenPayError::from_ffi(ffi, ffi.to_string());
JsValue::from(err)
}
/// Same for fraud errors.
#[allow(dead_code)] // not used until we expose fraud-failing surfaces
pub(crate) fn jsify_fraud_err(e: op_fraud::Error) -> JsValue {
let ffi: FfiError = e.into();
let err = OpenPayError::from_ffi(ffi, ffi.to_string());
JsValue::from(err)
}
/// Direct `FfiError -> JsValue` for cases where we don't have a
/// concrete inner error (null pointer, bad input shape).
pub(crate) fn jsify_ffi(e: FfiError, message: impl Into<String>) -> JsValue {
let err = OpenPayError::from_ffi(e, message);
JsValue::from(err)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip_through_i32() {
for v in [
FfiError::Ok,
FfiError::InvalidInput,
FfiError::VaultLookupFailed,
FfiError::TokenExpired,
FfiError::TokenAlreadyConsumed,
FfiError::FraudDeclined,
FfiError::FraudReviewRequired,
FfiError::Backend,
FfiError::Internal,
FfiError::Capacity,
] {
assert_eq!(FfiError::from_i32(v.as_i32()), v);
}
}
#[test]
fn unknown_i32_maps_to_internal() {
assert_eq!(FfiError::from_i32(999), FfiError::Internal);
assert_eq!(FfiError::from_i32(-1), FfiError::Internal);
}
#[test]
fn vault_oracle_collapse_preserved() {
assert_eq!(
FfiError::from(op_vault::Error::NotFound),
FfiError::VaultLookupFailed
);
assert_eq!(
FfiError::from(op_vault::Error::AuthFailed),
FfiError::VaultLookupFailed
);
assert_eq!(
FfiError::from(op_vault::Error::InvalidToken),
FfiError::VaultLookupFailed
);
}
#[test]
fn expired_consumed_distinct_from_lookup() {
assert_eq!(
FfiError::from(op_vault::Error::Expired),
FfiError::TokenExpired
);
assert_eq!(
FfiError::from(op_vault::Error::AlreadyConsumed),
FfiError::TokenAlreadyConsumed
);
}
#[test]
fn invalid_card_to_invalid_input() {
assert_eq!(
FfiError::from(op_vault::Error::InvalidCard("luhn".into())),
FfiError::InvalidInput
);
}
#[test]
fn backend_does_not_leak_inner_message() {
let e = FfiError::from(op_vault::Error::Backend("postgres detail".into()));
assert_eq!(e, FfiError::Backend);
let dbg = format!("{e:?}");
assert!(!dbg.contains("postgres"));
}
#[test]
fn fraud_features_to_invalid_input() {
assert_eq!(
FfiError::from(op_fraud::Error::Features("nan".into())),
FfiError::InvalidInput
);
}
#[test]
fn fraud_other_errors_to_backend() {
assert_eq!(
FfiError::from(op_fraud::Error::ModelLoad("missing".into())),
FfiError::Backend
);
assert_eq!(
FfiError::from(op_fraud::Error::ScoreOutOfRange(1.5)),
FfiError::Backend
);
}
#[test]
fn kind_string_is_pascal_case_and_stable() {
// The .kind field is part of the public JS API; renaming a
// variant would break consumer code. Lock it in.
assert_eq!(FfiError::Ok.kind(), "Ok");
assert_eq!(FfiError::InvalidInput.kind(), "InvalidInput");
assert_eq!(FfiError::VaultLookupFailed.kind(), "VaultLookupFailed");
assert_eq!(FfiError::TokenExpired.kind(), "TokenExpired");
assert_eq!(
FfiError::TokenAlreadyConsumed.kind(),
"TokenAlreadyConsumed"
);
assert_eq!(FfiError::FraudDeclined.kind(), "FraudDeclined");
assert_eq!(FfiError::FraudReviewRequired.kind(), "FraudReviewRequired");
assert_eq!(FfiError::Backend.kind(), "Backend");
assert_eq!(FfiError::Internal.kind(), "Internal");
assert_eq!(FfiError::Capacity.kind(), "Capacity");
}
#[test]
fn discriminants_match_phase_8_swift_and_phase_9_jni() {
// Invariant: a single observability backend uses the same
// i32 codes across all three platform bridges. Verify the
// contract for Phase 10 too.
assert_eq!(FfiError::Ok.as_i32(), 0);
assert_eq!(FfiError::InvalidInput.as_i32(), 1);
assert_eq!(FfiError::VaultLookupFailed.as_i32(), 2);
assert_eq!(FfiError::TokenExpired.as_i32(), 3);
assert_eq!(FfiError::TokenAlreadyConsumed.as_i32(), 4);
assert_eq!(FfiError::FraudDeclined.as_i32(), 5);
assert_eq!(FfiError::FraudReviewRequired.as_i32(), 6);
assert_eq!(FfiError::Backend.as_i32(), 7);
assert_eq!(FfiError::Internal.as_i32(), 8);
assert_eq!(FfiError::Capacity.as_i32(), 9);
}
#[test]
fn open_pay_error_carries_three_fields() {
let err = OpenPayError::from_ffi(FfiError::TokenExpired, "token expired");
assert_eq!(err.code(), 3);
assert_eq!(err.kind(), "TokenExpired");
assert_eq!(err.message(), "token expired");
}
#[test]
fn open_pay_error_to_string_format() {
let err = OpenPayError::from_ffi(FfiError::InvalidInput, "bad pan");
assert_eq!(err.to_string_js(), "OpenPayError [1/InvalidInput]: bad pan");
}
}