Skip to content

feat(cryptify): move the upload limits and rolling window into config, and serve the default tier's on GET /limits - #392

Merged
rubenhensen merged 4 commits into
mainfrom
feat/386-limits-config
Aug 31, 2026
Merged

feat(cryptify): move the upload limits and rolling window into config, and serve the default tier's on GET /limits#392
rubenhensen merged 4 commits into
mainfrom
feat/386-limits-config

Conversation

@dobby-coder

@dobby-coder dobby-coder Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Closes #386.

Five config keys

per_upload_limit, rolling_limit, api_key_per_upload_limit,
api_key_rolling_limit and rolling_window_days are optional on
RawCryptifyConfig and resolved on CryptifyConfig, following chunk_size.
They default to 5 GB / 5 GB / 100 GB / 100 GB / 14 days, which is what the five
consts in store.rs held, so no deployment changes behaviour. Those consts are
gone.

The window is configured in days. CryptifyConfig carries both
rolling_window_days() and rolling_window_secs(), and the doc comment on the
field records what the ticket asked it to: changing the window reinterprets
usage already recorded, which changing a byte limit never does.

Every reader goes through an accessor

The per-upload branch, the rolling branch, the {}-day in the rolling 413's
message, and GET /usage, which held its own copy of the API-key tier's numbers
independently of both branches. That third authority is most of why the ticket
exists; usage now takes &State<CryptifyConfig> like everything else.

The window reaches the store through Store::with_idle_ttl, which grows a
rolling_window_secs: i64 parameter, and on to StateDb and prune_records.
store.rs takes the value and never fetches it: no config is read there.

GET /limits

Unauthenticated, mounted in api_routes() and added to api-description.yaml.

{"per_upload_limit_bytes":5000000000,"rolling_limit_bytes":5000000000,"window_days":14}

Default tier only. No guard, no Option<ValidatedApiKey>, nothing per-tenant,
so there is no path by which the body could vary with Authorization. It
inherits the global CORS fairing and needs no preflight. rolling_limit_bytes
is deliberately not /usage's limit_bytes, since the two report different
tiers.

Tests

served_per_upload_limit_is_the_enforced_one and
served_rolling_limit_is_the_enforced_one read /limits unauthenticated, drive
an upload past each cap, and assert the 413's limit_bytes equals what was just
served. The two numbers are compared against each other, not against a literal.

limits_are_byte_identical_for_every_caller compares the anonymous body against
one sent with an API key the mock pg-pkg really validates, checked by a 200 from
/usage with the same key first.

limits_serve_the_default_tier_only configures all four byte limits to distinct
values and pins both which pair is served and that the body has exactly three
keys.

a_configured_rolling_window_reaches_limits_and_the_store sets the window to 3
days and asserts /limits reports it and get_usage's oldest_expires_at
moves with it.

Five config tests, one per key: the default when the key is absent, the
configured value when it is set.

Test 3 shown red

As the acceptance check asks. Giving limits an ApiKey guard and returning
the API-key tier when a tenant resolves:

assertion `left == right` failed: /limits must not vary by Authorization, in status or in body
  left: (Status { code: 200 }, "{\"per_upload_limit_bytes\":5000000000,\"rolling_limit_bytes\":5000000000,\"window_days\":14}")
 right: (Status { code: 200 }, "{\"per_upload_limit_bytes\":100000000000,\"rolling_limit_bytes\":100000000000,\"window_days\":14}")

Restored afterwards. The committed tree is the one that passes.

Where the deleted consts' test readers get their number

Per the amendment on the issue. The store tests pass the window in as the new
parameter and compute their expectations from that same binding; the main.rs
test that asserted against ROLLING_LIMIT reads it off the CryptifyConfig its
figment already built. No test-local const. The tests that never mention the
window pass it at the call site rather than through a shared name.

One anchor in mod api_gate_tests had to move

narrow_a_response_property_type anchored on

                  window_days:
                    type: "integer"

which /limits now also matches, so once failed on a non-unique anchor: the
module's own trap firing as designed. The anchor is now USAGE_WINDOW_DAYS,
which takes in the per_upload_limit_bytes line that follows it in /usage.
Same mutation, same verdict.

Scope

cryptify/CLAUDE.md gained a line about where the numbers live now, since the
old one said the store prunes on a 14-day window and the store no longer knows.
Nothing else moved: not metrics.rs, not COMPATIBILITY.md, nothing under
.github/workflows/, not the website. The companion
website ticket in postguard-js is unblocked by this one.

