crates/gunnar-ui-server/proto/gunnar/v1/billing.proto
syntax = "proto3";
// ─────────────────────────────────────────────────────────────────────────────
// `Billing`: **the appliance's word that this human may renew this tenant.**
// Compiled only with the `metered` feature, exactly as `entitlement.proto` is:
// the community appliance does not DECLINE to sell a renewal, it does not
// CONTAIN the code, and the symbol table of the packed binary is what proves it
// (`OptionAudit`, `gunnar-cli/tests/feature_split.rs`).
//
// # Which way this one points, and why it exists at all
//
// Every other signed thing that crosses the monetize seam travels
// monetize → appliance: a `monetize_embed::signing::verify_fact` is monetize's
// verdict about a tenant, and a `GoAhead` is monetize's permission to grow a
// data set. Both are VERIFIED here, against monetize's key.
//
// An actor ticket travels the other way, and one fact about monetize is the
// whole reason. monetize authenticates exactly ONE caller on its gRPC surface —
// a shared bearer token, and an empty one means open — so `Purchase.Start`
// cannot tell the product's console from the product's operator, and certainly
// cannot tell one browser session from another. When a CUSTOMER renews their
// own subscription, the thing monetize needs and does not have is evidence that
// the human driving the console holds the tenant they are paying for.
//
// The appliance is the one party that knows. It holds the identity table, the
// keys and the grants; on gunnar a principal's id **is** the namespace it owns
// (`gunnar_core::AuthTable::owns_account`, a string equality with no row to get
// wrong). So the appliance decides, says so in a ticket, and monetize checks
// the signature offline — no call back to a box that may be the very box the
// customer is paying to un-suspend.
//
// # Why the console cannot be trusted to do this by itself
//
// The console (`gunnar-ui`'s BFF) holds a SERVER-WIDE admin grant on this
// appliance, because it renders operator screens. If the console alone decided
// "this browser session may only touch namespace X", then any routing bug in
// the console — one `namespace` read off the wrong request, one cached session
// — would be full administrative reach over every customer on the box. So the
// console does not decide. It presents the human's OWN credential
// (`gunnar-sshsig-v2`, signed with the ed25519 key `gunnar-ui` enrolled on that
// person's own gunnar principal) and gunnar performs the grant check itself,
// against the identity table, on the namespace the request names.
//
// # What a ticket is NOT
//
// A ticket is permission for ONE verb and never a widening of any other. It
// authorises `purpose: "renew"` on exactly the tenant it names. It cannot
// authorise an order (new caps), it cannot name a second tenant, and its
// absence changes nothing — a request to monetize with no ticket is the
// operator path, unchanged.
//
// # The format is monetize-embed's, not a second one written here
//
// `MintActorTicketResponse.ticket` is the JSON of `monetize_embed::ticket::
// ActorTicket`, signed Ed25519 over `monetize_embed::ticket::ticket_message` —
// the canonical form that crate's `verify_ticket` checks, and the same
// canonical form and base64 alphabet a `GoAhead` uses. gunnar links that crate
// and writes no second encoder; `gunnar-grpc/tests/billing.rs` mints here and
// verifies with `monetize_embed::verify_ticket`, so the two halves of the
// contract are proven to agree in the same test run rather than in prose.
// ─────────────────────────────────────────────────────────────────────────────
package gunnar.v1;
service Billing {
// **Mint a ticket for one namespace.** `Admin` on THAT namespace — see
// `MintActorTicketRequest.namespace` for why `Admin` and not `Write`, which
// is a ruling and not an accident.
//
// A namespace the caller does not hold is `PERMISSION_DENIED` and NOTHING is
// minted: the grant check runs before the clock is read and before the key is
// touched, so a refusal cannot leave a signed artefact behind.
//
// **A lapsed tenant may still call this.** gunnar's entitlement gate
// (`gunnar_ssh::PlanGate`) is the SSH/git door only; the gRPC control plane
// has no entitlement gate, so a Suspended namespace still authenticates here
// and can still buy its way back. That is load-bearing rather than
// incidental — a design in which the box must be paid-up before it can be
// paid is a design that cannot serve the case it exists for — and
// `gunnar-grpc/tests/billing.rs` pins it, because if it ever changes,
// self-service renewal dies silently.
rpc MintActorTicket(MintActorTicketRequest) returns (MintActorTicketResponse);
// **The public half of the key the tickets above are signed with**, so an
// operator can configure their monetize with it.
//
// This is a PUBLIC key. Nothing is kept secret by refusing to serve it, and
// that is worth saying out loud so the gate below is not mistaken for
// protecting key material: it is `Admin` at SERVER scope because it is
// OPERATOR CONFIGURATION. The one person who needs this string is the one
// wiring this appliance to a monetize, and handing it to every tenant who
// asks would invite the reading that a ticket is worth something to whoever
// holds the key — it is not; the key VERIFIES tickets, it does not mint them.
rpc ApplianceIdentity(ApplianceIdentityRequest) returns (ApplianceIdentityResponse);
}
message MintActorTicketRequest {
// The tenant the caller claims to act for. On gunnar a namespace and a
// tenant are the same string, and a principal's id IS the namespace it owns,
// so "may this caller act for this tenant" is answered by the ordinary grant
// check every other RPC on this box uses — `Access::Namespace(namespace)` at
// `GrantLevel::ADMIN` — and not by anything this service decides for itself.
//
// **`ADMIN` and not `WRITE`, deliberately.** See the doc comment on the Rust
// handler: this excludes an org member who holds only `Write` on the
// namespace and who may well be the person who actually pays the bill. That
// exclusion is known. It is the direction whose mistake is recoverable:
// widening to `Write` later is a one-line change that nobody has to be told
// about, while narrowing from `Write` to `Admin` would take renewal away
// from customers who had it and had been using it.
string namespace = 1;
// The verb the ticket authorises: `monetize_embed::ticket::PURPOSE_RENEW`
// ("renew") or `PURPOSE_ORDER` ("order").
//
// A string and not a bool or an enum, because a purpose invented later must
// be one that a VERIFIER written today refuses rather than waves through
// because it only looked at the signature. `verify_ticket` takes the purpose
// it wants and compares, so the word has to survive the round trip as a word.
// An empty value is the same as "renew" — the purpose that predates this
// field, so a console that never learned it still renews; anything else is
// `INVALID_ARGUMENT` here, so a caller that asks for a verb this appliance
// does not mint finds out at the mint rather than at the till.
string purpose = 2;
// **The caps the order covers**, meter by meter — the target allowance the
// order will name, exactly as `EntitlementFact.caps` names it.
//
// **Required for `order`, and required to be EMPTY for `renew`.** Both
// mismatches are `INVALID_ARGUMENT` here, by name, because both are a client
// that has confused the two paths and the remedy is to say which path it
// wanted.
//
// The reason an order ticket carries an amount at all: a renewal sells a
// known thing — another period of what the tenant already holds, off their
// stored fact — so the appliance vouching for the tenant is the whole of what
// monetize needs. An order names an AMOUNT, and an amount the ticket did not
// cover is an amount nobody vouched for. A ticket that said only "this human
// may buy" would be a blank cheque, signed by this appliance and spendable by
// whatever sent it, for whatever it liked. So the caps go inside the signed
// message, `verify_ticket` compares them against the order they arrived with,
// and a mismatch is refused rather than ignored — the same rule the tenant,
// product and purpose fields are already held to.
//
// A `map` and not a repeated pair: it is field for field
// `monetize_embed::ticket::ActorTicket::caps`, generated as a `BTreeMap`
// (`gunnar-proto/build.rs`) for the reason `EntitlementFact.caps` is one —
// the signed canonical JSON sorts keys, so an ordered map makes the Rust
// value and the signed bytes agree by construction rather than by the
// serialiser's mood, and nothing between here and the ticket can reorder it.
//
// **This RPC does not judge the numbers.** What a cap MEANS on this box is
// `Entitlement.Set`'s business (`service::meters::caps_gunnar_cannot_honour`
// refuses a meter the appliance can neither enforce nor report, at the sale).
// A ticket is evidence about a PERSON, not a sale: it says this human may act
// for this tenant, up to this amount. monetize decides what to charge, and
// the fact that comes back is what this box then refuses or stores.
map<string, uint64> caps = 3;
}
message MintActorTicketResponse {
// The ticket, as the JSON bytes `monetize_embed::verify_ticket` parses.
//
// `bytes` and not a structured message: the signature covers a canonical
// JSON form, so anything that re-encodes it between here and monetize breaks
// it. Handing over the exact bytes that were signed is the only shape that
// cannot be re-serialised by a well-meaning hop.
bytes ticket = 1;
// When the ticket stops verifying, so a console can decide whether to reuse
// one it already has rather than parsing the JSON to find out.
//
// It is minted far under `monetize_embed::ticket::MAX_LIFE_MS` (ten minutes,
// a cap the VERIFIER enforces and the issuer does not get to choose): a
// ticket is fetched inside the request that spends it, so its life has to
// cover one round trip and a clock disagreement, not a session.
uint64 expires_unix_ms = 2;
}
message ApplianceIdentityRequest {}
message ApplianceIdentityResponse {
// The Ed25519 public key, 64 lowercase hex characters — the form an operator
// pastes into a monetize configuration, and the same form
// `--monetize-pubkey` takes in the other direction.
//
// Hex rather than base64 for one reason: this string is copied by a human
// between two config files, and hex has no alphabet variants, no padding and
// no `+/` versus `-_` question to get wrong on the way.
string public_key_hex = 1;
// The `issuer` field every ticket from this appliance carries (`gunnar`), so
// a monetize holding keys for two products can tell which one this is
// without inferring it from the key it happened to verify under.
string issuer = 2;
}