open-standards/openpay/docs/23-server-progress.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

182 lines
7.8 KiB
Markdown

# Phase 23 — HTTP API server: vendors can actually deploy this
**Status**: Draft v0.23
**Date**: 2026-05-22
## Why
Until Phase 22 OpenPay was a workspace of well-tested Rust crates
with no HTTP surface. To bring margin back to vendors, operators
have to be able to *run* the stack — and that means a deployable
binary that speaks REST. Phase 23 adds `op-server`: an
[`axum`]-based HTTP server that exposes the workspace's surfaces
(intents, refunds, disputes, settlement, audit) as a clean REST
API.
Second of three sequenced phases (22 → 23 → 24). Next phase wires
concrete driver SDKs and reference rail integrations.
## What shipped
| # | Item | Where |
|--:|---|---|
| 1 | `op-server` library + binary crate (`axum` 0.8, `tokio` 1.x, `tower-http`) | `crates/op-server/` |
| 2 | `AppState` — `Clone`-able bundle of `Arc<store>`s + `Arc<Orchestrator>` + shared `GraphHandle` | `state.rs` |
| 3 | `ApiError` — HTTP error envelope with stable JSON shape + per-crate `From` conversions mapping each domain error to the right status code | `error.rs` |
| 4 | Intent endpoint — `POST /v1/intents` runs the orchestrator, returns per-attempt outcome list | `handlers/intent.rs` |
| 5 | Refund endpoints — create / get / submit / approve / settle | `handlers/refund.rs` |
| 6 | Dispute endpoints — create / get / attach evidence | `handlers/dispute.rs` |
| 7 | Settlement endpoints — open / get / add entry / close | `handlers/settlement.rs` |
| 8 | Audit endpoint — `GET /v1/audit/report?start_tx=..&end_tx=..&generated_at_unix_secs=..` | `handlers/audit.rs` |
| 9 | Health + readiness — `GET /health` always-200, `GET /readiness` reports store counts | `handlers/health.rs` |
| 10 | Binary entry point — TCP bind + graceful Ctrl-C / SIGTERM shutdown + `tracing-subscriber` setup | `main.rs` |
| 11 | Integration tests via `tower::ServiceExt::oneshot` — no socket, no listener, 9 e2e tests | `tests/api.rs` |
Workspace at the end of Phase 23:
| Check | Result |
|---|---|
| `cargo build --workspace --all-targets` | **0 errors, 0 warnings** |
| `cargo test --workspace` | **906 passing, 0 failing** (+9 vs Phase 22) |
| `cargo clippy --workspace --all-targets` | **0 warnings** |
## Endpoint surface
```
GET /health → 200 always
GET /readiness → store counts
POST /v1/intents → run orchestrator
POST /v1/refunds → create refund
GET /v1/refunds/{id} → fetch refund
POST /v1/refunds/{id}/submit → Requested→Submitted
POST /v1/refunds/{id}/approve → Submitted→Approved
POST /v1/refunds/{id}/settle → Approved→Settled
POST /v1/disputes → create dispute
GET /v1/disputes/{id} → fetch dispute
POST /v1/disputes/{id}/evidence → attach evidence
POST /v1/settlement/batches → open batch
GET /v1/settlement/batches/{id} → fetch batch
POST /v1/settlement/batches/{id}/entries → add tx to batch
POST /v1/settlement/batches/{id}/close → apply holdback + close
GET /v1/audit/report → multi-store audit join
```
All POST endpoints take JSON. All responses are JSON; errors carry
the stable shape:
```json
{ "code": "not_found", "message": "refund not found: 0123...", "details": null }
```
## State shape
```rust
pub struct AppState {
pub orchestrator: Arc<Orchestrator>,
pub refunds: Arc<InMemoryRefundStore>,
pub disputes: Arc<InMemoryDisputeStore>,
pub settlement: Arc<InMemorySettlementStore>,
pub ledger: Arc<GraphLedgerStore>,
pub reconciliation: Arc<GraphReconciliationStore>,
pub telemetry: Arc<GraphRailTelemetry>,
pub graph: GraphHandle,
}
```
`Clone` is cheap (everything is `Arc` or the graph handle's own
`Arc`). axum extracts `State<AppState>` per request. Operators
replace `InMemory*` with their own `*Store` impls (Postgres,
TigerBeetle, etc.) by constructing a non-default state and
calling `router(state)`.
## Error mapping
Each domain crate's `Error` enum has a `From<Error> for ApiError`
implementation that picks the right HTTP class:
| Domain variant | HTTP |
|---|---|
| `*::NotFound(_)`, `op_ledger::*NotFound(_)` | 404 |
| `*::InvalidTransition { .. }`, `op_ledger::TerminalState` | 409 |
| `*::IdempotencyMismatch(_)` | 409 |
| `*::Invalid`, `*::AmountExceeded`, currency / parse failures | 400 |
| `op_orchestrator::*`, `op_graph::*` (catch-all) | 500 |
The body always uses the stable `{code, message, details}`
envelope, regardless of which crate raised the error.
## Why `&impl SettlementStore` matters here
The settlement engine's `update<F>(... f: F)` trait-method makes
`SettlementStore` non-dyn-compatible. The HTTP handler resolves
this by holding `Arc<InMemorySettlementStore>` directly (concrete
type) — exactly the integration shape the engine expects. Same
pattern for the refund and dispute stores.
## Testing approach
`tower::ServiceExt::oneshot` invokes the router with a synthetic
`Request<Body>` and returns the `Response<Body>` directly — no
TCP socket, no listener, no port assignment. Each test in
`tests/api.rs` builds an `AppState::new_in_memory()`, calls
`oneshot`, asserts on status + JSON body. Fast (≈10ms per test),
deterministic, no port conflicts.
## Honest concerns (carry-forward)
- **No auth.** Operators apply a `tower::Layer` (API-key
middleware, JWT validator, mTLS). The reference binary ships
unauthenticated — running it on a public IP is the operator's
problem.
- **No TLS.** Terminate upstream (Caddy / nginx / load balancer).
The binary listens plain HTTP.
- **No request body size limits.** Default axum limits apply.
Operators wanting harder bounds add `RequestBodyLimitLayer`.
- **Reference handlers only.** Refund-approve doesn't take a body;
dispute lifecycle (escalate/represent/resolve) is currently
represented only via the in-memory store API, not HTTP. Adding
those endpoints is straightforward — followed the same pattern
as the shipped ones.
- **No pagination.** Endpoints that *would* benefit from pagination
(audit window, list-open batches) currently return everything in
the window. Real deployments add `?limit=&cursor=` once the
query layer in `op-graph` supports it.
- **No webhook delivery from the server.** `op-webhook` exists in
the workspace but its dispatcher isn't wired through the HTTP
layer — operators run the dispatcher in a separate worker. The
server publishes events to the store; the dispatcher polls /
consumes them.
- **Synchronous orchestrator on an async handler.** `Orchestrator::run`
is sync; the axum handler awaits a `tokio::task::spawn_blocking`
in production, but the reference path runs it inline on the
async runtime. Fine for the in-memory ref adapter, painful for a
real PSP-calling adapter.
## Operator boot sequence
```bash
OP_BIND_ADDR=0.0.0.0:8080 \
RUST_LOG=info,op_server=debug \
cargo run --release -p op-server
```
The binary uses `tracing-subscriber` to emit structured logs;
operators install their own subscriber (OpenTelemetry exporter,
JSON-to-Datadog, etc.) by replacing the init block in `main.rs`
or by linking against the library and calling `op_server::router`
from their own composition root.
## Test totals
```
op-server 9 integration tests
health 1, readiness 1, refund create/get/idem 3,
refund 404 1, dispute 1, settlement 1, audit 1,
bad-currency 1
----
+9 net
```
`cargo test --workspace`: **906 passing, 0 failing.**
`cargo build --workspace --all-targets`: **0 warnings.**
`cargo clippy --workspace --all-targets`: **0 warnings.**