fix(media): require authenticated reads (#4610)

This change requires a valid signed Blossom authorization request and
current relay membership for every media GET and HEAD request. It
removes the unauthenticated compatibility path and updates desktop reads
to send the required authorization.

This blocks anonymous retrieval and access after relay-membership
revocation. It does not yet bind a blob to its originating channel, so
someone removed from a private channel can still read a known blob while
remaining a relay member. That channel-ACL follow-up remains required
before closing the full finding.

## Testing

- `git diff --check origin/main...codex/security-media-read-auth`
- Rebased onto `origin/main` at `5c98932`
- Full CI pending

Originating Buzz thread:
`buzz://message?channel=3928fe05-df61-4b5d-b9c7-d623b9b10ea1&id=3c6c02312f763fbe0d2bfc33a6c1a362f91d0354f3d18b039cf7a0558c1439d1`

---------

Signed-off-by: Jordan Mecom <jm@squareup.com>
Signed-off-by: Alex Rosenzweig <arosenzweig@squareup.com>
Signed-off-by: Eli Foster <efoster@squareup.com>
Co-authored-by: Eli Foster <efoster@squareup.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jordan Mecom
2026-08-07 00:46:42 +00:00
committed by GitHub
co-authored by Eli Foster Claude Opus 5
parent f03de210cd
commit 769ac70b74
18 changed files with 295 additions and 133 deletions
+4 -5
View File
@@ -102,11 +102,10 @@ BUZZ_S3_ADDRESSING_STYLE=path
# BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS=8 # BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS=8
# BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS_PER_PUBKEY=2 # BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS_PER_PUBKEY=2
# BUZZ_MEDIA_UPLOADS_PER_MINUTE=30 # BUZZ_MEDIA_UPLOADS_PER_MINUTE=30
# Require Blossom t=get auth and relay membership for GET/HEAD /media/*. # GET/HEAD /media/* always require Blossom t=get auth and relay membership.
# Keep off until desktop/mobile/CLI clients that attach media read auth are deployed. # BUZZ_REQUIRE_MEDIA_GET_AUTH and BUZZ_REQUIRE_MEDIA_READ_AUTH are no longer
# BUZZ_REQUIRE_MEDIA_GET_AUTH=false # read; setting either (including to false) changes nothing and the relay warns
# Legacy alias accepted by the relay while rollout docs catch up: # about it at startup.
# BUZZ_REQUIRE_MEDIA_READ_AUTH=false
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Ephemeral Channels (TTL testing) # Ephemeral Channels (TTL testing)
+13
View File
@@ -768,6 +768,19 @@ jobs:
env: env:
RELAY_URL: ws://localhost:3000 RELAY_URL: ws://localhost:3000
GIT_CREDENTIAL_NOSTR_BIN: ${{ github.workspace }}/target/ci/git-credential-nostr GIT_CREDENTIAL_NOSTR_BIN: ${{ github.workspace }}/target/ci/git-credential-nostr
- name: Media read-auth e2e
# Reads require kind:24242 `t=get` auth, so these binaries are the only
# coverage that a real relay rejects bare reads and honours host- and
# hash-scoped tokens. They were #[ignore]d and selected by no CI job, so
# the lane never ran; select it here, where MinIO and the seeded
# 'localhost:3000' community already exist.
# --no-fail-fast: without it cargo stops after the first failing binary,
# so one broken case hides every later binary's result.
run: |
cargo test -p buzz-test-client --no-fail-fast --test e2e_media --test e2e_media_extended --test e2e_media_video -- --ignored --nocapture
env:
RELAY_URL: ws://localhost:3000
RELAY_HTTP_URL: http://localhost:3000
- name: Upload relay logs - name: Upload relay logs
if: failure() if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
-1
View File
@@ -277,7 +277,6 @@ out of the box with `just setup` or `just relay`. Common overrides:
| `REDIS_URL` | `redis://localhost:6379` | | | `REDIS_URL` | `redis://localhost:6379` | |
| `BUZZ_REQUIRE_AUTH_TOKEN` | `false` | When true, REST requires NIP-98 (no `X-Pubkey` fallback) | | `BUZZ_REQUIRE_AUTH_TOKEN` | `false` | When true, REST requires NIP-98 (no `X-Pubkey` fallback) |
| `BUZZ_REQUIRE_RELAY_MEMBERSHIP` | `false` | When true, only pubkeys in `relay_members` can connect | | `BUZZ_REQUIRE_RELAY_MEMBERSHIP` | `false` | When true, only pubkeys in `relay_members` can connect |
| `BUZZ_REQUIRE_MEDIA_GET_AUTH` | `false` | When true, `GET`/`HEAD /media/*` require Blossom kind 24242 `t=get` auth plus relay membership. |
| `BUZZ_DRAIN_JITTER_MS` | `0` (off) | Per-connection upper bound, in ms, for the random delay before each live WebSocket gets its `1012 Service Restart` close on graceful shutdown. `0` closes every socket at once (the previous behavior). A positive value spreads closes uniformly over `[1, value]` ms to avoid a reconnect thundering herd on rolling deploys. Values above `20000` are capped to `20000` (`MAX_DRAIN_JITTER_MS`) to leave close-frame delivery headroom under the relay's 30s hard-drain timeout. Empty or whitespace-only is treated as unset (off); a non-integer fails startup loudly. | | `BUZZ_DRAIN_JITTER_MS` | `0` (off) | Per-connection upper bound, in ms, for the random delay before each live WebSocket gets its `1012 Service Restart` close on graceful shutdown. `0` closes every socket at once (the previous behavior). A positive value spreads closes uniformly over `[1, value]` ms to avoid a reconnect thundering herd on rolling deploys. Values above `20000` are capped to `20000` (`MAX_DRAIN_JITTER_MS`) to leave close-frame delivery headroom under the relay's 30s hard-drain timeout. Empty or whitespace-only is treated as unset (off); a non-integer fails startup loudly. |
| `BUZZ_AUDIT_ENABLED` | `true` | Tamper-evident event/media audit log. Set `false`/`0`/`off` to skip its DB pool and writes. Does not disable the separate moderation audit trail. | | `BUZZ_AUDIT_ENABLED` | `true` | Tamper-evident event/media audit log. Set `false`/`0`/`off` to skip its DB pool and writes. Does not disable the separate moderation audit trail. |
| `BUZZ_AUTO_MIGRATE` | `false` | Opt in with `true`/`1`/`yes`/`on` to run embedded SQLx migrations on relay startup | | `BUZZ_AUTO_MIGRATE` | `false` | Opt in with `true`/`1`/`yes`/`on` to run embedded SQLx migrations on relay startup |
+14 -39
View File
@@ -493,10 +493,6 @@ async fn authenticate_media_read(
) -> Result<MediaReadAuth, MediaError> { ) -> Result<MediaReadAuth, MediaError> {
let tenant = bind_media_read_tenant(state, headers).await?; let tenant = bind_media_read_tenant(state, headers).await?;
if !state.config.require_media_get_auth {
return Ok(MediaReadAuth { tenant });
}
let auth_event = extract_blossom_auth(headers)?; let auth_event = extract_blossom_auth(headers)?;
let sha256 = sha256_ext.split('.').next().unwrap_or(sha256_ext); let sha256 = sha256_ext.split('.').next().unwrap_or(sha256_ext);
buzz_media::auth::verify_blossom_get_auth(&auth_event, sha256, Some(tenant.host()), 3600)?; buzz_media::auth::verify_blossom_get_auth(&auth_event, sha256, Some(tenant.host()), 3600)?;
@@ -514,12 +510,8 @@ async fn authenticate_media_read(
Ok(MediaReadAuth { tenant }) Ok(MediaReadAuth { tenant })
} }
fn blob_cache_control(require_auth: bool) -> &'static str { fn blob_cache_control() -> &'static str {
if require_auth { "private, max-age=31536000, immutable"
"private, max-age=31536000, immutable"
} else {
"public, max-age=31536000, immutable"
}
} }
/// Whether a path-segment extension is a safe token. /// Whether a path-segment extension is a safe token.
@@ -623,7 +615,7 @@ pub(crate) async fn serve_blob_for_tenant(
req_headers: &HeaderMap, req_headers: &HeaderMap,
) -> Result<Response, MediaError> { ) -> Result<Response, MediaError> {
validate_media_path(sha256_ext)?; validate_media_path(sha256_ext)?;
let cache_control = blob_cache_control(state.config.require_media_get_auth); let cache_control = blob_cache_control();
// Sidecar gate FIRST — reject before any blob I/O. Storage is not authoritative. // Sidecar gate FIRST — reject before any blob I/O. Storage is not authoritative.
let content_type = if sha256_ext.ends_with(".thumb.jpg") { let content_type = if sha256_ext.ends_with(".thumb.jpg") {
@@ -801,10 +793,9 @@ pub async fn head_blob(
Path(sha256_ext): Path<String>, Path(sha256_ext): Path<String>,
) -> Result<Response, MediaError> { ) -> Result<Response, MediaError> {
validate_media_path(&sha256_ext)?; validate_media_path(&sha256_ext)?;
let require_media_get_auth = state.config.require_media_get_auth;
let media_auth = authenticate_media_read(&state, &headers, &sha256_ext).await?; let media_auth = authenticate_media_read(&state, &headers, &sha256_ext).await?;
let tenant = media_auth.tenant; let tenant = media_auth.tenant;
let cache_control = blob_cache_control(require_media_get_auth); let cache_control = blob_cache_control();
// Sidecar gate FIRST — reject before any blob I/O. // Sidecar gate FIRST — reject before any blob I/O.
let content_type = if sha256_ext.ends_with(".thumb.jpg") { let content_type = if sha256_ext.ends_with(".thumb.jpg") {
@@ -946,13 +937,8 @@ mod tests {
} }
async fn test_state() -> Arc<AppState> { async fn test_state() -> Arc<AppState> {
test_state_with_media_get_auth(false).await
}
async fn test_state_with_media_get_auth(require_media_get_auth: bool) -> Arc<AppState> {
let mut config = crate::config::Config::from_env().expect("default config loads"); let mut config = crate::config::Config::from_env().expect("default config loads");
config.require_relay_membership = false; config.require_relay_membership = false;
config.require_media_get_auth = require_media_get_auth;
config.redis_url = "redis://127.0.0.1:1".to_string(); config.redis_url = "redis://127.0.0.1:1".to_string();
config.media_uploads_per_minute = 1; config.media_uploads_per_minute = 1;
config.media_max_concurrent_uploads = 2; config.media_max_concurrent_uploads = 2;
@@ -994,8 +980,8 @@ mod tests {
Arc::new(state) Arc::new(state)
} }
async fn media_get_auth_router(require_media_get_auth: bool) -> axum::Router { async fn media_get_auth_router() -> axum::Router {
let state = test_state_with_media_get_auth(require_media_get_auth).await; let state = test_state().await;
axum::Router::new() axum::Router::new()
.route( .route(
"/media/{sha256_ext}", "/media/{sha256_ext}",
@@ -1041,20 +1027,9 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn media_get_auth_flag_off_allows_unauthenticated_read_until_sidecar_gate() { async fn media_reads_reject_unauthenticated_get_and_head_before_sidecar_gate() {
let response = media_get_auth_router(false)
.await
.oneshot(media_request("GET", None))
.await
.expect("response");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn media_get_auth_flag_on_rejects_unauthenticated_get_and_head_before_sidecar_gate() {
for method in ["GET", "HEAD"] { for method in ["GET", "HEAD"] {
let response = media_get_auth_router(true) let response = media_get_auth_router()
.await .await
.oneshot(media_request(method, None)) .oneshot(media_request(method, None))
.await .await
@@ -1065,10 +1040,10 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn media_get_auth_flag_on_valid_server_scoped_token_reaches_sidecar_gate() { async fn media_read_with_valid_server_scoped_token_reaches_sidecar_gate() {
let keys = Keys::generate(); let keys = Keys::generate();
let auth = media_get_auth_header(&keys, media_get_tags_for("relay.example", None)); let auth = media_get_auth_header(&keys, media_get_tags_for("relay.example", None));
let response = media_get_auth_router(true) let response = media_get_auth_router()
.await .await
.oneshot(media_request("GET", Some(auth))) .oneshot(media_request("GET", Some(auth)))
.await .await
@@ -1078,7 +1053,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn media_get_auth_flag_on_rejects_upload_verb_wrong_server_and_wrong_x() { async fn media_read_rejects_upload_verb_wrong_server_and_wrong_x() {
let keys = Keys::generate(); let keys = Keys::generate();
let now = Timestamp::now().as_secs(); let now = Timestamp::now().as_secs();
let expiration = (now + 300).to_string(); let expiration = (now + 300).to_string();
@@ -1102,7 +1077,7 @@ mod tests {
for tags in cases { for tags in cases {
let auth = media_get_auth_header(&keys, tags); let auth = media_get_auth_header(&keys, tags);
let response = media_get_auth_router(true) let response = media_get_auth_router()
.await .await
.oneshot(media_request("GET", Some(auth))) .oneshot(media_request("GET", Some(auth)))
.await .await
@@ -1119,7 +1094,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn media_get_auth_flag_on_accepts_range_header_only_after_auth() { async fn media_read_accepts_range_header_only_after_auth() {
let keys = Keys::generate(); let keys = Keys::generate();
let auth = media_get_auth_header(&keys, media_get_tags_for("relay.example", None)); let auth = media_get_auth_header(&keys, media_get_tags_for("relay.example", None));
let mut request = media_request("GET", Some(auth)); let mut request = media_request("GET", Some(auth));
@@ -1127,7 +1102,7 @@ mod tests {
.headers_mut() .headers_mut()
.insert(header::RANGE, "bytes=0-0".parse().expect("range header")); .insert(header::RANGE, "bytes=0-0".parse().expect("range header"));
let response = media_get_auth_router(true) let response = media_get_auth_router()
.await .await
.oneshot(request) .oneshot(request)
.await .await
+85 -17
View File
@@ -227,10 +227,6 @@ pub struct Config {
/// Maximum media upload starts accepted from one pubkey per minute. /// Maximum media upload starts accepted from one pubkey per minute.
pub media_uploads_per_minute: u32, pub media_uploads_per_minute: u32,
/// Require Blossom kind:24242 `t=get` auth plus relay membership before
/// serving media GET/HEAD. Default off for staged client rollout.
pub require_media_get_auth: bool,
/// Whether tamper-evident event/media audit logging is enabled. Defaults to true. /// Whether tamper-evident event/media audit logging is enabled. Defaults to true.
/// This does not control the separate `moderation_actions` audit trail. /// This does not control the separate `moderation_actions` audit trail.
/// Set `BUZZ_AUDIT_ENABLED=false` for deployments that do not require it. /// Set `BUZZ_AUDIT_ENABLED=false` for deployments that do not require it.
@@ -435,6 +431,31 @@ fn ensure_git_path(
Ok(git_repo_path) Ok(git_repo_path)
} }
/// Env vars that once gated authenticated media reads.
///
/// `BUZZ_REQUIRE_MEDIA_GET_AUTH` was the real flag; `BUZZ_REQUIRE_MEDIA_READ_AUTH`
/// was documented in `.env.example` as an accepted alias but was never read by
/// the relay. Media reads are now unconditionally authenticated, so both are
/// inert and an operator still setting either — especially to `false` — holds a
/// belief about their deployment that is no longer true.
const INERT_MEDIA_READ_AUTH_VARS: [&str; 2] = [
"BUZZ_REQUIRE_MEDIA_GET_AUTH",
"BUZZ_REQUIRE_MEDIA_READ_AUTH",
];
/// Which of `names` are present, so startup can warn that they do nothing.
///
/// `lookup` is injected rather than calling `std::env::var` directly: process
/// env is global mutable state, so a test that set real vars would race every
/// other test in the binary.
fn inert_env_vars<'a>(names: &[&'a str], lookup: impl Fn(&str) -> Option<String>) -> Vec<&'a str> {
names
.iter()
.copied()
.filter(|name| lookup(name).is_some())
.collect()
}
impl Config { impl Config {
/// Loads configuration from environment variables, falling back to development defaults. /// Loads configuration from environment variables, falling back to development defaults.
pub fn from_env() -> Result<Self, ConfigError> { pub fn from_env() -> Result<Self, ConfigError> {
@@ -776,14 +797,13 @@ impl Config {
.filter(|&v| v > 0) .filter(|&v| v > 0)
.unwrap_or(30); .unwrap_or(30);
let require_media_get_auth = std::env::var("BUZZ_REQUIRE_MEDIA_GET_AUTH") for name in inert_env_vars(&INERT_MEDIA_READ_AUTH_VARS, |n| std::env::var(n).ok()) {
.map(|v| { warn!(
v == "true" "{name} is set but is no longer read — GET/HEAD /media/* always require \
|| v == "1" Blossom t=get auth plus relay membership. Remove it; a value of `false` \
|| v.eq_ignore_ascii_case("yes") does not re-open unauthenticated media reads."
|| v.eq_ignore_ascii_case("on") );
}) }
.unwrap_or(false);
let ephemeral_ttl_override = std::env::var("BUZZ_EPHEMERAL_TTL_OVERRIDE") let ephemeral_ttl_override = std::env::var("BUZZ_EPHEMERAL_TTL_OVERRIDE")
.ok() .ok()
@@ -1003,7 +1023,6 @@ impl Config {
media_max_concurrent_uploads, media_max_concurrent_uploads,
media_max_concurrent_uploads_per_pubkey, media_max_concurrent_uploads_per_pubkey,
media_uploads_per_minute, media_uploads_per_minute,
require_media_get_auth,
audit_enabled, audit_enabled,
ephemeral_ttl_override, ephemeral_ttl_override,
git_repo_path, git_repo_path,
@@ -1035,6 +1054,59 @@ mod tests {
// value set by `invalid_bind_addr_returns_error`, causing a flaky failure. // value set by `invalid_bind_addr_returns_error`, causing a flaky failure.
static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Look up against a fixed set, standing in for process env.
fn env_of<'a>(set: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + use<'a> {
move |name| {
set.iter()
.find(|(key, _)| *key == name)
.map(|(_, value)| (*value).to_string())
}
}
/// The case that matters: an operator who pinned the old flag to `false`
/// must be told it is inert, not left believing media reads are still open.
#[test]
fn inert_media_read_auth_vars_are_reported_even_when_false() {
let found = inert_env_vars(
&INERT_MEDIA_READ_AUTH_VARS,
env_of(&[("BUZZ_REQUIRE_MEDIA_GET_AUTH", "false")]),
);
assert_eq!(found, vec!["BUZZ_REQUIRE_MEDIA_GET_AUTH"]);
}
/// `BUZZ_REQUIRE_MEDIA_READ_AUTH` was advertised in `.env.example` as an
/// accepted alias but the relay never read it, so operators may hold it
/// today. It warns too.
#[test]
fn inert_media_read_auth_vars_include_the_documented_alias() {
let found = inert_env_vars(
&INERT_MEDIA_READ_AUTH_VARS,
env_of(&[
("BUZZ_REQUIRE_MEDIA_GET_AUTH", "true"),
("BUZZ_REQUIRE_MEDIA_READ_AUTH", "false"),
]),
);
assert_eq!(
found,
vec![
"BUZZ_REQUIRE_MEDIA_GET_AUTH",
"BUZZ_REQUIRE_MEDIA_READ_AUTH"
]
);
}
#[test]
fn inert_media_read_auth_vars_stay_quiet_when_unset() {
let found = inert_env_vars(
&INERT_MEDIA_READ_AUTH_VARS,
env_of(&[("BUZZ_REQUIRE_RELAY_MEMBERSHIP", "true")]),
);
assert!(found.is_empty(), "unrelated vars must not warn: {found:?}");
}
#[test] #[test]
fn defaults_are_valid() { fn defaults_are_valid() {
let _guard = ENV_MUTEX.lock().unwrap(); let _guard = ENV_MUTEX.lock().unwrap();
@@ -1072,10 +1144,6 @@ mod tests {
!config.serve_git_web_gui, !config.serve_git_web_gui,
"serve_git_web_gui should default to false" "serve_git_web_gui should default to false"
); );
assert!(
!config.require_media_get_auth,
"require_media_get_auth should default to false for staged client rollout"
);
assert_eq!( assert_eq!(
config.media.s3_addressing_style, config.media.s3_addressing_style,
buzz_media::config::S3AddressingStyle::Path, buzz_media::config::S3AddressingStyle::Path,
@@ -2612,17 +2612,27 @@ mod pubsub_presence_typing {
mod media_blossom { mod media_blossom {
use super::*; use super::*;
/// Obligation: public blob `GET/HEAD /media/{sha256.ext}` stays /// Obligation: blob `GET/HEAD /media/{sha256.ext}` requires Blossom read auth
/// unauthenticated (N=1 compat, shared CAS bytes). The community boundary is /// scoped to the serving host or the blob hash, and the request is bound to the
/// the metadata/descriptor/upload-auth/quota/audit layer: B's private upload /// tenant resolved from the request headers. A bare read is rejected before any
/// metadata/errors must not be observable from A, even when the blob bytes /// storage lookup, so the endpoint does not leak blob existence.
/// are deduplicated and shared. ///
/// CAS bytes are still deduplicated across communities, so the boundary is not
/// the bytes: it is the metadata/descriptor/upload-auth/quota/audit layer plus
/// the per-tenant read binding. B's private upload metadata and errors must not
/// be observable from A even when the underlying blob is shared.
///
/// Known limitation, deferred: relay membership plus knowledge of a hash is
/// sufficient to read a blob. Read auth binds host and tenant, not the channel
/// ACL of the message the blob was attached to.
#[tokio::test] #[tokio::test]
#[ignore] #[ignore]
async fn media_metadata_boundary_holds_while_blob_bytes_shared() { async fn media_metadata_boundary_holds_while_blob_bytes_shared() {
pending_lane( pending_lane(
"buzz-media", "buzz-media",
"shared SHA bytes OK; A cannot read B's upload metadata/quota/audit; errors generic", "reads require host/hash-scoped Blossom auth and bind to the header tenant; \
bare reads 401 before storage; shared SHA bytes OK; A cannot read B's upload \
metadata/quota/audit; errors generic",
); );
} }
} }
+87 -3
View File
@@ -48,6 +48,26 @@ fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event {
.expect("sign blossom auth") .expect("sign blossom auth")
} }
/// Sign a kind:24242 Blossom *read* auth event for the given sha256.
///
/// Reads are authenticated unconditionally, so every successful GET/HEAD in this
/// file has to present one of these. The `x` tag is hash-scoped and covers the
/// derived paths too -- the relay matches on the sha256 before the extension, so
/// one token serves `{sha}.jpg` and `{sha}.thumb.jpg` alike.
fn sign_blossom_get_auth(keys: &Keys, sha256: &str) -> nostr::Event {
let now = Timestamp::now().as_secs();
let exp_str = (now + 300).to_string();
let tags = vec![
Tag::parse(["t", "get"]).expect("t tag"),
Tag::parse(["x", sha256]).expect("x tag"),
Tag::parse(["expiration", &exp_str]).expect("expiration tag"),
];
EventBuilder::new(Kind::from(24242), "Get test")
.tags(tags)
.sign_with_keys(keys)
.expect("sign blossom get auth")
}
/// Build `Authorization: Nostr <base64url(json)>` header value. /// Build `Authorization: Nostr <base64url(json)>` header value.
fn blossom_auth_header(event: &nostr::Event) -> String { fn blossom_auth_header(event: &nostr::Event) -> String {
format!( format!(
@@ -144,10 +164,14 @@ async fn test_upload_and_get() {
descriptor["dim"], descriptor["blurhash"] descriptor["dim"], descriptor["blurhash"]
); );
// Reads are authenticated, so mint one hash-scoped token for all three below.
let read_auth = blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256));
// GET /media/{sha256}.jpg — bytes must match // GET /media/{sha256}.jpg — bytes must match
let get_url = format!("{}/media/{sha256}.jpg", relay_http_url()); let get_url = format!("{}/media/{sha256}.jpg", relay_http_url());
let get_resp = client let get_resp = client
.get(&get_url) .get(&get_url)
.header("Authorization", &read_auth)
.send() .send()
.await .await
.expect("GET /media/{sha256}.jpg failed"); .expect("GET /media/{sha256}.jpg failed");
@@ -162,6 +186,7 @@ async fn test_upload_and_get() {
// HEAD /media/{sha256}.jpg — must return 200 with content-type // HEAD /media/{sha256}.jpg — must return 200 with content-type
let head_resp = client let head_resp = client
.head(&get_url) .head(&get_url)
.header("Authorization", &read_auth)
.send() .send()
.await .await
.expect("HEAD /media/{sha256}.jpg failed"); .expect("HEAD /media/{sha256}.jpg failed");
@@ -175,6 +200,7 @@ async fn test_upload_and_get() {
let thumb_url = format!("{}/media/{sha256}.thumb.jpg", relay_http_url()); let thumb_url = format!("{}/media/{sha256}.thumb.jpg", relay_http_url());
let thumb_resp = client let thumb_resp = client
.get(&thumb_url) .get(&thumb_url)
.header("Authorization", &read_auth)
.send() .send()
.await .await
.expect("GET thumbnail failed"); .expect("GET thumbnail failed");
@@ -293,19 +319,69 @@ async fn test_upload_hash_mismatch_returns_400() {
assert_eq!(resp.status(), 401, "hash mismatch must be 401"); assert_eq!(resp.status(), 401, "hash mismatch must be 401");
} }
/// GET a sha256 that was never uploaded must return 404. /// GET an authenticated sha256 that was never uploaded must return 404.
///
/// The token has to be valid for the 404 to be reachable at all: authentication
/// runs before the storage lookup, so a bare request is rejected with 401 and
/// never distinguishes "missing" from "unauthorized" (see
/// `test_unauthenticated_reads_are_rejected`).
#[tokio::test] #[tokio::test]
#[ignore] #[ignore]
async fn test_get_nonexistent_returns_404() { async fn test_get_nonexistent_returns_404() {
let client = http_client(); let client = http_client();
let keys = Keys::generate();
let missing_sha256 = "0".repeat(64); let missing_sha256 = "0".repeat(64);
let url = format!("{}/media/{missing_sha256}.jpg", relay_http_url()); let url = format!("{}/media/{missing_sha256}.jpg", relay_http_url());
let resp = client.get(&url).send().await.expect("GET failed"); let resp = client
.get(&url)
.header(
"Authorization",
blossom_auth_header(&sign_blossom_get_auth(&keys, &missing_sha256)),
)
.send()
.await
.expect("GET failed");
println!("missing blob → {}", resp.status()); println!("missing blob → {}", resp.status());
assert_eq!(resp.status(), 404, "missing blob must be 404"); assert_eq!(resp.status(), 404, "missing blob must be 404");
} }
/// Bare reads are rejected with 401 before any storage lookup.
///
/// This is the boundary PR #4610 made unconditional: there is no longer a config
/// flag that lets an unauthenticated GET through, so the acceptance lane has to
/// assert the rejection directly. Uses a never-uploaded hash deliberately -- a 401
/// here rather than a 404 proves auth runs ahead of the storage lookup and that the
/// endpoint does not leak blob existence to an unauthenticated caller.
#[tokio::test]
#[ignore]
async fn test_unauthenticated_reads_are_rejected() {
let client = http_client();
let missing_sha256 = "0".repeat(64);
let blob_url = format!("{}/media/{missing_sha256}.jpg", relay_http_url());
let thumb_url = format!("{}/media/{missing_sha256}.thumb.jpg", relay_http_url());
let get_resp = client.get(&blob_url).send().await.expect("bare GET failed");
println!("bare GET → {}", get_resp.status());
assert_eq!(get_resp.status(), 401, "bare GET must be 401");
let head_resp = client
.head(&blob_url)
.send()
.await
.expect("bare HEAD failed");
println!("bare HEAD → {}", head_resp.status());
assert_eq!(head_resp.status(), 401, "bare HEAD must be 401");
let thumb_resp = client
.get(&thumb_url)
.send()
.await
.expect("bare thumbnail GET failed");
println!("bare thumbnail GET → {}", thumb_resp.status());
assert_eq!(thumb_resp.status(), 401, "bare thumbnail GET must be 401");
}
/// Upload a real image from the filesystem (set TEST_IMAGE_PATH env var). /// Upload a real image from the filesystem (set TEST_IMAGE_PATH env var).
/// Verifies the full round-trip: upload → BlobDescriptor → GET bytes match. /// Verifies the full round-trip: upload → BlobDescriptor → GET bytes match.
#[tokio::test] #[tokio::test]
@@ -363,7 +439,15 @@ async fn test_upload_real_image() {
// GET bytes back and verify // GET bytes back and verify
let get_url = descriptor["url"].as_str().unwrap(); let get_url = descriptor["url"].as_str().unwrap();
let get_resp = client.get(get_url).send().await.expect("GET failed"); let get_resp = client
.get(get_url)
.header(
"Authorization",
blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256)),
)
.send()
.await
.expect("GET failed");
assert_eq!(get_resp.status(), 200); assert_eq!(get_resp.status(), 200);
let returned = get_resp.bytes().await.unwrap(); let returned = get_resp.bytes().await.unwrap();
assert_eq!( assert_eq!(
@@ -39,6 +39,21 @@ fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event {
.unwrap() .unwrap()
} }
/// Sign a kind:24242 Blossom *read* auth event. Reads are authenticated
/// unconditionally, so round-trip GETs must present one of these.
fn sign_blossom_get_auth(keys: &Keys, sha256: &str) -> nostr::Event {
let now = Timestamp::now().as_secs();
let tags = vec![
Tag::parse(["t", "get"]).unwrap(),
Tag::parse(["x", sha256]).unwrap(),
Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(),
];
EventBuilder::new(Kind::from(24242), "Get test")
.tags(tags)
.sign_with_keys(keys)
.unwrap()
}
fn blossom_auth_header(event: &nostr::Event) -> String { fn blossom_auth_header(event: &nostr::Event) -> String {
format!( format!(
"Nostr {}", "Nostr {}",
@@ -98,15 +113,15 @@ fn tiny_jpeg() -> Vec<u8> {
} }
fn tiny_png() -> Vec<u8> { fn tiny_png() -> Vec<u8> {
// Valid 2x2 red PNG generated by ffmpeg // Valid 2x2 red PNG generated by ffmpeg, with ffmpeg's pHYs chunk stripped:
// `validate_png_metadata_free` rejects pHYs as an identity channel, so the
// original fixture uploaded as 422 MetadataForbidden. IHDR/IDAT/IEND only.
vec![ vec![
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
0x52, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, 0x08, 0x02, 0x00, 0x00, 0x00, 0xfd, 0x52, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, 0x08, 0x02, 0x00, 0x00, 0x00, 0xfd,
0xd4, 0x9a, 0x73, 0x00, 0x00, 0x00, 0x09, 0x70, 0x48, 0x59, 0x73, 0x00, 0x00, 0x00, 0x01, 0xd4, 0x9a, 0x73, 0x00, 0x00, 0x00, 0x10, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xfc,
0x00, 0x00, 0x00, 0x01, 0x00, 0x4f, 0x25, 0xc4, 0xd6, 0x00, 0x00, 0x00, 0x10, 0x49, 0x44, 0xc3, 0x00, 0x02, 0x2c, 0x60, 0x92, 0x01, 0x00, 0x0d, 0x04, 0x01, 0x02, 0xbf, 0x50, 0x15,
0x41, 0x54, 0x78, 0x9c, 0x63, 0xfc, 0xc3, 0x00, 0x02, 0x2c, 0x60, 0x92, 0x01, 0x00, 0x0d, 0xb3, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
0x04, 0x01, 0x02, 0xbf, 0x50, 0x15, 0xb3, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44,
0xae, 0x42, 0x60, 0x82,
] ]
} }
@@ -168,9 +183,14 @@ async fn test_upload_png_roundtrip() {
assert!(desc["url"].as_str().unwrap().ends_with(".png")); assert!(desc["url"].as_str().unwrap().ends_with(".png"));
println!("✅ PNG upload: {}", desc["url"]); println!("✅ PNG upload: {}", desc["url"]);
// GET back // GET back — reads are authenticated, so scope a token to the uploaded hash.
let sha256 = desc["sha256"].as_str().expect("descriptor sha256");
let get = client let get = client
.get(desc["url"].as_str().unwrap()) .get(desc["url"].as_str().unwrap())
.header(
"Authorization",
blossom_auth_header(&sign_blossom_get_auth(&keys, sha256)),
)
.send() .send()
.await .await
.unwrap(); .unwrap();
@@ -192,8 +212,13 @@ async fn test_upload_gif_roundtrip() {
assert!(desc["url"].as_str().unwrap().ends_with(".gif")); assert!(desc["url"].as_str().unwrap().ends_with(".gif"));
println!("✅ GIF upload: {}", desc["url"]); println!("✅ GIF upload: {}", desc["url"]);
let sha256 = desc["sha256"].as_str().expect("descriptor sha256");
let get = client let get = client
.get(desc["url"].as_str().unwrap()) .get(desc["url"].as_str().unwrap())
.header(
"Authorization",
blossom_auth_header(&sign_blossom_get_auth(&keys, sha256)),
)
.send() .send()
.await .await
.unwrap(); .unwrap();
@@ -40,6 +40,23 @@ fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event {
.expect("sign blossom auth") .expect("sign blossom auth")
} }
/// Sign a kind:24242 Blossom *read* auth event. Reads are authenticated
/// unconditionally, so blob and range GETs must present one of these -- without it
/// the 206 and 416 range behaviour below would never be reached.
fn sign_blossom_get_auth(keys: &Keys, sha256: &str) -> nostr::Event {
let now = Timestamp::now().as_secs();
let exp_str = (now + 300).to_string();
let tags = vec![
Tag::parse(["t", "get"]).expect("t tag"),
Tag::parse(["x", sha256]).expect("x tag"),
Tag::parse(["expiration", &exp_str]).expect("expiration tag"),
];
EventBuilder::new(Kind::from(24242), "Get test")
.tags(tags)
.sign_with_keys(keys)
.expect("sign blossom get auth")
}
fn blossom_auth_header(event: &nostr::Event) -> String { fn blossom_auth_header(event: &nostr::Event) -> String {
format!( format!(
"Nostr {}", "Nostr {}",
@@ -272,7 +289,15 @@ async fn test_video_upload_and_get() {
// GET the blob back // GET the blob back
let get_url = desc["url"].as_str().unwrap(); let get_url = desc["url"].as_str().unwrap();
let get_resp = client.get(get_url).send().await.expect("GET blob"); let get_resp = client
.get(get_url)
.header(
"Authorization",
blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256)),
)
.send()
.await
.expect("GET blob");
assert_eq!(get_resp.status(), StatusCode::OK); assert_eq!(get_resp.status(), StatusCode::OK);
let body = get_resp.bytes().await.expect("body bytes"); let body = get_resp.bytes().await.expect("body bytes");
assert_eq!(body.len(), mp4.len()); assert_eq!(body.len(), mp4.len());
@@ -345,6 +370,10 @@ async fn test_video_range_request_206() {
// Range request: first 100 bytes // Range request: first 100 bytes
let range_resp = client let range_resp = client
.get(blob_url) .get(blob_url)
.header(
"Authorization",
blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256)),
)
.header("Range", "bytes=0-99") .header("Range", "bytes=0-99")
.send() .send()
.await .await
@@ -389,6 +418,10 @@ async fn test_video_range_request_416() {
// Request a range beyond the file size // Request a range beyond the file size
let range_resp = client let range_resp = client
.get(blob_url) .get(blob_url)
.header(
"Authorization",
blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256)),
)
.header( .header(
"Range", "Range",
format!("bytes={}-{}", mp4.len() + 1000, mp4.len() + 2000), format!("bytes={}-{}", mp4.len() + 1000, mp4.len() + 2000),
-5
View File
@@ -62,11 +62,6 @@
{{- if not .Values.relay.requireRelayMembership }} {{- if not .Values.relay.requireRelayMembership }}
⚠ relay.requireRelayMembership=false — relay is OPEN. Anyone can publish. ⚠ relay.requireRelayMembership=false — relay is OPEN. Anyone can publish.
{{- end }} {{- end }}
{{- if not .Values.relay.requireMediaGetAuth }}
⚠ relay.requireMediaGetAuth=false — media GET/HEAD reads are not auth-gated.
Anyone who learns a media URL/hash can fetch private attachments. Only
use for local development or fully public communities.
{{- end }}
{{- if not .Values.migrate.autoMigrate }} {{- if not .Values.migrate.autoMigrate }}
⚠ migrate.autoMigrate=false — relay startup will NOT run sqlx migrations. ⚠ migrate.autoMigrate=false — relay startup will NOT run sqlx migrations.
You must run `buzz-admin migrate` against the database before every You must run `buzz-admin migrate` against the database before every
@@ -131,7 +131,6 @@ spec:
- { name: BUZZ_DRAIN_JITTER_MS, value: {{ .Values.relay.drainJitterMs | quote }} } - { name: BUZZ_DRAIN_JITTER_MS, value: {{ .Values.relay.drainJitterMs | quote }} }
- { name: BUZZ_REQUIRE_AUTH_TOKEN, value: {{ .Values.relay.requireAuthToken | quote }} } - { name: BUZZ_REQUIRE_AUTH_TOKEN, value: {{ .Values.relay.requireAuthToken | quote }} }
- { name: BUZZ_REQUIRE_RELAY_MEMBERSHIP, value: {{ .Values.relay.requireRelayMembership | quote }} } - { name: BUZZ_REQUIRE_RELAY_MEMBERSHIP, value: {{ .Values.relay.requireRelayMembership | quote }} }
- { name: BUZZ_REQUIRE_MEDIA_GET_AUTH, value: {{ .Values.relay.requireMediaGetAuth | quote }} }
- { name: BUZZ_ALLOW_NIP_OA_AUTH, value: {{ .Values.relay.allowNipOaAuth | quote }} } - { name: BUZZ_ALLOW_NIP_OA_AUTH, value: {{ .Values.relay.allowNipOaAuth | quote }} }
- { name: BUZZ_PUBKEY_ALLOWLIST, value: {{ .Values.relay.pubkeyAllowlist | quote }} } - { name: BUZZ_PUBKEY_ALLOWLIST, value: {{ .Values.relay.pubkeyAllowlist | quote }} }
{{- if .Values.relay.corsOrigins }} {{- if .Values.relay.corsOrigins }}
-29
View File
@@ -48,17 +48,6 @@ tests:
name: BUZZ_HUDDLE_AUDIO_AVAILABLE name: BUZZ_HUDDLE_AUDIO_AVAILABLE
value: "true" value: "true"
template: templates/deployment.yaml template: templates/deployment.yaml
# Security default: media GET/HEAD reads must be auth-gated out of the
# box. A private attachment must never be publicly readable by URL/hash
# in an unmodified render. If this assertion fails, someone flipped the
# default — treat that as a security regression, not a config tweak.
- contains:
path: spec.template.spec.containers[0].env
content:
name: BUZZ_REQUIRE_MEDIA_GET_AUTH
value: "true"
template: templates/deployment.yaml
- it: renders virtual-hosted S3 addressing for providers that require it - it: renders virtual-hosted S3 addressing for providers that require it
set: set:
relayUrl: wss://buzz.example.com relayUrl: wss://buzz.example.com
@@ -85,24 +74,6 @@ tests:
value: "virtual" value: "virtual"
template: templates/deployment.yaml template: templates/deployment.yaml
- it: lets an explicit value opt out of media read auth for dev/public deployments
set:
relayUrl: wss://buzz.example.com
ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
externalPostgresql.url: postgres://u:p@h:5432/d
externalRedis.url: redis://h:6379
s3.endpoint: http://minio:9000
s3.accessKey: a
s3.secretKey: s
relay.requireMediaGetAuth: false
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: BUZZ_REQUIRE_MEDIA_GET_AUTH
value: "false"
template: templates/deployment.yaml
- it: lets an explicit value disable huddle audio in a single-replica render - it: lets an explicit value disable huddle audio in a single-replica render
set: set:
relayUrl: wss://buzz.example.com relayUrl: wss://buzz.example.com
-1
View File
@@ -62,7 +62,6 @@
"drainJitterMs": { "type": "integer", "minimum": 0 }, "drainJitterMs": { "type": "integer", "minimum": 0 },
"requireAuthToken": { "type": "boolean" }, "requireAuthToken": { "type": "boolean" },
"requireRelayMembership": { "type": "boolean" }, "requireRelayMembership": { "type": "boolean" },
"requireMediaGetAuth": { "type": "boolean" },
"allowNipOaAuth": { "type": "boolean" }, "allowNipOaAuth": { "type": "boolean" },
"huddleAudioAvailable": { "huddleAudioAvailable": {
"type": ["boolean", "null"], "type": ["boolean", "null"],
-6
View File
@@ -117,12 +117,6 @@ relay:
drainJitterMs: 0 drainJitterMs: 0
requireAuthToken: true requireAuthToken: true
requireRelayMembership: true requireRelayMembership: true
# Authenticated media reads: relay GET/HEAD /media/* requires Blossom
# kind 24242 t=get plus relay membership. Enabled by default so private
# attachments are never publicly readable by URL/hash. Only set false for
# local development or fully public communities — desktop, mobile, and CLI
# clients all attach read auth.
requireMediaGetAuth: true
allowNipOaAuth: true allowNipOaAuth: true
pubkeyAllowlist: false pubkeyAllowlist: false
corsOrigins: [] corsOrigins: []
+3 -5
View File
@@ -350,11 +350,9 @@ pub(crate) fn sign_blossom_get_auth_header(
/// Mint a `t=get` Authorization header value for a relay media fetch, or /// Mint a `t=get` Authorization header value for a relay media fetch, or
/// `None` when signing is unavailable (identity in recovery mode). /// `None` when signing is unavailable (identity in recovery mode).
/// ///
/// Fail-open by design: while the relay's `BUZZ_REQUIRE_MEDIA_GET_AUTH` flag /// When signing is unavailable, callers send no header and the relay rejects
/// is off, an unauthenticated request still succeeds, so degrading to no /// the read. This keeps recovery mode from accidentally treating a media URL
/// header (instead of erroring) keeps media rendering during key recovery. /// as a bearer capability.
/// Once the flag is on, these requests will 403 — the correct outcome for an
/// identity that can't prove membership.
/// ///
/// Safety contract: callers must only attach the returned header to URLs /// Safety contract: callers must only attach the returned header to URLs
/// constructed from (or validated against) the app's own relay base URL — /// constructed from (or validated against) the app's own relay base URL —
@@ -668,9 +668,9 @@ pub async fn mint_agent_card(
.ok_or_else(|| "Agent avatar data URL could not be decoded.".to_string())?, .ok_or_else(|| "Agent avatar data URL could not be decoded.".to_string())?,
Some(url) if url.starts_with("http://") || url.starts_with("https://") => { Some(url) if url.starts_with("http://") || url.starts_with("https://") => {
// Relay-hosted avatars (kind:0 pictures under the relay's /media/) // Relay-hosted avatars (kind:0 pictures under the relay's /media/)
// may require Blossom get-auth (`require_media_get_auth`). Mint the // require Blossom get-auth. Mint the header ONLY for same-origin URLs
// header ONLY for same-origin URLs so the token never leaves the // so the token never leaves the relay (same contract as
// relay (same contract as `media_download.rs`). // `media_download.rs`).
let relay_base = crate::relay::relay_api_base_url_with_override(&state); let relay_base = crate::relay::relay_api_base_url_with_override(&state);
let auth = is_same_origin(url, &relay_base) let auth = is_same_origin(url, &relay_base)
.then(|| crate::commands::media::mint_media_get_auth(&state, &relay_base)) .then(|| crate::commands::media::mint_media_get_auth(&state, &relay_base))
+3 -3
View File
@@ -54,9 +54,9 @@ sidecar before accessing the shared content-addressed blob. Unknown feedback,
unreferenced hashes, malformed paths, and cross-community substitutions all unreferenced hashes, malformed paths, and cross-community substitutions all
collapse to `404`. collapse to `404`.
Only `GET` and `HEAD` are routed. Existing community `/media/*` authorization is Only `GET` and `HEAD` are routed. Community `/media/*` reads always require
unchanged, including `BUZZ_REQUIRE_MEDIA_GET_AUTH`; the browser receives no Blossom authorization and relay membership; the browser receives no reusable
Blossom credential or reusable signed URL. Responses are uncached, `nosniff`, signed URL. Responses are uncached, `nosniff`,
governed by a restrictive CSP, streamed from object storage, and non-previewable governed by a restrictive CSP, streamed from object storage, and non-previewable
content retains attachment disposition. Successful reads produce a structured content retains attachment disposition. Successful reads produce a structured
trace containing feedback ID, community ID, and attachment hash, but no feedback trace containing feedback ID, community ID, and attachment hash, but no feedback
+1 -1
View File
@@ -49,7 +49,7 @@ Conformance obligations:
| Workflows, runs, approvals, webhooks, schedules | Workflows are channel-scoped or project/channel-global; triggers fire on matching stored events; schedule/webhook/manual triggers create runs; approval tokens are hashed. | Workflow definition's community from `req.community` at create/update; webhook/schedule/manual routes resolve workflow id inside host-derived community. | Community-global workflow namespace; runs/approvals inherit workflow community. | `workflows`, `workflow_runs`, `workflow_approvals` include `community_id`; workflow id/token hash lookups are scoped; trigger event ids are scoped. | Trigger evaluation only sees events in the same community. Webhook URLs include host-derived community; approval token grants cannot act on another community's same hash/id. | Existing workflow APIs and YAML remain unchanged in default community. | Add tests for identical workflow UUID/approval token hash in different communities and schedule execution isolation. | | Workflows, runs, approvals, webhooks, schedules | Workflows are channel-scoped or project/channel-global; triggers fire on matching stored events; schedule/webhook/manual triggers create runs; approval tokens are hashed. | Workflow definition's community from `req.community` at create/update; webhook/schedule/manual routes resolve workflow id inside host-derived community. | Community-global workflow namespace; runs/approvals inherit workflow community. | `workflows`, `workflow_runs`, `workflow_approvals` include `community_id`; workflow id/token hash lookups are scoped; trigger event ids are scoped. | Trigger evaluation only sees events in the same community. Webhook URLs include host-derived community; approval token grants cannot act on another community's same hash/id. | Existing workflow APIs and YAML remain unchanged in default community. | Add tests for identical workflow UUID/approval token hash in different communities and schedule execution isolation. |
| Search / FTS | Postgres FTS over the `events.search_tsv` generated `tsvector` column (GIN-indexed); searchable rows expose `id`, `content`, `kind`, `pubkey`, optional `channel_id`, `created_at`, tag terms; channel-less scope is `ChannelScope::ChannelLessOnly`; the relay refetches canonical events from Postgres by hit id. | Search query carries `req.community`; searchable rows carry `community_id`. | Community-global search results; operator-global FTS index infrastructure may be shared. | Every search query filters by `community_id`, BitmapAnd-ed with the GIN `@@` probe; refetch by `(community_id, event_id)`. | Every query carries `community_id` plus channel scope. `ChannelLessOnly` means channel-less within the community, not platform global. | One community produces the same search results as today. | Tests for same event id/content in A and B, deletion in A not deleting B. | | Search / FTS | Postgres FTS over the `events.search_tsv` generated `tsvector` column (GIN-indexed); searchable rows expose `id`, `content`, `kind`, `pubkey`, optional `channel_id`, `created_at`, tag terms; channel-less scope is `ChannelScope::ChannelLessOnly`; the relay refetches canonical events from Postgres by hit id. | Search query carries `req.community`; searchable rows carry `community_id`. | Community-global search results; operator-global FTS index infrastructure may be shared. | Every search query filters by `community_id`, BitmapAnd-ed with the GIN `@@` probe; refetch by `(community_id, event_id)`. | Every query carries `community_id` plus channel scope. `ChannelLessOnly` means channel-less within the community, not platform global. | One community produces the same search results as today. | Tests for same event id/content in A and B, deletion in A not deleting B. |
| Redis pub/sub, presence, typing, and cache invalidation | Event fan-out uses `buzz:channel:{uuid}`; presence uses `buzz:presence:{pubkey}`; typing uses `buzz:typing:{channel_id}`; cache invalidation uses `buzz:cache-invalidate`. | Pub/sub calls receive `TenantContext` and derive keys from `community_id` plus channel/pubkey. | Pub/sub and presence are community-global; Redis deployment is operator-global shared infrastructure. | Redis keys include community: `buzz:{community}:channel:{uuid}`, `buzz:{community}:presence:{pubkey}`, `buzz:{community}:typing:{channel_id}`, and community-aware cache invalidation payloads/channels. | Cross-node fan-out must not deliver events to subscriptions in another community. Same pubkey can be online/away differently in two communities. Cache drops only affect same-community membership/visibility caches unless explicitly all-community operator maintenance. | Single-community can preserve existing key names only if deployment is isolated; shared multi-tenant Redis must use the prefixed form. | Add tests for same pubkey presence in two communities and same channel UUID collision in two communities. | | Redis pub/sub, presence, typing, and cache invalidation | Event fan-out uses `buzz:channel:{uuid}`; presence uses `buzz:presence:{pubkey}`; typing uses `buzz:typing:{channel_id}`; cache invalidation uses `buzz:cache-invalidate`. | Pub/sub calls receive `TenantContext` and derive keys from `community_id` plus channel/pubkey. | Pub/sub and presence are community-global; Redis deployment is operator-global shared infrastructure. | Redis keys include community: `buzz:{community}:channel:{uuid}`, `buzz:{community}:presence:{pubkey}`, `buzz:{community}:typing:{channel_id}`, and community-aware cache invalidation payloads/channels. | Cross-node fan-out must not deliver events to subscriptions in another community. Same pubkey can be online/away differently in two communities. Cache drops only affect same-community membership/visibility caches unless explicitly all-community operator maintenance. | Single-community can preserve existing key names only if deployment is isolated; shared multi-tenant Redis must use the prefixed form. | Add tests for same pubkey presence in two communities and same channel UUID collision in two communities. |
| Media / Blossom / S3 | Authenticated uploads return content-addressed descriptors; public `GET/HEAD /media/{sha256.ext}` serves blobs; upload audit has `channel_id = None`. | Upload request host provides `req.community`; Blossom/NIP-98 auth URL host must agree. | Blob CAS bytes may be operator-global shared storage; metadata, authorization, quotas, audit, and visibility are community-global. | Media metadata/audit rows include `community_id`; if object keys stay SHA-addressed, any per-community policy lives outside the raw blob key. | Upload/read authorization uses community context. Shared hash bytes are allowed only as dedup/storage optimization; metadata/errors must not reveal another community's private upload. | Existing media URLs keep working for default community. | Decide whether unauthenticated blob `GET` remains intentionally public; if not, reads need host-scoped auth/visibility checks. | | Media / Blossom / S3 | Authenticated uploads return content-addressed descriptors; `GET/HEAD /media/{sha256.ext}` requires a Blossom `t=get` auth event scoped to the serving host or the blob hash and binds the read to the header-resolved tenant, so a bare read is rejected before any storage lookup; upload audit has `channel_id = None`. | Upload and read request host provides `req.community`; Blossom/NIP-98 auth URL host must agree. | Blob CAS bytes may be operator-global shared storage; metadata, authorization, quotas, audit, and visibility are community-global. | Media metadata/audit rows include `community_id`; if object keys stay SHA-addressed, any per-community policy lives outside the raw blob key. | Upload/read authorization uses community context. Shared hash bytes are allowed only as dedup/storage optimization; metadata/errors must not reveal another community's private upload. | Existing media URLs keep working for default community, but clients must now present read auth; there is no config flag that restores unauthenticated reads. | Resolved: blob reads are authenticated and host/tenant-scoped, not public. Remaining gap, deferred: a read is not gated on the channel ACL of the message the blob was attached to, so relay membership plus a known hash is sufficient. |
| Git hosting / NIP-34 / object storage | Smart HTTP at `/git/{owner}/{repo}` hydrates from S3 object pointers; NIP-34 repo announcements use `d=repo-id`; pointer key is `repos/{owner}/{repo}/pointer`; git push emits kind:30618. | Git HTTP host gives `req.community`; NIP-98 URL and repo announcement community must agree. | Community-global repo namespace and NIP-34 state; pack/manifests CAS objects may be operator-global if pointers are scoped. | Pointer/name keys include community, e.g. `repos/{community}/{owner}/{repo}/pointer`; NIP-34 replaceable coords include `community_id`; any repo-name registry is `(community_id, owner, repo)` or `(community_id, repo)` per product rule. | Clone/push/read policy resolves repo and branch protections only inside the host community. Git hook policy callback carries community and rejects mismatches. | Existing clone URLs and repo ids work under the default community; object-store migration can move pointers under default prefix without changing git clients. | Add tests for same owner/repo in two communities and push in A not advancing B pointer. | | Git hosting / NIP-34 / object storage | Smart HTTP at `/git/{owner}/{repo}` hydrates from S3 object pointers; NIP-34 repo announcements use `d=repo-id`; pointer key is `repos/{owner}/{repo}/pointer`; git push emits kind:30618. | Git HTTP host gives `req.community`; NIP-98 URL and repo announcement community must agree. | Community-global repo namespace and NIP-34 state; pack/manifests CAS objects may be operator-global if pointers are scoped. | Pointer/name keys include community, e.g. `repos/{community}/{owner}/{repo}/pointer`; NIP-34 replaceable coords include `community_id`; any repo-name registry is `(community_id, owner, repo)` or `(community_id, repo)` per product rule. | Clone/push/read policy resolves repo and branch protections only inside the host community. Git hook policy callback carries community and rejects mismatches. | Existing clone URLs and repo ids work under the default community; object-store migration can move pointers under default prefix without changing git clients. | Add tests for same owner/repo in two communities and push in A not advancing B pointer. |
| Mesh, agents, ACP/MCP, and CLI | Agents/CLI connect to a relay URL and use WS/REST; mesh/pairing/presence/status events are regular signed relay events. | The relay URL/host configured in the agent/CLI session selects community. | Agent membership, persona/profile, presence, jobs, memory events, and mesh status are community-global unless a future operator mesh plane is explicitly separate. | Any persisted agent profile/job/mesh status rows/events use `community_id`; Redis/presence/search keys follow the same community scoping. | A portable key may join multiple communities, but memberships, DMs, profiles, jobs, and presence do not bleed across them. | Existing `BUZZ_RELAY_URL` continues to select the one default community. | Add CLI/ACP smoke tests against two hosts using same key with different memberships/profile. | | Mesh, agents, ACP/MCP, and CLI | Agents/CLI connect to a relay URL and use WS/REST; mesh/pairing/presence/status events are regular signed relay events. | The relay URL/host configured in the agent/CLI session selects community. | Agent membership, persona/profile, presence, jobs, memory events, and mesh status are community-global unless a future operator mesh plane is explicitly separate. | Any persisted agent profile/job/mesh status rows/events use `community_id`; Redis/presence/search keys follow the same community scoping. | A portable key may join multiple communities, but memberships, DMs, profiles, jobs, and presence do not bleed across them. | Existing `BUZZ_RELAY_URL` continues to select the one default community. | Add CLI/ACP smoke tests against two hosts using same key with different memberships/profile. |
| Audit log and observability | One hash-chain audit log records event/channel/auth/media actions; errors are sanitized before reaching clients. | Every tenant-observable audit entry is labeled with `req.community` or inherited community from the object being acted on. | Community-global audit chains; operator metrics/log aggregation may be platform-global only if tenant labels are bounded and access-controlled. | `audit_log` key/sequence/head includes `community_id`; error/audit projection tables include `community_id`; uniqueness is `(community_id, seq)` and `(community_id, hash)` as appropriate. | Audit reads verify only one community chain. Error strings must not include cross-community IDs, constraint names, or existence facts. | Single-community audit verification still traverses one chain. | Eva owns model edits here; infra lane must ensure media/git/token/search rows emit community-labeled audit entries. | | Audit log and observability | One hash-chain audit log records event/channel/auth/media actions; errors are sanitized before reaching clients. | Every tenant-observable audit entry is labeled with `req.community` or inherited community from the object being acted on. | Community-global audit chains; operator metrics/log aggregation may be platform-global only if tenant labels are bounded and access-controlled. | `audit_log` key/sequence/head includes `community_id`; error/audit projection tables include `community_id`; uniqueness is `(community_id, seq)` and `(community_id, hash)` as appropriate. | Audit reads verify only one community chain. Error strings must not include cross-community IDs, constraint names, or existence facts. | Single-community audit verification still traverses one chain. | Eva owns model edits here; infra lane must ensure media/git/token/search rows emit community-labeled audit entries. |