tollgate_store/wire.rs
1//! The HTTP wire contract between `tollgate-server` and `tollgate-client`'s HTTP
2//! transport. One place, versioned by [`API_PREFIX`] on the server.
3//!
4//! Every 128-bit identifier is exactly 32 lowercase hexadecimal characters,
5//! without a `0x` prefix, in both JSON strings and URL path segments. JSON
6//! numbers are not portable for these values: common consumers round integers
7//! above 2^53. Snapshots travel whole, cost table included — the receiving
8//! instance validates them and resolves nothing again.
9
10use std::sync::Arc;
11
12use jiff::SignedDuration;
13use serde::{Deserialize, Serialize};
14
15use tollgate_core::{
16 AccountId, AccountSnapshot, AccountStatus, CapacityClass, CostUnits, FencingToken, Generation,
17 LeaseId, UsageEvent,
18};
19
20/// Current HTTP wire-contract prefix.
21///
22/// The pre-public V1 contract uses canonical textual identifiers. Keeping the
23/// prefix here makes client and server select the same contract without
24/// duplicating a magic string.
25pub const API_PREFIX: &str = "/v1";
26
27/// The widest a single [`UsageEvent`] serialises to, in bytes.
28///
29/// Measured, not estimated: every identifier at its full 32-hex width, the
30/// policy revision at its full 64-hex width, both 64-bit fields at `u64::MAX`,
31/// and an expanded negative year with nine fractional digits — the leased
32/// form, which carries a lease id and fencing token the overage form does not.
33///
34/// ```text
35/// {"request_id":"ff…ff","account_id":"ff…ff","source":{"Leased":
36/// {"lease_id":"ff…ff","fencing_token":18446744073709551615}},
37/// "units":18446744073709551615,"occurred_at":"-009999-01-02T01:59:59.999999999Z",
38/// "policy_revision":"ff…ff","key_id":"ff…ff"}
39/// ```
40///
41/// Pinned by `the_widest_usage_event_still_fits_its_declared_size`, so a field
42/// added to `UsageEvent` cannot silently push a legitimate batch past the body
43/// limit derived from this number.
44///
45/// GL-94 raised it from 268: the policy revision is fixed-width, so it costs the
46/// same 85 bytes on every event whether stated or unstated. That is the price
47/// of carrying it on the wire in its canonical spelling, and it is recorded
48/// here rather than discovered when a maximal batch starts being refused.
49/// GL-105 adds the optional key ID and includes expanded negative years and
50/// nine fractional digits in the fixture: measured maximum 410 bytes.
51pub const MAX_USAGE_EVENT_BYTES: usize = 410;
52
53/// The body limit `/v1/usage/ingest` is served with, in bytes.
54///
55/// Derived from the two constants above rather than chosen: a full batch of
56/// the widest events is `MAX_INGEST_BATCH * (MAX_USAGE_EVENT_BYTES + 1)` — the
57/// `+ 1` being each event's separating comma — plus `{"events":[]}`. That is
58/// about 1.61 MiB, and 2 MiB still leaves room to
59/// spare, so a legitimate maximal batch is never refused for want of a byte.
60/// The headroom is checked by
61/// `a_full_batch_of_the_widest_events_fits_the_declared_body_limit`, not
62/// assumed: the next field to land here may be the one that exhausts it, and
63/// this limit must then rise with it.
64///
65/// Declared rather than inherited. Without it the endpoint ran on axum's
66/// implicit 2 MiB default, which no document stated and which the server
67/// reported as malformed JSON when it bit (GL-61).
68pub const MAX_INGEST_BODY_BYTES: usize = 2 * 1024 * 1024;
69
70/// Four maximal u64 counters plus field names and framing. Includes a
71/// present attribution count; legacy missing/null values are shorter.
72/// Pinned by the maximal acknowledgement serialization witness.
73pub const MAX_INGEST_REPORT_BYTES: usize = 134;
74
75/// The body limit `PUT /v1/admin/snapshots/{principal}` is served with, in
76/// bytes.
77///
78/// A published snapshot carries its whole cost table, so its worst case grows
79/// with the number of priced classes rather than with any batch size. Measured
80/// against that: full-width weights and per-operation permissions serialize
81/// as parallel arrays at about 32 bytes per class. A hundred-thousand-class
82/// catalogue is about 3.1 MiB and fits with room for the snapshot envelope. Pinned
83/// by `the_snapshot_limit_admits_a_hundred_thousand_class_catalogue`. It is stated for the same
84/// reason the ingest limit is — an operator publishing a large catalogue
85/// should be refused by a documented number or not at all, never by an
86/// undocumented default reported as bad JSON.
87pub const MAX_SNAPSHOT_BODY_BYTES: usize = 4 * 1024 * 1024;
88
89/// One complete active credential projection. Digests are fixed 32-byte arrays;
90/// HMAC secrets and raw credentials are never part of this response.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct KeysResponse {
93 /// The source revision the page was read at.
94 pub revision: u64,
95 /// The server-clock instant active records were selected at.
96 pub as_of: jiff::Timestamp,
97 /// Active credentials, in strictly increasing key-id order.
98 pub keys: Vec<crate::CredentialRecord>,
99 /// Cursor for the next page, or `null` when this page is the last. Required:
100 /// an omitted field is refused rather than read as terminal.
101 #[serde(deserialize_with = "crate::credentials::required_option")]
102 pub next_after: Option<tollgate_core::KeyId>,
103}
104
105/// Fixed-width identifiers/digest plus the widest timestamp and JSON framing.
106/// `wire_limits` measures a maximal record and page against these bounds.
107pub const MAX_KEY_RECORD_BYTES: usize = 216;
108/// The maximal envelope is 134 bytes; the first record needs no comma.
109pub const MAX_KEYS_BODY_BYTES: usize = crate::MAX_KEY_PAGE_LIMIT * (MAX_KEY_RECORD_BYTES + 1) + 133;
110
111/// The TTL fields shared by both lease-creating requests.
112///
113/// Positive whole seconds through `u32::MAX` retain the original
114/// `ttl_seconds` spelling. Every other positive duration carries Jiff's exact
115/// duration string in `ttl`, with `ttl_seconds: 0`: a legacy server rejects
116/// that sentinel instead of silently allocating a differently timed lease.
117/// Upgrade servers before enabling these durations on HTTP clients.
118///
119/// Deserialized fields are untrusted; [`Self::duration`] validates them before
120/// a handler invokes its allocator. [`Self::try_from`] refuses nonpositive caller
121/// input before an HTTP request is built. Neither operation applies policy's
122/// maximum TTL: that remains the allocator's decision.
123#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
124pub struct LeaseTtl {
125 ttl_seconds: u32,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
127 ttl: Option<SignedDuration>,
128}
129
130impl TryFrom<SignedDuration> for LeaseTtl {
131 type Error = crate::AllocateError;
132
133 fn try_from(ttl: SignedDuration) -> Result<Self, Self::Error> {
134 if ttl <= SignedDuration::ZERO {
135 return Err(crate::AllocateError::InvalidTtl);
136 }
137 Ok(match (u32::try_from(ttl.as_secs()), ttl.subsec_nanos()) {
138 (Ok(ttl_seconds), 0) => Self {
139 ttl_seconds,
140 ttl: None,
141 },
142 _ => Self {
143 ttl_seconds: 0,
144 ttl: Some(ttl),
145 },
146 })
147 }
148}
149
150impl LeaseTtl {
151 /// The lease lifetime this request declares.
152 ///
153 /// # Errors
154 ///
155 /// [`AllocateError::InvalidTtl`](crate::AllocateError::InvalidTtl) when
156 /// `ttl_seconds` is nonzero alongside `ttl`, or the declared duration is not
157 /// strictly positive.
158 pub fn duration(self) -> Result<SignedDuration, crate::AllocateError> {
159 let ttl = match (self.ttl_seconds, self.ttl) {
160 (0, Some(ttl)) => ttl,
161 (seconds, None) => SignedDuration::from_secs(i64::from(seconds)),
162 // Two nonzero declarations have no implicit precedence.
163 _ => return Err(crate::AllocateError::InvalidTtl),
164 };
165 if ttl <= SignedDuration::ZERO {
166 return Err(crate::AllocateError::InvalidTtl);
167 }
168 Ok(ttl)
169 }
170}
171
172/// Body of `POST /v1/leases/acquire`: [`LeaseAllocator::acquire`](crate::LeaseAllocator::acquire)
173/// over HTTP.
174#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
175pub struct AcquireRequest {
176 /// The account to debit.
177 pub account_id: AccountId,
178 /// Units asked for. The grant may be smaller, per the server's [`GrantPolicy`](crate::GrantPolicy).
179 pub requested: CostUnits,
180 /// Requested lease lifetime, flattened into this object. The server clamps it to its policy's `max_ttl`.
181 #[serde(flatten)]
182 pub ttl: LeaseTtl,
183}
184
185/// A grant with its funding evidence flattened beside it. A client that
186/// predates the evidence reads the grant and ignores `funding`; a server that
187/// predates it omits `funding`, which reads as no attestation.
188pub type AcquireResponse = crate::Allocation;
189
190/// Body of `POST /v1/leases/release`: [`LeaseAllocator::release`](crate::LeaseAllocator::release)
191/// over HTTP.
192#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
193pub struct ReleaseRequest {
194 /// The lease to release.
195 pub lease_id: LeaseId,
196 /// The fencing token stored with that lease; any other value is refused as fenced.
197 pub fencing_token: FencingToken,
198 /// Units the holder did not spend, credited back to the account.
199 pub unspent: CostUnits,
200}
201
202/// A lease returned and re-granted in one server-side transaction.
203///
204/// Deliberately not an `AcquireRequest` plus a `ReleaseRequest`: the account
205/// is the one the released lease names, so there is no field for a client to
206/// disagree with the ledger about (see `LeaseAllocator::consolidate`).
207#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
208pub struct ConsolidateRequest {
209 /// The lease to return.
210 pub lease_id: LeaseId,
211 /// The fencing token stored with that lease; any other value is refused as fenced.
212 pub fencing_token: FencingToken,
213 /// Units the holder did not spend on the returned lease.
214 pub unspent: CostUnits,
215 /// Units asked for in the replacement grant.
216 pub requested: CostUnits,
217 /// The largest quote the returned lease refused (GL-131). Omitted when
218 /// zero, so a server that predates it sees the request it always did;
219 /// absent from an older client, it reads as zero: today's sizing.
220 #[serde(default, skip_serializing_if = "no_demand")]
221 pub needed: CostUnits,
222 /// Replacement lease lifetime, flattened into this object.
223 #[serde(flatten)]
224 pub ttl: LeaseTtl,
225}
226
227/// Response to `POST /v1/leases/consolidate`: the replacement grant and its
228/// funding evidence, shaped exactly as [`AcquireResponse`].
229pub type ConsolidateResponse = crate::Allocation;
230
231// Serde passes a reference; `CostUnits::is_zero` takes the value.
232#[allow(
233 clippy::trivially_copy_pass_by_ref,
234 reason = "serde's skip_serializing_if passes the field by reference"
235)]
236fn no_demand(units: &CostUnits) -> bool {
237 units.is_zero()
238}
239
240/// The owned form, which the server needs: axum's `Json<T>` extractor requires
241/// `DeserializeOwned`, so the receiving side cannot borrow from the body.
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct IngestRequest {
244 /// The usage batch. The route's body limit, [`MAX_INGEST_BODY_BYTES`], admits
245 /// [`MAX_INGEST_BATCH`](crate::MAX_INGEST_BATCH) of the widest events.
246 pub events: Vec<UsageEvent>,
247}
248
249/// The sending form. `UsageSink::ingest` hands the transport a `&[UsageEvent]`
250/// it does not own, and copying that batch into a `Vec` just to reach serde
251/// bought nothing — least of all on the one path where it happened repeatedly.
252/// A failing sink is retried indefinitely with backoff by design (an outage is
253/// a duration, not an event), and every attempt re-copied the same batch (GL-20).
254///
255/// This produces byte-identical JSON to [`IngestRequest`], which is the whole
256/// reason it is safe to have two types; `borrowed_and_owned_ingest_requests_serialize_identically`
257/// is what checks that rather than trusting it.
258#[derive(Debug, Serialize)]
259pub struct IngestRequestRef<'a> {
260 /// The usage batch to send.
261 pub events: &'a [UsageEvent],
262}
263
264/// Body of `POST /v1/admin/accounts`. The server creates the account with
265/// [`CapacityClass::Assured`]; change it afterwards with
266/// [`SetCapacityClassRequest`].
267#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct CreateAccountRequest {
269 /// Identifier for the new account.
270 pub account_id: AccountId,
271 /// Opening balance, deposited as a top-up.
272 pub initial_balance: CostUnits,
273 /// Administrative status at creation.
274 pub status: AccountStatus,
275}
276
277/// Body of `POST /v1/admin/accounts/{account}/deposit`.
278#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
279pub struct DepositRequest {
280 /// Units to add as a top-up. The server refuses zero.
281 pub units: CostUnits,
282}
283
284#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
285/// The one operator action for an account's administrative status (GL-51).
286///
287/// An [`AccountStatus`] rather than the `active` bool this carried before:
288/// the bool named only the ledger flag, while the request now also republishes
289/// every live snapshot of the account. Changing what an existing field means
290/// would have been the silent-semantics change the repository guidelines
291/// forbid, so the field is renamed and an old body fails loudly.
292pub struct SetStatusRequest {
293 /// The status to set.
294 pub status: AccountStatus,
295}
296
297/// The one operator action for an account's execution-capacity class (GL-99).
298///
299/// Its own request type rather than an optional field on
300/// [`SetStatusRequest`]: the two are different operator decisions about
301/// different axes, and a combined body would make "change the status" and
302/// "change the class" indistinguishable from "change the status and leave the
303/// class alone" without a nested `Option` nobody would enjoy reading.
304///
305/// The response is [`SetStatusResponse`], reused deliberately: both actions
306/// answer the same question — how many live snapshots did this move, and how
307/// many rows could not be pushed.
308#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
309pub struct SetCapacityClassRequest {
310 /// The class to set.
311 pub capacity_class: CapacityClass,
312}
313
314/// What a status change did, so an operator learns its blast radius at the
315/// moment of the call rather than from a later denial (GL-51).
316///
317/// `republished: 0` means the account had no live snapshot to change — no
318/// credentials, all of them revoked, or the change was a repeat. All three are
319/// worth seeing.
320#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
321pub struct SetStatusResponse {
322 /// Live snapshots republished with the new value; see [`StatusChange::republished`](crate::StatusChange::republished).
323 pub republished: usize,
324 /// Rows that changed durably but could not be decoded well enough to push,
325 /// so those principals converge only at their next refresh. Always zero on
326 /// an in-memory backend.
327 pub unreadable: usize,
328}
329
330/// Body of both snapshot-publication routes:
331/// `PUT /v1/admin/snapshots/{principal}` and
332/// `PUT /v1/admin/accounts/{account}/keys/{key}/snapshot`.
333#[derive(Debug, Clone, Serialize, Deserialize)]
334pub struct PublishSnapshotRequest {
335 /// The complete compiled snapshot, cost table included, which the server
336 /// validates before publishing. On the credential-bound route, an unstated
337 /// `key_id` is filled in from the path.
338 pub snapshot: Arc<AccountSnapshot>,
339}
340
341/// The catalogue of principals a control plane knows, revoked ones included
342/// (GL-48). An instance serving any customer needs this to learn the set that
343/// already exists; pushes only carry what changes after it subscribes.
344#[derive(Debug, Clone, Serialize, Deserialize)]
345pub struct PrincipalsResponse {
346 /// Every principal the control plane knows, revoked ones included.
347 pub principals: Vec<tollgate_core::Principal>,
348}
349
350/// RFC-7807-shaped error body with a stable machine `code`, mirrored back
351/// into domain errors by the HTTP transport.
352#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct Problem {
354 /// The HTTP status code of the response.
355 pub status: u16,
356 /// Stable machine-readable error code, such as `unknown-account`, which the
357 /// HTTP transport maps back to a domain error.
358 pub code: String,
359 /// Human-readable summary. Never carries backend error text (INVARIANTS.md 37).
360 pub title: String,
361 /// Optional extension used by versioned tombstone responses.
362 #[serde(default, skip_serializing_if = "Option::is_none")]
363 pub generation: Option<Generation>,
364 /// Authoritative exhaustion, present only on a confirmed allocator refusal.
365 #[serde(default, skip_serializing_if = "Option::is_none")]
366 pub balance_exhaustion: Option<tollgate_core::BalanceExhaustion>,
367 /// Authoritative remaining funding on an `insufficient-balance` refusal.
368 /// The code is unchanged, so a client that ignores this extension reads
369 /// the same unattested refusal it always did.
370 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub balance_shortfall: Option<tollgate_core::BalanceShortfall>,
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377 use jiff::Timestamp;
378 use tollgate_core::PolicyRevision;
379 use tollgate_core::RequestId;
380 use tollgate_core::UsageSource;
381
382 /// GL-131's demand field changes nothing on the wire until it is used: an
383 /// older server never sees it at zero, and an older client's request
384 /// reads as zero.
385 #[test]
386 fn consolidation_demand_is_invisible_until_used() {
387 let request = |needed| ConsolidateRequest {
388 lease_id: LeaseId(1),
389 fencing_token: FencingToken(1),
390 unspent: CostUnits(30),
391 requested: CostUnits(1_000),
392 needed: CostUnits(needed),
393 ttl: LeaseTtl::try_from(SignedDuration::from_secs(60)).unwrap(),
394 };
395 let idle = serde_json::to_value(request(0)).unwrap();
396 assert!(idle.get("needed").is_none(), "{idle}");
397 let old: ConsolidateRequest = serde_json::from_value(idle).unwrap();
398 assert_eq!(old.needed, CostUnits::ZERO);
399 let demand = serde_json::to_value(request(51)).unwrap();
400 assert_eq!(demand["needed"], 51);
401 let parsed: ConsolidateRequest = serde_json::from_value(demand).unwrap();
402 assert_eq!(parsed.needed, CostUnits(51));
403 }
404
405 fn event(seq: u128) -> UsageEvent {
406 UsageEvent::new(
407 RequestId(seq),
408 AccountId(7),
409 UsageSource::Leased {
410 lease_id: LeaseId(11),
411 fencing_token: FencingToken(3),
412 },
413 CostUnits(64),
414 Timestamp::from_second(1_755_600_000).unwrap(),
415 PolicyRevision::UNSTATED,
416 None,
417 )
418 }
419
420 /// GL-20 added a second ingest type so the transport could stop copying the
421 /// batch, and the entire argument for that being safe is that the two
422 /// produce the same bytes. Checked here rather than trusted, because it is
423 /// a claim about what leaves the process.
424 #[test]
425 fn borrowed_and_owned_ingest_requests_serialize_identically() {
426 for count in [0, 1, 256] {
427 let events: Vec<UsageEvent> = (0..count).map(event).collect();
428 let borrowed = serde_json::to_string(&IngestRequestRef { events: &events }).unwrap();
429 let owned = serde_json::to_string(&IngestRequest {
430 events: events.clone(),
431 })
432 .unwrap();
433 assert_eq!(
434 borrowed, owned,
435 "the two ingest forms disagree at {count} events, so the wire \
436 format depends on which one the caller happened to use"
437 );
438 }
439 }
440
441 /// The client→server path in one fast test: the transport serializes the
442 /// borrowed form and the server deserializes the owned one. A field added
443 /// to one type and not the other fails here in milliseconds instead of
444 /// only in the loopback suite.
445 #[test]
446 fn a_borrowed_request_deserializes_as_the_owned_one() {
447 let events: Vec<UsageEvent> = (0..3).map(event).collect();
448 let body = serde_json::to_string(&IngestRequestRef { events: &events }).unwrap();
449 let received: IngestRequest = serde_json::from_str(&body).unwrap();
450 assert_eq!(received.events, events);
451 }
452
453 /// An empty flush is legitimate — the writer can wake with nothing queued
454 /// — and must not become a malformed body or a missing field.
455 #[test]
456 fn an_empty_batch_stays_an_empty_list() {
457 let body = serde_json::to_string(&IngestRequestRef { events: &[] }).unwrap();
458 assert_eq!(body, r#"{"events":[]}"#);
459 let received: IngestRequest = serde_json::from_str(&body).unwrap();
460 assert!(received.events.is_empty());
461 }
462
463 #[test]
464 fn high_bit_ids_are_portable_text_in_an_untyped_json_consumer() {
465 let high = (1u128 << 127) | 0x2a;
466 let mut event = event(high);
467 event.account_id = AccountId(high + 1);
468 event.source = UsageSource::Leased {
469 lease_id: LeaseId(high + 2),
470 fencing_token: FencingToken(3),
471 };
472 let events = [event];
473 let value = serde_json::to_value(IngestRequestRef { events: &events }).unwrap();
474 let event = &value["events"][0];
475 let leased = &event["source"]["Leased"];
476
477 for (field, node, expected) in [
478 ("request_id", event, high),
479 ("account_id", event, high + 1),
480 ("lease_id", leased, high + 2),
481 ] {
482 let text = node[field]
483 .as_str()
484 .unwrap_or_else(|| panic!("{field} must be JSON text, got {}", node[field]));
485 assert_eq!(text.len(), 32);
486 assert_eq!(u128::from_str_radix(text, 16).unwrap(), expected);
487 }
488
489 let received: IngestRequest = serde_json::from_value(value).unwrap();
490 assert_eq!(received.events, events);
491 }
492}
493
494/// One account as an operator reads it (GL-121).
495///
496/// **Authoritative, not an estimate**, and as of the instant it was read: every
497/// field comes from one consistent backend snapshot, so the terms agree with
498/// each other. It is not a live feed — an admission committed a millisecond
499/// later is not in it — so a caller comparing two reads is comparing two
500/// instants, which is why `as_of` is part of the answer rather than left to a
501/// header.
502///
503/// **Funding is not billing.** `balance` is what remains *spendable*, and it
504/// falls for three different reasons that must not be conflated:
505/// units going out on a lease that has not settled
506/// (`outstanding_lease_grants`), units actually consumed (`settled_usage`),
507/// and a budget period closing on an unspent allowance (`expired_allowance`).
508/// Only the second is billed. A surface reporting depletion alone would let a
509/// customer read a grant as spend.
510///
511/// All unit counts are `CostUnits` — whole units, never fractional and never a
512/// currency; converting to money is the application's job, with its own
513/// prices.
514#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
515pub struct AccountResponse {
516 /// The account read.
517 pub account_id: tollgate_core::AccountId,
518 /// When this view was read. Every other field is as of this instant.
519 pub as_of: jiff::Timestamp,
520 /// Administrative status.
521 pub status: tollgate_core::AccountStatus,
522 /// Execution-capacity class.
523 pub capacity_class: tollgate_core::CapacityClass,
524 /// Which authority created the account (#39). Defaults to `Operator` when
525 /// absent, which is what every account a server predating the field
526 /// holds.
527 #[serde(default = "operator_authority")]
528 pub origin: crate::AdminAuthority,
529 /// Which authority set the current `status`. An operator's `Suspended` is
530 /// a hold a provisioner cannot lift (#39). Defaults as `origin` does.
531 #[serde(default = "operator_authority")]
532 pub status_set_by: crate::AdminAuthority,
533 /// The periodic allowance, or `None` for "no schedule; the balance does
534 /// not expire". Not the same as an allowance of zero.
535 pub budget: Option<tollgate_core::BudgetSchedule>,
536 /// First instant of the period currently in force. Meaningful only
537 /// alongside `budget`.
538 pub period_start: jiff::Timestamp,
539 /// Still spendable. **Not** a bill, and not what has been used.
540 pub balance: tollgate_core::CostUnits,
541 /// Out on leases that have not settled: committed capacity, not yet spend,
542 /// and not yet billable.
543 pub outstanding_lease_grants: tollgate_core::CostUnits,
544 /// Consumed and billable. This is the usage number.
545 pub settled_usage: tollgate_core::CostUnits,
546 /// Funded but never spendable again, because the period that funded them
547 /// closed.
548 pub expired_allowance: tollgate_core::CostUnits,
549 /// Granted units that settled without being accounted for by usage —
550 /// reported rather than absorbed, because silence would make loss look
551 /// like unspent capacity.
552 pub settlement_loss: tollgate_core::CostUnits,
553 /// Everything ever deposited, and unfunded units admitted under
554 /// `Elastic`. Together these are the left side of the funding equation
555 /// whose right side is the four figures above.
556 pub deposited: tollgate_core::CostUnits,
557 /// Unfunded units admitted under `Elastic` enforcement; see [`Conservation::overage_recorded`](crate::Conservation::overage_recorded).
558 pub overage_recorded: tollgate_core::CostUnits,
559}
560
561/// Set or clear an account's periodic allowance (GL-121).
562///
563/// `budget: null` clears the schedule, which is a different request from one
564/// with an allowance of zero: the first means "this balance does not expire",
565/// the second means "this account is funded nothing each period". The field is
566/// required rather than defaulted so neither can be reached by omission.
567#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
568#[serde(deny_unknown_fields)]
569pub struct SetBudgetRequest {
570 /// The schedule to set, or `null` to clear it. Required: an omitted field is refused.
571 #[serde(deserialize_with = "required_budget")]
572 pub budget: Option<tollgate_core::BudgetSchedule>,
573}
574
575// A custom field deserializer makes omission an error while still accepting
576// explicit JSON null. Plain Option deserialization also accepts missing fields.
577fn required_budget<'de, D>(
578 deserializer: D,
579) -> Result<Option<tollgate_core::BudgetSchedule>, D::Error>
580where
581 D: serde::Deserializer<'de>,
582{
583 Option::deserialize(deserializer)
584}
585
586/// What a budget change committed.
587///
588/// Both states, because "set to 500" is not the useful answer on its own — an
589/// operator needs to know whether that introduced a schedule, replaced a
590/// different one, or changed nothing. Equal values mean the call was a no-op.
591#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
592pub struct SetBudgetResponse {
593 /// The schedule this call replaced, or `null` when there was none.
594 pub previous: Option<tollgate_core::BudgetSchedule>,
595 /// The schedule now in force, which is the one requested.
596 pub current: Option<tollgate_core::BudgetSchedule>,
597}
598
599/// Issue one credential for an account (GL-121).
600///
601/// The caller chooses `key_id`, and that choice is the retry contract: a
602/// request that is lost after the credential is stored can be resent with the
603/// same id and will be refused as a duplicate rather than minting a second
604/// credential. Choose an unguessable one (a v4 UUID) and keep it until the
605/// call is acknowledged.
606///
607/// `max_active_keys` is the caller's own policy, enforced here atomically
608/// against concurrent issuers. It is supplied per request because what counts
609/// as a reasonable number of credentials belongs to the application's plan.
610#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
611#[serde(deny_unknown_fields)]
612pub struct IssueKeyRequest {
613 /// Caller-chosen identifier for the new credential. Resending it after a lost
614 /// response is refused as a duplicate.
615 pub key_id: tollgate_core::KeyId,
616 /// The most live credentials the account may hold. Issuance is refused when
617 /// the account already holds this many; the count and the insert are atomic.
618 pub max_active_keys: std::num::NonZeroUsize,
619 /// When the credential stops being valid of its own accord. `None` means
620 /// it lapses only on revocation.
621 #[serde(default)]
622 pub not_after: Option<jiff::Timestamp>,
623}
624
625/// A newly issued credential — **the only time its secret is ever returned**.
626///
627/// The secret is not stored anywhere in recoverable form: what persists is an
628/// HMAC of it. A caller that loses this response cannot get the secret back by
629/// any means, and resending the request answers `409` rather than reissuing.
630/// The recovery is to revoke the credential and issue a new one under a new
631/// `key_id`.
632#[derive(Debug, Clone, Serialize, Deserialize)]
633pub struct IssuedKeyResponse {
634 /// The issued credential's identifier.
635 pub key_id: tollgate_core::KeyId,
636 /// The bearer secret, hex-encoded. Disclosed once.
637 pub secret: String,
638 /// The credential's expiry, as requested, or `null` for none.
639 pub not_after: Option<jiff::Timestamp>,
640}
641
642/// One credential in an account listing. Never carries the secret, the
643/// verifier digest, or the principal — the principal is the leading 128 bits
644/// of the digest, so exposing it would leak half of it.
645#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
646pub struct AccountKeyResponse {
647 /// The credential's non-secret identifier.
648 pub key_id: tollgate_core::KeyId,
649 /// When the credential stops being valid of its own accord, or `null` if never.
650 pub not_after: Option<jiff::Timestamp>,
651 /// When the credential was revoked, or `null` if it has not been.
652 pub revoked_at: Option<jiff::Timestamp>,
653 /// Whether this credential can still authenticate as of `as_of` in the
654 /// enclosing page. Derived, so a caller need not re-implement the rule.
655 pub live: bool,
656}
657
658/// One page of an account's credentials.
659#[derive(Debug, Clone, Serialize, Deserialize)]
660pub struct AccountKeysResponse {
661 /// The server-clock instant each `live` flag is evaluated at.
662 pub as_of: jiff::Timestamp,
663 /// Credentials in increasing key-id order, revoked and expired ones included.
664 pub keys: Vec<AccountKeyResponse>,
665 /// Cursor for the next page, or `None` when this page is the last.
666 pub next_after: Option<tollgate_core::KeyId>,
667}
668
669/// What a revocation did. `retired: false` means the credential was already
670/// revoked — reported rather than treated as an error, because the caller's
671/// intent is satisfied either way, but the distinction matters in an audit.
672#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
673pub struct RevokeKeyResponse {
674 /// The credential named in the request.
675 pub key_id: tollgate_core::KeyId,
676 /// `true` if this call retired the credential, `false` if it was already revoked.
677 pub retired: bool,
678}
679
680fn operator_authority() -> crate::AdminAuthority {
681 crate::AdminAuthority::Operator
682}