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

@@ -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,