Files
buzz/docs/operator-private-media.md
Brad Seiler 96af488d96 fix(media): revoke private channel attachment reads (#9077)
## Summary

Removed private-channel members can currently download attachments if
they know the URL. With `BUZZ_MEDIA_READ_MODE=enforce`, fresh GET, HEAD
and Range requests check current membership and readable references on
the primary database. Removed members receive 404; remaining members
retain access, and rejoining restores access. Thumbnail aliases use the
parent attachment’s permissions.

Publication checks also prevent copied private URLs from granting new
access. They cover attachments, link previews, profile pictures,
banners, group pictures and emoji. Persona creation and changed-avatar
edits, plus team and individual snapshot imports, transfer animated
posters and animations under the agent’s signer before profile
publication. Failed persona transfers preserve existing local records;
import failures appear in the results.

Authorization uses bounded queries, a two-second statement deadline and
transaction-local JIT suppression. An index on edit targets avoids broad
scans. Publications have a 512-reference limit, and capacity waits up to
500 ms before refusal.

Protection defaults off. Existing downloaded bytes cannot be recalled.
All modes stop creating immutable HTTP cache entries.

### Related issue

Related approaches: #7565, #5185, #8451 and #8444. This change evaluates
live stored references directly.

### Testing

Published head: `3d1aa7431cc495a001a0f13e95e2e422c5a77ab1`, rebased on
main `2cd40badb85de059a6916206e133da7a0a88fb2e`. Push hooks were
**SKIPPED** with explicit author approval. Current-head HTTP, full
package, PostgreSQL migration/media and profile-filter mutation checks
are pending; CI is pending. The earlier complete HTTP journey passed at
`f20b449262` (identical source tree), with its evidence attributed
separately. Earlier failing preview/report-fixture runs are preserved;
they do not count as passes.

The HTTP journey covers upload/publication, fresh GET/HEAD/Range
revocation and rejoin, emoji replacement, edited previews, persona
visibility and non-reference decoys. The migration regression forces an
index timeout, verifies no 0086 history or media tables, then proves a
migrator through the base's 0085 can restart.

Human attachment removal/rejoin testing passed on the pinned
`1f08a8e0fd2edf19dc94321f7258518149fe1bf6` trial. Native animated
persona edits and team/individual import acceptance remain incomplete.
That old-snapshot database stays intact and cannot run the reconciled
binary; a new trial needs a separate fresh database.

### Rollout prerequisites

- Build and verify the reference index before shadow mode. Small
databases can auto-migrate through one atomic 0086 migration within the
configured timeouts. It builds the index before creating the media
tables, so failure records no new migration version. Large deployments
must disable auto-migration and prebuild indexes concurrently.
- **Enforce remains blocked unless the operator can rule out SDK/CLI
attachment edits and the unverified agent-harness edit producer.**
SDK/CLI edits still omit attachment metadata. That behavior is deferred,
not fixed. Removing this blocker requires latest-effective attachment
metadata across successive edits, no restoration of removed files, and
an upload → successive edits → GET/HEAD regression.
- Validate client signer compatibility and split emoji publications
above 512 references before enforce mode.
- Complete the human workflows above in an isolated community with
enforce enabled.
- Follow the [index deployment
guide](https://github.com/block/buzz/blob/honor/private-channel-media/docs/private-media-index-deployment.md)
and [operator
guidance](https://github.com/block/buzz/blob/honor/private-channel-media/docs/operator-private-media.md)
for rollout and rollback.

The [prebuild timeout
finding](https://github.com/block/buzz/pull/9077#discussion_r4486221478)
and [avatar-transfer
finding](https://github.com/block/buzz/pull/9077#discussion_r4486221458)
are implemented and independently reviewed.

### Schema change checklist

- [x] Additive expand and code deployment; no backfill or contract
phase. Prebuild/verify before shadow under either schema deployment
path.
- [x] No SQL function, trigger, procedure, rule or foreign key added.
Signed events are untouched.
- [x] No migration mutates existing rows.
- [x] Migration and desired-state schema match. Catalog parity, target
parsing, future partition inheritance and upgrade refusal are covered.
- [x] Old/new binaries coexist for serving. Suspend community deletion
during expansion and while rolled back to a binary with the old deletion
catalog.
- [x] Keep auto-migration disabled on old binaries after schema
expansion; sqlx rejects unknown versions.
- [x] Migration lock timeout 1 s; statement timeout 5 s. Small populated
databases build inline within those budgets; large deployments use
concurrent child-index prebuild/attachment with auto-migration off.
Missing, invalid or wrong indexes fail verification.

Generated with Codex

---------

Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
2026-11-06 22:35:00 +00:00

13 KiB

Private-channel media revocation

BUZZ_MEDIA_READ_MODE is a startup setting: off (default), shadow, or enforce. Unknown values fail startup. Channel revocation is effective only in enforce. Blossom authentication, tenant binding, and community admission remain mandatory in every mode.

Read policy

Every GET and HEAD, including each Range request, authorizes the parent hash before returning blob bytes, thumbnail bytes, existence, size, or range errors. A tenant sidecar lookup precedes the new database lookup. Authorization uses a fresh primary-database repeatable-read snapshot, without membership caches or replicas. A removal committed before that snapshot excludes the member. A request authorized before removal may finish afterward.

A file is readable through any current readable reference. Shared bytes may therefore remain readable through another open channel or a DM the person still belongs to. Channel owners/admins have no blanket exception to private channel membership. Ordinary REQ event visibility, recipient-gated kinds and encrypted engrams retain their restrictions. Gift-wrap ciphertext itself cannot supply hidden attachment references.

Counted references are imeta url and image fields, link-preview snapshot image/favicon fields, profile picture (or image when picture is absent) and banner fields, group metadata picture tags, and emoji tags on emoji lists and sets and per-emoji entries (kind 30180). Persona definitions (kind 30175) count avatar_url, with the same owner-only/shared visibility as their events. Root-relative paths and the tenant's HTTP/HTTPS media bases count. Animated profile descriptors count their poster and decoded animation URL independently, with the same tenant check on each. The indexed profile lookup expects hash characters to remain literal, as in the desktop descriptor. Republish over-encoded hashes such as %61 or JSON Unicode-escaped hash characters such as \u0061 using canonical URLs. Ordinary Unicode escapes in profile names are supported; the URL hash must remain plain ASCII. A pasted URL in free text alone grants nothing; messages must publish imeta. Deleted events/channels grant nothing. Only the newest live edit supplies a message's attachments; its original must remain live. Original link-preview snapshots remain readable through an edit unless the original or any live edit carries the exact ["link-preview","none"] marker; later edits do not undo suppression; this does not retain removed attachments. Superseded profiles are excluded. Current community icons grant community-wide access.

The read path probes the existing tags GIN index for imeta hashes and exact local URL aliases. It scans content only for current kind-0 profiles and kind-30175 persona avatar fields in the community, never all message bodies. LLVM/JIT is disabled only in this short authorization transaction: compiling partitioned permission probes costs seconds without amortizing that cost. Other database work retains its configured JIT policy. A two-second statement deadline and a 1,024-candidate budget per probe and a 16 MiB aggregate work budget bound each lookup. An accessible candidate can succeed before an overflow refusal. Candidates stream one at a time rather than allocating an entire page of message bodies. Budget exhaustion or a database failure returns 503; an inaccessible/missing reference returns 404. NIP-FI strict mode keeps its existing authorization-unavailable response contract.

Uploads and publication

After the upload pipeline verifies the supplied bytes, the relay records media_uploaders through community write admission. This is independent of optional audit/upload moderation records. An unposted file is previewable by its uploader only. Real reuploads may prove possession for another uploader. Possession authorizes publication; a known URL/hash alone does not. A registered agent must hold its own upload proof or readable reference; owner proof is never inherited.

The shared publication check runs before common ingestion dispatch (WS and HTTP, including metadata commands) and before direct workflow persistence. It uses the same reference extractor as reads, plus structured command URLs. A durable media_publications marker consumes preview authority before publication dispatch and survives source deletion. A later rejected operation may conservatively consume a preview; reupload still proves publication rights. Reuploads also record a boundary when a legacy reference already exists, so later purging that reference cannot revive previews. The marker grants no download access. Both tables are purged during community deletion, and every writer uses admission.

Deployment-admin feedback attachment reads retain their independent admin and feedback-row provenance checks. Feedback does not grant community-wide media access through the ordinary media endpoint.

Rollout and recovery

  1. Before any pod enters shadow, build and verify the edit-target index. Small databases may auto-migrate through 0086: a missing index builds within a one-second lock and five-second statement budget, even with data. Large deployments disable auto-migration and prebuild concurrently per partition before applying 0086 or desired-state schema. A budget failure leaves migration retryable; a valid prebuild skips the blocking build. Bypassing the schema step can cause lookup timeouts/503 in enforce. Restore off, finish index construction/verification, then begin shadow. The migrations add plain tenant tables and an index, with no SQL functions, triggers, foreign keys or data rewrite.
  2. Deploy the new binary on every writer with mode off.
  3. Restart all writers in shadow; inspect buzz_media_read_access_total{mode="shadow",reason="no_ref"}, reason="not_member", and reason="lookup_error", plus buzz_media_publication_access_total. Exercise avatars, icons, emoji, uploads, private messages and old attachments during a staging soak.
  4. Repair unsupported/historical URLs or republish them through an authorized uploader before restarting every serving pod in enforce.

Enforce blocker: SDK/CLI attachment edits remain unsupported. buzz_sdk::build_edit and buzz messages edit currently omit attachment imeta. Editing an attachment message through these clients can make its file unreadable, including to its sender. This review item is deferred, not fixed. Enforce stays blocked unless the operator can rule out SDK/CLI attachment edits in the community. The agent harness edit producer is unverified and remains inside this blocker until its metadata behavior is traced and tested. To remove the blocker, update both SDK/CLI paths to preserve the latest effective attachment metadata across successive edits without restoring files removed by an earlier edit. Require an upload → successive edits → GET/HEAD regression proving retained files stay readable and removed files stay denied. Keeping a URL in the edited body does not preserve access.

There is no grant backfill: existing stored references are evaluated directly, and signed event tags are never rewritten. Legacy unreferenced/orphan blobs have no upload proof and are denied in enforce mode; their owner must reupload. Pre-enforcement references remain authoritative, including a preexisting avatar that points to the same bytes as a private attachment. Custom historical media hosts beyond the tenant's current authority require explicit repair.

Roll back policy by restarting with off; retain the additive schema and publication markers. If rolling back to a pre-0086 binary, keep auto-migration disabled and suspend community deletion: its exact-set deletion catalog does not recognize the two added media tables, so deletion fails closed. Resume deletion after deploying a binary whose deletion catalog includes them. Mixed off/enforce serving pods cannot provide revocation. Shadow/off do not close the original exposure. Shadow authorization lookup failures, including sidecar failures, are counted without refusing reads. The serving path can still fail if storage cannot supply bytes. Enforcement fails closed.

Successful media responses, authorized range errors and media denials use private, no-store in all modes, including off. Off preserves the previous authorization behavior; cache headers intentionally change before enforce to avoid creating more year-long immutable entries during the preparatory rollout. New responses therefore increase repeat-download bandwidth. The desktop proxy passes this policy to the WebView; its local file cache and the mobile client's local copies can still display previously fetched bytes. Old immutable HTTP cache entries, files already downloaded, screenshots and copied bytes cannot be recalled. The guarantee applies to requests reaching the relay; migrating existing client caches is a separate client rollout concern.

Any authorized member may deliberately republish or reupload bytes into a readable channel or avatar. Revoking one channel cannot revoke that independent share. This policy does not prevent authorized recipients from copying data.

Media authorization uses a shared semaphore across database-handle clones, limited to one quarter of the writer pool, capped at eight lookups. Saturation waits up to 500 ms before returning authorization-unavailable, counted with reason="capacity", before pool acquisition. Every Range request reauthorizes; all events, including emoji lists and sets, have a 512-reference cap, and checks run sequentially within that connection bound. Missing sidecars reject publication in enforce mode; shadow counts failures and accepts the write, including stale avatar metadata edits and upload-proof/publication-marker errors.

Eight slots provide an upper bound of 8 / mean_lookup_seconds lookups per second per pod before queueing (for example, 20 ms means about 400/s; 100 ms means about 80/s). A smaller writer pool reduces that bound proportionally. These are capacity calculations, not measured throughput targets. Measure full request latency and concurrent image/avatar bursts, including all probes, sidecars and pool acquisition. Keep sustained traffic below the measured bound; the 500 ms queue absorbs bursts but cannot increase throughput. Each lookup uses a writer connection and several statements; no-store repeats that work on every view. The synthetic query-plan times exclude storage/network/pool costs.

During staging soak, abort enforcement if writer authorization acquire p95 exceeds 50 ms for five minutes, event-write acquire p95 doubles its pre-rollout baseline, or media lookup_error/capacity refusal exceeds 0.1% of requests for five minutes. Investigate query latency, pool utilization and storage errors before retrying. Shadow failures must be repaired before enforce, since a failed recording does not establish possession. Uploads made in off mode record no proof: uploads not yet posted when enforce starts require reuploading.

Before enforce, every client that uploads avatars with one key and publishes profiles with another must transfer the actual poster/animation bytes to an upload signed by the profile key. Owner relationships never inherit possession. Validate this in each supported client during shadow; upgrading only this repository's desktop does not migrate other clients. This includes the new desktop/web app in block/buzz-app, mobile, and CLI/plugin integrations that publish agent profiles: verify their signer and upload flow before enabling enforce for their users.

The legacy desktop transfers both poster and animation bytes for changed persona avatars before saving the edit; transfer failures leave the old local avatar intact. Team and individual agent snapshot imports also transfer selected target-local avatars before publishing each new agent profile. Imports report transfer failures in their profile-sync results. Validate all three workflows against enforce mode before rollout. Unchanged-avatar reconciliation never borrows the owner's signer.

New managed-agent creation explicitly downloads the chosen target-local avatar with the captured owner's signer, verifies its image bytes and hash, then uploads those bytes with the new agent's signer. The agent holds its own possession proof. The captured community stays pinned through profile publication, even if the owner changes workspaces during transfer. Automatic reconciliation never borrows the owner's proof or signer.

Changed-avatar edits refuse the entire save before any transfer when a linked instance has no usable signing key. The error names affected instances; restore their keys, or remove/unlink those instances, then retry. The previous persona and instance avatars remain unchanged, so the same requested avatar is retryable. Keys may be permanently lost; waiting alone does not repair a lost key.