Verification

cargo test -p cryptify (219 passed), cargo clippy -p cryptify --all-targets -- -D warnings, cargo fmt --check and cargo test --workspace are all green.
oasdiff is not installed in this container, so the gate's mutation test
skipped locally; its anchor assertions ran and pass.

🤖 Generated with Claude Code

Reviewed by dobby: the code-comments rule over the full diff (no breach — config.rs, store.rs and main.rs all comment at their files' measured rate), plus a correctness/design pass; 4 findings, all 4 fixed in cbad854, d6b1434 and 643249dcargo test -p cryptify (220 passed), cargo test --workspace, cargo clippy -p cryptify --all-targets -- -D warnings and cargo fmt --check all green — approve.
Note: the Scope section above no longer says conf/config.toml is untouched; the five keys are documented there now, which is one of the four fixes.

The four byte limits and the rolling window were consts in `store.rs`, and
`GET /usage` hardcoded the API-key tier's numbers a third time, independently
of the two enforcement branches. Nothing served the default tier's limits at
all: `/usage` is API-key gated, so the website kept its own copy of them.

`per_upload_limit`, `rolling_limit`, `api_key_per_upload_limit`,
`api_key_rolling_limit` and `rolling_window_days` are now `CryptifyConfig`
keys, defaulting to the values the consts had, so no deployment changes
behaviour. Every reader goes through an accessor, including `/usage`. The
window is configured in days and reaches the store as a parameter on
`Store::with_idle_ttl` and `prune_records`: `store.rs` takes the value, it
does not fetch it.

`GET /limits` is new and unauthenticated. It serves the default tier's two
limits and the window, takes no credential guard, and carries nothing
per-tenant, so its body is the same bytes for every caller.

Closes #386

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dobby-coder
dobby-coder Bot requested a review from rubenhensen August 31, 2026 07:55
dobby-coder Bot and others added 3 commits August 31, 2026 08:24
…g them

`conf/config.toml` gained no entry for `per_upload_limit`, `rolling_limit`,
`api_key_per_upload_limit`, `api_key_rolling_limit` or `rolling_window_days`,
so an operator reading the shipped production config had no way to discover
that the numbers moved out of the consts at all. #383 set the precedent one
commit ago by documenting `attributed_email` there; follow it. The
`rolling_window_days` block carries the caveat the field's doc comment does,
since the config file is where an operator edits it.

`api-description.yaml` stated the old constants as fixed facts in six
descriptions: `/usage`'s summary ("the last 14 days"), its `limit_bytes` and
`per_upload_limit_bytes` ("100 GB"), `/fileupload/init`'s Authorization
("100 GB", "5 GB") and both 413s. A consumer generating client copy from the
spec would show those regardless of what the deployment is configured with.
The `example:` values stay -- an example is not a guarantee. Description-only,
so oasdiff sees no breaking change, and no `mod api_gate_tests` anchor covers
these lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`StateDb` stored `rolling_window_secs` and `SharedState` stored the same
number again. `StateDb::record_usage` is the field's only reader and its only
caller, `Store::record_upload`, already holds `self.shared.rolling_window_secs`
-- it uses it two lines later for the in-memory prune. So take the window as a
parameter the way `prune_records` now does, and let `StateDb::open` go back to
taking only a path.

The field's own doc comment claimed "there is one authority for the window and
it is not here" while being a second stored authority, and the two could be
constructed independently: `StateDb::open` is called directly in tests with a
hardcoded window and no `Store`. Nothing diverged, both being set from the same
argument, but "two places hold this number" is the shape #386 set out to
remove one layer up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…quota

Both ends of `rolling_window_days` failed open, and only silently.

`0` was accepted with no validation: `prune_records`' cutoff becomes `now`, so
every recorded upload falls outside the window at once and the rolling quota
stops enforcing -- on a path whose init and chunk PUT take no credential. It
now logs a startup warning next to the `metrics_token` one. A warning rather
than a rejection: 0 is a coherent thing for an operator to ask for, it just
must not be silent. While the value was a const this state was unreachable.

`rolling_window_secs()` multiplied `days as i64` by 86_400, which wraps
silently in release for an absurd input, and a *negative* window makes
`prune_records` drop every record it sees -- the same failure, reached from the
other end. It saturates now, so a misconfiguration lands on "window far too
long" instead of "quota off", with a test over `u64::MAX` and `i64::MAX`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cryptify: move the four upload limits and the rolling window into config, and serve the default tier's on GET /limits

1 participant