Last modified: 2026-08-15
The active implementation of sbproxy. Cargo workspace with ~20
crates under crates/, an e2e suite under e2e/, examples under
examples/, and an internal observability/cache/AI/security stack.
Before committing any change, run all checks. Each one corresponds to a
required CI gate; if any fails locally, CI will fail too. The table is
ordered the way scripts/check.sh runs them, cheapest first, so a
failure that takes seconds to find is not discovered behind a
ten-minute build.
| Check | Command |
|---|---|
| Tracker placeholders | grep -rn 'WOR-XXX' crates/ --include='*.rs' --include='*.toml' (any hit fails) |
| pub-item ratchet | bash scripts/check-pub-item-ratchet.sh |
| unwrap/expect/panic ratchet | bash scripts/check-unwrap-ratchet.sh |
| Spec citations | bash scripts/check-spec-citations.sh |
| Env mutation | bash scripts/check-env-mutation.sh |
| NOTICE (Apache-2.0-only) | bash scripts/check-notice.sh |
| Doc drift | bash scripts/check-doc-drift.sh |
| Tapes + GIF wiring | make tapes-check |
| Doc configs | python3 scripts/sync-doc-configs.py --check |
| Documented output | python3 scripts/check-doc-captures.py --check --stackless-only |
| Installer | sh scripts/tests/install_verify.sh |
| Format | cargo fmt --all -- --check |
| Nested lockfiles | bash scripts/check-nested-lockfiles.sh |
| Supply chain (crates) | cargo deny --all-features check |
| Supply chain (npm) | cd ui && npm audit --package-lock-only --audit-level=high |
| UI | cd ui && npm ci && npm run typecheck && npm run test -- --run |
| Build | cargo build --workspace --exclude sbproxy-e2e --locked |
| Test | cargo nextest run --workspace --exclude sbproxy-e2e --locked --profile ci |
| Doctest | cargo test --workspace --exclude sbproxy-e2e --locked --doc |
| Clippy | cargo clippy --workspace --all-targets -- -D warnings |
| Docs | RUSTDOCFLAGS="-D warnings -D missing_docs" cargo doc --workspace --no-deps --locked |
| Payment features (opt-in) | SBPROXY_CHECK_PAYMENTS=1 bash scripts/check.sh |
Two rows in that table are easy to get subtly wrong.
-D missing_docs appears in exactly one other place in this repository,
.github/workflows/ci.yml, and it is the flag that bites. Do not pair
it with --document-private-items: that combination demands rustdoc on
private items too, which is stricter than CI and produces failures CI
will never report. If you want the private-items pass anyway, run
scripts/check.sh with SBPROXY_CHECK_PRIVATE_DOCS=1, which runs it
as its own phase under plain -D warnings.
--locked on the build is equally load bearing. Without it, cargo build silently rewrites the root Cargo.lock in place, and the
--locked test step that follows then passes against the file the build
just regenerated. Lockfile drift gets quietly repaired instead of
reported.
The last row is the only opt-in entry, and opt-in is not the same as
optional: CI requires the payments lane like every other one here. No
payment feature is in any default set, so every other row in this table
resolves a union that compiles none of the settlement path, and until
WOR-2222 nothing anywhere did. The phase is behind a variable because
the settlement union has a different fingerprint from the rest of the
gate and recompiles the graph rather than reusing it, which is not a
price worth paying on a change that cannot reach payments. Run it before
pushing anything that touches crates/sbproxy-billing,
crates/sbproxy-core's settle or billing modules, sbproxy-modules'
crawl pricing, or the shared HTTP kit those reach the network through. A
run without it prints the skip in the SKIPPED PHASES block.
Fix the issue before pushing. Do not paper over with #[allow(...)]
unless you also write a one-line comment explaining the deliberate
exception.
scripts/check.sh records the working-tree state before the first phase
and re-checks it after the last one. An uncommitted file at the end
fails the run, and the message separates work that was already dirty
from files a generator rewrote during the gate. A green gate on a dirty
tree is a claim about a tree nobody is going to push: PR #837 shipped a
broken commit exactly that way, behind a gate that had passed against an
uncommitted fix.
Commit first, then run the gate. For a deliberate work-in-progress run,
set SBPROXY_ALLOW_DIRTY_TREE=1; the run then prints that the guard was
bypassed and lists the paths.
The local runner is scripts/check.sh. The default path mirrors the
required PR lane: non-e2e workspace tests in the dev profile plus
doctests. This keeps the local target directory materially smaller than
full release/e2e runs.
| Variable | Effect |
|---|---|
SBPROXY_RELEASE_TESTS=1 |
compile test binaries in release mode |
SBPROXY_CHECK_E2E=1 |
include the sbproxy-e2e package |
SBPROXY_CHECK_PAYMENTS=1 |
clippy + test the settlement feature union |
SBPROXY_CLEAN_AFTER_BUILD=0 |
keep every build artifact after the run |
SBPROXY_ALLOW_DIRTY_TREE=1 |
do not fail on an uncommitted working tree |
SBPROXY_ALLOW_CARGO_TEST_FALLBACK=1 |
permit the serial cargo test fallback |
SBPROXY_CHECK_PRIVATE_DOCS=1 |
extra rustdoc pass over private items |
SBPROXY_CHECK_CAPTURES=1 |
replay every documented command and diff it against the block the doc shows |
Anything the runner could not run is reprinted as a SKIPPED PHASES
block just before the final result, so "All checks passed" cannot hide a
lane that never executed. promtool and cargo-deny are the two
optional tools; install prometheus, and cargo install cargo-deny --locked,
to close those two gaps.
Always run the test lane through nextest. cargo-nextest is
already installed at ~/.cargo/bin/cargo-nextest. check.sh probes
cargo nextest --version and treats a miss as a hard error, because
serial cargo test turns a few-minute test lane into a ~90-minute one
and a local fallback is a misconfigured shell rather than an intended
path. If ~/.cargo/bin is not already on your PATH, export it before
running the gate:
export PATH="$HOME/.cargo/bin:$PATH"By default, scripts/check.sh runs scripts/cleanup-build-artifacts.sh
on exit to prune target/doc, nextest output, incremental directories,
and other high-churn artifacts while keeping dependency build outputs
available for reuse. Set SBPROXY_CLEAN_AFTER_BUILD=0 only when you
are deliberately preserving every artifact for local debugging.
For day-to-day editing, these run in seconds against just the slice you're working in:
cargo check -p <crate>- single-crate type check, ~1-5scargo test -p <crate> --lib <prefix>- unit tests by name prefixcargo test -p sbproxy-config --tests- config tests + example + v1-compat sweep, ~3scargo test -p sbproxy-modules --lib <policy_name>- per-policy unit testscargo test -p sbproxy-e2e --release --test <name>- one e2e test file (release build of the proxy is reused if present)
cargo clippy -p sbproxy-core is a stricter check than
cargo clippy --workspace, not a faster subset of it, and CI only runs
the workspace form. If a per-crate invocation reports errors the CI lane
does not, the crate is probably fine and your invocation is the one
telling the truth.
The mechanism is feature unification. crates/sbproxy is a workspace
member and its default features enable things like
sbproxy-core/inprocess-classify. Under resolver = "2" a
--workspace build compiles one sbproxy-core with the union of
features the whole graph asks for, so those features are on. Build
sbproxy-core alone and it gets its own default = [], so they are
off.
That difference is visible for real. A helper function with no #[cfg]
gate whose only callers sit behind #[cfg(feature = ...)] compiles
clean workspace-wide (the callers exist) and fails per-crate with
function ... is never used (they do not). This happened in #757 and
was fixed in #759 by gating the helpers to match their callers.
Demonstrated at that commit: cargo clippy --workspace --all-targets
exits 0 while cargo clippy -p sbproxy-core --all-targets exits 101 on
the same source.
Two consequences worth remembering:
- A green CI run does not mean every crate lints in isolation. When
you add a feature-gated caller, gate its helpers to match, and check
with
cargo clippy -p <crate> --all-targets. - Do not conclude from a per-crate failure that
mainis red. Confirm with the documented workspace command before filing anything.
Different feature unions mean different fingerprints, and different
fingerprints mean cargo rebuilds. Inside one CI job that is expensive:
adding a cargo test -p sbproxy-e2e step after a --workspace build
recompiled 179 crates to run two tests, and a cargo test -p sbproxy-platform step cost another 2m07 the same way.
So .github/workflows/ci.yml holds one invariant: every cargo
invocation in a job uses the same package selection. The test lane
is --workspace --exclude sbproxy-e2e throughout; the obs-budgets
lane is --workspace throughout, in its own job with its own cache key
because it needs e2e's wider union.
When adding a check to CI, express it in the lane's existing selection.
Narrow the targets (--test <name>) or the tests
(nextest -E 'test(...)'), never the packages. If a check genuinely
needs a different package set, give it its own job rather than paying
the rebuild on the critical path. scripts/lib/workspace-bin.sh exists
for the same reason: it execs the already-built generator binaries
instead of re-entering cargo with a -p selection.
Fingerprints change with features, not just with packages, so a lane
that needs a feature no default set enables gets its own job on the same
grounds. embed-admin-ui and payments are both that, and the
payments lane shows how to keep the two rules from fighting.
Its package selection is the test lane's, --workspace --exclude sbproxy-e2e, and only the union differs. It reaches that union through
the sbproxy binary's flags, --features sbproxy/payment-x402,...,
rather than sbproxy-core's, which makes it a strict superset of what
--workspace already resolves. Going the other way and selecting -p sbproxy-core would be narrower than CI on both axes at once: fewer
features, so dead-code failures CI never reports, and fewer packages, so
sbproxy-billing's test targets would not be built at all. Reach for
<binary-crate>/<feature> when a lane needs a wider union, and let the
package selection stay where the rest of CI has it.
e2e/tests/settlement_gate.rs and e2e/tests/usage_bridge.rs are the
only tests that drive a payment through a real proxy process.
challenge_settle_allow_and_replay_refusal is the one that asserts the
origin served exactly once for a settled payment and then refused the
replay. Both files spawn a binary no other lane builds: a release
sbproxy carrying
payment-x402,payment-mpp,payment-stripe,payment-lightning-cln, found
under target/payments/ or named by SBPROXY_E2E_PAYMENTS_BIN.
That binary, plus a spawned child on real ports, is why these two files
are not in the required PR lane.
.github/workflows/payments-e2e.yml runs them nightly at 03:40 UTC
instead, with workflow_dispatch for a manual run. A red run opens or
comments on a "Payments e2e is failing" issue and names the tests that
failed. Two other scheduled lanes happen to run the same files,
release-checks.yml's test-isolation job nightly and e2e.yml weekly,
but neither is named for them and neither says which payment test broke.
What that costs: a PR can merge green having never built the binary
these tests spawn, and the exactly-once property is confirmed the next
morning rather than before the merge. The payments lane in ci.yml
still runs on every PR, so the settlement code compiles and its unit
tests run there. What waits for the night is the proof that an assembled
proxy serves a paid request once.
So run the pair locally before merging anything that reaches settlement:
CARGO_TARGET_DIR=target/payments cargo build --release -p sbproxy --locked \
--features payment-x402,payment-mpp,payment-stripe,payment-lightning-cln
cargo test -p sbproxy-e2e --locked --no-fail-fast \
--test settlement_gate --test usage_bridge -- --test-threads=1--test-threads=1 is load bearing: settlement_gate is red under
parallelism (WOR-2295). Build that binary once and SBPROXY_CHECK_E2E=1 bash scripts/check.sh picks both files up on every later gate. Without
it the gate lists them under SKIPPED PHASES.
Every branch gets an adversarial review against
.github/code-review-rubric.md before it becomes a PR, run by an agent
with shell access, and a verification round on the fixes when the first
round finds anything. The rubric file carries its own "How to run it"
instructions (give the reviewer the diff and the worktree; require
findings with severity, file:line, and a failure scenario each). The
findings and their resolutions go into the PR body, so the review
history merges with the change it reviewed. The mechanical gates above
answer "does it compile, lint, and pass"; the rubric answers "is this
going to be a problem in six months", and this repository has merged
nothing without both since the protocol landed.
sbproxy/
crates/
sbproxy/ - binary entry point (cmd line, signal handling, server boot)
sbproxy-core/ - request pipeline (request_filter, response_filter,
response_body_filter), Pingora glue
sbproxy-config/ - config schema, compile_config(), example sweep,
v1 schema-compat regression test
sbproxy-modules/ - all action / auth / policy / transform modules
(plugin-style registry, register-via-init pattern)
sbproxy-plugin/ - public plugin trait surface
sbproxy-httpkit/ - HTTP request/response helpers shared by plugin authors
sbproxy-platform/ - circuit breaker, dns, health, kv storage
(redb embedded KV; SQLite for relational state)
sbproxy-cache/ - response cache, KV stores (memory/file/memcached/redis)
sbproxy-ai/ - AI gateway path (providers, routing, guardrails,
streaming, budgets, cost tracking)
sbproxy-extension/ - scripting (CEL, Lua, JavaScript, WASM via
wasmtime + WASI preview-1), MCP server,
feature flags
sbproxy-observe/ - metrics (sbproxy_*), events, structured logging
sbproxy-security/ - crypto (HKDF), hostfilter, IP/CIDR utilities,
PII redactor, SSRF guard; optional headless-detect
(TLS fingerprint) and agent-verify (reverse DNS)
sbproxy-tls/ - TLS config, mTLS
sbproxy-transport/ - HTTP/1.1, H2, H3, websockets, gRPC, GraphQL
sbproxy-vault/ - secret backends + interpolation
sbproxy-middleware/ - middleware chain (CORS, HSTS, compression, ...)
sbproxy-openapi/ - OpenAPI emission from live config
sbproxy-k8s-operator/ - CRDs + reconcile loop
sbproxy-classifiers/ - ONNX-backed text classifiers (prompt injection v2)
e2e/
Cargo.toml - e2e harness crate (sbproxy-e2e)
src/ - ProxyHarness lib used by e2e tests
tests/ - Rust-native e2e (one file per feature)
cases/ - per-feature config fixtures used by Rust tests
conformance/ - vendored curl-and-bash conformance suite
(93 cases). See e2e/conformance/HOW-TO-RUN.md.
examples/ - ~90 dir-style examples; every sb.yml here is
swept by validate_examples test
scripts/ - dev-loop helpers (run-e2e.sh, perf-compare.sh,
install.sh, generate-certs.sh)
docker/ - docker-compose stack (sbproxy + Redis +
Jaeger) for local dev
dashboards/ - Grafana dashboards + Prometheus alerts that
consume the sbproxy_* metrics
docs/ - public per-feature docs (architecture, ai-gateway,
configuration, scripting, etc.)
Built-in modules under crates/sbproxy-modules/src/{action, auth,policy,transform}/ are enum variants dispatched by explicit
match arms on the config type string in
crates/sbproxy-modules/src/compile.rs. There is no init()-time
registration, no imports.rs, and no pkg/plugin registry; those
were Go-era mechanisms. Adding a new built-in module:
- Create the module file, define its config struct, implement the
relevant trait (
PolicyEnforcer,ActionHandler,AuthProvider,TransformHandler). - Add
pub mod my_module;to the parentmod.rsand a variant to that kind's enum (Policy,Action,Auth,Transform). - Add a match arm for the
typestring incrates/sbproxy-modules/src/compile.rs. - Run the pre-commit checks.
A type string with no match arm falls through to the typed
inventory registrations in sbproxy-plugin
(inventory::submit! with {Action,Auth,Policy,Transform}PluginRegistration),
which is how linked third-party plugins load, and then to the
config-loaded JS/WASM extension-bundle registry. The generic
PluginRegistration channel (with PluginKind and a Box<dyn Any>
factory) is diagnostics/listing only; the compiler never builds
handlers from it.
crates/sbproxy-config/src/compiler.rs builds each origin's handler
chain inside-out (auth, response cache, transforms, callbacks,
modifiers, policies, etc.). The chain compiles once per origin and
caches; per-request execution does no allocation in the
chain-construction path.
-
The public API surface is the following three crates, and only these three. Internal crates must not be imported from them, and no other crate in this workspace is part of the public surface today.
sbproxy-plugin- public plugin trait surface (PolicyEnforcer,ActionHandler,AuthProvider,TransformHandler, registry). Each of those four has a typedinventoryregistration channel the config compiler builds handlers from; that pairing is the bar for adding a fifth.RequestEnricherwas declared here from the first commit without one and was removed rather than wired, because its only output channel was a&mut dyn Anyno out-of-tree implementor can downcast. Early request annotation isIdentityResolverHook,MlClassifierHook, andAnomalyDetectorHook, which are dispatched and take a typedRequestContextView.sbproxy-config- config schema andcompile_config()entry point.sbproxy-httpkit- HTTP request/response helpers shared by plugin authors.
Two further public crates are planned but not yet shipped:
sbproxy-events(planned) - until it lands, events and metrics are reached throughsbproxy-observe, which is treated as internal.sbproxy-proxy(planned) - until it lands, the request pipeline lives insbproxy-coreplus thesbproxybinary, both also treated as internal.
Do not advertise the two planned crates as available; reach for the
sbproxy-observe/sbproxy-coreanalogs in the interim and expect the seam to move when the planned crates ship. -
Storage stack:
redbfor embedded KV, SQLite for relational, andmemory / file / memcached / redisfor the response cache. Pebble is Go-only and is not used in this workspace. -
All examples in
examples/usetest.sbproxy.devas the upstream hostname placeholder. -
No em-dashes in any user-facing content (docs, README, CHANGELOG, rustdoc, commit messages).
-
The marketing site at
www.sbproxy.devis language-agnostic; do not lead with "Rust" there. The README and technical docs in this repo can. -
Every feature in this repository ships under Apache-2.0. Do not add a direct dependency on a closed-source crate or name closed-source crate paths in this repository's docs or rustdoc.
docs/ is flat: lowercase-hyphenated filenames at the top level, no
subdirectories, no per-crate READMEs. Every doc starts with a level-1
title, then *Last modified: YYYY-MM-DD* on the next line. The index
of doc slugs lives in docs/README.md and in the marketing site's
src/data/docsNavigation.js and must stay in sync.
Buyer-facing reference docs live here: architecture.md,
ai-gateway.md, configuration.md, scripting.md,
openapi-emission.md, glossary.md. Keep archived-Go references to
useful migration or compatibility history only, and link each one to
https://github.com/soapbucket/sbproxy-go.
Public install + extension story is configuration, not Rust traits.
Surface curl, Homebrew, and Docker for install; surface CEL, Lua,
JavaScript, and WebAssembly for extension. Do not push readers at
cargo install or "implement this trait" from buyer-facing docs.
Implementation plans, specs, and other internal working documents do not
belong in docs/ - that tree is buyer-facing. Save planning artifacts
under .claude/plans/ (already gitignored) unless a specific task
explicitly directs otherwise.
The AI provider list ships from crates/sbproxy-ai/data/ai_providers.yml.
When adding or changing a provider, update every surface in the same
commit:
- Regenerate the embedded copy:
gzip -9 -n -c crates/sbproxy-ai/data/ai_providers.yml > crates/sbproxy-ai/data/ai_providers.yml.gz - Mirror the change in
docs/providers.md's table (hand-maintained; nothing generates it). - Update the hardcoded provider count everywhere: grep the whole repo
for the old number. It appears roughly 30 times across 20+ files
(README, docs/ai-gateway.md, every use-case doc, the comparison
table, root
llms.txt); check each hit's context before editing so an unrelated number is not caught. - Leave
docs/llms-full.txtalone. CI regenerates it onmainafter the merge (.github/workflows/llms-full-refresh.yml); a branch that commits it fails the docs lane. - The sbproxy.dev site keeps its own copy of the provider docs; flag the change for the site repo.
The active git history of this Rust implementation starts at v1.0.0.
The Go implementation shipped publicly as v0.1.0 through v0.1.2
and is archived at soapbucket/sbproxy-go.
See MIGRATION.md for upgrade guidance.
The internal config schema is independently versioned and is referred
to as schema-v1; the same schema is supported by both the Go
v0.1.x line and the Rust v1.x line. The compatibility promise is
pinned by the v1_compat::v1_fixtures_compile_unmodified test in
crates/sbproxy-config/. Do not conflate schema-v1 with binary
v1.0; the schema label predates this rename and is intentionally
unchanged.
Before tagging a new sbproxy release, update our pinned Pingora fork
first. All pingora-* crates are [patch]ed (see Cargo.toml) to
github.com/soapbucket/pingora, branch sbproxy-0.8.0. That fork carries
our local patches (for example the dynamic rustls cert resolver) on top of
Cloudflare's upstream, so a release built against a stale branch silently
ships without any upstream fixes landed since the last cut.
Re-sync it as the first step of every release:
- In the
pingoracheckout, fetch upstream (origin,github.com/cloudflare/pingora) and rebasesbproxy-0.8.0onto the target upstream tag, keeping our patch commits on top. Resolve any conflicts against the new upstream. - Push the rebased branch to the
sbremote (git@github.com:soapbucket/pingora.git). - Back in this workspace, refresh
Cargo.lockso the build resolves to the new fork commit, then run the full gate. Only cut the tag once it is green against the updated fork.
Apache License 2.0 (LICENSE). Open source; free for any use,
including production and commercial, with no field-of-use restriction.
When adding or upgrading a dependency licensed only under Apache
2.0 (not dual MIT/Apache-2.0), update the NOTICE file in the same
commit; Apache 2.0 §4 requires those attribution entries. Easier to
keep the file correct as you go than to reconstruct it later.
scripts/check-notice.sh diffs the current Apache-2.0-only dep set
against the names already mentioned in NOTICE and fails on a gap.
scripts/check.sh and the CI lint job both run it. Zero output and
exit 0 means the file is current.
bash scripts/check-notice.shIf it prints crate names, add an attribution stanza to NOTICE for
each (Apache 2.0 section 4(d) requires the copyright notice and the
URL of the project's source). Dev-dependencies that are Apache-only
should also be listed (mark them "Used as a dev-dependency in test
fixtures only" so the intent is clear). The check is conservative;
err on the side of attributing rather than skipping.
Commercial licensing inquiries: legal@soapbucket.com. Trademark
policy is in TRADEMARKS.md. Copyright holder is Soap Bucket LLC.