Files
buzz/crates/buzz-relay/src/api/buzz_v1/handlers.rs
Bradley Axen 1e80066c93 feat(relay): reshape read-state API to the sidebar wire format (#8220)
## Summary

Changes the private read-state API (`/buzz/v1`) to the sidebar wire
format agreed in #buzzin-in-secret. **The counting code is unchanged.**
Requests and responses take the new shape, so the client integration can
build against it now and later behavior fixes (counting rules, followed
threads, another-device pings) ship without another wire change. Nothing
ships against v1 yet (no buzz-app use on `main`), so this replaces
version 1 directly.

Supersedes #8188 (same branch content, rebased onto current `main`, plus
the thread-cap and doc changes below).

```
GET /buzz/v1/me/sidebar?limit=&cursor=  |  ?channel_ids=a,b,…
→ { channels: [channel], next_cursor }
channel: { channel_id, unread: bool, mentions, read_through_id|null, latest_id|null, threads: [thread] }
thread:  { root_id, unread: bool, mentions, read_through_id|null, latest_id }

POST /buzz/v1/me/read-state
{ intents: [{ type: "mark_through", target: { channel_id [, root_id] }, message_id }] }
→ { outcomes: [applied|blocked|invalid|unknown], channels: [channel] }
```

What changed and why:
- **Channel and thread positions are fully separate.** Channel
`unread`/`mentions` count only top-level messages. Thread replies count
only on their thread row. `mark_channel_read` and its whole-channel cut
are gone. "Mark channel read" sends a channel `mark_through` at
`latest_id`, plus one per thread row to clear threads too.
- **Channel `latest_id` is always a top-level message.** It used to be
able to point at a reply, and a channel `mark_through` rejects a reply
as its anchor. It also skips a reply-marked message whose ancestry never
resolved, because marking through one would fail on every retry.
- **`read_through_id`:** frontiers stored only the anchor's arrival
time. Migration 0058 adds `personal_read_frontiers.through_message_id`,
which follows the greatest arrival, and drops the now-unused
`threads_through_timestamp`. This is the only storage change.
- **Counts are plain numbers.** The exact/at_least/unknown wrappers are
gone. Evidence the relay can't use (unusable tags, unresolved ancestry,
undecided conversation membership, past the scan cap) leaves the message
out, so counts can undercount.
- **POST returns the updated row** of each distinct channel it applied
to, so the client doesn't refetch after every read. If those rows can't
be read after the writes commit, `channels` is omitted and the outcomes
still stand. The row refresh shares the 8 s intent deadline.
- **Up to 25 thread rows per channel** (was 5). Five hid a mention in a
sixth unread thread. 25 is the most that keeps a POST's rows for
`MAX_INTENTS` (100) channels inside the 1 MiB response limit; 50 would
be ~1.4 MB, a 503 that hides already-committed outcomes. A unit test
builds that worst-case response and fails if the cap outgrows the limit.
- **The doc separates shape from behavior.** `/buzz/v1` promises a
stable shape (fields, types, status variants, what `mark_through` does).
What the counts include (which messages are unread or directed, which
threads are listed, the bounds) may change while v1 is pre-release, so
clients replace their rows with each response instead of reconciling
against local counts.
- **Removed from the wire:** the explicit-contexts read (`GET
/me/read-state?targets=`), `account`, channel name/type/archived/hidden
(these come from Nostr metadata), `latest_message_at`/`_complete`, and
the NIP-11 `max_contexts`/`max_context_messages`/`max_thread_summaries`.

Deliberately deferred (behavior or additive, no shape change):
- Thread counting rule (relay counts a reply if you wrote or replied to
its direct parent; the client uses "posted anywhere in the thread or
followed"), the workflow-owner attribution p-tag, and thread-row
`mentions` counting every counted reply rather than directed ones.
- `follow`/`unfollow` intents, listing followed threads that are already
read, and a truncation flag for the thread list.

Thread: buzz://channel/6f773b94-34d0-4004-bec9-a100b6ad17d5 (#buzz-bff,
root 31020fff)

### Related issue
Follows #8157. Supersedes #8188; replaces the wire parts of the closed
#8155.

