//! `/api/v1/admin/apps/{id}/kv*` — read-only KV inspection (G2). //! //! Mirrors the minimal `files_api` / `queues_api` admin surface so the //! `pic kv` CLI (and a future dashboard tab) can browse stored keys //! without a script. **Read-only by design** — KV writes go through //! `kv::set` in scripts, which emit change events the trigger framework //! depends on; an admin write would bypass that and could break app //! invariants, so it is deliberately out of scope here (matching the //! read-only queues precedent). //! //! Two operations: //! * `GET /apps/{id}/kv?collection=&cursor=&limit=` — list keys in a //! collection (cursor-paginated). //! * `GET /apps/{id}/kv/{collection}/{key}` — fetch one value. //! //! Capability: `AppKvRead`, resolved against the app loaded from the //! path (same tier the SDK read path uses). use std::sync::Arc; use axum::extract::{Path, Query, State}; use axum::response::{IntoResponse, Json, Response}; use axum::routing::get; use axum::{Extension, Router}; use picloud_shared::{AppId, Principal}; use serde::{Deserialize, Serialize}; use serde_json::json; use crate::app_repo::AppRepository; use crate::authz::{require, AuthzDenied, AuthzRepo, Capability}; use crate::kv_repo::KvRepo; #[derive(Clone)] pub struct KvAdminState { pub kv: Arc, pub apps: Arc, pub authz: Arc, } pub fn kv_admin_router(state: KvAdminState) -> Router { Router::new() .route("/apps/{app_id}/kv", get(list_keys)) .route("/apps/{app_id}/kv/{collection}/{key}", get(get_value)) .with_state(state) } #[derive(Debug, Deserialize)] pub struct ListKvQuery { pub collection: String, #[serde(default)] pub cursor: Option, #[serde(default)] pub limit: Option, } #[derive(Debug, Serialize)] struct GetValueResponse { value: serde_json::Value, } /// Serialize mirror of `shared::KvListPage` (which is not `Serialize`). #[derive(Debug, Serialize)] struct ListKeysResponse { keys: Vec, next_cursor: Option, } async fn list_keys( State(s): State, Extension(principal): Extension, Path(id_or_slug): Path, Query(q): Query, ) -> Result, KvApiError> { let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require(s.authz.as_ref(), &principal, Capability::AppKvRead(app_id)).await?; let page = s.kv.list( app_id, &q.collection, q.cursor.as_deref(), q.limit.unwrap_or(0), ) .await .map_err(|e| KvApiError::Backend(e.to_string()))?; Ok(Json(ListKeysResponse { keys: page.keys, next_cursor: page.next_cursor, })) } async fn get_value( State(s): State, Extension(principal): Extension, Path((id_or_slug, collection, key)): Path<(String, String, String)>, ) -> Result, KvApiError> { let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require(s.authz.as_ref(), &principal, Capability::AppKvRead(app_id)).await?; let value = s.kv.get(app_id, &collection, &key) .await .map_err(|e| KvApiError::Backend(e.to_string()))? .ok_or(KvApiError::NotFound)?; Ok(Json(GetValueResponse { value })) } async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result { crate::app_repo::resolve_app(apps, ident) .await .map_err(|e| KvApiError::Backend(e.to_string()))? .map(|l| l.app.id) .ok_or(KvApiError::AppNotFound) } #[derive(Debug, thiserror::Error)] pub enum KvApiError { #[error("app not found")] AppNotFound, #[error("key not found")] NotFound, #[error("forbidden")] Forbidden, #[error("authorization repo error: {0}")] AuthzRepo(String), #[error("kv backend: {0}")] Backend(String), } impl From for KvApiError { fn from(d: AuthzDenied) -> Self { match d { AuthzDenied::Denied => Self::Forbidden, AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()), } } } impl IntoResponse for KvApiError { fn into_response(self) -> Response { use axum::http::StatusCode; let (status, body) = match &self { Self::AppNotFound | Self::NotFound => { (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })) } Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })), Self::AuthzRepo(e) => { tracing::error!(error = %e, "kv admin authz error"); ( StatusCode::INTERNAL_SERVER_ERROR, json!({ "error": "internal error" }), ) } Self::Backend(e) => { tracing::error!(error = %e, "kv admin backend error"); ( StatusCode::INTERNAL_SERVER_ERROR, json!({ "error": "internal error" }), ) } }; (status, Json(body)).into_response() } }