Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
e0d28ed
feat(relay): record where media is shared and who uploaded it
ngoodger-block Oct 19, 2026
86525c2
test(db): upload media before posting it in share-recording tests
ngoodger-block Oct 19, 2026
35e67a2
fix(db): backfill only history unchecked; lock media shares in key order
ngoodger-block Oct 19, 2026
4275742
fix(media): record shares safely before enforcing reads
ngoodger-block Oct 19, 2026
28fda7b
Merge main into media share recording
ngoodger-block Oct 19, 2026
36c9cda
fix(media): revoke grants from deleted messages
ngoodger-block Oct 20, 2026
7f7f21c
fix(media): preserve grants through profile updates
ngoodger-block Oct 20, 2026
6ca6acb
docs(media): clarify backfill permission changes
ngoodger-block Oct 21, 2026
e539e8d
feat(relay): record where media is shared and who uploaded it
ngoodger-block Oct 19, 2026
aa6ba13
test(db): upload media before posting it in share-recording tests
ngoodger-block Oct 19, 2026
b34e8bb
fix(db): backfill only history unchecked; lock media shares in key order
ngoodger-block Oct 19, 2026
d4a6a1a
fix(media): record shares safely before enforcing reads
ngoodger-block Oct 19, 2026
ac39fcf
fix(media): revoke grants from deleted messages
ngoodger-block Oct 20, 2026
6c82228
fix(media): preserve grants through profile updates
ngoodger-block Oct 20, 2026
a7cdc13
docs(media): clarify backfill permission changes
ngoodger-block Oct 21, 2026
3d77f74
fix(db): renumber media migration after rebase
ngoodger-block Oct 22, 2026
dd5a57a
feat(relay): record who uploads each media blob
ngoodger-block Oct 19, 2026
ca631da
fix(relay): bind uploader records to the upload's serving lease
ngoodger-block Oct 19, 2026
0309016
fix(relay): scope media reads to the channels a blob is attached in
ngoodger-block Oct 18, 2026
c22f768
fix(relay): cover posters and public media in channel-scoped reads
ngoodger-block Oct 19, 2026
d8d166f
refactor(relay): decide media reads in one query; index posters by hash
ngoodger-block Oct 19, 2026
1864474
Merge branch 'feat/media-uploaders' into fix/channel-scoped-media
ngoodger-block Oct 19, 2026
8cefaca
fix(relay): only readers may attach media; uploaders may always read
ngoodger-block Oct 19, 2026
f12be8f
Merge branch 'feat/media-uploaders' into fix/channel-scoped-media
ngoodger-block Oct 19, 2026
7efeb69
fix(relay): keep avatars and emoji public; check all media references…
ngoodger-block Oct 19, 2026
784f6ae
refactor(relay): authorize media reads from an explicit media_shares …
ngoodger-block Oct 19, 2026
42feb17
fix(relay): share only readable media instead of rejecting references
ngoodger-block Oct 19, 2026
af6554a
refactor(relay): share every local media path an event names
ngoodger-block Oct 19, 2026
65c24f9
refactor(db): create media access tables in one migration
ngoodger-block Oct 19, 2026
147eb25
fix(relay): uploading lets you share a blob, not read it
ngoodger-block Oct 19, 2026
cba772d
Merge branch 'feat/media-shares' into fix/channel-scoped-media
ngoodger-block Oct 19, 2026
865f390
fix(relay): deny media reads outside the channels a file is shared in
ngoodger-block Oct 19, 2026
e201a89
Merge branch 'feat/media-shares' into fix/channel-scoped-media
ngoodger-block Oct 19, 2026
50a698c
Merge branch 'feat/media-shares' into fix/channel-scoped-media
ngoodger-block Oct 19, 2026
7cb0537
Merge media recording fixes into channel-scoped reads
ngoodger-block Oct 19, 2026
53f6a1c
test(media): cover avatars uploaded by agent owners
ngoodger-block Oct 19, 2026
d078ce2
test(media): use the configured relay in upload fixtures
ngoodger-block Oct 19, 2026
0188ccf
Merge current media recording base into enforcement
ngoodger-block Oct 19, 2026
4b24239
Merge message-scoped grants into media enforcement
ngoodger-block Oct 20, 2026
1b2cb9e
test(media): cover author and moderator deletion
ngoodger-block Oct 20, 2026
a818eb7
Merge branch 'feat/media-shares' into fix/channel-scoped-media
ngoodger-block Oct 20, 2026
583c54f
test(media): cover avatar grant replacement
ngoodger-block Oct 20, 2026
17cc268
fix(media): stage read enforcement and audit report access
ngoodger-block Oct 20, 2026
ea55d46
Merge branch 'feat/media-shares' into fix/channel-scoped-media
ngoodger-block Oct 21, 2026
e1dbda7
docs(media): separate protocol policy from rollout settings
ngoodger-block Oct 21, 2026
727b989
Merge branch 'feat/media-shares' into fix/channel-scoped-media
ngoodger-block Oct 21, 2026
b267129
test(media): adapt fixtures to membership writer API
ngoodger-block Oct 21, 2026
bdfb630
fix(db): bootstrap only declared legacy fences
ngoodger-block Oct 22, 2026
715c972
Merge branch 'ngoodger/explicit-schema-fences' into feat/media-shares
ngoodger-block Oct 22, 2026
7892f0b
test(db): audit the schema bootstrap fixture
ngoodger-block Oct 22, 2026
53073b1
fix(media): bind grants to message lifetimes
ngoodger-block Oct 22, 2026
8868e28
docs(media): decide on grants before backfill
ngoodger-block Oct 22, 2026
6164b50
Merge branch 'ngoodger/explicit-schema-fences' into feat/media-shares
ngoodger-block Oct 22, 2026
658dbbb
docs(media): require acceptance before first backfill
ngoodger-block Oct 22, 2026
9550edb
Merge branch 'feat/media-shares' into fix/channel-scoped-media
ngoodger-block Oct 22, 2026
7b8bee1
fix(media): honor edited message lifetime in report reads
ngoodger-block Oct 22, 2026
11f017f
fix(db): bound the media migration lock wait
ngoodger-block Oct 22, 2026
2cf0a83
Merge branch 'feat/media-shares' into fix/channel-scoped-media
ngoodger-block Oct 22, 2026
f67333b
fix(media): reclaim inactive grants and cover upload attribution
ngoodger-block Oct 22, 2026
c4e48e0
Merge branch 'feat/media-shares' into fix/channel-scoped-media
ngoodger-block Oct 22, 2026
42e69e6
fix(media): run cleanup before telemetry collection
ngoodger-block Oct 22, 2026
4a1a1be
Merge branch 'feat/media-shares' into fix/channel-scoped-media
ngoodger-block Oct 22, 2026
4c74b29
test(media): verify cleanup preserves file access boundaries
ngoodger-block Oct 22, 2026
2a67653
fix(media): finish deferred provenance and access feedback
ngoodger-block Oct 22, 2026
66fcf2d
Merge branch 'feat/media-shares' into fix/channel-scoped-media
ngoodger-block Oct 22, 2026
6483588
test(media): cover removed members and document audit recovery
ngoodger-block Oct 22, 2026
bb44789
test(db): expect only legacy tables to carry community write fences
ngoodger-block Oct 23, 2026
e25d56f
Merge branch 'feat/media-shares' into fix/channel-scoped-media
ngoodger-block Oct 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ PGDATABASE=buzz
# Redis 7
# -----------------------------------------------------------------------------
REDIS_URL=redis://localhost:6379

