open-standards/openpay/crates/op-wasm/README.md
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

259 lines
7.4 KiB
Markdown

# 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
```bash
# 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)
```typescript
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
```typescript
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
```javascript
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`:**
```javascript
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`:
```javascript
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](https://github.com/rustwasm/wasm-bindgen/issues/1787).
Pattern-match on `.code` or `.kind` instead, or wrap at the JS
boundary:
```typescript
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:
```bash
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
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.