Skip to content

Rate-limit group funding receipts (HYPER-575) + fix update attachment previews - #246

Merged
holkexyz merged 3 commits into
mainfrom
staging
Sep 7, 2026
Merged

holkexyz merged 3 commits into
mainfrom
staging

Conversation

@holkexyz

@holkexyz holkexyz commented Sep 1, 2026 •

Copy link
Copy Markdown
Member

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.receipt is listed in RATE_LIMITED_WRITE_COLLECTIONS, but PUT /api/groups/[groupDid]/funding created 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.createRecord through the operator's session rather than com.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:

Route Collection Limited before this PR
endorse/route.ts app.certified.badge.award yes, as of #245
funding/route.ts org.hypercerts.funding.receipt no

The 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]/funding now 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 enforceWriteRateLimit in src/lib/auth/rate-limit.ts: look the scope up by collection, count against the given DID, 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 — so endorse/route.ts loses 33 net lines and both routes now read as three.

Errors go to an onError callback rather than a logger import, so the module stays dependency-light and each route logs through its own pipeline. That is the convention the neighbouring enforceRateLimit documents ("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.ts scans 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 existing src/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 — enforceWriteRateLimit itself against the real helper with only getRedis stubbed: unregistered collection touches no bucket, under-cap allows, over-cap returns 429 with Retry-After and X-RateLimit-Reset, and a backend throw fails open while reporting to onError. 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.
  • Both route suites gained a test that validation runs before the limiter, so a malformed request cannot be used to burn an operator's budget.

Full suite: 1285 passing across 153 files. tsc --noEmit and eslint clean.

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.getBlob proxy URL as soon as the upload returned:

const imgUrl = a.kind === "image" ? buildAvatarUrlFromCid(targetDid, a.cid) : null

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 — handleFile uploads and pushes the blob into content[], but the write only happens on submit. So the PDS answers BlobNotFound and the browser renders its broken-image icon.

It reads as intermittent for four reasons, none of which are randomness:

  1. Re-opening a saved update round-trips attachments that are referenced, so those thumbnails load — same form, different result.
  2. Saving and returning makes it correct itself.
  3. Whether an unreferenced temp blob is readable through getBlob at all varies by PDS version and blobstore backend.
  4. A group update (targetDid !== ownDid) takes the unauthenticated proxyPublicGetBlob path 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.pendingBlobs bridges 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.tsx contained zero createObjectURL calls.

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:

  • Keyed off the CID resolveAttachment reports, not blob.ref.$link. extractContentBlobCid also unwraps a map[$link:...] string ref form, where .ref.$link is undefined. 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.
  • Object URLs are freed on unmount, not per removal. Blobs are content-addressed, so attaching the same image twice yields two entries sharing one CID and therefore one URL. Revoking on the first removal would break the surviving chip.

Scope was verified by grep: update-form.tsx is the only pre-save buildAvatarUrlFromCid call 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, the map[$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 --noEmit clean
  • npm run lint clean, no warnings
  • npm test — 1290 passing across 154 files
  • The four CLAUDE.md UI greps silent (neither change touches CSS)

c1caff09 on this branch is a graph-only sync merge of main into staging, 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

`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
@coderabbitai

coderabbitai Bot commented Sep 1, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@vercel

vercel Bot commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
certified-app Ready Ready Preview Sep 7, 2026 9:39am UTC
certified-app (staging) Ready Ready Preview Sep 7, 2026 9:39am UTC

Request Review

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 holkexyz changed the title fix(groups): rate-limit group funding receipts Rate-limit group funding receipts (HYPER-575) + fix update attachment previews Sep 7, 2026
@holkexyz
holkexyz marked this pull request as ready for review September 7, 2026 10:03
@holkexyz
holkexyz merged commit d112c0d into main Sep 7, 2026
7 checks passed

This branch was successfully deployed

1 active deployment
staging — 3feb7d00 Deployed Sep 7, 2026 by vercel[bot]
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.

1 participant