# File-share authorization rollout (independent of NIP-FI).
# off: skip share checks; log-only (default): count would-deny/errors but allow;
# enforce: deny unauthorized reads. Authentication/community checks always apply.
# Read at startup: change deployment config and restart every relay pod.
BUZZ_MEDIA_READ_MODE=log-only
# Max connections in the relay's shared Redis pool (default 16).
# BUZZ_REDIS_POOL_SIZE=16

Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/_ci-relay.yml
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,9 @@ jobs:

relay-e2e:
name: Relay E2E
env:
BUZZ_MEDIA_READ_MODE: enforce
BUZZ_USAGE_METRICS_INTERVAL_SECS: "5"
runs-on: ubuntu-latest
timeout-minutes: 20
if: inputs.lane == 'required' && (github.event_name == 'push' || inputs.rust)
Expand Down Expand Up @@ -643,7 +646,7 @@ jobs:
# --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
cargo test -p buzz-test-client --no-fail-fast --test e2e_media --test e2e_media_extended --test e2e_media_review --test e2e_media_video -- --ignored --nocapture
env:
RELAY_URL: ws://localhost:3000
RELAY_HTTP_URL: http://localhost:3000
Expand Down
2 changes: 2 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,8 @@ shape is an explicit transition, not a flag day:
never spans communities. Serving leases use the same application admission.
Startup and deletion catalog validation tolerate the universal fence objects
being absent; inventories still record live fences for older executors.
Desired-state bootstraps install only explicitly declared legacy fences.
New tenant-owned tables rely on application admission, without a fence trigger.
- **Replica floor (transition foundation):** runtime now provides a shared
replica-floor lock helper for compliant channel-event writers, and the writer
probe handshake takes the exclusive counterpart before sampling `S`, scanning
Expand Down
24 changes: 24 additions & 0 deletions crates/buzz-admin/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,22 @@ enum Command {
#[arg(long, default_value_t = 10_000_000)]
max_objects: u64,
},
/// Record media shares for events stored before share recording existed.
///
/// Run after every relay pod records shares and
/// before enforcing channel-scoped media reads. Historical references are
/// trusted only before each community's schema-recorded cutoff.
/// Restores uploader proof from surviving server media_uploaded audit rows;
/// files without that evidence still need an upload of the exact bytes.
/// Later references use current permissions, so any run can grant references
/// live recording previously refused if their authors have since gained access.
/// Accept the resulting lasting grants before the first backfill; otherwise
/// defer it. Each invocation restarts the scan and deduplicates existing rows.
BackfillMediaShares {
/// Events read and recorded per transaction.
#[arg(long, default_value_t = 1000, value_parser = clap::value_parser!(i64).range(1..))]
batch_size: i64,
},
/// Run the partition catalog audit using a read-only database session.
PartitionAudit {
/// Future months to include in the coverage check.
Expand Down Expand Up @@ -193,6 +209,14 @@ async fn run(cli: Cli) -> Result<i32> {
Ok(0)
}
Command::StorageSnapshot { max_objects } => cmd_storage_snapshot(max_objects).await,
Command::BackfillMediaShares { batch_size } => {
let added = connect_db()
.await?
.backfill_media_shares(batch_size)
.await?;
println!("Recorded {added} media shares.");
Ok(0)
}
Command::PartitionAudit { months_ahead } => cmd_partition_audit(months_ahead).await,
Command::AddMember { pubkey, role } => cmd_add_member(pubkey, role).await,
Command::RemoveMember { pubkey, role } => cmd_remove_member(pubkey, role).await,
Expand Down
4 changes: 4 additions & 0 deletions crates/buzz-audit/src/action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ pub enum AuditAction {
RateLimitExceeded,
/// A media file was uploaded via the Blossom endpoint.
MediaUploaded,
/// A moderator received temporary access to a reported message's file.
ModerationMediaRead,
}

impl AuditAction {
Expand All @@ -45,6 +47,7 @@ impl AuditAction {
Self::AuthFailure => "auth_failure",
Self::RateLimitExceeded => "rate_limit_exceeded",
Self::MediaUploaded => "media_uploaded",
Self::ModerationMediaRead => "moderation_media_read",
}
}

Expand All @@ -60,6 +63,7 @@ impl AuditAction {
Self::AuthFailure,
Self::RateLimitExceeded,
Self::MediaUploaded,
Self::ModerationMediaRead,
];
}

