From e4a58dc140dfdf1aa2563603e225076c300d7036 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Wed, 15 Jul 2026 20:53:11 +0200 Subject: [PATCH] feat(cli): per-app docs admin read (pic docs ls/get --app) Fills the one per-app admin-read gap kv/files already covered. New docs_api.rs mirrors kv_api (GET /apps/{id}/docs[/{collection}/{doc_id}], AppDocsRead capability, DocsRepo::list/get) with the group_blobs_api docs response shape so the CLI shares one deserialize. DocsCmd gains optional --app/--group (mutually exclusive, like KvCmd) dispatched via require_one_owner; new client docs_list/docs_get. Read-only by design, matching kv/files. Pinned by a collections journey: a script writes the app's own docs collection, the operator lists + fetches it with no script. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/manager-core/src/docs_api.rs | 186 ++++++++++++++++++++++++ crates/manager-core/src/lib.rs | 2 + crates/picloud-cli/src/client.rs | 33 +++++ crates/picloud-cli/src/cmds/docs.rs | 36 +++-- crates/picloud-cli/src/main.rs | 19 ++- crates/picloud-cli/tests/collections.rs | 76 ++++++++++ crates/picloud/src/lib.rs | 52 ++++--- 7 files changed, 366 insertions(+), 38 deletions(-) create mode 100644 crates/manager-core/src/docs_api.rs diff --git a/crates/manager-core/src/docs_api.rs b/crates/manager-core/src/docs_api.rs new file mode 100644 index 0000000..ace7e22 --- /dev/null +++ b/crates/manager-core/src/docs_api.rs @@ -0,0 +1,186 @@ +//! `/api/v1/admin/apps/{id}/docs*` — read-only docs inspection. +//! +//! Mirrors `kv_api` / `files_api` so the `pic docs … --app` CLI (and a +//! future dashboard tab) can browse stored documents without a script. +//! The per-group equivalent shipped first (`group_blobs_api`); this is +//! its per-app counterpart, filling the one admin-read gap the other +//! data-plane services already covered. **Read-only by design** — docs +//! writes go through `docs::create/update/delete` in scripts, which emit +//! change events the trigger framework depends on; an admin write would +//! bypass that, so it is deliberately out of scope (matching kv/files). +//! +//! Two operations: +//! * `GET /apps/{id}/docs?collection=&cursor=&limit=` — list docs in +//! a collection (cursor-paginated). +//! * `GET /apps/{id}/docs/{collection}/{doc_id}` — fetch one document. +//! +//! Capability: `AppDocsRead`, 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 uuid::Uuid; + +use crate::app_repo::AppRepository; +use crate::authz::{require, AuthzDenied, AuthzRepo, Capability}; +use crate::docs_repo::DocsRepo; + +#[derive(Clone)] +pub struct DocsAdminState { + pub docs: Arc, + pub apps: Arc, + pub authz: Arc, +} + +pub fn docs_admin_router(state: DocsAdminState) -> Router { + Router::new() + .route("/apps/{app_id}/docs", get(list_docs)) + .route("/apps/{app_id}/docs/{collection}/{doc_id}", get(get_doc)) + .with_state(state) +} + +#[derive(Debug, Deserialize)] +pub struct ListDocsQuery { + pub collection: String, + #[serde(default)] + pub cursor: Option, + #[serde(default)] + pub limit: Option, +} + +/// Mirrors the `group_blobs_api` docs shape so the CLI can share one +/// deserialize across `--app` and `--group`. +#[derive(Debug, Serialize)] +struct DocEntry { + id: String, + data: serde_json::Value, +} + +#[derive(Debug, Serialize)] +struct ListDocsResponse { + docs: Vec, + next_cursor: Option, +} + +async fn list_docs( + State(s): State, + Extension(principal): Extension, + Path(id_or_slug): Path, + Query(q): Query, +) -> Result, DocsApiError> { + let app_id = resolve_app(&*s.apps, &id_or_slug).await?; + require( + s.authz.as_ref(), + &principal, + Capability::AppDocsRead(app_id), + ) + .await?; + let page = s + .docs + .list( + app_id, + &q.collection, + q.cursor.as_deref(), + q.limit.unwrap_or(0), + ) + .await + .map_err(|e| DocsApiError::Backend(e.to_string()))?; + Ok(Json(ListDocsResponse { + docs: page + .docs + .into_iter() + .map(|d| DocEntry { + id: d.id.to_string(), + data: d.data, + }) + .collect(), + next_cursor: page.next_cursor, + })) +} + +async fn get_doc( + State(s): State, + Extension(principal): Extension, + Path((id_or_slug, collection, doc_id)): Path<(String, String, String)>, +) -> Result, DocsApiError> { + let app_id = resolve_app(&*s.apps, &id_or_slug).await?; + require( + s.authz.as_ref(), + &principal, + Capability::AppDocsRead(app_id), + ) + .await?; + let id = doc_id.parse::().map_err(|_| DocsApiError::NotFound)?; + let row = s + .docs + .get(app_id, &collection, id) + .await + .map_err(|e| DocsApiError::Backend(e.to_string()))? + .ok_or(DocsApiError::NotFound)?; + Ok(Json(json!({ "id": row.id.to_string(), "data": row.data }))) +} + +async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result { + crate::app_repo::resolve_app(apps, ident) + .await + .map_err(|e| DocsApiError::Backend(e.to_string()))? + .map(|l| l.app.id) + .ok_or(DocsApiError::AppNotFound) +} + +#[derive(Debug, thiserror::Error)] +pub enum DocsApiError { + #[error("app not found")] + AppNotFound, + #[error("document not found")] + NotFound, + #[error("forbidden")] + Forbidden, + #[error("authorization repo error: {0}")] + AuthzRepo(String), + #[error("docs backend: {0}")] + Backend(String), +} + +impl From for DocsApiError { + fn from(d: AuthzDenied) -> Self { + match d { + AuthzDenied::Denied => Self::Forbidden, + AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()), + } + } +} + +impl IntoResponse for DocsApiError { + 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, "docs admin authz error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ "error": "internal error" }), + ) + } + Self::Backend(e) => { + tracing::error!(error = %e, "docs admin backend error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ "error": "internal error" }), + ) + } + }; + (status, Json(body)).into_response() + } +} diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs index 61aaa75..0db547c 100644 --- a/crates/manager-core/src/lib.rs +++ b/crates/manager-core/src/lib.rs @@ -40,6 +40,7 @@ pub mod dead_letter_service; pub mod dead_letters_api; pub mod dev_email_api; pub mod dispatcher; +pub mod docs_api; pub mod docs_filter; pub mod docs_repo; pub mod docs_service; @@ -188,6 +189,7 @@ pub use dead_letter_service::PostgresDeadLetterService; pub use dead_letters_api::{dead_letters_router, DeadLettersApiError, DeadLettersState}; pub use dev_email_api::{dev_emails_router, DevEmailState}; pub use dispatcher::{compute_backoff, Dispatcher, DispatcherError}; +pub use docs_api::{docs_admin_router, DocsAdminState}; pub use docs_repo::{DocsRepo, DocsRepoError, PostgresDocsRepo}; pub use docs_service::DocsServiceImpl; pub use email_inbound_api::{ diff --git a/crates/picloud-cli/src/client.rs b/crates/picloud-cli/src/client.rs index 8ae40dd..084b77e 100644 --- a/crates/picloud-cli/src/client.rs +++ b/crates/picloud-cli/src/client.rs @@ -1285,6 +1285,39 @@ impl Client { Ok(wrapped.value) } + /// Per-app docs (operator read; list-only ids). + /// `GET /api/v1/admin/apps/{id_or_slug}/docs?collection=…&limit=…` + /// Reuses `GroupDocsListDto` — the server sends the same `{ docs, next_cursor }` shape. + pub async fn docs_list( + &self, + app: &str, + collection: &str, + limit: u32, + ) -> Result { + let (app, collection) = (seg(app), seg(collection)); + let resp = self + .request( + Method::GET, + &format!("/api/v1/admin/apps/{app}/docs?collection={collection}&limit={limit}"), + ) + .send() + .await?; + decode(resp).await + } + + /// `GET /api/v1/admin/apps/{id_or_slug}/docs/{collection}/{doc_id}` + pub async fn docs_get(&self, app: &str, collection: &str, doc_id: &str) -> Result { + let (app, collection, doc_id) = (seg(app), seg(collection), seg(doc_id)); + let resp = self + .request( + Method::GET, + &format!("/api/v1/admin/apps/{app}/docs/{collection}/{doc_id}"), + ) + .send() + .await?; + decode(resp).await + } + /// §11.6 M4: a group's shared-KV collection. /// `GET /api/v1/admin/groups/{id_or_slug}/kv?collection=…` pub async fn group_kv_list( diff --git a/crates/picloud-cli/src/cmds/docs.rs b/crates/picloud-cli/src/cmds/docs.rs index 64adf15..1008140 100644 --- a/crates/picloud-cli/src/cmds/docs.rs +++ b/crates/picloud-cli/src/cmds/docs.rs @@ -1,10 +1,11 @@ -//! `pic docs ls | get --group` — read-only inspection of a group's shared-docs -//! collection (§11.6). Group-only: per-app docs have no admin read route yet -//! (unlike kv/files), so there is no `--app` variant here. +//! `pic docs ls | get --app | --group ` — read-only inspection of a +//! docs collection. Both owners are supported: `--app` browses an app's own docs +//! collection, `--group` a group's shared-docs collection (§11.6). //! //! Read-only by design (matching `pic kv`): docs writes go through -//! `docs::shared_collection(...).create/update` in scripts, which emit the -//! change events shared triggers depend on; an admin write would bypass that. +//! `docs::create/update` (or `docs::shared_collection(...).create/update`) in +//! scripts, which emit the change events triggers depend on; an admin write would +//! bypass that. use anyhow::Result; @@ -12,10 +13,19 @@ use crate::client::Client; use crate::config; use crate::output::{OutputMode, Table}; -pub async fn ls(group: &str, collection: &str, limit: u32, mode: OutputMode) -> Result<()> { +pub async fn ls( + app: Option<&str>, + group: Option<&str>, + collection: &str, + limit: u32, + mode: OutputMode, +) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; - let page = client.group_docs_list(group, collection, limit).await?; + let page = match crate::cmds::require_one_owner(app, group)? { + crate::cmds::OwnerRef::App(a) => client.docs_list(a, collection, limit).await?, + crate::cmds::OwnerRef::Group(g) => client.group_docs_list(g, collection, limit).await?, + }; let mut table = Table::new(["id"]); for d in page.docs { table.row([d.id]); @@ -27,10 +37,18 @@ pub async fn ls(group: &str, collection: &str, limit: u32, mode: OutputMode) -> Ok(()) } -pub async fn get(group: &str, collection: &str, doc_id: &str) -> Result<()> { +pub async fn get( + app: Option<&str>, + group: Option<&str>, + collection: &str, + doc_id: &str, +) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; - let value = client.group_docs_get(group, collection, doc_id).await?; + let value = match crate::cmds::require_one_owner(app, group)? { + crate::cmds::OwnerRef::App(a) => client.docs_get(a, collection, doc_id).await?, + crate::cmds::OwnerRef::Group(g) => client.group_docs_get(g, collection, doc_id).await?, + }; // Emit the document JSON (pretty) so it pipes cleanly into jq. let pretty = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()); println!("{pretty}"); diff --git a/crates/picloud-cli/src/main.rs b/crates/picloud-cli/src/main.rs index 5e0709e..9d0f35e 100644 --- a/crates/picloud-cli/src/main.rs +++ b/crates/picloud-cli/src/main.rs @@ -381,19 +381,24 @@ enum KvCmd { #[derive(Subcommand)] enum DocsCmd { - /// List document ids in a group's shared-docs collection. + /// List document ids in a collection. Exactly one of `--app` (per-app docs) + /// or `--group` (a group's §11.6 shared docs). Ls { + #[arg(long, conflicts_with = "group")] + app: Option, #[arg(long)] - group: String, + group: Option, #[arg(long)] collection: String, #[arg(long, default_value_t = 100)] limit: u32, }, - /// Fetch one document's data (printed as JSON). + /// Fetch one document's data (printed as JSON). `--app` or `--group`. Get { + #[arg(long, conflicts_with = "group")] + app: Option, #[arg(long)] - group: String, + group: Option, #[arg(long)] collection: String, id: String, @@ -2256,19 +2261,21 @@ async fn main() -> ExitCode { Cmd::Docs { cmd: DocsCmd::Ls { + app, group, collection, limit, }, - } => cmds::docs::ls(&group, &collection, limit, mode).await, + } => cmds::docs::ls(app.as_deref(), group.as_deref(), &collection, limit, mode).await, Cmd::Docs { cmd: DocsCmd::Get { + app, group, collection, id, }, - } => cmds::docs::get(&group, &collection, &id).await, + } => cmds::docs::get(app.as_deref(), group.as_deref(), &collection, &id).await, }; match result { diff --git a/crates/picloud-cli/tests/collections.rs b/crates/picloud-cli/tests/collections.rs index d2eb37d..3193635 100644 --- a/crates/picloud-cli/tests/collections.rs +++ b/crates/picloud-cli/tests/collections.rs @@ -749,3 +749,79 @@ fn operator_reads_shared_docs_and_group_dead_letters_via_cli() { .assert() .success(); } + +/// B4: the per-app docs admin read surface (`pic docs ls/get --app`), the +/// per-app counterpart to the group read above. A script writes to the app's +/// OWN docs collection (no group); the operator then lists + fetches it with +/// no script, mirroring `pic kv --app`. +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn operator_reads_per_app_docs_via_cli() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let app = common::unique_slug("appdocs"); + + let _a = AppGuard::new(&env.url, &env.token, &app); + common::pic_as(&env) + .args(["apps", "create", &app]) + .assert() + .success(); + + // A script creates a document in the app's own docs collection. + let dir = manifest_dir(); + fs::write( + dir.path().join("scripts/dw.rhai"), + r#"docs::collection("notes").create(#{ title: "per-app-hi" })"#, + ) + .unwrap(); + common::pic_as(&env) + .args(["scripts", "deploy"]) + .arg(dir.path().join("scripts/dw.rhai")) + .args(["--app", &app, "--name", "dw"]) + .assert() + .success(); + let body = invoke_body(&env, &app_script_id(&env, &app, "dw")); + let doc_id = body + .as_str() + .map(str::to_string) + .or_else(|| body.get("id").and_then(|v| v.as_str().map(str::to_string))) + .unwrap_or_else(|| panic!("create should return a doc id, got: {body}")); + + // Operator lists the app's docs ids — no script. + let ls = String::from_utf8( + common::pic_as(&env) + .args(["docs", "ls", "--app", &app, "--collection", "notes"]) + .output() + .unwrap() + .stdout, + ) + .unwrap(); + assert!( + ls.contains(&doc_id), + "docs ls --app must list the new doc id `{doc_id}`:\n{ls}" + ); + + // Operator fetches the document body. + let get = String::from_utf8( + common::pic_as(&env) + .args([ + "docs", + "get", + "--app", + &app, + "--collection", + "notes", + &doc_id, + ]) + .output() + .unwrap() + .stdout, + ) + .unwrap(); + assert!( + get.contains("per-app-hi"), + "docs get --app must return the document data:\n{get}" + ); +} diff --git a/crates/picloud/src/lib.rs b/crates/picloud/src/lib.rs index 8c6f865..f8a2319 100644 --- a/crates/picloud/src/lib.rs +++ b/crates/picloud/src/lib.rs @@ -12,32 +12,33 @@ use picloud_executor_core::{Engine, Limits}; use picloud_manager_core::{ admin_router, admins_router, api_keys_router, app_members_router, apply_router, apps_api, apps_router, attach_principal_if_present, auth_router, dead_letters_router, dev_emails_router, - email_inbound_router, files_admin_router, groups_router, kv_admin_router, migrations, - projects_router, rebuild_route_table, require_authenticated, route_admin_router, + docs_admin_router, email_inbound_router, files_admin_router, groups_router, kv_admin_router, + migrations, projects_router, rebuild_route_table, require_authenticated, route_admin_router, secrets_router, topics_router, triggers_router, vars_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository, ApplyService, AppsState, AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState, - DevEmailState, Dispatcher, DocsServiceImpl, EmailInboundState, EmailServiceImpl, - FilesAdminState, FilesConfig, FilesServiceImpl, FsFilesRepo, FsGroupFilesRepo, - GroupDocsServiceImpl, GroupFilesServiceImpl, GroupKvServiceImpl, GroupMembersRepository, - GroupPubsubServiceImpl, GroupQueueServiceImpl, GroupRepository, GroupsState, HttpConfig, - HttpServiceImpl, InboundNonceDedup, KvAdminState, KvServiceImpl, OutboxEventEmitter, - OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository, - PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository, - PostgresAppRepository, PostgresAppSecretsRepo, PostgresAppUserInvitationRepo, - PostgresAppUserPasswordResetRepo, PostgresAppUserRepository, PostgresAppUserRoleRepo, - PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo, PostgresDeadLetterRepo, - PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository, - PostgresExecutionLogSink, PostgresGroupCollectionResolver, PostgresGroupDocsRepo, - PostgresGroupKvRepo, PostgresGroupMembersRepository, PostgresGroupQueueRepo, - PostgresGroupRepository, PostgresKvRepo, PostgresOutboxRepo, PostgresProjectRepository, - PostgresPubsubRepo, PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo, - PostgresTopicRepo, PostgresTriggerRepo, PostgresVarsRepo, PrincipalResolver, ProjectRepository, - ProjectsState, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, RouteAdminState, - SandboxCeiling, ScriptRepository, SecretsConfig, SecretsServiceImpl, SecretsState, - SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig, TriggerRepo, TriggersState, - UsersServiceConfig, UsersServiceImpl, VarsApiState, VarsServiceImpl, + DevEmailState, Dispatcher, DocsAdminState, DocsServiceImpl, EmailInboundState, + EmailServiceImpl, FilesAdminState, FilesConfig, FilesServiceImpl, FsFilesRepo, + FsGroupFilesRepo, GroupDocsServiceImpl, GroupFilesServiceImpl, GroupKvServiceImpl, + GroupMembersRepository, GroupPubsubServiceImpl, GroupQueueServiceImpl, GroupRepository, + GroupsState, HttpConfig, HttpServiceImpl, InboundNonceDedup, KvAdminState, KvServiceImpl, + OutboxEventEmitter, OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, + PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository, + PostgresAppMembersRepository, PostgresAppRepository, PostgresAppSecretsRepo, + PostgresAppUserInvitationRepo, PostgresAppUserPasswordResetRepo, PostgresAppUserRepository, + PostgresAppUserRoleRepo, PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo, + PostgresDeadLetterRepo, PostgresDeadLetterService, PostgresDocsRepo, + PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresGroupCollectionResolver, + PostgresGroupDocsRepo, PostgresGroupKvRepo, PostgresGroupMembersRepository, + PostgresGroupQueueRepo, PostgresGroupRepository, PostgresKvRepo, PostgresOutboxRepo, + PostgresProjectRepository, PostgresPubsubRepo, PostgresRouteRepository, + PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo, PostgresTriggerRepo, + PostgresVarsRepo, PrincipalResolver, ProjectRepository, ProjectsState, PubsubServiceImpl, + RealtimeAuthorityImpl, RepoResolver, RouteAdminState, SandboxCeiling, ScriptRepository, + SecretsConfig, SecretsServiceImpl, SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState, + TriggerConfig, TriggerRepo, TriggersState, UsersServiceConfig, UsersServiceImpl, VarsApiState, + VarsServiceImpl, }; use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS; use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable}; @@ -217,7 +218,7 @@ pub async fn build_app( ); let docs: Arc = Arc::new( DocsServiceImpl::with_max_value_bytes( - docs_repo, + docs_repo.clone(), authz.clone(), events.clone(), picloud_manager_core::docs_service::docs_max_value_bytes_from_env(), @@ -804,6 +805,11 @@ pub async fn build_app( apps: apps_repo.clone(), authz: authz.clone(), })) + .merge(docs_admin_router(DocsAdminState { + docs: docs_repo.clone(), + apps: apps_repo.clone(), + authz: authz.clone(), + })) // §11.6 M4: read-only operator inspection of a group's shared collections. .merge(picloud_manager_core::group_blobs_api::group_blobs_router( picloud_manager_core::group_blobs_api::GroupBlobsState {