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:
186
crates/manager-core/src/docs_api.rs
Normal file
186
crates/manager-core/src/docs_api.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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::{
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user