Expand Down
2 changes: 2 additions & 0 deletions crates/buzz-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ pub mod git_perms;
pub mod invite;
/// Buzz kind number registry — custom event type constants.
pub mod kind;
/// Which media blobs an event references, for channel-scoped media access.
pub mod media;
/// Network utilities — SSRF-safe IP classification.
pub mod network;
/// NIP-10 thread-marker parsing — shared `root`/`reply` marker resolver.
Expand Down
151 changes: 151 additions & 0 deletions crates/buzz-core/src/media.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
//! Which media blobs an event shares, for channel-scoped media access.

use std::collections::BTreeSet;

use crate::kind::{
event_is_shared, is_author_only_event_kind, is_shared_gated_kind, KIND_AGENT_ENGRAM,
P_GATED_KINDS,
};

/// Hashes of every blob `event` shares into its channel, or with the whole
/// community when it is channel-less: each local media path `/media/<sha256>`
/// anywhere in its content or tags (attachments, posters, thumbnails, link
/// previews, avatars, custom emoji, pasted links).
///
/// Empty for an event only its author or tagged recipients can read, since
/// referencing a blob there shows it to no one new.
pub fn shared_blob_hashes(event: &nostr::Event) -> Vec<String> {
let kind = u32::from(event.kind.as_u16());
if is_author_only_event_kind(event)
|| P_GATED_KINDS.contains(&kind)
|| kind == KIND_AGENT_ENGRAM
|| (is_shared_gated_kind(kind) && !event_is_shared(event))
{
return Vec::new();
}
// JSON content may escape `/` as `\/`; scan the path as clients will see it.
let content = event.content.replace("\\/", "/");
let mut hashes: BTreeSet<String> = media_path_hashes(&content).collect();
for tag in event.tags.iter() {
hashes.extend(
tag.as_slice()
.iter()
.flat_map(|part| media_path_hashes(part)),
);
}
hashes.into_iter().collect()
}

