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

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