open-standards/openpay/crates/op-wasm
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Transaction Science d14f583d92 Cargo: no minimum-supported-version floor, because the policy is latest stable
The repository tracks the latest stable Rust, pinned at the root. A
`rust-version` key states a *floor* -- the oldest toolchain a crate supports --
which is a different promise, and one this repository does not make. It also
said 1.97 while the pin was 1.98.0 and nothing verified it, so it was a claim in
the wrong direction and unchecked in both.

So it goes, everywhere:

    59  rust-version = "1.97"            (workspace and package declarations)
   417  rust-version.workspace = true    (members inheriting them)
   476  files, 476 deletions, 0 insertions

The 417 are why this is not the sixty-file change it looks like from a grep for
the value. A member inheriting a key that no longer exists does not fall back to
a default -- its manifest stops resolving. Removing the declarations without the
inherits would have broken every workspace that has members.

Deliberately untouched: `joule-code-rs/vendor/wtransport/Cargo.toml`. That is a
vendored third-party crate declaring a floor about itself, not about us, and our
pin is above it. Editing vendored source to satisfy our own policy would make
the copy diverge from upstream for no benefit.

Verified rather than assumed:

- all 493 `Cargo.toml` files in the tree parse (`tomllib`)
- `cargo metadata --no-deps --locked` resolves on `joule-code-rs`, `wai-rs`,
  `openpay`, `mesh-rs`, `sandbox-rs` and `jcp-rs` -- the check that actually
  exercises workspace inheritance, which a TOML parse does not
- no `Cargo.lock` changed; `rust-version` is not lockfile state

`wai/wai-verify/Cargo.toml` already carried a comment saying it omits
`rust-version` on purpose, so this makes the rest of the tree agree with the one
crate that had already decided.

What this gives up, stated plainly: cargo can no longer greet an older
toolchain with "requires rustc 1.97" and will instead fail wherever the code
first needs something newer. That is the cost of not maintaining a floor, and
it is the right cost when the floor was fiction.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-11-03 17:42:20 -05:00
..
js Initial import — three open standards under one shelf 2026-05-23 07:58:09 -04:00
scripts Initial import — three open standards under one shelf 2026-05-23 07:58:09 -04:00
src Initial import — three open standards under one shelf 2026-05-23 07:58:09 -04:00
tests Initial import — three open standards under one shelf 2026-05-23 07:58:09 -04:00
Cargo.toml Cargo: no minimum-supported-version floor, because the policy is latest stable 2026-11-03 17:42:20 -05:00
README.md Initial import — three open standards under one shelf 2026-05-23 07:58:09 -04:00

OpenPay for JavaScript / TypeScript

WebAssembly bridge over the OpenPay Rust core. Same architectural shape as the iOS (Phase 8) and Android (Phase 9) bridges: opaque class handles, oracle-discipline error collapsing, and cross-platform error code alignment.

What you get

  • CardData — Validated PAN + expiration. Luhn / length / date sanity checked on construction. PAN bytes live in wasm linear memory and are never readable from JavaScript.
  • VaultRef — Opaque token reference.
  • RustVault — In-memory reference vault (development).
  • HeuristicScorer — Rule-based fraud scorer.
  • OpenPayError — Error class with .code, .kind, .message.
  • TokenizationPolicy — Configurable format / lifetime / TTL.
  • TokenFormat / TokenLifetime — TypeScript-friendly enums.

Build the wasm bundle first

# One-time setup:
cargo install wasm-pack
rustup target add wasm32-unknown-unknown

# From the crate root (`crates/op-wasm/`):
bash scripts/build-wasm.sh --target web      # browsers (ESM)
bash scripts/build-wasm.sh --target nodejs   # Node.js (CommonJS)
bash scripts/build-wasm.sh --target bundler  # webpack / rollup

The build produces pkg/op_wasm.js, pkg/op_wasm_bg.wasm, and pkg/op_wasm.d.ts (the TypeScript types).

Usage example

Browser (ESM)

import init, {
  CardData,
  RustVault,
  TokenizationPolicy,
  OpenPayError,
} from './pkg/op_wasm.js';

await init();

const vault = new RustVault('checkout');

try {
  const card = new CardData('4242424242424242', 12, 2030);
  const token = vault.tokenize(card, TokenizationPolicy.cardOnFile());
  // `card` was consumed by tokenize() — its JS pointer is now null.

  console.log('Token:', token.asString);

  // Persist token.asString to localStorage or send to server.
  localStorage.setItem('pm:default', token.asString);

  token.free();
} catch (e) {
  if (e instanceof OpenPayError) {
    console.warn(`payment failed: ${e.kind}`, e.message);
  } else {
    throw e;
  }
} finally {
  vault.free();
}

Recovering a token later

import { VaultRef } from './pkg/op_wasm.js';

const tokenStr = localStorage.getItem('pm:default');
const ref = VaultRef.fromString(tokenStr);