/// The media blob used as a kind:0 profile's picture. A registered agent may
/// publish this one field using its owner's upload proof; arbitrary content
/// and tags must not inherit that authority.
pub fn profile_picture_blob_hash(event: &nostr::Event) -> Option<String> {
if event.kind != nostr::Kind::Metadata {
return None;
}
let profile: serde_json::Value = serde_json::from_str(&event.content).ok()?;
let hash = media_path_hashes(profile.get("picture")?.as_str()?).next();
hash
}

fn is_sha256_hex(value: &str) -> bool {
value.len() == 64 && value.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f'))
}

/// The hash in each `/media/<sha256>` path in `text`, where the hash is not
/// followed by another hex digit.
fn media_path_hashes(text: &str) -> impl Iterator<Item = String> + '_ {
text.match_indices("/media/").filter_map(|(at, path)| {
let rest = &text[at + path.len()..];
let hash = rest.get(..64)?;
let bounded = !rest[64..].starts_with(|c: char| matches!(c, '0'..='9' | 'a'..='f'));
(is_sha256_hex(hash) && bounded).then(|| hash.to_owned())
})
}

#[cfg(test)]
mod tests {
use super::*;
use crate::kind::{KIND_EVENT_REMINDER, KIND_PERSONA};
use nostr::{EventBuilder, Keys, Kind, Tag};

fn media_url(sha256: &str, ext: &str) -> String {
format!("https://relay.example/media/{sha256}.{ext}")
}

fn signed(builder: EventBuilder) -> nostr::Event {
builder.sign_with_keys(&Keys::generate()).unwrap()
}

#[test]
fn shares_every_local_media_path() {
let (video, poster, embed) = ("a".repeat(64), "c".repeat(64), "d".repeat(64));
let event = signed(
EventBuilder::new(
Kind::TextNote,
format!("look ![image]({})", media_url(&embed, "png")),
)
.tags([
Tag::parse([
"imeta",
&format!("url {}", media_url(&video, "mp4")),
&format!("x {video}"),
&format!("image /media/{poster}.jpg"),
])
.unwrap(),
Tag::parse([
"r",
&format!("https://elsewhere.example/{}", "e".repeat(64)),
])
.unwrap(),
Tag::parse(["r", &format!("/media/{}0", "f".repeat(64))]).unwrap(),
]),
);
assert_eq!(shared_blob_hashes(&event), vec![video, poster, embed]);
}

#[test]
fn shares_avatars_emoji_and_shared_personas_only() {
let (avatar, emoji) = ("c".repeat(64), "d".repeat(64));
let content = format!(r#"{{"picture":"{}"}}"#, media_url(&avatar, "jpg"));
let profile = signed(EventBuilder::new(Kind::Metadata, &content));
assert_eq!(shared_blob_hashes(&profile), vec![avatar.clone()]);
let escaped = signed(EventBuilder::new(
Kind::Metadata,
content.replace('/', "\\/"),
));
assert_eq!(shared_blob_hashes(&escaped), vec![avatar.clone()]);

let emoji_set = signed(
EventBuilder::new(Kind::from(30030), "").tags([Tag::parse([
"emoji",
"party",
&media_url(&emoji, "png"),
])
.unwrap()]),
);
assert_eq!(shared_blob_hashes(&emoji_set), vec![emoji]);

let persona = |shared: bool| {
let mut tags = vec![Tag::parse(["d", "helper"]).unwrap()];
if shared {
tags.push(Tag::parse(["shared", "true"]).unwrap());
}
signed(EventBuilder::new(Kind::from(KIND_PERSONA as u16), &content).tags(tags))
};
assert_eq!(shared_blob_hashes(&persona(true)), vec![avatar]);
assert!(shared_blob_hashes(&persona(false)).is_empty());
}

#[test]
fn author_only_events_share_nothing() {
let reminder = signed(
EventBuilder::new(Kind::from(KIND_EVENT_REMINDER as u16), "").tags([Tag::parse([
"imeta",
&format!("url /media/{}.jpg", "a".repeat(64)),
])
.unwrap()]),
);
assert!(shared_blob_hashes(&reminder).is_empty());
}
}
8 changes: 4 additions & 4 deletions crates/buzz-db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,10 @@ pub(crate) use runtime::{
};
pub use store::{
admin_moderation, allowlist, api_token, archived_identities, artifact, channel,
channel_members, community, deletion, dm, event, feed, git_repo, moderation, nip_fi_binding,
operator_listener, partition, personal_read, product_feedback, push, reaction, read_state,
relay_admin_actions, relay_invite, relay_members, relay_operators, reminder, replaceable,
storage_accounting, thread, thread_window, usage, user, workflow,
channel_members, community, deletion, dm, event, feed, git_repo, media_cleanup, moderation,
nip_fi_binding, operator_listener, partition, personal_read, product_feedback, push, reaction,
read_state, relay_admin_actions, relay_invite, relay_members, relay_operators, reminder,
replaceable, storage_accounting, thread, thread_window, usage, user, workflow,
};

pub use allowlist::AllowlistEntry;
Expand Down
16 changes: 15 additions & 1 deletion crates/buzz-db/src/runtime/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ use crate::Result;

static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("../../migrations");

#[cfg(test)]
#[path = "tests/schema_fence_bootstrap_postgres_tests.rs"]
mod schema_fence_bootstrap_postgres_tests;

/// Run all pending Buzz database migrations.
///
/// The entire run holds the exclusive [`SCHEMA_DESTRUCTION_LOCK_KEY`] session
Expand Down Expand Up @@ -769,7 +773,7 @@ mod postgres_tests {
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
migrations.sort_by_key(|migration| migration.version);

assert_eq!(migrations.len(), 67);
assert_eq!(migrations.len(), 68);
assert_eq!(migrations[64].version, 65);
let retire_ttl = strip_sql_comments(migrations[64].sql.as_str());
for statement in [
Expand Down Expand Up @@ -806,6 +810,16 @@ mod postgres_tests {
.sql
.as_str()
.contains("CREATE TABLE community_nip_fi_issuers"));
assert_eq!(migrations[67].version, 68);
assert!(strip_sql_comments(migrations[67].sql.as_str())
.trim_start()
.starts_with("SET LOCAL lock_timeout = '5s';"));
for table in ["media_uploaders", "media_shares"] {
assert!(migrations[67]
.sql
.as_str()
.contains(&format!("CREATE TABLE {table}")));
}
assert_eq!(migrations[55].version, 56);
assert!(migrations[55]
.sql
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
use sqlx::PgPool;

/// A fresh bootstrap keeps the declared backstops but never discovers new
/// community tables and silently attaches a trigger to them.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn migration_schema_bootstrap_keeps_only_explicit_legacy_fences() {
let pool = PgPool::connect(&crate::test_support::database_url())
.await
.unwrap();
sqlx::raw_sql("CREATE TABLE new_tenant_probe (community_id UUID NOT NULL)")
.execute(&pool)
.await
.unwrap();
// Only checked-in schema files contribute SQL to this test bootstrap.
sqlx::raw_sql(sqlx::AssertSqlSafe(
crate::test_support::desired_state_schema_sql(),
))
.execute(&pool)
.await
.unwrap();

for (table, expected) in [
("new_tenant_probe", false),
("events", true),
("users", true),
] {
let fenced: bool = sqlx::query_scalar(
"SELECT EXISTS (SELECT 1 FROM pg_trigger \
WHERE tgrelid = $1::regclass \
AND tgfoid = 'enforce_community_write_fence()'::regprocedure)",
)
.bind(table)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(fenced, expected, "unexpected fence on {table}");
}
}
9 changes: 2 additions & 7 deletions crates/buzz-db/src/store/channel_members.rs
Original file line number Diff line number Diff line change
Expand Up @@ -556,13 +556,8 @@ impl LockedMemberSnapshot {
"member snapshot event id already exists".into(),
));
}
crate::store::event_follow_up::after_admitted_insert(
&mut self.tx,
event.id.as_bytes().as_slice(),
kind,
Some(channel_id),
)
.await?;
crate::store::event_follow_up::after_admitted_insert(&mut self.tx, event, Some(channel_id))
.await?;
crate::insert_mentions_in_transaction(&mut self.tx, event, Some(channel_id)).await?;
Ok((
buzz_core::StoredEvent::with_received_at(
Expand Down
Loading
Loading