crates/monetize-server/src/config.rs
//! `/etc/monetize/monetize.toml` — which products are served, which vendors and
//! clouds are active, what a unit of capacity costs the customer, and where the
//! key and the ledger live.
//!
//! ```toml
//! grpc_addr = "127.0.0.1:50061" # gunnar is 50051
//! data_dir = "/var/lib/monetize" # ledger.redb, usage.redb, the pools' state
//! signing_key = "/var/lib/monetize/signing.key" # 32 raw bytes; minted at first start
//! token_file = "/var/lib/monetize/token" # bearer; flag > $MONETIZE_SERVER_TOKEN > this
//! poll_minutes = 5
//! backup_every_minutes = 60 # the backup into backup_dir; 0 = off
//! default_cloud = "server"
//! # The market an order is priced in when the caller names none. SE or FI.
//! default_market = "SE"
//!
//! # THE ONE POLICY: the billing period, grace and retention — for every order,
//! # every product. Not a per-rung number, because there are no rungs.
//! [policy]
//! period = "month" # month | year
//! grace_days = 14
//! retention_days = 90
//!
//! [products.gunnar]
//! endpoint = "127.0.0.1:50051"
//! # THE ACCOUNT gunnar's money goes to: a `[vendors.<kind>.<instance>]` block
//! # below, by its id `<kind>/<instance>`. REQUIRED on every priced product —
//! # a priced product with none, or one naming an instance that is not
//! # configured and enabled, is refused when this file loads.
//! vendor = "stripe/gunnar"
//! # The APPLIANCE's public key, 64 hex — the other direction: monetize VERIFIES
//! # with it and can never mint with it. Set it and a tenant may renew their own
//! # subscription (the appliance vouches for them in a signed actor ticket);
//! # leave it out and only an operator can renew, which is what every deployment
//! # did before this line existed.
//! appliance_key = "3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29"
//! # The host a bought disk attaches to (UpCloud resolves it by title; the
//! # `server` cloud ignores it — the box is the only box). Default: the product id.
//! server = "gunnar-appliance"
//! # THE DATA SET the wallet reading prices (`grow::affordable_bytes`): the cloud
//! # that holds the member volumes, the server they hang off, the members in order.
//! [products.gunnar.grow]
//! cloud = "upcloud"
//! server = "00dc8871-e06c-47a9-af25-116eb0213e09"
//! members = ["01000001-…", "01000002-…", "01000003-…", "01000004-…"]
//!
//! # holger's gRPC door (plaintext; the plugin refuses an `https://` endpoint
//! # because this workspace's tonic carries no TLS feature and must not pretend
//! # it can verify a certificate).
//! [products.holger]
//! endpoint = "127.0.0.1:50051"
//! vendor = "stripe/holger" # holger's OWN Stripe account
//! # Only for a holger with `require_auth_for_reads = true`. Omitted — the
//! # normal case — the plugin calls anonymously, which is holger's posture.
//! # A bearer here makes this file a secret: it must then be 0600 (refused
//! # at load otherwise).
//! # bearer_token = "…"
//!
//! # THE SHOP WINDOW — what a tenant of this product may BUY, by name. Each
//! # entry is a named cap DELTA; the PRICE comes from [prices] below and is
//! # never restated here, and the label is monetize's own words for the delta.
//! # A bundle the price list cannot price is not offered and the reason is
//! # logged (`monetize::bundle`). No bundles = an empty shop window, which is
//! # the open-source shape and not an error.
//! [products.gunnar.bundles."10gb-1m"]
//! cap_delta = { pack_bytes = 10737418240 } # 10 GiB more room
//! # Optional and it decides NOTHING: the period a purchase is billed and
//! # served for is `[policy] period`. It is checked against it, so a bundle
//! # that disagrees is refused rather than sold.
//! period_days = 30
//!
//! # THE PRICE LIST — what a RESOURCE UNIT costs the CUSTOMER, per product, per
//! # market, per month, in the market's minor units (öre for SE, cents for FI).
//! # An order's DELTA is priced by these; the provider's COST is measured
//! # separately and both go on the ledger row. A unit that is absent is 0, and
//! # **absent or zero means FREE — the open-source deployment has no [prices]
//! # at all.** The units are the ones `Products.List` names on each meter:
//! [prices.gunnar.SE]
//! disk_gib_month = 2200 # 22.00 SEK per GiB of pack_bytes per month
//! cpu_core_month = 0
//! ram_gib_month = 0
//! [prices.gunnar.FI]
//! disk_gib_month = 200 # 2.00 EUR
//! # THE RATE THESE PRICES EXCLUDE, in basis points. Absent = the market's own
//! # (SE 2500, FI 2550). Every price above is NET; the tax return computes the
//! # tax on top of it (`monetize::tax`). It is the one key here that is not a unit.
//! vat_basis_points = 2550
//!
//! # THE PAID SIGNUP SEAM — what a hosted checkout page may sell to somebody who
//! # has no tenant yet. The plan id is the vendor page's own; a plan not named
//! # here is REFUSED BY NAME, and an amount that is not this one is a refusal
//! # with both numbers in it. This is the only price the open half carries, and
//! # it is here rather than in the code deliberately (see `SignupConfig`).
//! [signups.gunnar] # one block per product, named by the product it sells for
//! # HOW MUCH OF THE IRON MAY BE SOLD, as a percentage of what the product says
//! # it can hold. 100 never promises a byte that does not exist; the default
//! # (300) sells it three times over, which is what overselling storage IS.
//! # There is no spelling of "unlimited", exactly as there is none for a cloud's
//! # `cap`. It gates Signup.Start alone — see `monetize_server::oversell`.
//! max_sold_percent_of_capacity = 300
//! [signups.gunnar.plans."gunnar/10gb-1m"] # every plan id starts with the product
//! amount_minor = 10000 # 100.00 SEK
//! currency = "Sek"
//! cap_bytes = 10737418240 # 10 GiB of pack_bytes
//! period_days = 30
//!
//! # THE TAX RETURN — which jurisdiction this deployment files in, and how
//! # often. The block travels whole to whichever implementation `Linked::tax`
//! # registered; with none linked the console's Tax page lists what a return is
//! # folded from and fills every box with `—`.
//! [tax]
//! # Optional: a SHORTER period than the turnover requires, which is a choice a
//! # company genuinely has. Absent = the jurisdiction's own rule over the
//! # ledger's turnover for the previous year. monthly | quarterly | yearly.
//! period = "quarterly"
//!
//! # THE VENDORS — one block per ACCOUNT, `[vendors.<kind>.<instance>]`, known
//! # to the server as `<kind>/<instance>`. The kind is the linked factory or
//! # guest (stripe, klarna, invoice, …); the instance is whose account it is.
//! # Each instance reads its OWN secrets from the environment
//! # (`MONETIZE_STRIPE_<INSTANCE>_SECRET_KEY`, `…_WEBHOOK_SECRET`) and has its
//! # OWN webhook, `/callback/<kind>/<instance>`. A block with no instance
//! # (`[vendors.stripe]`) is refused.
//! [vendors.stripe.gunnar]
//! enabled = true
//! # WHERE THE ENTITY THAT INVOICES US IS ESTABLISHED, ISO 3166-1 alpha-2.
//! # Empty (the default) = the same country as this deployment, i.e. no reverse
//! # charge. Set it on any vendor or cloud that bills from abroad — the tax
//! # return reads it and nothing else can know it.
//! country = "IE"
//! [vendors.stripe.holger]
//! enabled = true
//! country = "IE"
//! [vendors.klarna.gunnar]
//! enabled = false
//! checkout_url = "https://checkout.klarna.com/{handle}" # `{handle}` `{reference}` substituted
//!
//! # THE PAID CLOUD — UpCloud, linked by the VETRA BUILD (the private monetize-impl);
//! # the open binary refuses this block by name. Same shape either way.
//! [clouds.upcloud]
//! enabled = true
//! # REQUIRED when enabled: an automated system with no spend ceiling is the one
//! # genuinely dangerous thing this file can say. 50 EUR/month here.
//! # A LIFETIME budget, which is what a rehearsal wants: EUR 10.00 ever, not
//! # EUR 10.00 per month. `monthly_minor` may be set as well, or instead.
//! cap = { total_minor = 1000, currency = "Eur" }
//! zone = "se-sto1"
//! # The knob that makes a rehearsal FREE: reserve nothing for the box and the
//! # pool answers OnHand instead of buying. Credentials come from
//! # UPCLOUD_USERNAME / UPCLOUD_PASSWORD in the environment, never from here.
//! pool_reserve_gib = 0
//! pool_exclude = ["00000000-0000-0000-0000-000000000000"] # the boot disk
//!
//! # THE FREE CLOUD — the iron this box already has (in every build; no credential).
//! # `ensure` answers OnHand out of `pool_gib` and NEVER buys; the product named
//! # (or the only one linked) is asked Admin.Capacity before every grant and its
//! # measurement is a wall the declaration cannot talk over. No `cap`: it cannot
//! # spend, and monetize_cloud::cap::CannotSpend holds it to that. Grants live in
//! # <data_dir>/server-pool.json. Its COST is zero; what the customer PAYS is the
//! # price list above, and with none the transaction runs at price 0 with no
//! # payment vendor at all (`vendors []` is a valid deployment).
//! [clouds.server]
//! enabled = true
//! pool_gib = 100 # GiB of this box tenants may be given; 0 refuses every sale.
//! # REFUSED AT START when it is larger than the store the product MEASURES:
//! # the measurement already walls every grant, so a bigger declaration never
//! # handed anything out — it only put a number nobody can honour in front of
//! # whoever reads this file (the live front said 100 against 37.8 GiB).
//! product = "gunnar" # whose disk this is; optional when one product is linked
//! # currency = "Sek" # what the zero cost is a number of; default Eur
//! # DECLARED ceilings on what no tenant owns, by the product's meter names.
//! # The fallback: when the product MEASURES the box (gunnar's Admin.Capacity
//! # reports cores and RAM since 744bf72d) the measurement wins; a gunnar built
//! # before that — the live appliance until its next re-image — leaves these
//! # to decide alone.
//! ceilings = { cpu_millicores = 4000, ram_bytes = 17179869184 }
//!
//! # THE REHEARSAL CLOUD — fake iron at real prices (build with --features sandbox).
//! # It provisions NOTHING: `ensure` records a resource `sandbox:disk:<n>` marked
//! # `monetize_provenance = sandbox`, idempotent on reference + resource like the
//! # real label search, honouring `pool_gib`; `destroy`/`status` behave like a
//! # real provider; state lives in <data_dir>/sandbox-cloud.json (or
//! # MONETIZE_SANDBOX_CLOUD_STATE) so a restart forgets nothing. Its ONLY network
//! # call is UpCloud's price GET: with --features upcloud and UPCLOUD_TOKEN in the
//! # environment (the vetra build), prices are MEASURED from the real list, so the
//! # cap below is exercised against real numbers. Without either, `prices` is the fallback —
//! # DECLARED, NOT MEASURED — and a zero in it is refused at start.
//! # It sits behind the same required `cap` as any provider.
//! [clouds.sandbox]
//! enabled = true
//! cap = { total_minor = 1000, currency = "Eur" } # 10 EUR of play iron, ever
//! zone = "se-sto1"
//! pool_gib = 0 # 0 ⇒ every disk is Provisioned and the cap is exercised
//! disk_step_gib = 10 # bought in 10 GiB steps, as UpCloud is
//! prices = { disk_minor_per_gib_month = 23, plan_minor_per_month = { "STARTER-2xCPU-4GB" = 1304 }, currency = "Eur" }
//! ```
//!
//! Vendor and cloud credentials are NOT in this file: each impl reads its own
//! environment (`MONETIZE_INVOICE_IMPORT_TOKEN`, …), which the unit carries as a
//! 0600 `EnvironmentFile=` via `ServiceSpec::secret_env`.
//!
//! # Switching from the sandbox to the REAL UpCloud plugin
//!
//! Written down 2026-09-07, to be *ready* to switch rather than
//! to switch. Nothing here is enabled; this is the procedure and its blast
//! radius, so the change is five lines and a rebuild instead of an afternoon.
//!
//! **The whole of it is rehearsed against a mocked UpCloud**, through this very
//! file: `monetize-impl`'s `crates/monetize-server-vetra/tests/upcloud_switch.rs`
//! parses the stanza below with [`Config::from_toml`], builds it with
//! `Registry::from_config_with`, and buys a disk from a canned-JSON server on
//! loopback. Seven tests, no socket off the box, no krona. If a line here is
//! wrong, that file goes red.
//!
//! ## The lines that change
//!
//! ```toml
//! [clouds.sandbox]
//! enabled = false # 1. was true
//!
//! [clouds.upcloud]
//! enabled = true # 2. was absent
//! cap = { total_minor = 1_000, currency = "Eur" } # 3. MANDATORY — see below
//! zone = "se-sto1"
//! disk_tier = "maxiops"
//! disk_step_gib = 10
//! pool_reserve_gib = <what /data ACTUALLY uses> # 4. NOT 0 — see below
//! pool_exclude = ["<the appliance's boot disk uuid>"]
//!
//! default_cloud = "upcloud" # 5. only if two stay enabled
//! ```
//!
//! …plus a rebuild with the feature (`cargo build --release -p
//! monetize-server-vetra --features invoice,sandbox,upcloud`) and
//! `UPCLOUD_TOKEN` in the installing operator's shell, because
//! `--install` copies it into the unit's 0600 `EnvironmentFile=`. Without the
//! feature, `enabled = true` fails at start naming the missing link rather than
//! running half-configured; without the token it fails at start too, naming
//! `[clouds.upcloud]` and the variable.
//!
//! ## `pool_reserve_gib = 0` does NOT mean what it means in the sandbox block
//!
//! This is the line that will be copied wrongly, because the live sandbox block
//! says `pool_gib = 0` and *there* it means "own no pool, so every order is
//! `Provisioned` and the cap is exercised". On UpCloud the same-looking zero
//! means the **opposite**. The pool is `provisioned − sold − reserve`, and
//! *provisioned* is every storage attached to the appliance that `pool_exclude`
//! does not name — including the disks gunnar's own `/data` is sitting on.
//!
//! With `pool_reserve_gib = 0` against an appliance holding 300 GiB of data
//! storage, the first 300 GiB of orders answer **`OnHand`**: the customer is
//! charged, nothing is bought, the ledger records no cost, and the spend cap
//! never moves because nothing was ever spent. monetize has sold gunnar's own
//! disk out from under it.
//!
//! Both readings are legitimate — zero is right when those GiB were bought in
//! order to be sold — which is exactly why the number has to be **chosen**. The
//! honest starting value is what the appliance is really using, read off the box
//! (`gunnar` reports it as `pack_bytes` + `cache_bytes` + LFS, and `Admin.Capacity`
//! gives free-of-total). Both readings are pinned in
//! `the_reserve_is_what_stands_between_a_sale_and_the_appliances_own_disks`.
//!
//! ## "Uncapped" is not a thing this file can say, and that is deliberate
//!
//! There is no opt-out and no unlimited spelling. `cap` is `Option` and absence
//! is REFUSED by name at start (`registry.rs`), a `Ceiling` with neither
//! `monthly_minor` nor `total_minor` is refused by `Ceiling::check`, and the
//! only exemption is a factory that declares it cannot spend at all — a
//! compile-time constant, not a config key, and UpCloud's says `spends: true`.
//!
//! Three honest ways to answer *"I want the non-capped plugin"*, and the
//! recommendation is the first:
//!
//! 1. **A `total_minor` you chose on purpose, written down.** No code, and the
//! number is the whole guard. **Recommended, and START SMALL.**
//!
//! **`total_minor = 1_000` — EUR 10 ever — is the FIRST-TRY number**
//! (2026-09-07: EUR 1000 is very much for a first try). It is deliberately
//! the SAME ceiling the sandbox already carries,
//! so the switch to real iron changes the PROVIDER and nothing else — one
//! variable moves, which is the only way to read the result. EUR 10 buys
//! about four provisions of the smallest step (10 GiB at EUR 2.26/month), so
//! the cap is reachable ON PURPOSE: you get to watch it refuse, in a week,
//! for the price of a coffee. A first ceiling nobody can hit proves only
//! that nothing happened.
//!
//! Raise it by editing one number once the path has been seen to work AND
//! refuse. The boot line prints how much is left, so approaching the ceiling
//! is a thing an operator sees coming rather than discovers. The ceiling is
//! not a promise about the business; it is a stop on a program that has a
//! credit card and no judgement — and the first time it runs, it is also
//! the only evidence that the stop works.
//! 2. **A loudly-named opt-out key** (`cap = "unlimited"`, `i_accept_unbounded_spend = true`).
//! Rejected. It would have to weaken `Ceiling::check`, whose entire argument
//! is that *"a guard that never refuses is worse than none because it reads
//! like protection"* — and the moment the spelling exists it is the spelling
//! a tired operator reaches for at 23:00 when an order is being refused. A
//! large number has the property the keyword lacks: it still refuses
//! something, and the thing it refuses is the loop. Nothing about option 1 is
//! harder to type than option 2; the only difference is that option 1 cannot
//! be typed without choosing a magnitude.
//! 3. **`monthly_minor` instead of a lifetime total.** Not an answer to the same
//! question and worth naming so it is not confused for one: a monthly window
//! resets, so it bounds the RATE, not the exposure. Set both when the
//! deployment wants "grow, but never more than X in a month"; both then hold
//! and the tighter one refuses.
//!
//! Whichever is written, the boot line reads it back in major units, which is
//! how it gets checked:
//!
//! ```text
//! monetize-server: upcloud spend cap — 0.00 EUR committed per the ledger; ceiling total 1000.00 EUR (1000.00 left); monthly not set
//! ```
//!
//! `0` is legal too and means the opposite of unlimited: refuse every purchase,
//! use only pool space already paid for.
//!
//! The currency must be `Eur`. `SpendCap::admit` refuses a mismatch rather than
//! converting — *"guessing one inside a spending guard would make the cap a
//! fiction"* — and UpCloud's price list is always quoted in EUR. A cap in `Sek`
//! against UpCloud BUILDS and then refuses every single purchase, silently
//! correctly, which is the failure most likely to be misread as an outage.
//!
//! ## What the first real provision costs
//!
//! Measured off UpCloud's own `GET /1.3/price` (`se-sto1`, `storage_maxiops` at
//! 0.0310 credits/GB/hour, 730 h/month), and asserted in the switch test:
//!
//! | order | what happens | cost to us |
//! |---|---|---|
//! | anything that fits in the pool | `OnHand` | **EUR 0.00** — nothing is bought |
//! | the smallest purchase there is (`disk_step_gib = 10`) | one 10 GiB storage, created and attached | **EUR 2.26/month** |
//! | 100 GiB | one 100 GiB storage | **EUR 22.63/month** |
//!
//! Two things follow. The **first** order after the switch very likely costs
//! nothing at all, because the pool covers it — so a green first purchase is not
//! evidence that spending works. And a disk is a MONTHLY cost that keeps
//! running: the cap counts `minor_per_month` at the moment of purchase, so
//! `total_minor = 1_000` is "about four disk-months of 10 GiB", not four disks
//! for ever. That distinction is what makes a small first ceiling useful — it
//! is crossed by TIME as well as by volume, so it will refuse even if nobody
//! buys anything new.
//!
//! ## Blast radius, stated rather than discovered
//!
//! * **The spend file changes name.** `<data_dir>/sandbox-spend.json` →
//! `upcloud-spend.json`. The sandbox's committed total does NOT carry over; the
//! new cap starts at zero and is then reconciled upward from the ledger at
//! boot (`reconcile_total`, upward only).
//! * **The pool ledger is new too**, `<data_dir>/upcloud-pool.json`, and its path
//! is not configurable.
//! * **Bad credentials fail at START, not at first purchase — since 2026-09-14.**
//! Before that date this line claimed it and it was FALSE: construction built
//! the HTTP client and opened the ledger but fetched nothing, so a dead
//! `UPCLOUD_TOKEN` started cleanly, the sandbox boot line printed
//! `prices measured:upcloud:se-sto1` because a credential was merely present,
//! and the first price read failed a day after cutover. Now the vetra build's
//! `UpCloud::new` and the measured sandbox's price book each issue one
//! `GET /1.3/price` during construction and refuse to start, naming the
//! credential, when it is refused or cannot be answered. `measured` on the boot
//! line therefore means the list was read.
//! * **Resources the account already holds are OUTSIDE the cap**, and correctly
//! so — monetize never bought them. A live account with pre-existing
//! `gunnar-*` storages carries them as `foreign` in `inventory()` because they
//! have no `monetize_ref` label, which is also what keeps a super purge from
//! eating the appliance's own disks.
//! * **`release_for` is INHERITED**, not overridden: the default walks
//! `inventory()` and calls `destroy()` per row, and UpCloud's halves of that
//! are the real ones — `inventory()` lists the whole account and partitions on
//! `monetize_ref`, `destroy()` is the full detach-then-delete. Proven against a
//! mocked account in `monetize-cloud-impl`'s
//! `release_for_destroys_only_the_named_tenants_rows_and_names_what_it_could_not`,
//! including the case that matters most: an account that cannot be LISTED comes
//! back as an error and never as a clean empty `Reclaimed`.
//! * **A purge does not buy back ceiling headroom.** `release_for` never lowers
//! `spent`, deliberately: that number records what was COMMITTED. Purging an
//! account expecting room to sell another one will not find it.
//! * **`stop_to_resize` inverts** between the provider's default (`true`) and a
//! deployment's (`false` unless the block says otherwise), so widening a plan
//! cannot reboot a serving appliance behind a tenant's back. Pinned in
//! `config_from_reads_every_key_the_switch_writes`.
//!
//! ## Rolling back
//!
//! The switch is five config lines and a restart, and so is the reversal:
//! `[clouds.upcloud] enabled = false`, `[clouds.sandbox] enabled = true`,
//! `default_cloud = "sandbox"`, restart. A disabled block builds nothing at all —
//! no client, no credential read, not even its ledger file
//! (`a_block_that_is_not_enabled_builds_nothing_at_all`), so the stanza may be
//! landed in the file long before the decision to switch.
//!
//! What a rollback does **not** undo is anything already bought: disks live at
//! the provider and keep billing until they are destroyed. Giving them back is
//! `Purge.Tenant` / `release_for`, which is never capped and always allowed. Read
//! `upcloud-pool.json` and the ledger for what exists; `Inventory` is the
//! provider's own word for it, and the one to trust.
//!
//! ## Checking yourself, in three reads
//!
//! 1. `journalctl -u monetize-server | grep 'spend cap'` — the ceiling in major
//! units, and what the ledger says is committed against it.
//! 2. `cat <data_dir>/upcloud-spend.json` — the cap's own cache. It should agree
//! with the boot line; if it is smaller, the boot line already corrected it.
//! 3. The console's Products screen, or `Inventory` — what the provider says it
//! is charging for, `foreign` rows included. That is the only number that can
//! disagree with everything else, and when it does, it is right.
use std::collections::BTreeMap;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use monetize::{Policy, PriceList};
use monetize_pay::Market;
pub const DEFAULT_CONFIG_PATH: &str = "/etc/monetize/monetize.toml";
pub const DEFAULT_DATA_DIR: &str = "/var/lib/monetize";
pub const SERVICE_USER: &str = "monetize";
/// The service account's HOME — deliberately **not** [`DEFAULT_DATA_DIR`].
///
/// skidbladnir refuses to accept a data root that IS an account's home
/// (`DataRootRefusal::IsUserHome`), and it is right to: the suite's uninstall law
/// is *erase the data, keep the ssh keys*, and keys live in the home. If the two
/// are one directory then either the keys go with the data or the data survives —
/// and what actually happened, measured on the gunnar.rs box 2026-09-04, is that
/// the install printed
///
/// ```text
/// REFUSING /var/lib/monetize as a data root: it IS account `monetize`'s home
/// directory — uninstall would erase nothing; not declaring a root
/// ```
///
/// i.e. `--uninstall` would have erased NOTHING and said success. The suite's own
/// shape is home `/home/<app>`, data root the product's own directory (nornir:
/// `/home/nornir` + `/home/nornir/.nornir`; korp: `/home/korp` + `/home/korp/.korp`).
pub const SERVICE_HOME: &str = "/home/monetize";
pub const TOKEN_ENV: &str = "MONETIZE_SERVER_TOKEN";
/// **The OPERATOR bearer** (g-refund-verb, 2026-09-28): the second credential,
/// for the verbs that move a customer's money back (`SignupAdmin.Refund`,
/// `Settle`). The service bearer ([`TOKEN_ENV`]) is held by every console
/// backend, the customer-facing one included, so it cannot be the key to a
/// refund. Ladder: `--operator-token` > this > `<data_dir>/`[`OPERATOR_TOKEN_FILE`];
/// none configured ⇒ those verbs are refused to everybody (never open).
pub const OPERATOR_TOKEN_ENV: &str = "MONETIZE_SERVER_OPERATOR_TOKEN";
/// The operator bearer's file under `data_dir`, written 0600 by the operator.
pub const OPERATOR_TOKEN_FILE: &str = "operator-token";
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct Config {
pub grpc_addr: SocketAddr,
pub data_dir: PathBuf,
/// **Where guests' secrets are**: `<secrets_dir>/secret.<name>`, owner-only
/// files the HOST reads at every use (`http_auth`, `hmac_secrets`) and no
/// guest ever sees. Default `<data_dir>/secrets`. See
/// `monetize_product_wasm::secrets`.
pub secrets_dir: Option<PathBuf>,
pub signing_key: Option<PathBuf>,
pub token_file: Option<PathBuf>,
pub poll_minutes: u64,
/// **This box's name for its plugin list** (OXG-5; fed by the kit, `plugins_box = "test2"`):
/// "Update plugins" reads `release/plugins/box/<box>/release-manifest.toml`
/// ([`crate::plugins::list_key_of`]). Absent: the shared list (a feed from before per-box lists).
pub plugins_box: Option<String>,
/// **The release bucket's base** (`https://<endpoint>/<bucket>`), NAMED by the kit's install
/// (stink-mon 2026-11-03, t2-plug): an empty box has no site and so no release object whose
/// url names the bucket, yet its "Update plugins" and its own update read the bucket from the
/// first boot. Read before the objects' urls ([`crate::plugins::bucket_of`]).
pub release_bucket: Option<String>,
/// Where `Ledger.Export` writes the operational-state backup. Default
/// `<data_dir>/backup`.
///
/// It is configured rather than passed on the RPC on purpose: a call that
/// named its own directory would let any holder of the bearer write
/// anywhere the service account can. An operator chooses this once, here.
pub backup_dir: Option<PathBuf>,
/// **The backup timer** (gap G17): the running server writes the backup into
/// [`Config::backup_dir`] every this many minutes. `0` turns it off. Default 60.
pub backup_every_minutes: u64,
pub default_cloud: Option<String>,
/// The market an order is priced in when the request names none: `SE` | `FI`.
pub default_market: String,
/// **Every product must be priced** (the paid front sets it; owner
/// 2026-09-25). When true, a `[products.<id>]` with no `[prices.<id>]` is
/// REFUSED by name (`product-no-prices`) — at start it is skipped, live it
/// is not added — unless its block says `free = true`. False (the
/// default) keeps the open deployment: a product with no list is free.
pub require_prices: bool,
/// **`[control_principals]`** — the admin principals that may call the
/// control API from OUTSIDE the estate by signing every call (SSHSIG,
/// `crate::control`): name → OpenSSH PUBLIC key line. The shared operator
/// console is `monetize-ui`. Empty: only the bearer opens the API.
#[serde(default)]
pub control_principals: BTreeMap<String, String>,
/// **The one policy**: period, grace, retention. See `monetize::Policy`.
pub policy: Policy,
/// **`[nonpay]`** — what happens to a customer who stops paying: the
/// warning mails, the automatic deletion, the renewal reminder. See
/// [`crate::nonpay`]. Absent: a lapsed customer is purged after
/// [`crate::nonpay::DEFAULT_DELETE_AFTER_DAYS`], warned on the day it lapsed
/// and the day before.
pub nonpay: crate::nonpay::NonpayPolicy,
/// **The MOCK's time travel** ([`crate::bench`]): `http://` on loopback or
/// the offline mock's `10.0.2.100`, serving `{"offset_ms": N}`; the decision
/// clock is then the wall clock plus N. Absent on every estate — the kit
/// renders it only for a mock run that asked for it.
pub bench_travel_url: Option<String>,
/// **The price lists**: product → market code (`SE`, `FI`) → unit → minor
/// units per unit-month. `[prices.gunnar.SE] disk_gib_month = 2200`. A
/// product or market with no entry is free. The currency is the market's.
pub prices: BTreeMap<String, BTreeMap<String, BTreeMap<String, u64>>>,
/// ★ **`[pricing.<product>]` — room sold as PLANS, priced by the rule**
/// (owner, 2026-10-05): what the estate costs, split among last month's
/// paying accounts, × 1.40 a month or × 1.12 on a 12-month plan, never
/// under cost + 10 %. See [`crate::plans`]. A product listed here is sold
/// plans in its shop window; its `[products.<id>.bundles]` are not shown.
pub pricing: BTreeMap<String, crate::plans::PricingConfig>,
pub products: BTreeMap<String, ProductConfig>,
/// **`[vendors.<kind>.<instance>]` — one block per ACCOUNT at a vendor**
/// (ONE monetize, 5.4): `[vendors.stripe.gunnar]` and
/// `[vendors.stripe.holger]` are gunnar's and holger's own Stripe accounts.
/// Keyed here by the instance id `<kind>/<instance>` (`stripe/gunnar`) —
/// the name the vendor has everywhere in the server: `vendor()`, the
/// ledger's handles, `[products.<id>] vendor`, `/callback/stripe/gunnar`.
/// A block with no instance (`[vendors.stripe] enabled = true`) is refused.
#[serde(with = "vendor_instances")]
pub vendors: BTreeMap<String, VendorConfig>,
pub clouds: BTreeMap<String, CloudConfig>,
/// **`[machines.<id>]` — the estate, by name.** See [`MachineConfig`].
///
/// Empty is the ordinary state of a deployment that has not written one,
/// and nothing depends on it: monetize places iron by meter and never by
/// machine, so this table changes no decision. It exists so an OPERATOR has
/// a picture — see [`crate::estate_svg`] — and so the twin is named once
/// instead of twice.
#[serde(default)]
pub machines: BTreeMap<String, MachineConfig>,
/// **`[signups.<product>]` — each product's own PAID SIGNUP** (one
/// monetize, several products, 2026-10-10): what a hosted checkout page is
/// allowed to have sold for it — its plans (`<product>/…`), the meter their
/// caps land on, its sold-capacity allowance and its sink (`guest =`). See
/// [`SignupConfig`]. The single `[signup]` is gone and refused at load.
pub signups: BTreeMap<String, SignupConfig>,
/// **`[dns_writers.<name>]` — the DNS writers taken in as signed guests** (the owner
/// 2026-10-11: a provider is a plugin, never linked): `guest = "<name>.wasm"`, its doors and
/// its own settings (the zone), built by `monetize-cloud-wasm::dns`. `[estate_orders] dns` /
/// `[estates.<e>.orders] dns` name one by its name.
#[serde(default)]
pub dns_writers: BTreeMap<String, serde_json::Value>,
/// **`[estates.<estate>]` — the SITES this monetize sells for** (ONE monetize, owner
/// 2026-10-11: monetize.ignalina.dk serves gunnar.rs AND test2.gunnar.rs, both gunnar,
/// each with its OWN Stripe account). Keyed by the estate's attach label
/// (`test2-gunnar-rs`); see [`EstateConfig`]. A product with estates declared routes its
/// money per estate, never per product: an order of an estate not declared here is
/// refused by name, never billed through another site's account. Empty: every product
/// routes by its own `[products.<id>] vendor`, as before.
pub estates: BTreeMap<String, EstateConfig>,
/// **`[tax]` — the jurisdiction implementation's own block.** Empty on a
/// deployment with none linked, which is a working deployment: the Tax page
/// then lists what a return is folded from and fills nothing in.
pub tax: TaxConfig,
/// (The appliance grow batcher's other `grow_*` knobs went with it, 2026-11-02, and are
/// refused by name at load: [`retired::GROW_KNOBS`]. These two still act: a twin's own plan
/// change waits for their boundary, [`crate::grow::next_window_boundary`].)
///
/// ★ **The daily batch** (owner, 2026-10-05): a purchase's provider side
/// waits for the day's batch — one provider operation per estate per day,
/// or none when the free room covers it. `0` (default) = off: the window
/// above. The kit writes `86400`; a bench shortens it.
pub grow_batch_every_secs: u64,
/// When the day's batch runs, seconds after midnight UTC. Default 7200
/// (02:00). With a short `grow_batch_every_secs` (the kit's LIVE growth,
/// 120 s, round 5.3.1) it is read modulo that period.
pub grow_batch_at_secs: u64,
/// **`[estate_orders]` — private gunnars sold here** (`<company>.<apex>`):
/// the lay-estate job's cloud, vendor, price, slots, deadlines, installer
/// and the estate's shape. See [`crate::estate_order::EstateOrderConfig`].
/// Absent (the default): this monetize sells no private estates.
pub estate_orders: Option<crate::estate_order::EstateOrderConfig>,
/// **The operator's TEST BUTTON's mailbox** (`TestPurchase`): a test
/// identity's mail goes to it, plus-addressed per run
/// (`ops+test-<stamp>@…`). Empty (the default): to an address at
/// `test-purchase.invalid`, which never receives mail — a test run never
/// mails a real person.
pub test_mailbox: String,
/// The return page a test run's signup names (`{session}` substituted);
/// empty: a placeholder nobody opens. The vendor requires one.
pub test_return_url: String,
}
/// **`[tax]`** — untyped, because the open server knows no jurisdiction and so
/// knows none of its knobs. The whole block travels to the linked
/// implementation's factory as `monetize::tax::TaxBuild::settings`, exactly as
/// `[vendors.<kind>.<instance>]` travels to a vendor's.
///
/// The one key documented here because every jurisdiction this seam was written
/// against has it: `period = "monthly" | "quarterly" | "yearly"`, the operator's
/// choice of a SHORTER period than their turnover requires. Absent means the
/// turnover decides.
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct TaxConfig {
#[serde(flatten)]
pub settings: toml::Table,
}
/// **`[signups.<product>]` — what a checkout page may sell for one product,
/// and what it costs.**
///
/// A signup arrives from OUTSIDE: the vendor's page collected a plan name, a
/// username and an amount from a visitor nobody has authenticated, and the
/// webhook asserts all three. Every one of them has to be checked against
/// something this deployment wrote down, and this block is that something.
///
/// **The price is here and nowhere else.** monetize's open half carries no
/// number a customer pays — `[prices]` above is the per-unit list an ORDER is
/// priced by, and a signup is not an order: it is one named bundle bought whole
/// from a page this process never rendered. A literal `100_00` in the code
/// would be a price in the open repo, which is the one thing the split exists
/// to prevent, and it would be a price no operator could change without a
/// rebuild.
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct SignupConfig {
/// Plan id → what it is. `[signups.gunnar.plans."gunnar/10gb-1m"]`; every
/// id names the block's product (`gunnar/…`), refused at load otherwise.
///
/// **A plan this map does not name is REFUSED by name.** Not defaulted, not
/// created at whatever the vendor charged: a checkout page that sold
/// something this deployment never listed is either a stale page or an
/// attack, and both must end as a stored refusal an operator can read.
pub plans: BTreeMap<String, SignupPlan>,
/// **How much of the iron this deployment may SELL**, as a percentage of
/// what the product says it can hold —
/// `max_sold_percent_of_capacity = 300`. Absent is
/// [`crate::oversell::DEFAULT_MAX_SOLD_PERCENT`], which is not "no limit":
/// there is no spelling of unlimited here, exactly as there is none for
/// `[clouds.<name>] cap`.
///
/// It gates `Signup.Start` (`crate::oversell`) and nothing else — a
/// purchase is a raise for a tenant that exists and is walled by the
/// product's own `can_absorb`. Below 100 is refused at start: this knob
/// says how much OVERSELLING is acceptable, and a reserve is a different
/// thing that would need a different name.
pub max_sold_percent_of_capacity: Option<u32>,
/// **Every other key in the block, untyped** — the SINK's own knobs, handed
/// to its factory as `monetize_pay::factory::Settings`, exactly as a
/// vendor's block travels to its own. This file does not know what a sink
/// needs, because the sink that creates a real gunnar principal is not in
/// this repository.
#[serde(flatten)]
pub settings: toml::Table,
}
/// One thing a hosted checkout page may sell.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct SignupPlan {
/// The price the vendor MUST have taken, in `currency`'s minor units.
/// `amount_minor = 10000, currency = "Sek"` is 100.00 SEK.
pub amount_minor: u64,
/// `Sek` | `Eur`. An amount in another currency is a mismatch, never converted.
pub currency: monetize_pay::Currency,
/// What the customer gets: the `pack_bytes` cap the sink puts on the new
/// tenant's entitlement.
pub cap_bytes: u64,
/// How long it is paid for.
pub period_days: u32,
}
impl SignupConfig {
/// `[signups.<product>] cap_meter` — the meter a plan's `cap_bytes` lands on (holger:
/// `disk_bytes`); absent is gunnar's `pack_bytes` ([`crate::signup::SIGNUP_CAP_METER`]).
/// Left in the block's settings too: a linked sink reads the same key.
pub fn cap_meter(&self) -> Option<String> {
self.settings
.get("cap_meter")
.and_then(|v| v.as_str())
.map(str::to_owned)
}
/// The sold-capacity allowance, or the refusal that names the key. See
/// [`SignupConfig::max_sold_percent_of_capacity`].
pub fn max_sold_percent(&self) -> Result<u32, String> {
match self.max_sold_percent_of_capacity {
None => Ok(crate::oversell::DEFAULT_MAX_SOLD_PERCENT),
Some(v) if v >= 100 => Ok(v),
Some(v) => Err(format!(
"max_sold_percent_of_capacity = {v} is below 100. The key says how much of the iron this deployment may \
SELL, as a percentage of what the product can hold: 100 never promises a byte that does not exist and 300 sells it \
three times over. A value below 100 would be a reserve — a different thing, which would need its own name"
)),
}
}
}
impl SignupPlan {
/// The plan's price as [`monetize_pay::Money`], for comparing with what the
/// vendor says it took.
pub fn price(&self) -> monetize_pay::Money {
monetize_pay::Money {
minor: self.amount_minor,
currency: self.currency,
}
}
}
/// **One machine on this estate**, `[machines.<id>]`.
///
/// # This table decides nothing
///
/// monetize's model is blind to machines on purpose: an order names a meter and
/// an amount, and the cloud decides what iron that becomes. Nothing in this
/// block is consulted when a purchase is placed, and nothing here may ever
/// become a placement input without that decision being made deliberately and
/// somewhere else. It is here so that the estate is WRITTEN DOWN once.
///
/// # It is emitted, not authored
///
/// The estate's own file (`private-gunnar-ops/estate.toml`) is where a human
/// changes these; the front provisioner writes this block from it. Code to
/// toml, one direction, the same rule the build model runs under — because the
/// two places that know a twin's volume id must not be able to disagree, and
/// the way they stop being able to is that only one of them is written by hand.
///
/// # Declared, and labelled as declared
///
/// [`MachineConfig::vcpu`] and [`MachineConfig::ram_mb`] are the PROVIDER'S
/// PLAN — a claim about the box, not a reading off it. Nothing on this estate
/// measures them, so everything that renders them says `plan says` beside them.
/// Disk is not here at all: it is measured, it moves, and a number that moves
/// does not belong in a config file.
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct MachineConfig {
/// **Whose estate this box belongs to** — a `[products.<id>]` key.
///
/// One monetize serves several products (gunnar and holger already), and
/// each has its own iron. Empty means the only product, which is the
/// ordinary single-product deployment and saves writing the same word on
/// every row.
pub product: String,
/// `front` | `appliance` | `twin`. Anything else is refused at start rather
/// than drawn as a box with no meaning.
///
/// Note that the ROLE and the ID differ on this estate: the appliance's id
/// is `main`. That is estate.toml's spelling and this table copies it
/// rather than improving on it — a second name for the same box is exactly
/// the drift this block exists to end.
pub role: String,
/// The box's hostname, as estate.toml gives it (`gunnar-appliance`).
pub hostname: String,
/// The one-line description estate.toml already writes for each machine
/// ("gunnar appliance — the forge, no shell"). Used as the chip's subtitle,
/// because it says more than the role word does.
pub title: String,
/// The `[clouds.<name>]` this box belongs to.
pub cloud: String,
/// The provider's id of the server, for the operator about to open a
/// support ticket.
pub server: String,
/// The provider's id of its one data volume, where it has one.
pub volume: String,
/// The provider's plan name, verbatim (`2xCPU-4GB`).
pub plan: String,
/// The plan's vCPU count. **Declared.** Zero = not stated.
pub vcpu: u32,
/// The plan's memory in MB. **Declared.** Zero = not stated.
pub ram_mb: u64,
/// Whether this box can gain capacity while it runs. The twin can — it has
/// no customers, so it is the one box allowed a restart. An appliance grows
/// in place and only where its boot stack carries the engine, so a `true`
/// here is a claim somebody has to have checked.
pub grow: bool,
/// One short sentence for the picture, when there is something to say.
pub note: String,
}
/// **A secret value read from the config**: the string itself to whoever
/// derefs it, `"<redacted>"` to Debug. Serialized as is, because the front
/// renders a `monetize.toml` from a [`Config`] (into a 0600 file).
#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(transparent)]
pub struct Secret(String);
impl std::ops::Deref for Secret {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
impl From<String> for Secret {
fn from(s: String) -> Self {
Secret(s)
}
}
impl std::fmt::Debug for Secret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("\"<redacted>\"")
}
}
/// **One site this monetize sells for** — `[estates."test2-gunnar-rs"] product = "gunnar",
/// vendor = "stripe/gunnar-test2", owner = "ignalina", site = "test2.gunnar.rs"`.
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct EstateConfig {
/// The product the estate runs (a `[products.<id>]` id).
pub product: String,
/// The vendor INSTANCE its money goes through (`<kind>/<instance>`): its own account.
pub vendor: String,
/// Whose estate it is (the account that owns the site) — the operator's words, kept.
pub owner: String,
/// The site its customers come from and return to (`test2.gunnar.rs`): a signup's
/// success and cancel pages must be on it. Empty: not checked.
pub site: String,
/// **The site's own main's ticket key** (ST-3, 2026-10-11): the ed25519 PUBLIC key, 64
/// hex, its main signs its accounts' actor tickets with (`ticket_pubkey` of its offer).
/// A ticket from this site is checked under this key and no other site's. Empty: this
/// monetize cannot check who a site's account is, and refuses its tickets by name.
pub appliance_key: String,
/// **One of the operator's OWN sites** (owner 2026-10-12: gunnar.rs and test2): its
/// admin — the account named [`EstateConfig::owner`] — buys a private Gunnar here for
/// EUR 0.50, the normal way (hold, lay, capture through this site's own vendor), the
/// margin floor not asked of it; the row is an operator test, never revenue. Every other
/// account, here or on any other site, pays the full price. Off unless said.
pub operator_site: bool,
/// **May this site be TERMINATED here** (owner 2026-10-12)? A declared site — the
/// operator's own production site above all (gunnar.rs) — is never ended by the ONE box's
/// terminate, by its label or its host, unless this says so. Off unless said.
pub terminable: bool,
/// **DEV-NARRATE for the site's OWN estate** (lane N2, 2026-10-20: debug logs from test
/// estates exactly like Gunnar's dev builds). On, the estate the ONE box lays for this site
/// admits udp/51822 from the box's `[estate_orders] narrate_from` address only, and the box
/// listens to each machine's sealed boot log ("Logs" on the site, admin only). Only an
/// operator's own TEST site says so — never a serving site (gunnar.rs) and never an estate
/// sold on a site. Off unless said; said without `operator_site` it is refused at load.
pub narrate: bool,
/// **The OFF-SITE copy of the site's OWN estate** (lane O1, 2026-10-20): on, the estate the
/// ONE box lays for this site is fed its off-site keys — the store the box was fed for it
/// (`secret.sold_offsite_*`), the prefix `<product>/<company>/` and the recipient minted
/// with its material — on the boxes its product ships a copy from (Holger's main and twin).
/// Only an operator's own site says so: the store's key pair is the operator's, and a
/// customer's boxes never hold it. Off unless said; said without `operator_site` it is
/// refused at load.
pub offsite: bool,
/// **The site's PUBLIC FACE** (TASKS-12, owner 2026-10-25): on, the site order feeds its
/// front `console_public_site = on`, and its console serves the public home, the sitemap,
/// robots.txt's allow, llms.txt and IndexNow (a `public-site` build). Only the operator's
/// own public sites say so — gunnar.rs and holger.rs; every sold private estate is the
/// airgap default and is never indexed. Off unless said; said without `operator_site` it
/// is refused at load.
pub public_site: bool,
/// **The CLI's gRPC hole is open to the internet** (RA1-14, owner rule 7): on, the site
/// order feeds its front `front_cli_hole = public`, so its operators reach the console's
/// gRPC from anywhere (still signed, limited and audited). Only an operator's own site says
/// so (gunnar.rs); every other front keeps the hole to the estate's own network. Off unless
/// said; said without `operator_site` it is refused at load.
pub cli_hole_public: bool,
/// **The repositories of the site's OWN estate that are public** (NIGHT-PUBREPO, owner
/// 2026-10-28: holger.rs's `crates` and `npm`, its public registry): the site order feeds its
/// main and twin `public_repositories = "crates,npm"`, and the product's box makes exactly
/// those readable with no sign-in (Holger: `visibility: Public` in its declaration). DATA per
/// site: the medium every estate shares makes none public. Each name is one plain word, said
/// once; only an operator's own site says it. Empty unless said: none public.
pub public_repositories: Vec<String>,
/// **The site's own order terms** (`[estates.<estate>.orders]`, ST-1 2026-10-11): what an
/// order for a private instance bought on THIS site holds and renews by, over the shop's
/// `[estate_orders]` (test2: one day, an hour of grace, 25 %). Unset: the shop's.
pub orders: SiteOrders,
/// **Where this site's customers pay and renew** (NP-3, audit 2026-10-12): the nonpay and
/// renewal mails of a tenant of this site name it. Empty: `https://<site>/` when the site
/// is named, else the box's `[nonpay] account_url`.
pub account_url: String,
}
/// `[estates.<estate>.orders]` — each unset field is the shop's `[estate_orders]` one.
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SiteOrders {
pub term_months: Option<u32>,
pub term_days: Option<u32>,
pub grace_hours: Option<u16>,
pub margin_bp: Option<u32>,
/// **The site's own seedless media** (2026-10-11, the owner: test2 sells from DEV media,
/// gunnar.rs from the release): kind → storage, as `[estate_orders.media]`. Named here
/// (with or without [`SiteOrders::media_objects`]), they replace the shop's for an order
/// bought on this site.
pub media: std::collections::BTreeMap<String, String>,
/// kind → object, as `[estate_orders.media_objects]`.
pub media_objects: std::collections::BTreeMap<String, monetize_cloud::lay::MediaObject>,
/// kind → template, as `[estate_orders.templates]` (they go with the site's media).
pub templates: std::collections::BTreeMap<String, crate::estate_order::ReleaseTemplate>,
/// kind → live record, as `[estate_orders.live_apps]` (they go with the site's media).
pub live_apps: std::collections::BTreeMap<String, crate::estate_order::update::LiveRecord>,
/// release id → what it IS, as `[estate_orders.release_notes]` (owner 2026-10-18: read live
/// with the site's media, so a release fed after the start is named).
pub release_notes: std::collections::BTreeMap<String, crate::estate_order::update::ReleaseNote>,
/// **The DNS writer of THIS site's zone** (2026-10-11: a sold host is `<company>.<site>`,
/// written where the site's zone lives — gunnar.rs's at Cloudflare), over the shop's
/// `[estate_orders] dns`. A writer this build does not link is refused by name for
/// this site's orders; the shop and every other site keep serving.
pub dns: Option<String>,
}
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct ProductConfig {
/// **The control plane monetize DIALS** (`host:port`). Empty for a product reached by
/// the pull channel ([`ProductConfig::instance`]): monetize never dials an estate.
pub endpoint: String,
/// **The estate whose main serves this product's tenants, over the pull channel**
/// (5.4, G1) — `instance = "gunnar.rs"`. The product's box CALLS monetize
/// (`Fleet.Report` carries its tenants, usage, servable and room; it pulls the signed
/// orders — entitlements — queued for it); monetize never dials it. Set together with
/// anything that dials (`endpoint`, `guest_grpc_hosts`, `restoring_from_twin`) it is
/// refused at load. Absent — the default — is a product the plugin answers itself.
pub instance: Option<String>,
/// **The estate's boxes' fleet keys**, by role (`main`, `twin`, `front`), base64 of
/// the Ed25519 public key each box signs its `Fleet` calls with — registered at start so
/// the first report is heard. Only beside `instance`.
pub fleet_keys: BTreeMap<String, String>,
/// **The plugin (or guest kind) that serves this block** — `plugin =
/// "gunnar"` (G15). Absent is the block's own id, as it always was. The
/// block's id is the PRODUCT id everything keys on — the ledger, the prices,
/// the vendor route, the signing key — so two estates of one product
/// (gunnar.rs and test2.gunnar.rs) are two blocks, `[products.gunnar]` and
/// `[products.gunnar-test2] plugin = "gunnar"`, and never one id twice.
pub plugin: Option<String>,
/// **The key this product's facts are signed with** — `signing_key =
/// "/var/lib/monetize/gunnar.key"`, 32 raw bytes (G8). Absent — the default
/// — is the key derived from the server's root `signing_key` for this
/// product id ([`monetize::Keyring`]). Named, it is what a merged product
/// keeps: the estates already trust the key their own monetize minted. A
/// named file that cannot be read is refused at start, by name — never a
/// silent fallback to a key no estate of this product trusts.
pub signing_key: Option<PathBuf>,
/// **The vendor instance this product's money goes through** —
/// `vendor = "stripe/gunnar"`, a `[vendors.stripe.gunnar]` block that is
/// enabled. REQUIRED for every priced product (a `[prices.<id>]` with a
/// unit above zero, or a `[pricing.<id>]`): a load-time error otherwise,
/// never "the only vendor there is". Each product bills through its own
/// account; a purchase of it never reaches another instance.
pub vendor: Option<String>,
/// **Served free, on purpose** — the one way a product with no
/// `[prices.<id>]` is taken where [`Config::require_prices`] holds.
pub free: bool,
/// **The product APPLIANCE's Ed25519 verifying key, 64 hex characters** —
/// what lets a TENANT renew their own subscription
/// (`monetize_embed::ticket`).
///
/// The appliance's PUBLIC key: monetize only ever verifies with it — this server cannot
/// mint a ticket and must not be able to. (monetize's own `identity_key`, the credential it
/// signed calls TO a product with, went in monetize v2.0 T13: the ONE box dials no product,
/// and a block that still names it is refused as an unknown key.)
///
/// Absent — the default — is a deployment where no console may offer
/// self-service renewal: the plugin answers `tenant_renewal()` false, and
/// refuses every ticket rather than trusting one it cannot check. Present
/// and malformed is refused at START, by name, because a key discovered to
/// be wrong at the first renewal is discovered by a customer.
pub appliance_key: Option<String>,
/// **A bearer token presented on every control-plane call** —
/// `authorization: Bearer <token>`. What the holger plugin authenticates
/// with; gunnar's plugin signs each call instead and ignores this.
///
/// Absent — the default — is a product whose reads are open. That is
/// holger's documented airgap-mirror posture and what the holger.rs estate
/// emits (`require_auth_for_reads = false`), so the normal deployment sets
/// nothing here. A deployment that turns the read gate on names its token,
/// and the product then filters what it answers to what that token may see:
/// the tenant list becomes the token's tenant list, which is the intended
/// behaviour and not a partial read.
///
/// A [`Secret`]: never in Debug output. And because it sits in this file,
/// a `monetize.toml` that holds one must be 0600 — [`Config::load`] refuses
/// it readable by group or others.
pub bearer_token: Option<Secret>,
/// **Name of the environment variable** carrying the product's API key —
/// the value itself never sits in this file (0644 in the unit). A product
/// that authenticates with a key it mints on its own admin page names the
/// variable here; the key then rides the same 0600 secret-env ladder as
/// the bearer and every vendor credential (`lifecycle::secret_env_with`).
pub api_key_env: Option<String>,
/// **The TWIN's control plane** — `restoring_from_twin = "10.0.0.2:50051"`.
///
/// The standby beside this product's appliance, when a deployment has one.
/// Two lanes read it: the RESTORE pulls the transaction log back from it
/// after the set is re-laid, and the TWIN GROWTH grows it exactly as main
/// is grown (owner ruling T21, 2026-09-22 — the twin is built from the
/// same medium and grows with the same verbs).
///
/// Absent — the default — is a deployment with no standby: both lanes then
/// refuse BY NAME, naming this key, which is a line of configuration away
/// from working.
///
/// A guest reaches it through the `grpc` door without it being written
/// twice: naming it here is what puts it on the door's allow-list.
pub restoring_from_twin: Option<String>,
/// **The CA a `https://` endpoint is verified against** —
/// `tls_ca = "/etc/monetize/holger-ca.pem"`.
///
/// Absent is the compiled-in Mozilla root set: what a publicly issued
/// certificate (the holger.rs appliance's) verifies against. An estate with
/// its own CA names its PEM here. Naming one for a `http://` endpoint is
/// refused at start, by name.
pub tls_ca: Option<PathBuf>,
/// **The client identity presented to this product** (f-grpc-tls): a PEM
/// certificate and its key. Both or neither — one alone is refused at load.
#[serde(default)]
pub tls_client_cert: Option<PathBuf>,
#[serde(default)]
pub tls_client_key: Option<PathBuf>,
/// **A signed `.wasm` this product is served by** — `guest = "<path>.wasm"`.
/// The guest host (`monetize-product-wasm`) builds the product; no plugin
/// crate is linked for it. Absent — the default — is a product a linked
/// plugin builds. A `guest =` key takes the id over from a linked plugin of
/// the same name, so a deployment can move one product at a time.
pub guest: Option<PathBuf>,
/// `guest_digest = "<hex blake3-256>"` — what the module bytes must hash to.
pub guest_digest: Option<String>,
/// `guest_trust_roots = ["<root>.der", …]` — what the module's `.p7s` must
/// chain to. REQUIRED (owner 2026-10-17): without it the guest is refused by
/// name (`[guest-unsigned]`), whatever `guest_digest` pins — every guest
/// monetize runs is signed.
pub guest_trust_roots: Vec<PathBuf>,
/// `grants = []` — the host doors the guest may import, from its kind's own set
/// (`monetize_product_guest::grants::of_kind`; a product guest's is empty). An import outside
/// the grant, or a grant outside the kind, is refused at start, by name.
pub grants: Vec<String>,
/// `http_hosts = ["api.example.se", "http://lab:8080"]` — the only hosts the
/// guest's `http` door reaches.
pub http_hosts: Vec<String>,
/// `guest_grpc_hosts = ["10.0.0.2:50051"]` — the other control planes the
/// guest's `grpc` door may dial, beside this block's `endpoint`. A restore
/// reads the twin's log while it fills the primary.
pub guest_grpc_hosts: Vec<String>,
/// `guest_fuel`, `guest_call_timeout_ms`, `guest_max_memory_bytes` — narrow
/// the guest kind's ceilings; `0` is refused, never "unlimited".
pub guest_fuel: Option<u64>,
pub guest_call_timeout_ms: Option<u64>,
pub guest_max_memory_bytes: Option<u64>,
/// `[[products.<id>.http_auth]]` — the credential the HOST adds per host,
/// from a secret file (`monetize_product::HttpAuth`).
pub http_auth: Vec<monetize_product::HttpAuth>,
/// `hmac_secrets = [..]` — what the guest's `hmac` door may verify against.
pub hmac_secrets: Vec<String>,
/// The top-level `secrets_dir`, resolved (default `<data_dir>/secrets`) and
/// carried here when the config is read, so a block is complete on its own.
/// Never written in a block.
#[serde(skip)]
pub secrets_dir: Option<PathBuf>,
/// **Retired, refused BY NAME** (RA2-16): `plans` (`[products.<id>.plans.<plan>]`) and
/// `return_hosts` fed only the ONE box's old `/kop` purchase page, deleted in OD6
/// (2026-10-26: buying happens on each product site's own sign-up). A block takes unknown
/// keys, so a deleted field would leave a config that sets them told nothing; these two
/// refuse the block instead, saying which key and what to do ([`retired`]).
#[serde(
rename = "plans",
skip_serializing,
deserialize_with = "retired::plans"
)]
pub retired_plans: retired::Gone,
#[serde(
rename = "return_hosts",
skip_serializing,
deserialize_with = "retired::return_hosts"
)]
pub retired_return_hosts: retired::Gone,
/// The host a bought disk attaches to (`Resource::Disk::server`). The
/// product id when unset.
pub server: Option<String>,
/// **The product's data set, for the runtime growth**
/// (`[products.<id>.grow]`, `DATA-SET-GROWTH-FLOW.md` T6): which cloud
/// holds its member volumes, the server they are attached to, and the
/// members themselves — what the wallet reading prices
/// (`grow::affordable_bytes`). Absent — the default — no wallet is read. The four
/// volumes carry no `monetize_ref` (they were never bought through
/// monetize), so the operator names them; nothing discovers them.
pub grow: Option<GrowSetConfig>,
/// **THE SHOP WINDOW: what this product's tenants may BUY, by name** —
/// `[products.<id>.bundles."10gb-1m"] cap_delta = { pack_bytes =
/// 10737418240 }, period_days = 30`.
///
/// Each entry is a named cap DELTA and nothing else: no amount, no
/// currency, no label. The price is computed from `[prices.<id>.<market>]`
/// (`monetize::bundle::offer`) and the words are monetize's, because a
/// price written twice is a price that will disagree with itself and a
/// label written by hand is a shop window that can contradict what the
/// order actually raises.
///
/// Absent — the default — is a deployment that offers no bundles.
/// `Purchase.Bundles` then answers an EMPTY list, which is not an error:
/// the console falls back to the typed order it has always had.
pub bundles: BTreeMap<String, monetize::BundleSpec>,
/// **Retired, refused BY NAME**: `[products.<id>.twin_grow]` drove the twin lane of the
/// appliance grow batcher, deleted 2026-11-02 ([`retired::TWIN_GROW`]).
#[serde(
rename = "twin_grow",
skip_serializing,
deserialize_with = "retired::twin_grow"
)]
pub retired_twin_grow: retired::Gone,
}
/// `[products.<id>.grow]` — see [`ProductConfig::grow`].
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct GrowSetConfig {
/// The `[clouds.<name>]` that holds the volumes (`upcloud`). It must also
/// set `allow_grow = true` in its own block, or the provider refuses.
pub cloud: String,
/// The provider's id of the server the members are attached to.
pub server: String,
/// The provider's ids of the member volumes, in the set's order.
pub members: Vec<String>,
}
/// **`[vendors.<kind>.<instance>]` ⇄ `{"<kind>/<instance>": VendorConfig}`.**
/// The file nests the instance under its kind; the server keys the flat
/// instance id. A kind table holding anything but instance tables — the old
/// flat `[vendors.stripe] enabled = true` — is refused by name.
mod vendor_instances {
use std::collections::BTreeMap;
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use super::VendorConfig;
pub fn deserialize<'de, D: Deserializer<'de>>(
d: D,
) -> Result<BTreeMap<String, VendorConfig>, D::Error> {
let kinds = BTreeMap::<String, toml::Table>::deserialize(d)?;
let mut out = BTreeMap::new();
for (kind, instances) in kinds {
if instances.is_empty() {
return Err(D::Error::custom(format!("[vendors.{kind}] names no instance: a vendor is [vendors.{kind}.<instance>], one block per account")));
}
for (instance, block) in instances {
let id = format!("{kind}/{instance}");
let toml::Value::Table(t) = block else {
return Err(D::Error::custom(format!(
"[vendors.{kind}] `{instance}` is not an instance block: a vendor is [vendors.{kind}.<instance>] (e.g. [vendors.{kind}.acme]), one block per account"
)));
};
monetize_pay::instance::split(&id).map_err(D::Error::custom)?;
let v: VendorConfig = toml::Value::Table(t)
.try_into()
.map_err(|e| D::Error::custom(format!("[vendors.{kind}.{instance}] {e}")))?;
out.insert(id, v);
}
}
Ok(out)
}
pub fn serialize<S: Serializer>(
v: &BTreeMap<String, VendorConfig>,
s: S,
) -> Result<S::Ok, S::Error> {
let mut kinds: BTreeMap<&str, BTreeMap<&str, &VendorConfig>> = BTreeMap::new();
for (id, block) in v {
let (kind, instance) = id.split_once('/').unwrap_or((id.as_str(), ""));
kinds.entry(kind).or_default().insert(instance, block);
}
kinds.serialize(s)
}
}
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct VendorConfig {
pub enabled: bool,
/// Where the customer is sent after `Purchase.Start`, with `{handle}` and
/// `{reference}` substituted. Empty for a vendor that needs no redirect.
pub checkout_url: String,
/// **Where the entity that INVOICES US for this vendor's fee is
/// established**, ISO 3166-1 alpha-2. Empty — the default — means "the same
/// country as this deployment", because a deployment that has said nothing
/// has said nothing about a cross-border charge either, and a tax report
/// that invented one would put a reverse charge in a return on no evidence.
///
/// It is the OPERATOR's declaration and not the impl's constant: which legal
/// entity of a payment vendor bills a given merchant is a fact about the
/// contract, not about the API the code talks to. See `monetize::tax::Books`.
pub country: String,
/// **A signed `.wasm` this vendor is served by** — `guest = "<path>.wasm"`.
/// The guest host builds it (`monetize_product_wasm::HotVendor`) and takes
/// a changed module or block in LIVE, no restart. Absent is a vendor a
/// linked factory builds. Every other `guest*` key means what it means on
/// a `[products.<id>]` block.
pub guest: Option<PathBuf>,
pub guest_digest: Option<String>,
pub guest_trust_roots: Vec<PathBuf>,
/// For a vendor guest: a subset of `http`, `hmac`, `state`.
pub grants: Vec<String>,
pub http_hosts: Vec<String>,
/// `[[vendors.<kind>.<instance>.http_auth]]` — the vendor's API credential, added by
/// the host per host from a secret file.
pub http_auth: Vec<monetize_product::HttpAuth>,
/// The webhook secrets the guest's `hmac` door may verify against.
pub hmac_secrets: Vec<String>,
pub guest_fuel: Option<u64>,
pub guest_call_timeout_ms: Option<u64>,
pub guest_max_memory_bytes: Option<u64>,
/// **Every other key in the block, untyped** — the vendor's own knobs,
/// handed to its factory as `monetize_pay::factory::Settings`. This file
/// does not know what a vendor needs (it does not know the vendor), and a
/// typo here is a refusal from the vendor naming the key, not "unset".
#[serde(flatten)]
pub settings: toml::Table,
}
/// A cloud provider's settings. Credentials are NOT here — each impl reads its
/// own environment (`UPCLOUD_USERNAME` / `UPCLOUD_PASSWORD`), which the unit
/// carries in its 0600 `EnvironmentFile=`.
///
/// Only `enabled` and `cap` are this file's; every other key is the provider's
/// and travels to its factory untyped. The knobs each provider reads are in its
/// own module (`monetize_cloud::server`, `monetize_cloud::sandbox`, and the
/// private impls' — the doc block at the top of this file lists the common ones).
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct CloudConfig {
pub enabled: bool,
/// **The spend ceiling. REQUIRED when `enabled`, unless the provider's
/// factory says it cannot spend** (`server`, the box's own iron).
///
/// An enabled cloud with no ceiling is the one genuinely dangerous state
/// this file can express: an automated system with an unbounded credit card.
/// So its absence is refused at start rather than defaulted — the same
/// reasoning `monetize_cloud::cap::Ceiling` applies to a zero ceiling.
/// Absence is not unlimited any more than zero is; a deliberate "very high"
/// is a very high NUMBER, written down, that somebody chose.
pub cap: Option<CapConfig>,
/// **Where the entity that invoices us for this cloud is established**, ISO
/// 3166-1 alpha-2. Empty is domestic — see [`VendorConfig::country`] for the
/// reasoning, which is the same on both sides of the ledger.
pub country: String,
/// **Every other key in the block, untyped** — the provider's own knobs
/// (`zone`, `pool_gib`, `prices`, `disk_step_gib`, …), handed to its
/// factory as `monetize_cloud::factory::Settings`.
#[serde(flatten)]
pub settings: toml::Table,
}
/// The spend ceiling, as the config file writes it. **At least one limit is
/// required**; a cap that limits nothing is refused at start.
#[derive(Clone, Copy, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct CapConfig {
/// Minor units per calendar MONTH, reset on the 1st.
pub monthly_minor: Option<u64>,
/// Minor units in TOTAL, ever — never reset. **This is the one a rehearsal
/// wants**: a monthly EUR 10 permits EUR 10 again next month and EUR 120 over
/// a year, which is not what "cap it at ten euros" means about a test budget.
///
/// It is kept honest across a lost disk: the figure is recomputed from the
/// ledger at every start (`Ledger::total_provisioned_minor`), so the cap's own
/// file is a cache that cannot quietly raise the budget.
pub total_minor: Option<u64>,
/// `Sek` or `Eur`. A resource priced in any other currency is refused, never
/// converted.
pub currency: Option<monetize_pay::Currency>,
}
impl Default for Config {
fn default() -> Self {
Config {
grpc_addr: SocketAddr::from(([127, 0, 0, 1], monetize_proto::DEFAULT_PORT)),
data_dir: PathBuf::from(DEFAULT_DATA_DIR),
secrets_dir: None,
signing_key: None,
token_file: None,
poll_minutes: 5,
plugins_box: None,
release_bucket: None,
backup_dir: None,
backup_every_minutes: 60,
default_cloud: None,
default_market: "SE".into(),
require_prices: false,
control_principals: BTreeMap::new(),
policy: Policy::default(),
// Absent `[nonpay]` purges after the default grace (owner 2026-10-11).
nonpay: crate::nonpay::NonpayPolicy::shipped(),
bench_travel_url: None,
prices: BTreeMap::new(),
pricing: BTreeMap::new(),
products: BTreeMap::new(),
vendors: BTreeMap::new(),
clouds: BTreeMap::new(),
machines: BTreeMap::new(),
signups: BTreeMap::new(),
dns_writers: BTreeMap::new(),
estates: BTreeMap::new(),
tax: TaxConfig::default(),
grow_batch_every_secs: 0,
grow_batch_at_secs: 7200,
estate_orders: None,
test_mailbox: String::new(),
test_return_url: String::new(),
}
}
}
impl Config {
pub fn from_toml(text: &str) -> anyhow::Result<Self> {
let raw: toml::Table = toml::from_str(text)?;
if raw.contains_key("signup") {
anyhow::bail!(
"[signup] is gone: one monetize sells several products, each from its own [signups.<product>] \
(e.g. [signups.acme] with [signups.acme.plans.\"acme/10gb-1m\"]). Move the block under its product's name"
);
}
if let Some(key) = retired::GROW_KNOBS.iter().find(|k| raw.contains_key(**k)) {
anyhow::bail!("{}", retired::grow_knob(key));
}
let mut cfg: Config = toml::from_str(text)?;
cfg.check_signups()?;
cfg.nonpay
.check_purges(&cfg.policy)
.map_err(|e| anyhow::anyhow!("{e}"))?;
for (product, p) in &cfg.pricing {
p.check(product).map_err(|e| anyhow::anyhow!("{e}"))?;
}
if let Some(r) = cfg
.bench_travel_url
.as_deref()
.and_then(crate::bench::travel_refusal)
{
anyhow::bail!("{r}");
}
cfg.check_vendor_routes()?;
cfg.check_estates()?;
cfg.check_pull_instances()?;
for (id, p) in &cfg.products {
if p.plugin.as_deref().is_some_and(|n| n.trim().is_empty()) {
anyhow::bail!("[products.{id}] plugin = \"\" names nothing: name what serves this block (`plugin = \"<kind>\"`), or drop the key and the block's own id is its plugin");
}
}
let dir = cfg.secrets_dir();
for p in cfg.products.values_mut() {
p.secrets_dir = Some(dir.clone());
}
Ok(cfg)
}
/// **Is `id` sold for money here?** A `[prices.<id>]` with any unit above
/// zero (the VAT rate is not a unit), or a `[pricing.<id>]`.
pub fn is_priced(&self, id: &str) -> bool {
self.pricing.contains_key(id)
|| self.prices.get(id).is_some_and(|m| {
m.values().any(|units| {
units
.iter()
.any(|(k, v)| k != VAT_BASIS_POINTS_KEY && *v > 0)
})
})
}
/// **product → vendor instance**, for every product that names one.
pub fn vendor_routes(&self) -> BTreeMap<String, String> {
self.products
.iter()
.filter_map(|(id, p)| p.vendor.clone().map(|v| (id.clone(), v)))
.collect()
}
/// Every priced `[products.<id>]` names an ENABLED vendor instance; every
/// `vendor =` names one. Refused at load, by the block's name. A price list
/// for a product with no block YET (one that self-registers after monetize
/// serves) is set aside at start; the block it arrives with is checked here.
fn check_pull_instances(&self) -> anyhow::Result<()> {
for (id, p) in &self.products {
let Some(instance) = p.instance.as_deref() else {
if !p.fleet_keys.is_empty() {
anyhow::bail!("[products.{id}] fleet_keys without instance: fleet keys are an estate's, and no estate is named");
}
continue;
};
let dials = [
(!p.endpoint.is_empty()).then_some("endpoint"),
(!p.guest_grpc_hosts.is_empty()).then_some("guest_grpc_hosts"),
p.restoring_from_twin
.is_some()
.then_some("restoring_from_twin"),
];
if let Some(key) = dials.into_iter().flatten().next() {
anyhow::bail!("[products.{id}] instance = {instance:?} beside `{key}`: the estate calls monetize over the pull channel and monetize never dials it; drop `{key}`");
}
for role in p.fleet_keys.keys() {
if ![
monetize_product::guardian::role::MAIN,
monetize_product::guardian::role::TWIN,
monetize_product::guardian::role::FRONT,
]
.contains(&role.as_str())
{
anyhow::bail!("[products.{id}] fleet_keys.{role}: no box of an estate has that role (main, twin, front)");
}
}
if self
.signups
.get(id)
.is_some_and(|s| s.settings.contains_key("guest"))
{
anyhow::bail!(
"[signups.{id}] guest beside [products.{id}] instance = {instance:?}: a pulled product's accounts are made by its estate's main from a signed `add_principal` order, never by a guest that dials it; drop `guest`"
);
}
}
Ok(())
}
/// `[estates.<estate>]`: each names a product and an ENABLED vendor instance of its own.
pub(crate) fn check_estates(&self) -> anyhow::Result<()> {
let mut used: BTreeMap<&str, &str> = BTreeMap::new();
for (id, e) in &self.estates {
if e.product.trim().is_empty() {
anyhow::bail!("[estates.{id}] names no product: a site sells one product (`product = \"<product>\"`)");
}
match self.vendors.get(e.vendor.as_str()) {
Some(b) if b.enabled => {}
Some(_) => anyhow::bail!("[estates.{id}] vendor = {:?}, and {} is not enabled: it takes no money", e.vendor, monetize_pay::instance::block_of(&e.vendor)),
None => anyhow::bail!(
"[estates.{id}] vendor = {:?}, and no {} is configured. Configured instances: [{}]",
e.vendor,
monetize_pay::instance::block_of(&e.vendor),
self.vendors.keys().cloned().collect::<Vec<_>>().join(", ")
),
}
if !e.appliance_key.trim().is_empty()
&& crate::site_ticket::key_of(&e.appliance_key).is_none()
{
anyhow::bail!("[estates.{id}] appliance_key is not an ed25519 public key (64 hex characters): no ticket of that site could be checked");
}
if e.operator_site && e.owner.trim().is_empty() {
anyhow::bail!("[estates.{id}] operator_site = true names no owner: the operator buy is its admin's (the account `owner` names) and nobody else's");
}
if e.cli_hole_public && !e.operator_site {
anyhow::bail!("[estates.{id}] cli_hole_public = true is said of an operator's own site (`operator_site = true`) only: a customer's front keeps the CLI hole to its own network");
}
if !e.public_repositories.is_empty() && !e.operator_site {
anyhow::bail!("[estates.{id}] public_repositories is said of an operator's own site (`operator_site = true`) only: a customer's repositories stay private");
}
let mut said = std::collections::BTreeSet::new();
for n in &e.public_repositories {
if n.is_empty()
|| !n
.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_' || c == b'.')
{
anyhow::bail!("[estates.{id}] public_repositories names {n:?}: a repository is one plain word");
}
if !said.insert(n.as_str()) {
anyhow::bail!("[estates.{id}] public_repositories names {n:?} twice");
}
}
if e.public_site && !e.operator_site {
anyhow::bail!("[estates.{id}] public_site = true is said of an operator's own site (`operator_site = true`) only: a customer's estate is the airgap default and is never indexed");
}
if e.offsite && !e.operator_site {
anyhow::bail!("[estates.{id}] offsite = true is said of an operator's own site (`operator_site = true`) only: the off-site store's key pair is the operator's, and a customer's boxes never hold it");
}
if e.narrate && !e.operator_site {
anyhow::bail!("[estates.{id}] narrate = true is said of an operator's own test site (`operator_site = true`) only: a customer's estate is never narrated");
}
if let Some(other) = used.insert(e.vendor.as_str(), id.as_str()) {
// The operator's OWN sites may share one of his accounts (a test site beside
// test2, owner 2026-10-13); a customer's site never shares.
if !(e.operator_site && self.estates.get(other).is_some_and(|o| o.operator_site)) {
anyhow::bail!("[estates.{other}] and [estates.{id}] both bill through {:?}: one account per site (only the operator's own sites, both `operator_site = true`, may share one)", e.vendor);
}
}
}
Ok(())
}
fn check_vendor_routes(&self) -> anyhow::Result<()> {
for id in self.products.keys().filter(|id| self.is_priced(id)) {
if self
.products
.get(id.as_str())
.and_then(|p| p.vendor.as_deref())
.is_none()
{
anyhow::bail!(
"[products.{id}] is priced and names no `vendor`: every priced product bills through its own vendor instance, \
e.g. `vendor = \"stripe/{id}\"` with a [vendors.stripe.{id}] block. Configured instances: [{}]",
self.vendors.keys().cloned().collect::<Vec<_>>().join(", ")
);
}
}
for (id, p) in &self.products {
let Some(v) = p.vendor.as_deref() else {
continue;
};
match self.vendors.get(v) {
Some(b) if b.enabled => {}
Some(_) => anyhow::bail!("[products.{id}] vendor = {v:?}, and {} is not enabled: it takes no money", monetize_pay::instance::block_of(v)),
None => anyhow::bail!(
"[products.{id}] vendor = {v:?}, and no {} is configured. Configured instances: [{}]",
monetize_pay::instance::block_of(v),
self.vendors.keys().cloned().collect::<Vec<_>>().join(", ")
),
}
}
Ok(())
}
/// Every `[signups.<product>]`: its plan ids name it, it does not name its
/// product a second time (`product =`), and its allowance is a real one.
/// Refused at load, by the block's name.
fn check_signups(&self) -> anyhow::Result<()> {
for (product, b) in &self.signups {
if b.settings.contains_key("product") {
anyhow::bail!("[signups.{product}] sets `product`: the block's name IS the product it sells for. Remove the key");
}
if let Some(id) = b
.plans
.keys()
.find(|id| crate::signup::plan_product(id) != product)
{
anyhow::bail!("[signups.{product}] lists plan {id:?}, which is not {product}'s: every plan id there starts with \"{product}/\"");
}
b.max_sold_percent()
.map_err(|e| anyhow::anyhow!("[signups.{product}] {e}"))?;
}
Ok(())
}
/// Every product's signup plans, as one map (a plan id names its product).
pub fn signup_plans(&self) -> BTreeMap<String, SignupPlan> {
self.signups
.values()
.flat_map(|b| b.plans.iter().map(|(id, p)| (id.clone(), *p)))
.collect()
}
/// product → the meter its signup plans' caps land on, where a block names one.
pub fn signup_cap_meters(&self) -> BTreeMap<String, String> {
self.signups
.iter()
.filter_map(|(p, b)| b.cap_meter().map(|m| (p.clone(), m)))
.collect()
}
/// `[signups.<product>]` as the sink reads it: the whole block, plans
/// included, with `product` set from the block's name.
pub fn signup_settings(&self, product: &str) -> anyhow::Result<serde_json::Value> {
let b = self
.signups
.get(product)
.ok_or_else(|| anyhow::anyhow!("no [signups.{product}] is configured"))?;
let mut v =
serde_json::to_value(b).map_err(|e| anyhow::anyhow!("[signups.{product}]: {e}"))?;
if let Some(o) = v.as_object_mut() {
o.insert(
"product".into(),
serde_json::Value::String(product.to_owned()),
);
}
Ok(v)
}
/// Every `[signups.<product>]` as its sink reads it (see [`Config::signup_settings`]).
pub fn signup_blocks(&self) -> anyhow::Result<BTreeMap<String, serde_json::Value>> {
self.signups
.keys()
.map(|p| Ok((p.clone(), self.signup_settings(p)?)))
.collect()
}
/// product → its sold-capacity allowance, or the refusal naming the block.
pub fn signup_max_sold_percents(&self) -> anyhow::Result<BTreeMap<String, u32>> {
self.signups
.iter()
.map(|(p, b)| {
Ok((
p.clone(),
b.max_sold_percent()
.map_err(|e| anyhow::anyhow!("[signups.{p}] {e}"))?,
))
})
.collect()
}
pub fn load(path: &Path) -> anyhow::Result<Self> {
use std::os::unix::fs::PermissionsExt as _;
let text = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("read {}: {e}", path.display()))?;
let cfg =
Self::from_toml(&text).map_err(|e| anyhow::anyhow!("parse {}: {e}", path.display()))?;
let holding: Vec<&str> = cfg
.products
.iter()
.filter(|(_, p)| p.bearer_token.is_some())
.map(|(id, _)| id.as_str())
.collect();
if !holding.is_empty() {
let mode = std::fs::metadata(path)
.map_err(|e| anyhow::anyhow!("stat {}: {e}", path.display()))?
.permissions()
.mode()
& 0o777;
if mode & 0o044 != 0 {
anyhow::bail!(
"{} holds a bearer_token ([products.{}]) and is readable by others (mode {mode:04o}): a secret file must be 0600 — chmod 0600 it",
path.display(),
holding.join("], [products.")
);
}
}
Ok(cfg)
}
pub fn signing_key_path(&self) -> PathBuf {
self.signing_key
.clone()
.unwrap_or_else(|| self.data_dir.join("signing.key"))
}
/// **The keys this server signs with** (G8): every product's key derived
/// from `root`, and each `[products.<id>] signing_key` pinned. A named key
/// file that is missing or not a key is refused by the block's name.
pub fn keyring(&self, root: monetize::Signer) -> anyhow::Result<monetize::Keyring> {
let ring = monetize::Keyring::new(root);
for (id, p) in &self.products {
if let Some(path) = &p.signing_key {
let key = monetize::Signer::from_path(path)
.map_err(|e| anyhow::anyhow!("[products.{id}] signing_key {}: {e} — the estates of `{id}` trust that key, so this server does not start signing for it with another", path.display()))?;
ring.pin(id, key).map_err(|e| {
anyhow::anyhow!("[products.{id}] signing_key {}: {e}", path.display())
})?;
}
}
Ok(ring)
}
/// Where `secret.<name>` files are read from: `secrets_dir`, default
/// `<data_dir>/secrets`.
pub fn secrets_dir(&self) -> PathBuf {
self.secrets_dir
.clone()
.unwrap_or_else(|| self.data_dir.join("secrets"))
}
/// Every environment name a PRODUCT in this config reads, so the install
/// forwards it into the unit's secret env like a vendor's. Products are
/// configured, not linked, so this lives on the config and not on `Linked`.
pub fn product_config_envs(&self) -> Vec<String> {
self.products
.values()
.filter_map(|p| p.api_key_env.clone())
.filter(|n| !n.trim().is_empty())
.collect()
}
pub fn token_file_path(&self) -> PathBuf {
self.token_file
.clone()
.unwrap_or_else(|| self.data_dir.join("token"))
}
/// Where the OPERATOR bearer is read from ([`OPERATOR_TOKEN_FILE`]).
/// ★ A32-4: where this monetize says what it serves ([`crate::serving`]).
pub fn serving_path(&self) -> PathBuf {
self.data_dir.join(crate::serving::SERVING_FILE)
}
pub fn operator_token_file_path(&self) -> PathBuf {
self.data_dir.join(OPERATOR_TOKEN_FILE)
}
pub fn ledger_path(&self) -> PathBuf {
self.data_dir.join("ledger.redb")
}
pub fn usage_path(&self) -> PathBuf {
self.data_dir.join("usage.redb")
}
/// The paid-signup store. Its own redb file beside the ledger and the usage
/// store, for the reason those two are separate from each other: redb is
/// single-process and one file per concern means a signup write cannot be
/// blocked behind the poller's usage transaction.
pub fn signup_path(&self) -> PathBuf {
self.data_dir.join("signups.redb")
}
/// **The vendor's deliveries, kept before they are acted on**
/// (`crate::inbox`). Its own redb file: one writer per file.
pub fn callback_inbox_path(&self) -> PathBuf {
self.data_dir.join("callbacks.redb")
}
/// The reconciler's case rows and each vendor's last pass. Its own file
/// for the same reason: one writer per redb file, and a reconcile write
/// must not queue behind the poller's usage transaction.
pub fn reconcile_path(&self) -> PathBuf {
self.data_dir.join("reconcile.redb")
}
/// The nonpay journal: every warning, deletion and cancellation, one JSON
/// line each ([`crate::nonpay`]).
pub fn nonpay_path(&self) -> PathBuf {
self.data_dir.join(crate::nonpay::JOURNAL_FILE)
}
/// Where `Ledger.Export` writes. See [`Config::backup_dir`].
pub fn backup_dir_path(&self) -> PathBuf {
self.backup_dir
.clone()
.unwrap_or_else(|| self.data_dir.join("backup"))
}
/// **The price lists, typed**: product → market → `PriceList`, each in its
/// market's currency. Refuses, by name, a market code that is not one of
/// ours and a unit that does not exist — a typo in a price list is a silent
/// gift otherwise.
///
/// `vat_basis_points` is the one key in a `[prices.<product>.<market>]`
/// block that is NOT a unit: it is the rate the list's prices EXCLUDE
/// (`monetize::PriceList::vat_basis_points`), lifted out before the rest is
/// validated as units. Absent means the market's own rate, which is what
/// every list written before the key existed meant.
pub fn price_lists(&self) -> anyhow::Result<BTreeMap<String, BTreeMap<Market, PriceList>>> {
let mut out: BTreeMap<String, BTreeMap<Market, PriceList>> = BTreeMap::new();
for (product, markets) in &self.prices {
for (code, all) in markets {
let market = market_from_code(code)
.ok_or_else(|| anyhow::anyhow!("[prices.{product}.{code}]: {code:?} is not a market; the markets are SE, FI and DK"))?;
let mut units = all.clone();
let vat_basis_points = units.remove(VAT_BASIS_POINTS_KEY).map(|bp| {
u32::try_from(bp).map_err(|_| anyhow::anyhow!("[prices.{product}.{code}] {VAT_BASIS_POINTS_KEY} = {bp} is not a rate in basis points; 2500 is 25 %"))
});
let vat_basis_points = match vat_basis_points {
Some(r) => Some(r?),
None => None,
};
let list = PriceList {
currency: market.currency(),
units,
vat_basis_points,
};
list.validate()
.map_err(|e| anyhow::anyhow!("[prices.{product}.{code}]: {e}"))?;
out.entry(product.clone()).or_default().insert(market, list);
}
}
Ok(out)
}
/// **Why `[products.<id>]` may not be served unpriced**, or `None` when it
/// may: [`Config::require_prices`] holds, the block does not say
/// `free = true`, and there is no `[prices.<id>]` with a market in it.
pub fn unpriced(&self, id: &str) -> Option<String> {
let free = self.products.get(id).is_some_and(|p| p.free);
let priced = self.prices.get(id).is_some_and(|m| !m.is_empty());
(self.require_prices && !free && !priced).then(|| {
format!("has no [prices.{id}] and its block does not say `free = true`; this deployment sells nothing unpriced (require_prices), so it is not served free by accident")
})
}
/// The market an order is priced in when the request names none.
pub fn default_market(&self) -> anyhow::Result<Market> {
market_from_code(&self.default_market).ok_or_else(|| {
anyhow::anyhow!(
"default_market = {:?} is not a market; the markets are SE, FI and DK",
self.default_market
)
})
}
/// The host a product's bought disks attach to: `[products.<id>] server`,
/// or the product id.
pub fn host_of(&self, product: &str) -> String {
self.products
.get(product)
.and_then(|p| p.server.clone())
.unwrap_or_else(|| product.to_owned())
}
}
/// `SE` | `FI` (any case) → the market. The spelling `[prices.<product>.<MARKET>]` uses.
/// The one key in a `[prices.<product>.<market>]` block that prices no unit.
/// Named once, here, so the parser and the documentation cannot drift.
pub const VAT_BASIS_POINTS_KEY: &str = "vat_basis_points";
pub fn market_from_code(code: &str) -> Option<Market> {
match code.to_ascii_uppercase().as_str() {
"SE" => Some(Market::Se),
"FI" => Some(Market::Fi),
"DK" => Some(Market::Dk),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_documented_example_parses_and_defaults_hold() {
let cfg = Config::from_toml(
r#"
grpc_addr = "0.0.0.0:50061"
default_market = "se"
[policy]
period = "Year"
grace_days = 7
[prices.gunnar.SE]
disk_gib_month = 2200
cpu_core_month = 0
[prices.gunnar.FI]
disk_gib_month = 200
[products.gunnar]
endpoint = "127.0.0.1:50051"
vendor = "invoice/gunnar"
server = "gunnar-appliance"
appliance_key = "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b"
[vendors.invoice.gunnar]
enabled = true
[vendors.klarna.gunnar]
enabled = false
checkout_url = "https://checkout.klarna.com/{handle}"
[clouds.upcloud]
enabled = false
"#,
)
.unwrap();
assert_eq!(cfg.grpc_addr.port(), 50061);
assert_eq!(cfg.data_dir, PathBuf::from("/var/lib/monetize"));
assert_eq!(
cfg.signing_key_path(),
PathBuf::from("/var/lib/monetize/signing.key")
);
assert_eq!(cfg.poll_minutes, 5);
assert_eq!(
cfg.backup_every_minutes, 60,
"the backup is on a timer unless turned off"
);
assert!(cfg.vendors["invoice/gunnar"].enabled && !cfg.vendors["klarna/gunnar"].enabled);
assert_eq!(cfg.vendor_routes()["gunnar"], "invoice/gunnar");
assert_eq!(cfg.products["gunnar"].endpoint, "127.0.0.1:50051");
assert_eq!(cfg.host_of("gunnar"), "gunnar-appliance");
// **The appliance's verifying key**, which is what lets a TENANT renew
// themselves at all. Absent is the default and the honest one: a
// deployment that never set it cannot check a ticket and must say so
// rather than trusting one.
assert_eq!(
cfg.products["gunnar"].appliance_key.as_deref(),
Some("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b")
);
assert_eq!(
Config::from_toml("[products.holger]\nendpoint = \"x\"")
.unwrap()
.products["holger"]
.appliance_key,
None
);
assert_eq!(cfg.host_of("holger"), "holger", "unset: the product id");
// The policy: what was written, and the default for what was not.
assert_eq!(
cfg.policy,
Policy {
period: monetize::Period::Year,
grace_days: 7,
retention_days: 90
}
);
assert_eq!(cfg.default_market().unwrap(), Market::Se);
// The price lists, typed, in the market's currency; SE in öre, FI in cents.
let lists = cfg.price_lists().unwrap();
let se = &lists["gunnar"][&Market::Se];
assert_eq!(
(
se.currency,
se.units["disk_gib_month"],
se.units["cpu_core_month"]
),
(monetize_pay::Currency::Sek, 2200, 0)
);
assert_eq!(
lists["gunnar"][&Market::Fi].currency,
monetize_pay::Currency::Eur
);
assert!(
!se.is_free()
&& !lists["gunnar"][&Market::Fi]
.units
.contains_key("ram_gib_month")
);
let empty = Config::from_toml("").unwrap();
assert!(
empty.price_lists().unwrap().is_empty(),
"no [prices] is the open-source deployment: free"
);
assert_eq!(empty.policy, Policy::default());
assert!(Config::from_toml("grpc_addr = 5").is_err());
}
/// A typo in a price list is refused by name at load — the alternative is
/// a silent gift.
#[test]
fn refuse_twin_a_mistyped_unit_or_market_in_a_price_list_is_refused_by_name() {
let routed = |list: &str| {
format!("[products.gunnar]\nvendor = \"stripe/gunnar\"\n[products.holger]\nvendor = \"stripe/holger\"\n[vendors.stripe.gunnar]\nenabled = true\n[vendors.stripe.holger]\nenabled = true\n{list}")
};
let cfg = Config::from_toml(&routed("[prices.gunnar.SE]\ndisk_gb_month = 2200\n")).unwrap();
let err = cfg.price_lists().unwrap_err().to_string();
assert!(
err.contains("[prices.gunnar.SE]")
&& err.contains("disk_gb_month")
&& err.contains("disk_gib_month"),
"{err}"
);
let cfg =
Config::from_toml(&routed("[prices.gunnar.NO]\ndisk_gib_month = 2200\n")).unwrap();
let err = cfg.price_lists().unwrap_err().to_string();
assert!(
err.contains("[prices.gunnar.NO]") && err.contains("SE, FI and DK"),
"{err}"
);
assert_eq!(market_from_code("se"), Some(Market::Se));
assert_eq!(market_from_code("fi"), Some(Market::Fi));
assert_eq!(
market_from_code("DK"),
Some(Market::Dk),
"owner 2026-09-28: holger sells in DK"
);
let dk = Config::from_toml(&routed("[prices.holger.DK]\ndisk_gib_month = 1250\n"))
.unwrap()
.price_lists()
.unwrap();
assert_eq!(
(
dk["holger"][&Market::Dk].currency,
dk["holger"][&Market::Dk]
.units
.get("disk_gib_month")
.copied()
),
(monetize_pay::Currency::Dkk, Some(1250))
);
assert!(Config::from_toml("default_market = \"NO\"")
.unwrap()
.default_market()
.is_err());
}
/// **The shop window parses, and it carries NO price.**
///
/// The contrast with `[signups.<product>.plans]` below is the whole design: a signup
/// plan states an amount (it is checked against what a vendor's own page
/// took), and a bundle states none (monetize prices it from `[prices]`).
/// A bundle table that could carry an amount would be a second source of
/// prices, which is the thing `monetize::bundle` exists to prevent.
#[test]
fn the_bundle_table_parses_and_carries_no_price_of_its_own() {
let cfg = Config::from_toml(
r#"
[products.gunnar]
endpoint = "127.0.0.1:50051"
[products.gunnar.bundles."10gb-1m"]
cap_delta = { pack_bytes = 10737418240 }
period_days = 30
[products.gunnar.bundles."100gb-1m"]
cap_delta = { pack_bytes = 107374182400 }
"#,
)
.unwrap();
let bundles = &cfg.products["gunnar"].bundles;
assert_eq!(bundles.len(), 2);
assert_eq!(bundles["10gb-1m"].cap_delta["pack_bytes"], 10_737_418_240);
assert_eq!(bundles["10gb-1m"].period_days, Some(30));
assert_eq!(
bundles["100gb-1m"].period_days, None,
"period_days is optional: absent takes [policy]'s word"
);
// A deployment that declares none — the open-source shape — and one
// that has no [products] block at all. Both are an empty shop window
// and neither is an error.
assert!(Config::from_toml(
"[products.gunnar]
endpoint = \"x\"\n"
)
.unwrap()
.products["gunnar"]
.bundles
.is_empty());
assert!(Config::from_toml("").unwrap().products.is_empty());
}
/// **The one price the open half carries is in this file, not in the code.**
/// The plan id holds a slash, so it is a quoted key; the amount is minor
/// units; the currency is spelled the way `monetize_pay::Currency` is.
#[test]
fn the_signup_plan_table_parses_and_the_sinks_own_keys_travel_untyped() {
let cfg = Config::from_toml(
r#"
[signups.gunnar]
gunnar_endpoint = "127.0.0.1:50051"
[signups.gunnar.plans."gunnar/10gb-1m"]
amount_minor = 10000
currency = "Sek"
cap_bytes = 10737418240
period_days = 30
"#,
)
.unwrap();
let plan = cfg.signups["gunnar"].plans["gunnar/10gb-1m"];
assert_eq!(
(
plan.amount_minor,
plan.currency,
plan.cap_bytes,
plan.period_days
),
(10_000, monetize_pay::Currency::Sek, 10_737_418_240, 30)
);
assert_eq!(
plan.price(),
monetize_pay::Money {
minor: 10_000,
currency: monetize_pay::Currency::Sek
}
);
// The sink's own knob is in `settings`, untyped, and NOT mistaken for a plan.
let g = &cfg.signups["gunnar"];
assert_eq!(
g.settings.get("gunnar_endpoint").and_then(|v| v.as_str()),
Some("127.0.0.1:50051")
);
assert!(
!g.settings.contains_key("plans"),
"the plan table is the server's, not the sink's"
);
// Nor is the oversell allowance: it gates this server's own checkout
// mint and means nothing to a sink.
assert_eq!(
g.max_sold_percent().unwrap(),
crate::oversell::DEFAULT_MAX_SOLD_PERCENT,
"absent is the default, never unlimited"
);
let set =
Config::from_toml("[signups.gunnar]\nmax_sold_percent_of_capacity = 150\n").unwrap();
assert_eq!(set.signups["gunnar"].max_sold_percent().unwrap(), 150);
assert!(
!set.signups["gunnar"]
.settings
.contains_key("max_sold_percent_of_capacity"),
"the allowance is the server's, not the sink's"
);
let bad = Config::from_toml("[signups.gunnar]\nmax_sold_percent_of_capacity = 80\n")
.unwrap_err()
.to_string();
assert!(
bad.contains("[signups.gunnar]")
&& bad.contains("max_sold_percent_of_capacity = 80")
&& bad.contains("reserve"),
"{bad}"
);
// No [signups] at all is a deployment that sells no signups — not an error.
assert!(Config::from_toml("").unwrap().signup_plans().is_empty());
}
/// ★ **G4: one monetize sells each product's signup** (2026-10-10). gunnar
/// and holger each have a `[signups.<product>]`: their plans (each id
/// names its product), the meter their caps land on, their own allowance
/// and their own sink — and the sink is handed its block with `product`
/// set from the block's name, so no guest defaults to gunnar.
#[test]
fn each_product_sells_its_own_signup_from_its_own_block() {
let cfg = Config::from_toml(
r#"
[signups.gunnar]
guest = "/g/signup-gunnar.wasm"
max_sold_percent_of_capacity = 300
[signups.gunnar.plans."gunnar/10gb-1m"]
amount_minor = 10000
currency = "Sek"
cap_bytes = 10737418240
period_days = 30
[signups.holger]
guest = "/g/signup-holger.wasm"
cap_meter = "disk_bytes"
max_sold_percent_of_capacity = 150
[signups.holger.plans."holger/base-se"]
amount_minor = 10000
currency = "Sek"
cap_bytes = 5368709120
period_days = 30
"#,
)
.unwrap();
assert_eq!(
cfg.signup_plans()
.keys()
.map(String::as_str)
.collect::<Vec<_>>(),
["gunnar/10gb-1m", "holger/base-se"]
);
assert_eq!(
cfg.signup_cap_meters(),
BTreeMap::from([("holger".to_string(), "disk_bytes".to_string())])
);
assert_eq!(
cfg.signup_max_sold_percents().unwrap(),
BTreeMap::from([("gunnar".to_string(), 300), ("holger".to_string(), 150)])
);
let h = cfg.signup_settings("holger").unwrap();
assert_eq!(
h.get("product").and_then(|v| v.as_str()),
Some("holger"),
"the block's name is the product the sink serves"
);
assert_eq!(
h.get("guest").and_then(|v| v.as_str()),
Some("/g/signup-holger.wasm")
);
assert!(
h.get("plans")
.and_then(|p| p.get("holger/base-se"))
.is_some(),
"the sink reads its own plans: {h}"
);
assert!(
h.get("plans")
.and_then(|p| p.get("gunnar/10gb-1m"))
.is_none(),
"and never another product's: {h}"
);
assert!(
cfg.signup_settings("idun").is_err(),
"no block, no settings"
);
}
/// ★ **G4, no legacy**: the single `[signup]` is gone and refused by name; a
/// plan id that names another product is refused; a block does not name its
/// product twice (`product =`: the block's name is the product).
#[test]
fn the_old_signup_block_a_foreign_plan_and_a_second_product_name_are_refused() {
let old = Config::from_toml("[signup]\nguest = \"/g.wasm\"\n")
.unwrap_err()
.to_string();
assert!(
old.contains("[signup]") && old.contains("[signups.<product>]"),
"{old}"
);
let foreign = Config::from_toml("[signups.gunnar.plans.\"holger/base-se\"]\namount_minor = 1\ncurrency = \"Sek\"\ncap_bytes = 1\nperiod_days = 30\n").unwrap_err().to_string();
assert!(
foreign.contains("[signups.gunnar]")
&& foreign.contains("holger/base-se")
&& foreign.contains("gunnar/"),
"{foreign}"
);
let named = Config::from_toml("[signups.holger]\nproduct = \"holger\"\n")
.unwrap_err()
.to_string();
assert!(
named.contains("[signups.holger]") && named.contains("product"),
"{named}"
);
}
/// **The three keys a tax return needs, and the fact that a config written
/// before them still parses.**
///
/// The migration is the point: every one of them is absent from the config
/// on the live box, and absence has to mean what that config meant when it
/// was written — the market's own rate, a domestic supplier, and no
/// jurisdiction linked.
#[test]
fn the_tax_keys_parse_and_a_config_written_before_them_still_means_what_it_meant() {
let cfg = Config::from_toml(
r#"
default_market = "SE"
[tax]
period = "quarterly"
[prices.gunnar.FI]
disk_gib_month = 200
vat_basis_points = 2550
[products.gunnar]
vendor = "stripe/gunnar"
[vendors.stripe.gunnar]
enabled = true
country = "IE"
[clouds.upcloud]
enabled = false
country = "FI"
"#,
)
.unwrap();
// The `[tax]` block travels untyped to whichever jurisdiction is linked.
assert_eq!(
cfg.tax.settings.get("period").and_then(|v| v.as_str()),
Some("quarterly")
);
assert_eq!(cfg.vendors["stripe/gunnar"].country, "IE");
assert_eq!(cfg.clouds["upcloud"].country, "FI");
// `vat_basis_points` is NOT a unit and must not be validated as one.
let fi = &cfg.price_lists().unwrap()["gunnar"][&Market::Fi];
assert_eq!(fi.vat_basis_points, Some(2550));
assert_eq!(
fi.units.len(),
1,
"the rate did not become a priced unit: {:?}",
fi.units
);
assert_eq!(fi.vat_basis_points_in(Market::Fi), 2550);
// THE OLD CONFIG, unchanged: every one of the three absent.
let old = Config::from_toml("[prices.gunnar.SE]\ndisk_gib_month = 2200\n[products.gunnar]\nvendor = \"invoice/gunnar\"\n[vendors.invoice.gunnar]\nenabled = true\n").unwrap();
assert!(
old.tax.settings.is_empty(),
"no [tax] is a working deployment: LoudTax, and it says so"
);
assert_eq!(
old.vendors["invoice/gunnar"].country, "",
"silence is domestic, never a reverse charge"
);
let se = &old.price_lists().unwrap()["gunnar"][&Market::Se];
assert_eq!(se.vat_basis_points, None);
assert_eq!(
se.vat_basis_points_in(Market::Se),
2500,
"absent means the market's own rate"
);
}
}
/// **Keys a `[products.<id>]` block no longer takes** (RA2-16): each is refused by name, with
/// the line that replaces it. The migration: delete the key from the block (and from the kit
/// that writes it); nothing else changes, because nothing has read either since OD6.
pub mod retired {
use serde::de::Error as _;
/// A retired key's slot: never set, since a present key fails the parse.
#[derive(Clone, Debug, Default)]
pub struct Gone;
pub const PLANS: &str = "`plans` ([products.<id>.plans.<plan>]) is retired (RA2-16): it fed the ONE box's old /kop \
purchase page, deleted in OD6 (2026-10-26). A product sells through its own sign-up \
([signups.<id>.plans.\"<id>/<plan>\"]) and its [products.<id>.bundles]; delete the \
[products.<id>.plans.*] tables";
pub const RETURN_HOSTS: &str =
"`return_hosts` is retired (RA2-16): it named where the ONE box's old /kop purchase \
page could send a buyer back to, and that page was deleted in OD6 (2026-10-26). Nothing \
reads it; delete the line";
pub fn plans<'de, D: serde::Deserializer<'de>>(_: D) -> Result<Gone, D::Error> {
Err(D::Error::custom(PLANS))
}
pub fn return_hosts<'de, D: serde::Deserializer<'de>>(_: D) -> Result<Gone, D::Error> {
Err(D::Error::custom(RETURN_HOSTS))
}
/// **The appliance grow batcher's top-level knobs** (deleted with the batcher, 2026-11-02).
/// `grow_batch_every_secs` and `grow_batch_at_secs` are not here: their daily boundary still acts.
pub const GROW_KNOBS: &[&str] = &[
"grow_batch_window_secs",
"grow_batch_max_wait_secs",
"grow_min_interval_secs",
"grow_trigger",
"grow_high_water_percent",
"grow_low_water_percent",
"grow_emergency_percent",
"grow_batch_hold_retry_secs",
"grow_watermark_check_secs",
"grow_headroom_percent",
"grow_flush_timeout_secs",
];
/// The refusal of one of [`GROW_KNOBS`], by name.
pub fn grow_knob(key: &str) -> String {
format!(
"`{key}` is retired: it tuned the appliance grow batcher, deleted 2026-11-02. A box grows through \
the v2 pull path (a signed grow go-ahead, Fleet.Ask{{grow_set}}) and nothing reads the key; delete the line"
)
}
pub const TWIN_GROW: &str = "`twin_grow` ([products.<id>.twin_grow]) is retired: it drove the twin lane of the appliance \
grow batcher, deleted 2026-11-02. A twin grows through the v2 pull path like main; delete the \
[products.<id>.twin_grow] table";
pub fn twin_grow<'de, D: serde::Deserializer<'de>>(_: D) -> Result<Gone, D::Error> {
Err(D::Error::custom(TWIN_GROW))
}
}
#[cfg(test)]
mod retired_product_keys {
use super::*;
/// **RA2-16: `plans` and `return_hosts` are refused BY NAME.** Both fed only the old
/// `/kop` purchase page, deleted in OD6 (2026-10-26). They were still parsed, and since a
/// `[products.<id>]` block takes unknown keys, deleting the fields alone would leave a
/// config that sets them told nothing. So each is refused, naming the key and what to do.
#[test]
fn a_product_block_that_sets_plans_or_return_hosts_is_refused_by_name() {
let ret = Config::from_toml(
"[products.holger]\ninstance = \"h\"\nreturn_hosts = [\"holger.rs\"]\n",
)
.unwrap_err()
.to_string();
assert!(
ret.contains("return_hosts") && ret.contains("delete"),
"{ret}"
);
let plans = Config::from_toml("[products.holger]\ninstance = \"h\"\n[products.holger.plans.\"5gib\"]\ncaps = { disk_bytes = 5 }\nlabel = \"5 GiB\"\n")
.unwrap_err()
.to_string();
assert!(
plans.contains("plans") && plans.contains("signups") && plans.contains("delete"),
"{plans}"
);
}
/// The feed's `plugin.<id>.toml` is read as a bare block, not through `Config`: the
/// refusal sits on the block itself, so that path names the key too.
#[test]
fn a_bare_block_refuses_them_too_and_a_clean_block_still_reads() {
let e = toml::from_str::<ProductConfig>("grants = []\nreturn_hosts = []\n")
.unwrap_err()
.to_string();
assert!(e.contains("return_hosts"), "{e}");
let e = toml::from_str::<ProductConfig>("[plans.a]\ncaps = {}\n")
.unwrap_err()
.to_string();
assert!(e.contains("plans"), "{e}");
let ok = toml::from_str::<ProductConfig>("instance = \"h\"\ngrants = []\n").map(|_| ());
assert!(ok.is_ok(), "{ok:?}");
}
}
#[cfg(test)]
mod retired_grow_keys {
use super::*;
/// **The appliance grow batcher's knobs are refused BY NAME** (lane mon-tidy, after the grow
/// cut of 2026-11-02). The batcher they drove is deleted; a deployment file that still sets
/// one would load and be told nothing. Each one fails the load, naming the key and saying
/// delete the line, as `plans` and `return_hosts` do (RA2-16). The daily boundary
/// (`grow_batch_every_secs`, `grow_batch_at_secs`) still acts and still reads.
#[test]
fn a_config_that_sets_a_deleted_grow_knob_is_refused_by_name() {
for line in [
"grow_batch_window_secs = 10",
"grow_batch_max_wait_secs = 0",
"grow_min_interval_secs = 3600",
"grow_trigger = \"used\"",
"grow_high_water_percent = 80",
"grow_low_water_percent = 65",
"grow_emergency_percent = 90",
"grow_batch_hold_retry_secs = 300",
"grow_watermark_check_secs = 60",
"grow_headroom_percent = 0",
"grow_flush_timeout_secs = 120",
] {
let key = line.split(' ').next().unwrap();
let e = Config::from_toml(&format!("{line}\n"))
.unwrap_err()
.to_string();
assert!(e.contains(key) && e.contains("delete"), "{key}: {e}");
}
let e = Config::from_toml("[products.gunnar]\nendpoint = \"x\"\n[products.gunnar.twin_grow]\ncloud = \"upcloud\"\n").unwrap_err().to_string();
assert!(e.contains("twin_grow") && e.contains("delete"), "{e}");
let e = toml::from_str::<ProductConfig>("[twin_grow]\nstep_gb = 20\n")
.unwrap_err()
.to_string();
assert!(e.contains("twin_grow"), "{e}");
}
#[test]
fn the_daily_boundary_still_reads() {
let cfg = Config::from_toml("grow_batch_every_secs = 86400\ngrow_batch_at_secs = 7200\n[products.gunnar]\nendpoint = \"x\"\n[products.gunnar.grow]\ncloud = \"upcloud\"\n").unwrap();
assert_eq!(
(cfg.grow_batch_every_secs, cfg.grow_batch_at_secs),
(86_400, 7_200)
);
assert!(cfg.products["gunnar"].grow.is_some());
}
}
#[cfg(test)]
mod twin_config_tests {
use super::*;
/// **`restoring_from_twin` is a key a config file can set**, and until
/// 2026-09-23 it was not: the gunnar plugin had the builder, its own
/// refusal named the key in those words, and NOTHING ever parsed it — so a
/// deployed restore could never run and an operator following the refusal
/// wrote a line the server ignored.
///
/// Both builds read it now: the linked plugin through
/// `ProductBuild::restoring_from_twin`, and a guest through the `grpc`
/// door's allow-list — named once, not twice.
#[test]
fn the_twin_is_a_key_a_deployment_can_actually_set() {
let cfg = Config::from_toml(
"[products.gunnar]\nendpoint = \"127.0.0.1:50051\"\nrestoring_from_twin = \"10.0.0.2:50051\"\n",
)
.expect("a block naming a twin parses");
assert_eq!(
cfg.products["gunnar"].restoring_from_twin.as_deref(),
Some("10.0.0.2:50051")
);
// Absent is the default, and it is a deployment with no standby —
// never a product that cannot have one.
let none = Config::from_toml("[products.gunnar]\nendpoint = \"x\"\n").unwrap();
assert_eq!(none.products["gunnar"].restoring_from_twin, None);
}
/// ★ Review #20: a product's bearer never reaches Debug output (a panic
/// message, a `{cfg:?}` in a log line).
#[test]
fn a_bearer_token_never_reaches_debug_output() {
let cfg = Config::from_toml(
"[products.holger]\nendpoint = \"x\"\nbearer_token = \"s3cret-bearer\"\n",
)
.unwrap();
assert_eq!(
cfg.products["holger"].bearer_token.as_deref(),
Some("s3cret-bearer"),
"the plugin still gets it"
);
let shown = format!("{cfg:?}");
assert!(
!shown.contains("s3cret-bearer"),
"the bearer is in Debug output:\n{shown}"
);
}
/// ★ Review #20: a `monetize.toml` that holds a bearer is a secret file. Read
/// by others (0644), it is refused at load, by name; 0600 loads.
#[test]
fn a_config_holding_a_bearer_must_not_be_readable_by_others() {
use std::os::unix::fs::PermissionsExt as _;
let dir =
std::env::temp_dir().join(format!("monetize-config-bearer-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("monetize.toml");
std::fs::write(
&path,
"[products.holger]\nendpoint = \"x\"\nbearer_token = \"s3cret-bearer\"\n",
)
.unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
let err = Config::load(&path)
.expect_err("a world-readable bearer must be refused")
.to_string();
assert!(
err.contains("bearer_token") && err.contains("0600"),
"{err}"
);
assert!(
!err.contains("s3cret-bearer"),
"the refusal must not print the secret: {err}"
);
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
assert!(Config::load(&path).is_ok(), "0600 loads");
// Without a bearer the file holds no secret and 0644 is fine.
std::fs::write(&path, "[products.holger]\nendpoint = \"x\"\n").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
assert!(Config::load(&path).is_ok(), "no bearer, no secret");
let _ = std::fs::remove_dir_all(&dir);
}
/// **G8: `[products.<id>] signing_key` pins the key a product's estates
/// already trust**; every other product signs with its key derived from the
/// root. A named file that is not there is refused by the block's name —
/// a server that silently signed gunnar's facts with a fresh key would be
/// refused by every gunnar estate, one customer at a time.
#[test]
fn a_product_signs_with_its_pinned_key_or_its_derived_one_and_a_missing_pin_is_refused() {
let dir = std::env::temp_dir().join(format!(
"monetize-g8-keys-{}-{:?}",
std::process::id(),
std::time::SystemTime::now()
));
std::fs::create_dir_all(&dir).unwrap();
let old = monetize::Signer::from_bytes(&[9u8; 32]);
old.write_to(&dir.join("gunnar.key")).unwrap();
let root = monetize::Signer::from_bytes(&[6u8; 32]);
let cfg = Config::from_toml(&format!(
"[products.gunnar]\nsigning_key = \"{}\"\n[products.holger]\n",
dir.join("gunnar.key").display()
))
.unwrap();
let ring = cfg.keyring(root.clone()).expect("the pinned key is there");
assert_eq!(
ring.product("gunnar").public_key(),
old.public_key(),
"gunnar keeps its key"
);
assert_eq!(
ring.product("holger").public_key(),
monetize::Keyring::derive(&root, "holger").public_key(),
"holger's is derived"
);
assert_ne!(ring.product("holger").public_key(), root.public_key());
let missing = Config::from_toml(&format!(
"[products.gunnar]\nsigning_key = \"{}\"\n",
dir.join("nope.key").display()
))
.unwrap();
let e = format!(
"{:#}",
missing
.keyring(root)
.err()
.expect("a missing pinned key is refused")
);
assert!(
e.contains("[products.gunnar]") && e.contains("signing_key") && e.contains("nope.key"),
"{e}"
);
let _ = std::fs::remove_dir_all(&dir);
}
/// **G15: `plugin = …` names what serves a block**; an empty one names
/// nothing and is refused at load.
#[test]
fn a_block_names_its_plugin_and_an_empty_plugin_is_refused() {
let cfg =
Config::from_toml("[products.gunnar]\n[products.gunnar-test2]\nplugin = \"gunnar\"\n")
.unwrap();
assert_eq!(
cfg.products["gunnar-test2"].plugin.as_deref(),
Some("gunnar")
);
assert_eq!(cfg.products["gunnar"].plugin, None);
let e = Config::from_toml("[products.gunnar-test2]\nplugin = \"\"\n")
.expect_err("an empty plugin is refused")
.to_string();
assert!(
e.contains("[products.gunnar-test2]") && e.contains("plugin"),
"{e}"
);
}
}