try {
  const card = vault.detokenize(ref);
  submitToAcquirer(card.firstSix, card.lastFour);
  card.free();
} catch (e) {
  if (e.code === 2 /* VaultLookupFailed */) {
    showError('Card not found');
  } else if (e.code === 3 /* TokenExpired */) {
    showError('Token expired — please re-enter your card');
  } else {
    throw e;
  }
} finally {
  ref.free();
}

Node.js

const { RustVault, CardData } = require('./pkg/op_wasm.js');
// No init() call needed for the nodejs target.

const vault = new RustVault('test');
const token = vault.tokenize(new CardData('4242424242424242', 12, 2030));
console.log(token.asString);
token.free();
vault.free();

The .free() protocol

wasm-bindgen does not have a finalizer mechanism — the JS engine doesn't expose one portably. Every #[wasm_bindgen]-exported Rust struct gets an explicit .free() method that must be called when the JS object is no longer needed. Forgetting to call it leaks wasm linear memory (which is never reclaimed by the JS GC).

Three idiomatic patterns:

  1. Explicit .free() in a try/finally:

    const card = new CardData(pan, m, y);
    try {
      // ...
    } finally {
      card.free();
    }
    
  2. Consume-by-value methods automatically invalidate the JS pointer. After vault.tokenize(card), the card JS object's pointer is set to null and any further method call throws. No .free() needed.

  3. using (ES2026) if your toolchain targets ES2026 or you shim Symbol.dispose:

    using vault = new RustVault('checkout');
    using card = new CardData(pan, m, y);
    // automatic .free() when scope exits
    

Error inspection

OpenPayError has three readable properties:

Property Type Description
.code number i32 discriminant, matches iOS/Android
.kind string PascalCase variant name
.message string Short human-readable description

Discriminants (stable across iOS, Android, web):

Code Kind Meaning
1 InvalidInput PAN, expiration, or token format malformed
2 VaultLookupFailed Token unknown, malformed, or auth-failed (collapsed)
3 TokenExpired Past TTL
4 TokenAlreadyConsumed Single-use, already used
5 FraudDeclined Scorer rejected
6 FraudReviewRequired Scorer flagged for human review
7 Backend Vault / rail / scorer opaque failure
8 Internal FFI-internal bug
9 Capacity Rate limit or capacity exhaustion

Note: OpenPayError does not extend Error

wasm-bindgen-exported Rust structs become standalone JS classes; they don't inherit from the native Error prototype. So e instanceof Error returns false for thrown OpenPayError instances. This is a known wasm-bindgen limitation. Pattern-match on .code or .kind instead, or wrap at the JS boundary:

import { OpenPayError } from './pkg/op_wasm.js';

export class OpenPayJsError extends Error {
  constructor(public readonly inner: OpenPayError) {
    super(inner.message);
    this.name = 'OpenPayJsError';
  }
  get code()    { return this.inner.code; }
  get kind()    { return this.inner.kind; }
}

export function safeTokenize(vault, card, policy) {
  try {
    return vault.tokenize(card, policy);
  } catch (e) {
    if (e instanceof OpenPayError) throw new OpenPayJsError(e);
    throw e;
  }
}

TypeScript

The pkg/op_wasm.d.ts file is generated automatically by wasm-bindgen from the Rust signatures. Drop pkg/ into your tsconfig.json include and TypeScript will pick it up.

Testing

Two test suites:

  • Rust-side (tests/wasm_bindgen.rs) runs inside a real wasm host:

    wasm-pack test --node              # Node.js
    wasm-pack test --headless --chrome # Chrome
    wasm-pack test --headless --firefox # Firefox
    
  • JS-side (js/test.mjs) exercises the consumer-visible classes after wasm-pack build:

    bash scripts/build-wasm.sh --target nodejs
    node js/test.mjs
    

Plain cargo test -p op-wasm runs the host-side unit tests (FfiError discriminant tests, OpenPayError construction, policy mapping).

Why no Web Crypto API integration?

The Web Crypto API (SubtleCrypto) is async-only — every operation returns a Promise. The OpenPay [Vault] trait is sync (consistent across iOS Keychain, Android Keystore, in-memory Rust, etc.). Reshaping the trait to be async would ripple through every crate; it's a coherent next step but doesn't fit Phase 10.

A future WebCryptoVault class, analogous to Phase 9's KeystoreVault, will use SubtleCrypto for hardware-accelerated AES-GCM and IndexedDB for persistence. That ships as Phase 10.1 once the async-vault story is designed.

For now: RustVault uses the same aes-gcm-siv cipher as iOS and Android. The cryptographic posture is identical across platforms.

Browser support

WebAssembly is supported by:

  • Chrome 57+ (March 2017)
  • Firefox 52+ (March 2017)
  • Safari 11+ (Sept 2017)
  • Edge 16+ (Oct 2017)

This covers ~96% of the global web audience per caniuse.com (May 2026). For older browsers, ship a server-side tokenization fallback.