### Testing
- `cargo test --no-fail-fast -p buzz-db -p buzz-relay` at 1f041e46: all
pass except 7 relay unit tests (`api::media::tests` ×6 needing a seeded
local DB, `api::mesh_demo` round trip) that fail identically on `main`
(394d4a43) in the same environment.
- `scripts/postgres-test-run.sh -p buzz-db -p buzz-relay` against local
PG14 + Redis at 88d2f6fc: 836 passed, 26 failed. 25 also fail on `main`
in the same environment (partition maintenance tests and
`huddle_started_links`, which need PG16). The 26th,
`boot_lifecycle::hanging_redis_peer_times_out…`, is a timing assertion
that failed under full-suite load and passed 3/3 when rerun alone on
this branch.
- All `personal_read`, `migration`, `buzz_v1`/`accessory` and NIP-FI
wire-contract tests pass, including the new response-size bound test and
the 25-thread `summarize` cap test.
- `cargo fmt --check` and `cargo clippy -p buzz-db -p buzz-relay --tests
-D warnings` are clean.
- Not done: running a standalone relay binary (the dev object store
rejected writes with HTTP 403). The relay tests drive the real HTTP
router with NIP-98-signed requests. A human test against a local relay
is still needed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Honey <8e307ae0076a4dab6b94b036ea3edc7e08f823a625269c1e6919e881a048b4d2@buzz.block.builderlab.xyz>
Co-authored-by: Honey <8e307ae0076a4dab6b94b036ea3edc7e08f823a625269c1e6919e881a048b4d2@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:07:19 -07:00

247 lines
8.5 KiB
Rust

