diff --git a/src/api/share.rs b/src/api/share.rs index c5b213a..fb7916f 100644 --- a/src/api/share.rs +++ b/src/api/share.rs @@ -1,6 +1,6 @@ -//! `/api/v1/share/*` — public share-link surface per Phase 1.g.1. +//! `/api/v1/share/*` — public share-link surface per Phase 1.g. //! -//! Three routes split across the same auth-vs-public boundary the +//! Five routes split across the same auth-vs-public boundary the //! streaming module uses: //! //! - **Mint** (`POST /api/v1/profiles/{profile_id}/playlists/{playlist_id}/share`): @@ -12,6 +12,17 @@ //! - **Revoke** (`DELETE /api/v1/profiles/{profile_id}/playlists/{playlist_id}/share`): //! JWT-authed. Sets `share_token = NULL`, instantly closing any //! public URL pointing at this playlist. +//! - **Mint by canonical** +//! (`POST /api/v1/share/playlists/by-canonical/{profile_canonical_id}/{playlist_canonical_id}`): +//! JWT-authed. Same semantics as `mint`, but keyed on the desktop's +//! canonical UUIDs (Phase 1.g.0). The desktop never sees the +//! server-side BIGSERIAL ids the apply pipeline assigns, so this +//! variant skips the lookup round-trip the desktop would otherwise +//! need to translate canonical → server id before calling the +//! classic endpoint. +//! - **Revoke by canonical** +//! (`DELETE /api/v1/share/playlists/by-canonical/{profile_canonical_id}/{playlist_canonical_id}`): +//! Mirror of revoke for the canonical-id surface. //! - **Public read** (`GET /api/v1/share/playlists/{token}`): NOT //! behind the JWT middleware — the token IS the auth. A miss (no //! row matches the token) returns 404 with no body so an attacker @@ -88,6 +99,8 @@ pub fn auth_router(state: AppState) -> OpenApiRouter { OpenApiRouter::new() .routes(routes!(mint_share_token)) .routes(routes!(revoke_share_token)) + .routes(routes!(mint_share_token_by_canonical)) + .routes(routes!(revoke_share_token_by_canonical)) .with_state(state) } @@ -159,6 +172,94 @@ async fn revoke_share_token( } } +#[utoipa::path( + post, + path = "/api/v1/share/playlists/by-canonical/{profile_canonical_id}/{playlist_canonical_id}", + tag = "share", + params( + ("authorization" = String, Header, description = "Bearer JWT issued by Better Auth"), + ("profile_canonical_id" = String, Path, description = "Desktop profile's canonical UUID"), + ("playlist_canonical_id" = String, Path, description = "Desktop playlist's canonical UUID"), + ), + responses( + (status = 200, description = "Token minted or echoed back", body = MintResponse), + (status = 401, description = "Missing or invalid bearer token"), + (status = 404, description = "Profile or playlist not found (or not owned by the caller)"), + (status = 500, description = "Database or internal failure"), + ), +)] +async fn mint_share_token_by_canonical( + State(state): State, + Extension(UserId(user_id)): Extension, + Path((profile_canonical_id, playlist_canonical_id)): Path<(String, String)>, +) -> impl IntoResponse { + match crate::db::share::mint_or_get_token_by_canonical( + &state.db, + user_id, + &profile_canonical_id, + &playlist_canonical_id, + ) + .await + { + Ok(Some(token)) => (StatusCode::OK, Json(MintResponse { token })).into_response(), + Ok(None) => (StatusCode::NOT_FOUND, "playlist not found").into_response(), + Err(err) => { + tracing::error!( + error = %err, + user_id, + profile_canonical_id = %profile_canonical_id, + playlist_canonical_id = %playlist_canonical_id, + "share mint (by canonical) failed", + ); + (StatusCode::INTERNAL_SERVER_ERROR, "mint failed").into_response() + } + } +} + +#[utoipa::path( + delete, + path = "/api/v1/share/playlists/by-canonical/{profile_canonical_id}/{playlist_canonical_id}", + tag = "share", + params( + ("authorization" = String, Header, description = "Bearer JWT issued by Better Auth"), + ("profile_canonical_id" = String, Path, description = "Desktop profile's canonical UUID"), + ("playlist_canonical_id" = String, Path, description = "Desktop playlist's canonical UUID"), + ), + responses( + (status = 204, description = "Token cleared (idempotent)"), + (status = 401, description = "Missing or invalid bearer token"), + (status = 404, description = "Profile or playlist not found (or not owned by the caller)"), + (status = 500, description = "Database or internal failure"), + ), +)] +async fn revoke_share_token_by_canonical( + State(state): State, + Extension(UserId(user_id)): Extension, + Path((profile_canonical_id, playlist_canonical_id)): Path<(String, String)>, +) -> impl IntoResponse { + match crate::db::share::revoke_token_by_canonical( + &state.db, + user_id, + &profile_canonical_id, + &playlist_canonical_id, + ) + .await + { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => (StatusCode::NOT_FOUND, "playlist not found").into_response(), + Err(err) => { + tracing::error!( + error = %err, + user_id, + profile_canonical_id = %profile_canonical_id, + playlist_canonical_id = %playlist_canonical_id, + "share revoke (by canonical) failed", + ); + (StatusCode::INTERNAL_SERVER_ERROR, "revoke failed").into_response() + } + } +} + #[utoipa::path( get, path = "/api/v1/share/playlists/{token}", diff --git a/src/db.rs b/src/db.rs index 922b686..096a8c4 100644 --- a/src/db.rs +++ b/src/db.rs @@ -384,4 +384,63 @@ pub mod share { .fetch_optional(pool) .await } + + /// Variant of [`mint_or_get_token`] keyed on canonical ids + /// instead of BIGSERIAL ids. The desktop only knows the UUIDs + /// it mints locally; the server-side ids are an artefact of + /// the apply pipeline that the desktop never sees directly. + /// Same race-free `COALESCE` shape, same no-existence-leak + /// `Ok(None)` for foreign tenants. The tenant chain becomes + /// `(user_id, profile.canonical_id, playlist.canonical_id)` + /// — a desktop user can only mint for their own profile, and + /// the playlist must already have been materialised by the + /// apply pipeline (see `apply::playlist::insert`). + pub async fn mint_or_get_token_by_canonical( + pool: &PgPool, + user_id: i64, + profile_canonical_id: &str, + playlist_canonical_id: &str, + ) -> Result, sqlx::Error> { + let candidate = Alphanumeric.sample_string(&mut rand::thread_rng(), TOKEN_LEN); + sqlx::query_scalar::<_, String>( + "UPDATE playlist + SET share_token = COALESCE(share_token, $1) + WHERE canonical_id = $2 + AND profile_id IN ( + SELECT id FROM profile + WHERE user_id = $3 AND canonical_id = $4 + ) + RETURNING share_token", + ) + .bind(&candidate) + .bind(playlist_canonical_id) + .bind(user_id) + .bind(profile_canonical_id) + .fetch_optional(pool) + .await + } + + /// Variant of [`revoke_token`] keyed on canonical ids. + pub async fn revoke_token_by_canonical( + pool: &PgPool, + user_id: i64, + profile_canonical_id: &str, + playlist_canonical_id: &str, + ) -> Result { + let res = sqlx::query( + "UPDATE playlist + SET share_token = NULL + WHERE canonical_id = $1 + AND profile_id IN ( + SELECT id FROM profile + WHERE user_id = $2 AND canonical_id = $3 + )", + ) + .bind(playlist_canonical_id) + .bind(user_id) + .bind(profile_canonical_id) + .execute(pool) + .await?; + Ok(res.rows_affected() > 0) + } } diff --git a/tests/share.rs b/tests/share.rs index 75e01fb..b0ac6e9 100644 --- a/tests/share.rs +++ b/tests/share.rs @@ -19,6 +19,7 @@ use reqwest::StatusCode; use serde_json::{json, Value}; use sqlx::PgPool; use support::{spawn_authenticated, spawn_two_authenticated}; +use uuid::Uuid; async fn mint_profile(base: &str, token: &str, name: &str) -> i64 { let created: Value = reqwest::Client::new() @@ -52,6 +53,50 @@ async fn mint_playlist(base: &str, token: &str, profile_id: i64, name: &str) -> created["id"].as_i64().expect("playlist id missing") } +/// Push a playlist `insert` sync_op carrying both canonical ids and +/// wait for the apply pipeline to materialise the row in the same +/// transaction. Mirrors what the desktop drain task will do once +/// Phase 1.g.0-desktop ships. +/// +/// Each call uses a fresh UUID for `device_id` so repeated +/// invocations within the same test never hit the +/// `(user_id, device_id, lamport_ts)` UNIQUE — the constraint is +/// scoped per device, so two "different devices" can both use +/// lamport_ts = 1 without colliding. Simpler than threading a +/// counter through callers, and accurate to real desktop life +/// where every test scenario plays the role of a fresh device. +async fn materialise_playlist_via_sync( + base: &str, + token: &str, + profile_canonical: &str, + playlist_canonical: &str, + name: &str, +) { + let resp = reqwest::Client::new() + .post(format!("{base}/api/v1/sync/ops")) + .bearer_auth(token) + .json(&json!({ + "device_id": Uuid::new_v4().to_string(), + "ops": [{ + "operation_id": Uuid::new_v4(), + "lamport_ts": 1, + "entity": "playlist", + "entity_id": playlist_canonical, + "op": "insert", + "payload": { "name": name }, + "profile_canonical_id": profile_canonical, + }], + })) + .send() + .await + .expect("sync push failed"); + assert!( + resp.status().is_success(), + "sync push for materialisation must succeed: {}", + resp.status() + ); +} + #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn mint_returns_token_and_public_get_resolves(pool: PgPool) { let h = spawn_authenticated(pool, "user-share").await; @@ -267,6 +312,216 @@ async fn public_get_unknown_token_is_404(pool: PgPool) { assert_eq!(resp.status(), StatusCode::NOT_FOUND); } +// --------------------------------------------------------------- +// by-canonical surface (Phase 1.g.1b) +// --------------------------------------------------------------- + +const PROF_CANON: &str = "prof-1111aaaa-1111-4111-8111-111111111111"; +const PROF_CANON_B: &str = "prof-2222bbbb-2222-4222-8222-222222222222"; + +#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] +async fn by_canonical_mint_resolves_via_public_get(pool: PgPool) { + let h = spawn_authenticated(pool, "user-share-canon").await; + let pl_canon = "pl-1g1b-aaaa"; + + materialise_playlist_via_sync(&h.base, &h.token, PROF_CANON, pl_canon, "Soirée canon").await; + + let resp: Value = reqwest::Client::new() + .post(format!( + "{}/api/v1/share/playlists/by-canonical/{PROF_CANON}/{pl_canon}", + h.base + )) + .bearer_auth(&h.token) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + let token = resp["token"].as_str().expect("mint token").to_string(); + assert_eq!(token.len(), 32); + + // Same public GET surface — by-canonical mint is just a different + // way to land the share_token row, the read side is unchanged. + let public: Value = reqwest::Client::new() + .get(format!("{}/api/v1/share/playlists/{}", h.base, token)) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(public["name"].as_str(), Some("Soirée canon")); +} + +#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] +async fn by_canonical_mint_is_idempotent(pool: PgPool) { + let h = spawn_authenticated(pool, "user-share-canon-idem").await; + let pl_canon = "pl-1g1b-bbbb"; + + materialise_playlist_via_sync(&h.base, &h.token, PROF_CANON, pl_canon, "Idem").await; + + let mint_once = || async { + reqwest::Client::new() + .post(format!( + "{}/api/v1/share/playlists/by-canonical/{PROF_CANON}/{pl_canon}", + h.base + )) + .bearer_auth(&h.token) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json::() + .await + .unwrap() + }; + + let t1 = mint_once().await["token"].as_str().unwrap().to_string(); + let t2 = mint_once().await["token"].as_str().unwrap().to_string(); + assert_eq!(t1, t2, "mint must be idempotent"); +} + +#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] +async fn by_canonical_revoke_closes_the_link(pool: PgPool) { + let h = spawn_authenticated(pool, "user-share-canon-revoke").await; + let pl_canon = "pl-1g1b-cccc"; + + materialise_playlist_via_sync(&h.base, &h.token, PROF_CANON, pl_canon, "Revoke me").await; + + // Mint then revoke. + let mint: Value = reqwest::Client::new() + .post(format!( + "{}/api/v1/share/playlists/by-canonical/{PROF_CANON}/{pl_canon}", + h.base + )) + .bearer_auth(&h.token) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + let token = mint["token"].as_str().unwrap().to_string(); + + let revoke = reqwest::Client::new() + .delete(format!( + "{}/api/v1/share/playlists/by-canonical/{PROF_CANON}/{pl_canon}", + h.base + )) + .bearer_auth(&h.token) + .send() + .await + .unwrap(); + assert_eq!(revoke.status(), StatusCode::NO_CONTENT); + + // Public GET now 404s on the revoked token. + let resp = reqwest::Client::new() + .get(format!("{}/api/v1/share/playlists/{}", h.base, token)) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] +async fn by_canonical_revoke_then_remint_returns_fresh_token(pool: PgPool) { + // Mirrors `revoke_then_remint_returns_a_fresh_token` for the + // by-canonical surface: pins the COALESCE-on-NULL path. After a + // revoke sets share_token to NULL, the next mint must generate a + // brand-new candidate rather than re-using the prior value. + let h = spawn_authenticated(pool, "user-share-canon-remint").await; + let pl_canon = "pl-1g1b-remint"; + + materialise_playlist_via_sync(&h.base, &h.token, PROF_CANON, pl_canon, "Remint").await; + + let mint_url = format!( + "{}/api/v1/share/playlists/by-canonical/{PROF_CANON}/{pl_canon}", + h.base + ); + + let first: Value = reqwest::Client::new() + .post(&mint_url) + .bearer_auth(&h.token) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + let t1 = first["token"].as_str().unwrap().to_string(); + + let revoke = reqwest::Client::new() + .delete(&mint_url) + .bearer_auth(&h.token) + .send() + .await + .unwrap(); + assert_eq!(revoke.status(), StatusCode::NO_CONTENT); + + let second: Value = reqwest::Client::new() + .post(&mint_url) + .bearer_auth(&h.token) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + let t2 = second["token"].as_str().unwrap().to_string(); + + assert_eq!(t2.len(), 32); + assert_ne!( + t1, t2, + "revoke + re-mint must produce a new token (NULL → fresh COALESCE candidate)" + ); +} + +#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] +async fn by_canonical_foreign_profile_is_404(pool: PgPool) { + let two = spawn_two_authenticated(pool, "alice-canon", "bob-canon").await; + let pl_canon = "pl-1g1b-foreign"; + + // Alice materialises the playlist under her profile. + materialise_playlist_via_sync(&two.base, &two.a.token, PROF_CANON, pl_canon, "Alice's").await; + + // Bob tries to mint with Alice's canonical ids — must 404. + let resp = reqwest::Client::new() + .post(format!( + "{}/api/v1/share/playlists/by-canonical/{PROF_CANON}/{pl_canon}", + two.base + )) + .bearer_auth(&two.b.token) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + + // Bob also can't mint pointing at an unknown profile canonical for + // his own playlists — same shape. + let resp = reqwest::Client::new() + .post(format!( + "{}/api/v1/share/playlists/by-canonical/{PROF_CANON_B}/{pl_canon}", + two.base + )) + .bearer_auth(&two.b.token) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + #[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] async fn mint_requires_auth(pool: PgPool) { let h = spawn_authenticated(pool, "user-share").await;