feat(scripts): group-owned script admin API + pic scripts … --group

Phase 4-lite C2. Lets a group own scripts (templates inherited by descendant
apps), with the create/list/manage surface — but not yet the inherited
resolution (binding + runtime), which is C3.

Capabilities: add `GroupScriptsRead` (viewer+ on the group → script:read) and
`GroupScriptsWrite` (editor+ → script:write), mirroring the group-vars tier and
resolved through the same group-ancestor walk.

API:
  * New `group_scripts_api`: `POST/GET /groups/{id}/scripts` — create a
    group-owned endpoint script and list a group's own (non-inherited) rows.
    Phase 4-lite is endpoint-only and self-contained: `kind=module` and any
    `import` are rejected (group modules + the lexical resolver are Phase 4b).
    Owner resolved first (slug-or-uuid); capability bound to the resolved id.
  * The by-id `/scripts/{id}` get/update/delete/logs handlers are now
    owner-polymorphic via a `script_cap` helper: app-owned scripts gate on
    `App*` exactly as before; group-owned on `GroupScripts*`. This is what
    makes `deploy --group` idempotent (update reuses the by-id PUT) and lets a
    group script be deleted by id. (C1 had these fail closed for groups.)

Repo: `list_for_group(group_id)` (the group's own rows).

CLI: `pic scripts ls --group <g>`, `pic scripts deploy --group <g>` (create or
update by name; `--app`/`--group` are mutually exclusive, exactly one
required). `pic scripts delete <id>` already works for group scripts via the
owner-polymorphic by-id route.

Live-validated against the dev DB: create → update (v2 via the by-id PUT) →
list → delete, plus module rejection and the polymorphic row shape
(`app_id NULL`, `group_id` set). Group scripts still can't be routed/triggered
or invoked — that lands in C3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-25 20:10:20 +02:00
parent 48178c5f60
commit 43ed4e8e7f
13 changed files with 491 additions and 56 deletions

View File

@@ -12,8 +12,8 @@ use axum::{
Extension, Json, Router,
};
use picloud_shared::{
AppId, ExecutionLog, ExecutionSource, InstanceRole, Principal, Script, ScriptId, ScriptKind,
ScriptSandbox, ScriptValidator, ValidatedScript, ValidationError,
AppId, ExecutionLog, ExecutionSource, GroupId, InstanceRole, Principal, Script, ScriptId,
ScriptKind, ScriptOwner, ScriptSandbox, ScriptValidator, ValidatedScript, ValidationError,
};
use serde::Deserialize;
@@ -181,13 +181,25 @@ async fn resolve_app_ident(apps: &dyn AppRepository, ident: &str) -> Result<AppI
Ok(lookup.app.id)
}
/// The owning app of a script, for app-scoped admin authz. Every script
/// before Phase 4 is app-owned and yields its app. A group-owned script
/// (Phase 4) is not addressable through the app-script admin API — it is
/// managed via the group-script API — so it 404s here, indistinguishable
/// from an absent id.
fn script_app(script: &Script) -> Result<AppId, ApiError> {
script.app_id.ok_or(ApiError::NotFound(script.id))
/// Capability authorizing an operation on a script of either owner (Phase 4).
/// App-owned scripts map to their `App*` capability exactly as before; a
/// group-owned script maps to the group-scoped capability, resolved against
/// the group ancestor walk. An orphan row (neither owner — impossible under
/// the DB CHECK) reads as a missing id.
///
/// The `app`/`group` arguments are the capability constructors for each
/// owner, so one helper serves read / write / admin / log-read by passing
/// the matching pair.
fn script_cap(
script: &Script,
app: fn(AppId) -> Capability,
group: fn(GroupId) -> Capability,
) -> Result<Capability, ApiError> {
match script.owner() {
Some(ScriptOwner::App(a)) => Ok(app(a)),
Some(ScriptOwner::Group(g)) => Ok(group(g)),
None => Err(ApiError::NotFound(script.id)),
}
}
async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
@@ -199,7 +211,7 @@ async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
require(
state.authz.as_ref(),
&principal,
Capability::AppRead(script_app(&script)?),
script_cap(&script, Capability::AppRead, Capability::GroupScriptsRead)?,
)
.await?;
Ok(Json(script))
@@ -301,7 +313,11 @@ async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
require(
state.authz.as_ref(),
&principal,
Capability::AppWriteScript(script_app(&script)?),
script_cap(
&script,
Capability::AppWriteScript,
Capability::GroupScriptsWrite,
)?,
)
.await?;
@@ -371,13 +387,15 @@ async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>(
Path(id): Path<ScriptId>,
) -> Result<StatusCode, ApiError> {
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
// Delete is gated tighter than Save: editors can edit scripts but
// only app_admin / instance admin / owner can remove them. See
// blueprint §11.6.
// Delete is gated tighter than Save for app-owned scripts: editors can
// edit but only app_admin / instance admin / owner can remove them (§11.6).
// A group-owned script (Phase 4) has no group-admin-tier script cap, so it
// is gated at `GroupScriptsWrite` (editor+ on the group) — the same tier
// that created it; group deletion itself stays group_admin-gated elsewhere.
require(
state.authz.as_ref(),
&principal,
Capability::AppAdmin(script_app(&script)?),
script_cap(&script, Capability::AppAdmin, Capability::GroupScriptsWrite)?,
)
.await?;
state.repo.delete(id).await?;
@@ -418,7 +436,11 @@ async fn list_logs<R: ScriptRepository, L: ExecutionLogRepository>(
require(
state.authz.as_ref(),
&principal,
Capability::AppLogRead(script_app(&script)?),
script_cap(
&script,
Capability::AppLogRead,
Capability::GroupScriptsRead,
)?,
)
.await?;
// Cap to keep the dashboard responsive; the data plane writes are

View File

@@ -135,6 +135,13 @@ pub enum Capability {
/// Write (set/delete) a group-owned secret (Phase 3). editor+ on the
/// group.
GroupSecretsWrite(GroupId),
/// Read (get / list) group-owned scripts (Phase 4). Resolved via the
/// group ancestor walk; viewer+ on the group. Maps to `script:read`.
GroupScriptsRead(GroupId),
/// Create / update / delete a group-owned script (Phase 4). editor+ on
/// the group; maps to `script:write` — the same tier as `AppWriteScript`
/// for an app, lifted to the group owner.
GroupScriptsWrite(GroupId),
/// Send an outbound email from a script in this app (v1.1.7). Maps
/// to `script:write` on API keys (sending mail is an outbound
/// side-effect like an HTTP request). Granted to `editor`+.
@@ -199,7 +206,9 @@ impl Capability {
| Self::GroupVarsRead(_)
| Self::GroupVarsWrite(_)
| Self::GroupSecretsRead(_)
| Self::GroupSecretsWrite(_) => None,
| Self::GroupSecretsWrite(_)
| Self::GroupScriptsRead(_)
| Self::GroupScriptsWrite(_) => None,
Self::AppRead(id)
| Self::AppWriteScript(id)
| Self::AppWriteRoute(id)
@@ -250,7 +259,8 @@ impl Capability {
| Self::AppUsersRead(_)
| Self::AppVarsRead(_)
| Self::GroupRead(_)
| Self::GroupVarsRead(_) => Scope::ScriptRead,
| Self::GroupVarsRead(_)
| Self::GroupScriptsRead(_) => Scope::ScriptRead,
Self::AppWriteScript(_)
| Self::AppKvWrite(_)
| Self::AppDocsWrite(_)
@@ -268,6 +278,7 @@ impl Capability {
// not app:admin. Only the VALUE READ (GroupSecretsRead) sits at
// the admin tier below.
| Self::GroupSecretsWrite(_)
| Self::GroupScriptsWrite(_)
| Self::AppInvoke(_) => Scope::ScriptWrite,
Self::AppWriteRoute(_) => Scope::RouteWrite,
Self::AppManageDomains(_) => Scope::DomainManage,
@@ -453,7 +464,9 @@ async fn role_grants(
| Capability::GroupVarsRead(g)
| Capability::GroupVarsWrite(g)
| Capability::GroupSecretsRead(g)
| Capability::GroupSecretsWrite(g) => {
| Capability::GroupSecretsWrite(g)
| Capability::GroupScriptsRead(g)
| Capability::GroupScriptsWrite(g) => {
group_member_grants(repo, principal.user_id, cap, g).await
}
// Creating a root-level group is an instance act — members
@@ -513,12 +526,15 @@ async fn group_member_grants(
/// viewer→read, editor→write, group_admin(=AppAdmin)→admin.
const fn group_role_satisfies(role: AppRole, cap: Capability) -> bool {
match cap {
// viewer+ reads group metadata and config vars.
Capability::GroupRead(_) | Capability::GroupVarsRead(_) => true,
// editor+ writes config vars/secrets.
// viewer+ reads group metadata, config vars, and scripts.
Capability::GroupRead(_)
| Capability::GroupVarsRead(_)
| Capability::GroupScriptsRead(_) => true,
// editor+ writes config vars/secrets/scripts.
Capability::GroupWrite(_)
| Capability::GroupVarsWrite(_)
| Capability::GroupSecretsWrite(_) => {
| Capability::GroupSecretsWrite(_)
| Capability::GroupScriptsWrite(_) => {
matches!(role, AppRole::Editor | AppRole::AppAdmin)
}
// group_admin manages the group + reads secret VALUES (the

View File

@@ -1658,6 +1658,12 @@ mod tests {
) -> Result<Vec<Script>, ScriptRepositoryError> {
unimplemented!("not used by this test")
}
async fn list_for_group(
&self,
_group_id: picloud_shared::GroupId,
) -> Result<Vec<Script>, ScriptRepositoryError> {
unimplemented!("not used by this test")
}
async fn list_for_user(
&self,
_user_id: AdminUserId,

View File

@@ -0,0 +1,236 @@
//! `/api/v1/admin/groups/{id}/scripts*` — group-owned script admin (Phase 4).
//!
//! * `GET /groups/{id}/scripts` — list the group's own scripts (not
//! inherited; just rows owned directly by this group). Gated by
//! `GroupScriptsRead` (viewer+ on the group).
//! * `POST /groups/{id}/scripts` — create a group-owned script. Gated by
//! `GroupScriptsWrite` (editor+ on the group).
//!
//! A group script is a **template** inherited by every descendant app,
//! resolved by name with nearest-owner-wins (CoW). Get / update / delete of an
//! existing group script go through the by-id `/scripts/{id}` endpoints, which
//! are owner-polymorphic — a group script there gates on `GroupScripts*`.
//!
//! **Phase 4-lite scope:** group ENDPOINT scripts only, and they must be
//! self-contained — `kind=module` and any `import` are rejected here, because
//! the origin-aware (lexical) module resolver is Phase 4b. The owner is
//! resolved FIRST (slug-or-uuid) and `authz::require` binds the capability to
//! the resolved group id, never to a caller-controlled path param.
use std::sync::Arc;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::get;
use axum::{Extension, Router};
use picloud_shared::{
GroupId, Principal, Script, ScriptKind, ScriptSandbox, ScriptValidator, ValidationError,
};
use serde::Deserialize;
use serde_json::json;
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
use crate::group_repo::GroupRepository;
use crate::repo::{NewScript, ScriptRepository, ScriptRepositoryError};
use crate::sandbox::SandboxCeiling;
#[derive(Clone)]
pub struct GroupScriptsState {
pub scripts: Arc<dyn ScriptRepository>,
pub groups: Arc<dyn GroupRepository>,
pub authz: Arc<dyn AuthzRepo>,
pub validator: Arc<dyn ScriptValidator>,
pub sandbox_ceiling: SandboxCeiling,
}
pub fn group_scripts_router(state: GroupScriptsState) -> Router {
Router::new()
.route(
"/groups/{group_id}/scripts",
get(list_group_scripts).post(create_group_script),
)
.with_state(state)
}
#[derive(Debug, Deserialize)]
pub struct CreateGroupScriptRequest {
pub name: String,
pub description: Option<String>,
pub source: String,
/// Phase 4-lite accepts only `endpoint`. A `module` is rejected (group
/// modules + the lexical import resolver are Phase 4b).
#[serde(default)]
pub kind: ScriptKind,
pub timeout_seconds: Option<i32>,
pub memory_limit_mb: Option<i32>,
#[serde(default)]
pub sandbox: ScriptSandbox,
}
async fn list_group_scripts(
State(s): State<GroupScriptsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<Script>>, GroupScriptsApiError> {
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupScriptsRead(group_id),
)
.await?;
Ok(Json(s.scripts.list_for_group(group_id).await?))
}
async fn create_group_script(
State(s): State<GroupScriptsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(input): Json<CreateGroupScriptRequest>,
) -> Result<(StatusCode, Json<Script>), GroupScriptsApiError> {
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupScriptsWrite(group_id),
)
.await?;
// Phase 4-lite: endpoint-only, self-contained.
if input.kind == ScriptKind::Module {
return Err(GroupScriptsApiError::Invalid(
"group modules are not supported yet (Phase 4b); create an endpoint script".into(),
));
}
let validated = s.validator.validate(&input.source)?;
if !validated.imports.is_empty() {
return Err(GroupScriptsApiError::Invalid(format!(
"group scripts must be self-contained in Phase 4-lite; \
remove the import(s): {}",
validated.imports.join(", ")
)));
}
s.sandbox_ceiling
.check(&input.sandbox)
.map_err(|e| GroupScriptsApiError::Invalid(e.to_string()))?;
let created = s
.scripts
.create(NewScript {
app_id: None,
group_id: Some(group_id),
name: input.name,
description: input.description,
source: input.source,
kind: ScriptKind::Endpoint,
timeout_seconds: input.timeout_seconds,
memory_limit_mb: input.memory_limit_mb,
sandbox: if input.sandbox.is_empty() {
None
} else {
Some(input.sandbox)
},
enabled: true,
// Self-contained — no import edges (enforced above).
imports: Vec::new(),
})
.await?;
Ok((StatusCode::CREATED, Json(created)))
}
async fn resolve_group(
groups: &dyn GroupRepository,
ident: &str,
) -> Result<GroupId, GroupScriptsApiError> {
let found = if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
groups
.get_by_id(uuid.into())
.await
.map_err(|e| GroupScriptsApiError::Backend(e.to_string()))?
} else {
groups
.get_by_slug(ident)
.await
.map_err(|e| GroupScriptsApiError::Backend(e.to_string()))?
};
found
.map(|g| g.id)
.ok_or(GroupScriptsApiError::GroupNotFound)
}
#[derive(Debug, thiserror::Error)]
pub enum GroupScriptsApiError {
#[error("group not found")]
GroupNotFound,
#[error("invalid request: {0}")]
Invalid(String),
#[error("conflict: {0}")]
Conflict(String),
#[error("forbidden")]
Forbidden,
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("scripts backend: {0}")]
Backend(String),
}
impl From<AuthzDenied> for GroupScriptsApiError {
fn from(d: AuthzDenied) -> Self {
match d {
AuthzDenied::Denied => Self::Forbidden,
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
}
}
}
impl From<AuthzError> for GroupScriptsApiError {
fn from(e: AuthzError) -> Self {
Self::AuthzRepo(e.to_string())
}
}
impl From<ValidationError> for GroupScriptsApiError {
fn from(e: ValidationError) -> Self {
Self::Invalid(e.to_string())
}
}
impl From<ScriptRepositoryError> for GroupScriptsApiError {
fn from(e: ScriptRepositoryError) -> Self {
match e {
ScriptRepositoryError::Conflict(m) => Self::Conflict(m),
ScriptRepositoryError::NotFound(id) => Self::Invalid(format!("script {id} not found")),
ScriptRepositoryError::Db(e) => Self::Backend(e.to_string()),
}
}
}
impl IntoResponse for GroupScriptsApiError {
fn into_response(self) -> Response {
let (status, body) = match &self {
Self::GroupNotFound => (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })),
Self::Invalid(_) => (
StatusCode::UNPROCESSABLE_ENTITY,
json!({ "error": self.to_string() }),
),
Self::Conflict(_) => (StatusCode::CONFLICT, json!({ "error": self.to_string() })),
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
Self::AuthzRepo(e) => {
tracing::error!(error = %e, "group-scripts admin authz repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
Self::Backend(e) => {
tracing::error!(error = %e, "group-scripts admin backend error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
};
(status, Json(body)).into_response()
}
}

View File

@@ -246,6 +246,12 @@ mod tests {
async fn list_for_app(&self, _app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError> {
unimplemented!()
}
async fn list_for_group(
&self,
_group_id: picloud_shared::GroupId,
) -> Result<Vec<Script>, ScriptRepositoryError> {
unimplemented!()
}
async fn list_for_user(
&self,
_user_id: AdminUserId,

View File

@@ -51,6 +51,7 @@ pub mod files_sweep;
pub mod gc;
pub mod group_members_repo;
pub mod group_repo;
pub mod group_scripts_api;
pub mod groups_api;
pub mod http_service;
pub mod invoke_service;
@@ -182,6 +183,7 @@ pub use group_repo::{
GroupChildCounts, GroupRepository, GroupRepositoryError, PostgresGroupRepository,
ROOT_GROUP_SLUG,
};
pub use group_scripts_api::{group_scripts_router, GroupScriptsApiError, GroupScriptsState};
pub use groups_api::{groups_router, GroupsApiError, GroupsState};
pub use http_service::{HttpConfig, HttpServiceImpl};
pub use kv_api::{kv_admin_router, KvAdminState};

View File

@@ -38,6 +38,10 @@ pub trait ScriptRepository: Send + Sync {
/// "global" views; the dashboard reaches scripts via `list_for_app`.
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError>;
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError>;
/// Phase 4: every script owned directly by `group_id` (not inherited —
/// just the group's own rows). Backs the group-script admin list.
async fn list_for_group(&self, group_id: GroupId)
-> Result<Vec<Script>, ScriptRepositoryError>;
/// Every script in any app the user is a member of. Drives
/// `GET /admin/scripts` for `member` instance-role callers so the
/// API never returns scripts they shouldn't see — even before the
@@ -99,6 +103,12 @@ impl<T: ScriptRepository + ?Sized> ScriptRepository for std::sync::Arc<T> {
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError> {
(**self).list_for_app(app_id).await
}
async fn list_for_group(
&self,
group_id: GroupId,
) -> Result<Vec<Script>, ScriptRepositoryError> {
(**self).list_for_group(group_id).await
}
async fn list_for_user(
&self,
user_id: AdminUserId,
@@ -262,6 +272,19 @@ impl ScriptRepository for PostgresScriptRepository {
Ok(rows.into_iter().map(Into::into).collect())
}
async fn list_for_group(
&self,
group_id: GroupId,
) -> Result<Vec<Script>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, ScriptRow>(&format!(
"SELECT {SCRIPT_SELECT_COLS} FROM scripts WHERE group_id = $1 ORDER BY name"
))
.bind(group_id.into_inner())
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn list_for_user(
&self,
user_id: AdminUserId,

View File

@@ -1411,6 +1411,12 @@ mod tests {
) -> Result<Vec<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
unimplemented!()
}
async fn list_for_group(
&self,
_group_id: picloud_shared::GroupId,
) -> Result<Vec<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
unimplemented!()
}
async fn list_for_user(
&self,
_user_id: AdminUserId,

View File

@@ -309,6 +309,33 @@ impl Client {
decode(resp).await
}
/// `GET /api/v1/admin/groups/{id_or_slug}/scripts` — a group's own
/// scripts (Phase 4). Not inherited; just the group's rows.
pub async fn group_scripts_list(&self, group_ident: &str) -> Result<Vec<Script>> {
let g = seg(group_ident);
let resp = self
.request(Method::GET, &format!("/api/v1/admin/groups/{g}/scripts"))
.send()
.await?;
decode(resp).await
}
/// `POST /api/v1/admin/groups/{id_or_slug}/scripts` — create a
/// group-owned script (Phase 4).
pub async fn group_scripts_create(
&self,
group_ident: &str,
body: &CreateGroupScriptBody<'_>,
) -> Result<Script> {
let g = seg(group_ident);
let resp = self
.request(Method::POST, &format!("/api/v1/admin/groups/{g}/scripts"))
.json(body)
.send()
.await?;
decode(resp).await
}
/// `PUT /api/v1/admin/scripts/{id}` — matches the dashboard, which
/// uses PUT despite the field-level update semantics. `cfg` carries
/// optional per-script runtime overrides (G3); unset fields are
@@ -1637,6 +1664,24 @@ pub struct CreateScriptBody<'a> {
pub sandbox: Option<ScriptSandbox>,
}
/// Phase 4: body for `POST /groups/{id}/scripts`. Like `CreateScriptBody`
/// minus `app_id` — the owning group comes from the path.
#[derive(Debug, Serialize)]
pub struct CreateGroupScriptBody<'a> {
pub name: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<&'a str>,
pub source: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout_seconds: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub memory_limit_mb: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub kind: Option<ScriptKind>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sandbox: Option<ScriptSandbox>,
}
#[derive(Debug, Serialize)]
struct UpdateScriptBody<'a> {
source: &'a str,

View File

@@ -8,16 +8,33 @@ use anyhow::{anyhow, Context, Result};
use picloud_shared::AppId;
use serde_json::Value;
use crate::client::{Client, CreateScriptBody, ScriptConfig};
use crate::client::{Client, CreateGroupScriptBody, CreateScriptBody, ScriptConfig};
use crate::config;
use crate::output::{KvBlock, OutputMode, Table};
pub async fn ls(app: Option<&str>, mode: OutputMode) -> Result<()> {
pub async fn ls(app: Option<&str>, group: Option<&str>, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let mut table = Table::new(["id", "app_slug", "name", "version", "updated_at"]);
if let Some(group_ident) = group {
// Phase 4: a group's own (non-inherited) scripts. The owner column
// shows the group, not an app.
let scripts = client.group_scripts_list(group_ident).await?;
for s in scripts {
table.row([
s.id.to_string(),
format!("group:{group_ident}"),
s.name,
s.version.to_string(),
s.updated_at.to_rfc3339(),
]);
}
table.print(mode);
return Ok(());
}
if let Some(ident) = app {
let app = client.apps_get(ident).await?;
let scripts = client.scripts_list_by_app(&app.app.slug).await?;
@@ -64,7 +81,8 @@ pub async fn ls(app: Option<&str>, mode: OutputMode) -> Result<()> {
pub async fn deploy(
file: &Path,
app_ident: &str,
app_ident: Option<&str>,
group_ident: Option<&str>,
name_override: Option<&str>,
description: Option<&str>,
cfg: &ScriptConfig,
@@ -89,28 +107,61 @@ pub async fn deploy(
})?,
};
// Slug-or-id resolution: a single GET satisfies both lookups and
// gives us the canonical app_id needed for create.
let app = client.apps_get(app_ident).await?;
let existing = client.scripts_list_by_app(app_ident).await?;
let (script, action) = if let Some(s) = existing.into_iter().find(|s| s.name == name) {
let updated = client
.scripts_update_source(&s.id.to_string(), &source, cfg)
.await?;
(updated, "updated")
} else {
let body = CreateScriptBody {
app_id: app.app.id,
name: &name,
description,
source: &source,
timeout_seconds: cfg.timeout_seconds,
memory_limit_mb: cfg.memory_limit_mb,
kind: cfg.kind,
sandbox: cfg.sandbox,
};
(client.scripts_create(&body).await?, "created")
// Deploy is create-or-update by name within the chosen owner. Exactly
// one owner — clap marks `--group` as conflicting with `--app`, so this
// only rejects the both-absent case.
let (script, action) = match (app_ident, group_ident) {
(Some(app_ident), None) => {
// Slug-or-id resolution: a single GET gives the canonical app_id.
let app = client.apps_get(app_ident).await?;
let existing = client.scripts_list_by_app(app_ident).await?;
if let Some(s) = existing.into_iter().find(|s| s.name == name) {
let updated = client
.scripts_update_source(&s.id.to_string(), &source, cfg)
.await?;
(updated, "updated")
} else {
let body = CreateScriptBody {
app_id: app.app.id,
name: &name,
description,
source: &source,
timeout_seconds: cfg.timeout_seconds,
memory_limit_mb: cfg.memory_limit_mb,
kind: cfg.kind,
sandbox: cfg.sandbox,
};
(client.scripts_create(&body).await?, "created")
}
}
(None, Some(group_ident)) => {
// Group scripts update through the owner-polymorphic by-id
// endpoint; create through the group endpoint.
let existing = client.group_scripts_list(group_ident).await?;
if let Some(s) = existing.into_iter().find(|s| s.name == name) {
let updated = client
.scripts_update_source(&s.id.to_string(), &source, cfg)
.await?;
(updated, "updated")
} else {
let body = CreateGroupScriptBody {
name: &name,
description,
source: &source,
timeout_seconds: cfg.timeout_seconds,
memory_limit_mb: cfg.memory_limit_mb,
kind: cfg.kind,
sandbox: cfg.sandbox,
};
(
client.group_scripts_create(group_ident, &body).await?,
"created",
)
}
}
(Some(_), Some(_)) | (None, None) => {
return Err(anyhow!("deploy requires exactly one of --app or --group"));
}
};
// Emit the script object so `--output json` callers can capture the
// id for the follow-up routes/triggers calls.

View File

@@ -529,15 +529,19 @@ enum DomainsCmd {
#[derive(Subcommand)]
enum ScriptsCmd {
/// List scripts. With `--app`, scoped to one app; without, one
/// List scripts. With `--app`, scoped to one app; with `--group`, a
/// group's own scripts (Phase 4); without either, one
/// `GET /admin/scripts` for everything the caller can see.
Ls {
#[arg(long)]
app: Option<String>,
/// Phase 4: list a group's own (non-inherited) scripts.
#[arg(long, conflicts_with = "app")]
group: Option<String>,
},
/// Upload a `.rhai` file. Patches the existing script with the
/// matching name in `--app` if one exists, otherwise creates it.
/// matching name in `--app`/`--group` if one exists, otherwise creates it.
Deploy(DeployArgs),
/// POST to `/api/v1/execute/{id}`. Body via `--body @path`,
@@ -551,8 +555,14 @@ enum ScriptsCmd {
#[derive(Args)]
struct DeployArgs {
file: PathBuf,
/// Owning app (slug or id). Exactly one of `--app` / `--group`.
#[arg(long)]
app: String,
app: Option<String>,
/// Phase 4: deploy a group-owned script (template inherited by
/// descendant apps) instead of an app-owned one. Exactly one of
/// `--app` / `--group`.
#[arg(long, conflicts_with = "app")]
group: Option<String>,
#[arg(long)]
name: Option<String>,
#[arg(long)]
@@ -1465,15 +1475,16 @@ async fn main() -> ExitCode {
},
} => cmds::groups::members_rm(&group, &user_id).await,
Cmd::Scripts {
cmd: ScriptsCmd::Ls { app },
} => cmds::scripts::ls(app.as_deref(), mode).await,
cmd: ScriptsCmd::Ls { app, group },
} => cmds::scripts::ls(app.as_deref(), group.as_deref(), mode).await,
Cmd::Scripts {
cmd: ScriptsCmd::Deploy(args),
} => match args.script_config() {
Ok(cfg) => {
cmds::scripts::deploy(
&args.file,
&args.app,
args.app.as_deref(),
args.group.as_deref(),
args.name.as_deref(),
args.description.as_deref(),
&cfg,
@@ -1516,7 +1527,8 @@ async fn main() -> ExitCode {
Ok(cfg) => {
cmds::scripts::deploy(
&args.file,
&args.app,
args.app.as_deref(),
args.group.as_deref(),
args.name.as_deref(),
args.description.as_deref(),
&cfg,

View File

@@ -578,6 +578,13 @@ pub async fn build_app(
groups: groups_repo.clone(),
authz: authz.clone(),
};
let group_scripts_state = picloud_manager_core::GroupScriptsState {
scripts: script_repo.clone(),
groups: groups_repo.clone(),
authz: authz.clone(),
validator: engine.clone(),
sandbox_ceiling: SandboxCeiling::from_env(),
};
let config_state = picloud_manager_core::ConfigApiState {
pool: pool.clone(),
apps: apps_repo.clone(),
@@ -606,6 +613,9 @@ pub async fn build_app(
.merge(apps_router(apps_state))
.merge(app_members_router(app_members_state))
.merge(groups_router(groups_state))
.merge(picloud_manager_core::group_scripts_router(
group_scripts_state,
))
.merge(vars_router(vars_state))
.merge(picloud_manager_core::config_router(config_state))
.merge(picloud_manager_core::app_users_router(

View File

@@ -86,7 +86,7 @@ pub use realtime::{BroadcasterError, NoopRealtimeBroadcaster, RealtimeBroadcaste
pub use realtime_authority::{DenyAllRealtimeAuthority, RealtimeAuthority, SubscribeDenied};
pub use route::{DispatchMode, HostKind, PathKind, Route};
pub use sandbox::ScriptSandbox;
pub use script::{Script, ScriptKind};
pub use script::{Script, ScriptKind, ScriptOwner};
pub use sdk_cx::SdkCallCx;
pub use secrets::{
validate_secret_name, NoopSecretsService, SecretsError, SecretsListPage, SecretsService,