Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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;
}