@balia-dev/billing-client
v0.2.0
Published
The contract between the BALIA billing service and a product (ADR-017)
Readme
@balia-dev/billing-client
The contract between the BALIA billing service and a product — ADR-017.
Both ends import this package. The service signs with the same code a product verifies with. That is the point: two copies of a signature scheme drift, and the drift surfaces months later as "events stopped verifying on one product".
What a product does
import {
receiveEvent,
responseFor,
isEnabled,
type ProjectionStore,
} from "@balia-dev/billing-client";
// 1. Implement the port against your own database: the subject types you
// have, read, hasApplied, apply (which says whether it applied, found the
// event stale under its lock, or has no such subject — and what it holds
// after), and held (what it holds now, read back, full precision, in the
// form isWireInstant accepts — in Postgres, to_json(col) #>> '{}' for a
// timestamptz column), in both held() and what apply returns.
const store: ProjectionStore = myDrizzleAdapter;
// 2. Receive events on /internal/billing/events.
app.post("/internal/billing/events", async (c) => {
const outcome = await receiveEvent(
await c.req.text(), // the RAW body — see below
c.req.header("X-Balia-Signature"),
{ secrets: [env.BILLING_SIGNING_SECRET], store },
);
const { status, body } = responseFor(outcome);
return body === null
? c.body(null, status)
: c.body(body, status, { "Content-Type": "application/json" });
});
// 3. Decide access, locally.
if (await isEnabled(store, { type: "user", id: userId }, "chat_with_video")) {
// ...
}The raw body, not a parsed one. The signature covers the bytes that were sent. A framework that parses JSON and re-serialises it produces different bytes — different key order, different spacing — and nothing will ever verify.
The answer, status and body. Since 0.2.0 a handled event is answered 200
with a small JSON body — what became of the event (applied, duplicate,
stale, unknown_subject) and what the product now holds for the subject (its
decision time and a fingerprint of its set). The service keeps that as the
product's acknowledged state; an empty 200 still stops its retries but
acknowledges nothing. A subject the product no longer has is answered 200
unknown_subject, never 404 or 410: the service retries anything that is not a
2xx, and a 410 would speak for the whole endpoint.
What a product never does
Interpret a billing state. There is none here to interpret: no subscription status, no grace period, no invoice, no trial, no proration. A right is live or it is not, and the service already decided which.
That is not tidiness. Both providers make the naive reading wrong: Stripe's
past_due must not revoke — the retries are still running — and a
RevenueCat subscription in its grace period is still active. A product
reimplementing that logic would reimplement it wrong, and each product would be
wrong differently.
What it can express
Everything, without naming how it was paid for. A subscription is a right with
an expiry the service keeps pushing forward; a one-off purchase or a perpetual
licence is a right with activeUntil: null; an admin grant is source:
"manual". The day a BALIA product is not a subscription SaaS, only the service
learns a new way to sell — no projection changes, no product migrates.
Guarantees
- No network call on the access path. The package exposes no way to ask the
service anything from
isEnabled/configValue/limitOf. The rule is structural rather than disciplinary: there is no method to misuse. The single exception lives in its own file,resyncAfterCheckout, and its name says where it belongs. - No runtime dependency. A product installs the contract without inheriting a validator's version, so a major release of somebody else's library can never become a coordinated upgrade across every BALIA product.
- Replays are free. An event id already applied changes nothing and is
answered
duplicate; an event whoseauthorityAtis older than what is projected changes nothing and is answeredstale— decided by the adapter, under its lock, at full precision. Delivery order cannot be trusted — RevenueCat documents that its events may arrive in any order — so nothing here depends on it.
Why this being public is not a risk to the products
The danger of a shared public package is never that someone reads it — it is that someone publishes to it. A compromised release reaches every BALIA product on its next install. Five things stand in the way, and each is checked rather than promised:
| | |
|---|---|
| No install scripts | install / preinstall / postinstall are the classic foothold: they run on every machine that installs, with the developer's privileges. This package has none, and pnpm guard fails if one appears. Installing from npm also never runs prepare, unlike a git dependency — one of the reasons this is published rather than pulled from git. |
| No runtime dependency | There is no upstream to compromise. The envelope parser is hand-written for exactly this reason. |
| Nothing publishes from a laptop | Releases are cut by tagging, and the workflow authenticates through npm trusted publishing (OIDC). No npm token exists to leak, to rotate, or to be stolen from a machine. |
| What ships is inspected | pnpm guard runs on every PR and again before publishing, on the actual tarball: file list, install hooks, dependencies, and a scan for hostnames, keys and database URLs in the built artefact. Each of its six checks has been verified to fail when the thing it forbids is introduced. |
| Products pin exact versions | With a committed lockfile and --frozen-lockfile, a product installs the bytes it locked. Since pnpm 11.4 an integrity mismatch is a hard failure rather than a silent re-resolve. |
No provenance attestation, and that is a decision. npm can attach a signed proof of which workflow and commit produced a tarball, but it only issues one for a public source repository — a proof pointing at commits nobody can look up would be a false assurance. This repository is private on purpose, so the attestation is declined rather than the privacy. What it would have bought is a way for a stranger to verify the origin of what they installed; there are no strangers here. Every consumer is a BALIA product pinning an exact version whose integrity its lockfile verifies on each install.
What the package can never contain is as important as what guards it: no service URL (it is a parameter), no key, and none of the code that handles encrypted secrets — that stays in the service.
Development
pnpm install
pnpm test # no database needed
pnpm typecheck
pnpm lint
pnpm buildVersions: in 0.x a minor version may change the port or the answer. The service deploys a new version before the products do, so that it reads every answer a product can send.
Each test encodes a way access breaks — an unsigned event believed, a replay applied twice, a late event resurrecting a revoked right, a corrupt expiry read as "no expiry" and granting permanent access. Every one of them has been checked to fail when the line it protects is removed.
