Repository navigation
Conversation
`org.hypercerts.funding.receipt` is in RATE_LIMITED_WRITE_COLLECTIONS, but `PUT /api/groups/[groupDid]/funding` created it through the group BFF without ever calling the limiter, so group-authored receipts were unlimited. The per-DID limiter is applied by the xrpc proxy, which every group BFF route bypasses by design -- they proxy through `app.certified.group.repo.createRecord` rather than `com.atproto.repo.createRecord`. Any group route writing a registry-listed collection therefore has to enforce the limit itself. Enumerating the nine group routes that create records, exactly two write a registered collection: `badge.award` (limited in 5c25dd8) and `funding.receipt` (this). The other seven write collections that are not rate-limited anywhere, so there is no further gap. A receipt names a third party in `from`/`to`, which is why the collection is registered at all -- the caps exist to "blunt a scripted flood of fake receipts naming a target". CGS enforces owner/admin on the route, so an attacker needs to be an owner or admin of some group; that is the same posture the endorse route had before it was hardened. Rather than paste a third copy of the check, extracted `enforceWriteRateLimit` in lib/auth/rate-limit.ts: look the scope up by collection, count against the acting operator, return a 429 or null. It reuses the module's existing `rateLimitResponse`, which already emitted exactly the body and headers the endorse route was hand-rolling. Errors go to an `onError` callback rather than a logger import, so the module stays dependency-light and the route logs through its own pipeline -- the convention the neighbouring `enforceRateLimit` documents. The contract test is the point of the change. Both sides of this coupling look correct alone -- the registry is right, each route is locally sensible -- and only the pair is wrong, which is why the gap survived review of the route and of the registry separately. write-rate-limit- contract.test.ts scans the group routes and fails when one writes a registered collection without calling the limiter, naming the route and the collection. That test earned its keep immediately: the first draft passed with the funding limiter deleted, because a leftover unused import satisfied a bare substring check. It now strips imports and matches a call. Route suites mock the limiter, so its 429 shaping and fail-open behaviour are covered directly in lib/auth/__tests__/rate-limit.test.ts against the real helper with only `getRedis` stubbed. Both route suites gained a test that validation runs first, so a malformed request cannot be used to burn an operator's budget. Full suite 1285 passing across 153 files. tsc and eslint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGztrq3uQXqJeCrnyt5DbP
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueThanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Contributor
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014juaXdXHsspZbspkfjGtJ6
An attachment thumbnail in the update form was built from a com.atproto.sync.getBlob proxy URL as soon as the upload returned. A PDS keeps an uploaded blob in temp storage until a record references it, so that request 404s for anything the user just picked and the chip renders as a broken image until the update is saved. Bridge the gap with a CID-keyed map of local object URLs, the same fix LeafletImageStorage.pendingBlobs already applies to images placed in the rich-text body. Saved attachments still resolve through getBlob, so edit mode is unchanged. The cache is keyed off the CID resolveAttachment reports rather than blob.ref.$link, since the resolver also unwraps the map[$link:...] string ref form that the render path looks up. URLs are freed on unmount rather than per removal: blobs are content-addressed, so the same image attached twice shares one CID and one URL, and revoking on the first removal would break the surviving chip. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014juaXdXHsspZbspkfjGtJ6
holkexyz
marked this pull request as ready for review
September 7, 2026 10:03
This branch was successfully deployed
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.
Two independent fixes. The rate-limit work below is unchanged; the attachment preview fix was added on 2026-09-07.
1. Rate-limit group funding receipts (HYPER-575)
org.hypercerts.funding.receiptis listed inRATE_LIMITED_WRITE_COLLECTIONS, butPUT /api/groups/[groupDid]/fundingcreated that record through the group BFF without ever calling the limiter — so group-authored funding receipts were unlimited. Closes HYPER-575.The bug
The per-DID write limiter is applied by the xrpc proxy. Every group BFF route bypasses that proxy by design: they call
app.certified.group.repo.createRecordthrough the operator's session rather thancom.atproto.repo.createRecord. So the proxy's limiter never sees them, and any group route writing a registry-listed collection has to enforce the limit itself.Enumerating the nine group routes that create records, exactly two write a collection that is in the registry:
endorse/route.tsapp.certified.badge.awardfunding/route.tsorg.hypercerts.funding.receiptThe other seven write collections that are not rate-limited anywhere, so there is no further gap. This is a single route.
Why it matters. The registry comment states the rationale: the caps are sized "low enough to blunt a scripted flood of fake receipts naming a target". A receipt names a third party in
from/to, so a flood is a reputational attack on someone who never consented to being named — which is why the collection is registered at all.CGS enforces owner/admin on the route, so an attacker needs to be an owner or admin of some group. That is the same posture the endorse route had before #245 hardened it.
The fix
PUT /api/groups/[groupDid]/fundingnow enforces the limit, counted against the acting operator rather than the group, so one operator cannot launder a flood through a group account. That is the same key the xrpc proxy uses on the personal path, so the two share one budget rather than offering two.Rather than paste a third copy of the check, this extracts
enforceWriteRateLimitinsrc/lib/auth/rate-limit.ts: look the scope up by collection, count against the given DID, return a 429 ornull. It reuses the module's existingrateLimitResponse, which already emitted exactly the body and headers the endorse route was hand-rolling — soendorse/route.tsloses 33 net lines and both routes now read as three.Errors go to an
onErrorcallback rather than a logger import, so the module stays dependency-light and each route logs through its own pipeline. That is the convention the neighbouringenforceRateLimitdocuments ("we don't import logSafe here to keep this module dependency-light").Fail-open is unchanged on both routes: a limiter backend error logs and allows the write. This is hardening, not an authorisation gate.
The contract test is the point
Both sides of this coupling look correct alone — the registry is right, each route is locally sensible — and only the pair is wrong. That is why the gap survived review of the route and of the registry separately, and why finding it took enumerating all nine routes rather than reading any one of them.
src/app/api/groups/__tests__/write-rate-limit-contract.test.tsscans the group routes and fails when one writes a registry-listed collection without calling the limiter, naming the route and the collection in the failure. Add a group route that writes a registered collection and it fails until you add the limiter. Same shape as the existingsrc/app/api/indexer/__tests__/operation-contract.test.ts.Worth recording: the first draft of that test passed with the funding limiter deleted. A leftover unused
import { enforceWriteRateLimit }satisfied a bare substring check, so the test was inert and would have shipped looking like coverage. It now strips imports and comments before scanning and matches an actual call. Every guard here was mutation-checked for that reason — reverting the fix must turn the test red, and does.Tests
write-rate-limit-contract.test.ts— the route-to-registry pair, above.src/lib/auth/__tests__/rate-limit.test.ts—enforceWriteRateLimititself against the real helper with onlygetRedisstubbed: unregistered collection touches no bucket, under-cap allows, over-cap returns 429 withRetry-AfterandX-RateLimit-Reset, and a backend throw fails open while reporting toonError. The route suites mock the limiter, so this is the only place that behaviour is real. Also pins the registry entries themselves, keyed off the lexicon constant the write paths use — every route suite that touches either path mocks the registry wholesale, so a rename would otherwise unlimit those creates on both paths with the suite still green.Full suite: 1285 passing across 153 files.
tsc --noEmitandeslintclean.Not in scope
Rate-limiting the other seven group routes. Their collections are not in the registry, and deciding whether any of them belongs there is a separate question from closing a bypass that already exists.
2. Preview freshly uploaded update attachments from the local file
From a user report: uploading an image to an Update sometimes showed a broken-image thumbnail instead of a preview.
The bug
The attachment chip in the update form built its thumbnail from a
com.atproto.sync.getBlobproxy URL as soon as the upload returned:A PDS keeps an uploaded blob in temp storage until a record references it. At that point in the flow the update record has not been written yet —
handleFileuploads and pushes the blob intocontent[], but the write only happens on submit. So the PDS answersBlobNotFoundand the browser renders its broken-image icon.It reads as intermittent for four reasons, none of which are randomness:
getBlobat all varies by PDS version and blobstore backend.targetDid !== ownDid) takes the unauthenticatedproxyPublicGetBlobpath instead of the session-bound agent, which is strictly less likely to serve a temp blob.Non-image attachments were unaffected — they render a text label, never an
<img>.The fix
The codebase already documents this exact failure mode and solves it one directory over.
LeafletImageStorage.pendingBlobsbridges the gap for images placed in the rich-text body, keying blob CID to a local object URL. The attachment list never got that bridge —update-form.tsxcontained zerocreateObjectURLcalls.This applies the same pattern to the attachment chips. Saved attachments still resolve through the getBlob proxy, so edit mode is unchanged.
Two details worth a reviewer's attention:
resolveAttachmentreports, notblob.ref.$link.extractContentBlobCidalso unwraps amap[$link:...]string ref form, where.ref.$linkisundefined. The render path looks up through that same resolver, so keying off the raw ref would silently miss for that shape. There is a test for it.Scope was verified by grep:
update-form.tsxis the only pre-savebuildAvatarUrlFromCidcall site. Every other one renders an already-saved record.Tests
src/components/context/__tests__/update-form-attachment-preview.test.tsx— five cases: a new upload previews from the local file, themap[$link:...]ref form resolves to the same key, edit-mode attachments still use getBlob, non-image attachments create no object URL, and unmount revokes.Checked for sensitivity rather than assumed: reverting only the render line makes two of them fail with
expected '/api/xrpc/com/atproto/sync/getBlob?di...' to be 'blob:local-preview'— the reported symptom, reproduced.Combined verification
npx tsc --noEmitcleannpm run lintclean, no warningsnpm test— 1290 passing across 154 filesc1caff09on this branch is a graph-only sync merge ofmainintostaging, with no content change.Left as Draft, not merged.
🤖 Generated with Claude Code
https://claude.ai/code/session_01KGztrq3uQXqJeCrnyt5DbP
https://claude.ai/code/session_014juaXdXHsspZbspkfjGtJ6