use super::auth::{self, Error};
use crate::state::AppState;
use axum::{
body::Bytes,
extract::{OriginalUri, Query, State},
http::HeaderMap,
response::Response,
};
use buzz_db::personal_read::{
ChannelReadSummary, ReadIntent, SidebarPage, MAX_CHANNELS, MAX_INTENTS,
};
use serde::Deserialize;
use serde_json::{json, Value};
use std::{collections::BTreeSet, sync::Arc, time::Duration};
use uuid::Uuid;
/// 1..=MAX_CHANNELS unique UUIDs, comma-separated; anything else is invalid.
fn parse_channel_ids(value: &str) -> Option<Vec<Uuid>> {
let ids = value
.split(',')
.map(|id| Uuid::parse_str(id).ok())
.collect::<Option<Vec<_>>>()?;
let unique: std::collections::HashSet<_> = ids.iter().collect();
((1..=MAX_CHANNELS).contains(&ids.len()) && unique.len() == ids.len()).then_some(ids)
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct SidebarQuery {
limit: Option<usize>,
cursor: Option<Uuid>,
/// Comma-separated channel UUIDs to refresh; exclusive with paging.
channel_ids: Option<String>,
}
pub(super) async fn sidebar(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
OriginalUri(uri): OriginalUri,
query: Result<Query<SidebarQuery>, axum::extract::rejection::QueryRejection>,
) -> Result<Response, Error> {
tokio::time::timeout(Duration::from_secs(8), async {
let principal = auth::authorize(&state, &headers, &uri, "GET", None).await?;
let Query(query) = query.map_err(|_| Error::invalid())?;
if query
.limit
.is_some_and(|limit| !(1..=MAX_CHANNELS).contains(&limit))
{
return Err(Error::invalid());
}
let community = principal.tenant.community();
let retention = state.config.buzz_v1_retention_seconds;
let page = match query.channel_ids {
Some(ids) => {
let ids = parse_channel_ids(&ids)
.filter(|_| query.limit.is_none() && query.cursor.is_none())
.ok_or_else(Error::invalid)?;
state
.db
.personal_read_sidebar_channels(community, &principal.actor, retention, &ids)
.await
}
None => {
state
.db
.personal_read_sidebar(
community,
&principal.actor,
retention,
query.limit.unwrap_or(MAX_CHANNELS),
query.cursor,
)
.await
}
}
.map_err(|_| Error::unavailable())?;
auth::response(release(&state, &headers, &principal, page).await?)
})
.await
.map_err(|_| Error::unavailable())?
}
/// Release projected rows only after admission and the actor's membership of
/// every row are rechecked outside the projection snapshot.
async fn release(
state: &AppState,
headers: &HeaderMap,
principal: &auth::Principal,
page: SidebarPage,
) -> Result<SidebarPage, Error> {
auth::recheck(state, headers, principal).await?;
let channels: Vec<_> = page.channels.iter().map(|c| c.channel_id).collect();
let memberships = state
.db
.membership_pairs(
principal.tenant.community(),
&channels,
&[principal.actor.to_bytes().to_vec()],
)
.await
.map_err(|_| Error::unavailable())?;
if memberships.len() != channels.len() {
return Err(Error::unavailable());
}
Ok(page)
}
/// Updated rows for the channels a batch applied to. A channel the actor has
/// not joined has no row.
async fn refreshed(
state: &AppState,
headers: &HeaderMap,
principal: &auth::Principal,
channels: &BTreeSet<Uuid>,
) -> Result<Vec<ChannelReadSummary>, Error> {
let channels: Vec<_> = channels.iter().copied().collect();
let mut rows = Vec::with_capacity(channels.len());
for chunk in channels.chunks(MAX_CHANNELS) {
let page = state
.db
.personal_read_sidebar_channels(
principal.tenant.community(),
&principal.actor,
state.config.buzz_v1_retention_seconds,
chunk,
)
.await
.map_err(|_| Error::unavailable())?;
rows.extend(release(state, headers, principal, page).await?.channels);
}
Ok(rows)
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Batch {
intents: Vec<Value>,
}
pub(super) async fn write(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
OriginalUri(uri): OriginalUri,
body: Bytes,
) -> Result<Response, Error> {
if body.len() > 64 * 1024 {
return Err(Error::invalid());
}
let principal = auth::authorize(&state, &headers, &uri, "POST", Some(&body)).await?;
let batch: Batch = serde_json::from_slice(&body).map_err(|_| Error::invalid())?;
if batch.intents.is_empty() || batch.intents.len() > MAX_INTENTS {
return Err(Error::invalid());
}
let deadline = tokio::time::Instant::now() + Duration::from_secs(8);
let mut outcomes = Vec::with_capacity(batch.intents.len());
let mut applied = BTreeSet::new();
for item in batch.intents {
let Ok(intent) = serde_json::from_value::<ReadIntent>(item) else {
outcomes.push(json!({"status":"invalid"}));
continue;
};
// A deadline/DB failure after commit is ambiguous, not a false failure.
// Earlier acknowledged commits survive all later projection/item failures.
let outcome = tokio::time::timeout_at(
deadline,
write_intent(&state, &headers, &principal, &intent),
)
.await
.unwrap_or_else(|_| json!({"status":"unknown","retryable":true}));
if outcome["status"] == "applied" {
let ReadIntent::MarkThrough { target, .. } = &intent;
applied.insert(target.channel_id);
}
outcomes.push(outcome);
}
let mut body = json!({ "outcomes": outcomes });
// Committed outcomes stand even when their rows cannot be read in the
// remaining budget: omit them.
if let Ok(Ok(channels)) =
tokio::time::timeout_at(deadline, refreshed(&state, &headers, &principal, &applied)).await
{
body["channels"] = json!(channels);
}
auth::response(body)
}
// Preserve earlier committed outcomes while distinguishing a definite denial
// before the transaction from an ambiguous storage/timeout failure.
pub(super) async fn write_intent(
state: &AppState,
headers: &HeaderMap,
principal: &auth::Principal,
intent: &ReadIntent,
) -> Value {
if let Err(error) = auth::recheck(state, headers, principal).await {
return if error.terminal_denial() {
json!({"status":"blocked"})
} else {
json!({"status":"unknown","retryable":true})
};
}
match state
.db
.apply_personal_read_intent(principal.tenant.community(), &principal.actor, intent)
.await
{
Ok(outcome) => json!(outcome),
Err(_) => json!({"status":"unknown","retryable":true}),
}
}
#[cfg(test)]
mod tests {
use super::*;
use buzz_db::personal_read::{ThreadReadSummary, MAX_THREAD_SUMMARIES};
/// A POST that applied to `MAX_INTENTS` channels, each with a full thread
/// list, must still fit: an oversized body is a 503 that hides committed
/// outcomes.
#[test]
fn largest_write_response_fits_the_response_limit() {
let id = || Some("f".repeat(64));
let channels: Vec<_> = (0..MAX_INTENTS)
.map(|_| ChannelReadSummary {
channel_id: Uuid::max(),
unread: true,
mentions: u32::MAX,
read_through_id: id(),
latest_id: id(),
threads: (0..MAX_THREAD_SUMMARIES)
.map(|_| ThreadReadSummary {
root_id: "f".repeat(64),
unread: true,
mentions: u32::MAX,
read_through_id: id(),
latest_id: "f".repeat(64),
})
.collect(),
})
.collect();
let outcomes = vec![json!({"status":"unknown","retryable":true}); MAX_INTENTS];
let body = json!({ "outcomes": outcomes, "channels": channels });
let bytes = serde_json::to_vec(&body).unwrap().len();
assert!(bytes <= auth::MAX_RESPONSE_BYTES, "{bytes} bytes");
}
}