feat(podcast): a PODCAST tier, a topics field, and a feed a phone can actually reach
Three gaps between "the pipeline works" and "you can use it".
**1. Topics could not be set.** The wizard never sent `config.topics`, so every
mission created through the UI silently fell back to
`library::default_topics()` — a hardcoded list that is somebody else's research
interests. The card now takes one arXiv search per line, and the description
field says plainly that for this template it IS the brief the agents judge
relevance against.
**2. There was nowhere to see or subscribe.** New PODCAST tier in the left rail,
between AGENT and REPOS: the feed URL with a copy button, the episode list, and
an inline player for checking one at a desk. `GET /api/podcast/episodes` and
`/subscription` back it. The panel also reports how many missions produced no
audio, so a missing day reads as a known gap rather than silence.
**3. The feed 404'd for the only client that will ever request it.** Three
layers each assumed a browser:
- `resolveBearer` is server-only (`next/headers`), so a client component that
imported it broke the build outright. The panel now goes through the
same-origin proxy like every other panel, and the backend mints the feed URL
because the session lives in an httpOnly cookie JavaScript cannot read.
- The `/api` proxy demanded a session COOKIE. A podcast app has none and
carries `?token=` instead — the same shape as the existing `hooks/` prefix,
which is already exempt for exactly this reason.
- The local autologin middleware 307'd it to `/auth/autologin`. A podcast app
follows redirects blindly and would have stored an HTML page as the episode.
Neither exemption weakens auth: the backend still validates the token and
answers 401 to a bad one, verified. `episode_audio` accepts the token from
either the query string or an Authorization header, because the app fetches it
one way and the browser player the other, and refusing either breaks one of the
two ways this is listened to.
`CLAWMATES_PUBLIC_URL` matters and was wrong first: the tailnet root proxies to
a different service on :18789, and this frontend is on :8443. A feed advertising
an unreachable origin syncs silently forever, so `/subscription` returns a
`reachable` flag and the panel warns when it is still localhost.
Verified from a phone's point of view: feed 200 application/rss+xml over the
tailnet, enclosure 200 with 6,739,582 bytes of audio at 421s, bad token 401.
367 tests pass.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f4adc8d0f9
commit
fe45f72f09
@@ -22,6 +22,13 @@ pub struct FeedAuth {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
/// The audio endpoint accepts a token either way — see `episode_audio`.
|
||||
#[derive(Deserialize)]
|
||||
pub struct OptionalAuth {
|
||||
#[serde(default)]
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
/// Resolve a feed token to the workspace it may read.
|
||||
///
|
||||
/// Reuses the normal API token table, so revoking a token revokes the feed with
|
||||
@@ -128,9 +135,24 @@ pub async fn feed(
|
||||
pub async fn episode_audio(
|
||||
State(state): State<AppState>,
|
||||
Path(file): Path<String>,
|
||||
Query(auth): Query<FeedAuth>,
|
||||
Query(auth): Query<OptionalAuth>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Result<Response, ApiError> {
|
||||
let workspace_id = workspace_for(&state, &auth.token).await?;
|
||||
// A podcast app fetches this with the token in the URL, because it cannot
|
||||
// set headers. The browser plays it through the same-origin proxy, which
|
||||
// supplies a bearer and no query token. Both are the same session; refusing
|
||||
// either would break one of the two ways this is listened to.
|
||||
let token = auth
|
||||
.token
|
||||
.or_else(|| {
|
||||
headers
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.map(str::to_string)
|
||||
})
|
||||
.ok_or(ApiError::Unauthorized)?;
|
||||
let workspace_id = workspace_for(&state, &token).await?;
|
||||
let id = file
|
||||
.strip_suffix(".mp3")
|
||||
.and_then(|s| uuid::Uuid::parse_str(s).ok())
|
||||
@@ -165,6 +187,93 @@ pub async fn episode_audio(
|
||||
.into_response())
|
||||
}
|
||||
|
||||
/// `GET /api/podcast/subscription` — the URL to paste into a podcast app.
|
||||
///
|
||||
/// Minted here rather than in the browser because the session lives in an
|
||||
/// httpOnly cookie that JavaScript cannot read, and the same-origin proxy that
|
||||
/// normally supplies the bearer is not available to a podcast app on a phone.
|
||||
/// So the caller's own token is echoed back inside a URL that points DIRECTLY
|
||||
/// at this backend.
|
||||
pub async fn subscription(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
crate::extract::Authed(_user): crate::extract::Authed,
|
||||
) -> Result<axum::Json<serde_json::Value>, ApiError> {
|
||||
let token = headers
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.ok_or(ApiError::Unauthorized)?;
|
||||
|
||||
let base = std::env::var("CLAWMATES_PUBLIC_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:8080".to_string());
|
||||
let base = base.trim_end_matches('/');
|
||||
let _ = &state;
|
||||
Ok(axum::Json(serde_json::json!({
|
||||
"feedUrl": format!("{base}/api/podcast/feed.xml?token={token}"),
|
||||
// The panel warns when this is still localhost: a phone cannot reach it,
|
||||
// and a feed that only works on the machine that made it is a feed that
|
||||
// silently never syncs.
|
||||
"reachable": !base.contains("localhost") && !base.contains("127.0.0.1"),
|
||||
})))
|
||||
}
|
||||
|
||||
/// `GET /api/podcast/episodes` — the list behind the UI panel.
|
||||
///
|
||||
/// Normal bearer auth, unlike the feed: this is the app talking to its own API,
|
||||
/// where a header is available and a token in a URL would be needless exposure.
|
||||
pub async fn list_episodes(
|
||||
State(state): State<AppState>,
|
||||
crate::extract::Authed(user): crate::extract::Authed,
|
||||
) -> Result<axum::Json<serde_json::Value>, ApiError> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT e.id, e.episode_date, e.title, e.bytes, e.duration_secs,
|
||||
e.rendered_by, e.created_at, e.mission_id, m.title AS mission_title
|
||||
FROM podcast_episodes e
|
||||
JOIN missions m ON m.id = e.mission_id
|
||||
WHERE e.workspace_id = $1 AND e.bytes > 0
|
||||
ORDER BY e.created_at DESC
|
||||
LIMIT 50",
|
||||
)
|
||||
.bind(user.workspace_id.as_uuid())
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let episodes: Vec<serde_json::Value> = rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let secs: i32 = r.get("duration_secs");
|
||||
let created: time::OffsetDateTime = r.get("created_at");
|
||||
serde_json::json!({
|
||||
"id": r.get::<uuid::Uuid, _>("id"),
|
||||
"missionId": r.get::<uuid::Uuid, _>("mission_id"),
|
||||
"missionTitle": r.get::<String, _>("mission_title"),
|
||||
"title": r.get::<String, _>("title"),
|
||||
"date": r.get::<String, _>("episode_date"),
|
||||
"bytes": r.get::<i64, _>("bytes"),
|
||||
"durationSecs": secs,
|
||||
"renderedBy": r.get::<String, _>("rendered_by"),
|
||||
"createdAt": created.unix_timestamp(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// How many missions produced no audio, so the panel can say so rather than
|
||||
// leaving a silent gap the operator has to notice for themselves.
|
||||
let unrenderable: i64 = sqlx::query_scalar(
|
||||
"SELECT count(*) FROM podcast_episodes WHERE workspace_id = $1 AND bytes = 0",
|
||||
)
|
||||
.bind(user.workspace_id.as_uuid())
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(axum::Json(serde_json::json!({
|
||||
"episodes": episodes,
|
||||
"unrenderable": unrenderable,
|
||||
})))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user