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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-15 20:53:11 +02:00
parent 8fc78cd07f
commit e4a58dc140
7 changed files with 366 additions and 38 deletions

View File

@@ -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=<c>&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<dyn DocsRepo>,
pub apps: Arc<dyn AppRepository>,
pub authz: Arc<dyn AuthzRepo>,
}
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<String>,
#[serde(default)]
pub limit: Option<u32>,
}
/// 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<DocEntry>,
next_cursor: Option<String>,
}
async fn list_docs(
State(s): State<DocsAdminState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Query(q): Query<ListDocsQuery>,
) -> Result<Json<ListDocsResponse>, 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<DocsAdminState>,
Extension(principal): Extension<Principal>,
Path((id_or_slug, collection, doc_id)): Path<(String, String, String)>,
) -> Result<Json<serde_json::Value>, 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::<Uuid>().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<AppId, DocsApiError> {
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<AuthzDenied> 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()
}
}

View File

@@ -40,6 +40,7 @@ pub mod dead_letter_service;
pub mod dead_letters_api; pub mod dead_letters_api;
pub mod dev_email_api; pub mod dev_email_api;
pub mod dispatcher; pub mod dispatcher;
pub mod docs_api;
pub mod docs_filter; pub mod docs_filter;
pub mod docs_repo; pub mod docs_repo;
pub mod docs_service; 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 dead_letters_api::{dead_letters_router, DeadLettersApiError, DeadLettersState};
pub use dev_email_api::{dev_emails_router, DevEmailState}; pub use dev_email_api::{dev_emails_router, DevEmailState};
pub use dispatcher::{compute_backoff, Dispatcher, DispatcherError}; 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_repo::{DocsRepo, DocsRepoError, PostgresDocsRepo};
pub use docs_service::DocsServiceImpl; pub use docs_service::DocsServiceImpl;
pub use email_inbound_api::{ pub use email_inbound_api::{

View File

@@ -1285,6 +1285,39 @@ impl Client {
Ok(wrapped.value) 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<GroupDocsListDto> {
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<Value> {
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. /// §11.6 M4: a group's shared-KV collection.
/// `GET /api/v1/admin/groups/{id_or_slug}/kv?collection=…` /// `GET /api/v1/admin/groups/{id_or_slug}/kv?collection=…`
pub async fn group_kv_list( pub async fn group_kv_list(

View File

@@ -1,10 +1,11 @@
//! `pic docs ls | get --group` — read-only inspection of a group's shared-docs //! `pic docs ls | get --app <slug> | --group <slug>` — read-only inspection of a
//! collection (§11.6). Group-only: per-app docs have no admin read route yet //! docs collection. Both owners are supported: `--app` browses an app's own docs
//! (unlike kv/files), so there is no `--app` variant here. //! collection, `--group` a group's shared-docs collection (§11.6).
//! //!
//! Read-only by design (matching `pic kv`): docs writes go through //! Read-only by design (matching `pic kv`): docs writes go through
//! `docs::shared_collection(...).create/update` in scripts, which emit the //! `docs::create/update` (or `docs::shared_collection(...).create/update`) in
//! change events shared triggers depend on; an admin write would bypass that. //! scripts, which emit the change events triggers depend on; an admin write would
//! bypass that.
use anyhow::Result; use anyhow::Result;
@@ -12,10 +13,19 @@ use crate::client::Client;
use crate::config; use crate::config;
use crate::output::{OutputMode, Table}; 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 creds = config::resolve()?;
let client = Client::from_creds(&creds)?; 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"]); let mut table = Table::new(["id"]);
for d in page.docs { for d in page.docs {
table.row([d.id]); table.row([d.id]);
@@ -27,10 +37,18 @@ pub async fn ls(group: &str, collection: &str, limit: u32, mode: OutputMode) ->
Ok(()) 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 creds = config::resolve()?;
let client = Client::from_creds(&creds)?; 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. // 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()); let pretty = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
println!("{pretty}"); println!("{pretty}");

View File

@@ -381,19 +381,24 @@ enum KvCmd {
#[derive(Subcommand)] #[derive(Subcommand)]
enum DocsCmd { 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 { Ls {
#[arg(long, conflicts_with = "group")]
app: Option<String>,
#[arg(long)] #[arg(long)]
group: String, group: Option<String>,
#[arg(long)] #[arg(long)]
collection: String, collection: String,
#[arg(long, default_value_t = 100)] #[arg(long, default_value_t = 100)]
limit: u32, limit: u32,
}, },
/// Fetch one document's data (printed as JSON). /// Fetch one document's data (printed as JSON). `--app` or `--group`.
Get { Get {
#[arg(long, conflicts_with = "group")]
app: Option<String>,
#[arg(long)] #[arg(long)]
group: String, group: Option<String>,
#[arg(long)] #[arg(long)]
collection: String, collection: String,
id: String, id: String,
@@ -2256,19 +2261,21 @@ async fn main() -> ExitCode {
Cmd::Docs { Cmd::Docs {
cmd: cmd:
DocsCmd::Ls { DocsCmd::Ls {
app,
group, group,
collection, collection,
limit, 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::Docs {
cmd: cmd:
DocsCmd::Get { DocsCmd::Get {
app,
group, group,
collection, collection,
id, id,
}, },
} => cmds::docs::get(&group, &collection, &id).await, } => cmds::docs::get(app.as_deref(), group.as_deref(), &collection, &id).await,
}; };
match result { match result {

View File

@@ -749,3 +749,79 @@ fn operator_reads_shared_docs_and_group_dead_letters_via_cli() {
.assert() .assert()
.success(); .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}"
);
}

View File

@@ -12,32 +12,33 @@ use picloud_executor_core::{Engine, Limits};
use picloud_manager_core::{ use picloud_manager_core::{
admin_router, admins_router, api_keys_router, app_members_router, apply_router, apps_api, 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, 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, docs_admin_router, email_inbound_router, files_admin_router, groups_router, kv_admin_router,
projects_router, rebuild_route_table, require_authenticated, route_admin_router, migrations, projects_router, rebuild_route_table, require_authenticated, route_admin_router,
secrets_router, topics_router, triggers_router, vars_router, AbandonedRepo, secrets_router, topics_router, triggers_router, vars_router, AbandonedRepo,
AdminPrincipalResolver, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState, AdminPrincipalResolver, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState,
ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState, ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState,
AppRepository, ApplyService, AppsState, AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState, AppRepository, ApplyService, AppsState, AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState,
DevEmailState, Dispatcher, DocsServiceImpl, EmailInboundState, EmailServiceImpl, DevEmailState, Dispatcher, DocsAdminState, DocsServiceImpl, EmailInboundState,
FilesAdminState, FilesConfig, FilesServiceImpl, FsFilesRepo, FsGroupFilesRepo, EmailServiceImpl, FilesAdminState, FilesConfig, FilesServiceImpl, FsFilesRepo,
GroupDocsServiceImpl, GroupFilesServiceImpl, GroupKvServiceImpl, GroupMembersRepository, FsGroupFilesRepo, GroupDocsServiceImpl, GroupFilesServiceImpl, GroupKvServiceImpl,
GroupPubsubServiceImpl, GroupQueueServiceImpl, GroupRepository, GroupsState, HttpConfig, GroupMembersRepository, GroupPubsubServiceImpl, GroupQueueServiceImpl, GroupRepository,
HttpServiceImpl, InboundNonceDedup, KvAdminState, KvServiceImpl, OutboxEventEmitter, GroupsState, HttpConfig, HttpServiceImpl, InboundNonceDedup, KvAdminState, KvServiceImpl,
OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository, OutboxEventEmitter, OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository,
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository, PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository,
PostgresAppRepository, PostgresAppSecretsRepo, PostgresAppUserInvitationRepo, PostgresAppMembersRepository, PostgresAppRepository, PostgresAppSecretsRepo,
PostgresAppUserPasswordResetRepo, PostgresAppUserRepository, PostgresAppUserRoleRepo, PostgresAppUserInvitationRepo, PostgresAppUserPasswordResetRepo, PostgresAppUserRepository,
PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo, PostgresDeadLetterRepo, PostgresAppUserRoleRepo, PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo,
PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository, PostgresDeadLetterRepo, PostgresDeadLetterService, PostgresDocsRepo,
PostgresExecutionLogSink, PostgresGroupCollectionResolver, PostgresGroupDocsRepo, PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresGroupCollectionResolver,
PostgresGroupKvRepo, PostgresGroupMembersRepository, PostgresGroupQueueRepo, PostgresGroupDocsRepo, PostgresGroupKvRepo, PostgresGroupMembersRepository,
PostgresGroupRepository, PostgresKvRepo, PostgresOutboxRepo, PostgresProjectRepository, PostgresGroupQueueRepo, PostgresGroupRepository, PostgresKvRepo, PostgresOutboxRepo,
PostgresPubsubRepo, PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo, PostgresProjectRepository, PostgresPubsubRepo, PostgresRouteRepository,
PostgresTopicRepo, PostgresTriggerRepo, PostgresVarsRepo, PrincipalResolver, ProjectRepository, PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo, PostgresTriggerRepo,
ProjectsState, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, RouteAdminState, PostgresVarsRepo, PrincipalResolver, ProjectRepository, ProjectsState, PubsubServiceImpl,
SandboxCeiling, ScriptRepository, SecretsConfig, SecretsServiceImpl, SecretsState, RealtimeAuthorityImpl, RepoResolver, RouteAdminState, SandboxCeiling, ScriptRepository,
SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig, TriggerRepo, TriggersState, SecretsConfig, SecretsServiceImpl, SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState,
UsersServiceConfig, UsersServiceImpl, VarsApiState, VarsServiceImpl, TriggerConfig, TriggerRepo, TriggersState, UsersServiceConfig, UsersServiceImpl, VarsApiState,
VarsServiceImpl,
}; };
use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS; use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS;
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable}; use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
@@ -217,7 +218,7 @@ pub async fn build_app(
); );
let docs: Arc<dyn DocsService> = Arc::new( let docs: Arc<dyn DocsService> = Arc::new(
DocsServiceImpl::with_max_value_bytes( DocsServiceImpl::with_max_value_bytes(
docs_repo, docs_repo.clone(),
authz.clone(), authz.clone(),
events.clone(), events.clone(),
picloud_manager_core::docs_service::docs_max_value_bytes_from_env(), picloud_manager_core::docs_service::docs_max_value_bytes_from_env(),
@@ -804,6 +805,11 @@ pub async fn build_app(
apps: apps_repo.clone(), apps: apps_repo.clone(),
authz: authz.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. // §11.6 M4: read-only operator inspection of a group's shared collections.
.merge(picloud_manager_core::group_blobs_api::group_blobs_router( .merge(picloud_manager_core::group_blobs_api::group_blobs_router(
picloud_manager_core::group_blobs_api::GroupBlobsState { picloud_manager_core::group_blobs_api::GroupBlobsState {