Conversation
Nothing described how to rebuild this. The knowledge lived in scattered commit messages and in the heads of whoever last touched the host, so if the machine died, rebuilding would have been archaeology. #246 asked for a runbook a new engineer could follow alone. Written from the install that is actually running rather than from the plan, and it says where the two diverged: #240 called for a dedicated staging VM with a Sepolia testnet Bee, and what exists is one host with two mainnet nodes. That was a cost decision, and it has a consequence worth stating rather than discovering — staging spends real BZZ, and in exchange catches things a testnet cannot. Covers VM specs and the exactly-five firewall rules, the egress check that matters because a node which cannot dial out returns 201 for uploads that never propagate, Bee bring-up and funding, the deploy and its runner, DNS and TLS, monitoring, verification, and troubleshooting. The parts that exist because they cost us something: BEE_NAT_ADDR must match the published port or the node reports itself Private with the port open, and since Bee 2.7.0 an invalid value fails startup the two RPC endpoints must differ — sharing one meant sharing its rate limit, and staging load produced 429s that broke production COMPOSE_PROJECT_NAME is pinned because Compose otherwise derives volume names from the directory path, and a deploy from elsewhere would start Bee with no keystore: a new wallet, the funded one orphaned diff the Caddyfile before overwriting — doing it blind once broke TLS check whether a runner already on the host serves other repositories; the one here also serves four, and unregistering it would have broken all of them No secrets, keys, addresses or hostnames — only what is needed, where it is stored, and how it reaches the process, per the issue's acceptance criteria. Verified: zero matches for real hostnames or credential patterns. Linked from the README's setup section, since standing up a host is a different job from running the app locally. Closes #246.
Add an install runbook for standing up a host from nothing
It should not have been added here, and that is my error rather than a change of mind. The issue asked for no secrets, keys, IPs or hostnames; the document contains none, and I treated that as the whole test for a PUBLIC repository. It is not. A runbook for this specific install describes the topology whether or not it names it: that both environments share one machine, the p2p and loopback port numbers, the firewall shape, that the Bee API is unpublished but reachable on the compose network, that a self-hosted CI runner sits on the same host, which secrets exist, and a list of our known failure modes — including that a node unable to dial out returns success for uploads that never store anything. Individually dull. Together, a map of where to look. The document itself is worth having and now lives in the private provenance space, unchanged apart from a header recording why it is there. Removing the file does NOT remove it from this repository's history. It was public from 2026-08-30T19:27Z and remains reachable via the commit and the merged pull request. Whether that warrants rewriting history is a judgement call for the maintainers, recorded on the issue rather than decided here. If a public install guide is wanted later it should be a different document: how to run the software, not how this deployment is arranged. Reopens #246 in effect — the runbook exists, in the right place.
The runbook documented OUR install, in a public repository. Wrong artifact for the audience: our topology is not useful to anyone else, and putting it there answered a question nobody outside had asked. What a public repo wants is how to run the software. That is this. Worth recording that the leak itself was smaller than it looked. Every fact the runbook exposed was already in this repository independently — the p2p and loopback ports in the compose files and Caddyfile, both environments side by side in docker-compose.host.yml, the unpublished Bee API in a comment, the self-hosted runner and secret names in deploy.yml, the reachability failure mode, and "uploads 201 without propagating" in CLAUDE.md. It aggregated rather than disclosed. That is why removing it is a tidiness decision rather than an incident, and why rewriting history would achieve nothing. The genuinely useful half survives here: the failure modes, which are properties of the software and not of our deployment. A NAT address that does not match the published port. Two nodes sharing an RPC endpoint and therefore its rate limit. An unpinned compose project name starting Bee with no keystore. Docker publishing ports through its own iptables chains and bypassing ufw. A node that cannot dial out returning 201 for uploads that never store anything. The three ways a config change can report success and do nothing. Everything that said "we do it this way" is gone; choices are presented as choices. The specifics live in the private space. Verified: zero occurrences of our hosts, ports, runner labels or addresses. Full suite: 1083 passed, 25 skipped.
Replace the install runbook with a generic deployment guide
A staging node bought 82 batches against a target of 5, spending about 8.9 BZZ. One mechanism was found and fixed and it accounts for five per restart, not seventy. The rest was never identified, and the evidence is gone: the proxy was not logging requests, the container holding the gateway's own log was replaced, and the purchase counter resets on restart with no long-term series (#271). So this does not try to find the cause. A ceiling does not need one — whatever the defect, a miscounting replenish path, a restart loop, an external caller draining the pool faster than it refills, it cannot spend past the limit. Correctness bugs stay wrong; they stop being expensive. STAMP_POOL_MAX_PURCHASES_PER_HOUR, default 10, across every depth. Far above any legitimate need — a target of five, fully drained and rebought, is five — and far below what an unbounded loop costs. Two placement decisions: Enforced inside _purchase_stamp rather than in the replenish loop. There are two callers today, the scheduled check and the immediate replenishment after an acquire, and the guard has to hold for whatever is added next. A limit the caller must remember to ask about is not a limit. Counted when Bee accepts the batch, not when it becomes usable. The money is spent at acceptance. Counting only usable batches would let a run of unusable ones spend without bound while appearing to buy nothing — which is close to the shape of the original incident. Refusals are logged at error level and surfaced in GET /api/v1/pool/status, since a pool silently declining to buy looks exactly like a pool that needs nothing. Four tests: the ceiling holds under repeated calls, an unusable purchase still counts, the refusal reaches pool status rather than only the log, and the window rolls so an old burst does not jam the pool permanently. Full suite: 1087 passed, 25 skipped.
* Give the stamp pool a daily budget per calling origin POST /api/v1/pool/acquire had no gate at all. It is absent from PROTECTED_ENDPOINTS, so it required neither payment nor the free-tier header: anyone able to resolve the hostname took a pre-bought batch. Measured on staging, 3,866 acquire calls in a day drove 40 replacement purchases, and an anonymous call took production's only batch during testing. The incentives were inverted. Buying a batch the slow way returns 402 without payment; taking a pre-bought one instantly was free. The premium path was the free one. An allowance keyed on Origin fits what is knowable here. The main consumer is a static browser app with no backend: it borrows the visitor's MetaMask, so there is no single address to allow-list and no server-held key it could sign with. Origin is the only thing a browser reliably supplies. What that is worth, stated plainly because it would otherwise be overread: a browser sets Origin itself and a page cannot forge another site's, so this does stop other WEBSITES spending the operator's postage. Anything that is not a browser can claim any origin, so the BUDGET protects the pool and the origin only selects which budget applies. A forged origin consumes that origin's allowance and no more. Attribution with a cap, not authorisation; require a payment or a signature if you need to know who is spending. The 429 carries a message written to be shown to a person rather than logged. The caller is a browser app whose user has never heard of a postage batch, so it gives the reset time and the direct-purchase alternative — what to do, not only that something failed. Consumed only after a batch is actually released, so an empty pool does not cost the caller a day's budget. Counters persist across restarts, because an in-memory count would hand out a fresh allowance on every restart, which is the shape of the incident this exists to prevent. POOL_DEFAULT_DAILY_ALLOWANCE defaults to -1, unlimited, matching the behaviour before this existed. Deploying therefore changes nothing until allowances are configured deliberately: dataprovenance-app calls this endpoint today and a limit arriving unannounced would break it (dataprovenance-app#126). Ten tests, including that a differently-written form of the same origin does not get a second budget, that a restart does not reset the count, and that a refused acquire does not spend the allowance. Full suite: 1093 passed, 25 skipped. * Offer the paid route in the exhausted-allowance message, accurately The message told the caller to purchase a stamp "directly" without saying it is payable, and the test only checked that some alternative existed. It now names x402 and the endpoint, and the test asserts both — plus that the allowance figure in the text comes from config rather than being written into the string. It deliberately does NOT offer to sell pool access, which would be the obvious thing to say and is not true. Paying for a pooled batch is not possible: the payment gate is a FastAPI dependency, and a caller with no payment header and no X-Payment-Mode: free receives 402 before the handler runs. Every current caller sends neither, so attaching it to this endpoint would break them — including dataprovenance-app, which is the reason the endpoint was left open. So the honest offer is the one that works today: POST /api/v1/stamps/ is already payable with x402 and buys a batch outright. The message says it takes about a minute to become usable, unlike a pooled one, so nobody is surprised by the latency the pool exists to remove. Paid pool access is #67 and is blocked on dataprovenance-app#126.
The allowance counted batches per origin regardless of size. Sizes differ in cost by powers of two — a depth-20 batch costs eight times a depth-17 one — so a caller could spend eight times its intended budget simply by asking for a larger size, without ever exceeding a limit. An allowance of 20 meant "20 small" or "20 medium" depending on what was asked for, which are not the same amount of money. The budget key is now (origin, size), and the limit applies to each size separately. Resolving the requested size moved above the budget check, since the check now needs it; the duplicate resolution further down is removed rather than left to drift. The 429 names the size as well as the count, so "the daily free allowance of 20 small stamps" says which budget was exhausted — a caller told only that "the allowance" ran out cannot tell that a different size is still available. Four tests: sizes hold separate budgets, a larger size cannot borrow the smaller one's, the limit applies to each size rather than being shared, and origin and size are both reported rather than conflated. Full suite: 1101 passed, 25 skipped.
Three settings added recently — the hourly purchase ceiling, and the two controlling daily allowances — were never added to deploy.yml, which writes the env files from repository variables. So setting them in GitHub had no effect whatsoever: the variable existed, the deploy succeeded, and the running gateway never saw it. This is precisely the propagation trap documented in CLAUDE.md, and I walked into it while configuring the allowance an hour after writing that page. Worth recording rather than quietly fixing: the failure is invisible from the GitHub side, because the variable is set and the deploy is green. POOL_DAILY_ALLOWANCES has no default, so an unset variable yields an empty string and no named allowances, which is the intended off state. POOL_DEFAULT_DAILY_ALLOWANCE defaults to -1 (unlimited) and STAMP_POOL_MAX_PURCHASES_PER_HOUR to 10, matching the code defaults, so a host that has configured neither behaves exactly as before. Added to both the staging and production blocks. Full suite: 1101 passed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Merge with a merge commit, not a squash — the merge base is currently correct and this shows only the 11 files that changed.
Three controls on what the stamp pool can give away and spend, plus the deploy wiring that makes them reachable.
Why
POST /api/v1/pool/acquirehad no gate at all — absent from the protected endpoint list, so neither payment nor the free-tier header was required. Anyone able to resolve the hostname took a pre-bought batch. Measured on staging: 3,866 acquire calls in a day drove 40 replacement purchases, and an anonymous call took production's only batch during testing.The incentives were inverted: buying a batch the slow way returns 402 without payment, while taking a pre-bought one instantly was free.
What lands
A daily allowance per origin and per size.
Originis what a browser reliably supplies, and the main consumer is a static browser app with no backend — it borrows the visitor's wallet, so there is no address to allow-list and no key it could sign with. Per size as well as per origin, because a depth-20 batch costs eight times a depth-17 one and a shared count would let a caller spend eight times its budget by asking for a larger size.Stated plainly: a browser cannot forge another site's origin, so this does stop other websites spending the operator's postage. Any non-browser client can claim any origin, so the budget is the control and the origin only selects which budget applies. Attribution with a cap, not authorisation.
A ceiling on purchases — 10 per rolling hour across every depth and every code path, enforced inside
_purchase_stampso no caller can miss it, counted when Bee accepts the batch rather than when it becomes usable. A staging node once bought 82 batches against a target of 5 and the cause was never identified; a ceiling does not need one.Deploy wiring. The three new settings were not written to the env files, so setting them in GitHub had no effect at all — green deploy, variable set, gateway never saw it. Exactly the propagation trap documented in CLAUDE.md, walked into an hour after writing that page.
Configuration applied with this
small=1, medium=0small=1, medium=0dataprovenance.app=20dev.dataprovenance.app=20The pool previously held a medium, which was my error: the app asks for
small, found none, and fell through to "any larger". Every request cost 0.1476 BZZ instead of 0.0184 — eight times over. At a 20/day allowance that is 2.95 BZZ/day rather than 0.37.Verified on staging
Target
{17:1}, allowance enforced, counter persisted across the request:A 409 from an empty pool does not spend the caller's budget — confirmed live.
Effect on production
Anything unconfigured keeps its previous behaviour: an unset default allowance means unlimited. The values above are already set as repository variables, so they take effect with this deploy.
dataprovenance.app relies on the
Originthe browser adds automatically, which is exactly what this uses — so it keeps working, now bounded at 20 small batches a day.Full suite: 1101 passed, 25 skipped.