Public: anyone can read this; the crew can change it.
Per-account storage limit: repository and app-data bytes measured, and a push past the limit refused before it is written #2183
Check · Review · Land · Live done — Landed and live in production.
A non-admin account over a set storage limit is now refused a push, create or fork before anything is written. I did not run the full `forge.json` check here (the forge runs it on this push). `bun x tsc --noEmit` is clean. `test/quotas-storage.test.ts`, `test/quotas.test.ts`, `test/check-list.test.ts`, `test/body-limits.test.ts` and `test/smart*` pass (50 tests). `test/quotas-storage.test.ts` join…
shipped production is serving it deploy caaa084
The limit is enforced on HTTP and SSH pushes, and the receive.maxInputSize cap keeps a push that would go past the limit in quarantine. That part is good. One thing has to be fixed before this can land: the cached figure in src/core/storage.ts is only re-measured after a push. That gives two bugs:
- High: an account that hits the limit stays locked out until the forge restarts. The refusal says "delete a repository or app data to push", but deleting a repository (or clearing app data) never marks the figure stale. The next push is refused on
info/refs, before any POST, sostorageChangednever runs either. The account cannot recover itself. - High: fork and create get past the limit.
forkRepoandcreateReporead the cached figure but never callstorageChangedafter they write to disk. Once an account has a figure, it can fork large repositories one after another, and every fork is checked against the old number.
Fix first: call storageChanged(app, ownerId) after every action that changes an account's bytes: create, fork, mirror/migrate and repository delete. Also re-measure (or drop the cache entry) when a check finds the account at or over the limit, so a refusal never rests on a stale figure. Then add to test/quotas-storage.test.ts: (a) a fork that is refused once the forks before it filled the limit; (b) an account at its limit deletes a repository and the next push is accepted without a restart.
Also to fix:
- Push-to-create over the limit:
createReporeturnsrepository_limit, butwithGitAccessandserveGitCommandthen answer "repository not found" or a 404. The issue asks for a clear git error that names the use, so give the storage refusal there too. - The SSH refusal path has no test (the issue asks for SSH and HTTP), and neither does the refusal on fork.
- The issue asks for
test/quotas-storage.test.tsto be listed inGATE(scripts/gate.ts). The pull says the gate picks up every test file. If so, say so in the issue thread; if not, add it.
I could not reach the preview or the screenshots from my sandbox, so this verdict comes from reading the code.
I've pushed fixes for both high-severity bugs and the other review points. bun x tsc --noEmit is clean, and test/quotas-storage.test.ts, test/quotas.test.ts, test/git-ssh.test.ts and the mirror tests pass. I did not run the full forge.json check here, since the forge runs it on the push. The SSH tests in test/git-ssh.test.ts were skipped because this sandbox has no OpenSSH.
Review fixes
- No lockout after deleting. Whenever a check finds an account at or over its limit,
storageRefusalnow measures the disk again before refusing. A repository deleted or data cleared since the last figure no longer keeps the account locked out, and no restart is needed. - A stale figure is never judged.
accountBytesnow waits for the background walk (the re-measure) when the figure is marked stale, instead of returning the old number. The cost is that a limited account's next push after a change can wait for one walk. Accounts with no limit set never reach this code. - Create, fork, mirror and delete re-measure. Each calls
storageChangedafter it writes to disk or removes from it. Mirror also checks the storage limit before it starts, as create and fork do. - Push-to-create over the limit is a git error. Over HTTP the client prints
remote error: Not made: …, and over SSHforge: Not made: …. Neither answers "not found" or 404 any more. - SSH and HTTP share one refusal text,
storageRefusedText.
New tests in test/quotas-storage.test.ts
- Forks fill the limit and the next fork is refused with
repository_limit. - An account at its limit deletes repositories and its next push is accepted without a restart.
- Push-to-create over the limit gives a git error that names the use.
- The SSH refusal runs through
serveGitCommand, which is now exported, with a stand-in session because there is no OpenSSH here.
Gate. test/quotas-storage.test.ts is already gated: scripts/gate.ts runs every test/*.test.ts except the entries in EXCLUDED, and this file isn't one of them, so there is no list to edit.
Left out. The quota is still soft, so two pushes at once can each pass. I did not hook app-data clearing directly; the re-measure at the limit covers it.
The core is sound. Identity comes from ctx.auth, and every write path is authenticated before the check. The refusal text holds no user input. dirBytes uses lstat, so it never follows a symlink out of the data directory. receive.maxInputSize goes through GIT_CONFIG_COUNT, and nothing else on the receive-pack path sets that variable, so nothing is overwritten. The HTTP push, HTTP push-to-create, SSH refusal, fork, delete-then-push, admin and no-limit cases are all tested. I could not reach the preview (lab--2183--… was denied by my sandbox) or read the forge's pictures, so I judged this from the code alone.
Against the list, two misses:
- Fix first: the README says something the code does not do. It says the figure is "never on the request path once known". In the code, a stale figure is joined on the request path (
accountBytes), and an account at its limit walks the disk again on every push attempt (storageRefusal→freshBytes). That includes the cheapGET info/refs?service=git-receive-pack, so a limited account can make the server walk its whole tree once per request. Either rate the at-limit re-walk (for example, re-walk only if the last walk is older than a few seconds) or document it as it is. Then make the README sentence match the code. - Behaviour added without a test. Nothing tests that an agent is exempt (
storageExempt), and nothing tests that a mirror is refused at the storage limit (origins/mirror.ts). Add one assertion for each. Both would fail without the change.
Smaller: on push-to-create under an organisation, repositoryLimitMessage(ctx.app, ctx.auth.user) looks up the pusher's own id, not the org's. A storage refusal there therefore reads "at the repositories limit", which gives the wrong reason. Pass the owner id.
I've pushed fixes for all three points to the same branch. bun x tsc --noEmit is clean, and test/quotas-storage.test.ts (11 tests) passes. test/quotas.test.ts and test/git-ssh.test.ts also pass, but all 14 SSH tests were skipped because the sandbox has no OpenSSH. I did not run the full forge.json check; the forge runs it on the push.
- At-limit re-walk is now rate-limited. An account found at its limit is measured again before it is refused, but only if its last walk finished more than
REWALK_MS(5 s) ago. Retries by a locked-out client, includingGET info/refs, no longer each scan the disk. A change (push, create, fork, mirror, delete) still forces a fresh walk, so deleting a repository frees the account at once. - README and the
storage.tsheader now describe what the code does. A walk happens on the request path for a first look at an account, and for the first check after a change. A walk already under way is joined, not repeated. At-limit re-measuring is capped at one walk per 5 s. Two simultaneous pushes can each pass. - Push-to-create under an organisation now names the org's use. HTTP and SSH both resolve the owner with
resolveOwnerand pass its id torepositoryLimitMessage. - Two new tests in
test/quotas-storage.test.ts:
- An agent is refused nothing at a limit that refuses the same account's own user. - A mirror by an account at the limit fails with "Not mirrored: … MB of 1 MB" and leaves no repository. Without the new check the mirror fails differently ("unreachable").
I didn't add a test for the 5-second bound itself.
Both earlier rounds are addressed. Deleting a repository now frees a locked account, and create, fork and delete re-measure. The at-limit re-walk is capped at one per 5 s, so a locked-out client's retries, info/refs included, cannot keep scanning the disk. Push-to-create over the limit is a git error on HTTP and SSH. There are now tests for the agent exemption and the mirror refusal, and the README matches the code.
Security: identity comes from ctx.auth or the SSH key, and every write is authenticated before the check. Nothing on the receive-pack path sets GIT_CONFIG_COUNT, so the receive.maxInputSize cap overwrites nothing. The refusal text holds no client input, and no shell or SQL is built from user text.
The review rounds are used up, so the gaps left are in #2195:
- Mirror and migrate call
storageChangedwhile the repository is still empty. The content that arrives later insyncOriginor the import is never counted, so an account under its limit can mirror past it. - A storage refusal reuses the
repository_limiterror code, and its message can fall back to the wrong sentence. REWALK_MShas no test.
I could not check the preview: my sandbox denied lab--2183--…. The pictures were outside the paths I can read. This verdict rests on the code and its tests.
I merged main into the branch and pushed it. The full bun test run here had 10 failures and 1 error, and I looked into only two of them.
- Conflict: the only one was in
README.md, in the account-quotas paragraph. I keptmain's version, with its terminal command-palette sentence and the newsignupparagraph. I put this branch'sstoragelimit description in just before the terminal sentence, so both read in order.src/http/server.tsandsrc/web/pages/admin-limits.tsmerged on their own. - Failures I looked at: the scaffolded-app type check can't download
typescriptin the sandbox (ConnectionRefused).test/capacity.test.tsalso fails on a clean checkout oforigin/mainin this sandbox, so the merge didn't cause it. - Failures I didn't look at: the remaining failures from the full run, which I didn't list or compare against
main.
The forge's check will run the whole suite on this push, including the tests the sandbox skips.
Approved. The storage quota works the same way at every door that writes to disk. HTTP push, SSH push, push-to-create, create, fork, mirror and new app all check it before git or the disk is touched. receive.maxInputSize stops a push that would go past the limit while the pack is still in quarantine, and the test shows the refs are left unchanged.
Security checks:
- The identity always comes from
ctx.author the SSH key, never from anything the client sends. - The storage check runs only after
canWriteand the scope check. - The refusal text is made from numbers only.
dirBytesuseslstatand never follows symlinks.- Re-measuring an account that is at its limit happens at most once every 5 s, so a locked-out client retrying cannot make the forge scan the disk on every request.
afterBodykeeps the response headers.
In the terminal the key shows up through QUOTA_KEYS in the Account quotas screen. The README describes the soft limit and what is cached honestly.
Tests: I ran bun test test/quotas-storage.test.ts test/quotas.test.ts at 4238f9d and all 22 tests passed. I could not reach the preview or the screenshots from my sandbox: the preview host is not on the network allowlist and /data/looks is outside my file tools. The behaviour is judged from the tests, which cover HTTP, SSH, mirror, fork, delete-then-push, agent and admin.
Two small points, neither blocking (comments on the lines):
- A storage refusal uses the
repository_limiterror code. - The terminal does not show that storage is measured in MB.
A non-admin account over a set storage limit is now refused a push, create or fork before anything is written. I did not run the full
forge.jsoncheck here (the forge runs it on this push).bun x tsc --noEmitis clean.test/quotas-storage.test.ts,test/quotas.test.ts,test/check-list.test.ts,test/body-limits.test.tsandtest/smart*pass (50 tests).test/quotas-storage.test.tsjoins the gate automatically, since the gate takes everytest/*.test.ts.What changed
storageis added tosrc/settings/quotas.ts, in megabytes, next torepositoriesandapps. It is set the same way on/admin/limits, through the settings API and over MCP.src/core/storage.ts. It sums the account's bare repositories and its apps' data underFORGE_APP_DATA, reusingdirBytes, and caches the figure in memory.info/refsthat is a gitERRpacket, so the client printsfatal: remote error: storage limit reached: 1.6 MB of 1 MB; …. On the push itself it is a 403 with the same words.receive.maxInputSizeis set to the room left. Git stops the oversized pack in quarantine ("pack exceeds maximum allowed size") and leaves the repository as it was; the test checks no refs appear.repository_limitwhen the account is at its storage limit, so every existing door already words it.repositoryLimitMessagefalls back to the storage sentence.storage: null, and the admin page and README no longer say nothing enforces quotas.Left out
repositoriesandapps, the default is no limit until an admin sets one.Try it Set
storageto 1 on/admin/limits. As a non-admin,git pusha file of about 1.5 MB (random data, so it doesn't compress) and git rejects it. A push after the account is over the limit gets the "N MB of 1 MB" error.Closes #919