Compare commits

..

46 Commits

Author SHA1 Message Date
MechaCat02
aa2831da90 docs+test(hierarchies): end-to-end review follow-ups (M5/M6/M7)
Findings from a 3-lens end-to-end review (correctness, security, tests).
Security review came back clean (cross-repo authz sound, isolation intact,
M5 gate sound modulo the documented manifest-trust boundary). No behavior
changes here — only accuracy + coverage:

- apply_service Phase B2: correct the descendant-expansion comment. The chain
  is COMMITTED ancestry (ancestors() reads the pool), so a reparent in the same
  apply takes effect next apply — same "one more apply" shape the in-tree path
  and group-create reparenting already have. Authz stays sound: the gate and the
  expansion consume the SAME chain (checked == written).
- doc §4.5: document the two known "one more apply / no data risk" limitations
  — reparent-in-same-apply template resolution, and the `{env}` source split
  (declared apps use --env; cross-repo descendants use apps.environment).
- approval journey: give the app real (`[vars]`) content so the editor member's
  AppVarsWrite is actually exercised — the admin-gate test now proves approval
  is ABOVE editor-write, not just "any non-admin is refused". (Vars cascade with
  the app; scripts are ON DELETE RESTRICT and would break AppGuard teardown.)
- templates journey: assert descendant expansions are idempotent (stable row
  ids on re-apply — no churn), matching the in-tree guarantee.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:57:21 +02:00
MechaCat02
22d6c33d0b feat(hierarchies): per-env approval-policy gating (M5)
A project's root manifest can mark environments confirm-required; applying to
one needs an explicit `pic apply --dir --env <e> --approve <e>` (a blanket
`--yes` does NOT cover it), the act is admin-gated and audited, and the policy
folds into the bound-plan token. The last unbuilt piece of the project-tool
track (§4.2/§6).

- manifest: `[project]` block → `ManifestProject { environments[{name,confirm}] }`,
  valid only on the tree's ROOT manifest (rejected elsewhere by build_tree).
- discover: build_tree emits `project` into the bundle from the root manifest.
- apply_service: `TreeBundle.project` + `ProjectPolicy`; policy folds into the
  state_token (a policy edit between plan and apply trips StateMoved); plan
  surfaces `approvals_required`; new `ApplyError::ApprovalRequired` → 409.
- apply_api: `enforce_env_approval` (after authz_tree) — refuses a gated env
  not in `approved_envs`, and on approval requires admin (AppAdmin/GroupAdmin)
  on every declared node + audits. The server re-derives the policy from the
  bundle (CLI check is convenience; server is authoritative).
- CLI: `--approve <env>` (repeatable); `resolve_approvals` refuses a gated env
  non-interactively, prompts on a TTY (retype the env name); plan renders gated
  envs. Single-node `apply --file --env <e>` REFUSES a confirm-required env
  (can't carry the admin-gated approval) and directs to `--dir` — closing the
  bypass found in review rather than silently skipping the gate.
- approval journey + manifest/ProjectPolicy unit tests. No migration (policy
  lives in the manifest, like takeover/blast-radius).
- doc §11 Phase 5 + §12: approval gating shipped; gate is a `--dir` feature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:06:27 +02:00
MechaCat02
c04c684a2e feat(hierarchies): cross-repo template expansion (M7)
Route/trigger templates declared at a group now fan out to EVERY descendant
app in the DB subtree, not just the app nodes present in the current
`pic apply --dir`. A template change at group N reaches subtree(N) across
repos, and a removed/disabled template reaps its expansions on those
descendants too.

- group_repo: recursive `descendant_app_ids` (+ `_tx`) — inverse of the
  ancestor chain CTE, app_id-ordered, depth-bounded.
- apply_service: Phase B2 expands templates into descendants of every in-tree
  group not already handled in Phase B. All descendant locks are taken up
  front in the single sorted batch (their union is invariant under reparenting
  in-tree groups), so there is no out-of-order mid-tx locking / deadlock.
  Per-recipient write caps (AppWriteRoute / AppManageTriggers /
  AppSecretsRead-for-email) are required AUTHORITATIVELY IN-TX against the
  post-reparent, already-locked app — checked set == written set, no TOCTOU.
  expand_*_templates_tx are now self-contained (load hand-declared identities
  from the tx), so collisions are caught on descendants too. Blast radius now
  counts the true DB subtree. Descendant expansion errors are tagged with the
  app slug (e.g. an {env} template on a NULL-environment descendant names it).
- apply_api: the pre-tx descendant authz pass is removed in favour of the
  in-tx gate (sound via the hierarchy-aware effective_app_role).
- templates journey: out-of-tree descendant at depth 1 AND 2 receives the
  expansion and is reaped on template removal.
- doc §4.5: strike the in-apply-only scope limitation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 18:38:33 +02:00
MechaCat02
7c17d6e363 feat(hierarchies): template {var:NAME} resolves in-transaction (M6)
Route/trigger template `{var:NAME}` placeholders now resolve against vars
applied in the SAME `pic apply` transaction, not committed-only state — so a
var and a template referencing it converge in one apply instead of two.

- config_resolver: add tx-scoped `fetch_var_candidates_tx`, sharing the SQL
  tail (`VAR_CANDIDATES_TAIL`) with the pool variant so they can't drift.
- apply_service: `expand_route_templates_tx`/`expand_trigger_templates_tx`
  read vars via the tx (vars are reconciled in Phase A / the app's own
  reconcile, both before Phase B expansion).
- templates journey: `route_template_var_resolves_in_same_apply` — would fail
  against the old committed-only read.
- doc §4.5: strike the `{var:NAME}` committed-only limitation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 18:11:44 +02:00
MechaCat02
4d2eed4e81 feat(hierarchies): trigger templates — per-app fan-out (M4b)
Completes §4.5 templates (M4a shipped routes). A group declares a trigger once;
the tree apply fans it out into one concrete per-app_id trigger on each
descendant app, reusing the M4a expansion engine over the trigger insert path.

- Migration 0054: `trigger_templates` table (group-owned; the whole
  BundleTrigger wire object stored as `spec` JSONB, placeholders unresolved) +
  `triggers.from_template` provenance column + index.
- `template_repo.rs`: trigger-template CRUD + chain-load.
- Manifest: `[group]` `[[trigger_templates]]` (flat: name, kind, script, + kind
  params); build_bundle emits them; rejected on an app node.
- Apply: group nodes reconcile trigger-template rows; tree Phase B expands each
  chain trigger template into a concrete trigger per descendant app —
  placeholders resolved in every spec string leaf, then the typed trigger is
  rebuilt and inserted through the shared `insert_bundle_trigger_tx` (email
  secrets resolved + re-sealed per recipient app). Idempotent via from_template
  (one trigger per template per app; semantic-identity compare → no-op /
  delete+recreate / reap). Collision with a hand-declared trigger or between
  templates is a hard error. Each resolved trigger is re-validated with the
  per-kind shape check (cron/queue/etc.) so a {var:}-injected bad
  schedule/timeout can't slip in.
- Authz: declaring → GroupScriptsWrite; receiving → AppManageTriggers, plus
  AppSecretsRead for email-trigger recipients (parity with hand-declared email).
- Plan/token/report extended for trigger templates; blast radius covers both.
- Tests: tests/templates.rs trigger fan-out (kv + {app_slug}, stable-ids,
  prune-reap); resolve_placeholders_in_json unit test; schema golden reblessed.

Reviewed (no CRITICAL; isolation + per-app email-secret sealing verified
sound). Closed a HIGH validation-parity gap (re-validate resolved triggers) and
a MEDIUM authz gap (AppSecretsRead for email expansions). v1.2 Hierarchies
template work (M4) complete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 17:38:04 +02:00
MechaCat02
0396866698 feat(hierarchies): route templates — group-declared, per-app fan-out (M4a)
§4.5 of the groups/project-tool design. A group declares a route ONCE; the
tree apply fans it out into one concrete per-app_id route on every descendant
app — instantiation, not inheritance (the cross-app isolation boundary is
unchanged; expanded rows stay app-owned).

- Migration 0053: `route_templates` table (group-owned) + `routes.from_template`
  provenance column + index.
- `template_repo.rs`: tx-aware CRUD + chain-load (mirrors group_repo/route_repo).
- Manifest: `[group]` `[[route_templates]]`; build_bundle emits them; rejected
  on an app node (422).
- Apply: group nodes reconcile template rows (create/update/delete via the
  plan); tree Phase B expands each chain template into a concrete route per
  descendant app node — placeholders {app_slug}/{env}/{var:NAME} resolved per
  app (unknown var/placeholder = hard error), script bound nearest-owner-wins.
  Idempotent via from_template (diffed create/update-as-replace/delete; re-apply
  is no-churn, --prune reaps expansions). Collision with a hand-declared route,
  or between two templates, is a hard error. Each RESOLVED expansion is
  validated like a hand-declared route (reserved-path, path/host parse,
  host-claim) so a {var:}-injected path or unclaimed strict host can't slip in.
- Plan: route-template diff at the group node + blast-radius (descendant app
  count in this apply); state_token folds each template (edit trips StateMoved).
- Authz: declaring → GroupScriptsWrite; an app receiving expansions →
  AppWriteRoute (gated even with no hand-declared routes).
- Tests: tests/templates.rs (fan-out + per-slug + idempotent-stable-ids +
  prune-reap + collision-rejected); placeholder/validation unit tests; schema
  golden reblessed.

Reviewed (no CRITICAL/HIGH; isolation core verified sound). Closed the two
validation-parity MEDIUMs (host-claim + reserved-path/pattern on expansions)
and added an out-of-apply-descendant prune warning. Scope: expansion targets
app nodes in the tree apply (not yet cross-repo descendants); {var:NAME} uses
committed vars. M4b (trigger templates) reuses this engine next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:58:41 +02:00
MechaCat02
193336a8a6 feat(hierarchies): multi-repo single-owner ownership + structural prune (M3)
§7 of the groups/project-tool design. A group node is now authoritatively
managed by exactly one project-root; `pic apply --dir` claims, conflicts,
takes over, and (with --prune) structurally reaps owned nodes.

- Migration 0052: `projects(id, key UNIQUE)` + promote the inert
  `groups.owner_project` (0047) to a real FK (ON DELETE SET NULL) + index.
- CLI mints a stable, gitignored project key in `.picloud/project.json`
  (`pic init`, or lazily on first tree plan/apply) and presents it on every
  tree request; `pic apply --dir --takeover` flag.
- Server: prepare_tree resolves ownership read-only (plan surfaces conflicts
  + prune candidates; token folds each group's owner key). apply_tree upserts
  the project in-tx, claims created groups on insert, reconciles existing-group
  ownership under the per-node advisory lock (first-commit-wins), and prunes
  owned-but-undeclared groups leaf-first (delete=RESTRICT, never another repo's
  or a UI-owned node).
- Authz (§7.4, ownership ⟂ RBAC): takeover requires GroupAdmin per contested
  node — enforced in authz_tree (pre-tx) AND re-verified in-tx at the ownership
  decision, so --force (which waives the staleness token) can't open a
  takeover-without-admin window. The attacker-supplied project key is
  length/charset-validated server-side.
- Tests: tests/ownership.rs (claim → conflict → takeover → flip; non-admin
  takeover → 403; prune-owned-only); format_conflicts unit test; schema golden
  reblessed. tree_shape M2 journey updated to reparent in-place (a fresh dir is
  now a distinct project and would correctly conflict).

Closes the M3 milestone; reviewed (4 findings: 1 authz-bypass via --force +
key validation + 2 LOW, all fixed). M4 (trigger/route templates) remains.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 23:04:53 +02:00
MechaCat02
c18ce7c2c4 feat(hierarchies): declarative group create/reparent — tree shape (M2)
M2 of the remaining-hierarchies work. `pic apply --dir` now owns the org-tree
SHAPE, not just node content: a `[group]` manifest for a group that doesn't
exist is CREATED under its declared parent, and an existing group whose declared
parent changed is REPARENTED — all inside the single tree-apply transaction
(create + reparent; structural prune is deferred to M3 with the ownership layer).

group_repo: extract transaction-aware structural mutations — `create_group_tx`,
`reparent_group_tx` (the ancestor-walk cycle guard, now reading through the tx so
it sees in-tx writes), `delete_group_tx`, and `acquire_structural_lock_tx`. The
trait `create`/`reparent`/`delete` delegate to them (one SQL definition,
behavior-preserving: the coarse structural advisory lock + structure_version
bump + delete=RESTRICT all preserved).

apply_service: `TreeNode` gains `parent_slug` + `name`. `prepare_tree` classifies
group nodes into existing (resolved id, maybe reparent) vs to-create (absent →
deferred), returning a `PreparedTree { prepared, creates, reparents, token }`.
`apply_tree` adds Phase 0 (`reconcile_tree_structure_tx`): create absent groups
parent-first (topo loop with cycle/unresolved-parent detection) and reparent
existing ones, then Phase A/B reconcile content as before. A to-create group
reconciles against an empty CurrentState (all-Create) — so it needs no DB read
and sidesteps the pool-vs-tx coupling. The bound-plan token folds a
declared-absent marker, so a group created out-of-band between plan and apply
trips StateMoved.

authz (apply_api `authz_tree`): a to-create group mirrors the interactive create
gate — root-level needs `InstanceCreateGroup`, a subgroup under an existing
parent needs only `GroupAdmin(parent)`; a reparent needs `GroupAdmin` at the
group, the SOURCE parent, and the DESTINATION parent (§5.6, parity with
`reparent_group`). No 404 on a to-create node.

CLI: `[group] parent` manifest key (else the parent is inferred from the nearest
ancestor directory's group); `build_tree` emits `parent_slug` + `name` per group
node; the apply report shows a `groups +N reparented M` line.

Reviewed by subagent (core mechanism verified sound: topo termination, empty-
CurrentState reconcile, in-tx cycle guard, atomicity, StateMoved, idempotency);
fixed the two authz-parity gaps it found (missing source-parent check on
reparent; over-gated subgroup create). Tests: a new `tree_shape` journey
(create + reparent + no-op re-plan); 393 manager-core lib + the Phase-5 tree/
group/ext journeys all green; clippy -D warnings clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:18:27 +02:00
MechaCat02
dc8c69ac6f docs(design): mark extension points (§5.5) shipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:36:11 +02:00
MechaCat02
4dcb8d1136 feat(hierarchies): extension points — opt-in dynamic module resolution (§5.5)
M1 of the remaining-hierarchies work. An extension point lets a PARENT/group
node opt a module name out of sealed lexical resolution: a parent script's
`import "x"` then resolves against the INHERITING app's module (with a declared
default body as fallback) instead of the parent's sealed chain — so each
descendant app supplies its own `x`. Only the declaring parent can open the
inversion; a descendant can never make a parent's sealed import dynamic or
hijack one (the "is this an extension point" decision keys on the importer's
trusted defining node, never a script-passed value).

Resolver (executor-core):
- `ModuleSource` gains `resolve_extension_point(origin, name)` and a
  chain-constrained `resolve_by_id(origin, id)`. The resolve seam checks for an
  ext point on the importer's chain first; on a hit it resolves against
  `App(cx.app_id)`, else the declared default, else ModuleNotFound. The Postgres
  query returns Some only when the NEAREST declaration of the name is an ext
  point (a peer/nearer concrete module shadows it). A group origin can't reach
  app-owned rows — the trust boundary holds. Cache stays id-keyed (no bleed).

Apply (manager-core, mirrors the `vars` pattern):
- migration 0051: owner-polymorphic `extension_points` table (group XOR app),
  optional `default_script_id` FK ON DELETE SET NULL, per-owner LOWER(name)
  unique indexes.
- Bundle/Plan/CurrentState gain extension_points; `diff_extension_points`
  (name-based, default change = Update), reconcile (upsert after scripts so the
  default resolves) + prune, `validate_bundle` (module-name shape; default must
  be a local module), `check_imports_resolve` (a declared/on-chain ext point
  satisfies an import), and the state_token fold. Writes gate on the
  script-write capability (AppWriteScript / GroupScriptsWrite).
- GET /apps|groups/{id}/extension-points so `pic pull` round-trips them — a
  re-applied pulled manifest is all-NoOp, so `--prune` can't silently drop one.

CLI: `[[extension_points]]` manifest table; plan/apply build + render; pull
exports them.

Tests: 5 resolver units (inversion, default fallback, no-provider error,
no-hijack of a sealed import, no cross-tenant cache bleed), 2 diff/state-token
units, 2 journeys (per-app resolution + default fallback via invoke; pull
round-trip). Schema golden re-blessed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:35:29 +02:00
MechaCat02
52da8a8704 test(cli): group-module lexical-resolution journeys + Phase 4b docs (C5)
Some checks failed
CI / Rust — fmt, clippy, test (push) Failing after 17m58s
CI / Dashboard — check (push) Successful in 9m45s
Two journeys against a live server + Postgres:
- `group_module_is_lexically_sealed_under_inheritance`: a group module +
  a group endpoint that imports it, inherited by an app — proves the §5.5
  trust boundary (a leaf's same-named module does NOT shadow the inherited
  endpoint's import) and app-origin CoW (an app endpoint's import resolves
  the app's module).
- `dangling_import_is_rejected_by_plan`: a manifest script importing a
  non-existent module is a `pic plan` error.

Docs: mark §5.5 residual resolved + §11 Phase 4b  in the design doc;
update CLAUDE.md current-focus (extension points are the remaining §5.5
piece).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:39:08 +02:00
MechaCat02
c8acac1d20 feat(modules): enable group modules + dangling-import plan check (Phase 4b C4)
Lift the Phase-4-lite restrictions now that import resolution is lexical:

- `create_group_script` (group_scripts_api): allow `kind = module` and
  group scripts with `import`s; record the import edges. Module-shape and
  sandbox-ceiling validation stay.
- The declarative apply path already reconciles group modules generically
  (`BundleScript.kind` + `owner.script_owner()` flow through
  `reconcile_node_tx`); no change needed beyond stale-comment fixes.

Adds the §5.5 dangling-import plan check (single-node plan/apply):
`check_imports_resolve` rejects an `import "<m>"` that resolves to neither a
bundle-local module nor a module reachable lexically up the node's chain —
caught at plan time instead of as a runtime 404. Roots resolution at the
node's owner (an app node reaches ancestor group modules; a group node walks
its own ancestry). ApplyService gains an injected `ModuleSource`. The tree
path opts out (a node may import a module created by another node in the same
uncommitted tx); the runtime check is the backstop there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:29:18 +02:00
MechaCat02
b53eabb786 feat(modules): lexical import resolver — seal imports to defining node (Phase 4b C3)
Make `import` resolution lexical (§5.5): an inherited group script's
imports resolve against the module set at its OWN defining node, walking
up from there — a leaf app can't shadow them, and a group script behaves
identically under every inheriting app.

Mechanism — Rhai's `_source` carries the importing AST's origin tag:
- The entry script has no source tag → resolve from `default_origin` (its
  defining node, threaded in via C2).
- Each resolved module's AST source is set to `encode(module.owner())`
  before `eval_ast_as_new`, so its nested `import`s carry the module's own
  node as `_source` and resolve from there — lexical chaining.
- `encode_origin`/`decode_origin` map a `ScriptOwner` to/from `app:<uuid>`
  / `group:<uuid>`.

Cache rekeyed from `(app_id, name)` to the resolved `ScriptId`: a compiled
module is lexically self-contained, so its body is a pure function of
`(script_id, updated_at)` — this also dedupes a shared group module across
inheriting apps and keeps same-named cross-app modules distinct.

Adds two executor-core unit tests (origin-keyed fake) proving the trust
boundary: a group module's import binds the group's module even when an
app-owned module of the same name exists; `ScriptOwner` gains `Hash`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:22:25 +02:00
MechaCat02
1844bef132 feat(executor): thread the defining node into ExecRequest (Phase 4b C2)
Carry the resolved script's owner (its defining node) from dispatch into
the executor so `import` resolution can be lexical (§5.5). The executing
app (`app_id`) stays the SDK isolation boundary; `script_owner` is a
separate axis — the lexical origin for imports.

- `ExecRequest.script_owner: Option<ScriptOwner>` (serde default; `None`
  falls back to `App(app_id)` in the engine for old payloads / cluster wire).
- `ScriptOwner` gains `Serialize`/`Deserialize` for the wire.
- The engine computes `default_origin` and hands it to the resolver
  (used fully in C3; the resolve() lookup already uses it).
- Every dispatch site sets it from the resolved `Script.owner()`:
  the 4 dispatcher arms (queue/trigger/http/invoke_async, via a new
  `ResolvedTrigger.script_owner`), the orchestrator id-bypass, and the
  `invoke()` SDK path (new `ResolvedScript.owner`, so a group script
  invoked by an app resolves imports from the group).

Behaviour-preserving: app scripts still resolve `App(app_id)`; group
scripts can't yet carry imports (lifted in C4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:17:33 +02:00
MechaCat02
7fef098b48 feat(modules): owner-aware module lookup — lexical chain walk (Phase 4b C1)
Make the module-loading contract origin-aware so group-owned modules
become resolvable and imports can resolve lexically (§5.5).

- `ModuleScript` gains a polymorphic owner (`app_id`/`group_id` + `owner()`),
  mirroring `Script`.
- `ModuleSource::lookup(&cx, name)` -> `resolve(origin: ScriptOwner, name)`:
  resolution walks the group chain rooted at the importing script's
  defining node (nearest-owner-wins), not the inheriting app's view.
- `PostgresModuleSource::resolve` branches on origin: app-rooted
  (`CHAIN_LEVELS_CTE`) or a new group-rooted `GROUP_CHAIN_LEVELS_CTE`,
  joining `kind='module'` rows.

The executor resolver passes a temporary `App(cx.app_id)` origin (behaviour-
preserving for app scripts; true lexical origin lands in C3). Group scripts
still can't carry imports until C4, so no trust-inversion window opens here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:07:04 +02:00
MechaCat02
3ef3038eb7 test(cli): project-tree journeys + Phase 5 docs
Phase 5 C5. Three `pic ... --dir` tree journeys:
  * a group + a nested app apply atomically as one tree (the app's route binds
    the group's same-apply `shared` script); the group node's script is
    created; re-plan is all-noop (idempotent),
  * one invalid node aborts the whole tree — the valid group node's script is
    NOT created (true all-or-nothing),
  * a `pic groups reparent` between `pic plan --dir` and `pic apply --dir` trips
    the bound-plan (StateMoved) check via the tree's structure version.

Docs: `groups-and-project-tool.md` §11.5 status ( shipped — single-repo
nested tree apply; multi-repo single-owner + per-env approval gating deferred
per §11.1) + CLAUDE.md current-focus.

Gates: fmt, clippy -D warnings, cargo test --workspace, check-versioning (50
migrations — Phase 5 added none), schema snapshot unchanged, full journey suite
108/108. (Pre-existing flake `queue_e2e::queue_receive_acks_on_success` — a
racy immediate `count==0` after the async ack; flaky at the pre-Phase-5
baseline too, unrelated to this work.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 22:43:20 +02:00
MechaCat02
1662d7806a feat(cli): nested-manifest discovery + pic plan/apply --dir tree mode (Phase 5 C4)
Phase 5 C4. `pic plan --dir <root>` / `pic apply --dir <root>` discover every
`picloud.toml` under a directory (groups + apps), assemble the wire tree
bundle, and drive the C3 `/tree/{plan,apply}` endpoints — a whole project
subtree reconciled and reviewed as one unit.

  * New `discover` module: recursive walk (skips `.picloud`/`.git`/`scripts`/
    `target`), one node per manifest, app/group inferred from the manifest;
    rejects a duplicate (kind, slug). On-disk nesting is organizational — the
    server resolves ancestry from its own group tree.
  * `client`: `plan_tree`/`apply_tree` + `TreePlanDto`/`NodePlanDto`. `pic plan
    --dir` renders a per-node diff table and records ONE tree bound-token under
    a `<tree>` linkstate marker; `pic apply --dir` replays it (force/`--yes`/
    prune apply tree-wide). `--dir` conflicts with `--file`.

Live-validated: a nested tree (root group dir + nested app dir, app route bound
to the group's inherited script) → `pic plan --dir .` shows both nodes' changes
→ `pic apply --dir .` applies all in one tx → re-plan all-noop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 22:25:29 +02:00
MechaCat02
8a559ea178 feat(apply): atomic project-tree apply with cross-node inherited binding (Phase 5 C3)
Phase 5 C3 — the headline. A whole project subtree (group + app nodes) is
reconciled in ONE Postgres transaction, all-or-nothing.

`POST /api/v1/admin/tree/{plan,apply}` take a `TreeBundle { nodes: [{kind,
slug, bundle}] }`. The actor must hold the relevant read/write caps for EVERY
node (per-kind, widened on prune) — `authz_tree` + reusable
`require_{app,group}_node_writes` (now shared with the single-node handlers).

Engine:
  * The in-tx reconcile body of `apply_owner` is extracted to `reconcile_node_tx`
    (returns the node's post-create `name → script_id`), so single-node and
    tree apply share one implementation — behaviour-preserving (391 unit tests
    + app journeys green).
  * `prepare_tree` resolves each slug→owner, validates (an app's route/trigger
    target may bind to an in-tree ancestor group's script), computes each
    node's plan, and folds a combined bound-plan token = every node's
    `state_token` + every in-scope group's `structure_version` (so a reparent
    or content edit between plan and apply trips StateMoved).
  * `apply_tree`: lock every node key (sorted, deadlock-free); reconcile GROUPS
    first, recording each group's `name → id` in an in-memory index; then APPS,
    resolving inherited route/trigger targets nearest-ancestor-wins across the
    in-tree index (incl. scripts created earlier in THIS tx, invisible to the
    pool resolver) and pre-existing out-of-tree ancestors. One commit, one
    post-commit route refresh.

Live-validated against the dev DB:
  * Headline: a group node creates `shared`; an app node in the SAME apply
    binds `GET /greet` to it — the route ends up bound to the group-owned
    script, and re-plan is all-noop (idempotent).
  * Atomicity: one invalid node → 422 and ZERO rows written (the valid group
    node's script is not created).

CLI tree discovery + `pic plan/apply` tree mode is C4; journeys + docs are C5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 22:18:07 +02:00
MechaCat02
da03fda1d6 feat(cli): [group] manifest + pic plan/apply for a group node (Phase 5 C2)
Phase 5 C2. A `picloud.toml` can now declare a `[group]` node (instead of
`[app]`): the group's own scripts + `[vars]` (+ secret names). `pic plan` /
`pic apply` reconcile it via the C1 group endpoints — the same plan/apply/
bound-token flow as an app, routed by node kind.

  * `Manifest` is now app-XOR-group: `app`/`group` are both optional with an
    exactly-one check in `parse`, plus a group-node guard that rejects
    `[[routes]]`/`[[triggers]]` (those bind to an app). Accessors `slug()` /
    `is_group()` replace the hardcoded `manifest.app.slug`.
  * `client`: `plan_node`/`apply_node` take a `NodeKind { App | Group }` that
    selects the `apps` vs `groups` API path; the old `plan`/`apply` wrappers
    are gone (callers pass the kind).
  * `pic plan`/`apply` detect the node kind from the manifest and label output
    `group`/`app`; `pic config --effective` cleanly rejects a group manifest
    (effective config is an app's inherited view).

Live-validated: a `[group]` manifest with a script + var → `pic plan` (script
+ var creates) → `pic apply` (group-owned) → idempotent re-plan noop →
`pic scripts ls --group` shows it. Manifest unit test for group parse +
app-only-block rejection; init/pull/plan/apply/overlay journeys all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:57:58 +02:00
MechaCat02
65049a6262 feat(apply): owner-generic apply engine — group-node plan/apply (Phase 5 C1)
Phase 5 C1. Generalize the reconcile engine from app-only to an `ApplyOwner
{ App | Group }`, so a GROUP's own config + scripts can be declaratively
reconciled — the foundation for the tree-spanning project tool.

A group node is a subset of an app node (scripts + vars only; no routes /
triggers / secrets-values), so the diff, script/route/trigger/var CRUD, prune,
idempotency, and the Phase-4 inherited-target resolution are all reused
unchanged. Only the owner-varying bits switch on `ApplyOwner`:
  * `load_current` — `list_for_group` + `VarOwner::Group` for a group (empty
    routes/triggers/secrets); `list_for_app` for an app, exactly as before.
  * script create owner (`NewScript{app_id|group_id}`), var writes (new
    `set_group_var_tx`/`delete_group_var_tx` mirroring the app variants at
    scope `*`), and the advisory lock (owner-tagged).
  * `validate_bundle_for` rejects routes/triggers on a group bundle (422);
    route-host validation, inherited-target resolution, the post-commit route
    refresh, and the unreachable-endpoint warning are app-only.

`apply`/`plan` are unchanged thin wrappers over the new `apply_owner`/
`plan_owner`. New endpoints `POST /groups/{id}/{plan,apply}` gated by
`GroupScripts{Read,Write}` / `GroupVarsWrite`. `ApplyService` gains a `groups`
repo (the tree apply in C3 needs it too).

Live-validated: group plan → apply (group-owned script + var) → idempotent
re-plan noop → routes rejected 422 → DB confirms group ownership. App apply
unchanged: 391 manager-core unit tests + the apply/prune/staleness/overlay
journeys all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:46:52 +02:00
MechaCat02
99e7100f0b fix(apply): resolve inherited-bound triggers in diff, state-token, and prune
Adversarial review of Phase 4-lite found two real gaps, both rooted in the
apply engine resolving script `id → name` from `current.scripts` (app-own)
only — so a route/trigger bound to an inherited GROUP script (whose id is not
app-own) couldn't be named:

  * **Prune gap (reachable via pure declarative apply):** a trigger bound to an
    inherited group script, once dropped from the manifest, never diffed to a
    Delete (the diff couldn't resolve its identity once the bundle stopped
    referencing the name), so `apply --prune` silently orphaned it.
  * **Bound-plan false-negative:** `state_token` excluded such a trigger from
    its fingerprint, so the StateMoved check missed a concurrent edit to that
    binding class. (Routes were already safe — they hash the raw `script_id`.)

Fix: `resolve_current_target_names` resolves the names of group scripts the
app's CURRENT routes/triggers are bound to (by id, independent of the bundle),
and that map now feeds `compute_diff_with_names`, `state_token_with_names`, and
the prune trigger-identity map. App-owned bindings are unaffected (their names
are already in `current.scripts`). Also fixes a stale "in this app" conflict
message for group-owned scripts.

New journey `inherited_bound_trigger_is_pruned_when_dropped` proves prune now
removes an inherited-bound cron trigger. Full journey suite 105/105.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:11:59 +02:00
MechaCat02
9598db149b docs: record Phase 4-lite (group-owned scripts) as shipped, live-resolved
Mark §11.4  shipped with the live-resolution decision (no body
materialization / invalidation fan-out, mirroring Phase 3's config), the
shipped surface, and the deliberate Phase-4b deferrals (group modules + lexical
import resolver, invoke-by-id, live auto-rebinding). Note the sharp edge:
routes.script_id ON DELETE CASCADE means deleting a group script drops
descendant routes. Update the §5.1 materialization box and CLAUDE.md
(current-focus + the data-model invariant now covers group-owned scripts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:44:51 +02:00
MechaCat02
c29e4b9c53 test(cli): journey for group-script inheritance, CoW, isolation + apply binding
Phase 4-lite C5. Two `pic` journeys:
  * `group_script_is_inherited_with_cow_and_isolation` — a group endpoint
    `shared` is invoked by name from an app under the group; the app's own
    `shared` then shadows it (CoW); an app outside the group cannot reach it.
  * `manifest_binds_route_to_inherited_group_script_idempotently` — a manifest
    that declares no `greet` script binds a route to the inherited group
    `greet`; plan shows a route create (no script create), apply succeeds, and
    re-plan is a clean no-op.

Adds a `ScriptGuard` (deletes a script by id on drop) since a group-owned
script blocks its group's deletion (ON DELETE RESTRICT) — it must drop before
the GroupGuard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:42:35 +02:00
MechaCat02
701ec90b56 feat(apply): declaratively bind routes/triggers to inherited group scripts
Phase 4-lite C4 — the headline declarative path. An app's manifest can now bind
a `[[routes]]` / `[[triggers]]` to a script it does NOT declare, resolving the
name to an inherited group-owned endpoint on the app's chain (nearest-owner
wins; an app's own script of the same name still shadows it — CoW).

  * `resolve_inherited_targets(app_id, bundle)` resolves every route/trigger
    target name that isn't an app-own manifest script to a group endpoint via
    `get_by_name_inherited`, returning `name → script_id`. Run once before the
    apply transaction (group scripts pre-exist, committed).
  * `validate_bundle` takes the inherited endpoint names so "binds to unknown
    script" / "is a module" checks pass for inherited targets.
  * The apply transaction merges the inherited targets into `name_to_id`
    (app-own keeps precedence), so route/trigger inserts bind to the group
    script's id.
  * `compute_diff_with_inherited` adds the inherited ids → names to the diff's
    name map, so a route bound to a group script resolves its name and
    re-apply is idempotent (without this it read as a perpetual rebind).

Live-validated: an app under group G with a manifest binding `GET /greet` to
the group's inherited `greet` plans as `route create → greet` (no script
create), applies (+1 route, +0 scripts), and re-plans as `noop` (idempotent).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:32:24 +02:00
MechaCat02
719a17432f feat(scripts): inherited resolution for invoke() + chain-aware dispatch guards
Phase 4-lite C3. A descendant app can now resolve and run a group-owned script
inherited down its ownership chain — the core of group-script inheritance.

Resolver (repo, reusing config_resolver's CHAIN_LEVELS_CTE):
  * `get_by_name_inherited(app_id, name)` — walk app → ancestor groups
    nearest-first, return the nearest script of that name (an app's own script
    shadows an inherited group one: CoW).
  * `is_invocable_by_app(script_id, app_id)` — EXISTS over the same chain; the
    cross-app isolation predicate generalized to the hierarchy.

invoke(): `invoke("name", …)` resolves via `get_by_name_inherited`, so a script
can call a shared group endpoint by name. A group script always runs under the
*caller's* app context, never its owner.

Dispatch guards (the isolation backstops touched in C1) are now chain-aware via
a `Dispatcher::script_invocable` helper and the chain-aware
`validate_trigger_target`: app-owned scripts keep the in-memory fast path (no
query, behavior byte-identical to the pre-Phase-4 same-app guard); only a
group-owned candidate pays one chain query, and a non-member / query error
fails closed (dead-letter or reject — never run under another app's SdkCallCx).

The orchestrator HTTP route path needs no change: its per-app route trie
already scopes routes to one app, and it dispatches under the route's app
without reading script.app_id — a route bound to a group script just works.

Live-validated against the dev DB: an app under group G invoking the group's
`shared` returns its output; an app NOT under G cannot resolve it (isolation);
and after the app deploys its own `shared`, the same call returns the app's
version (CoW shadowing). Route/trigger binding of inherited scripts via the
declarative engine is C4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:23:07 +02:00
MechaCat02
43ed4e8e7f 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>
2026-06-25 20:10:20 +02:00
MechaCat02
48178c5f60 feat(scripts): polymorphic script owner (app XOR group) — Phase 4 foundation
Phase 4-lite C1. Make script ownership polymorphic so a script can be owned
by a GROUP (a template inherited by descendant apps) instead of an app —
mirroring vars/secrets (0048/0049), but ON DELETE RESTRICT (code is not data).

Schema (0050): `scripts.group_id` (nullable FK→groups RESTRICT), `app_id` made
nullable, `scripts_owner_exactly_one` CHECK, and the per-app name index split
into two per-owner partial-unique indexes. Existing app-owned rows keep their
exact `(app_id, lower(name))` uniqueness.

Type: `Script.app_id` becomes `Option<AppId>`; add `Script.group_id` +
`ScriptOwner` / `is_owned_by_app()`. `NewScript` gains the same polymorphic
owner. The execution-context app (what a script runs *under*) is supplied by
the invoking route/trigger/caller, never read off the script — group scripts
have no single app.

Behavior is fully preserved for app-owned scripts (the only kind creatable
today): every isolation backstop and authz site now uses `is_owned_by_app`,
which is byte-identical for app owners and **fails closed** for group owners.
A group script therefore can't yet be run, route/trigger-bound, invoked, or
managed via the app-script API — those land in C2 (group-script creation) and
C3 (chain-membership resolution + binding). The `/execute/{id}` bypass 404s a
group script (no app context to run under).

Re-blesses expected_schema.txt; note the golden was last blessed at migration
0044, so this also captures the already-committed 0045–0049 schema.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 19:53:05 +02:00
MechaCat02
27dc04819f test(cli): journey for declarative app-var reconciliation
`apply_reconciles_app_vars` drives the full cycle through `pic` against a
real server: a manifest `[vars] region = "eu"` is applied (var created +
injected — a script `vars::get("region")` returns "eu"), the value is
changed and re-applied (Update, returns "us"), then the var is dropped and
`apply --prune` removes it (`vars::get` resolves to `()` / JSON null).
Exercises the Bundle.vars wire, diff_vars create/update/delete, the in-tx
writes, and the runtime resolver end to end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:06:44 +02:00
MechaCat02
181622a84a feat(cli): pic pull exports an app's own vars into [vars]
Completes the vars round-trip: pull now fetches the app's OWN env-agnostic
vars (`vars_list` filtered to scope `*`, non-tombstone) and writes them
into the manifest `[vars]` block, alongside scripts/routes/secrets.
Inherited group vars stay out (pull exports own rows only, §4.6); a value
TOML can't represent (e.g. JSON null) is skipped with a warning. So
`pic pull` → edit → `pic apply` is now lossless for app config vars.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:03:26 +02:00
MechaCat02
34dcae98a2 feat(cli): declare app vars in the manifest + plan/apply them
Adds a `[vars]` block to `picloud.toml` (key → TOML value), reconciled by
`pic plan`/`pic apply` alongside scripts/routes/triggers/secrets. Unlike
`[secrets]` (name-only), var values live inline since they aren't secret.
The overlay (`picloud.<env>.toml`) may carry `[vars]`; overlay keys win
per key (the env-specific value of an env-agnostic default).

`build_bundle` serializes the vars to JSON for the wire (TOML round-trips
via serde); `pic plan` shows a `var` row group and `pic apply` reports
`vars +c ~u -d`. The `PlanDto`/`ApplyReportDto` gain the matching fields.
`pic pull` carries an empty `[vars]` for now (export lands next). Adds a
round-trip + overlay-precedence unit test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 07:40:36 +02:00
MechaCat02
e517f6dc8c feat(apply): reconcile app-owned vars in the declarative engine
Extends the Phase-1 apply engine to manage app `vars` declaratively,
alongside scripts/routes/triggers/secrets. The `Bundle` gains a `vars`
map (key → JSON value; values are non-secret, so they live inline, unlike
secret names). `diff_vars` is value-sensitive: a changed value is an
Update, a live var the manifest dropped is a Delete (applied only under
`--prune` — vars are prunable config, unlike never-pruned secrets).

Writes go through `set_app_var_tx`/`delete_app_var_tx` inside the apply
transaction (mirroring `PostgresVarsRepo::set` for `VarOwner::App`, scope
`*`), so they commit atomically with the rest of the apply and roll back
together on failure. `CurrentState` loads the app's OWN scope-`*`,
non-tombstone vars (inherited group vars and tombstones are out of scope
for the manifest); `state_token` folds them in so the bound-plan check
catches an out-of-band `pic vars set` between plan and apply. Keys are
validated against the same kebab rule as the vars admin API, and the
apply handler now requires `AppVarsWrite` when vars are touched or
`--prune` is set.

Scope: app-owned vars at scope `*` (an app *is* an environment). Group
vars via manifest and tombstone-via-manifest wait for the nested-manifest
model. Unit tests cover the create/update/noop/delete diff and the
state-token value sensitivity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 07:35:12 +02:00
MechaCat02
b2822c8435 Merge: groups Phase 3 — group-inherited config (vars + group secrets)
Adds the net-new env-scoped `vars` table + the §3 resolution engine
(env pre-filter, proximity-first, per-key map merge, explicit tombstones),
group-owned environment-scoped secrets with owner-discriminated AAD
(secret:group:{group_id}:{name}, disjoint from the unchanged app AAD so
existing ciphertexts still decrypt), masked group-secret reads (app devs
see exists, never plaintext; only owning-group principals read values;
runtime injection bypasses the human gate), the vars:: SDK, and
pic vars / pic secrets --group / config --effective. Migrations 0048
(vars) and 0049 (group secrets reshape).

Verified before merge: fmt/clippy(-D warnings) clean; manager-core +
executor-core 523 unit tests pass (incl. AAD cross-owner-swap rejection
and the pure §3 resolver semantics); check-versioning OK (49 migrations);
all 101 CLI journeys pass against a live DB, including masked-read
(group_secret_value_is_masked_from_app_devs), runtime injection +
override, and var inheritance. Commit 79f8c9d also fixes the 6
long-standing stale logs/scripts/roles journey assertions, so the suite
is now fully green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 07:19:08 +02:00
MechaCat02
b46667c309 docs: reconcile the groups/project-tool initiative into the roadmap
The groups/project-tool work had no home in the blueprint's version
scheme, two "Phase N" numbering schemes overlapped, and CLAUDE.md's
"current focus" was stale (claimed v1.1.0 SDK foundation; we're at v1.1.9
with the SDK line complete). Reconcile all three:

* blueprint §12 Phase 5 (v1.2, already titled "Advanced Workflows &
  Hierarchies") — split into a Hierarchies track (the groups + project
  tool initiative, with §11 Phases 1–3 marked shipped) and a Workflows
  track, with sequencing flagged as an open call. This gives the work an
  explicit version home: the Hierarchies half of v1.2.
* design doc header — Status Draft → Active, "scheduled as the Hierarchies
  track of v1.2", and an explicit warning that §11's local Phase 1–6
  numbering is distinct from the blueprint product-phase numbering.
* design doc §11.1 — a post-Phase-3 re-sequencing review: resolve Phase 4
  scripts LIVE (drop the fan-out-invalidation sub-project, per the Phase 3
  result); consider a "Phase 5-lite" (declarative apply of vars/secrets +
  app scripts) before the hard group-scripts work; and force the two
  deferred decisions (trigger/route templates vs tenant cardinality;
  multi-repo ownership vs a single-repo start).
* CLAUDE.md — refresh "current focus" to v1.2 Hierarchies, and correct the
  now-broken "every v1.1+ table is app_id NOT NULL" invariant: the new
  group-inheritable config tables (vars, secrets) use a polymorphic owner
  (group_id XOR app_id), not app_id NOT NULL.

Doc-only; no code change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 07:12:25 +02:00
MechaCat02
ca360d84cb docs(design): record Phase 3 shipped + the live-resolution decision
Phase 3 (group-inherited config) is implemented, so fold the outcome back
into the design doc (per its own "before it drifts" note) — and flag the
one deliberate divergence from the plan.

§5.1 / §11.3 / §12 prescribed a *materialized* per-app effective view with
a generation counter and fan-out invalidation. We shipped **live,
resolve-on-read inheritance** instead: one recursive CTE per
`vars::get`/`secrets::get`/`config/effective`, no cache. The org tree is
small and there was no pre-existing config cache to fight, so this is
cheap and sidesteps §10's unsolved invalidation SLA.

Records, mirroring the Phase 1/2 status blocks:
* a "Status (Phase 3):  shipped" block in §11.3 with the full surface
  (0048/0049 schema incl. `apps.environment`, the §3 resolver, vars SDK +
  CRUD, group-secret AAD + inherited read, the two secret gates, masked
  `config/effective`, `--explain`) and the live-vs-materialized rationale;
* §5.1 runtime-model bullet annotated as the deferred (Phase-4 script
  inheritance) design, not Phase-3 runtime;
* §10 "invalidation SLA" and "inherited-membership revocation lag" marked
  moot/resolved — no cache means immediate propagation;
* §12 resolver contract + the header framing updated to live resolution.

Doc-only; no code change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:53:58 +02:00
MechaCat02
79f8c9d420 test(cli): refresh stale deploy + logs journey assertions
Six journeys asserted against output formats that changed in earlier
commits and had been failing regardless of branch:

* `pic scripts deploy` prints a `name`/`version`/`action` KvBlock, not a
  prose "Created X vN" line. The name-override, version-bump, and the
  editor-can-deploy role check now parse the KvBlock fields (asserting the
  exact version + created/updated action) via a small `kv_field` helper.
* `pic logs` gained a `source` column (now `created_at, source, status,
  summary`); the success/error-status and summary-truncation checks now
  index the status at col 2 and the summary at col 3.

No production code touched — the deploys/logs always succeeded; only the
assertions were stale. Full `--test cli --include-ignored` suite is now
green (101 passed, 0 failed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:37:36 +02:00
MechaCat02
bb68e5e50a fix(secrets): composite keyset for group-secret admin list pagination
`list_meta` orders by `(name, environment_scope)` — a group secret name
can carry several env-scoped rows — but the cursor encoded only `name`
and paginated with `name > $cursor`. When a name's scopes straddled a
page boundary, the remaining scope rows were silently skipped from the
admin listing. Switch to a composite `(name, environment_scope)` keyset
(base64url of `name \x1f scope`) and a Postgres row-comparison predicate.
App secrets (single `*` scope per name) were unaffected; group secrets
with multi-scope names now page completely. Found in final branch review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:13:00 +02:00
MechaCat02
11ac168839 test(secrets): group-secret inheritance + masked-read journeys
Two end-to-end journeys via `pic` against a real server:

* `group_secret_is_injected_then_app_value_overrides` — a group-owned
  secret is injected into a descendant app's script through `secrets::get`
  (decrypted under the group AAD), then an app-owned secret of the same
  name shadows it (decrypted under the app AAD). Drives the resolver + both
  owner-AAD open paths against Postgres.
* `group_secret_value_is_masked_from_app_devs` — the §5.3 boundary: an app
  dev (editor on the app, no group role) is denied the secret VALUE (403)
  yet still sees it EXISTS, masked, in `config/effective` (status set,
  owner group, no value); a group_admin reads the value. Proves an app
  runs with config its own devs cannot read.

Also updates the existing app-secrets `ls` journey for the new `env`
column (group secrets are env-scoped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:09:14 +02:00
MechaCat02
30441549d5 feat(cli): pic secrets --group, value read, and effective vars/config
Mirrors `pic vars` on the secrets surface: `pic secrets ls/set/rm` take an
`--app`/`--group` owner selector (exactly-one) with `--env` for group
secrets. Adds `pic secrets read --group <g> <name> [--env]` — the only
command that reveals a secret value, hitting the group-gated value
endpoint. `pic config --effective` now folds in the resolved vars section
(value + owner + scope) and an `--explain` provenance view, alongside the
existing masked-secrets cross-reference, via `GET /config/effective`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:00:32 +02:00
MechaCat02
c914758c09 feat(secrets): group-secrets admin API, masked read, config/effective
Adds the Phase-3 admin surface on top of the group-secrets storage:

* `secrets_api` gains group routes under `/groups/{id}/secrets`
  (set/list/delete, env-scoped) gated `GroupSecretsWrite` (editor+), plus
  the ONE plaintext endpoint `GET /groups/{id}/secrets/{name}/value` gated
  `GroupSecretsRead` (group_admin only). That is the masked-secret
  boundary: a descendant app's dev sees a group secret EXISTS and consumes
  it at runtime via `secrets::get`, but only a reader at the OWNING group
  gets the value. App secrets stay env-agnostic (a stray `env` is rejected).
  The owner is resolved first, then the capability binds to the resolved
  id — never a path param.

* `config_api`: `GET /apps/{id}/config/effective` (gated `AppVarsRead`)
  returns the resolved view a dev would get — every inherited var with its
  value + provenance, and every inherited secret MASKED (name/owner/scope,
  never the value). Backed by a new `fetch_effective_secret_meta`
  (DISTINCT-ON nearest-wins, same ordering as the per-name resolver).

* authz: `GroupSecretsWrite` moves from `app:admin` to `script:write`
  scope so its API-key scope matches its editor role tier (closing the
  latent scope/role mismatch the checkpoint review flagged); the value
  read `GroupSecretsRead` stays at `app:admin`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:00:32 +02:00
MechaCat02
e6b4792389 feat(secrets): group-owned, env-scoped secrets + inherited resolution
Migration 0049 reshapes `secrets` to the same polymorphic-owner contract
as `vars` (0048): a secret is owned by an app XOR an ancestor group,
carries an `environment_scope`, and the old PK `(app_id, name)` becomes
two partial-unique indexes `(owner, environment_scope, name)`. Existing
rows backfill to `app_id` + scope `'*'`; the v1 AAD does NOT include the
scope, so every current ciphertext keeps decrypting byte-for-byte.

`SecretsRepo` is generalised to be owner+scope keyed (`SecretOwner` moves
down from `secrets_service` and is re-exported for path stability). The
SDK read path now goes through `SecretsRepo::resolve`, which reuses the
shared `CHAIN_LEVELS_CTE` to walk app→ancestor-group→root, env-filters,
and takes the nearest level (`@E` beating `*` within a level) — returning
the winning owner so the value is decrypted under the AAD it was sealed
with. Runtime injection stays anchored to `cx.app_id`: an app only ever
resolves its own and its ancestors' secrets.

All callers updated to owner+scope (`SecretOwner::App(_)`, scope `'*'`):
the app-secrets admin API, the SDK service, and the apply email-secret
path. The 21 secrets unit tests (incl. the app/group AAD-disjointness and
cross-row swap proofs) stay green; the chain-walk was live-verified
against Postgres (nearest-wins + env-filter). Admin API for group secrets
and the masked-read endpoint land next (Step E).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:48:33 +02:00
MechaCat02
b588fc9d35 fix(config): same-level @E config suppresses * instead of merging it
§3 step 1 treats an environment scope as *eligibility*, not a merge tier:
within a single level a value scoped `@E` shadows the same level's `*`
fallback — it never layers on top of it. The resolver's map-run loop
collected every leading object row regardless of (depth, scope), so a
level holding both an `@E` map and a `*` map would deep-merge the two
(and report a bogus `*` layer in `merged_from`) rather than letting the
`@E` map win alone.

Dedup the sorted candidates by depth before the map run: each level is a
single owner (single-parent tree) with at most one `@E` and one `*` row
per key after env-filtering, and `@E` sorts first, so keeping the first
row per level yields the level's winner. Scalar/tombstone cases already
went through the boundary path and were unaffected; only same-level
map-vs-map mis-merged.

Adds two regression tests (same-level suppression; suppressed `*` stays
invisible to cross-level merge). Found in checkpoint review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:32:15 +02:00
MechaCat02
6bd0f4699d feat(secrets): owner-discriminated AAD (SecretOwner) for group secrets
The crypto foundation for group-owned secrets (the §5.3 'single hardest
correctness detail'), done first and proven before the storage/resolution
layer.

- SecretOwner{App(AppId)|Group(GroupId)}; secret_aad/seal/open take the
  owner. The app AAD is byte-identical to the pre-Phase-3
  'secret:{app_id}:{name}', so every existing v1 row decrypts unchanged;
  group secrets use a disjoint 'secret:group:{group_id}:{name}' namespace
  (the 'group:' infix separates app/group AAD even for equal UUIDs).
- All callers (SDK get/set, secrets_api, apply email-secret read) wrapped
  in SecretOwner::App — behavior identical for app secrets.
- New audit test aad_distinguishes_app_and_group_owner proves the two
  namespaces don't collide (both directions, even reusing the UUID); the
  existing cross-app/cross-name swap audit tests stay green.

16 secrets_service tests pass; clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:17:18 +02:00
MechaCat02
9ee85993d8 feat(vars): admin CRUD API + pic vars CLI
Completes the vars half of Phase 3 end-to-end:
- vars_repo: VarOwner{Group|App} upsert/delete/list (owner-kind-specific
  SQL; ON CONFLICT restates the partial-index predicate).
- vars_api: GET/PUT/DELETE under /apps/{id}/vars and /groups/{id}/vars,
  resolve-then-require gated on App/GroupVars{Read,Write}, secrets-style
  error mapping + key/env-scope validation.
- pic vars ls/set/rm (--group|--app, --env, --json, --tombstone).
- journey test: a group var is inherited by a descendant app's
  vars::get(), and an app-level value overrides it (proximity) — green.

386 manager-core lib tests + the vars journey pass; clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:11:46 +02:00
MechaCat02
343f6d3b4d feat(vars): VarsService + vars:: SDK + config capabilities
Scripts can now read group-inherited config via vars::get(key) / vars::all().

- shared: VarsService trait (read-only get/all) + NoopVarsService; added as
  the 14th field on the Services bundle.
- manager-core: VarsServiceImpl resolves the calling app's config via
  config_resolver (derives app_id from cx.app_id; AppVarsRead-gated for
  authed principals, script-as-gate for anon).
- executor-core: vars:: Rhai bridge (get/all), registered in the SDK.
- authz: AppVarsRead/Write, GroupVarsRead/Write, GroupSecretsRead/Write
  capabilities (group caps resolve via the Phase-2 ancestor walk; the
  GroupSecretsRead human-read gate is group_admin-only, distinct from an
  app's runtime injection).
- wired VarsServiceImpl into the picloud binary; updated the executor-core
  test Services::new call sites.

386 manager-core lib tests green; clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:59:23 +02:00
MechaCat02
35dbd9f368 feat(config): vars schema + the §3 resolution engine
Phase 3 foundation (docs §3): env-filtered, proximity-first config
inheritance down the group tree.

- Migration 0048: `vars` (polymorphic group|app owner via two nullable FKs
  + CHECK exactly-one, env scope, JSONB value, explicit tombstone) +
  `apps.environment` (the env marker the resolver filters on — 'an
  environment is an app').
- config_resolver: a shared chain-walk CTE (mirrors effective_app_role) that
  walks app → ancestor groups, env-filters in SQL, plus a pure Rust
  resolution pass implementing the §3 semantics SQL can't — per-key
  proximity-first, @E-over-* within a level, map deep-merge, tombstone
  suppression, and provenance for --explain.

Verified: 8 unit tests (incl. the §3.2 proximity-beats-env-specificity call,
deep-merge, tombstone, boundary) + the candidate-fetch CTE against live
Postgres (env-filter drops a non-matching @production value; nearer level
shadows farther).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:43:16 +02:00
MechaCat02
6db057fb08 Merge: groups Phase 2 — org/RBAC/UI container (migration 0047)
Adds the GitLab-style single-parent group tree above apps: migration
0047 (groups + group_members + apps.group_id NOT NULL/RESTRICT with
single-root backfill), tree repos with an ancestor-walk cycle guard
under a coarse structural advisory lock, slug-freeze and structure_version,
hierarchy-aware RBAC (effective role = MAX across app membership + every
ancestor group, resolved live so revocation is immediate), new
Group{Read,Write,Admin}/InstanceCreateGroup caps that bound API keys
cannot obtain, plus `pic groups` and a dashboard group tree/detail/members.

Verified before merge: fmt/clippy(-D warnings)/check clean; manager-core
378 unit tests pass (incl. hierarchy authz + group repos); dashboard
npm run check 0 errors; check-versioning OK (47 migrations sequential).
New CLI groups journeys pass against a live DB — cycle guard
(reparent-into-descendant rejected), delete=RESTRICT, and
inherited-group-admin deploy + live revoke. The 6 failing logs/scripts/roles
journeys are the same pre-existing format drift on main, not regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:26:21 +02:00
101 changed files with 14124 additions and 845 deletions

View File

@@ -8,7 +8,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Authoritative design: [serverless_cloud_blueprint.md](serverless_cloud_blueprint.md). The blueprint is a living document — when architecture decisions are made in conversation that contradict it, treat the latest decision as truth and update the blueprint.
**Current focus (Phase 4, v1.1.0):** SDK foundation + stdlib utilities — the shape every v1.1.x service module hangs off, see [docs/sdk-shape.md](docs/sdk-shape.md). Stdlib reference at [docs/stdlib-reference.md](docs/stdlib-reference.md). Subsequent v1.1.x releases (KV in v1.1.1, docs in v1.1.2, …) fill it in; see blueprint §12 for the full table. Phase 3 shipped end-to-end: admin auth, multi-app scoping, and Phase 3.5 capability gating (`manager-core::authz::{can, require, Capability}` + migration `0006_users_authz.sql`). Every v1.1+ table starts with `app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE` and every Rhai SDK call resolves its app from the execution context.
**v1.1.x — SDK foundation + services — is complete.** The SDK shape (handle pattern, `::` namespaces, `Services`/`SdkCallCx`; see [docs/sdk-shape.md](docs/sdk-shape.md), stdlib at [docs/stdlib-reference.md](docs/stdlib-reference.md)) fixed in v1.1.0, then KV, docs, modules, HTTP, cron, files, pub/sub, email, users, and durable queues + `invoke()` filled it in through **v1.1.9** blueprint §12 has the table. Earlier groundwork: blueprint Phase 3 (admin auth, multi-app scoping, Phase 3.5 capability gating `manager-core::authz::{can, require, Capability}`, migration `0006_users_authz.sql`).
**Current focus: v1.2 _Hierarchies_ — groups + the declarative project tool** ([docs/design/groups-and-project-tool.md](docs/design/groups-and-project-tool.md)). That doc's §11 uses its own **Phase 16 numbering, distinct from the blueprint product-phase numbering above — do not conflate them** (its "Phase 3" = group-inherited config, not admin auth). Implemented on `feat/groups-*` branches: §11 Phase 1 (declarative `pic plan`/`apply`/`prune` + env overlays), Phase 2 (single-parent groups tree + hierarchy-aware RBAC), Phase 3 (group-inherited, env-scoped `vars` + secrets resolved **live** via a recursive CTE — no materialized cache), Phase 4-lite (group-owned **endpoint** scripts: `scripts` polymorphic owner in `0050_group_scripts.sql`, `get_by_name_inherited`/`is_invocable_by_app` chain resolution, inherited `invoke()` + declarative route/trigger binding — all **live**, no body materialization), Phase 5 (the **declarative project tool maps onto the group tree**: the reconcile engine generalized to `ApplyOwner{App|Group}`, a `[group]` manifest kind, and a single atomic **tree apply**`pic plan/apply --dir` reconciles a whole directory tree of `picloud.toml` nodes in one Postgres transaction, groups-before-apps so an app route can bind a group script created in the same tx; the bound token folds in each group's `structure_version`. Multi-repo single-owner/attach-point and per-env approval gating are deferred; groups pre-exist), Phase 4b (group **modules** + the **lexical (sealed-by-default) import resolver**, §5.5: owner-polymorphic `ModuleScript`, origin-rooted `ModuleSource::resolve` walking the importing node's chain, `ExecRequest.script_owner` threaded from every dispatch + `invoke()` site, `_source`-driven lexical chaining in `PicloudModuleResolver` with the compiled-module cache re-keyed by `ScriptId`, group modules/imports allowed, single-node dangling-import `plan` check — an inherited group script's imports **seal to the group**, a leaf can't shadow them). Next: §5.5 **opt-in extension points** (the one deferred §5.5 piece) or §11.6 (group-level collections, v1.3).
**Data-model invariant:** app-owned data-plane tables (KV, docs, files, …) start with `app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE`; the group-inheritable tables — _config_ (`vars`, `secrets`) and now group-owned _code_ (`scripts`, `0050`) — instead carry a **polymorphic owner**: nullable `group_id` and `app_id` with an exactly-one CHECK and per-owner partial-unique indexes (config is `ON DELETE CASCADE`, scripts `RESTRICT` — code is not data). Inheritance resolves **live** down `apps.group_id → groups.parent_id` via `CHAIN_LEVELS_CTE` (no materialized view); nearest-owner-wins with an app's own row shadowing the inherited one (CoW). Every Rhai SDK call resolves its app from `cx.app_id`, never a script-passed arg, and a group script always runs under the *inheriting* app's `cx.app_id` (the cross-app isolation boundary).
## Three-Service Architecture

View File

@@ -300,11 +300,18 @@ impl Engine {
// installed with the real per-call resolver. The resolver owns
// `cx.clone()` so cross-app isolation derives from this exact
// call's context, not from any script-passed argument.
// The lexical origin for `import` resolution (§5.5): the top-level
// script's defining node. Falls back to the executing app when the
// request predates Phase 4b (old payloads / cluster wire).
let default_origin = req
.script_owner
.unwrap_or(picloud_shared::ScriptOwner::App(req.app_id));
let resolver = PicloudModuleResolver::new(
self.services.modules.clone(),
cx.clone(),
self.module_cache.clone(),
effective_limits.module_import_depth_max,
default_origin,
);
engine.set_module_resolver(resolver);
let self_engine = self.self_arc();

View File

@@ -1,32 +1,50 @@
//! `PicloudModuleResolver` — the v1.1.3 per-app Rhai module resolver.
//! `PicloudModuleResolver` — the Rhai module resolver (v1.1.3; made
//! origin-aware / lexical in v1.2 Phase 4b).
//!
//! Replaces `DummyModuleResolver` in `Engine::build_engine`. Constructed
//! fresh per `Engine::execute` call: holds an `Arc<SdkCallCx>` so every
//! `import "<name>"` request resolves against the calling app
//! (`cx.app_id`). The script-side `name` argument carries no `app_id`
//! — that's the load-bearing cross-app isolation property.
//! fresh per `Engine::execute` call.
//!
//! **Lexical resolution (§5.5).** An `import "<name>"` resolves against the
//! module set visible at the **importing script's own defining node**,
//! walking up its group chain — not the inheriting app's effective view.
//! The importing node is read from Rhai's `_source` (the importing AST's
//! source tag, which the resolver sets to each module's owner) and falls
//! back to `default_origin` (the entry script's defining node) for the
//! top-level AST. So an inherited group script's imports seal to the group
//! (a leaf can't shadow them — the trust boundary), while every SDK call
//! *inside* a module still scopes to the inheriting app via `cx.app_id`.
//!
//! **Extension points (§5.5).** A node may opt a module name OUT of sealing
//! by declaring it an extension point: an importer on that node's chain then
//! resolves the name against the *inheriting app's* effective view
//! (`App(cx.app_id)`), with the declared default body as fallback — so each
//! descendant app supplies its own impl. The "is this an extension point"
//! check keys on the importer's `origin` (trusted), so only the declaring
//! parent can open the inversion; a descendant can't hijack a sealed import.
//!
//! Three runtime invariants are enforced:
//!
//! 1. **Cross-app isolation** — `ModuleSource::lookup` is called with
//! `&cx`; the Postgres impl scopes by `cx.app_id` (never by a
//! script-passed argument).
//! 1. **Lexical isolation** — `ModuleSource::resolve` is keyed by the
//! importing node's owner (a trusted dispatch-derived value), never a
//! script-passed argument.
//! 2. **Cycle detection** — an in-progress-imports stack rejects
//! `A → B → A` with `ErrorInModule(... circular import detected ...)`.
//! 3. **Depth limit** — guards against deep but acyclic chains
//! (default 8, override via `PICLOUD_MODULE_IMPORT_DEPTH_MAX`).
//!
//! Compiled modules are cached per `(app_id, name)` and invalidated by
//! `updated_at` change — no explicit pub/sub. The cache is owned by
//! `Engine` and shared across calls; only the resolver state (stack,
//! depth) is per-call.
//! Compiled modules are cached by resolved `ScriptId` (a compiled module
//! is lexically self-contained) and invalidated by `updated_at` change —
//! no explicit pub/sub. The cache is owned by `Engine` and shared across
//! calls; only the resolver state (stack, depth) is per-call.
use std::num::NonZeroUsize;
use std::sync::{Arc, Mutex};
use chrono::{DateTime, Utc};
use lru::LruCache;
use picloud_shared::{AppId, ModuleSource, ModuleSourceError, SdkCallCx, ValidatedScript};
use picloud_shared::{
ModuleSource, ModuleSourceError, ScriptId, ScriptOwner, SdkCallCx, ValidatedScript,
};
use rhai::module_resolvers::ModuleResolver;
use rhai::{Engine as RhaiEngine, EvalAltResult, Module, Position, Shared, AST};
@@ -35,10 +53,38 @@ use rhai::{Engine as RhaiEngine, EvalAltResult, Module, Position, Shared, AST};
/// `sync` feature that the workspace pins.
type SharedRhaiModule = Shared<Module>;
/// Cache key: `(app_id, module name)`. v1.1.3 enforces module names as
/// a conservative identifier shape (migration 0015 `scripts_module_name_shape`
/// CHECK) so the `String` here is bounded by ~64 bytes.
pub type ModuleCacheKey = (AppId, String);
/// Cache key: the resolved module's `ScriptId` (Phase 4b). A compiled
/// module is lexically self-contained (its nested imports resolve from
/// its own defining node, baked in at compile time), so the *body* is a
/// pure function of `(script_id, updated_at)` — independent of which
/// script imported it. Keying by id also dedupes a shared group module
/// across every inheriting app, and keeps cross-app modules of the same
/// name distinct (distinct ids).
pub type ModuleCacheKey = ScriptId;
/// Encode a defining node as an AST `source` tag (`app:<uuid>` /
/// `group:<uuid>`). Set on a resolved module's AST so its own nested
/// `import`s carry *its* node as Rhai's `_source` — the lexical chaining
/// that seals each module's imports to where it was authored (§5.5).
fn encode_origin(owner: ScriptOwner) -> String {
match owner {
ScriptOwner::App(a) => format!("app:{a}"),
ScriptOwner::Group(g) => format!("group:{g}"),
}
}
/// Inverse of [`encode_origin`]. `None` for an unrecognised tag (e.g. a
/// source set by something other than this resolver) — the caller then
/// falls back to the resolver's `default_origin`.
fn decode_origin(s: &str) -> Option<ScriptOwner> {
let (kind, id) = s.split_once(':')?;
let uuid = uuid::Uuid::parse_str(id).ok()?;
match kind {
"app" => Some(ScriptOwner::App(uuid.into())),
"group" => Some(ScriptOwner::Group(uuid.into())),
_ => None,
}
}
/// Cache value: the freshness comparator + the compiled module Rhai
/// hands to importing scripts. Cloning the `Shared<Module>` is an Arc bump.
@@ -65,17 +111,19 @@ pub fn new_module_cache(capacity: usize) -> Arc<ModuleCache> {
/// The v1.1.3 module resolver. One per `Engine::execute` call.
pub struct PicloudModuleResolver {
/// Backend the resolver consults for `(app_id, name)`. The bridge
/// runs Rhai's sync `resolve()` and the async `lookup()` together
/// via `tokio::runtime::Handle::block_on(...)` — safe because
/// Backend the resolver consults to resolve a module name against an
/// origin node's chain. The bridge runs Rhai's sync `resolve()` and the
/// async `ModuleSource::resolve()` together via
/// `tokio::runtime::Handle::block_on(...)` — safe because
/// `LocalExecutorClient` runs `Engine::execute` inside
/// `spawn_blocking`, which puts us on a Tokio blocking thread
/// that still carries a `Handle`.
source: Arc<dyn ModuleSource>,
/// Calling context. `cx.app_id` is the cross-app isolation
/// boundary; the resolver passes `&cx` to every `ModuleSource`
/// call so the backend can scope its queries.
/// Calling context. `cx.app_id` is the execution boundary every SDK
/// call inside a module scopes to; the resolver keeps it for diagnostic
/// logging. (Module *resolution* is keyed by the importing node's owner,
/// not `cx.app_id` — see the module docs.)
cx: Arc<SdkCallCx>,
/// Compiled-module cache. Shared across executions; invalidated
@@ -96,6 +144,12 @@ pub struct PicloudModuleResolver {
/// via `PICLOUD_MODULE_IMPORT_DEPTH_MAX`. Read from `Limits` at
/// resolver construction.
depth_limit: u32,
/// Lexical origin for a top-level `import` — the defining node of the
/// script being executed (Phase 4b, §5.5). Used when Rhai gives no
/// `_source` (the entry AST). Nested imports inside a resolved module
/// override this via the module AST's source (C3).
default_origin: ScriptOwner,
}
impl PicloudModuleResolver {
@@ -105,6 +159,7 @@ impl PicloudModuleResolver {
cx: Arc<SdkCallCx>,
cache: Arc<ModuleCache>,
depth_limit: u32,
default_origin: ScriptOwner,
) -> Self {
Self {
source,
@@ -113,6 +168,7 @@ impl PicloudModuleResolver {
in_progress: Mutex::new(Vec::new()),
depth: Mutex::new(0),
depth_limit,
default_origin,
}
}
@@ -224,7 +280,7 @@ impl ModuleResolver for PicloudModuleResolver {
fn resolve(
&self,
engine: &RhaiEngine,
_source: Option<&str>,
importer_source: Option<&str>,
path: &str,
pos: Position,
) -> Result<SharedRhaiModule, Box<EvalAltResult>> {
@@ -319,8 +375,54 @@ impl ModuleResolver for PicloudModuleResolver {
))
})?;
// Lexical resolution (§5.5): resolve `path` against the *importing
// node's* chain. Rhai gives `_source` = the importing AST's source
// tag — set to the module's defining node for a nested import, or
// unset (`None`) for the top-level entry script, where we fall back
// to `default_origin` (the executing script's node). An app-rooted
// walk reaches ancestor group modules; a group-rooted walk is sealed
// from app modules below it (the trust boundary).
let origin = importer_source
.and_then(decode_origin)
.unwrap_or(self.default_origin);
// Extension-point inversion (§5.5): if `path`'s nearest declaration on
// the importer's chain is an extension point (not a concrete module),
// resolve it against the INHERITING app's effective view instead of the
// sealed lexical chain — each descendant app supplies its own impl,
// falling back to the declared default body. The decision keys on
// `origin` (the trusted importer node), so only a declaration on the
// importer's own ancestry can open this; a descendant can never make a
// parent's sealed `import` dynamic, and a non-ext-point import never
// consults `App(cx.app_id)`. All branches funnel any backend error into
// the single redaction arm below.
let lookup_result: Result<Option<picloud_shared::ModuleScript>, ModuleSourceError> =
tokio::task::block_in_place(|| handle.block_on(self.source.lookup(&self.cx, path)));
(|| {
let ep = tokio::task::block_in_place(|| {
handle.block_on(self.source.resolve_extension_point(origin, path))
})?;
let Some(ep) = ep else {
// Sealed lexical resolution (the default, unchanged).
return tokio::task::block_in_place(|| {
handle.block_on(self.source.resolve(origin, path))
});
};
let app_origin = ScriptOwner::App(self.cx.app_id);
if let Some(m) = tokio::task::block_in_place(|| {
handle.block_on(self.source.resolve(app_origin, path))
})? {
return Ok(Some(m));
}
match ep.default_script_id {
// The default body is a module declared at the node that
// opened the ext point, i.e. on the importer's (`origin`)
// chain — resolve it there, not against the app.
Some(id) => tokio::task::block_in_place(|| {
handle.block_on(self.source.resolve_by_id(origin, id))
}),
None => Ok(None),
}
})();
let module_row = match lookup_result {
Ok(Some(m)) => m,
@@ -355,8 +457,10 @@ impl ModuleResolver for PicloudModuleResolver {
};
// Cache lookup: hit only if both key matches AND updated_at
// matches (cache is invalidated lazily on version change).
let cache_key = (self.cx.app_id, path.to_string());
// matches (cache is invalidated lazily on version change). Keyed by
// the resolved module's id (lexically self-contained — see
// `ModuleCacheKey`).
let cache_key = module_row.script_id;
{
let mut cache = self.cache.lock().expect("module cache lock poisoned");
if let Some(cached) = cache.get(&cache_key) {
@@ -389,7 +493,7 @@ impl ModuleResolver for PicloudModuleResolver {
// already been gated at create-time (admin endpoint runs
// `validate_module`), but we revalidate here to catch DB-direct
// inserts that bypass the API surface.
let ast = engine.compile(&module_row.source).map_err(|e| {
let mut ast = engine.compile(&module_row.source).map_err(|e| {
// Wrap as an ErrorRuntime to preserve the parse message
// text without trying to reconstruct rhai's internal
// ParseErrorType variant (which would require matching on
@@ -404,6 +508,15 @@ impl ModuleResolver for PicloudModuleResolver {
))
})?;
// Seal this module's own imports to its defining node (§5.5): tag
// the AST source with the resolved module's owner so that when
// `eval_ast_as_new` evaluates its nested `import`s, Rhai hands us
// that tag as `_source` and we resolve them lexically from *here*,
// not from the original importer. Fall back to the lookup origin
// for a corrupt owner row (never reached under the DB CHECK).
let module_owner = module_row.owner().unwrap_or(origin);
ast.set_source(encode_origin(module_owner));
if let Err(msg) = Self::check_module_shape(&ast, path) {
return Err(Box::new(EvalAltResult::ErrorInModule(
path.to_string(),

View File

@@ -179,6 +179,10 @@ fn invoke_blocking(
rest: String::new(),
sandbox_overrides: picloud_shared::ScriptSandbox::default(),
app_id: cx.app_id,
// Lexical origin (§5.5): the callee's defining node — a group
// script's imports resolve from the group even when invoked by an
// app. `None` falls back to `App(cx.app_id)` in the engine.
script_owner: resolved.owner,
// Same-app invoke is a function call, not a re-auth boundary —
// inherit the caller's principal.
principal: cx.principal.clone(),

View File

@@ -26,6 +26,7 @@ pub mod retry;
pub mod secrets;
pub mod stdlib;
pub mod users;
pub mod vars;
pub use bridge::{dynamic_to_json, json_to_dynamic};
pub use cx::SdkCallCx;
@@ -62,6 +63,7 @@ pub fn register_all(
queue::register(engine, services, cx.clone());
retry::register(engine, services, cx.clone());
secrets::register(engine, services, cx.clone());
vars::register(engine, services, cx.clone());
email::register(engine, services, cx.clone());
users::register(engine, services, cx.clone());
invoke::register(engine, services, cx, limits, self_engine);

View File

@@ -0,0 +1,57 @@
//! `vars::` Rhai bridge — read-only access to the app's resolved,
//! group-inherited config (Phase 3).
//!
//! ```rhai
//! let region = vars::get("region"); // value or ()
//! let all = vars::all(); // #{ key: value, ... }
//! ```
//!
//! Values are inherited down the group tree and env-filtered (§3); the
//! resolution happens server-side in manager-core. Writes go through the
//! admin API, not the SDK. `app_id` is derived from `cx.app_id` in the
//! service — never a script argument — preserving cross-app isolation.
use std::sync::Arc;
use picloud_shared::{SdkCallCx, Services};
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
use super::bridge::{block_on, json_to_dynamic};
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
let svc = services.vars.clone();
let mut module = Module::new();
// vars::get(key) — resolved value, or () if no level defines it.
{
let svc = svc.clone();
let cx = cx.clone();
module.set_native_fn(
"get",
move |key: &str| -> Result<Dynamic, Box<EvalAltResult>> {
let svc = svc.clone();
let cx = cx.clone();
let opt = block_on("vars", async move { svc.get(&cx, key).await })?;
Ok(opt.map_or(Dynamic::UNIT, json_to_dynamic))
},
);
}
// vars::all() — the fully-resolved config map.
{
let svc = svc.clone();
let cx = cx.clone();
module.set_native_fn("all", move || -> Result<Map, Box<EvalAltResult>> {
let svc = svc.clone();
let cx = cx.clone();
let resolved = block_on("vars", async move { svc.all(&cx).await })?;
let mut m = Map::new();
for (k, v) in resolved {
m.insert(k.into(), json_to_dynamic(v));
}
Ok(m)
});
}
engine.register_static_module("vars", module.into());
}

View File

@@ -3,7 +3,7 @@ use std::collections::BTreeMap;
use chrono::{DateTime, Utc};
use picloud_shared::{
AppId, ExecutionId, ExecutionLog, ExecutionSource, ExecutionStatus, Principal, RequestId,
ScriptId, ScriptSandbox, TriggerEvent,
ScriptId, ScriptOwner, ScriptSandbox, TriggerEvent,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
@@ -69,6 +69,16 @@ pub struct ExecRequest {
/// Internal-only; not surfaced via `ctx` (which the script sees).
pub app_id: AppId,
/// The **defining node** of the top-level script being executed —
/// the resolved `Script`'s owner (Phase 4b). This is the lexical
/// origin its `import` statements resolve against (§5.5): an inherited
/// group endpoint's imports seal to the **group**, not the inheriting
/// app, so a leaf can't shadow them. Distinct from `app_id`, which is
/// always the execution boundary (where SDK calls scope). `None` (old
/// payloads / cluster wire) falls back to `App(app_id)` in the engine.
#[serde(default)]
pub script_owner: Option<ScriptOwner>,
/// Caller identity, when authenticated. `None` for unauthenticated
/// data-plane HTTP requests (the common case for public scripts);
/// `Some` when a bearer token or session cookie was resolved.

View File

@@ -23,6 +23,7 @@ fn req(body: serde_json::Value) -> ExecRequest {
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id: AppId::new(),
script_owner: None,
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,

View File

@@ -16,7 +16,7 @@ use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
use picloud_shared::{
AppId, ExecutionId, ModuleScript, ModuleSource, ModuleSourceError, NoopDeadLetterService,
NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService, RequestId, ScriptId,
ScriptSandbox, SdkCallCx, Services,
ScriptOwner, ScriptSandbox, Services,
};
use serde_json::Value;
use tracing_subscriber::fmt::MakeWriter;
@@ -27,13 +27,29 @@ struct FailingSource;
#[async_trait]
impl ModuleSource for FailingSource {
async fn lookup(
async fn resolve(
&self,
_cx: &SdkCallCx,
_origin: ScriptOwner,
_name: &str,
) -> Result<Option<ModuleScript>, ModuleSourceError> {
Err(ModuleSourceError::Backend(SENTINEL.to_string()))
}
async fn resolve_extension_point(
&self,
_origin: ScriptOwner,
_name: &str,
) -> Result<Option<picloud_shared::ExtPointResolution>, ModuleSourceError> {
Err(ModuleSourceError::Backend(SENTINEL.to_string()))
}
async fn resolve_by_id(
&self,
_origin: ScriptOwner,
_script_id: picloud_shared::ScriptId,
) -> Result<Option<ModuleScript>, ModuleSourceError> {
Err(ModuleSourceError::Backend(SENTINEL.to_string()))
}
}
/// `MakeWriter` that appends to a shared buffer.
@@ -74,6 +90,7 @@ fn req(app_id: AppId) -> ExecRequest {
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id,
script_owner: None,
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,
@@ -107,6 +124,7 @@ async fn original_backend_error_is_logged_at_error_level() {
Arc::new(picloud_shared::NoopUsersService),
Arc::new(picloud_shared::NoopQueueService),
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
);
let engine = Engine::new(Limits::default(), services);

View File

@@ -16,9 +16,9 @@ use async_trait::async_trait;
use chrono::{DateTime, Utc};
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
use picloud_shared::{
AppId, ExecutionId, ModuleScript, ModuleSource, ModuleSourceError, NoopDeadLetterService,
NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService, RequestId, ScriptId,
ScriptSandbox, SdkCallCx, Services,
AppId, ExecutionId, GroupId, ModuleScript, ModuleSource, ModuleSourceError,
NoopDeadLetterService, NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService,
RequestId, ScriptId, ScriptOwner, ScriptSandbox, Services,
};
use tokio::sync::Mutex;
@@ -55,7 +55,8 @@ impl CountingModuleSource {
(app_id, name.to_string()),
ModuleScript {
script_id,
app_id,
app_id: Some(app_id),
group_id: None,
name: name.to_string(),
source: source.to_string(),
updated_at,
@@ -71,20 +72,51 @@ impl CountingModuleSource {
#[async_trait]
impl ModuleSource for CountingModuleSource {
async fn lookup(
async fn resolve(
&self,
cx: &SdkCallCx,
origin: ScriptOwner,
name: &str,
) -> Result<Option<ModuleScript>, ModuleSourceError> {
self.lookups.fetch_add(1, Ordering::SeqCst);
if let Some(err) = self.fail_with.lock().await.as_ref() {
return Err(ModuleSourceError::Backend(err.clone()));
}
// This fake is flat/app-scoped — the inheritance + lexical
// resolution semantics are covered by the CLI journey tests
// against real Postgres. A group origin has no entries here.
let app_id = match origin {
ScriptOwner::App(a) => a,
ScriptOwner::Group(_) => return Ok(None),
};
Ok(self
.table
.lock()
.await
.get(&(cx.app_id, name.to_string()))
.get(&(app_id, name.to_string()))
.cloned())
}
async fn resolve_extension_point(
&self,
_origin: ScriptOwner,
_name: &str,
) -> Result<Option<picloud_shared::ExtPointResolution>, ModuleSourceError> {
// This flat fake has no extension points; the inversion is covered by
// `LexicalModuleSource` below and the Postgres journey tests.
Ok(None)
}
async fn resolve_by_id(
&self,
_origin: ScriptOwner,
script_id: ScriptId,
) -> Result<Option<ModuleScript>, ModuleSourceError> {
Ok(self
.table
.lock()
.await
.values()
.find(|m| m.script_id == script_id)
.cloned())
}
}
@@ -104,6 +136,7 @@ fn services_with(modules: Arc<dyn ModuleSource>) -> Services {
Arc::new(picloud_shared::NoopUsersService),
Arc::new(picloud_shared::NoopQueueService),
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
)
}
@@ -128,6 +161,7 @@ fn req(app_id: AppId) -> ExecRequest {
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id,
script_owner: None,
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,
@@ -599,3 +633,350 @@ fn validate_endpoint_skips_dynamic_imports_in_imports_list() {
v.imports
);
}
// ---------------------------------------------------------------------------
// Phase 4b — lexical (origin-aware) resolution.
//
// `LexicalModuleSource` keys modules by their *exact* defining node, with no
// chain walk. That's enough to prove the resolver passes the RIGHT origin:
// `default_origin` for the entry AST, and each module's own owner (decoded
// from Rhai's `_source`) for its nested imports. The chain-walk + group-tree
// semantics are covered end-to-end by the CLI journey tests against Postgres.
// ---------------------------------------------------------------------------
#[derive(Default)]
struct LexicalModuleSource {
table: Mutex<HashMap<(ScriptOwner, String), ModuleScript>>,
/// Extension-point declarations, keyed by exact declaring owner →
/// optional default module id. Flat (exact-owner) — the chain-walk +
/// shadowing tie-break is covered by the Postgres journey tests.
ext_points: Mutex<HashMap<(ScriptOwner, String), Option<ScriptId>>>,
}
impl LexicalModuleSource {
fn new() -> Arc<Self> {
Arc::new(Self::default())
}
async fn put(self: &Arc<Self>, owner: ScriptOwner, name: &str, source: &str) -> ScriptId {
let (app_id, group_id) = match owner {
ScriptOwner::App(a) => (Some(a), None),
ScriptOwner::Group(g) => (None, Some(g)),
};
let script_id = ScriptId::new();
self.table.lock().await.insert(
(owner, name.to_string()),
ModuleScript {
script_id,
app_id,
group_id,
name: name.to_string(),
source: source.to_string(),
updated_at: Utc::now(),
},
);
script_id
}
/// Declare `name` an extension point at `owner`, with an optional default
/// module body id.
async fn put_ext_point(
self: &Arc<Self>,
owner: ScriptOwner,
name: &str,
default_script_id: Option<ScriptId>,
) {
self.ext_points
.lock()
.await
.insert((owner, name.to_string()), default_script_id);
}
}
#[async_trait]
impl ModuleSource for LexicalModuleSource {
async fn resolve(
&self,
origin: ScriptOwner,
name: &str,
) -> Result<Option<ModuleScript>, ModuleSourceError> {
Ok(self
.table
.lock()
.await
.get(&(origin, name.to_string()))
.cloned())
}
async fn resolve_extension_point(
&self,
origin: ScriptOwner,
name: &str,
) -> Result<Option<picloud_shared::ExtPointResolution>, ModuleSourceError> {
Ok(self
.ext_points
.lock()
.await
.get(&(origin, name.to_string()))
.map(|default_script_id| picloud_shared::ExtPointResolution {
default_script_id: *default_script_id,
}))
}
async fn resolve_by_id(
&self,
_origin: ScriptOwner,
script_id: ScriptId,
) -> Result<Option<ModuleScript>, ModuleSourceError> {
Ok(self
.table
.lock()
.await
.values()
.find(|m| m.script_id == script_id)
.cloned())
}
}
fn req_with_owner(app_id: AppId, owner: ScriptOwner) -> ExecRequest {
let mut r = req(app_id);
r.script_owner = Some(owner);
r
}
/// A group module's `import` resolves from the GROUP (its own defining
/// node), not the inheriting app — even when an app-owned module of the
/// same name exists. This is the §5.5 trust boundary: a leaf can't shadow
/// a module an inherited group script depends on.
#[tokio::test(flavor = "multi_thread")]
async fn lexical_nested_import_seals_to_module_owner() {
let source = LexicalModuleSource::new();
let app = AppId::new();
let group = GroupId::new();
// Two "inner" modules: the group's (real) and the app's (a trap).
source
.put(ScriptOwner::Group(group), "inner", "fn v() { 42 }")
.await;
source
.put(ScriptOwner::App(app), "inner", "fn v() { 999 }")
.await;
// The group's "outer" imports "inner" — must bind the group's inner.
source
.put(
ScriptOwner::Group(group),
"outer",
r#"import "inner" as i; fn val() { i::v() }"#,
)
.await;
let engine = engine_with(source.clone());
// Entry script runs as the inherited GROUP script (default_origin = group).
let resp = engine
.execute(
r#"import "outer" as o; o::val()"#,
req_with_owner(app, ScriptOwner::Group(group)),
)
.expect("should execute");
assert_eq!(
resp.body,
serde_json::json!(42),
"group module's import must seal to the group's `inner`, not the app's trap"
);
}
/// The same registry, entered as an APP-owned script, reaches the app's
/// module — proving the fake distinguishes origins and the entry uses
/// `default_origin`.
#[tokio::test(flavor = "multi_thread")]
async fn lexical_entry_origin_selects_app_module() {
let source = LexicalModuleSource::new();
let app = AppId::new();
let group = GroupId::new();
source
.put(ScriptOwner::Group(group), "inner", "fn v() { 42 }")
.await;
source
.put(ScriptOwner::App(app), "inner", "fn v() { 999 }")
.await;
let engine = engine_with(source.clone());
let resp = engine
.execute(
r#"import "inner" as i; i::v()"#,
req_with_owner(app, ScriptOwner::App(app)),
)
.expect("should execute");
assert_eq!(resp.body, serde_json::json!(999));
}
// ---------------------------------------------------------------------------
// Extension points (§5.5) — the opt-in resolution inversion. A group declares
// a module name an extension point; an importer on the group's chain resolves
// it against the INHERITING APP, not the sealed lexical chain.
// ---------------------------------------------------------------------------
/// A group script importing a declared extension point resolves it against the
/// executing app's own module — each tenant supplies its own body.
#[tokio::test(flavor = "multi_thread")]
async fn ext_point_resolves_to_app_module() {
let source = LexicalModuleSource::new();
let app = AppId::new();
let group = GroupId::new();
// The group opens "theme" as an extension point (no default).
source
.put_ext_point(ScriptOwner::Group(group), "theme", None)
.await;
// The app provides its own "theme".
source
.put(
ScriptOwner::App(app),
"theme",
r#"fn name() { "app-theme" }"#,
)
.await;
let engine = engine_with(source.clone());
// Entry runs as the inherited GROUP script (default_origin = group), but
// the executing app is `app` (cx.app_id).
let resp = engine
.execute(
r#"import "theme" as t; t::name()"#,
req_with_owner(app, ScriptOwner::Group(group)),
)
.expect("should execute");
assert_eq!(
resp.body,
serde_json::json!("app-theme"),
"extension point must resolve to the inheriting app's module"
);
}
/// When the app provides no module for the extension point, the declared
/// default body is used.
#[tokio::test(flavor = "multi_thread")]
async fn ext_point_falls_back_to_default() {
let source = LexicalModuleSource::new();
let app = AppId::new();
let group = GroupId::new();
// The group's default "theme" body, registered as a group module.
let default_id = source
.put(
ScriptOwner::Group(group),
"default-theme",
r#"fn name() { "default-theme" }"#,
)
.await;
source
.put_ext_point(ScriptOwner::Group(group), "theme", Some(default_id))
.await;
// The app provides NO "theme".
let engine = engine_with(source.clone());
let resp = engine
.execute(
r#"import "theme" as t; t::name()"#,
req_with_owner(app, ScriptOwner::Group(group)),
)
.expect("should execute");
assert_eq!(resp.body, serde_json::json!("default-theme"));
}
/// An extension point with neither an app provider nor a default is a clean
/// module-not-found at import time.
#[tokio::test(flavor = "multi_thread")]
async fn ext_point_no_provider_no_default_errors() {
let source = LexicalModuleSource::new();
let app = AppId::new();
let group = GroupId::new();
source
.put_ext_point(ScriptOwner::Group(group), "theme", None)
.await;
let engine = engine_with(source.clone());
let err = engine
.execute(
r#"import "theme" as t; t::name()"#,
req_with_owner(app, ScriptOwner::Group(group)),
)
.expect_err("ext point with no provider/default should fail");
let msg = format!("{err:?}").to_lowercase();
assert!(
msg.contains("module") || msg.contains("not found") || msg.contains("theme"),
"expected module-not-found-flavoured error, got {msg}"
);
}
/// A NORMAL (non-extension-point) group import is NOT hijacked by a leaf app's
/// module of the same name — the inversion only fires for declared extension
/// points, so the trust boundary holds.
#[tokio::test(flavor = "multi_thread")]
async fn sealed_import_not_hijacked_by_leaf_when_not_ext_point() {
let source = LexicalModuleSource::new();
let app = AppId::new();
let group = GroupId::new();
// "auth" is a concrete group module — NOT an extension point.
source
.put(
ScriptOwner::Group(group),
"auth",
r#"fn who() { "group-auth" }"#,
)
.await;
// The app has a trap "auth" that must NOT be selected.
source
.put(ScriptOwner::App(app), "auth", r#"fn who() { "leaf-trap" }"#)
.await;
let engine = engine_with(source.clone());
let resp = engine
.execute(
r#"import "auth" as a; a::who()"#,
req_with_owner(app, ScriptOwner::Group(group)),
)
.expect("should execute");
assert_eq!(
resp.body,
serde_json::json!("group-auth"),
"a non-ext-point import must seal to the group, never the leaf's trap"
);
}
/// Two apps inheriting the same extension point get their OWN bodies — the
/// id-keyed module cache does not bleed one tenant's body into another.
#[tokio::test(flavor = "multi_thread")]
async fn ext_point_two_apps_get_distinct_bodies() {
let source = LexicalModuleSource::new();
let app1 = AppId::new();
let app2 = AppId::new();
let group = GroupId::new();
source
.put_ext_point(ScriptOwner::Group(group), "theme", None)
.await;
source
.put(ScriptOwner::App(app1), "theme", r#"fn name() { "one" }"#)
.await;
source
.put(ScriptOwner::App(app2), "theme", r#"fn name() { "two" }"#)
.await;
let engine = engine_with(source.clone());
let r1 = engine
.execute(
r#"import "theme" as t; t::name()"#,
req_with_owner(app1, ScriptOwner::Group(group)),
)
.expect("app1 executes");
let r2 = engine
.execute(
r#"import "theme" as t; t::name()"#,
req_with_owner(app2, ScriptOwner::Group(group)),
)
.expect("app2 executes");
assert_eq!(r1.body, serde_json::json!("one"));
assert_eq!(
r2.body,
serde_json::json!("two"),
"no cross-tenant cache bleed"
);
}

View File

@@ -51,6 +51,7 @@ fn baseline_request() -> ExecRequest {
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id: AppId::new(),
script_owner: None,
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,

View File

@@ -235,6 +235,7 @@ fn make_engine() -> Arc<Engine> {
Arc::new(picloud_shared::NoopUsersService),
Arc::new(picloud_shared::NoopQueueService),
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
);
Arc::new(Engine::new(Limits::default(), services))
}
@@ -256,6 +257,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id,
script_owner: None,
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,

View File

@@ -44,6 +44,7 @@ fn engine_with(rec: Arc<RecordingEmail>) -> Arc<Engine> {
Arc::new(picloud_shared::NoopUsersService),
Arc::new(picloud_shared::NoopQueueService),
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
);
Arc::new(Engine::new(Limits::default(), services))
}
@@ -65,6 +66,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id,
script_owner: None,
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,

View File

@@ -172,6 +172,7 @@ fn make_engine() -> Arc<Engine> {
Arc::new(picloud_shared::NoopUsersService),
Arc::new(picloud_shared::NoopQueueService),
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
);
Arc::new(Engine::new(Limits::default(), services))
}
@@ -193,6 +194,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id,
script_owner: None,
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,

View File

@@ -95,6 +95,7 @@ fn engine_with(http: Arc<dyn HttpService>) -> Arc<Engine> {
Arc::new(picloud_shared::NoopUsersService),
Arc::new(picloud_shared::NoopQueueService),
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
);
Arc::new(Engine::new(Limits::default(), services))
}
@@ -116,6 +117,7 @@ fn baseline_request(app_id: AppId, script_id: ScriptId) -> ExecRequest {
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id,
script_owner: None,
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,

View File

@@ -58,6 +58,7 @@ impl InvokeService for FakeInvokeService {
Ok(ResolvedScript {
script_id: id,
app_id: app,
owner: None,
source,
updated_at: Utc::now(),
name,
@@ -90,6 +91,7 @@ fn build_engine(svc: Arc<FakeInvokeService>) -> Arc<Engine> {
Arc::new(NoopUsersService),
Arc::new(NoopQueueService),
svc,
Arc::new(picloud_shared::NoopVarsService),
);
let engine = Arc::new(Engine::new(Limits::default(), services));
engine.set_self_weak(Arc::downgrade(&engine));
@@ -113,6 +115,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id,
script_owner: None,
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,

View File

@@ -114,6 +114,7 @@ fn make_engine() -> Arc<Engine> {
Arc::new(picloud_shared::NoopUsersService),
Arc::new(picloud_shared::NoopQueueService),
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
);
Arc::new(Engine::new(Limits::default(), services))
}
@@ -135,6 +136,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id,
script_owner: None,
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,

View File

@@ -52,6 +52,7 @@ fn make_engine(svc: Arc<RecordingPubsub>) -> Arc<Engine> {
Arc::new(picloud_shared::NoopUsersService),
Arc::new(picloud_shared::NoopQueueService),
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
);
Arc::new(Engine::new(Limits::default(), services))
}
@@ -73,6 +74,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id,
script_owner: None,
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,

View File

@@ -66,6 +66,7 @@ fn make_engine(svc: Arc<RecordingQueue>) -> Arc<Engine> {
Arc::new(picloud_shared::NoopUsersService),
svc,
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
);
Arc::new(Engine::new(Limits::default(), services))
}
@@ -87,6 +88,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id,
script_owner: None,
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,

View File

@@ -30,6 +30,7 @@ fn build_engine() -> Arc<Engine> {
Arc::new(NoopUsersService),
Arc::new(NoopQueueService),
Arc::new(NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
);
Arc::new(Engine::new(Limits::default(), services))
}
@@ -51,6 +52,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id,
script_owner: None,
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,

View File

@@ -105,6 +105,7 @@ fn make_engine() -> Arc<Engine> {
Arc::new(picloud_shared::NoopUsersService),
Arc::new(picloud_shared::NoopQueueService),
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
);
Arc::new(Engine::new(Limits::default(), services))
}
@@ -126,6 +127,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id,
script_owner: None,
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,

View File

@@ -99,6 +99,7 @@ fn make_engine() -> Arc<Engine> {
Arc::new(picloud_shared::NoopUsersService),
Arc::new(picloud_shared::NoopQueueService),
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
);
Arc::new(Engine::new(Limits::default(), services))
}
@@ -120,6 +121,7 @@ fn request(app_id: AppId, with_principal: bool) -> ExecRequest {
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id,
script_owner: None,
principal: with_principal.then(|| Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Owner,

View File

@@ -37,6 +37,7 @@ fn baseline_request() -> ExecRequest {
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id: AppId::new(),
script_owner: None,
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,

View File

@@ -0,0 +1,47 @@
-- Phase 3: group-inherited config — the `vars` table + an app environment
-- marker. See docs/design/groups-and-project-tool.md §3, §5.1.
--
-- `vars` is the net-new, env-scoped configuration layer (greenfield — there
-- is no env-agnostic config today, only `secrets`). A var is owned by
-- exactly one group OR one app, optionally scoped to an environment, and
-- resolved down the tree (§3): env-filter first, then nearest level wins.
--
-- "An environment is an app": each env is already a distinct app row. To
-- env-filter group-level `@E` values, the resolver must know which env an
-- app represents — recorded here on `apps.environment` (NULL = env-agnostic,
-- set at app-create from the CLI's known env).
ALTER TABLE apps ADD COLUMN environment TEXT;
CREATE TABLE vars (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Polymorphic owner: exactly one of the two FKs is set. Real FKs (not a
-- bare owner_id) so ON DELETE CASCADE works per owner kind and a dangling
-- owner is impossible.
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
CONSTRAINT vars_owner_exactly_one
CHECK ((group_id IS NULL) <> (app_id IS NULL)),
-- '*' = env-agnostic; otherwise an env name matched against
-- apps.environment. NOT NULL with a '*' sentinel so the uniqueness
-- indexes are total (a NULL scope would break UNIQUE).
environment_scope TEXT NOT NULL DEFAULT '*',
key TEXT NOT NULL,
-- JSONB per the v1.1 data-plane convention. A tombstone (deletion of an
-- inherited key, §3) is the explicit boolean below, NOT value = 'null'
-- — JSON null is a legitimate value and must stay distinguishable.
value JSONB NOT NULL,
is_tombstone BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- One row per (owner, scope, key). Partial unique indexes because the owner
-- is split across two nullable columns.
CREATE UNIQUE INDEX vars_group_uidx
ON vars (group_id, environment_scope, key) WHERE group_id IS NOT NULL;
CREATE UNIQUE INDEX vars_app_uidx
ON vars (app_id, environment_scope, key) WHERE app_id IS NOT NULL;
CREATE INDEX vars_group_id_idx ON vars (group_id) WHERE group_id IS NOT NULL;
CREATE INDEX vars_app_id_idx ON vars (app_id) WHERE app_id IS NOT NULL;

View File

@@ -0,0 +1,51 @@
-- Phase 3 (v1.1.9): group-owned, environment-scoped secrets.
--
-- Until now `secrets` was strictly per-app: PK `(app_id, name)`, one
-- envelope per app. Phase 3 makes secrets inheritable down the group tree
-- (blueprint §11 bullet 3 / docs/design §3), exactly like `vars` (0048):
-- a secret may be owned by an app OR an ancestor group, and a descendant
-- app resolves the nearest one, environment-filtered.
--
-- Reshape (mirrors `vars`):
-- * `group_id` — nullable FK→groups, CASCADE (a deleted group drops its
-- secrets, same as its vars).
-- * `app_id` — made nullable (was NOT NULL); the exactly-one CHECK now
-- enforces "owned by an app XOR a group".
-- * `environment_scope` — `'*'` (env-agnostic) or a concrete env name;
-- the resolver filters on it. Existing rows backfill to `'*'`, so every
-- current app secret stays env-agnostic and resolves unchanged.
-- * PK `(app_id, name)` → two PARTIAL unique indexes, one per owner, both
-- keyed `(owner, environment_scope, name)`.
--
-- CRYPTO INVARIANT (audit 2026-06-11 H-D1): the v1 AAD is
-- `secret:{app_id}:{name}` for app secrets and `secret:group:{group_id}:{name}`
-- for group secrets — it does NOT include `environment_scope`. Adding the
-- column therefore leaves every existing ciphertext decryptable byte-for-byte;
-- the app-owner AAD is unchanged and the group namespace is disjoint.
ALTER TABLE secrets
ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
ADD COLUMN environment_scope TEXT NOT NULL DEFAULT '*';
-- Drop the old composite PK first: `app_id` cannot lose NOT NULL while it
-- is a primary-key column.
ALTER TABLE secrets
DROP CONSTRAINT secrets_pkey;
ALTER TABLE secrets
ALTER COLUMN app_id DROP NOT NULL;
ALTER TABLE secrets
ADD CONSTRAINT secrets_owner_exactly_one
CHECK ((group_id IS NULL) <> (app_id IS NULL));
-- One secret per (owner, env, name). Partial so each owner column only
-- constrains its own rows; the resolver and every upsert restate the
-- predicate as the ON CONFLICT arbiter.
CREATE UNIQUE INDEX secrets_app_uidx
ON secrets (app_id, environment_scope, name) WHERE app_id IS NOT NULL;
CREATE UNIQUE INDEX secrets_group_uidx
ON secrets (group_id, environment_scope, name) WHERE group_id IS NOT NULL;
-- Owner lookup index for the group side (the app side keeps idx_secrets_app).
CREATE INDEX idx_secrets_group ON secrets (group_id) WHERE group_id IS NOT NULL;

View File

@@ -0,0 +1,40 @@
-- Phase 4 (v1.2 Hierarchies): group-owned scripts.
--
-- Until now every script was owned by exactly one app (`app_id NOT NULL`,
-- ON DELETE RESTRICT). Phase 4 lets a script be owned by a GROUP instead, so
-- it is shared by every descendant app — resolved by name, nearest-owner
-- wins (CoW: an app's own script of the same name shadows the inherited one),
-- exactly like `vars`/`secrets` (0048/0049).
--
-- Phase 4-LITE scope: group-owned ENDPOINT scripts only. Group modules and
-- the origin-aware (lexical) import resolver are deferred to Phase 4b, so a
-- group-owned script must be self-contained (enforced in the service layer).
--
-- Reshape (mirrors vars/secrets, but RESTRICT not CASCADE — code is not data,
-- a group can't be deleted out from under the scripts it owns, matching the
-- existing app_id RESTRICT and the §5.6 delete=RESTRICT rule):
-- * `group_id` — nullable FK→groups, ON DELETE RESTRICT.
-- * `app_id` — made nullable (was NOT NULL); the exactly-one CHECK now
-- enforces "owned by an app XOR a group".
-- * the per-app unique name index becomes two partial indexes, one per owner.
ALTER TABLE scripts
ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE RESTRICT;
ALTER TABLE scripts
ALTER COLUMN app_id DROP NOT NULL;
ALTER TABLE scripts
ADD CONSTRAINT scripts_owner_exactly_one
CHECK ((group_id IS NULL) <> (app_id IS NULL));
-- Per-owner, case-insensitive name uniqueness. Partial so each owner column
-- only constrains its own rows; existing app rows (group_id NULL) keep the
-- exact same (app_id, LOWER(name)) uniqueness they had under the old index.
DROP INDEX scripts_name_uidx;
CREATE UNIQUE INDEX scripts_app_name_uidx
ON scripts (app_id, LOWER(name)) WHERE app_id IS NOT NULL;
CREATE UNIQUE INDEX scripts_group_name_uidx
ON scripts (group_id, LOWER(name)) WHERE group_id IS NOT NULL;
CREATE INDEX scripts_group_id_idx ON scripts (group_id) WHERE group_id IS NOT NULL;

View File

@@ -0,0 +1,49 @@
-- Phase 4b+1 (v1.2 Hierarchies): extension points (§5.5).
--
-- An extension point opts a MODULE NAME into dynamic, per-tenant resolution.
-- Normally a group/parent script's `import "x"` resolves sealed-lexically
-- against the importer's own defining node — a leaf app cannot shadow it
-- (the trust boundary). When a node declares `x` an extension point, an
-- importer whose defining node is on that node's chain instead resolves `x`
-- against the INHERITING app's effective view, so each descendant app may
-- supply its own `x`. Only the declaring (parent) node can open the hole; a
-- descendant cannot make one of its parent's sealed imports dynamic, because
-- the declaration must live on the importer's own ancestry.
--
-- Owner-polymorphic exactly like vars (0048): exactly one of group_id/app_id.
-- Optional `default_script_id` is the fallback module body used when the
-- inheriting app provides no module of `name`.
CREATE TABLE extension_points (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Polymorphic owner: exactly one FK set (real FKs so ON DELETE CASCADE
-- works per owner kind and a dangling owner is impossible).
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
CONSTRAINT extension_points_owner_exactly_one
CHECK ((group_id IS NULL) <> (app_id IS NULL)),
name TEXT NOT NULL,
-- Optional fallback module (a module owned by the declaring node). SET
-- NULL on delete so deleting the module can never block — the fallback is
-- then absent and a non-providing app errors at import time (the next plan
-- shows the now-null default as an Update). A valid manifest apply can't
-- reach this: validation rejects a kept ext point whose default names a
-- module not in the bundle.
default_script_id UUID REFERENCES scripts(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- One declaration per (owner, name), case-insensitive to match the module
-- name indexes (0050). Partial because the owner is split across two columns.
CREATE UNIQUE INDEX extension_points_group_uidx
ON extension_points (group_id, LOWER(name)) WHERE group_id IS NOT NULL;
CREATE UNIQUE INDEX extension_points_app_uidx
ON extension_points (app_id, LOWER(name)) WHERE app_id IS NOT NULL;
CREATE INDEX extension_points_group_id_idx
ON extension_points (group_id) WHERE group_id IS NOT NULL;
CREATE INDEX extension_points_app_id_idx
ON extension_points (app_id) WHERE app_id IS NOT NULL;
CREATE INDEX extension_points_default_script_idx
ON extension_points (default_script_id) WHERE default_script_id IS NOT NULL;

View File

@@ -0,0 +1,39 @@
-- Phase 5 / M3 (v1.2 Hierarchies): multi-repo single-owner ownership (§7).
--
-- A group node is authoritatively MANAGED by at most one project-root (the
-- repo whose manifest declares it as something it applies, not merely
-- references). `groups.owner_project` has carried this seam since 0047 as an
-- inert, FK-less UUID. M3 makes it a real reference: a `projects` table keyed
-- by a stable, gitignored project key the CLI mints in `.picloud/`, and a
-- foreign key from `groups.owner_project` to it.
--
-- Ownership is recorded at GROUP granularity; apps inherit their owning project
-- from their group (apps are never claimed directly). A NULL `owner_project`
-- means the node is UI/API-owned — no manifest fights it (§7.5). First apply
-- claims; a second project's apply to an owned node is refused unless
-- `--takeover` (group-admin gated). Ownership ⟂ RBAC: owning the manifest does
-- NOT grant write — the actor still needs the usual group capabilities (§7.4).
--
-- ON DELETE SET NULL: deleting a project row (not something the platform does
-- today) reverts its nodes to UI-owned rather than cascading away real groups.
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Stable, opaque key minted by `pic init` and persisted (gitignored) in the
-- repo's `.picloud/`. The CLI presents it on every tree apply; the server
-- maps it to this row (upsert-by-key), so the same repo keeps the same
-- project identity across clones/CI without committing any server id.
key TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Promote the inert `groups.owner_project` (0047:38) to a real FK now that the
-- referent exists. Existing rows are all NULL (no projects yet), so this adds
-- no validation burden.
ALTER TABLE groups
ADD CONSTRAINT groups_owner_project_fkey
FOREIGN KEY (owner_project) REFERENCES projects(id) ON DELETE SET NULL;
-- The ownership reconcile reads "which groups does project P own?" (to find
-- owned-but-undeclared nodes for structural prune) and "who owns group G?".
CREATE INDEX groups_owner_project_idx ON groups (owner_project);

View File

@@ -0,0 +1,49 @@
-- Phase 5 / M4a (v1.2 Hierarchies): ROUTE templates (§4.5).
--
-- Triggers and routes are app-scoped (never group-inherited — §5.1). At high
-- tenant cardinality that means 100 apps × N routes = 100N hand declarations.
-- A TEMPLATE fixes that: declare a route ONCE on a group, and the apply engine
-- fans it out into one concrete per-`app_id` row for every descendant app. This
-- is INSTANTIATION, not inheritance — expanded rows stay app-owned (the
-- isolation boundary is unchanged); the template is just a stamp.
--
-- Placeholders in template fields are a small, inert, documented set resolved
-- per descendant at expansion: `{app_slug}`, `{env}`, `{var:NAME}` (the app's
-- effective var). Provenance: each expanded `routes` row carries `from_template`
-- (the template id it was stamped from), so re-apply diffs idempotently and
-- `--prune` removes expansions WITHOUT touching a hand-declared route of the
-- same identity.
--
-- (Trigger templates reuse this engine in a follow-up: `trigger_templates` +
-- `triggers.from_template`.)
-- Route templates — one per (group, name). Mirrors the `routes` column shape
-- minus `app_id` (filled per descendant) plus `script_name` (resolved to the
-- nearest inherited endpoint at expansion, like declarative route bindings).
CREATE TABLE route_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
-- Explicit identity/upsert key, unique per group (case-insensitive).
name TEXT NOT NULL,
script_name TEXT NOT NULL,
method TEXT,
host_kind TEXT NOT NULL CHECK (host_kind IN ('any', 'strict', 'wildcard')),
host TEXT NOT NULL DEFAULT '',
host_param_name TEXT,
path_kind TEXT NOT NULL CHECK (path_kind IN ('exact', 'prefix', 'param')),
path TEXT NOT NULL,
dispatch_mode TEXT NOT NULL DEFAULT 'sync' CHECK (dispatch_mode IN ('sync', 'async')),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX route_templates_group_name_idx
ON route_templates (group_id, LOWER(name));
-- Provenance on the expanded rows. NULL = hand-declared (the app's own
-- manifest); non-NULL = stamped from this template, managed by template
-- expansion/prune. No FK: the apply engine owns the expansion lifecycle (it
-- deletes orphaned expansions explicitly), and a dangling id after a raw
-- template delete is harmless (treated as "template gone → prune the row").
ALTER TABLE routes ADD COLUMN from_template UUID;
CREATE INDEX routes_from_template_idx ON routes (app_id, from_template);

View File

@@ -0,0 +1,33 @@
-- Phase 5 / M4b (v1.2 Hierarchies): TRIGGER templates (§4.5).
--
-- The trigger half of templates (M4a shipped routes). A `[group]` manifest
-- declares `[[trigger_templates.<kind>]]`; the apply fans each one out into a
-- concrete per-`app_id` trigger on every descendant app, reusing the same
-- expansion engine, placeholder set (`{app_slug}`/`{env}`/`{var:NAME}`), and
-- `from_template` provenance as routes.
--
-- A trigger's parameters vary by kind (collection_glob/ops, schedule/timezone,
-- topic_pattern, queue_name, inbound_secret_ref, …), so the template stores the
-- whole `BundleTrigger` wire object as `spec` JSONB (kind tag included). The
-- expansion resolves placeholders in the spec's string leaves, then rebuilds the
-- typed trigger and inserts it through the normal trigger path (email secrets
-- are resolved + re-sealed per app, exactly like a hand-declared email trigger).
CREATE TABLE trigger_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
-- Identity/upsert key, unique per group (case-insensitive).
name TEXT NOT NULL,
-- The full `BundleTrigger` wire object (internally tagged by `kind`), with
-- placeholders left unresolved. Rebuilt + resolved per descendant at apply.
spec JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX trigger_templates_group_name_idx
ON trigger_templates (group_id, LOWER(name));
-- Provenance on expanded triggers (mirrors routes.from_template, 0053). NULL =
-- hand-declared; non-NULL = stamped from this template, managed by expansion.
ALTER TABLE triggers ADD COLUMN from_template UUID;
CREATE INDEX triggers_from_template_idx ON triggers (app_id, from_template);

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,6 +181,27 @@ async fn resolve_app_ident(apps: &dyn AppRepository, ident: &str) -> Result<AppI
Ok(lookup.app.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>(
State(state): State<AdminState<R, L>>,
Extension(principal): Extension<Principal>,
@@ -190,7 +211,7 @@ async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
require(
state.authz.as_ref(),
&principal,
Capability::AppRead(script.app_id),
script_cap(&script, Capability::AppRead, Capability::GroupScriptsRead)?,
)
.await?;
Ok(Json(script))
@@ -234,7 +255,8 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
let created = state
.repo
.create(NewScript {
app_id: input.app_id,
app_id: Some(input.app_id),
group_id: None,
name: input.name,
description: input.description,
source: input.source,
@@ -291,7 +313,11 @@ async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
require(
state.authz.as_ref(),
&principal,
Capability::AppWriteScript(script.app_id),
script_cap(
&script,
Capability::AppWriteScript,
Capability::GroupScriptsWrite,
)?,
)
.await?;
@@ -361,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_id),
script_cap(&script, Capability::AppAdmin, Capability::GroupScriptsWrite)?,
)
.await?;
state.repo.delete(id).await?;
@@ -408,7 +436,11 @@ async fn list_logs<R: ScriptRepository, L: ExecutionLogRepository>(
require(
state.authz.as_ref(),
&principal,
Capability::AppLogRead(script.app_id),
script_cap(
&script,
Capability::AppLogRead,
Capability::GroupScriptsRead,
)?,
)
.await?;
// Cap to keep the dashboard responsive; the data plane writes are

View File

@@ -60,7 +60,8 @@ async fn seed_into(
) -> Result<(), ScriptRepositoryError> {
let script = scripts
.create(NewScript {
app_id: default.id,
app_id: Some(default.id),
group_id: None,
name: "hello".to_string(),
description: Some("Reference example: returns a greeting at GET /hello.".to_string()),
source: HELLO_RHAI_SOURCE.to_string(),
@@ -87,6 +88,7 @@ async fn seed_into(
method: None,
dispatch_mode: picloud_shared::DispatchMode::Sync,
enabled: true,
from_template: None,
})
.await?;

View File

@@ -8,27 +8,75 @@ use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::post,
routing::{get, post},
Extension, Json, Router,
};
use picloud_shared::{AppId, Principal};
use picloud_shared::{AppId, GroupId, Principal};
use serde::Deserialize;
use serde_json::json;
use crate::app_repo::AppRepository;
use crate::apply_service::{
ApplyError, ApplyReport, ApplyService, Bundle, BundleTrigger, PlanResult,
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, ExtPointView,
NodeKind, PlanResult, TreeBundle, TreePlanResult,
};
use crate::authz::{require, AuthzDenied, Capability};
use crate::group_repo::GroupRepository;
/// Build the apply/plan router. Mounted under `/api/v1/admin`.
pub fn apply_router(service: ApplyService) -> Router {
Router::new()
.route("/apps/{id}/plan", post(plan_handler))
.route("/apps/{id}/apply", post(apply_handler))
.route("/groups/{id}/plan", post(group_plan_handler))
.route("/groups/{id}/apply", post(group_apply_handler))
.route("/apps/{id}/extension-points", get(app_ext_points_handler))
.route(
"/groups/{id}/extension-points",
get(group_ext_points_handler),
)
.route("/tree/plan", post(tree_plan_handler))
.route("/tree/apply", post(tree_apply_handler))
.with_state(service)
}
/// `GET .../apps/{id}/extension-points` — list the app's OWN extension-point
/// declarations (for `pic pull`). Read-only; viewer-tier (`AppRead`).
async fn app_ext_points_handler(
State(svc): State<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<ExtPointView>>, ApplyError> {
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
.await
.map_err(map_authz)?;
Ok(Json(
svc.list_extension_points(ApplyOwner::App(app_id)).await?,
))
}
/// `GET .../groups/{id}/extension-points` — list the group's OWN extension-point
/// declarations. Read-only; viewer-tier (`GroupScriptsRead`).
async fn group_ext_points_handler(
State(svc): State<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<ExtPointView>>, ApplyError> {
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
require(
svc.authz.as_ref(),
&principal,
Capability::GroupScriptsRead(group_id),
)
.await
.map_err(map_authz)?;
Ok(Json(
svc.list_extension_points(ApplyOwner::Group(group_id))
.await?,
))
}
#[derive(Deserialize)]
pub struct ApplyRequest {
pub bundle: Bundle,
@@ -47,53 +95,7 @@ async fn apply_handler(
Json(req): Json<ApplyRequest>,
) -> Result<Json<ApplyReport>, ApplyError> {
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
// Read is always needed; write caps are required for the resource kinds
// the bundle touches — and for ALL kinds when `prune` is set, since
// pruning deletes resources whose bundle section is empty (and a script
// delete cascades its routes/triggers).
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
.await
.map_err(map_authz)?;
if req.prune || !req.bundle.scripts.is_empty() {
require(
svc.authz.as_ref(),
&principal,
Capability::AppWriteScript(app_id),
)
.await
.map_err(map_authz)?;
}
if req.prune || !req.bundle.routes.is_empty() {
require(
svc.authz.as_ref(),
&principal,
Capability::AppWriteRoute(app_id),
)
.await
.map_err(map_authz)?;
}
if req.prune || !req.bundle.triggers.is_empty() {
require(
svc.authz.as_ref(),
&principal,
Capability::AppManageTriggers(app_id),
)
.await
.map_err(map_authz)?;
}
// Email triggers resolve and decrypt a stored secret by name server-side,
// which the secrets API guards with `AppSecretsRead`. Require it here too
// so apply can't bind a secret a principal couldn't otherwise read — the
// caps aren't strictly nested on the API-key scope path.
if req.bundle.triggers.iter().any(BundleTrigger::is_email) {
require(
svc.authz.as_ref(),
&principal,
Capability::AppSecretsRead(app_id),
)
.await
.map_err(map_authz)?;
}
require_app_node_writes(&svc, &principal, app_id, &req.bundle, req.prune).await?;
let report = svc
.apply(
app_id,
@@ -126,6 +128,602 @@ async fn plan_handler(
Ok(Json(plan))
}
// ----------------------------------------------------------------------------
// Group node apply (Phase 5): a group declares only scripts + vars (+ secret
// names). The bundle reuses the app `Bundle` shape with empty routes/triggers;
// the service rejects routes/triggers on a group node.
// ----------------------------------------------------------------------------
async fn group_plan_handler(
State(svc): State<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(bundle): Json<Bundle>,
) -> Result<Json<PlanResult>, ApplyError> {
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
// Plan discloses the group's script + var + secret names; viewer-tier reads.
require(
svc.authz.as_ref(),
&principal,
Capability::GroupScriptsRead(group_id),
)
.await
.map_err(map_authz)?;
let plan = svc.plan_owner(ApplyOwner::Group(group_id), &bundle).await?;
Ok(Json(plan))
}
async fn group_apply_handler(
State(svc): State<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(req): Json<ApplyRequest>,
) -> Result<Json<ApplyReport>, ApplyError> {
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
require_group_node_writes(&svc, &principal, group_id, &req.bundle, req.prune).await?;
let report = svc
.apply_owner(
ApplyOwner::Group(group_id),
&req.bundle,
req.prune,
principal.user_id,
req.expected_token.as_deref(),
)
.await?;
Ok(Json(report))
}
// ----------------------------------------------------------------------------
// Tree apply (Phase 5): a whole project subtree in one transaction. The actor
// must hold the relevant read/write caps for EVERY node touched.
// ----------------------------------------------------------------------------
#[derive(Deserialize)]
struct TreePlanRequest {
bundle: TreeBundle,
/// Stable project key from the repo's `.picloud/` link state (§7, M3).
/// Lets `plan` surface ownership conflicts and prune candidates. Optional
/// for legacy callers (then no ownership diagnosis is reported).
#[serde(default)]
project_key: Option<String>,
}
#[derive(Deserialize)]
struct TreeApplyRequest {
bundle: TreeBundle,
#[serde(default)]
prune: bool,
#[serde(default)]
expected_token: Option<String>,
/// Stable project key (§7, M3): the apply claims unclaimed declared groups
/// for this project and refuses ones another project owns.
#[serde(default)]
project_key: Option<String>,
/// Take over declared groups owned by another project (group-admin gated).
#[serde(default)]
allow_takeover: bool,
/// Selected environment (§4.5, M4a): the value substituted for the `{env}`
/// placeholder when expanding route templates. The CLI passes `--env`.
#[serde(default)]
env: Option<String>,
/// Environments the actor explicitly approved this apply for (§4.2, M5).
/// A confirm-required env (per the bundle's `[project]` policy) is refused
/// unless it appears here; `--yes` alone does NOT add it.
#[serde(default)]
approved_envs: Vec<String>,
}
async fn tree_plan_handler(
State(svc): State<ApplyService>,
Extension(principal): Extension<Principal>,
Json(req): Json<TreePlanRequest>,
) -> Result<Json<TreePlanResult>, ApplyError> {
authz_tree(&svc, &principal, &req.bundle, None, false).await?;
Ok(Json(
svc.plan_tree(&req.bundle, req.project_key.as_deref())
.await?,
))
}
async fn tree_apply_handler(
State(svc): State<ApplyService>,
Extension(principal): Extension<Principal>,
Json(req): Json<TreeApplyRequest>,
) -> Result<Json<ApplyReport>, ApplyError> {
authz_tree(
&svc,
&principal,
&req.bundle,
Some(req.prune),
req.allow_takeover,
)
.await?;
enforce_env_approval(
&svc,
&principal,
&req.bundle,
req.env.as_deref(),
&req.approved_envs,
)
.await?;
let report = svc
.apply_tree(
&req.bundle,
req.prune,
&principal,
req.expected_token.as_deref(),
req.project_key.as_deref(),
req.allow_takeover,
req.env.as_deref(),
)
.await?;
Ok(Json(report))
}
/// Per-node capability check for a tree plan/apply. `prune` widens an apply's
/// write requirement to every kind. A plan (`prune` ignored, no writes) needs
/// only the per-node read cap.
#[allow(clippy::too_many_lines)]
async fn authz_tree(
svc: &ApplyService,
principal: &Principal,
bundle: &TreeBundle,
apply_prune: Option<bool>,
allow_takeover: bool,
) -> Result<(), ApplyError> {
for node in &bundle.nodes {
match node.kind {
NodeKind::App => {
let app_id = resolve_app_id(svc.apps.as_ref(), &node.slug).await?;
match apply_prune {
Some(prune) => {
require_app_node_writes(svc, principal, app_id, &node.bundle, prune)
.await?;
// Template expansion (§4.5) writes routes/triggers INTO
// this app, so the actor needs the matching write cap
// when the app's chain carries templates — even if the
// app itself declares none. Without this, expanding into
// an app a principal can't write would slip the gate.
let recv = app_receives_template_expansions(svc, app_id, bundle).await?;
if recv.routes {
require(
svc.authz.as_ref(),
principal,
Capability::AppWriteRoute(app_id),
)
.await
.map_err(map_authz)?;
}
if recv.triggers {
require(
svc.authz.as_ref(),
principal,
Capability::AppManageTriggers(app_id),
)
.await
.map_err(map_authz)?;
}
// Email-trigger expansion resolves + seals the recipient
// app's secret server-side — same `AppSecretsRead` gate a
// hand-declared email trigger requires.
if recv.email {
require(
svc.authz.as_ref(),
principal,
Capability::AppSecretsRead(app_id),
)
.await
.map_err(map_authz)?;
}
}
None => {
require(svc.authz.as_ref(), principal, Capability::AppRead(app_id))
.await
.map_err(map_authz)?;
}
}
}
NodeKind::Group => {
let existing = svc
.groups
.get_by_slug(&node.slug)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
let Some(g) = existing else {
// To-create group (M2): mirror the interactive create gate
// (`groups_api::create_group`). A root-level group (no
// resolvable parent) needs `InstanceCreateGroup`; a subgroup
// under an EXISTING parent needs only `GroupAdmin(parent)`.
// A parent that is itself to-create has no id yet → fall
// back to `InstanceCreateGroup`.
let parent = match &node.parent_slug {
Some(p) => svc
.groups
.get_by_slug(p)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?,
None => None,
};
match parent {
Some(pg) => {
require(svc.authz.as_ref(), principal, Capability::GroupAdmin(pg.id))
.await
.map_err(map_authz)?;
}
None => {
require(
svc.authz.as_ref(),
principal,
Capability::InstanceCreateGroup,
)
.await
.map_err(map_authz)?;
}
}
continue;
};
let group_id = g.id;
match apply_prune {
Some(prune) => {
require_group_node_writes(svc, principal, group_id, &node.bundle, prune)
.await?;
// Takeover gate (§7.4): seizing a group that another
// project owns needs GroupAdmin specifically — a second,
// independent gate on top of the write caps. We gate it
// for any already-claimed node under `--takeover` (a
// superset of genuinely-contested, never laxer); claiming
// an unclaimed node, or applying without `--takeover`,
// does not require admin. The authoritative owner rewrite
// happens in-tx in `apply_tree` (which knows our project).
if allow_takeover
&& crate::group_repo::get_group_owner(&svc.pool, group_id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
.is_some()
{
require(
svc.authz.as_ref(),
principal,
Capability::GroupAdmin(group_id),
)
.await
.map_err(map_authz)?;
}
// Reparent: an explicit parent differing from the live
// one needs GroupAdmin at the group, the SOURCE parent,
// and the DESTINATION parent — mirroring the interactive
// `reparent_group` (§5.6 triply-gated).
if let Some(parent) = &node.parent_slug {
if let Some(pg) = svc
.groups
.get_by_slug(parent)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
{
if Some(pg.id) != g.parent_id {
require(
svc.authz.as_ref(),
principal,
Capability::GroupAdmin(group_id),
)
.await
.map_err(map_authz)?;
if let Some(src) = g.parent_id {
require(
svc.authz.as_ref(),
principal,
Capability::GroupAdmin(src),
)
.await
.map_err(map_authz)?;
}
require(
svc.authz.as_ref(),
principal,
Capability::GroupAdmin(pg.id),
)
.await
.map_err(map_authz)?;
}
}
}
}
None => {
require(
svc.authz.as_ref(),
principal,
Capability::GroupScriptsRead(group_id),
)
.await
.map_err(map_authz)?;
}
}
}
}
}
// NOTE (§4.5, M7): templates also fan out to descendant apps NOT declared in
// this bundle (the cross-repo subtree). Those per-recipient write caps are
// gated AUTHORITATIVELY IN-TRANSACTION in `apply_tree` Phase B2 — against the
// post-reparent app set, each app already locked — so the checked set is by
// construction the written set (no pre-tx TOCTOU, and a reparent that moves
// apps under a templated group in the same apply can't slip the gate).
Ok(())
}
/// Per-env approval gate (§4.2, §6, M5). If the apply selects a confirm-required
/// environment (per the bundle's `[project]` policy, re-derived here — the CLI's
/// enforcement is convenience, this is authoritative), it must be explicitly
/// approved via `approved_envs` (`pic apply --approve <env>`); a blanket `--yes`
/// does NOT satisfy it. An approved gated apply additionally requires ADMIN
/// authority on every declared node — the "override a gate" capability (§4.2),
/// a deliberate step up from the editor-level write caps an ordinary apply
/// needs — and is audited.
async fn enforce_env_approval(
svc: &ApplyService,
principal: &Principal,
bundle: &TreeBundle,
env: Option<&str>,
approved_envs: &[String],
) -> Result<(), ApplyError> {
let (Some(policy), Some(env)) = (&bundle.project, env) else {
return Ok(());
};
if !policy.confirm_required(env) {
return Ok(());
}
if !approved_envs.iter().any(|e| e == env) {
return Err(ApplyError::ApprovalRequired(env.to_string()));
}
// Approved: require admin authority on every declared node.
for node in &bundle.nodes {
match node.kind {
NodeKind::App => {
let app_id = resolve_app_id(svc.apps.as_ref(), &node.slug).await?;
require(svc.authz.as_ref(), principal, Capability::AppAdmin(app_id))
.await
.map_err(map_authz)?;
}
NodeKind::Group => {
// A to-create group has no id yet; its creation already required
// InstanceCreateGroup / GroupAdmin(parent) in `authz_tree`.
if let Some(g) = svc
.groups
.get_by_slug(&node.slug)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
{
require(svc.authz.as_ref(), principal, Capability::GroupAdmin(g.id))
.await
.map_err(map_authz)?;
}
}
}
}
tracing::info!(
user_id = ?principal.user_id,
env = %env,
nodes = bundle.nodes.len(),
"apply: approved gated-environment apply (audit)"
);
Ok(())
}
/// App-node write caps: per resource kind the bundle touches, widened to all
/// kinds when `prune` (which deletes empty-section resources + cascades). Read
/// is always needed. Shared by the single-app and tree apply paths.
async fn require_app_node_writes(
svc: &ApplyService,
principal: &Principal,
app_id: picloud_shared::AppId,
bundle: &Bundle,
prune: bool,
) -> Result<(), ApplyError> {
require(svc.authz.as_ref(), principal, Capability::AppRead(app_id))
.await
.map_err(map_authz)?;
// Extension points are module-resolution declarations, so they gate on the
// same script-write tier as modules — without this, an ext-point-only
// bundle (no `default`, so no script) would pass with viewer-tier `AppRead`
// and let a reader open the §5.5 import trust boundary.
if prune || !bundle.scripts.is_empty() || !bundle.extension_points.is_empty() {
require(
svc.authz.as_ref(),
principal,
Capability::AppWriteScript(app_id),
)
.await
.map_err(map_authz)?;
}
if prune || !bundle.routes.is_empty() {
require(
svc.authz.as_ref(),
principal,
Capability::AppWriteRoute(app_id),
)
.await
.map_err(map_authz)?;
}
if prune || !bundle.triggers.is_empty() {
require(
svc.authz.as_ref(),
principal,
Capability::AppManageTriggers(app_id),
)
.await
.map_err(map_authz)?;
}
if prune || !bundle.vars.is_empty() {
require(
svc.authz.as_ref(),
principal,
Capability::AppVarsWrite(app_id),
)
.await
.map_err(map_authz)?;
}
// Email triggers resolve + decrypt a stored secret by name server-side
// (`AppSecretsRead`), so require it so apply can't bind a secret the
// principal couldn't otherwise read.
if bundle.triggers.iter().any(BundleTrigger::is_email) {
require(
svc.authz.as_ref(),
principal,
Capability::AppSecretsRead(app_id),
)
.await
.map_err(map_authz)?;
}
Ok(())
}
/// Group-node write caps (editor-tier): scripts → `GroupScriptsWrite`, vars →
/// `GroupVarsWrite`, both on prune.
async fn require_group_node_writes(
svc: &ApplyService,
principal: &Principal,
group_id: GroupId,
bundle: &Bundle,
prune: bool,
) -> Result<(), ApplyError> {
// Extension points and route/trigger templates gate on the script-write
// tier (see the app variant) — all are code-binding declarations.
if prune
|| !bundle.scripts.is_empty()
|| !bundle.extension_points.is_empty()
|| !bundle.route_templates.is_empty()
|| !bundle.trigger_templates.is_empty()
{
require(
svc.authz.as_ref(),
principal,
Capability::GroupScriptsWrite(group_id),
)
.await
.map_err(map_authz)?;
}
if prune || !bundle.vars.is_empty() {
require(
svc.authz.as_ref(),
principal,
Capability::GroupVarsWrite(group_id),
)
.await
.map_err(map_authz)?;
}
Ok(())
}
/// What template expansion (§4.5) will write into `app_id` during this tree
/// apply. Returns `Recipient { routes, triggers, email }` — whether the app's
/// ancestor chain carries a route / trigger / EMAIL-trigger template (committed,
/// or declared by a group node in THIS bundle). Drives the `AppWriteRoute` /
/// `AppManageTriggers` / `AppSecretsRead` gates for recipients (email expansion
/// resolves + seals the recipient app's secret server-side, like a hand-declared
/// email trigger).
#[derive(Default)]
struct Recipient {
routes: bool,
triggers: bool,
email: bool,
}
fn spec_is_email(spec: &serde_json::Value) -> bool {
spec.get("kind").and_then(serde_json::Value::as_str) == Some("email")
}
async fn app_receives_template_expansions(
svc: &ApplyService,
app_id: AppId,
bundle: &TreeBundle,
) -> Result<Recipient, ApplyError> {
let Some(app) = svc
.apps
.get_by_id(app_id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
else {
return Ok(Recipient::default());
};
let ancestors = svc
.groups
.ancestors(app.group_id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
let ancestor_ids: std::collections::HashSet<GroupId> = ancestors.iter().map(|g| g.id).collect();
let mut r = Recipient::default();
// Committed templates on any ancestor group.
for gid in &ancestor_ids {
if !r.routes
&& !crate::template_repo::list_for_group(&svc.pool, *gid)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
.is_empty()
{
r.routes = true;
}
for tt in crate::template_repo::list_triggers_for_group(&svc.pool, *gid)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
{
r.triggers = true;
r.email = r.email || spec_is_email(&tt.spec);
}
}
// Templates newly declared by a group node in this bundle that is an ancestor
// of the app (existing groups only — a to-create group has no apps).
for node in &bundle.nodes {
if node.kind != NodeKind::Group {
continue;
}
let declares_routes = !node.bundle.route_templates.is_empty();
let declares_triggers = !node.bundle.trigger_templates.is_empty();
if !declares_routes && !declares_triggers {
continue;
}
if let Some(g) = svc
.groups
.get_by_slug(&node.slug)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
{
if ancestor_ids.contains(&g.id) {
r.routes = r.routes || declares_routes;
r.triggers = r.triggers || declares_triggers;
r.email = r.email
|| node
.bundle
.trigger_templates
.iter()
.any(|t| spec_is_email(&t.spec));
}
}
}
Ok(r)
}
/// Resolve a slug-or-id path param to a `GroupId`, mapping miss → 404.
async fn resolve_group_id(
groups: &dyn GroupRepository,
ident: &str,
) -> Result<GroupId, ApplyError> {
let found = if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
groups
.get_by_id(uuid.into())
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
} else {
groups
.get_by_slug(ident)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
};
found
.map(|g| g.id)
.ok_or_else(|| ApplyError::AppNotFound(format!("group {ident}")))
}
/// Resolve a slug-or-id path param to an `AppId`, mapping miss → 404.
/// Mirrors the `triggers_api` helper of the same shape.
async fn resolve_app_id(apps: &dyn AppRepository, ident: &str) -> Result<AppId, ApplyError> {
@@ -152,6 +750,9 @@ impl IntoResponse for ApplyError {
json!({ "error": self.to_string() }),
),
Self::StateMoved => (StatusCode::CONFLICT, json!({ "error": self.to_string() })),
Self::OwnershipConflict(_) | Self::ApprovalRequired(_) => {
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
}
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
Self::AuthzRepo(e) => {
tracing::error!(error = %e, "apply authz repo error");

File diff suppressed because it is too large Load Diff

View File

@@ -116,6 +116,32 @@ pub enum Capability {
/// Write (set/delete) a secret in this app's secrets store (v1.1.7).
/// Granted to `editor`+, maps to `script:write` on API keys.
AppSecretsWrite(AppId),
/// Read this app's resolved config vars (Phase 3). Same trust shape as
/// secrets-read — granted to `viewer`+, maps to `script:read`.
AppVarsRead(AppId),
/// Write (set/delete) an app-owned config var (Phase 3). Granted to
/// `editor`+, maps to `script:write`.
AppVarsWrite(AppId),
/// Read a group's config vars (Phase 3). Resolved via the group
/// ancestor walk; viewer+ on the group.
GroupVarsRead(GroupId),
/// Write (set/delete) a group-owned config var (Phase 3). editor+ on
/// the group.
GroupVarsWrite(GroupId),
/// Read a group-owned secret's VALUE (Phase 3, the human-read gate).
/// group_admin on the owning group — distinct from runtime injection,
/// which an inheriting app does without this check.
GroupSecretsRead(GroupId),
/// 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`+.
@@ -176,7 +202,13 @@ impl Capability {
// app) is denied group management at the binding layer.
| Self::GroupRead(_)
| Self::GroupWrite(_)
| Self::GroupAdmin(_) => None,
| Self::GroupAdmin(_)
| Self::GroupVarsRead(_)
| Self::GroupVarsWrite(_)
| Self::GroupSecretsRead(_)
| Self::GroupSecretsWrite(_)
| Self::GroupScriptsRead(_)
| Self::GroupScriptsWrite(_) => None,
Self::AppRead(id)
| Self::AppWriteScript(id)
| Self::AppWriteRoute(id)
@@ -194,6 +226,8 @@ impl Capability {
| Self::AppQueueEnqueue(id)
| Self::AppSecretsRead(id)
| Self::AppSecretsWrite(id)
| Self::AppVarsRead(id)
| Self::AppVarsWrite(id)
| Self::AppEmailSend(id)
| Self::AppManageTriggers(id)
| Self::AppDeadLetterManage(id)
@@ -223,7 +257,10 @@ impl Capability {
| Self::AppFilesRead(_)
| Self::AppSecretsRead(_)
| Self::AppUsersRead(_)
| Self::GroupRead(_) => Scope::ScriptRead,
| Self::AppVarsRead(_)
| Self::GroupRead(_)
| Self::GroupVarsRead(_)
| Self::GroupScriptsRead(_) => Scope::ScriptRead,
Self::AppWriteScript(_)
| Self::AppKvWrite(_)
| Self::AppDocsWrite(_)
@@ -235,6 +272,13 @@ impl Capability {
| Self::AppEmailSend(_)
| Self::AppUsersWrite(_)
| Self::AppUsersAdmin(_)
| Self::AppVarsWrite(_)
// Group-secret WRITE is editor-role-gated (same tier as
// GroupVarsWrite), so its API-key scope matches: script:write,
// 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,
@@ -243,7 +287,9 @@ impl Capability {
| Self::AppDeadLetterManage(_)
| Self::AppTopicManage(_)
| Self::GroupWrite(_)
| Self::GroupAdmin(_) => Scope::AppAdmin,
| Self::GroupAdmin(_)
| Self::GroupVarsWrite(_)
| Self::GroupSecretsRead(_) => Scope::AppAdmin,
Self::AppLogRead(_) => Scope::LogRead,
}
}
@@ -412,7 +458,15 @@ async fn role_grants(
// walk (a group_admin on an ancestor is implicitly admin of
// the descendant group). Routed before member_grants because
// group caps carry no app_id.
Capability::GroupRead(g) | Capability::GroupWrite(g) | Capability::GroupAdmin(g) => {
Capability::GroupRead(g)
| Capability::GroupWrite(g)
| Capability::GroupAdmin(g)
| Capability::GroupVarsRead(g)
| Capability::GroupVarsWrite(g)
| Capability::GroupSecretsRead(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
@@ -472,9 +526,22 @@ 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 {
Capability::GroupRead(_) => true, // any role can read
Capability::GroupWrite(_) => matches!(role, AppRole::Editor | AppRole::AppAdmin),
Capability::GroupAdmin(_) => matches!(role, AppRole::AppAdmin),
// 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::GroupScriptsWrite(_) => {
matches!(role, AppRole::Editor | AppRole::AppAdmin)
}
// group_admin manages the group + reads secret VALUES (the
// human-read gate, distinct from an app's runtime injection).
Capability::GroupAdmin(_) | Capability::GroupSecretsRead(_) => {
matches!(role, AppRole::AppAdmin)
}
_ => false,
}
}
@@ -493,6 +560,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
| Capability::AppFilesRead(_)
| Capability::AppSecretsRead(_)
| Capability::AppUsersRead(_)
| Capability::AppVarsRead(_)
);
let in_editor = in_viewer
|| matches!(
@@ -508,6 +576,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
| Capability::AppSecretsWrite(_)
| Capability::AppEmailSend(_)
| Capability::AppUsersWrite(_)
| Capability::AppVarsWrite(_)
| Capability::AppInvoke(_)
);
let in_app_admin = in_editor

View File

@@ -0,0 +1,158 @@
//! `GET /api/v1/admin/apps/{id}/config/effective` — the resolved config an
//! app actually sees: every inherited var (with its value + provenance) and
//! every inherited secret (MASKED — name/owner/scope only, never the value).
//!
//! This is the read-only companion to the `vars`/`secrets` admin surfaces.
//! It runs the same §3 resolution the `vars::`/`secrets::` SDK calls run, so
//! a dev can see exactly what `vars::get`/`secrets::get` would return and
//! where each value comes from (`--explain` on the CLI surfaces the
//! provenance). Gated by `AppVarsRead` (config is app-readable); the secret
//! VALUES are deliberately absent — reading those needs `GroupSecretsRead`
//! at the owning group via the dedicated value endpoint.
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::{AppId, Principal};
use serde_json::json;
use sqlx::PgPool;
use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
use crate::config_resolver::{
fetch_effective_secret_meta, fetch_var_candidates, resolve, OwnerKind,
};
#[derive(Clone)]
pub struct ConfigApiState {
pub pool: PgPool,
pub apps: Arc<dyn AppRepository>,
pub authz: Arc<dyn AuthzRepo>,
}
pub fn config_router(state: ConfigApiState) -> Router {
Router::new()
.route("/apps/{id_or_slug}/config/effective", get(effective_config))
.with_state(state)
}
fn owner_json(kind: OwnerKind, id: uuid::Uuid, depth: i32) -> serde_json::Value {
json!({ "kind": kind.as_str(), "id": id, "depth": depth })
}
async fn effective_config(
State(s): State<ConfigApiState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<serde_json::Value>, ConfigApiError> {
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::AppVarsRead(app_id),
)
.await?;
// Vars: resolve to values + provenance (vars are app-readable config).
let candidates = fetch_var_candidates(&s.pool, app_id)
.await
.map_err(|e| ConfigApiError::Backend(e.to_string()))?;
let (values, provenance) = resolve(candidates);
let mut vars = serde_json::Map::new();
for (key, value) in values {
let p = &provenance[&key];
vars.insert(
key,
json!({
"value": value,
"owner": owner_json(p.owner_kind, p.owner_id, p.depth),
"scope": p.scope,
"merged_from": p.merged_from
.iter()
.map(|(d, sc)| json!({ "depth": d, "scope": sc }))
.collect::<Vec<_>>(),
}),
);
}
// Secrets: masked — name + owner/level/scope + status, never the value.
let secret_meta = fetch_effective_secret_meta(&s.pool, app_id)
.await
.map_err(|e| ConfigApiError::Backend(e.to_string()))?;
let mut secrets = serde_json::Map::new();
for m in secret_meta {
secrets.insert(
m.name,
json!({
"status": "set",
"owner": owner_json(m.owner_kind, m.owner_id, m.depth),
"scope": m.scope,
}),
);
}
Ok(Json(json!({ "vars": vars, "secrets": secrets })))
}
async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, ConfigApiError> {
crate::app_repo::resolve_app(apps, ident)
.await
.map_err(|e| ConfigApiError::Backend(e.to_string()))?
.map(|l| l.app.id)
.ok_or(ConfigApiError::AppNotFound)
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigApiError {
#[error("app not found")]
AppNotFound,
#[error("forbidden")]
Forbidden,
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("config backend: {0}")]
Backend(String),
}
impl From<AuthzDenied> for ConfigApiError {
fn from(d: AuthzDenied) -> Self {
match d {
AuthzDenied::Denied => Self::Forbidden,
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
}
}
}
impl From<AuthzError> for ConfigApiError {
fn from(e: AuthzError) -> Self {
Self::AuthzRepo(e.to_string())
}
}
impl IntoResponse for ConfigApiError {
fn into_response(self) -> Response {
let (status, body) = match &self {
Self::AppNotFound => (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })),
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
Self::AuthzRepo(e) => {
tracing::error!(error = %e, "config effective authz repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
Self::Backend(e) => {
tracing::error!(error = %e, "config effective backend error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
};
(status, Json(body)).into_response()
}
}

View File

@@ -0,0 +1,495 @@
//! The §3 configuration-resolution engine: env-filtered, proximity-first
//! inheritance down the group tree.
//!
//! To resolve a key for app A in environment E (docs/design §3):
//! 1. **Env-filter first, per level** — a value scoped `@E` is eligible;
//! `*` (env-agnostic) is the fallback. Within one level `@E` beats `*`.
//! Env is *eligibility*, not a precedence tier.
//! 2. **Nearest level wins** — walk A → parent group → … → root; the
//! closest level that defines the (filtered) key wins. Proximity beats
//! farther-level env-specificity (a leaf's `*` beats an ancestor's `@E`).
//! 3. **Maps deep-merge per key; scalars/arrays replace; deletion is an
//! explicit tombstone** that suppresses the inherited key.
//!
//! The recursive CTE that walks `apps.group_id → groups.parent_id → root`
//! mirrors `app_members_repo::effective_app_role`. The env-eligibility
//! filter happens in SQL; the §3 merge/replace/tombstone semantics — which
//! a window-function pick can't express — happen in the pure `resolve`
//! function below, so they're unit-tested without Postgres.
use std::collections::BTreeMap;
use serde_json::{Map, Value};
use sqlx::PgPool;
use uuid::Uuid;
use picloud_shared::AppId;
/// Shared chain-walk CTE: emits one row per owner-level for an app —
/// depth 0 = the app itself (`app_owner` set), then ancestor groups
/// nearest-first (`group_owner` set), each carrying the app's environment.
/// Depth-bounded `< 64` (the group cycle guard already forbids cycles; this
/// is a runaway guard). Bind `$1 = app_id`. Reused by the vars and secret
/// resolvers, which each append their own owner-keyed JOIN.
pub(crate) const CHAIN_LEVELS_CTE: &str = "\
WITH RECURSIVE chain AS ( \
SELECT a.id AS app_owner, NULL::uuid AS group_owner, \
a.group_id AS next_group, 0 AS depth, a.environment AS app_env \
FROM apps a WHERE a.id = $1 \
UNION ALL \
SELECT NULL::uuid, g.id, g.parent_id, c.depth + 1, c.app_env \
FROM groups g JOIN chain c ON g.id = c.next_group \
WHERE c.depth < 64 \
)";
/// Group-rooted variant of [`CHAIN_LEVELS_CTE`]: the chain starts at a
/// **group** (depth 0 = the group itself, `group_owner` set), then walks
/// its ancestor groups nearest-first. There is no `app_owner` level — a
/// group origin never sees app-owned rows below it (the §5.5 trust
/// boundary). Bind `$1 = group_id`. Used for the lexical module resolver
/// when the importing script's defining node is a group.
pub(crate) const GROUP_CHAIN_LEVELS_CTE: &str = "\
WITH RECURSIVE chain AS ( \
SELECT g.id AS group_owner, g.parent_id AS next_group, 0 AS depth \
FROM groups g WHERE g.id = $1 \
UNION ALL \
SELECT g.id, g.parent_id, c.depth + 1 \
FROM groups g JOIN chain c ON g.id = c.next_group \
WHERE c.depth < 64 \
)";
/// Owner kind of a resolved value, for `--explain` provenance.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OwnerKind {
App,
Group,
}
impl OwnerKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::App => "app",
Self::Group => "group",
}
}
}
/// One eligible (env-filtered) candidate row pulled by the resolver, before
/// §3 proximity/merge resolution.
#[derive(Debug, Clone)]
pub struct Candidate {
/// 0 = the app itself; 1 = its parent group; … (nearest-first).
pub depth: i32,
pub owner_kind: OwnerKind,
pub owner_id: Uuid,
/// `*` (env-agnostic) or a concrete environment name.
pub scope: String,
pub key: String,
pub value: Value,
pub is_tombstone: bool,
}
/// Where a resolved key came from (for `config --effective --explain`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Provenance {
pub owner_kind: OwnerKind,
pub owner_id: Uuid,
pub depth: i32,
pub scope: String,
/// For a deep-merged map, the `(depth, scope)` of every layer that
/// contributed, nearest-first. Empty for a plain scalar/array winner.
pub merged_from: Vec<(i32, String)>,
}
/// `@E`-scoped values outrank `*` *within the same level* (§3 step 1).
fn env_priority(scope: &str) -> u8 {
u8::from(scope != "*")
}
/// Deep-merge `src` into `dst` per key: nested objects merge recursively,
/// everything else is replaced by `src`. Caller merges farthest→nearest so
/// nearer layers overwrite.
fn deep_merge(dst: &mut Map<String, Value>, src: &Map<String, Value>) {
for (k, sv) in src {
match (dst.get_mut(k), sv) {
(Some(Value::Object(dm)), Value::Object(sm)) => deep_merge(dm, sm),
_ => {
dst.insert(k.clone(), sv.clone());
}
}
}
}
/// Resolve one key's candidate rows (any order in) to its effective value,
/// plus the provenance. Returns `None` when a tombstone suppresses the key.
fn resolve_one(mut rows: Vec<Candidate>) -> Option<(Value, Provenance)> {
// Nearest-first; within a level, `@E` before `*` (§3 steps 1-2).
rows.sort_by(|a, b| {
a.depth
.cmp(&b.depth)
.then(env_priority(&b.scope).cmp(&env_priority(&a.scope)))
});
// §3 step 1: env is eligibility, not a merge tier — within a single level
// `@E` *suppresses* `*` (it does not layer on top of it). Each level is one
// owner (single-parent tree) with at most one `@E` and one `*` row per key
// after env-filtering; keep only the level's winner (the `@E` row sorts
// first). Without this, a level holding both an `@E` map and a `*` map would
// deep-merge them instead of the `@E` shadowing the `*`.
rows.dedup_by_key(|r| r.depth);
// Collect the contiguous run of map-valued rows from the nearest. A
// tombstone or scalar/array boundary stops the run (and, if it's the
// nearest row, decides the result outright).
let mut maps: Vec<&Candidate> = Vec::new();
let mut boundary: Option<&Candidate> = None;
for r in &rows {
if r.is_tombstone {
boundary = Some(r);
break;
}
if r.value.is_object() {
maps.push(r);
} else {
boundary = Some(r);
break;
}
}
if let Some(first) = maps.first() {
// Map run wins; deep-merge farthest→nearest so nearest keys win.
let mut acc = Map::new();
for m in maps.iter().rev() {
if let Value::Object(obj) = &m.value {
deep_merge(&mut acc, obj);
}
}
let prov = Provenance {
owner_kind: first.owner_kind,
owner_id: first.owner_id,
depth: first.depth,
scope: first.scope.clone(),
merged_from: maps.iter().map(|m| (m.depth, m.scope.clone())).collect(),
};
return Some((Value::Object(acc), prov));
}
// No leading map run: the nearest row is the boundary.
match boundary {
// Nearest is a tombstone → key deleted.
Some(b) if b.is_tombstone => None,
// Nearest is a scalar/array → take it verbatim.
Some(b) => Some((
b.value.clone(),
Provenance {
owner_kind: b.owner_kind,
owner_id: b.owner_id,
depth: b.depth,
scope: b.scope.clone(),
merged_from: Vec::new(),
},
)),
// No rows at all (caller only passes non-empty groups).
None => None,
}
}
/// Resolve a full candidate set (mixed keys, any order) into the effective
/// config map + per-key provenance. Pure — unit-tested without Postgres.
#[must_use]
pub fn resolve(
candidates: Vec<Candidate>,
) -> (BTreeMap<String, Value>, BTreeMap<String, Provenance>) {
let mut by_key: BTreeMap<String, Vec<Candidate>> = BTreeMap::new();
for c in candidates {
by_key.entry(c.key.clone()).or_default().push(c);
}
let mut values = BTreeMap::new();
let mut provenance = BTreeMap::new();
for (key, rows) in by_key {
if let Some((v, p)) = resolve_one(rows) {
values.insert(key.clone(), v);
provenance.insert(key, p);
}
}
(values, provenance)
}
/// Pull every env-eligible `vars` candidate for `app_id`: the app's own
/// rows + every ancestor group's rows, filtered to `*` or the app's
/// environment, ordered nearest-first.
///
/// # Errors
/// Propagates sqlx errors.
pub async fn fetch_var_candidates(
pool: &PgPool,
app_id: AppId,
) -> Result<Vec<Candidate>, sqlx::Error> {
let sql = format!("{CHAIN_LEVELS_CTE} {VAR_CANDIDATES_TAIL}");
let rows = sqlx::query_as::<_, VarCandidateRow>(&sql)
.bind(app_id.into_inner())
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
/// Transaction-scoped twin of [`fetch_var_candidates`]: runs the identical
/// `CHAIN_LEVELS_CTE` query against an open transaction, so it sees vars
/// **written earlier in the same transaction** (not just committed state).
/// Used by template expansion so a `{var:NAME}` placeholder resolves against
/// a var set in the *same* `pic apply` rather than landing one apply late.
///
/// # Errors
/// Propagates sqlx errors.
pub async fn fetch_var_candidates_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
app_id: AppId,
) -> Result<Vec<Candidate>, sqlx::Error> {
let sql = format!("{CHAIN_LEVELS_CTE} {VAR_CANDIDATES_TAIL}");
let rows = sqlx::query_as::<_, VarCandidateRow>(&sql)
.bind(app_id.into_inner())
.fetch_all(&mut **tx)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
/// The `SELECT … FROM chain …` tail appended after [`CHAIN_LEVELS_CTE`] to
/// pull env-eligible `vars` candidates, nearest-first. Shared by the pool and
/// transaction variants so the two can't drift.
const VAR_CANDIDATES_TAIL: &str = "\
SELECT c.depth, \
CASE WHEN v.app_id IS NOT NULL THEN 'app' ELSE 'group' END AS owner_kind, \
COALESCE(v.app_id, v.group_id) AS owner_id, \
v.environment_scope, v.key, v.value, v.is_tombstone \
FROM chain c \
JOIN vars v ON (v.app_id = c.app_owner OR v.group_id = c.group_owner) \
WHERE v.environment_scope = '*' OR v.environment_scope = c.app_env \
ORDER BY c.depth ASC";
/// The masked, resolved view of one inherited secret for `config/effective`:
/// which owner/level/scope supplies it — **never** the value.
#[derive(Debug, Clone)]
pub struct EffectiveSecretMeta {
pub name: String,
pub owner_kind: OwnerKind,
pub owner_id: Uuid,
pub scope: String,
pub depth: i32,
}
/// Resolve the *names* of every secret an app effectively sees — its own
/// plus every ancestor group's, env-filtered, nearest-wins per name. Returns
/// masked metadata only (owner/level/scope), so it's safe for an app-level
/// principal to read. `DISTINCT ON (name)` with the same ordering as the
/// per-name secret resolver guarantees the same winner.
///
/// # Errors
/// Propagates sqlx errors.
pub async fn fetch_effective_secret_meta(
pool: &PgPool,
app_id: AppId,
) -> Result<Vec<EffectiveSecretMeta>, sqlx::Error> {
let sql = format!(
"{CHAIN_LEVELS_CTE} \
SELECT DISTINCT ON (s.name) s.name, \
CASE WHEN s.app_id IS NOT NULL THEN 'app' ELSE 'group' END AS owner_kind, \
COALESCE(s.app_id, s.group_id) AS owner_id, \
s.environment_scope, c.depth \
FROM chain c \
JOIN secrets s ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \
WHERE s.environment_scope = '*' OR s.environment_scope = c.app_env \
ORDER BY s.name ASC, c.depth ASC, (s.environment_scope <> '*') DESC"
);
let rows: Vec<(String, String, Uuid, String, i32)> = sqlx::query_as(&sql)
.bind(app_id.into_inner())
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(
|(name, owner_kind, owner_id, scope, depth)| EffectiveSecretMeta {
name,
owner_kind: if owner_kind == "app" {
OwnerKind::App
} else {
OwnerKind::Group
},
owner_id,
scope,
depth,
},
)
.collect())
}
#[derive(sqlx::FromRow)]
struct VarCandidateRow {
depth: i32,
owner_kind: String,
owner_id: Uuid,
environment_scope: String,
key: String,
value: Value,
is_tombstone: bool,
}
impl From<VarCandidateRow> for Candidate {
fn from(r: VarCandidateRow) -> Self {
Self {
depth: r.depth,
owner_kind: if r.owner_kind == "app" {
OwnerKind::App
} else {
OwnerKind::Group
},
owner_id: r.owner_id,
scope: r.environment_scope,
key: r.key,
value: r.value,
is_tombstone: r.is_tombstone,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn cand(depth: i32, scope: &str, key: &str, value: Value) -> Candidate {
Candidate {
depth,
owner_kind: if depth == 0 {
OwnerKind::App
} else {
OwnerKind::Group
},
owner_id: Uuid::nil(),
scope: scope.into(),
key: key.into(),
value,
is_tombstone: false,
}
}
fn tomb(depth: i32, scope: &str, key: &str) -> Candidate {
Candidate {
is_tombstone: true,
value: Value::Null,
..cand(depth, scope, key, Value::Null)
}
}
#[test]
fn nearest_level_wins() {
// app (depth 0) overrides group (depth 2).
let (v, p) = resolve(vec![
cand(2, "*", "region", json!("eu")),
cand(0, "*", "region", json!("us")),
]);
assert_eq!(v["region"], json!("us"));
assert_eq!(p["region"].depth, 0);
}
#[test]
fn env_scoped_beats_agnostic_within_a_level() {
let (v, p) = resolve(vec![
cand(1, "*", "db_url", json!("default")),
cand(1, "staging", "db_url", json!("staging-db")),
]);
assert_eq!(v["db_url"], json!("staging-db"));
assert_eq!(p["db_url"].scope, "staging");
}
#[test]
fn proximity_beats_env_specificity_across_levels() {
// §3.2 deliberately-novel call: a leaf's `*` beats an ancestor's `@E`.
let (v, _) = resolve(vec![
cand(2, "production", "db_url", json!("anc-prod")),
cand(0, "*", "db_url", json!("leaf-default")),
]);
assert_eq!(v["db_url"], json!("leaf-default"));
}
#[test]
fn maps_deep_merge_nearest_wins() {
// ancestor sets {title, region}; leaf overrides title, adds locale.
let (v, p) = resolve(vec![
cand(2, "*", "ui", json!({"title": "Base", "region": "eu"})),
cand(0, "*", "ui", json!({"title": "Leaf", "locale": "en"})),
]);
assert_eq!(
v["ui"],
json!({"title": "Leaf", "region": "eu", "locale": "en"})
);
assert_eq!(p["ui"].merged_from.len(), 2);
}
#[test]
fn same_level_env_map_suppresses_agnostic_map_no_merge() {
// §3 step 1: within ONE level, `@E` is not a merge layer over `*` — it
// shadows it. A level holding both an `@staging` map and a `*` map must
// resolve to the `@staging` map alone, never a deep-merge of the two.
let (v, p) = resolve(vec![
cand(1, "*", "cfg", json!({"a": 1, "shared": "default"})),
cand(1, "staging", "cfg", json!({"shared": "staging"})),
]);
assert_eq!(v["cfg"], json!({"shared": "staging"}));
assert_eq!(p["cfg"].scope, "staging");
// Provenance must not list the suppressed `*` layer.
assert_eq!(p["cfg"].merged_from, vec![(1, "staging".to_string())]);
}
#[test]
fn same_level_env_map_then_ancestor_map_merges_without_agnostic_sibling() {
// The suppressed same-level `*` must also be invisible to cross-level
// merge: leaf `@staging` map merges onto the ancestor map, but the
// leaf's own `*` map (shadowed at its level) never contributes.
let (v, _) = resolve(vec![
cand(2, "*", "cfg", json!({"region": "eu", "tier": "base"})),
cand(0, "*", "cfg", json!({"leak": "should-not-appear"})),
cand(0, "staging", "cfg", json!({"tier": "leaf"})),
]);
assert_eq!(v["cfg"], json!({"region": "eu", "tier": "leaf"}));
assert!(!v["cfg"].as_object().unwrap().contains_key("leak"));
}
#[test]
fn nearer_scalar_replaces_whole_inherited_map() {
let (v, _) = resolve(vec![
cand(2, "*", "x", json!({"a": 1})),
cand(0, "*", "x", json!("scalar")),
]);
assert_eq!(v["x"], json!("scalar"));
}
#[test]
fn tombstone_suppresses_inherited_key() {
let (v, _) = resolve(vec![
cand(2, "*", "secret_flag", json!(true)),
tomb(0, "*", "secret_flag"),
]);
assert!(!v.contains_key("secret_flag"));
}
#[test]
fn json_null_is_a_value_not_a_deletion() {
let (v, _) = resolve(vec![cand(0, "*", "k", Value::Null)]);
assert!(v.contains_key("k"));
assert_eq!(v["k"], Value::Null);
}
#[test]
fn map_merge_stops_at_a_nearer_scalar_boundary() {
// nearest map, then a scalar, then a farther map: only the
// contiguous top map run merges; the scalar bounds it.
let (v, _) = resolve(vec![
cand(3, "*", "m", json!({"deep": 1})),
cand(2, "*", "m", json!("scalar-boundary")),
cand(0, "*", "m", json!({"near": 2})),
]);
// depth 0 map is the only one above the depth-2 scalar boundary.
assert_eq!(v["m"], json!({"near": 2}));
}
}

View File

@@ -29,9 +29,9 @@ use picloud_executor_core::{
};
use picloud_orchestrator_core::{ExecutionGate, ExecutorClient};
use picloud_shared::{
DeadLetterId, ExecResponseSummary, ExecutionId, ExecutionLogSink, ExecutionSource,
AppId, DeadLetterId, ExecResponseSummary, ExecutionId, ExecutionLogSink, ExecutionSource,
HttpDispatchPayload, InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult,
RequestId, ScriptId, ScriptSandbox, TriggerEvent,
RequestId, Script, ScriptId, ScriptOwner, ScriptSandbox, TriggerEvent,
};
use rand::Rng;
use uuid::Uuid;
@@ -191,6 +191,32 @@ impl ExecLogContext {
}
impl Dispatcher {
/// Phase 4 isolation backstop: may `script` run under `app_id`'s context?
/// True when the script is owned by `app_id` directly, or by a group on
/// its ancestor chain (an inherited group script). App-owned is the
/// in-memory fast path — only a group-owned script pays the chain query,
/// and a query error fails closed (not invocable). This generalizes the
/// pre-Phase-4 same-app guard (`script.app_id == row.app_id`) to the
/// hierarchy without changing behavior for app-owned scripts.
async fn script_invocable(&self, script: &Script, app_id: AppId) -> bool {
if script.is_owned_by_app(app_id) {
return true;
}
if script.group_id.is_none() {
return false;
}
match self.scripts.is_invocable_by_app(script.id, app_id).await {
Ok(ok) => ok,
Err(e) => {
tracing::error!(
error = %e, script_id = %script.id, %app_id,
"chain-membership check failed; treating script as not invocable"
);
false
}
}
}
/// Spawn the dispatcher loop as a detached `tokio::task`. Also
/// spawns the v1.1.9 queue visibility-timeout reclaim task. Both
/// run for the process lifetime; returned `JoinHandle`s are
@@ -359,9 +385,14 @@ impl Dispatcher {
// existing consumers. Dead-letter the message so the misfire is
// observable rather than executing one app's script under
// another app's SdkCallCx.
if script.app_id != claimed.app_id {
// Phase 4: the target must be invocable in the claimed message's app
// context — owned by that app or an inherited group script on its
// chain. App-owned scripts take the in-memory fast path; only a group
// script pays the chain query, and a non-member fails closed
// (dead-lettered, not silently run under another app's SdkCallCx).
if !self.script_invocable(&script, claimed.app_id).await {
tracing::error!(
script_app = %script.app_id,
script_app = ?script.app_id,
claim_app = %claimed.app_id,
script_id = %consumer.script_id,
"queue consumer script belongs to a different app; dead-lettering"
@@ -440,6 +471,7 @@ impl Dispatcher {
rest: String::new(),
sandbox_overrides: script.sandbox,
app_id: claimed.app_id,
script_owner: script.owner(),
principal: Some(principal),
trigger_depth: 0,
root_execution_id: execution_id,
@@ -786,7 +818,9 @@ impl Dispatcher {
// boundary. Not reachable via the trigger-create or `apply` paths
// (both resolve the script within the app's own scope), so this is
// the runtime backstop the other arms already carry.
if script.app_id != row.app_id {
// Phase 4: invocable in the outbox row's app context (app-owned or an
// inherited group script on the chain); a non-member fails closed.
if !self.script_invocable(&script, row.app_id).await {
return Err(DispatcherError::ResolveTrigger(
"trigger outbox target belongs to a different app".into(),
));
@@ -797,6 +831,7 @@ impl Dispatcher {
is_dead_letter_handler: matches!(trigger.kind, TriggerKind::DeadLetter),
active: trigger.enabled && script.enabled,
script_id: script.id,
script_owner: script.owner(),
script_source: script.source,
script_name: script.name,
script_updated_at: script.updated_at,
@@ -839,6 +874,7 @@ impl Dispatcher {
rest: String::new(),
sandbox_overrides: resolved.sandbox_overrides,
app_id: row.app_id,
script_owner: resolved.script_owner,
principal: Some(principal),
trigger_depth: row.trigger_depth,
root_execution_id: row.root_execution_id.unwrap_or(execution_id),
@@ -876,7 +912,8 @@ impl Dispatcher {
// build_invoke_request and dispatch_one_queue. A hand-edited
// outbox row could otherwise execute one app's script under
// another app's SdkCallCx.
if script.app_id != row.app_id {
// Phase 4: invocable in this app's context (see queue/trigger arms).
if !self.script_invocable(&script, row.app_id).await {
return Err(DispatcherError::ResolveTrigger(
"http outbox target belongs to a different app".into(),
));
@@ -901,6 +938,7 @@ impl Dispatcher {
rest: payload.rest,
sandbox_overrides: script.sandbox,
app_id: row.app_id,
script_owner: script.owner(),
// HTTP outbox rows don't run as the trigger registrant —
// they run with no principal (public ingress) or the
// attached one (origin_principal forensic field is not
@@ -919,6 +957,7 @@ impl Dispatcher {
// enqueue is dropped at fire time by the post-match active check.
active: script.enabled,
script_id,
script_owner: script.owner(),
script_source: script.source,
script_name: payload.script_name,
script_updated_at: script.updated_at,
@@ -969,7 +1008,8 @@ impl Dispatcher {
})?;
// Same-app guard — the script could have been re-assigned or
// the row could have been hand-edited. Reject mismatches.
if script.app_id != row.app_id {
// Phase 4: invocable in this app's context (app-owned or inherited).
if !self.script_invocable(&script, row.app_id).await {
return Err(DispatcherError::ResolveTrigger(
"invoke target belongs to a different app".into(),
));
@@ -1000,6 +1040,7 @@ impl Dispatcher {
rest: String::new(),
sandbox_overrides: script.sandbox,
app_id: row.app_id,
script_owner: script.owner(),
// invoke_async runs with no principal — same as HTTP outbox.
principal: None,
trigger_depth,
@@ -1019,6 +1060,7 @@ impl Dispatcher {
// enqueue is dropped at fire time by the post-match active check.
active: script.enabled,
script_id: script.id,
script_owner: script.owner(),
script_source: script.source,
script_name: script.name,
script_updated_at: script.updated_at,
@@ -1321,6 +1363,10 @@ pub struct ResolvedTrigger {
pub script_id: ScriptId,
pub script_source: String,
pub script_name: String,
/// Phase 4b: the resolved script's defining node — the lexical origin
/// for its `import`s (§5.5). `None` only for a corrupt owner row; the
/// executor then falls back to `App(app_id)`.
pub script_owner: Option<ScriptOwner>,
/// v1.1.3: freshness comparator for the orchestrator's top-level
/// script cache. The dispatcher hands `(script_id, updated_at)`
/// in alongside the source so cached ASTs can be reused across
@@ -1640,6 +1686,20 @@ mod tests {
) -> Result<Option<Script>, ScriptRepositoryError> {
unimplemented!("not used by this test")
}
async fn get_by_name_inherited(
&self,
_app_id: AppId,
_name: &str,
) -> Result<Option<Script>, ScriptRepositoryError> {
unimplemented!("not used by this test")
}
async fn is_invocable_by_app(
&self,
_script_id: ScriptId,
app_id: AppId,
) -> Result<bool, ScriptRepositoryError> {
Ok(self.script.is_owned_by_app(app_id))
}
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
unimplemented!("not used by this test")
}
@@ -1649,6 +1709,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,
@@ -2097,7 +2163,8 @@ mod tests {
pub(super) fn disabled_script(app_id: AppId) -> Script {
Script {
id: ScriptId::new(),
app_id,
app_id: Some(app_id),
group_id: None,
name: "worker".into(),
description: None,
version: 1,

View File

@@ -14,7 +14,7 @@
//! future CLI/orchestrator can detect structural drift (§6).
use async_trait::async_trait;
use picloud_shared::{Group, GroupId};
use picloud_shared::{AppId, Group, GroupId};
use sqlx::PgPool;
use uuid::Uuid;
@@ -191,27 +191,11 @@ impl GroupRepository for PostgresGroupRepository {
description: Option<&str>,
parent_id: Option<GroupId>,
) -> Result<Group, GroupRepositoryError> {
let res = sqlx::query_as::<_, GroupRow>(&format!(
"INSERT INTO groups (slug, name, description, parent_id) \
VALUES ($1, $2, $3, $4) \
RETURNING {GROUP_COLS}"
))
.bind(slug)
.bind(name)
.bind(description)
.bind(parent_id.map(GroupId::into_inner))
.fetch_one(&self.pool)
.await;
match res {
Ok(row) => Ok(row.into()),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
GroupRepositoryError::Conflict(format!("slug {slug:?} is already in use")),
),
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => Err(
GroupRepositoryError::Conflict("parent group does not exist".into()),
),
Err(e) => Err(e.into()),
}
let mut tx = self.pool.begin().await?;
// Interactive create: UI/API-owned (no project claim — §7.5).
let g = create_group_tx(&mut tx, slug, name, description, parent_id, None).await?;
tx.commit().await?;
Ok(g)
}
async fn rename(
@@ -247,96 +231,342 @@ impl GroupRepository for PostgresGroupRepository {
) -> Result<Group, GroupRepositoryError> {
let mut tx = self.pool.begin().await?;
// Coarse structural lock: serialize all structural mutations so the
// cycle guard + parent write can't interleave with a concurrent
// reparent and race into a cycle.
sqlx::query("SELECT pg_advisory_xact_lock($1)")
.bind(GROUP_STRUCTURAL_LOCK_KEY)
.execute(&mut *tx)
.await?;
if let Some(parent) = new_parent {
if parent == id {
return Err(GroupRepositoryError::Conflict(
"a group cannot be its own parent".into(),
));
}
// Cycle guard: walk from the destination up to the root; if we
// reach `id`, the move would place `id` beneath itself.
let mut cursor = Some(parent);
let mut hops = 0u32;
while let Some(node) = cursor {
if node == id {
return Err(GroupRepositoryError::Conflict(
"cannot reparent a group beneath one of its own descendants".into(),
));
}
hops += 1;
if hops > 64 {
return Err(GroupRepositoryError::Conflict(
"group ancestry exceeds the maximum depth".into(),
));
}
let parent_of: Option<(Option<Uuid>,)> =
sqlx::query_as("SELECT parent_id FROM groups WHERE id = $1")
.bind(node.into_inner())
.fetch_optional(&mut *tx)
.await?;
match parent_of {
Some((p,)) => cursor = p.map(GroupId::from),
// Destination parent doesn't exist.
None => {
return Err(GroupRepositoryError::Conflict(
"destination parent group does not exist".into(),
));
}
}
}
}
let row = sqlx::query_as::<_, GroupRow>(&format!(
"UPDATE groups SET \
parent_id = $2, \
structure_version = structure_version + 1, \
updated_at = NOW() \
WHERE id = $1 \
RETURNING {GROUP_COLS}"
))
.bind(id.into_inner())
.bind(new_parent.map(GroupId::into_inner))
.fetch_optional(&mut *tx)
.await?;
let Some(row) = row else {
return Err(GroupRepositoryError::NotFound(id));
};
// cycle guard + parent write can't race a concurrent reparent.
acquire_structural_lock_tx(&mut tx).await?;
let g = reparent_group_tx(&mut tx, id, new_parent).await?;
tx.commit().await?;
Ok(row.into())
Ok(g)
}
async fn delete(&self, id: GroupId) -> Result<(), GroupRepositoryError> {
// Pre-check for a clean message; the FK RESTRICT is the real guard.
let counts = self.child_counts(id).await?;
if !counts.is_empty() {
return Err(GroupRepositoryError::Conflict(format!(
"group still has {} subgroup(s) and {} app(s); move or delete them first",
counts.subgroups, counts.apps
)));
let mut tx = self.pool.begin().await?;
delete_group_tx(&mut tx, id).await?;
tx.commit().await?;
Ok(())
}
}
// ----------------------------------------------------------------------------
// Transaction-aware structural mutations (Phase 5+ declarative tree apply).
//
// The declarative `apply_tree` reconciles a whole subtree in ONE transaction,
// so group create/reparent/delete must run inside the caller's tx (not on the
// pool) to stay all-or-nothing. These free functions hold the SQL; the trait
// methods above delegate to them (begin → fn → commit) so there is one SQL
// definition each. The caller takes [`acquire_structural_lock_tx`] ONCE before
// the structure phase, so the cycle guard + parent writes serialize against
// concurrent reparents exactly as the single-shot `reparent` does.
// ----------------------------------------------------------------------------
/// Take the coarse structural lock inside the caller's transaction. Held until
/// the tx commits/rolls back. Call once before any `*_group_tx` mutation.
pub(crate) async fn acquire_structural_lock_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<(), GroupRepositoryError> {
sqlx::query("SELECT pg_advisory_xact_lock($1)")
.bind(GROUP_STRUCTURAL_LOCK_KEY)
.execute(&mut **tx)
.await?;
Ok(())
}
/// Insert a group inside the caller's tx. Maps unique/FK violations to a clean
/// conflict. (The structural lock is not required for a pure insert, but the
/// apply path holds it for the whole phase anyway.)
pub(crate) async fn create_group_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
slug: &str,
name: &str,
description: Option<&str>,
parent_id: Option<GroupId>,
owner_project: Option<Uuid>,
) -> Result<Group, GroupRepositoryError> {
let res = sqlx::query_as::<_, GroupRow>(&format!(
"INSERT INTO groups (slug, name, description, parent_id, owner_project) \
VALUES ($1, $2, $3, $4, $5) RETURNING {GROUP_COLS}"
))
.bind(slug)
.bind(name)
.bind(description)
.bind(parent_id.map(GroupId::into_inner))
.bind(owner_project)
.fetch_one(&mut **tx)
.await;
match res {
Ok(row) => Ok(row.into()),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
GroupRepositoryError::Conflict(format!("slug {slug:?} is already in use")),
),
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => Err(
GroupRepositoryError::Conflict("parent group does not exist".into()),
),
Err(e) => Err(e.into()),
}
}
/// Reparent a group inside the caller's tx. Runs the ancestor-walk cycle guard
/// against the IN-TX tree (so it sees parent writes made earlier in the same
/// apply). The caller must already hold [`acquire_structural_lock_tx`].
pub(crate) async fn reparent_group_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
id: GroupId,
new_parent: Option<GroupId>,
) -> Result<Group, GroupRepositoryError> {
if let Some(parent) = new_parent {
if parent == id {
return Err(GroupRepositoryError::Conflict(
"a group cannot be its own parent".into(),
));
}
let res = sqlx::query("DELETE FROM groups WHERE id = $1")
.bind(id.into_inner())
.execute(&self.pool)
.await;
match res {
Ok(r) if r.rows_affected() == 0 => Err(GroupRepositoryError::NotFound(id)),
Ok(_) => Ok(()),
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
// Lost a race with a concurrent child insert.
Err(GroupRepositoryError::Conflict(
"group still has descendants; move or delete them first".into(),
))
let mut cursor = Some(parent);
let mut hops = 0u32;
while let Some(node) = cursor {
if node == id {
return Err(GroupRepositoryError::Conflict(
"cannot reparent a group beneath one of its own descendants".into(),
));
}
hops += 1;
if hops > 64 {
return Err(GroupRepositoryError::Conflict(
"group ancestry exceeds the maximum depth".into(),
));
}
let parent_of: Option<(Option<Uuid>,)> =
sqlx::query_as("SELECT parent_id FROM groups WHERE id = $1")
.bind(node.into_inner())
.fetch_optional(&mut **tx)
.await?;
match parent_of {
Some((p,)) => cursor = p.map(GroupId::from),
None => {
return Err(GroupRepositoryError::Conflict(
"destination parent group does not exist".into(),
));
}
}
Err(e) => Err(e.into()),
}
}
let row = sqlx::query_as::<_, GroupRow>(&format!(
"UPDATE groups SET parent_id = $2, structure_version = structure_version + 1, \
updated_at = NOW() WHERE id = $1 RETURNING {GROUP_COLS}"
))
.bind(id.into_inner())
.bind(new_parent.map(GroupId::into_inner))
.fetch_optional(&mut **tx)
.await?;
row.map(Into::into)
.ok_or(GroupRepositoryError::NotFound(id))
}
/// Delete an empty group inside the caller's tx (delete = RESTRICT). Refused
/// with a clean conflict if it still has child groups or apps.
pub(crate) async fn delete_group_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
id: GroupId,
) -> Result<(), GroupRepositoryError> {
let counts: (i64, i64) = sqlx::query_as(
"SELECT (SELECT COUNT(*) FROM groups WHERE parent_id = $1), \
(SELECT COUNT(*) FROM apps WHERE group_id = $1)",
)
.bind(id.into_inner())
.fetch_one(&mut **tx)
.await?;
if counts.0 != 0 || counts.1 != 0 {
return Err(GroupRepositoryError::Conflict(format!(
"group still has {} subgroup(s) and {} app(s); move or delete them first",
counts.0, counts.1
)));
}
let res = sqlx::query("DELETE FROM groups WHERE id = $1")
.bind(id.into_inner())
.execute(&mut **tx)
.await;
match res {
Ok(r) if r.rows_affected() == 0 => Err(GroupRepositoryError::NotFound(id)),
Ok(_) => Ok(()),
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
Err(GroupRepositoryError::Conflict(
"group still has descendants; move or delete them first".into(),
))
}
Err(e) => Err(e.into()),
}
}
// ----------------------------------------------------------------------------
// Project ownership (§7, M3). A `projects` row is keyed by the stable, opaque
// key the CLI mints in `.picloud/`; `groups.owner_project` references it. These
// helpers are free functions (pool + tx variants) so the apply path can claim
// ownership inside the single apply transaction (first-commit-wins), while the
// read-only plan path can surface conflicts without writing.
// ----------------------------------------------------------------------------
/// Resolve a project key to its id, if the project has ever been persisted.
/// `None` means no apply has claimed under this key yet (so `plan` treats every
/// node as claimable). Read-only — used by the plan path.
pub(crate) async fn get_project_id_by_key(
pool: &PgPool,
key: &str,
) -> Result<Option<Uuid>, GroupRepositoryError> {
let row: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM projects WHERE key = $1")
.bind(key)
.fetch_optional(pool)
.await?;
Ok(row.map(|r| r.0))
}
/// Upsert the project keyed by `key` inside the apply tx and return its id.
/// Idempotent: re-applying the same repo reuses the same project identity.
pub(crate) async fn upsert_project_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
key: &str,
) -> Result<Uuid, GroupRepositoryError> {
let row: (Uuid,) = sqlx::query_as(
"INSERT INTO projects (key) VALUES ($1) \
ON CONFLICT (key) DO UPDATE SET key = EXCLUDED.key RETURNING id",
)
.bind(key)
.fetch_one(&mut **tx)
.await?;
Ok(row.0)
}
/// The project that owns `group_id`, as `(project_id, project_key)`. `None` when
/// the node is UI/API-owned (no claim). Read-only — used by plan + the in-tx
/// authoritative re-check. Joins through `projects` so the caller gets the
/// human-facing key for a conflict message.
pub(crate) async fn get_group_owner(
pool: &PgPool,
group_id: GroupId,
) -> Result<Option<(Uuid, String)>, GroupRepositoryError> {
let row: Option<(Uuid, String)> = sqlx::query_as(
"SELECT p.id, p.key FROM groups g \
JOIN projects p ON p.id = g.owner_project WHERE g.id = $1",
)
.bind(group_id.into_inner())
.fetch_optional(pool)
.await?;
Ok(row)
}
/// The same owner lookup, but inside the apply tx — the authoritative read that
/// the claim/conflict decision keys on (so a concurrent claim that committed
/// after `plan` is seen under this tx's per-node advisory lock).
pub(crate) async fn get_group_owner_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
) -> Result<Option<(Uuid, String)>, GroupRepositoryError> {
let row: Option<(Uuid, String)> = sqlx::query_as(
"SELECT p.id, p.key FROM groups g \
JOIN projects p ON p.id = g.owner_project WHERE g.id = $1",
)
.bind(group_id.into_inner())
.fetch_optional(&mut **tx)
.await?;
Ok(row)
}
/// Stamp (claim or take over) `group_id`'s owning project inside the apply tx.
/// Bumps `structure_version` so an ownership flip trips a bound plan token.
pub(crate) async fn set_group_owner_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
project_id: Uuid,
) -> Result<(), GroupRepositoryError> {
let res = sqlx::query(
"UPDATE groups SET owner_project = $2, \
structure_version = structure_version + 1, updated_at = NOW() WHERE id = $1",
)
.bind(group_id.into_inner())
.bind(project_id)
.execute(&mut **tx)
.await?;
if res.rows_affected() == 0 {
return Err(GroupRepositoryError::NotFound(group_id));
}
Ok(())
}
/// Every group `(id, slug)` owned by `project_id` — the candidate set for
/// structural prune (those absent from the manifest are the ones to delete).
/// Read-only; the apply path filters and deletes leaf-first under `delete_tx`.
pub(crate) async fn list_owned_groups(
pool: &PgPool,
project_id: Uuid,
) -> Result<Vec<(GroupId, String)>, GroupRepositoryError> {
let rows: Vec<(Uuid, String)> =
sqlx::query_as("SELECT id, slug FROM groups WHERE owner_project = $1")
.bind(project_id)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|(id, slug)| (id.into(), slug))
.collect())
}
/// The same owned-group enumeration inside the apply tx, with each node's
/// `parent_id` so the prune can delete leaf-first (children before parents).
pub(crate) async fn list_owned_groups_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
project_id: Uuid,
) -> Result<Vec<(GroupId, String, Option<GroupId>)>, GroupRepositoryError> {
let rows: Vec<(Uuid, String, Option<Uuid>)> =
sqlx::query_as("SELECT id, slug, parent_id FROM groups WHERE owner_project = $1")
.bind(project_id)
.fetch_all(&mut **tx)
.await?;
Ok(rows
.into_iter()
.map(|(id, slug, parent)| (id.into(), slug, parent.map(GroupId::from)))
.collect())
}
/// Recursive descendant-app query, the inverse of the ancestor `CHAIN_LEVELS_CTE`
/// in `config_resolver`: walk `groups.parent_id` DOWN from `$1` to collect the
/// whole subtree of group ids, then every app whose `group_id` falls in it.
/// Ordered by `app_id` so callers lock/iterate deterministically. Depth-bounded
/// `< 64` as a runaway guard (the cycle guard already forbids real cycles).
const DESCENDANT_APPS_SQL: &str = "\
WITH RECURSIVE subtree AS ( \
SELECT id, 0 AS depth FROM groups WHERE id = $1 \
UNION ALL \
SELECT g.id, s.depth + 1 FROM groups g JOIN subtree s ON g.parent_id = s.id \
WHERE s.depth < 64 \
) \
SELECT a.id FROM apps a WHERE a.group_id IN (SELECT id FROM subtree) ORDER BY a.id";
/// Every app in the subtree rooted at `group_id` (the group itself and all
/// descendant groups), `app_id`-ordered. The read-only pool variant — used by
/// `plan` to size a template's true blast radius across the whole DB subtree,
/// not just the apps present in the current apply.
///
/// # Errors
/// Propagates sqlx errors.
pub async fn descendant_app_ids(
pool: &PgPool,
group_id: GroupId,
) -> Result<Vec<AppId>, GroupRepositoryError> {
let rows: Vec<(Uuid,)> = sqlx::query_as(DESCENDANT_APPS_SQL)
.bind(group_id.into_inner())
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(|(id,)| id.into()).collect())
}
/// The same subtree enumeration inside the apply tx, so groups/apps created
/// earlier in this transaction (M2 structural reconcile) are included when
/// fanning a template out to its descendants (§4.5, M7).
pub(crate) async fn descendant_app_ids_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
) -> Result<Vec<AppId>, GroupRepositoryError> {
let rows: Vec<(Uuid,)> = sqlx::query_as(DESCENDANT_APPS_SQL)
.bind(group_id.into_inner())
.fetch_all(&mut **tx)
.await?;
Ok(rows.into_iter().map(|(id,)| id.into()).collect())
}
#[derive(sqlx::FromRow)]

View File

@@ -0,0 +1,225 @@
//! `/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 4b: group modules + imports are allowed. Imports resolve
// lexically from this group's chain at runtime (§5.5); the recorded
// edges feed the declarative dangling-import plan check.
let validated = s.validator.validate(&input.source)?;
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: input.kind,
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,
imports: validated.imports,
})
.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

@@ -80,7 +80,11 @@ impl InvokeServiceImpl {
.await
.map_err(|e| InvokeError::Backend(e.to_string()))?
.ok_or_else(|| InvokeError::NotFound(format!("id {script_id}")))?;
if script.app_id != cx.app_id {
// Phase 4: group scripts (`app_id: None`) fail closed here — invoke()
// by-id stays app-owned until chain-aware resolution lands. The
// execution context is the *caller's* app (`cx.app_id`), never read
// off the target script.
if !script.is_owned_by_app(cx.app_id) {
return Err(InvokeError::CrossApp);
}
if !script.enabled {
@@ -90,7 +94,8 @@ impl InvokeServiceImpl {
}
Ok(ResolvedScript {
script_id: script.id,
app_id: script.app_id,
app_id: cx.app_id,
owner: script.owner(),
source: script.source,
updated_at: script.updated_at,
name: script.name,
@@ -102,9 +107,13 @@ impl InvokeServiceImpl {
cx: &SdkCallCx,
name: &str,
) -> Result<ResolvedScript, InvokeError> {
// Phase 4: resolve down the app's chain — an inherited group script
// of this name is reachable, nearest-owner-wins (an app's own script
// shadows it). The chain only contains the app + its ancestors, so the
// resolved script is invocable by `cx.app_id` by construction.
let script = self
.scripts
.get_by_name(cx.app_id, name)
.get_by_name_inherited(cx.app_id, name)
.await
.map_err(|e| InvokeError::Backend(e.to_string()))?
.ok_or_else(|| InvokeError::NotFound(format!("name {name:?}")))?;
@@ -113,7 +122,12 @@ impl InvokeServiceImpl {
}
Ok(ResolvedScript {
script_id: script.id,
app_id: script.app_id,
// The execution-context app is always the caller's app — a group
// script runs under the inheriting app, never its owner.
app_id: cx.app_id,
// …but its `import`s resolve from its *defining node* (the group
// for an inherited script) — the lexical origin (§5.5).
owner: script.owner(),
source: script.source,
updated_at: script.updated_at,
name: script.name,
@@ -227,19 +241,40 @@ mod tests {
name: &str,
) -> Result<Option<Script>, ScriptRepositoryError> {
Ok(
if app_id == self.script.app_id && name == self.script.name {
if self.script.app_id == Some(app_id) && name == self.script.name {
Some(self.script.clone())
} else {
None
},
)
}
async fn get_by_name_inherited(
&self,
app_id: AppId,
name: &str,
) -> Result<Option<Script>, ScriptRepositoryError> {
// This one-script repo has no hierarchy; inherited == own-scope.
self.get_by_name(app_id, name).await
}
async fn is_invocable_by_app(
&self,
script_id: ScriptId,
_app_id: AppId,
) -> Result<bool, ScriptRepositoryError> {
Ok(script_id == self.script.id)
}
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
Ok(vec![self.script.clone()])
}
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,
@@ -312,7 +347,8 @@ mod tests {
fn make_script(app_id: AppId, name: &str) -> Script {
Script {
id: ScriptId::new(),
app_id,
app_id: Some(app_id),
group_id: None,
name: name.into(),
description: None,
version: 1,

View File

@@ -31,6 +31,8 @@ pub mod auth_api;
pub mod auth_bootstrap;
pub mod auth_middleware;
pub mod authz;
pub mod config_api;
pub mod config_resolver;
pub mod cron_scheduler;
pub mod dead_letter_repo;
pub mod dead_letter_service;
@@ -49,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;
@@ -77,6 +80,7 @@ pub mod secrets_api;
pub mod secrets_repo;
pub mod secrets_service;
pub mod ssrf;
pub mod template_repo;
pub mod topic_repo;
pub mod topics_api;
pub mod trigger_config;
@@ -84,6 +88,9 @@ pub mod trigger_repo;
pub mod triggers_api;
pub mod users_admin_api;
pub mod users_service;
pub mod vars_api;
pub mod vars_repo;
pub mod vars_service;
pub use abandoned_repo::{
AbandonedRepo, AbandonedRepoError, NewAbandonedExecution, PostgresAbandonedRepo,
@@ -146,6 +153,7 @@ pub use auth_middleware::{
API_KEY_PREFIX, API_KEY_PREFIX_LEN,
};
pub use authz::{can, require, AuthzDenied, AuthzError, AuthzRepo, Capability, Decision};
pub use config_api::{config_router, ConfigApiError, ConfigApiState};
pub use cron_scheduler::spawn_cron_scheduler;
pub use dead_letter_repo::{
DeadLetterRepo, DeadLetterRepoError, DeadLetterRow, NewDeadLetter, PostgresDeadLetterRepo,
@@ -176,6 +184,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};
@@ -200,11 +209,11 @@ pub use route_repo::{NewRoute, PostgresRouteRepository, RouteRepository};
pub use sandbox::{CeilingError, SandboxCeiling};
pub use secrets_api::{secrets_router, SecretsApiError, SecretsState};
pub use secrets_repo::{
PostgresSecretsRepo, SecretMeta, SecretsMetaPage, SecretsNamePage, SecretsRepo,
PostgresSecretsRepo, ResolvedSecret, SecretMeta, SecretsMetaPage, SecretsNamePage, SecretsRepo,
SecretsRepoError, StoredSecret,
};
pub use secrets_service::{
open as open_secret, seal as seal_secret, SecretsConfig, SecretsServiceImpl,
open as open_secret, seal as seal_secret, SecretOwner, SecretsConfig, SecretsServiceImpl,
DEFAULT_SECRET_MAX_VALUE_BYTES,
};
pub use topic_repo::{PostgresTopicRepo, Topic, TopicAuthMode, TopicRepo, TopicRepoError};
@@ -219,3 +228,6 @@ pub use trigger_repo::{
pub use triggers_api::{triggers_router, TriggersApiError, TriggersState};
pub use users_admin_api::{app_users_router, AppUsersApiError, AppUsersState};
pub use users_service::{UsersServiceConfig, UsersServiceImpl};
pub use vars_api::{vars_router, VarsApiError, VarsApiState};
pub use vars_repo::{PostgresVarsRepo, VarOwner, VarRow, VarsRepo, VarsRepoError};
pub use vars_service::VarsServiceImpl;

View File

@@ -1,18 +1,24 @@
//! `PostgresModuleSource` — the Postgres-backed `ModuleSource` impl.
//!
//! Mirrors the structure of [`crate::kv_repo::PostgresKvRepo`] /
//! [`crate::docs_repo::PostgresDocsRepo`]: thin wrapper around a
//! `PgPool` that owns a single statement returning the module by
//! `(cx.app_id, name, kind = 'module')`. The resolver lives in
//! `executor-core` and consumes this trait through the `Services`
//! bundle, so manager-core stays the only crate that touches
//! Postgres.
//! Resolution is **lexical** (§5.5): a module is looked up by walking the
//! group chain rooted at the **importing script's defining node**
//! (`origin`), nearest-owner-wins — *not* the inheriting app's effective
//! view. An `App` origin walks `app → its group → ancestors` (reusing
//! [`CHAIN_LEVELS_CTE`]); a `Group` origin walks `group → ancestors`
//! ([`GROUP_CHAIN_LEVELS_CTE`]) and never sees app-owned modules below it
//! (the trust boundary). The resolver lives in `executor-core` and consumes
//! this trait through the `Services` bundle, so manager-core stays the only
//! crate that touches Postgres.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use picloud_shared::{ModuleScript, ModuleSource, ModuleSourceError, SdkCallCx};
use picloud_shared::{
ExtPointResolution, ModuleScript, ModuleSource, ModuleSourceError, ScriptId, ScriptOwner,
};
use sqlx::PgPool;
use crate::config_resolver::{CHAIN_LEVELS_CTE, GROUP_CHAIN_LEVELS_CTE};
pub struct PostgresModuleSource {
pool: PgPool,
}
@@ -27,7 +33,8 @@ impl PostgresModuleSource {
#[derive(sqlx::FromRow)]
struct ModuleRow {
id: uuid::Uuid,
app_id: uuid::Uuid,
app_id: Option<uuid::Uuid>,
group_id: Option<uuid::Uuid>,
name: String,
source: String,
updated_at: DateTime<Utc>,
@@ -37,7 +44,8 @@ impl From<ModuleRow> for ModuleScript {
fn from(r: ModuleRow) -> Self {
Self {
script_id: r.id.into(),
app_id: r.app_id.into(),
app_id: r.app_id.map(Into::into),
group_id: r.group_id.map(Into::into),
name: r.name,
source: r.source,
updated_at: r.updated_at,
@@ -47,25 +55,132 @@ impl From<ModuleRow> for ModuleScript {
#[async_trait]
impl ModuleSource for PostgresModuleSource {
async fn lookup(
async fn resolve(
&self,
cx: &SdkCallCx,
origin: ScriptOwner,
name: &str,
) -> Result<Option<ModuleScript>, ModuleSourceError> {
// The query is the cross-app isolation boundary: app_id comes
// from cx (never from the script-passed argument), and the
// CHECK constraint `kind IN ('endpoint','module')` plus the
// `kind = 'module'` filter together guarantee endpoint scripts
// are never importable. The `(app_id, kind)` index from
// migration 0015 makes this an index scan returning at most
// one row (per-app uniqueness on `name`).
let row: Option<ModuleRow> = sqlx::query_as(
"SELECT id, app_id, name, source, updated_at \
FROM scripts \
WHERE app_id = $1 AND kind = 'module' AND name = $2",
)
.bind(cx.app_id.into_inner())
.bind(name)
// Lexical lookup: JOIN module scripts against the chain rooted at
// `origin`, take the nearest level (lowest depth). The `kind =
// 'module'` filter + the CHECK constraint guarantee endpoint
// scripts are never importable. Case-insensitive to match the
// per-owner `LOWER(name)` partial-unique indexes (0050). `origin`
// is a trusted dispatch-derived value, so the chain it roots is
// the script's true ancestry — the cross-app isolation boundary
// is preserved (a group origin can't reach an app's modules).
let query = match origin {
ScriptOwner::App(_) => format!(
"{CHAIN_LEVELS_CTE} \
SELECT s.id, s.app_id, s.group_id, s.name, s.source, s.updated_at \
FROM scripts s \
JOIN chain c ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \
WHERE s.kind = 'module' AND LOWER(s.name) = LOWER($2) \
ORDER BY c.depth ASC LIMIT 1",
),
ScriptOwner::Group(_) => format!(
"{GROUP_CHAIN_LEVELS_CTE} \
SELECT s.id, s.app_id, s.group_id, s.name, s.source, s.updated_at \
FROM scripts s \
JOIN chain c ON s.group_id = c.group_owner \
WHERE s.kind = 'module' AND LOWER(s.name) = LOWER($2) \
ORDER BY c.depth ASC LIMIT 1",
),
};
let root = match origin {
ScriptOwner::App(a) => a.into_inner(),
ScriptOwner::Group(g) => g.into_inner(),
};
let row: Option<ModuleRow> = sqlx::query_as(&query)
.bind(root)
.bind(name)
.fetch_optional(&self.pool)
.await
.map_err(|e| ModuleSourceError::Backend(e.to_string()))?;
Ok(row.map(Into::into))
}
async fn resolve_extension_point(
&self,
origin: ScriptOwner,
name: &str,
) -> Result<Option<ExtPointResolution>, ModuleSourceError> {
// Find the nearest extension-point declaration of `name` on `origin`'s
// chain that is NOT shadowed by a concrete module of the same name at
// depth <= its own (tie → concrete wins). The NOT EXISTS encodes that
// shadowing rule; ORDER BY depth ASC LIMIT 1 picks the nearest
// surviving declaration. An App origin can see ext points declared on
// an ancestor group (or on the app itself); a Group origin only walks
// groups — same trust boundary as `resolve`.
let (cte, ep_join, mod_join) = match origin {
ScriptOwner::App(_) => (
CHAIN_LEVELS_CTE,
"(e.group_id = c.group_owner OR e.app_id = c.app_owner)",
"(s.app_id = c2.app_owner OR s.group_id = c2.group_owner)",
),
ScriptOwner::Group(_) => (
GROUP_CHAIN_LEVELS_CTE,
"e.group_id = c.group_owner",
"s.group_id = c2.group_owner",
),
};
let query = format!(
"{cte} \
SELECT e.default_script_id \
FROM extension_points e \
JOIN chain c ON {ep_join} \
WHERE LOWER(e.name) = LOWER($2) \
AND NOT EXISTS ( \
SELECT 1 FROM scripts s \
JOIN chain c2 ON {mod_join} \
WHERE s.kind = 'module' AND LOWER(s.name) = LOWER($2) \
AND c2.depth <= c.depth \
) \
ORDER BY c.depth ASC LIMIT 1",
);
let root = match origin {
ScriptOwner::App(a) => a.into_inner(),
ScriptOwner::Group(g) => g.into_inner(),
};
let row: Option<(Option<uuid::Uuid>,)> = sqlx::query_as(&query)
.bind(root)
.bind(name)
.fetch_optional(&self.pool)
.await
.map_err(|e| ModuleSourceError::Backend(e.to_string()))?;
Ok(row.map(|(default_script_id,)| ExtPointResolution {
default_script_id: default_script_id.map(Into::into),
}))
}
async fn resolve_by_id(
&self,
origin: ScriptOwner,
script_id: ScriptId,
) -> Result<Option<ModuleScript>, ModuleSourceError> {
// Load a module body by id (the ext-point fallback), but ONLY if it is
// owned by a node on `origin`'s chain — the same trust boundary as
// `resolve`. This keeps the resolver self-defending: a `default_script_id`
// that ever pointed off-chain resolves to None instead of executing
// cross-tenant code. Endpoints are never importable (kind filter).
let (cte, join) = match origin {
ScriptOwner::App(_) => (
CHAIN_LEVELS_CTE,
"(s.app_id = c.app_owner OR s.group_id = c.group_owner)",
),
ScriptOwner::Group(_) => (GROUP_CHAIN_LEVELS_CTE, "s.group_id = c.group_owner"),
};
let root = match origin {
ScriptOwner::App(a) => a.into_inner(),
ScriptOwner::Group(g) => g.into_inner(),
};
let row: Option<ModuleRow> = sqlx::query_as(&format!(
"{cte} \
SELECT s.id, s.app_id, s.group_id, s.name, s.source, s.updated_at \
FROM scripts s JOIN chain c ON {join} \
WHERE s.id = $2 AND s.kind = 'module' LIMIT 1"
))
.bind(root)
.bind(script_id.into_inner())
.fetch_optional(&self.pool)
.await
.map_err(|e| ModuleSourceError::Backend(e.to_string()))?;

View File

@@ -3,7 +3,7 @@ use std::collections::BTreeMap;
use async_trait::async_trait;
use picloud_orchestrator_core::{ResolverError, ScriptResolver};
use picloud_shared::{
AdminUserId, AppId, ExecutionLog, ExecutionSource, ExecutionStatus, RequestId, Script,
AdminUserId, AppId, ExecutionLog, ExecutionSource, ExecutionStatus, GroupId, RequestId, Script,
ScriptId, ScriptKind, ScriptSandbox,
};
use sqlx::PgPool;
@@ -34,10 +34,34 @@ pub trait ScriptRepository: Send + Sync {
app_id: AppId,
name: &str,
) -> Result<Option<Script>, ScriptRepositoryError>;
/// Phase 4: resolve `name → Script` down `app_id`'s ownership chain —
/// the app itself, then its ancestor groups nearest-first. Nearest owner
/// wins (an app's own script shadows an inherited group one of the same
/// name: CoW). Returns `None` if no script in the chain has that name.
/// Backs inherited `invoke("name")` and declarative name→id binding.
async fn get_by_name_inherited(
&self,
app_id: AppId,
name: &str,
) -> Result<Option<Script>, ScriptRepositoryError>;
/// Phase 4: is `script_id` invocable in `app_id`'s context? True iff the
/// script is owned by `app_id` directly, OR owned by a group on `app_id`'s
/// ancestor chain. The cross-app isolation predicate generalized to the
/// hierarchy — the runtime backstop for trigger/queue/invoke dispatch and
/// trigger-bind validation. A missing script returns `false` (fail closed).
async fn is_invocable_by_app(
&self,
script_id: ScriptId,
app_id: AppId,
) -> Result<bool, ScriptRepositoryError>;
/// Every script across all apps. Mostly for tests and admin
/// "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
@@ -93,12 +117,32 @@ impl<T: ScriptRepository + ?Sized> ScriptRepository for std::sync::Arc<T> {
) -> Result<Option<Script>, ScriptRepositoryError> {
(**self).get_by_name(app_id, name).await
}
async fn get_by_name_inherited(
&self,
app_id: AppId,
name: &str,
) -> Result<Option<Script>, ScriptRepositoryError> {
(**self).get_by_name_inherited(app_id, name).await
}
async fn is_invocable_by_app(
&self,
script_id: ScriptId,
app_id: AppId,
) -> Result<bool, ScriptRepositoryError> {
(**self).is_invocable_by_app(script_id, app_id).await
}
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
(**self).list().await
}
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,
@@ -142,7 +186,13 @@ impl<T: ScriptRepository + ?Sized> ScriptRepository for std::sync::Arc<T> {
/// constraints; the repo enforces them in the DB regardless.
#[derive(Debug, Clone)]
pub struct NewScript {
pub app_id: AppId,
/// App owner (the common case). Exactly one of `app_id`/`group_id` must
/// be `Some` — the DB CHECK is the backstop, but callers should uphold
/// it. App-owned creation continues to pass `Some(app_id)`, `group_id:
/// None`; group-owned creation (Phase 4) inverts that.
pub app_id: Option<AppId>,
/// Group owner (Phase 4). See [`NewScript::app_id`].
pub group_id: Option<GroupId>,
pub name: String,
pub description: Option<String>,
pub source: String,
@@ -206,7 +256,7 @@ impl PostgresScriptRepository {
/// Columns selected from `scripts` everywhere — kept in one constant so
/// adding `kind` (v1.1.3) and future columns can't accidentally skip
/// one query.
const SCRIPT_SELECT_COLS: &str = "id, app_id, name, description, version, source, kind, \
const SCRIPT_SELECT_COLS: &str = "id, app_id, group_id, name, description, version, source, kind, \
timeout_seconds, memory_limit_mb, sandbox, enabled, \
created_at, updated_at";
@@ -237,6 +287,58 @@ impl ScriptRepository for PostgresScriptRepository {
Ok(row.map(Into::into))
}
async fn get_by_name_inherited(
&self,
app_id: AppId,
name: &str,
) -> Result<Option<Script>, ScriptRepositoryError> {
// Walk app → ancestor groups (CHAIN_LEVELS_CTE binds $1 = app_id),
// join scripts owned at each level, and take the nearest (lowest
// depth) row named `name`. Case-insensitive to match the per-owner
// `LOWER(name)` unique indexes. Mirrors the vars/secrets resolvers.
let cols = SCRIPT_SELECT_COLS
.split(", ")
.map(|c| format!("s.{c}"))
.collect::<Vec<_>>()
.join(", ");
let row = sqlx::query_as::<_, ScriptRow>(&format!(
"{cte} SELECT {cols} FROM scripts s \
JOIN chain c ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \
WHERE LOWER(s.name) = LOWER($2) \
ORDER BY c.depth ASC \
LIMIT 1",
cte = crate::config_resolver::CHAIN_LEVELS_CTE,
))
.bind(app_id.into_inner())
.bind(name)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(Into::into))
}
async fn is_invocable_by_app(
&self,
script_id: ScriptId,
app_id: AppId,
) -> Result<bool, ScriptRepositoryError> {
// EXISTS over the same chain: the script's owner (app or group) must
// appear on `app_id`'s app→ancestor-group chain. CHAIN_LEVELS_CTE
// binds $1 = app_id; $2 = script_id.
let exists: (bool,) = sqlx::query_as(&format!(
"{cte} SELECT EXISTS ( \
SELECT 1 FROM scripts s \
JOIN chain c ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \
WHERE s.id = $2 \
)",
cte = crate::config_resolver::CHAIN_LEVELS_CTE,
))
.bind(app_id.into_inner())
.bind(script_id.into_inner())
.fetch_one(&self.pool)
.await?;
Ok(exists.0)
}
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, ScriptRow>(&format!(
"SELECT {SCRIPT_SELECT_COLS} FROM scripts ORDER BY name"
@@ -256,6 +358,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,
@@ -397,12 +512,13 @@ pub(crate) async fn insert_script_tx(
.unwrap_or_else(|_| serde_json::json!({}));
let res = sqlx::query_as::<_, ScriptRow>(&format!(
"INSERT INTO scripts ( \
app_id, name, description, source, kind, \
app_id, group_id, name, description, source, kind, \
timeout_seconds, memory_limit_mb, sandbox, enabled \
) VALUES ($1, $2, $3, $4, $5, COALESCE($6, 30), COALESCE($7, 256), $8, $9) \
) VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 30), COALESCE($8, 256), $9, $10) \
RETURNING {SCRIPT_SELECT_COLS}"
))
.bind(input.app_id.into_inner())
.bind(input.app_id.map(AppId::into_inner))
.bind(input.group_id.map(GroupId::into_inner))
.bind(&input.name)
.bind(input.description.as_deref())
.bind(&input.source)
@@ -417,13 +533,18 @@ pub(crate) async fn insert_script_tx(
Ok(row) => row.into(),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
return Err(ScriptRepositoryError::Conflict(format!(
"a script named {:?} already exists in this app",
"a script named {:?} already exists in this owner",
input.name
)));
}
Err(e) => return Err(e.into()),
};
replace_imports_tx(tx, script.id, script.app_id, &input.imports).await?;
// Module imports are resolved within the owning app's script set. Group
// scripts must be self-contained in Phase 4-lite (the origin-aware import
// resolver is Phase 4b), so only app-owned scripts wire up import edges.
if let Some(app_id) = script.app_id {
replace_imports_tx(tx, script.id, app_id, &input.imports).await?;
}
Ok(script)
}
@@ -470,13 +591,15 @@ pub(crate) async fn update_script_tx(
Ok(None) => return Err(ScriptRepositoryError::NotFound(id)),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
return Err(ScriptRepositoryError::Conflict(
"a script with that name already exists in this app".into(),
"a script with that name already exists in this owner".into(),
));
}
Err(e) => return Err(e.into()),
};
if let Some(imports) = patch.imports.as_deref() {
replace_imports_tx(tx, script.id, script.app_id, imports).await?;
if let Some(app_id) = script.app_id {
replace_imports_tx(tx, script.id, app_id, imports).await?;
}
}
Ok(script)
}
@@ -501,7 +624,10 @@ pub(crate) async fn delete_script_tx(
#[derive(sqlx::FromRow)]
struct ScriptRow {
id: uuid::Uuid,
app_id: uuid::Uuid,
/// Polymorphic owner (Phase 4): exactly one of `app_id`/`group_id` is
/// non-NULL (DB CHECK). App-owned rows keep `app_id` set as before.
app_id: Option<uuid::Uuid>,
group_id: Option<uuid::Uuid>,
name: String,
description: Option<String>,
version: i32,
@@ -531,7 +657,8 @@ impl From<ScriptRow> for Script {
let kind = ScriptKind::parse_str(&r.kind).unwrap_or(ScriptKind::Endpoint);
Self {
id: r.id.into(),
app_id: r.app_id.into(),
app_id: r.app_id.map(Into::into),
group_id: r.group_id.map(Into::into),
name: r.name,
description: r.description,
version: r.version,

View File

@@ -148,10 +148,16 @@ async fn list_routes<RR: RouteRepository, SR: ScriptRepository>(
.get(script_id)
.await?
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
// Phase 4: routes bind to app-owned scripts; a group-owned script
// (`app_id: None`) is not addressable here (binding lands in C3), so it
// reads as a missing script.
let app_id = script
.app_id
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
require(
state.authz.as_ref(),
&principal,
Capability::AppRead(script.app_id),
Capability::AppRead(app_id),
)
.await?;
Ok(Json(state.routes.list_for_script(script_id).await?))
@@ -176,7 +182,11 @@ async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
.get(script_id)
.await?
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
let app_id = script.app_id;
// Phase 4: only app-owned scripts are route-bindable for now (group
// binding is C3); a group script reads as missing here.
let app_id = script
.app_id
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
require(
state.authz.as_ref(),
&principal,
@@ -231,6 +241,8 @@ async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
dispatch_mode: input.dispatch_mode,
// Routes are created active; toggling is a dedicated path.
enabled: true,
// Hand-declared via the interactive API — not a template expansion.
from_template: None,
})
.await?;
refresh_table(&state).await?;

View File

@@ -23,6 +23,10 @@ pub struct NewRoute {
pub dispatch_mode: DispatchMode,
/// Three-state lifecycle (§4.3). Create active by default.
pub enabled: bool,
/// Provenance (§4.5, M4): the route-template id this row was expanded from,
/// or `None` for a hand-declared route. Defaults `None` everywhere except
/// template expansion.
pub from_template: Option<Uuid>,
}
#[async_trait]
@@ -175,8 +179,8 @@ pub(crate) async fn insert_route_tx(
let res = sqlx::query_as::<_, RouteRow>(
"INSERT INTO routes ( \
app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, dispatch_mode, enabled \
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \
path_kind, path, method, dispatch_mode, enabled, from_template \
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \
RETURNING id, app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, dispatch_mode, enabled, created_at",
)
@@ -190,6 +194,7 @@ pub(crate) async fn insert_route_tx(
.bind(input.method.as_deref())
.bind(input.dispatch_mode.as_str())
.bind(input.enabled)
.bind(input.from_template)
.fetch_one(&mut **tx)
.await;
match res {

View File

@@ -1,16 +1,27 @@
//! `/api/v1/admin/apps/{id}/secrets*` — secrets admin endpoints
//! (v1.1.7).
//! `/api/v1/admin/{apps,groups}/{id}/secrets*` — secrets admin endpoints.
//!
//! * `GET /apps/{id}/secrets` — list names + updated_at
//! * `GET /apps/{id}/secrets` — list app secret names + updated_at
//! (NEVER values).
//! * `POST /apps/{id}/secrets` — set/overwrite a secret.
//! * `DELETE /apps/{id}/secrets/{name}` — delete a secret.
//! * `POST /apps/{id}/secrets` — set/overwrite an app secret.
//! * `DELETE /apps/{id}/secrets/{name}` — delete an app secret.
//! * `GET /groups/{id}/secrets` — list group secret names + scope
//! + updated_at (NEVER values).
//! * `POST /groups/{id}/secrets` — set/overwrite a group secret
//! (env-scoped).
//! * `DELETE /groups/{id}/secrets/{name}` — delete a group secret.
//! * `GET /groups/{id}/secrets/{name}/value` — **human value read** —
//! the ONE endpoint that returns plaintext, gated at the owning group.
//!
//! Set/delete are gated by `AppSecretsWrite` (→ `script:write`); list by
//! `AppSecretsRead` (→ `script:read`). The list surface deliberately
//! returns only names + timestamps — the dashboard never receives
//! plaintext. Values are encrypted with the process master key before
//! they touch the database (same envelope as the script `secrets::set`).
//! App set/delete are gated by `AppSecretsWrite`, list by `AppSecretsRead`.
//! Group set/list/delete are gated by `GroupSecretsWrite` (editor+); the
//! value-read is gated by `GroupSecretsRead` (group_admin only) — that is
//! the masked-secret boundary: a descendant app's dev can see that a group
//! secret EXISTS (and consume it at runtime via `secrets::get`) but only a
//! principal with read rights AT THE OWNING GROUP can read its value. The
//! owner is resolved FIRST (slug-or-uuid), THEN `authz::require` binds the
//! capability to the resolved owner id — never to a caller-controlled path
//! param. Values are encrypted with the process master key before they
//! touch the database (owner-bound AAD; see `secrets_service`).
use std::sync::Arc;
@@ -19,19 +30,24 @@ use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::get;
use axum::{Extension, Router};
use picloud_shared::{validate_secret_name, AppId, MasterKey, Principal, SecretsError};
use picloud_shared::{validate_secret_name, AppId, GroupId, MasterKey, Principal, SecretsError};
use serde::Deserialize;
use serde_json::json;
use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
use crate::secrets_repo::{SecretsRepo, SecretsRepoError};
use crate::secrets_service::seal;
use crate::group_repo::GroupRepository;
use crate::secrets_repo::{SecretOwner, SecretsRepo, SecretsRepoError};
use crate::secrets_service::{open, seal};
/// App secrets are env-agnostic; only group secrets carry a concrete scope.
const APP_SECRET_SCOPE: &str = "*";
#[derive(Clone)]
pub struct SecretsState {
pub repo: Arc<dyn SecretsRepo>,
pub apps: Arc<dyn AppRepository>,
pub groups: Arc<dyn GroupRepository>,
pub authz: Arc<dyn AuthzRepo>,
pub master_key: MasterKey,
pub max_value_bytes: usize,
@@ -39,10 +55,25 @@ pub struct SecretsState {
pub fn secrets_router(state: SecretsState) -> Router {
Router::new()
.route("/apps/{app_id}/secrets", get(list_secrets).post(set_secret))
.route(
"/apps/{app_id}/secrets",
get(list_app_secrets).post(set_app_secret),
)
.route(
"/apps/{app_id}/secrets/{name}",
axum::routing::delete(delete_secret),
axum::routing::delete(delete_app_secret),
)
.route(
"/groups/{group_id}/secrets",
get(list_group_secrets).post(set_group_secret),
)
.route(
"/groups/{group_id}/secrets/{name}",
axum::routing::delete(delete_group_secret),
)
.route(
"/groups/{group_id}/secrets/{name}/value",
get(read_group_secret_value),
)
.with_state(state)
}
@@ -55,9 +86,18 @@ pub struct ListQuery {
pub limit: Option<u32>,
}
#[derive(Debug, Deserialize)]
pub struct EnvQuery {
#[serde(default)]
pub env: Option<String>,
}
#[derive(Debug, serde::Serialize)]
struct SecretItem {
name: String,
/// Environment scope — `*` for app secrets, possibly a concrete env for
/// group secrets.
env: String,
updated_at: chrono::DateTime<chrono::Utc>,
}
@@ -67,7 +107,23 @@ struct ListSecretsResponse {
next_cursor: Option<String>,
}
async fn list_secrets(
#[derive(Debug, Deserialize)]
pub struct SetSecretRequest {
pub name: String,
/// Any JSON value — the dashboard sends a single-line string, but
/// maps/arrays/numbers round-trip too (matching `secrets::set`).
pub value: serde_json::Value,
/// Environment scope (group secrets only). `*` (env-agnostic, default)
/// or a concrete env matched against `apps.environment` at resolution.
#[serde(default)]
pub env: Option<String>,
}
// ----------------------------------------------------------------------------
// App handlers (env-agnostic, scope `*`)
// ----------------------------------------------------------------------------
async fn list_app_secrets(
State(s): State<SecretsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
@@ -80,32 +136,10 @@ async fn list_secrets(
Capability::AppSecretsRead(app_id),
)
.await?;
let page = s
.repo
.list_meta(app_id, q.cursor.as_deref(), q.limit.unwrap_or(0))
.await?;
Ok(Json(ListSecretsResponse {
secrets: page
.items
.into_iter()
.map(|m| SecretItem {
name: m.name,
updated_at: m.updated_at,
})
.collect(),
next_cursor: page.next_cursor,
}))
list_meta(&*s.repo, SecretOwner::App(app_id), &q).await
}
#[derive(Debug, Deserialize)]
pub struct SetSecretRequest {
pub name: String,
/// Any JSON value — the dashboard sends a single-line string, but
/// maps/arrays/numbers round-trip too (matching `secrets::set`).
pub value: serde_json::Value,
}
async fn set_secret(
async fn set_app_secret(
State(s): State<SecretsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
@@ -118,23 +152,17 @@ async fn set_secret(
Capability::AppSecretsWrite(app_id),
)
.await?;
validate_secret_name(&input.name)?;
// Audit 2026-06-11 H-D1 — v1 envelope with AAD bound to
// (app_id, name). Same path as the SDK secrets::set.
let (ciphertext, nonce, version) = seal(
&s.master_key,
app_id,
&input.name,
&input.value,
s.max_value_bytes,
)?;
s.repo
.set(app_id, &input.name, &ciphertext, &nonce, version)
.await?;
Ok(StatusCode::NO_CONTENT)
// App secrets are always env-agnostic; reject a stray `env`.
if input.env.as_deref().is_some_and(|e| e != APP_SECRET_SCOPE) {
return Err(SecretsApiError::Invalid(
"app secrets are env-agnostic; set an environment scope on a group secret instead"
.into(),
));
}
seal_and_store(&s, SecretOwner::App(app_id), APP_SECRET_SCOPE, input).await
}
async fn delete_secret(
async fn delete_app_secret(
State(s): State<SecretsState>,
Extension(principal): Extension<Principal>,
Path((id_or_slug, name)): Path<(String, String)>,
@@ -146,12 +174,161 @@ async fn delete_secret(
Capability::AppSecretsWrite(app_id),
)
.await?;
if !s.repo.delete(app_id, &name).await? {
delete(&*s.repo, SecretOwner::App(app_id), APP_SECRET_SCOPE, &name).await
}
// ----------------------------------------------------------------------------
// Group handlers (env-scoped)
// ----------------------------------------------------------------------------
async fn list_group_secrets(
State(s): State<SecretsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Query(q): Query<ListQuery>,
) -> Result<Json<ListSecretsResponse>, SecretsApiError> {
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupSecretsWrite(group_id),
)
.await?;
list_meta(&*s.repo, SecretOwner::Group(group_id), &q).await
}
async fn set_group_secret(
State(s): State<SecretsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(input): Json<SetSecretRequest>,
) -> Result<StatusCode, SecretsApiError> {
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupSecretsWrite(group_id),
)
.await?;
let env = input.env.clone().unwrap_or_else(|| APP_SECRET_SCOPE.into());
validate_env_scope(&env)?;
seal_and_store(&s, SecretOwner::Group(group_id), &env, input).await
}
async fn delete_group_secret(
State(s): State<SecretsState>,
Extension(principal): Extension<Principal>,
Path((id_or_slug, name)): Path<(String, String)>,
Query(q): Query<EnvQuery>,
) -> Result<StatusCode, SecretsApiError> {
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupSecretsWrite(group_id),
)
.await?;
let env = q.env.unwrap_or_else(|| APP_SECRET_SCOPE.into());
validate_env_scope(&env)?;
delete(&*s.repo, SecretOwner::Group(group_id), &env, &name).await
}
/// The ONE plaintext-returning endpoint. Gated by `GroupSecretsRead`
/// (group_admin) — the masked-secret boundary. A descendant app's dev can
/// see the secret exists and consume it at runtime, but only a reader at the
/// owning group gets the value here.
async fn read_group_secret_value(
State(s): State<SecretsState>,
Extension(principal): Extension<Principal>,
Path((id_or_slug, name)): Path<(String, String)>,
Query(q): Query<EnvQuery>,
) -> Result<Json<serde_json::Value>, SecretsApiError> {
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupSecretsRead(group_id),
)
.await?;
validate_secret_name(&name)?;
let env = q.env.unwrap_or_else(|| APP_SECRET_SCOPE.into());
validate_env_scope(&env)?;
let owner = SecretOwner::Group(group_id);
let stored = s
.repo
.get(owner, &env, &name)
.await?
.ok_or(SecretsApiError::NotFound)?;
let value = open(&s.master_key, owner, &name, &stored).map_err(|e| {
tracing::error!(group_id = %group_id, secret = %name, "group secret could not be decrypted");
SecretsApiError::from(e)
})?;
Ok(Json(json!({ "name": name, "env": env, "value": value })))
}
// ----------------------------------------------------------------------------
// Shared owner-generic bodies
// ----------------------------------------------------------------------------
async fn list_meta(
repo: &dyn SecretsRepo,
owner: SecretOwner,
q: &ListQuery,
) -> Result<Json<ListSecretsResponse>, SecretsApiError> {
let page = repo
.list_meta(owner, q.cursor.as_deref(), q.limit.unwrap_or(0))
.await?;
Ok(Json(ListSecretsResponse {
secrets: page
.items
.into_iter()
.map(|m| SecretItem {
name: m.name,
env: m.environment_scope,
updated_at: m.updated_at,
})
.collect(),
next_cursor: page.next_cursor,
}))
}
async fn seal_and_store(
s: &SecretsState,
owner: SecretOwner,
env: &str,
input: SetSecretRequest,
) -> Result<StatusCode, SecretsApiError> {
validate_secret_name(&input.name)?;
// Audit 2026-06-11 H-D1 — v1 envelope with AAD bound to the owner+name.
let (ciphertext, nonce, version) = seal(
&s.master_key,
owner,
&input.name,
&input.value,
s.max_value_bytes,
)?;
s.repo
.set(owner, env, &input.name, &ciphertext, &nonce, version)
.await?;
Ok(StatusCode::NO_CONTENT)
}
async fn delete(
repo: &dyn SecretsRepo,
owner: SecretOwner,
env: &str,
name: &str,
) -> Result<StatusCode, SecretsApiError> {
if !repo.delete(owner, env, name).await? {
return Err(SecretsApiError::NotFound);
}
Ok(StatusCode::NO_CONTENT)
}
// ----------------------------------------------------------------------------
// Resolution + validation
// ----------------------------------------------------------------------------
async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, SecretsApiError> {
crate::app_repo::resolve_app(apps, ident)
.await
@@ -160,10 +337,58 @@ async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, Sec
.ok_or(SecretsApiError::AppNotFound)
}
async fn resolve_group(
groups: &dyn GroupRepository,
ident: &str,
) -> Result<GroupId, SecretsApiError> {
let found = if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
groups
.get_by_id(uuid.into())
.await
.map_err(|e| SecretsApiError::Backend(e.to_string()))?
} else {
groups
.get_by_slug(ident)
.await
.map_err(|e| SecretsApiError::Backend(e.to_string()))?
};
found.map(|g| g.id).ok_or(SecretsApiError::GroupNotFound)
}
/// Env scope is `*` (env-agnostic) or a kebab env name. Mirrors the vars
/// admin validator so a secret and a var share the same env vocabulary.
fn validate_env_scope(env: &str) -> Result<(), SecretsApiError> {
if env == "*" {
return Ok(());
}
if env.is_empty() || env.len() > 63 {
return Err(SecretsApiError::Invalid(
"env must be '*' or 163 characters".into(),
));
}
let first = env.chars().next().unwrap();
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
return Err(SecretsApiError::Invalid(
"env must start with a lowercase letter or digit".into(),
));
}
if !env
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(SecretsApiError::Invalid(
"env may contain only lowercase letters, digits, and hyphens".into(),
));
}
Ok(())
}
#[derive(Debug, thiserror::Error)]
pub enum SecretsApiError {
#[error("app not found")]
AppNotFound,
#[error("group not found")]
GroupNotFound,
#[error("secret not found")]
NotFound,
#[error("invalid request: {0}")]
@@ -214,7 +439,7 @@ impl From<SecretsError> for SecretsApiError {
impl IntoResponse for SecretsApiError {
fn into_response(self) -> Response {
let (status, body) = match &self {
Self::AppNotFound | Self::NotFound => {
Self::AppNotFound | Self::GroupNotFound | Self::NotFound => {
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
}
Self::Invalid(_) => (

View File

@@ -2,13 +2,23 @@
//! opaque ciphertext + nonce blobs in and out. Encryption, JSON
//! encoding, authorization, name validation, and the value-size cap all
//! live one layer up in `SecretsServiceImpl` / `secrets_api`.
//!
//! Phase 3 made secrets polymorphic-owner + env-scoped (migration
//! `0049_group_secrets.sql`): a secret is owned by exactly one app OR one
//! ancestor group, and a descendant app resolves the nearest one,
//! environment-filtered (mirroring `vars` / `config_resolver`). Writes are
//! owner-keyed via [`SecretOwner`]; the SDK read path goes through
//! [`SecretsRepo::resolve`], which walks the app→group→root chain.
use async_trait::async_trait;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use chrono::{DateTime, Utc};
use picloud_shared::AppId;
use picloud_shared::{AppId, GroupId};
use sqlx::PgPool;
use uuid::Uuid;
use crate::config_resolver::CHAIN_LEVELS_CTE;
#[derive(Debug, thiserror::Error)]
pub enum SecretsRepoError {
@@ -19,14 +29,22 @@ pub enum SecretsRepoError {
InvalidCursor,
}
/// Who owns a secret (Phase 3). A secret is owned by exactly one app OR
/// one group; the owner is bound into the AES-GCM AAD (see
/// `secrets_service::secret_aad`) so a cross-owner ciphertext swap fails
/// decryption, and it selects the partial-unique conflict target on write.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SecretOwner {
App(AppId),
Group(GroupId),
}
/// An encrypted secret as it lives on disk: ciphertext (auth tag
/// appended) plus the nonce it was sealed with.
///
/// Audit 2026-06-11 H-D1: `version` discriminates the envelope layout.
/// `0` = legacy AES-GCM with no AAD (pre-2026-06-11 writes); `1` =
/// AES-GCM with AAD bound to `"secret:{app_id}:{name}"`. Migration
/// `0042_secrets_envelope_version.sql` adds the column with a default
/// of `0`, so existing rows keep working.
/// AES-GCM with AAD bound to the owner+name (see `secrets_service`).
#[derive(Debug, Clone)]
pub struct StoredSecret {
pub encrypted_value: Vec<u8>,
@@ -34,11 +52,21 @@ pub struct StoredSecret {
pub version: i16,
}
/// The winner of an inherited-secret resolution: the stored ciphertext
/// plus the owner it actually came from (app-own or an ancestor group),
/// which the caller needs to pick the right AAD when decrypting.
#[derive(Debug, Clone)]
pub struct ResolvedSecret {
pub owner: SecretOwner,
pub stored: StoredSecret,
}
/// Admin-surface metadata for one secret. Values are never returned —
/// only the name and the last-modified timestamp.
/// only the name, its environment scope, and the last-modified timestamp.
#[derive(Debug, Clone)]
pub struct SecretMeta {
pub name: String,
pub environment_scope: String,
pub updated_at: DateTime<Utc>,
}
@@ -60,38 +88,62 @@ pub struct SecretsMetaPage {
/// substitute an in-memory backing without Postgres.
#[async_trait]
pub trait SecretsRepo: Send + Sync {
async fn get(
/// Resolve the effective secret for `app_id` by name: walk the
/// app→ancestor-group chain (depth 0 = the app), env-filter to the
/// app's environment or `*`, and return the nearest winner (with
/// `@E` beating `*` within a level). `None` if no level defines it.
/// This is the runtime injection path — isolation is anchored to
/// `app_id`, so an app only ever sees its own + its ancestors' secrets.
async fn resolve(
&self,
app_id: AppId,
name: &str,
) -> Result<Option<ResolvedSecret>, SecretsRepoError>;
/// Read one owner's OWN secret at a specific env scope (no inheritance).
/// Backs the group-gated human value-read and the apply email path.
async fn get(
&self,
owner: SecretOwner,
env_scope: &str,
name: &str,
) -> Result<Option<StoredSecret>, SecretsRepoError>;
/// Upsert (overwrite if present). `version` is the AES-GCM envelope
/// discriminator from [`StoredSecret::version`].
/// Upsert (overwrite if present) one `(owner, env_scope, name)` row.
/// `version` is the AES-GCM envelope discriminator.
async fn set(
&self,
app_id: AppId,
owner: SecretOwner,
env_scope: &str,
name: &str,
encrypted_value: &[u8],
nonce: &[u8],
version: i16,
) -> Result<(), SecretsRepoError>;
/// Delete; returns whether a row was present.
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, SecretsRepoError>;
/// Delete one `(owner, env_scope, name)` row; returns whether a row
/// was present.
async fn delete(
&self,
owner: SecretOwner,
env_scope: &str,
name: &str,
) -> Result<bool, SecretsRepoError>;
/// Names only — the SDK `list` surface.
/// Distinct names of an owner's OWN secrets (NOT inherited) — the SDK
/// `list` surface. Names are de-duplicated across env scopes.
async fn list_names(
&self,
app_id: AppId,
owner: SecretOwner,
cursor: Option<&str>,
limit: u32,
) -> Result<SecretsNamePage, SecretsRepoError>;
/// Name + updated_at — the admin `GET` surface.
/// Name + scope + updated_at of an owner's OWN secrets — the admin
/// `GET` surface.
async fn list_meta(
&self,
app_id: AppId,
owner: SecretOwner,
cursor: Option<&str>,
limit: u32,
) -> Result<SecretsMetaPage, SecretsRepoError>;
@@ -131,21 +183,98 @@ pub(crate) fn decode_cursor(cursor: &str) -> Result<String, SecretsRepoError> {
String::from_utf8(bytes).map_err(|_| SecretsRepoError::InvalidCursor)
}
/// `list_meta` orders by `(name, environment_scope)` — a name can now have
/// several env-scoped rows (group secrets) — so its keyset cursor must carry
/// BOTH columns, else a name whose scopes straddle a page boundary loses its
/// tail. Encoded as base64url of `name \x1f scope` (US is not a valid env or
/// secret-name char, so it's an unambiguous delimiter).
const CURSOR_SEP: char = '\u{1f}';
fn encode_meta_cursor(name: &str, scope: &str) -> String {
URL_SAFE_NO_PAD.encode(format!("{name}{CURSOR_SEP}{scope}").as_bytes())
}
fn decode_meta_cursor(cursor: &str) -> Result<(String, String), SecretsRepoError> {
let bytes = URL_SAFE_NO_PAD
.decode(cursor)
.map_err(|_| SecretsRepoError::InvalidCursor)?;
let s = String::from_utf8(bytes).map_err(|_| SecretsRepoError::InvalidCursor)?;
s.split_once(CURSOR_SEP)
.map(|(n, sc)| (n.to_string(), sc.to_string()))
.ok_or(SecretsRepoError::InvalidCursor)
}
/// `(owner_column, owner_uuid)` for binding an owner into a query.
fn owner_bind(owner: SecretOwner) -> (&'static str, Uuid) {
match owner {
SecretOwner::App(a) => ("app_id", a.into_inner()),
SecretOwner::Group(g) => ("group_id", g.into_inner()),
}
}
#[async_trait]
impl SecretsRepo for PostgresSecretsRepo {
async fn get(
async fn resolve(
&self,
app_id: AppId,
name: &str,
) -> Result<Option<StoredSecret>, SecretsRepoError> {
let row: Option<(Vec<u8>, Vec<u8>, i16)> = sqlx::query_as(
"SELECT encrypted_value, nonce, version FROM secrets \
WHERE app_id = $1 AND name = $2",
) -> Result<Option<ResolvedSecret>, SecretsRepoError> {
// Reuse the shared chain-walk ($1 = app_id), join secrets by name,
// env-filter, and take the nearest level — `@E` beating `*` within a
// level via the secondary sort key. One row out, or none.
let sql = format!(
"{CHAIN_LEVELS_CTE} \
SELECT CASE WHEN s.app_id IS NOT NULL THEN 'app' ELSE 'group' END AS owner_kind, \
COALESCE(s.app_id, s.group_id) AS owner_id, \
s.encrypted_value, s.nonce, s.version \
FROM chain c \
JOIN secrets s ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \
WHERE s.name = $2 \
AND (s.environment_scope = '*' OR s.environment_scope = c.app_env) \
ORDER BY c.depth ASC, (s.environment_scope <> '*') DESC \
LIMIT 1"
);
let row: Option<(String, Uuid, Vec<u8>, Vec<u8>, i16)> = sqlx::query_as(&sql)
.bind(app_id.into_inner())
.bind(name)
.fetch_optional(&self.pool)
.await?;
Ok(
row.map(|(owner_kind, owner_id, encrypted_value, nonce, version)| {
let owner = if owner_kind == "app" {
SecretOwner::App(AppId::from(owner_id))
} else {
SecretOwner::Group(GroupId::from(owner_id))
};
ResolvedSecret {
owner,
stored: StoredSecret {
encrypted_value,
nonce,
version,
},
}
}),
)
.bind(app_id.into_inner())
.bind(name)
.fetch_optional(&self.pool)
.await?;
}
async fn get(
&self,
owner: SecretOwner,
env_scope: &str,
name: &str,
) -> Result<Option<StoredSecret>, SecretsRepoError> {
let (col, id) = owner_bind(owner);
let sql = format!(
"SELECT encrypted_value, nonce, version FROM secrets \
WHERE {col} = $1 AND environment_scope = $2 AND name = $3"
);
let row: Option<(Vec<u8>, Vec<u8>, i16)> = sqlx::query_as(&sql)
.bind(id)
.bind(env_scope)
.bind(name)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|(encrypted_value, nonce, version)| StoredSecret {
encrypted_value,
nonce,
@@ -155,34 +284,54 @@ impl SecretsRepo for PostgresSecretsRepo {
async fn set(
&self,
app_id: AppId,
owner: SecretOwner,
env_scope: &str,
name: &str,
encrypted_value: &[u8],
nonce: &[u8],
version: i16,
) -> Result<(), SecretsRepoError> {
sqlx::query(
"INSERT INTO secrets (app_id, name, encrypted_value, nonce, version) \
VALUES ($1, $2, $3, $4, $5) \
ON CONFLICT (app_id, name) DO UPDATE \
// Owner-kind-specific SQL: write only the owner's nullable column and
// restate the partial-unique predicate as the ON CONFLICT arbiter.
let (col, id) = owner_bind(owner);
let predicate = match owner {
SecretOwner::App(_) => "app_id IS NOT NULL",
SecretOwner::Group(_) => "group_id IS NOT NULL",
};
let sql = format!(
"INSERT INTO secrets ({col}, environment_scope, name, encrypted_value, nonce, version) \
VALUES ($1, $2, $3, $4, $5, $6) \
ON CONFLICT ({col}, environment_scope, name) WHERE {predicate} DO UPDATE \
SET encrypted_value = EXCLUDED.encrypted_value, \
nonce = EXCLUDED.nonce, \
version = EXCLUDED.version, \
updated_at = NOW()",
)
.bind(app_id.into_inner())
.bind(name)
.bind(encrypted_value)
.bind(nonce)
.bind(version)
.execute(&self.pool)
.await?;
updated_at = NOW()"
);
sqlx::query(&sql)
.bind(id)
.bind(env_scope)
.bind(name)
.bind(encrypted_value)
.bind(nonce)
.bind(version)
.execute(&self.pool)
.await?;
Ok(())
}
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, SecretsRepoError> {
let res = sqlx::query("DELETE FROM secrets WHERE app_id = $1 AND name = $2")
.bind(app_id.into_inner())
async fn delete(
&self,
owner: SecretOwner,
env_scope: &str,
name: &str,
) -> Result<bool, SecretsRepoError> {
let (col, id) = owner_bind(owner);
let sql = format!(
"DELETE FROM secrets WHERE {col} = $1 AND environment_scope = $2 AND name = $3"
);
let res = sqlx::query(&sql)
.bind(id)
.bind(env_scope)
.bind(name)
.execute(&self.pool)
.await?;
@@ -191,7 +340,7 @@ impl SecretsRepo for PostgresSecretsRepo {
async fn list_names(
&self,
app_id: AppId,
owner: SecretOwner,
cursor: Option<&str>,
limit: u32,
) -> Result<SecretsNamePage, SecretsRepoError> {
@@ -201,16 +350,20 @@ impl SecretsRepo for PostgresSecretsRepo {
None => None,
};
let take = i64::from(limit) + 1;
let rows: Vec<(String,)> = sqlx::query_as(
"SELECT name FROM secrets \
WHERE app_id = $1 AND ($2::text IS NULL OR name > $2) \
ORDER BY name ASC LIMIT $3",
)
.bind(app_id.into_inner())
.bind(last_name.as_deref())
.bind(take)
.fetch_all(&self.pool)
.await?;
let (col, id) = owner_bind(owner);
// DISTINCT collapses a name that exists at several env scopes into
// one entry — the SDK `list` is a name catalogue, not per-scope.
let sql = format!(
"SELECT DISTINCT name FROM secrets \
WHERE {col} = $1 AND ($2::text IS NULL OR name > $2) \
ORDER BY name ASC LIMIT $3"
);
let rows: Vec<(String,)> = sqlx::query_as(&sql)
.bind(id)
.bind(last_name.as_deref())
.bind(take)
.fetch_all(&self.pool)
.await?;
let mut names: Vec<String> = rows.into_iter().map(|(n,)| n).collect();
let next_cursor = if names.len() > limit as usize {
@@ -224,34 +377,49 @@ impl SecretsRepo for PostgresSecretsRepo {
async fn list_meta(
&self,
app_id: AppId,
owner: SecretOwner,
cursor: Option<&str>,
limit: u32,
) -> Result<SecretsMetaPage, SecretsRepoError> {
let limit = clamp_limit(limit);
let last_name = match cursor {
Some(c) => Some(decode_cursor(c)?),
// Composite keyset on (name, environment_scope) — see encode_meta_cursor.
let last = match cursor {
Some(c) => Some(decode_meta_cursor(c)?),
None => None,
};
let (last_name, last_scope) = match &last {
Some((n, sc)) => (Some(n.as_str()), Some(sc.as_str())),
None => (None, None),
};
let take = i64::from(limit) + 1;
let rows: Vec<(String, DateTime<Utc>)> = sqlx::query_as(
"SELECT name, updated_at FROM secrets \
WHERE app_id = $1 AND ($2::text IS NULL OR name > $2) \
ORDER BY name ASC LIMIT $3",
)
.bind(app_id.into_inner())
.bind(last_name.as_deref())
.bind(take)
.fetch_all(&self.pool)
.await?;
let (col, id) = owner_bind(owner);
let sql = format!(
"SELECT name, environment_scope, updated_at FROM secrets \
WHERE {col} = $1 \
AND ($2::text IS NULL OR (name, environment_scope) > ($2, $3)) \
ORDER BY name ASC, environment_scope ASC LIMIT $4"
);
let rows: Vec<(String, String, DateTime<Utc>)> = sqlx::query_as(&sql)
.bind(id)
.bind(last_name)
.bind(last_scope)
.bind(take)
.fetch_all(&self.pool)
.await?;
let mut items: Vec<SecretMeta> = rows
.into_iter()
.map(|(name, updated_at)| SecretMeta { name, updated_at })
.map(|(name, environment_scope, updated_at)| SecretMeta {
name,
environment_scope,
updated_at,
})
.collect();
let next_cursor = if items.len() > limit as usize {
items.truncate(limit as usize);
items.last().map(|m| encode_cursor(&m.name))
items
.last()
.map(|m| encode_meta_cursor(&m.name, &m.environment_scope))
} else {
None
};

View File

@@ -22,22 +22,39 @@ use std::sync::Arc;
use async_trait::async_trait;
use picloud_shared::{
crypto, validate_secret_name, AppId, MasterKey, SdkCallCx, SecretsError, SecretsListPage,
crypto, validate_secret_name, MasterKey, SdkCallCx, SecretsError, SecretsListPage,
SecretsService,
};
use crate::authz::{self, AuthzRepo, Capability};
use crate::secrets_repo::{SecretsRepo, SecretsRepoError, StoredSecret};
// `SecretOwner` is defined one layer down in `secrets_repo` (it keys both
// the storage CRUD and the AAD). Re-exported here so the historical
// `secrets_service::SecretOwner` path stays stable.
pub use crate::secrets_repo::SecretOwner;
/// Current AES-GCM envelope version for the per-app secret store.
/// `0` = legacy (no AAD); `1` = AAD-bound. New writes always emit v1.
pub const SECRET_ENVELOPE_V1: i16 = 1;
/// Audit 2026-06-11 H-D1 — AAD bound into the GCM auth tag so a
/// cross-row swap (e.g. moving one app's ciphertext under another
/// app's `(app_id, name)` slot) fails decryption.
fn secret_aad(app_id: AppId, name: &str) -> Vec<u8> {
format!("secret:{app_id}:{name}").into_bytes()
/// The env scope under which an app's OWN secrets are stored. App secrets
/// are env-agnostic — only group secrets carry a concrete environment.
const APP_SECRET_SCOPE: &str = "*";
/// Audit 2026-06-11 H-D1 — AAD bound into the GCM auth tag so a cross-row
/// swap (moving one owner's ciphertext under another's slot) fails
/// decryption. The **app** form is byte-identical to the pre-Phase-3
/// `secret:{app_id}:{name}`, so every existing v1 row keeps decrypting
/// unchanged; group secrets use a distinct `secret:group:{group_id}:{name}`
/// namespace (the `group:` infix keeps app and group AAD disjoint even if a
/// group UUID happened to equal an app UUID).
fn secret_aad(owner: SecretOwner, name: &str) -> Vec<u8> {
match owner {
SecretOwner::App(app_id) => format!("secret:{app_id}:{name}"),
SecretOwner::Group(group_id) => format!("secret:group:{group_id}:{name}"),
}
.into_bytes()
}
/// Default per-secret plaintext cap (64 KB). Override with
@@ -96,7 +113,7 @@ impl Default for SecretsConfig {
/// failure (should not happen for a `serde_json::Value`).
pub fn seal(
master_key: &MasterKey,
app_id: AppId,
owner: SecretOwner,
name: &str,
value: &serde_json::Value,
max_value_bytes: usize,
@@ -109,7 +126,7 @@ pub fn seal(
actual: plaintext.len(),
});
}
let aad = secret_aad(app_id, name);
let aad = secret_aad(owner, name);
let enc = crypto::encrypt_with_aad(&plaintext, &aad, master_key.as_bytes());
Ok((enc.ciphertext, enc.nonce, SECRET_ENVELOPE_V1))
}
@@ -125,7 +142,7 @@ pub fn seal(
/// [`SecretsError::Corrupted`] when decryption or JSON decoding fails.
pub fn open(
master_key: &MasterKey,
app_id: AppId,
owner: SecretOwner,
name: &str,
stored: &StoredSecret,
) -> Result<serde_json::Value, SecretsError> {
@@ -136,7 +153,7 @@ pub fn open(
master_key.as_bytes(),
),
SECRET_ENVELOPE_V1 => {
let aad = secret_aad(app_id, name);
let aad = secret_aad(owner, name);
crypto::decrypt_with_aad(
&stored.encrypted_value,
&stored.nonce,
@@ -259,10 +276,15 @@ impl SecretsService for SecretsServiceImpl {
) -> Result<Option<serde_json::Value>, SecretsError> {
validate_secret_name(name)?;
self.check_read(cx).await?;
let Some(stored) = self.repo.get(cx.app_id, name).await? else {
// Inherited resolution: the app's own secret, else the nearest
// ancestor group's, env-filtered. `resolve` anchors the walk to
// `cx.app_id`, so an app can only ever read its own + its ancestors'
// secrets — the cross-app isolation boundary. The winning owner comes
// back with the row so we decrypt under the AAD it was sealed with.
let Some(resolved) = self.repo.resolve(cx.app_id, name).await? else {
return Ok(None);
};
match open(&self.master_key, cx.app_id, name, &stored) {
match open(&self.master_key, resolved.owner, name, &resolved.stored) {
Ok(value) => Ok(Some(value)),
Err(e) => {
// A decrypt failure is operationally significant — surface
@@ -286,15 +308,11 @@ impl SecretsService for SecretsServiceImpl {
) -> Result<(), SecretsError> {
validate_secret_name(name)?;
self.check_write(cx).await?;
let (ciphertext, nonce, version) = seal(
&self.master_key,
cx.app_id,
name,
&value,
self.max_value_bytes,
)?;
let owner = SecretOwner::App(cx.app_id);
let (ciphertext, nonce, version) =
seal(&self.master_key, owner, name, &value, self.max_value_bytes)?;
self.repo
.set(cx.app_id, name, &ciphertext, &nonce, version)
.set(owner, APP_SECRET_SCOPE, name, &ciphertext, &nonce, version)
.await?;
Ok(())
}
@@ -302,7 +320,10 @@ impl SecretsService for SecretsServiceImpl {
async fn delete(&self, cx: &SdkCallCx, name: &str) -> Result<bool, SecretsError> {
validate_secret_name(name)?;
self.check_write(cx).await?;
Ok(self.repo.delete(cx.app_id, name).await?)
Ok(self
.repo
.delete(SecretOwner::App(cx.app_id), APP_SECRET_SCOPE, name)
.await?)
}
async fn list(
@@ -312,7 +333,10 @@ impl SecretsService for SecretsServiceImpl {
limit: u32,
) -> Result<SecretsListPage, SecretsError> {
self.check_read(cx).await?;
let page = self.repo.list_names(cx.app_id, cursor, limit).await?;
let page = self
.repo
.list_names(SecretOwner::App(cx.app_id), cursor, limit)
.await?;
Ok(SecretsListPage {
names: page.names,
next_cursor: page.next_cursor,
@@ -328,44 +352,76 @@ impl SecretsService for SecretsServiceImpl {
mod tests {
use super::*;
use crate::authz::{AuthzError, AuthzRepo};
use crate::secrets_repo::{SecretsMetaPage, SecretsNamePage};
use crate::secrets_repo::{ResolvedSecret, SecretsMetaPage, SecretsNamePage};
use async_trait::async_trait;
use picloud_shared::{
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId,
UserId,
AdminUserId, AppId, AppRole, ExecutionId, GroupId, InstanceRole, Principal, RequestId,
ScriptId, UserId,
};
use std::collections::BTreeMap;
use tokio::sync::Mutex;
/// In-memory backing keyed by `(owner_key, env_scope, name)`. The owner
/// key is `"app:{uuid}"` / `"group:{uuid}"` so app and group rows never
/// collide. These unit tests exercise the app surface (scope `*`); the
/// chain-walk `resolve` is journey-tested against real Postgres, so here
/// it degrades to a plain app-own `*` lookup.
#[derive(Default)]
struct InMemorySecretsRepo {
data: Mutex<BTreeMap<(AppId, String), StoredSecret>>,
data: Mutex<BTreeMap<(String, String, String), StoredSecret>>,
}
fn owner_key(owner: SecretOwner) -> String {
match owner {
SecretOwner::App(a) => format!("app:{a}"),
SecretOwner::Group(g) => format!("group:{g}"),
}
}
#[async_trait]
impl SecretsRepo for InMemorySecretsRepo {
async fn get(
async fn resolve(
&self,
app_id: AppId,
name: &str,
) -> Result<Option<ResolvedSecret>, SecretsRepoError> {
let owner = SecretOwner::App(app_id);
Ok(self
.data
.lock()
.await
.get(&(
owner_key(owner),
APP_SECRET_SCOPE.to_string(),
name.to_string(),
))
.cloned()
.map(|stored| ResolvedSecret { owner, stored }))
}
async fn get(
&self,
owner: SecretOwner,
env_scope: &str,
name: &str,
) -> Result<Option<StoredSecret>, SecretsRepoError> {
Ok(self
.data
.lock()
.await
.get(&(app_id, name.to_string()))
.get(&(owner_key(owner), env_scope.to_string(), name.to_string()))
.cloned())
}
async fn set(
&self,
app_id: AppId,
owner: SecretOwner,
env_scope: &str,
name: &str,
encrypted_value: &[u8],
nonce: &[u8],
version: i16,
) -> Result<(), SecretsRepoError> {
self.data.lock().await.insert(
(app_id, name.to_string()),
(owner_key(owner), env_scope.to_string(), name.to_string()),
StoredSecret {
encrypted_value: encrypted_value.to_vec(),
nonce: nonce.to_vec(),
@@ -374,29 +430,36 @@ mod tests {
);
Ok(())
}
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, SecretsRepoError> {
async fn delete(
&self,
owner: SecretOwner,
env_scope: &str,
name: &str,
) -> Result<bool, SecretsRepoError> {
Ok(self
.data
.lock()
.await
.remove(&(app_id, name.to_string()))
.remove(&(owner_key(owner), env_scope.to_string(), name.to_string()))
.is_some())
}
async fn list_names(
&self,
app_id: AppId,
owner: SecretOwner,
cursor: Option<&str>,
limit: u32,
) -> Result<SecretsNamePage, SecretsRepoError> {
let data = self.data.lock().await;
let ok = owner_key(owner);
let last = cursor.map(std::string::ToString::to_string);
let mut names: Vec<String> = data
.iter()
.filter(|((a, _), _)| *a == app_id)
.map(|((_, n), _)| n.clone())
.filter(|((o, _, _), _)| *o == ok)
.map(|((_, _, n), _)| n.clone())
.filter(|n| last.as_ref().is_none_or(|l| n > l))
.collect();
names.sort();
names.dedup();
let take = (limit as usize).max(1);
let next_cursor = if names.len() > take {
names.truncate(take);
@@ -408,7 +471,7 @@ mod tests {
}
async fn list_meta(
&self,
_app_id: AppId,
_owner: SecretOwner,
_cursor: Option<&str>,
_limit: u32,
) -> Result<SecretsMetaPage, SecretsRepoError> {
@@ -635,7 +698,11 @@ mod tests {
repo.data
.lock()
.await
.get_mut(&(app, "k".to_string()))
.get_mut(&(
owner_key(SecretOwner::App(app)),
"*".to_string(),
"k".to_string(),
))
.unwrap()
.encrypted_value[0] ^= 0xff;
let err = s.get(&anon_cx(app), "k").await.unwrap_err();
@@ -666,10 +733,21 @@ mod tests {
.data
.lock()
.await
.get(&(a, "k".to_string()))
.get(&(
owner_key(SecretOwner::App(a)),
"*".to_string(),
"k".to_string(),
))
.cloned()
.unwrap();
repo.data.lock().await.insert((b, "k".to_string()), stolen);
repo.data.lock().await.insert(
(
owner_key(SecretOwner::App(b)),
"*".to_string(),
"k".to_string(),
),
stolen,
);
let err = s.get(&anon_cx(b), "k").await.unwrap_err();
assert!(
matches!(err, SecretsError::Corrupted),
@@ -677,6 +755,55 @@ mod tests {
);
}
#[tokio::test]
async fn aad_distinguishes_app_and_group_owner() {
// Phase 3: app and group secrets live in disjoint AAD namespaces
// (`secret:{app}` vs `secret:group:{group}`). A ciphertext sealed
// for a group must not open as an app secret (and vice-versa),
// even if the raw UUIDs were equal — the `group:` infix separates
// them. Exercise the free seal/open functions directly.
let k = key();
let app = AppId::new();
let group = GroupId::new();
let value = serde_json::json!("shared-config");
// Seal under the GROUP owner.
let (ct, nonce, version) =
seal(&k, SecretOwner::Group(group), "db_url", &value, 4096).unwrap();
let stored = StoredSecret {
encrypted_value: ct,
nonce: nonce.to_vec(),
version,
};
// Opens fine as the same group owner.
assert_eq!(
open(&k, SecretOwner::Group(group), "db_url", &stored).unwrap(),
value
);
// Fails as an app owner (AAD mismatch) — even reusing the UUID.
let app_from_group = AppId::from(group.into_inner());
assert!(matches!(
open(&k, SecretOwner::App(app_from_group), "db_url", &stored),
Err(SecretsError::Corrupted)
));
// And the symmetric direction: an app-sealed row won't open as a group.
let (ct2, nonce2, v2) = seal(&k, SecretOwner::App(app), "db_url", &value, 4096).unwrap();
let stored2 = StoredSecret {
encrypted_value: ct2,
nonce: nonce2.to_vec(),
version: v2,
};
assert!(matches!(
open(
&k,
SecretOwner::Group(GroupId::from(app.into_inner())),
"db_url",
&stored2
),
Err(SecretsError::Corrupted)
));
}
#[tokio::test]
async fn aad_blocks_cross_name_ciphertext_swap() {
// Same app, different name → AAD mismatch.
@@ -695,13 +822,21 @@ mod tests {
.data
.lock()
.await
.get(&(a, "real".to_string()))
.get(&(
owner_key(SecretOwner::App(a)),
"*".to_string(),
"real".to_string(),
))
.cloned()
.unwrap();
repo.data
.lock()
.await
.insert((a, "renamed".to_string()), stolen);
repo.data.lock().await.insert(
(
owner_key(SecretOwner::App(a)),
"*".to_string(),
"renamed".to_string(),
),
stolen,
);
let err = s.get(&anon_cx(a), "renamed").await.unwrap_err();
assert!(matches!(err, SecretsError::Corrupted));
}
@@ -726,7 +861,11 @@ mod tests {
)
.unwrap();
repo.data.lock().await.insert(
(app, "old".to_string()),
(
owner_key(SecretOwner::App(app)),
"*".to_string(),
"old".to_string(),
),
StoredSecret {
encrypted_value: ct,
nonce: nonce.to_vec(),

View File

@@ -0,0 +1,318 @@
//! CRUD over `route_templates` (§4.5, M4a) — route declarations owned by a
//! group that the apply engine fans out into one concrete `routes` row per
//! descendant app. Mirrors the tx-accepting free-function pattern of
//! `route_repo`/`group_repo`: pool variants for the read/diff path, `_tx`
//! variants for the transactional apply (upsert/delete and chain expansion).
use picloud_shared::{DispatchMode, HostKind, PathKind};
use sqlx::PgPool;
use uuid::Uuid;
use crate::apply_service::RouteTemplateSpec;
use crate::group_repo::GroupRepositoryError as Error;
use picloud_shared::GroupId;
/// A persisted route template, decoded with its enum fields parsed — ready for
/// the diff (by name) and for expansion (synthesizing a concrete route).
#[derive(Debug, Clone)]
pub struct RouteTemplate {
pub id: Uuid,
pub group_id: GroupId,
pub name: String,
pub script_name: String,
pub method: Option<String>,
pub host_kind: HostKind,
pub host: String,
pub host_param_name: Option<String>,
pub path_kind: PathKind,
pub path: String,
pub dispatch_mode: DispatchMode,
pub enabled: bool,
}
#[derive(sqlx::FromRow)]
struct RouteTemplateRow {
id: Uuid,
group_id: Uuid,
name: String,
script_name: String,
method: Option<String>,
host_kind: String,
host: String,
host_param_name: Option<String>,
path_kind: String,
path: String,
dispatch_mode: String,
enabled: bool,
}
impl From<RouteTemplateRow> for RouteTemplate {
fn from(r: RouteTemplateRow) -> Self {
Self {
id: r.id,
group_id: r.group_id.into(),
name: r.name,
script_name: r.script_name,
method: r.method,
host_kind: match r.host_kind.as_str() {
"strict" => HostKind::Strict,
"wildcard" => HostKind::Wildcard,
_ => HostKind::Any,
},
host: r.host,
host_param_name: r.host_param_name,
path_kind: match r.path_kind.as_str() {
"prefix" => PathKind::Prefix,
"param" => PathKind::Param,
_ => PathKind::Exact,
},
path: r.path,
dispatch_mode: DispatchMode::from_wire(&r.dispatch_mode).unwrap_or(DispatchMode::Sync),
enabled: r.enabled,
}
}
}
const COLS: &str = "id, group_id, name, script_name, method, host_kind, host, \
host_param_name, path_kind, path, dispatch_mode, enabled";
const fn host_kind_str(k: HostKind) -> &'static str {
match k {
HostKind::Any => "any",
HostKind::Strict => "strict",
HostKind::Wildcard => "wildcard",
}
}
const fn path_kind_str(k: PathKind) -> &'static str {
match k {
PathKind::Exact => "exact",
PathKind::Prefix => "prefix",
PathKind::Param => "param",
}
}
/// A group's OWN route templates (read/diff path).
pub(crate) async fn list_for_group(
pool: &PgPool,
group_id: GroupId,
) -> Result<Vec<RouteTemplate>, Error> {
let rows = sqlx::query_as::<_, RouteTemplateRow>(&format!(
"SELECT {COLS} FROM route_templates WHERE group_id = $1 ORDER BY LOWER(name)"
))
.bind(group_id.into_inner())
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
/// Every route template owned by any group in `group_ids` (expansion path,
/// in-tx so it sees templates just reconciled earlier in this apply).
pub(crate) async fn list_for_groups_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_ids: &[GroupId],
) -> Result<Vec<RouteTemplate>, Error> {
if group_ids.is_empty() {
return Ok(Vec::new());
}
let ids: Vec<Uuid> = group_ids.iter().map(|g| g.into_inner()).collect();
let rows = sqlx::query_as::<_, RouteTemplateRow>(&format!(
"SELECT {COLS} FROM route_templates WHERE group_id = ANY($1) ORDER BY LOWER(name)"
))
.bind(&ids)
.fetch_all(&mut **tx)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
/// Insert a new route template (diff Op::Create).
pub(crate) async fn insert_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
spec: &RouteTemplateSpec,
) -> Result<(), Error> {
let r = &spec.route;
sqlx::query(
"INSERT INTO route_templates ( \
group_id, name, script_name, method, host_kind, host, \
host_param_name, path_kind, path, dispatch_mode, enabled \
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
)
.bind(group_id.into_inner())
.bind(&spec.name)
.bind(&r.script)
.bind(r.method.as_deref())
.bind(host_kind_str(r.host_kind))
.bind(&r.host)
.bind(r.host_param_name.as_deref())
.bind(path_kind_str(r.path_kind))
.bind(&r.path)
.bind(r.dispatch_mode.as_str())
.bind(r.enabled)
.execute(&mut **tx)
.await?;
Ok(())
}
/// Replace an existing route template's fields, keyed by `(group, lower(name))`
/// (diff Op::Update).
pub(crate) async fn update_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
spec: &RouteTemplateSpec,
) -> Result<(), Error> {
let r = &spec.route;
sqlx::query(
"UPDATE route_templates SET \
script_name = $3, method = $4, host_kind = $5, host = $6, \
host_param_name = $7, path_kind = $8, path = $9, dispatch_mode = $10, \
enabled = $11, updated_at = NOW() \
WHERE group_id = $1 AND LOWER(name) = LOWER($2)",
)
.bind(group_id.into_inner())
.bind(&spec.name)
.bind(&r.script)
.bind(r.method.as_deref())
.bind(host_kind_str(r.host_kind))
.bind(&r.host)
.bind(r.host_param_name.as_deref())
.bind(path_kind_str(r.path_kind))
.bind(&r.path)
.bind(r.dispatch_mode.as_str())
.bind(r.enabled)
.execute(&mut **tx)
.await?;
Ok(())
}
/// Delete a route template by name (diff Op::Delete under `--prune`).
pub(crate) async fn delete_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
name: &str,
) -> Result<(), Error> {
sqlx::query("DELETE FROM route_templates WHERE group_id = $1 AND LOWER(name) = LOWER($2)")
.bind(group_id.into_inner())
.bind(name)
.execute(&mut **tx)
.await?;
Ok(())
}
// ----------------------------------------------------------------------------
// Trigger templates (§4.5, M4b). The kind-specific params vary, so the whole
// `BundleTrigger` wire object is stored as `spec` JSONB (placeholders
// unresolved); the apply rebuilds + resolves it per descendant.
// ----------------------------------------------------------------------------
/// A persisted trigger template: name + the raw (unresolved) `BundleTrigger`
/// wire object.
#[derive(Debug, Clone)]
pub struct TriggerTemplate {
pub id: Uuid,
pub group_id: GroupId,
pub name: String,
pub spec: serde_json::Value,
}
#[derive(sqlx::FromRow)]
struct TriggerTemplateRow {
id: Uuid,
group_id: Uuid,
name: String,
spec: serde_json::Value,
}
impl From<TriggerTemplateRow> for TriggerTemplate {
fn from(r: TriggerTemplateRow) -> Self {
Self {
id: r.id,
group_id: r.group_id.into(),
name: r.name,
spec: r.spec,
}
}
}
/// A group's OWN trigger templates (read/diff path).
pub(crate) async fn list_triggers_for_group(
pool: &PgPool,
group_id: GroupId,
) -> Result<Vec<TriggerTemplate>, Error> {
let rows = sqlx::query_as::<_, TriggerTemplateRow>(
"SELECT id, group_id, name, spec FROM trigger_templates \
WHERE group_id = $1 ORDER BY LOWER(name)",
)
.bind(group_id.into_inner())
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
/// Every trigger template owned by any group in `group_ids` (expansion path).
pub(crate) async fn list_triggers_for_groups_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_ids: &[GroupId],
) -> Result<Vec<TriggerTemplate>, Error> {
if group_ids.is_empty() {
return Ok(Vec::new());
}
let ids: Vec<Uuid> = group_ids.iter().map(|g| g.into_inner()).collect();
let rows = sqlx::query_as::<_, TriggerTemplateRow>(
"SELECT id, group_id, name, spec FROM trigger_templates \
WHERE group_id = ANY($1) ORDER BY LOWER(name)",
)
.bind(&ids)
.fetch_all(&mut **tx)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
/// Insert a new trigger template (diff Op::Create).
pub(crate) async fn insert_trigger_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
name: &str,
spec: &serde_json::Value,
) -> Result<(), Error> {
sqlx::query("INSERT INTO trigger_templates (group_id, name, spec) VALUES ($1, $2, $3)")
.bind(group_id.into_inner())
.bind(name)
.bind(spec)
.execute(&mut **tx)
.await?;
Ok(())
}
/// Replace a trigger template's spec, keyed by `(group, lower(name))` (Update).
pub(crate) async fn update_trigger_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
name: &str,
spec: &serde_json::Value,
) -> Result<(), Error> {
sqlx::query(
"UPDATE trigger_templates SET spec = $3, updated_at = NOW() \
WHERE group_id = $1 AND LOWER(name) = LOWER($2)",
)
.bind(group_id.into_inner())
.bind(name)
.bind(spec)
.execute(&mut **tx)
.await?;
Ok(())
}
/// Delete a trigger template by name (Delete under `--prune`).
pub(crate) async fn delete_trigger_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
name: &str,
) -> Result<(), Error> {
sqlx::query("DELETE FROM trigger_templates WHERE group_id = $1 AND LOWER(name) = LOWER($2)")
.bind(group_id.into_inner())
.bind(name)
.execute(&mut **tx)
.await?;
Ok(())
}

View File

@@ -519,6 +519,7 @@ pub(crate) async fn insert_trigger_tx(
retry_backoff: BackoffShape,
retry_base_ms: u32,
details: &TriggerDetails,
from_template: Option<Uuid>,
) -> Result<TriggerId, TriggerRepoError> {
let kind = match details {
TriggerDetails::Kv { .. } => "kv",
@@ -562,8 +563,8 @@ pub(crate) async fn insert_trigger_tx(
"INSERT INTO triggers ( \
app_id, script_id, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal \
) VALUES ($1, $2, $3, TRUE, $4, $5, $6, $7, $8) RETURNING id",
registered_by_principal, from_template \
) VALUES ($1, $2, $3, TRUE, $4, $5, $6, $7, $8, $9) RETURNING id",
)
.bind(app_id.into_inner())
.bind(script_id.into_inner())
@@ -573,6 +574,7 @@ pub(crate) async fn insert_trigger_tx(
.bind(retry_backoff.as_str())
.bind(i32::try_from(retry_base_ms).unwrap_or(1000))
.bind(registered_by.into_inner())
.bind(from_template)
.fetch_one(&mut **tx)
.await?;
let tid = row.0;
@@ -679,17 +681,19 @@ pub(crate) async fn insert_email_trigger_tx(
registered_by: AdminUserId,
inbound_secret_encrypted: &[u8],
inbound_secret_nonce: &[u8],
from_template: Option<Uuid>,
) -> Result<TriggerId, TriggerRepoError> {
let row: (Uuid,) = sqlx::query_as(
"INSERT INTO triggers ( \
app_id, script_id, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal \
) VALUES ($1, $2, 'email', TRUE, 'async', 3, 'exponential', 1000, $3) RETURNING id",
registered_by_principal, from_template \
) VALUES ($1, $2, 'email', TRUE, 'async', 3, 'exponential', 1000, $3, $4) RETURNING id",
)
.bind(app_id.into_inner())
.bind(script_id.into_inner())
.bind(registered_by.into_inner())
.bind(from_template)
.fetch_one(&mut **tx)
.await?;
sqlx::query(

View File

@@ -289,7 +289,17 @@ async fn validate_trigger_target(
.ok_or_else(|| {
TriggersApiError::Invalid(format!("script {script_id} not found in this app"))
})?;
if script.app_id != app_id {
// Phase 4: a trigger target must be invocable in this app's context —
// owned by the app, or by a group on the app's ancestor chain (inherited).
// App-owned is the in-memory fast path (no query); only a group-owned
// candidate pays the chain lookup.
let invocable = script.is_owned_by_app(app_id)
|| (script.group_id.is_some()
&& scripts
.is_invocable_by_app(script_id, app_id)
.await
.map_err(map_script_repo_err)?);
if !invocable {
return Err(TriggersApiError::Invalid(format!(
"script {script_id} does not belong to this app"
)));
@@ -1348,7 +1358,8 @@ mod tests {
script_id,
picloud_shared::Script {
id: script_id,
app_id,
app_id: Some(app_id),
group_id: None,
name: format!(
"{}_{}",
match kind {
@@ -1393,9 +1404,29 @@ mod tests {
.lock()
.await
.values()
.find(|s| s.app_id == app_id && s.name == name)
.find(|s| s.app_id == Some(app_id) && s.name == name)
.cloned())
}
async fn get_by_name_inherited(
&self,
app_id: AppId,
name: &str,
) -> Result<Option<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
// No hierarchy in this in-memory repo; inherited == own-scope.
self.get_by_name(app_id, name).await
}
async fn is_invocable_by_app(
&self,
script_id: ScriptId,
app_id: AppId,
) -> Result<bool, crate::repo::ScriptRepositoryError> {
Ok(self
.existing
.lock()
.await
.get(&script_id)
.is_some_and(|s| s.is_owned_by_app(app_id)))
}
async fn list(
&self,
) -> Result<Vec<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
@@ -1407,6 +1438,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

@@ -0,0 +1,410 @@
//! `/api/v1/admin/{apps,groups}/{id_or_slug}/vars*` — the Phase-3 config
//! `vars` admin surface (write/list side; resolution lives in
//! `config_resolver` + the `vars::` SDK).
//!
//! * `GET /apps/{id}/vars` — list the app's OWN vars.
//! * `PUT /apps/{id}/vars` — set/overwrite one app var.
//! * `DELETE /apps/{id}/vars/{key}` — delete one app var.
//! * `GET/PUT/DELETE /groups/{id}/vars[...]` — same, group-owned.
//!
//! App routes gate on `App{Vars}Read/Write`; group routes on
//! `Group{Vars}Read/Write`. The owner is resolved FIRST (slug-or-uuid),
//! THEN `authz::require` binds the capability to the resolved owner id —
//! never to a caller-controlled path param. Listing returns the owner's
//! OWN rows only (not the resolved/inherited view).
use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{get, put};
use axum::{Extension, Router};
use picloud_shared::{AppId, GroupId, Principal};
use serde::Deserialize;
use serde_json::json;
use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
use crate::group_repo::GroupRepository;
use crate::vars_repo::{VarOwner, VarsRepo, VarsRepoError};
#[derive(Clone)]
pub struct VarsApiState {
pub vars: Arc<dyn VarsRepo>,
pub apps: Arc<dyn AppRepository>,
pub groups: Arc<dyn GroupRepository>,
pub authz: Arc<dyn AuthzRepo>,
}
pub fn vars_router(state: VarsApiState) -> Router {
Router::new()
.route(
"/apps/{id_or_slug}/vars",
get(list_app_vars).put(set_app_var),
)
.route(
"/apps/{id_or_slug}/vars/{key}",
axum::routing::delete(delete_app_var),
)
.route(
"/groups/{id_or_slug}/vars",
put(set_group_var).get(list_group_vars),
)
.route(
"/groups/{id_or_slug}/vars/{key}",
axum::routing::delete(delete_group_var),
)
.with_state(state)
}
// ----------------------------------------------------------------------------
// DTOs
// ----------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct SetVarRequest {
pub key: String,
pub value: serde_json::Value,
/// Environment scope — `*` (env-agnostic, default) or a concrete env
/// name matched against `apps.environment` at resolution time.
#[serde(default)]
pub env: Option<String>,
/// Write a tombstone (suppresses an inherited key) instead of a real
/// value. The body's `value` is ignored for a tombstone.
#[serde(default)]
pub tombstone: bool,
}
#[derive(Debug, Deserialize)]
pub struct EnvQuery {
#[serde(default)]
pub env: Option<String>,
}
#[derive(Debug, serde::Serialize)]
struct VarItem {
key: String,
env: String,
value: serde_json::Value,
is_tombstone: bool,
updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, serde::Serialize)]
struct ListVarsResponse {
vars: Vec<VarItem>,
}
// ----------------------------------------------------------------------------
// App handlers
// ----------------------------------------------------------------------------
async fn list_app_vars(
State(s): State<VarsApiState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<ListVarsResponse>, VarsApiError> {
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::AppVarsRead(app_id),
)
.await?;
list(&*s.vars, VarOwner::App(app_id)).await
}
async fn set_app_var(
State(s): State<VarsApiState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(input): Json<SetVarRequest>,
) -> Result<StatusCode, VarsApiError> {
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::AppVarsWrite(app_id),
)
.await?;
set(&*s.vars, VarOwner::App(app_id), input).await
}
async fn delete_app_var(
State(s): State<VarsApiState>,
Extension(principal): Extension<Principal>,
Path((id_or_slug, key)): Path<(String, String)>,
Query(q): Query<EnvQuery>,
) -> Result<StatusCode, VarsApiError> {
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::AppVarsWrite(app_id),
)
.await?;
delete(&*s.vars, VarOwner::App(app_id), &key, q.env.as_deref()).await
}
// ----------------------------------------------------------------------------
// Group handlers
// ----------------------------------------------------------------------------
async fn list_group_vars(
State(s): State<VarsApiState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<ListVarsResponse>, VarsApiError> {
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupVarsRead(group_id),
)
.await?;
list(&*s.vars, VarOwner::Group(group_id)).await
}
async fn set_group_var(
State(s): State<VarsApiState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(input): Json<SetVarRequest>,
) -> Result<StatusCode, VarsApiError> {
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupVarsWrite(group_id),
)
.await?;
set(&*s.vars, VarOwner::Group(group_id), input).await
}
async fn delete_group_var(
State(s): State<VarsApiState>,
Extension(principal): Extension<Principal>,
Path((id_or_slug, key)): Path<(String, String)>,
Query(q): Query<EnvQuery>,
) -> Result<StatusCode, VarsApiError> {
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupVarsWrite(group_id),
)
.await?;
delete(&*s.vars, VarOwner::Group(group_id), &key, q.env.as_deref()).await
}
// ----------------------------------------------------------------------------
// Shared owner-generic bodies
// ----------------------------------------------------------------------------
async fn list(
vars: &dyn VarsRepo,
owner: VarOwner,
) -> Result<Json<ListVarsResponse>, VarsApiError> {
let rows = vars.list_for_owner(owner).await?;
Ok(Json(ListVarsResponse {
vars: rows
.into_iter()
.map(|r| VarItem {
key: r.key,
env: r.environment_scope,
value: r.value,
is_tombstone: r.is_tombstone,
updated_at: r.updated_at,
})
.collect(),
}))
}
async fn set(
vars: &dyn VarsRepo,
owner: VarOwner,
input: SetVarRequest,
) -> Result<StatusCode, VarsApiError> {
validate_key(&input.key)?;
let env = input.env.as_deref().unwrap_or("*");
validate_env_scope(env)?;
// A tombstone carries no meaningful value (the resolver suppresses the
// key regardless); store JSON null so the NOT NULL column is satisfied.
let value = if input.tombstone {
serde_json::Value::Null
} else {
input.value
};
vars.set(owner, env, &input.key, &value, input.tombstone)
.await?;
Ok(StatusCode::NO_CONTENT)
}
async fn delete(
vars: &dyn VarsRepo,
owner: VarOwner,
key: &str,
env: Option<&str>,
) -> Result<StatusCode, VarsApiError> {
let env = env.unwrap_or("*");
validate_env_scope(env)?;
if !vars.delete(owner, env, key).await? {
return Err(VarsApiError::NotFound);
}
Ok(StatusCode::NO_CONTENT)
}
// ----------------------------------------------------------------------------
// Resolution + validation
// ----------------------------------------------------------------------------
async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, VarsApiError> {
crate::app_repo::resolve_app(apps, ident)
.await
.map_err(|e| VarsApiError::Backend(e.to_string()))?
.map(|l| l.app.id)
.ok_or(VarsApiError::AppNotFound)
}
async fn resolve_group(groups: &dyn GroupRepository, ident: &str) -> Result<GroupId, VarsApiError> {
let found = if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
groups
.get_by_id(uuid.into())
.await
.map_err(|e| VarsApiError::Backend(e.to_string()))?
} else {
groups
.get_by_slug(ident)
.await
.map_err(|e| VarsApiError::Backend(e.to_string()))?
};
found.map(|g| g.id).ok_or(VarsApiError::GroupNotFound)
}
/// Keys are kebab identifiers (`^[a-z0-9][a-z0-9-]*$`) — same shape as the
/// manifest's var names (docs/design §4.3).
fn validate_key(key: &str) -> Result<(), VarsApiError> {
if key.is_empty() || key.len() > 128 {
return Err(VarsApiError::Invalid("key must be 1128 characters".into()));
}
let mut chars = key.chars();
let first = chars.next().unwrap();
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
return Err(VarsApiError::Invalid(
"key must start with a lowercase letter or digit".into(),
));
}
if !key
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(VarsApiError::Invalid(
"key may contain only lowercase letters, digits, and hyphens".into(),
));
}
Ok(())
}
/// Env scope is `*` (env-agnostic) or a kebab env name.
fn validate_env_scope(env: &str) -> Result<(), VarsApiError> {
if env == "*" {
return Ok(());
}
if env.is_empty() || env.len() > 63 {
return Err(VarsApiError::Invalid(
"env must be '*' or 163 characters".into(),
));
}
let mut chars = env.chars();
let first = chars.next().unwrap();
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
return Err(VarsApiError::Invalid(
"env must start with a lowercase letter or digit".into(),
));
}
if !env
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(VarsApiError::Invalid(
"env may contain only lowercase letters, digits, and hyphens".into(),
));
}
Ok(())
}
// ----------------------------------------------------------------------------
// Errors
// ----------------------------------------------------------------------------
#[derive(Debug, thiserror::Error)]
pub enum VarsApiError {
#[error("app not found")]
AppNotFound,
#[error("group not found")]
GroupNotFound,
#[error("var not found")]
NotFound,
#[error("invalid request: {0}")]
Invalid(String),
#[error("forbidden")]
Forbidden,
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("vars backend: {0}")]
Backend(String),
}
impl From<AuthzDenied> for VarsApiError {
fn from(d: AuthzDenied) -> Self {
match d {
AuthzDenied::Denied => Self::Forbidden,
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
}
}
}
impl From<AuthzError> for VarsApiError {
fn from(e: AuthzError) -> Self {
Self::AuthzRepo(e.to_string())
}
}
impl From<VarsRepoError> for VarsApiError {
fn from(e: VarsRepoError) -> Self {
match e {
VarsRepoError::Db(e) => Self::Backend(e.to_string()),
}
}
}
impl IntoResponse for VarsApiError {
fn into_response(self) -> Response {
let (status, body) = match &self {
Self::AppNotFound | Self::GroupNotFound | Self::NotFound => {
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
}
Self::Invalid(_) => (
StatusCode::UNPROCESSABLE_ENTITY,
json!({ "error": self.to_string() }),
),
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
Self::AuthzRepo(e) => {
tracing::error!(error = %e, "vars admin authz repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
Self::Backend(e) => {
tracing::error!(error = %e, "vars admin backend error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
};
(status, Json(body)).into_response()
}
}

View File

@@ -0,0 +1,210 @@
//! Low-level Postgres CRUD over `vars` — the write/admin side of the
//! Phase-3 config layer (the read/resolution side lives in
//! `config_resolver`). Storage-only: it upserts and lists an owner's OWN
//! rows. Authorization, env-scope validation, and value encoding live one
//! layer up in `vars_api`.
//!
//! A var is owned by exactly one group OR one app (the migration's
//! `vars_owner_exactly_one` CHECK). Because the owner is split across two
//! nullable columns (`group_id`, `app_id`), the upsert writes
//! owner-kind-specific SQL with the matching partial-unique conflict
//! target.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use picloud_shared::{AppId, GroupId};
use serde_json::Value as JsonValue;
use sqlx::PgPool;
#[derive(Debug, thiserror::Error)]
pub enum VarsRepoError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
}
/// Which side of the polymorphic owner a var hangs off. The repo chooses
/// the conflict target (group vs app partial-unique index) from this.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VarOwner {
Group(GroupId),
App(AppId),
}
/// One of an owner's OWN var rows (NOT a resolved/inherited value). Backs
/// the admin list surface.
#[derive(Debug, Clone)]
pub struct VarRow {
pub environment_scope: String,
pub key: String,
pub value: JsonValue,
pub is_tombstone: bool,
pub updated_at: DateTime<Utc>,
}
/// Repo surface. A trait so service/handler tests can substitute an
/// in-memory backing without Postgres.
#[async_trait]
pub trait VarsRepo: Send + Sync {
/// Upsert one (owner, env_scope, key) row.
async fn set(
&self,
owner: VarOwner,
env_scope: &str,
key: &str,
value: &JsonValue,
is_tombstone: bool,
) -> Result<(), VarsRepoError>;
/// Delete one (owner, env_scope, key) row; returns whether a row was
/// present.
async fn delete(
&self,
owner: VarOwner,
env_scope: &str,
key: &str,
) -> Result<bool, VarsRepoError>;
/// The owner's OWN rows only (NOT resolved/inherited), ordered by
/// (key, environment_scope).
async fn list_for_owner(&self, owner: VarOwner) -> Result<Vec<VarRow>, VarsRepoError>;
}
pub struct PostgresVarsRepo {
pool: PgPool,
}
impl PostgresVarsRepo {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl VarsRepo for PostgresVarsRepo {
async fn set(
&self,
owner: VarOwner,
env_scope: &str,
key: &str,
value: &JsonValue,
is_tombstone: bool,
) -> Result<(), VarsRepoError> {
// Owner-kind-specific SQL: only one of the two nullable owner
// columns is written, and the conflict target is the matching
// partial-unique index.
match owner {
VarOwner::Group(g) => {
sqlx::query(
// The conflict target is a PARTIAL unique index, so the
// index predicate (`WHERE group_id IS NOT NULL`) must be
// restated for Postgres to infer the arbiter.
"INSERT INTO vars (group_id, environment_scope, key, value, is_tombstone) \
VALUES ($1, $2, $3, $4, $5) \
ON CONFLICT (group_id, environment_scope, key) \
WHERE group_id IS NOT NULL DO UPDATE \
SET value = EXCLUDED.value, \
is_tombstone = EXCLUDED.is_tombstone, \
updated_at = NOW()",
)
.bind(g.into_inner())
.bind(env_scope)
.bind(key)
.bind(value)
.bind(is_tombstone)
.execute(&self.pool)
.await?;
}
VarOwner::App(a) => {
sqlx::query(
// Partial-index conflict target — restate the predicate.
"INSERT INTO vars (app_id, environment_scope, key, value, is_tombstone) \
VALUES ($1, $2, $3, $4, $5) \
ON CONFLICT (app_id, environment_scope, key) \
WHERE app_id IS NOT NULL DO UPDATE \
SET value = EXCLUDED.value, \
is_tombstone = EXCLUDED.is_tombstone, \
updated_at = NOW()",
)
.bind(a.into_inner())
.bind(env_scope)
.bind(key)
.bind(value)
.bind(is_tombstone)
.execute(&self.pool)
.await?;
}
}
Ok(())
}
async fn delete(
&self,
owner: VarOwner,
env_scope: &str,
key: &str,
) -> Result<bool, VarsRepoError> {
let res = match owner {
VarOwner::Group(g) => {
sqlx::query(
"DELETE FROM vars \
WHERE group_id = $1 AND environment_scope = $2 AND key = $3",
)
.bind(g.into_inner())
.bind(env_scope)
.bind(key)
.execute(&self.pool)
.await?
}
VarOwner::App(a) => {
sqlx::query(
"DELETE FROM vars \
WHERE app_id = $1 AND environment_scope = $2 AND key = $3",
)
.bind(a.into_inner())
.bind(env_scope)
.bind(key)
.execute(&self.pool)
.await?
}
};
Ok(res.rows_affected() > 0)
}
async fn list_for_owner(&self, owner: VarOwner) -> Result<Vec<VarRow>, VarsRepoError> {
let rows: Vec<(String, String, JsonValue, bool, DateTime<Utc>)> = match owner {
VarOwner::Group(g) => {
sqlx::query_as(
"SELECT environment_scope, key, value, is_tombstone, updated_at \
FROM vars WHERE group_id = $1 \
ORDER BY key ASC, environment_scope ASC",
)
.bind(g.into_inner())
.fetch_all(&self.pool)
.await?
}
VarOwner::App(a) => {
sqlx::query_as(
"SELECT environment_scope, key, value, is_tombstone, updated_at \
FROM vars WHERE app_id = $1 \
ORDER BY key ASC, environment_scope ASC",
)
.bind(a.into_inner())
.fetch_all(&self.pool)
.await?
}
};
Ok(rows
.into_iter()
.map(
|(environment_scope, key, value, is_tombstone, updated_at)| VarRow {
environment_scope,
key,
value,
is_tombstone,
updated_at,
},
)
.collect())
}
}

View File

@@ -0,0 +1,63 @@
//! `VarsService` — the runtime read path for group-inherited config.
//!
//! Resolves the calling app's config (own rows + inherited group rows,
//! env-filtered, proximity-first; see `config_resolver`) and exposes it to
//! scripts as `vars::get(key)` / `vars::all()`. Read-only from scripts;
//! writes go through the admin API. Like every SDK service, it derives the
//! app from `cx.app_id` — never a script argument — so cross-app isolation
//! holds.
use std::collections::BTreeMap;
use std::sync::Arc;
use async_trait::async_trait;
use picloud_shared::{SdkCallCx, VarsError, VarsService};
use serde_json::Value;
use sqlx::PgPool;
use crate::authz::{self, AuthzRepo, Capability};
use crate::config_resolver::{fetch_var_candidates, resolve};
pub struct VarsServiceImpl {
pool: PgPool,
authz: Arc<dyn AuthzRepo>,
}
impl VarsServiceImpl {
#[must_use]
pub fn new(pool: PgPool, authz: Arc<dyn AuthzRepo>) -> Self {
Self { pool, authz }
}
/// Authed principals need `AppVarsRead`; anonymous public-HTTP scripts
/// (`principal: None`) read freely under script-as-gate semantics.
async fn check_read(&self, cx: &SdkCallCx) -> Result<(), VarsError> {
if let Some(ref principal) = cx.principal {
authz::require(&*self.authz, principal, Capability::AppVarsRead(cx.app_id))
.await
.map_err(|_| VarsError::Forbidden)?;
}
Ok(())
}
async fn resolved(&self, cx: &SdkCallCx) -> Result<BTreeMap<String, Value>, VarsError> {
let candidates = fetch_var_candidates(&self.pool, cx.app_id)
.await
.map_err(|e| VarsError::Backend(e.to_string()))?;
let (values, _provenance) = resolve(candidates);
Ok(values)
}
}
#[async_trait]
impl VarsService for VarsServiceImpl {
async fn get(&self, cx: &SdkCallCx, key: &str) -> Result<Option<Value>, VarsError> {
self.check_read(cx).await?;
Ok(self.resolved(cx).await?.remove(key))
}
async fn all(&self, cx: &SdkCallCx) -> Result<BTreeMap<String, Value>, VarsError> {
self.check_read(cx).await?;
self.resolved(cx).await
}
}

View File

@@ -132,6 +132,8 @@ table: apps
description: text NULL
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
group_id: uuid NOT NULL
environment: text NULL
table: cron_trigger_details
trigger_id: uuid NOT NULL
@@ -196,6 +198,15 @@ table: execution_logs
app_id: uuid NOT NULL
source: text NOT NULL default='http'::text
table: extension_points
id: uuid NOT NULL default=gen_random_uuid()
group_id: uuid NULL
app_id: uuid NULL
name: text NOT NULL
default_script_id: uuid NULL
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
table: files
app_id: uuid NOT NULL
collection: text NOT NULL
@@ -212,6 +223,23 @@ table: files_trigger_details
collection_glob: text NOT NULL
ops: ARRAY NOT NULL
table: group_members
group_id: uuid NOT NULL
user_id: uuid NOT NULL
role: text NOT NULL
created_at: timestamp with time zone NOT NULL default=now()
table: groups
id: uuid NOT NULL default=gen_random_uuid()
parent_id: uuid NULL
slug: text NOT NULL
name: text NOT NULL
description: text NULL
structure_version: bigint NOT NULL default=1
owner_project: uuid NULL
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
table: kv_entries
app_id: uuid NOT NULL
collection: text NOT NULL
@@ -242,6 +270,11 @@ table: outbox
claimed_by: text NULL
created_at: timestamp with time zone NOT NULL default=now()
table: projects
id: uuid NOT NULL default=gen_random_uuid()
key: text NOT NULL
created_at: timestamp with time zone NOT NULL default=now()
table: pubsub_trigger_details
trigger_id: uuid NOT NULL
topic_pattern: text NOT NULL
@@ -265,6 +298,22 @@ table: queue_trigger_details
visibility_timeout_secs: integer NOT NULL default=30
last_fired_at: timestamp with time zone NULL
table: route_templates
id: uuid NOT NULL default=gen_random_uuid()
group_id: uuid NOT NULL
name: text NOT NULL
script_name: text NOT NULL
method: text NULL
host_kind: text NOT NULL
host: text NOT NULL default=''::text
host_param_name: text NULL
path_kind: text NOT NULL
path: text NOT NULL
dispatch_mode: text NOT NULL default='sync'::text
enabled: boolean NOT NULL default=true
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
table: routes
id: uuid NOT NULL default=gen_random_uuid()
script_id: uuid NOT NULL
@@ -277,6 +326,8 @@ table: routes
created_at: timestamp with time zone NOT NULL default=now()
app_id: uuid NOT NULL
dispatch_mode: text NOT NULL default='sync'::text
enabled: boolean NOT NULL default=true
from_template: uuid NULL
table: script_imports
app_id: uuid NOT NULL
@@ -295,17 +346,21 @@ table: scripts
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
sandbox: jsonb NOT NULL default='{}'::jsonb
app_id: uuid NOT NULL
app_id: uuid NULL
kind: text NOT NULL default='endpoint'::text
enabled: boolean NOT NULL default=true
group_id: uuid NULL
table: secrets
app_id: uuid NOT NULL
app_id: uuid NULL
name: text NOT NULL
encrypted_value: bytea NOT NULL
nonce: bytea NOT NULL
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
version: smallint NOT NULL default=0
group_id: uuid NULL
environment_scope: text NOT NULL default='*'::text
table: topics
app_id: uuid NOT NULL
@@ -315,6 +370,14 @@ table: topics
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
table: trigger_templates
id: uuid NOT NULL default=gen_random_uuid()
group_id: uuid NOT NULL
name: text NOT NULL
spec: jsonb NOT NULL
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
table: triggers
id: uuid NOT NULL default=gen_random_uuid()
app_id: uuid NOT NULL
@@ -328,6 +391,19 @@ table: triggers
registered_by_principal: uuid NOT NULL
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
name: text NOT NULL default=(gen_random_uuid())::text
from_template: uuid NULL
table: vars
id: uuid NOT NULL default=gen_random_uuid()
group_id: uuid NULL
app_id: uuid NULL
environment_scope: text NOT NULL default='*'::text
key: text NOT NULL
value: jsonb NOT NULL
is_tombstone: boolean NOT NULL default=false
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
## indexes
@@ -395,6 +471,7 @@ indexes on app_users:
idx_app_users_app_email_lower: public.app_users USING btree (app_id, lower(email))
indexes on apps:
apps_group_id_idx: public.apps USING btree (group_id)
apps_pkey: public.apps USING btree (id)
apps_slug_key: public.apps USING btree (slug)
@@ -426,6 +503,14 @@ indexes on execution_logs:
execution_logs_pkey: public.execution_logs USING btree (id)
execution_logs_script_id_created_at_idx: public.execution_logs USING btree (script_id, created_at DESC)
indexes on extension_points:
extension_points_app_id_idx: public.extension_points USING btree (app_id) WHERE (app_id IS NOT NULL)
extension_points_app_uidx: public.extension_points USING btree (app_id, lower(name)) WHERE (app_id IS NOT NULL)
extension_points_default_script_idx: public.extension_points USING btree (default_script_id) WHERE (default_script_id IS NOT NULL)
extension_points_group_id_idx: public.extension_points USING btree (group_id) WHERE (group_id IS NOT NULL)
extension_points_group_uidx: public.extension_points USING btree (group_id, lower(name)) WHERE (group_id IS NOT NULL)
extension_points_pkey: public.extension_points USING btree (id)
indexes on files:
files_pkey: public.files USING btree (app_id, collection, id)
idx_files_app_collection: public.files USING btree (app_id, collection)
@@ -433,6 +518,16 @@ indexes on files:
indexes on files_trigger_details:
files_trigger_details_pkey: public.files_trigger_details USING btree (trigger_id)
indexes on group_members:
group_members_pkey: public.group_members USING btree (group_id, user_id)
group_members_user_id_idx: public.group_members USING btree (user_id)
indexes on groups:
groups_owner_project_idx: public.groups USING btree (owner_project)
groups_parent_id_idx: public.groups USING btree (parent_id)
groups_pkey: public.groups USING btree (id)
groups_slug_key: public.groups USING btree (slug)
indexes on kv_entries:
idx_kv_entries_app_collection: public.kv_entries USING btree (app_id, collection)
kv_entries_pkey: public.kv_entries USING btree (app_id, collection, key)
@@ -445,6 +540,10 @@ indexes on outbox:
idx_outbox_due: public.outbox USING btree (next_attempt_at) WHERE (claimed_at IS NULL)
outbox_pkey: public.outbox USING btree (id)
indexes on projects:
projects_key_key: public.projects USING btree (key)
projects_pkey: public.projects USING btree (id)
indexes on pubsub_trigger_details:
pubsub_trigger_details_pkey: public.pubsub_trigger_details USING btree (trigger_id)
@@ -458,8 +557,13 @@ indexes on queue_trigger_details:
idx_queue_trigger_details_queue_name: public.queue_trigger_details USING btree (queue_name)
queue_trigger_details_pkey: public.queue_trigger_details USING btree (trigger_id)
indexes on route_templates:
route_templates_group_name_idx: public.route_templates USING btree (group_id, lower(name))
route_templates_pkey: public.route_templates USING btree (id)
indexes on routes:
routes_app_id_idx: public.routes USING btree (app_id)
routes_from_template_idx: public.routes USING btree (app_id, from_template)
routes_lookup_idx: public.routes USING btree (host_kind, host)
routes_pkey: public.routes USING btree (id)
routes_script_id_idx: public.routes USING btree (script_id)
@@ -473,22 +577,39 @@ indexes on script_imports:
indexes on scripts:
idx_scripts_app_kind: public.scripts USING btree (app_id, kind)
scripts_app_id_idx: public.scripts USING btree (app_id)
scripts_name_uidx: public.scripts USING btree (app_id, lower(name))
scripts_app_name_uidx: public.scripts USING btree (app_id, lower(name)) WHERE (app_id IS NOT NULL)
scripts_group_id_idx: public.scripts USING btree (group_id) WHERE (group_id IS NOT NULL)
scripts_group_name_uidx: public.scripts USING btree (group_id, lower(name)) WHERE (group_id IS NOT NULL)
scripts_pkey: public.scripts USING btree (id)
indexes on secrets:
idx_secrets_app: public.secrets USING btree (app_id)
secrets_pkey: public.secrets USING btree (app_id, name)
idx_secrets_group: public.secrets USING btree (group_id) WHERE (group_id IS NOT NULL)
secrets_app_uidx: public.secrets USING btree (app_id, environment_scope, name) WHERE (app_id IS NOT NULL)
secrets_group_uidx: public.secrets USING btree (group_id, environment_scope, name) WHERE (group_id IS NOT NULL)
indexes on topics:
topics_pkey: public.topics USING btree (app_id, name)
indexes on trigger_templates:
trigger_templates_group_name_idx: public.trigger_templates USING btree (group_id, lower(name))
trigger_templates_pkey: public.trigger_templates USING btree (id)
indexes on triggers:
idx_triggers_app_kind_enabled: public.triggers USING btree (app_id, kind) WHERE (enabled = true)
idx_triggers_app_pubsub_enabled: public.triggers USING btree (app_id, kind) WHERE ((enabled = true) AND (kind = 'pubsub'::text))
idx_triggers_kind_enabled: public.triggers USING btree (kind) WHERE (enabled = true)
triggers_app_name_uniq: public.triggers USING btree (app_id, name)
triggers_from_template_idx: public.triggers USING btree (app_id, from_template)
triggers_pkey: public.triggers USING btree (id)
indexes on vars:
vars_app_id_idx: public.vars USING btree (app_id) WHERE (app_id IS NOT NULL)
vars_app_uidx: public.vars USING btree (app_id, environment_scope, key) WHERE (app_id IS NOT NULL)
vars_group_id_idx: public.vars USING btree (group_id) WHERE (group_id IS NOT NULL)
vars_group_uidx: public.vars USING btree (group_id, environment_scope, key) WHERE (group_id IS NOT NULL)
vars_pkey: public.vars USING btree (id)
## constraints
constraints on abandoned_executions:
@@ -561,6 +682,7 @@ constraints on app_users:
[PRIMARY KEY] app_users_pkey: PRIMARY KEY (id)
constraints on apps:
[FOREIGN KEY] apps_group_id_fk: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE RESTRICT
[PRIMARY KEY] apps_pkey: PRIMARY KEY (id)
[UNIQUE] apps_slug_key: UNIQUE (slug)
@@ -597,6 +719,13 @@ constraints on execution_logs:
[FOREIGN KEY] execution_logs_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE SET NULL
[PRIMARY KEY] execution_logs_pkey: PRIMARY KEY (id)
constraints on extension_points:
[CHECK] extension_points_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
[FOREIGN KEY] extension_points_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[FOREIGN KEY] extension_points_default_script_id_fkey: FOREIGN KEY (default_script_id) REFERENCES scripts(id) ON DELETE SET NULL
[FOREIGN KEY] extension_points_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
[PRIMARY KEY] extension_points_pkey: PRIMARY KEY (id)
constraints on files:
[FOREIGN KEY] files_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[PRIMARY KEY] files_pkey: PRIMARY KEY (app_id, collection, id)
@@ -605,6 +734,18 @@ constraints on files_trigger_details:
[FOREIGN KEY] files_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
[PRIMARY KEY] files_trigger_details_pkey: PRIMARY KEY (trigger_id)
constraints on group_members:
[CHECK] group_members_role_check: CHECK ((role = ANY (ARRAY['app_admin'::text, 'editor'::text, 'viewer'::text])))
[FOREIGN KEY] group_members_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
[FOREIGN KEY] group_members_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
[PRIMARY KEY] group_members_pkey: PRIMARY KEY (group_id, user_id)
constraints on groups:
[FOREIGN KEY] groups_owner_project_fkey: FOREIGN KEY (owner_project) REFERENCES projects(id) ON DELETE SET NULL
[FOREIGN KEY] groups_parent_id_fkey: FOREIGN KEY (parent_id) REFERENCES groups(id) ON DELETE RESTRICT
[PRIMARY KEY] groups_pkey: PRIMARY KEY (id)
[UNIQUE] groups_slug_key: UNIQUE (slug)
constraints on kv_entries:
[FOREIGN KEY] kv_entries_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[PRIMARY KEY] kv_entries_pkey: PRIMARY KEY (app_id, collection, key)
@@ -618,6 +759,10 @@ constraints on outbox:
[FOREIGN KEY] outbox_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[PRIMARY KEY] outbox_pkey: PRIMARY KEY (id)
constraints on projects:
[PRIMARY KEY] projects_pkey: PRIMARY KEY (id)
[UNIQUE] projects_key_key: UNIQUE (key)
constraints on pubsub_trigger_details:
[FOREIGN KEY] pubsub_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
[PRIMARY KEY] pubsub_trigger_details_pkey: PRIMARY KEY (trigger_id)
@@ -630,6 +775,13 @@ constraints on queue_trigger_details:
[FOREIGN KEY] queue_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
[PRIMARY KEY] queue_trigger_details_pkey: PRIMARY KEY (trigger_id)
constraints on route_templates:
[CHECK] route_templates_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text])))
[CHECK] route_templates_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text])))
[CHECK] route_templates_path_kind_check: CHECK ((path_kind = ANY (ARRAY['exact'::text, 'prefix'::text, 'param'::text])))
[FOREIGN KEY] route_templates_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
[PRIMARY KEY] route_templates_pkey: PRIMARY KEY (id)
constraints on routes:
[CHECK] routes_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text])))
[CHECK] routes_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text])))
@@ -648,19 +800,26 @@ constraints on scripts:
[CHECK] scripts_kind_check: CHECK ((kind = ANY (ARRAY['endpoint'::text, 'module'::text])))
[CHECK] scripts_memory_limit_mb_check: CHECK (((memory_limit_mb > 0) AND (memory_limit_mb <= 2048)))
[CHECK] scripts_module_name_shape: CHECK (((kind <> 'module'::text) OR (name ~ '^[a-zA-Z_][a-zA-Z0-9_]{0,63}$'::text)))
[CHECK] scripts_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
[CHECK] scripts_timeout_seconds_check: CHECK (((timeout_seconds > 0) AND (timeout_seconds <= 300)))
[FOREIGN KEY] scripts_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE RESTRICT
[FOREIGN KEY] scripts_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE RESTRICT
[PRIMARY KEY] scripts_pkey: PRIMARY KEY (id)
constraints on secrets:
[CHECK] secrets_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
[FOREIGN KEY] secrets_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[PRIMARY KEY] secrets_pkey: PRIMARY KEY (app_id, name)
[FOREIGN KEY] secrets_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
constraints on topics:
[CHECK] topics_auth_mode_check: CHECK ((auth_mode = ANY (ARRAY['public'::text, 'token'::text, 'session'::text])))
[FOREIGN KEY] topics_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[PRIMARY KEY] topics_pkey: PRIMARY KEY (app_id, name)
constraints on trigger_templates:
[FOREIGN KEY] trigger_templates_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
[PRIMARY KEY] trigger_templates_pkey: PRIMARY KEY (id)
constraints on triggers:
[CHECK] triggers_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text])))
[CHECK] triggers_kind_check: CHECK ((kind = ANY (ARRAY['kv'::text, 'dead_letter'::text, 'docs'::text, 'cron'::text, 'files'::text, 'pubsub'::text, 'email'::text, 'queue'::text])))
@@ -670,6 +829,12 @@ constraints on triggers:
[FOREIGN KEY] triggers_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
[PRIMARY KEY] triggers_pkey: PRIMARY KEY (id)
constraints on vars:
[CHECK] vars_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
[FOREIGN KEY] vars_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[FOREIGN KEY] vars_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
[PRIMARY KEY] vars_pkey: PRIMARY KEY (id)
## applied migrations
0001: init
0002: sandbox
@@ -715,3 +880,13 @@ constraints on triggers:
0042: secrets envelope version
0043: execution logs source
0044: delete reserved path routes
0045: scripts routes enabled
0046: trigger name
0047: groups
0048: vars
0049: group secrets
0050: group scripts
0051: extension points
0052: owner project
0053: templates
0054: trigger templates

View File

@@ -126,9 +126,16 @@ where
if !script.enabled {
return Err(ApiError::NotFound(id));
}
// The direct-execute bypass runs a script under its *owning app's*
// context. A group-owned script (Phase 4) is a template with no single
// app, so it cannot be invoked through this id-addressed bypass — it
// runs only via a descendant app's route/trigger, which supplies the
// execution-context app. Fail closed (404) rather than guess an app.
let app_id = script.app_id.ok_or(ApiError::NotFound(id))?;
let mut req = build_exec_request(id, &script.name, &headers, &body, script.app_id, principal)?;
let mut req = build_exec_request(id, &script.name, &headers, &body, app_id, principal)?;
req.sandbox_overrides = script.sandbox;
req.script_owner = script.owner();
let request_id = req.request_id;
let request_path = req.path.clone();
let request_headers = req.headers.clone();
@@ -151,7 +158,7 @@ where
// audit-visible platform — but a sink failure must not mask the
// user-facing result, so we only log a warning if it fails.
let log = build_execution_log(
script.app_id,
app_id,
id,
request_id,
request_path,
@@ -622,6 +629,10 @@ fn build_exec_request(
// Overwritten by the handler after the script is resolved.
sandbox_overrides: picloud_shared::ScriptSandbox::default(),
app_id,
// Set by the handler from the resolved script's owner. The
// id-bypass runs app-owned scripts only (group scripts 404 here),
// so this is always `App(app_id)`; `None` falls back to that.
script_owner: None,
principal,
// Direct invocations are at depth 0 with a self-referential
// root. The triggers framework (v1.1.1) increments depth and

View File

@@ -34,6 +34,7 @@ toml = "0.8"
directories = "5"
rpassword = "7"
anyhow = "1"
uuid = { version = "1", features = ["v4"] }
[dev-dependencies]
assert_cmd = "2"

View File

@@ -123,6 +123,19 @@ impl Client {
decode(resp).await
}
/// `GET /api/v1/admin/apps/{id}/extension-points` — the app's OWN
/// extension-point declarations (for `pic pull`).
pub async fn extension_points_list(&self, ident: &str) -> Result<Vec<ExtPointDto>> {
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/apps/{}/extension-points", seg(ident)),
)
.send()
.await?;
decode(resp).await
}
/// `GET /api/v1/admin/scripts` — every script the caller can see
/// (server filters by membership for `Member`). Lets `pic scripts ls`
/// (no `--app`) collapse what used to be an N+1 per-app walk into a
@@ -309,6 +322,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
@@ -657,39 +697,138 @@ impl Client {
decode_status(resp).await
}
/// `GET /api/v1/admin/apps/{id}/secrets`
pub async fn secrets_list(&self, app: &str) -> Result<SecretListDto> {
let app = seg(app);
/// `GET /api/v1/admin/{apps,groups}/{id}/secrets` — secret names +
/// last-modified for the owner. Values never travel on this path. `env`
/// is only meaningful for group owners (app secrets are env-agnostic).
pub async fn secrets_list(
&self,
owner: VarOwnerArg<'_>,
env: Option<&str>,
) -> Result<SecretListDto> {
let mut path = format!("{}/secrets", owner.base_path());
if let Some(env) = env {
path.push_str(&format!("?env={}", seg(env)));
}
let resp = self.request(Method::GET, &path).send().await?;
decode(resp).await
}
/// `POST /api/v1/admin/{apps,groups}/{id}/secrets`. `env` rides in the
/// body and is only honored by group owners.
pub async fn secrets_set(
&self,
owner: VarOwnerArg<'_>,
name: &str,
value: serde_json::Value,
env: Option<&str>,
) -> Result<()> {
// `name` travels in the JSON body, not the path.
let mut body = serde_json::json!({ "name": name, "value": value });
if let Some(env) = env {
body["env"] = serde_json::Value::String(env.to_string());
}
let resp = self
.request(Method::GET, &format!("/api/v1/admin/apps/{app}/secrets"))
.request(Method::POST, &format!("{}/secrets", owner.base_path()))
.json(&body)
.send()
.await?;
decode_status(resp).await
}
/// `DELETE /api/v1/admin/{apps,groups}/{id}/secrets/{name}`
pub async fn secrets_delete(
&self,
owner: VarOwnerArg<'_>,
name: &str,
env: Option<&str>,
) -> Result<()> {
let mut path = format!("{}/secrets/{}", owner.base_path(), seg(name));
if let Some(env) = env {
path.push_str(&format!("?env={}", seg(env)));
}
let resp = self.request(Method::DELETE, &path).send().await?;
decode_status(resp).await
}
/// `GET /api/v1/admin/groups/{id}/secrets/{name}/value` — the ONLY path
/// that returns a decrypted secret value. Gated server-side at the owning
/// group; there is no app-secret equivalent by design.
pub async fn group_secret_read_value(
&self,
group: &str,
name: &str,
env: Option<&str>,
) -> Result<SecretValueDto> {
let (group, name) = (seg(group), seg(name));
let mut path = format!("/api/v1/admin/groups/{group}/secrets/{name}/value");
if let Some(env) = env {
path.push_str(&format!("?env={}", seg(env)));
}
let resp = self.request(Method::GET, &path).send().await?;
decode(resp).await
}
// ---------- vars (Phase 3 config) ----------
/// `GET /api/v1/admin/{apps,groups}/{id}/vars` — the owner's OWN vars
/// (not the resolved/inherited view).
pub async fn vars_list(&self, owner: VarOwnerArg<'_>) -> Result<VarListDto> {
let resp = self
.request(Method::GET, &format!("{}/vars", owner.base_path()))
.send()
.await?;
decode(resp).await
}
/// `POST /api/v1/admin/apps/{id}/secrets`
pub async fn secrets_set(&self, app: &str, name: &str, value: serde_json::Value) -> Result<()> {
// `name` travels in the JSON body, not the path — only `app` needs encoding.
let app = seg(app);
/// `PUT /api/v1/admin/{apps,groups}/{id}/vars`
pub async fn vars_set(
&self,
owner: VarOwnerArg<'_>,
key: &str,
value: serde_json::Value,
env: Option<&str>,
tombstone: bool,
) -> Result<()> {
let mut body = serde_json::json!({ "key": key, "value": value, "tombstone": tombstone });
if let Some(env) = env {
body["env"] = serde_json::Value::String(env.to_string());
}
let resp = self
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/secrets"))
.json(&serde_json::json!({ "name": name, "value": value }))
.request(Method::PUT, &format!("{}/vars", owner.base_path()))
.json(&body)
.send()
.await?;
decode_status(resp).await
}
/// `DELETE /api/v1/admin/apps/{id}/secrets/{name}`
pub async fn secrets_delete(&self, app: &str, name: &str) -> Result<()> {
let (app, name) = (seg(app), seg(name));
/// `DELETE /api/v1/admin/{apps,groups}/{id}/vars/{key}`
pub async fn vars_delete(
&self,
owner: VarOwnerArg<'_>,
key: &str,
env: Option<&str>,
) -> Result<()> {
let mut path = format!("{}/vars/{}", owner.base_path(), seg(key));
if let Some(env) = env {
path.push_str(&format!("?env={}", seg(env)));
}
let resp = self.request(Method::DELETE, &path).send().await?;
decode_status(resp).await
}
/// `GET /api/v1/admin/apps/{id}/config/effective` — the app's resolved
/// (group-inherited) vars plus masked secret statuses, each annotated with
/// the owner that won and the layers it merged from (§4.6).
pub async fn config_effective(&self, app: &str) -> Result<EffectiveConfigDto> {
let app = seg(app);
let resp = self
.request(
Method::DELETE,
&format!("/api/v1/admin/apps/{app}/secrets/{name}"),
Method::GET,
&format!("/api/v1/admin/apps/{app}/config/effective"),
)
.send()
.await?;
decode_status(resp).await
decode(resp).await
}
// ---------- domains ----------
@@ -1042,33 +1181,47 @@ impl Client {
/// `POST /api/v1/admin/apps/{id_or_slug}/plan` — diff a desired-state
/// bundle against the app's live state. Read-only.
pub async fn plan(&self, app: &str, bundle: &serde_json::Value) -> Result<PlanDto> {
let app = seg(app);
/// `POST /api/v1/admin/{apps|groups}/{id_or_slug}/plan` — diff a bundle
/// against an app OR group node (Phase 5).
pub async fn plan_node(
&self,
kind: NodeKind,
slug: &str,
bundle: &serde_json::Value,
) -> Result<PlanDto> {
let slug = seg(slug);
let resp = self
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/plan"))
.request(
Method::POST,
&format!("/api/v1/admin/{}/{slug}/plan", kind.path()),
)
.json(bundle)
.send()
.await?;
decode(resp).await
}
/// `POST /api/v1/admin/apps/{id_or_slug}/apply` — reconcile the live
/// app to the bundle in one transaction.
pub async fn apply(
/// `POST /api/v1/admin/{apps|groups}/{id_or_slug}/apply` — reconcile an
/// app OR group node in one transaction (Phase 5).
pub async fn apply_node(
&self,
app: &str,
kind: NodeKind,
slug: &str,
bundle: &serde_json::Value,
prune: bool,
expected_token: Option<&str>,
) -> Result<ApplyReportDto> {
let app = seg(app);
let slug = seg(slug);
let body = serde_json::json!({
"bundle": bundle,
"prune": prune,
"expected_token": expected_token,
});
let resp = self
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/apply"))
.request(
Method::POST,
&format!("/api/v1/admin/{}/{slug}/apply", kind.path()),
)
.json(&body)
.send()
.await?;
@@ -1080,13 +1233,72 @@ impl Client {
let body = resp.text().await.unwrap_or_default();
let msg = parse_error_body(&body).unwrap_or(body);
return Err(anyhow!(
"{msg}\nThe app changed since your last `pic plan`. Re-run \
"{msg}\nThe live state changed since your last `pic plan`. Re-run \
`pic plan` to review the new diff, then `pic apply` — or \
`pic apply --force` to apply without re-reviewing."
));
}
decode(resp).await
}
/// `POST /api/v1/admin/tree/plan` — diff a whole project tree (Phase 5).
/// `project_key` (§7, M3) lets the server report ownership conflicts and
/// structural-prune candidates for this repo.
pub async fn plan_tree(
&self,
bundle: &serde_json::Value,
project_key: Option<&str>,
) -> Result<TreePlanDto> {
let body = serde_json::json!({
"bundle": bundle,
"project_key": project_key,
});
let resp = self
.request(Method::POST, "/api/v1/admin/tree/plan")
.json(&body)
.send()
.await?;
decode(resp).await
}
/// `POST /api/v1/admin/tree/apply` — reconcile a whole project tree in one
/// transaction (Phase 5). `project_key`/`allow_takeover` drive ownership
/// (§7, M3): the apply claims unclaimed declared groups for this project and
/// refuses (or, with `allow_takeover`, seizes) ones another project owns.
#[allow(clippy::too_many_arguments)]
pub async fn apply_tree(
&self,
bundle: &serde_json::Value,
prune: bool,
expected_token: Option<&str>,
project_key: Option<&str>,
allow_takeover: bool,
env: Option<&str>,
approved_envs: &[String],
) -> Result<ApplyReportDto> {
let body = serde_json::json!({
"bundle": bundle,
"prune": prune,
"expected_token": expected_token,
"project_key": project_key,
"allow_takeover": allow_takeover,
"env": env,
"approved_envs": approved_envs,
});
let resp = self
.request(Method::POST, "/api/v1/admin/tree/apply")
.json(&body)
.send()
.await?;
if resp.status() == reqwest::StatusCode::CONFLICT {
// 409 covers both bound-plan staleness AND an ownership conflict; the
// server's message is self-explanatory for each, so surface it raw.
let body = resp.text().await.unwrap_or_default();
let msg = parse_error_body(&body).unwrap_or(body);
return Err(anyhow!("{msg}"));
}
decode(resp).await
}
}
/// `POST /api/v1/admin/auth/login` — sits outside the `Client` because
@@ -1109,6 +1321,23 @@ pub async fn auth_login(url: &str, username: &str, password: &str) -> Result<Log
decode(resp).await
}
/// Which kind of node a plan/apply targets (Phase 5) — selects the API path
/// prefix (`apps` vs `groups`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NodeKind {
App,
Group,
}
impl NodeKind {
fn path(self) -> &'static str {
match self {
Self::App => "apps",
Self::Group => "groups",
}
}
}
// ---------- DTOs (CLI-local, wire-shape-matched) ----------
/// Response of `POST .../plan`: per-resource diffs grouped by kind.
@@ -1122,6 +1351,10 @@ pub struct PlanDto {
pub triggers: Vec<ChangeDto>,
#[serde(default)]
pub secrets: Vec<ChangeDto>,
#[serde(default)]
pub vars: Vec<ChangeDto>,
#[serde(default)]
pub extension_points: Vec<ChangeDto>,
/// Fingerprint of the live state this plan was computed against; carried
/// in `.picloud/` and replayed to `apply` for the bound-plan check.
#[serde(default)]
@@ -1136,6 +1369,73 @@ pub struct ChangeDto {
pub detail: Option<String>,
}
/// One of an owner's OWN extension-point declarations (`pic pull`).
#[derive(Debug, Deserialize)]
pub struct ExtPointDto {
pub name: String,
#[serde(default)]
pub default: Option<String>,
}
/// Response of `POST .../tree/plan` (Phase 5): a per-node diff + one combined
/// bound-plan token for the whole subtree.
#[derive(Debug, Deserialize)]
pub struct TreePlanDto {
#[serde(default)]
pub nodes: Vec<NodePlanDto>,
#[serde(default)]
pub state_token: String,
/// Declared groups owned by another project (§7, M3) — surfaced so CI sees
/// the conflict before `apply` refuses it.
#[serde(default)]
pub conflicts: Vec<OwnershipConflictDto>,
/// Slugs of owned groups absent from the manifest — what `--prune` deletes.
#[serde(default)]
pub structural_prunes: Vec<String>,
/// Route-template fan-out per declaring group (§4.5, M4a).
#[serde(default)]
pub template_blast_radius: Vec<TemplateBlastRadiusDto>,
/// Environments the project marks confirm-required (§4.2, M5).
#[serde(default)]
pub approvals_required: Vec<String>,
}
/// A single ownership conflict (`pic plan --dir`).
#[derive(Debug, Deserialize)]
pub struct OwnershipConflictDto {
pub slug: String,
pub owner_key: String,
}
/// One group's route-template blast radius (`pic plan --dir`).
#[derive(Debug, Deserialize)]
pub struct TemplateBlastRadiusDto {
pub group: String,
pub affected_apps: usize,
}
#[derive(Debug, Deserialize)]
pub struct NodePlanDto {
pub kind: String,
pub slug: String,
#[serde(default)]
pub scripts: Vec<ChangeDto>,
#[serde(default)]
pub routes: Vec<ChangeDto>,
#[serde(default)]
pub triggers: Vec<ChangeDto>,
#[serde(default)]
pub secrets: Vec<ChangeDto>,
#[serde(default)]
pub vars: Vec<ChangeDto>,
#[serde(default)]
pub extension_points: Vec<ChangeDto>,
#[serde(default)]
pub route_templates: Vec<ChangeDto>,
#[serde(default)]
pub trigger_templates: Vec<ChangeDto>,
}
/// Response of `POST .../apply`: counts of what changed.
#[derive(Debug, Default, Deserialize)]
pub struct ApplyReportDto {
@@ -1156,6 +1456,40 @@ pub struct ApplyReportDto {
#[serde(default)]
pub triggers_deleted: u32,
#[serde(default)]
pub vars_created: u32,
#[serde(default)]
pub vars_updated: u32,
#[serde(default)]
pub vars_deleted: u32,
#[serde(default)]
pub extension_points_created: u32,
#[serde(default)]
pub extension_points_updated: u32,
#[serde(default)]
pub extension_points_deleted: u32,
#[serde(default)]
pub groups_created: u32,
#[serde(default)]
pub groups_reparented: u32,
#[serde(default)]
pub groups_claimed: u32,
#[serde(default)]
pub groups_taken_over: u32,
#[serde(default)]
pub groups_pruned: u32,
#[serde(default)]
pub route_templates_created: u32,
#[serde(default)]
pub route_templates_updated: u32,
#[serde(default)]
pub route_templates_deleted: u32,
#[serde(default)]
pub trigger_templates_created: u32,
#[serde(default)]
pub trigger_templates_updated: u32,
#[serde(default)]
pub trigger_templates_deleted: u32,
#[serde(default)]
pub warnings: Vec<String>,
}
@@ -1394,6 +1728,37 @@ pub struct DeadLetterDto {
pub resolution: Option<String>,
}
/// Which owner a `pic vars` call targets. Selects the `apps` vs `groups`
/// admin path prefix; the identifier travels in the path (encoded).
#[derive(Debug, Clone, Copy)]
pub enum VarOwnerArg<'a> {
App(&'a str),
Group(&'a str),
}
impl VarOwnerArg<'_> {
fn base_path(&self) -> String {
match self {
Self::App(ident) => format!("/api/v1/admin/apps/{}", seg(ident)),
Self::Group(ident) => format!("/api/v1/admin/groups/{}", seg(ident)),
}
}
}
#[derive(Debug, Deserialize)]
pub struct VarListDto {
pub vars: Vec<VarItemDto>,
}
#[derive(Debug, Deserialize)]
pub struct VarItemDto {
pub key: String,
pub env: String,
pub value: serde_json::Value,
pub is_tombstone: bool,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct SecretListDto {
pub secrets: Vec<SecretItemDto>,
@@ -1405,9 +1770,72 @@ pub struct SecretListDto {
#[derive(Debug, Deserialize)]
pub struct SecretItemDto {
pub name: String,
/// Env scope. Only meaningful (and populated) for group owners; the server
/// omits it for app secrets, so default to `*` when absent.
#[serde(default = "default_env")]
pub env: String,
pub updated_at: DateTime<Utc>,
}
fn default_env() -> String {
"*".to_string()
}
/// Plaintext value of a single group secret — the response of the gated
/// `.../secrets/{name}/value` read. The decrypted value is arbitrary JSON.
#[derive(Debug, Deserialize)]
pub struct SecretValueDto {
#[allow(dead_code)]
pub name: String,
#[allow(dead_code)]
pub env: String,
pub value: serde_json::Value,
}
// --- effective config (`/config/effective`) ---
/// The owner (app or group) a resolved layer belongs to, with its distance
/// from the app in the inheritance chain (`depth` 0 = the app itself).
#[derive(Debug, Deserialize)]
pub struct EffectiveOwnerDto {
pub kind: String,
#[allow(dead_code)]
pub id: String,
pub depth: u32,
}
/// One layer a resolved var merged from, deepest-first as the server returns it.
#[derive(Debug, Deserialize)]
pub struct MergedFromDto {
pub depth: u32,
pub scope: String,
}
#[derive(Debug, Deserialize)]
pub struct EffectiveVarDto {
pub value: serde_json::Value,
pub owner: EffectiveOwnerDto,
pub scope: String,
#[serde(default)]
pub merged_from: Vec<MergedFromDto>,
}
#[derive(Debug, Deserialize)]
pub struct EffectiveSecretDto {
#[allow(dead_code)]
pub status: String,
pub owner: EffectiveOwnerDto,
pub scope: String,
}
#[derive(Debug, Deserialize)]
pub struct EffectiveConfigDto {
#[serde(default)]
pub vars: std::collections::BTreeMap<String, EffectiveVarDto>,
#[serde(default)]
pub secrets: std::collections::BTreeMap<String, EffectiveSecretDto>,
}
/// Per-script runtime config the CLI can now set (G3). All optional — an
/// unset field is omitted so the server applies its own default (and the
/// `PICLOUD_SANDBOX_MAX_*` admin ceilings still clamp overrides).
@@ -1436,6 +1864,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,7 +8,7 @@ use std::path::Path;
use anyhow::{Context, Result};
use crate::client::Client;
use crate::client::{Client, NodeKind};
use crate::cmds::plan::build_bundle;
use crate::config;
use crate::manifest::Manifest;
@@ -26,38 +26,60 @@ pub async fn run(
let client = Client::from_creds(&creds)?;
let manifest = Manifest::load_with_env(manifest_path, env)?;
// Env approval gating (§4.2, M5) is a project-tree (`--dir`) feature: the
// admin-gated, audited per-env approval is enforced server-side only on the
// tree apply path. Single-node `apply --file` cannot provide that guarantee,
// so rather than silently bypass a confirm-required env, refuse and direct
// the user to `--dir` (which carries the policy + `--approve` to the server).
if let (Some(env), Some(project)) = (env, &manifest.project) {
if project
.environments
.iter()
.any(|e| e.name == env && e.confirm)
{
anyhow::bail!(
"environment `{env}` is confirm-required by this project's [project] policy; \
single-node `pic apply --file` cannot provide the admin-gated, audited approval. \
Use `pic apply --dir <root> --env {env} --approve {env}` instead."
);
}
}
let base_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
let bundle = build_bundle(&manifest, base_dir)?;
let kind = if manifest.is_group() {
NodeKind::Group
} else {
NodeKind::App
};
let slug = manifest.slug().to_string();
if prune && !yes {
confirm_prune(&manifest.app.slug)?;
confirm_prune(&slug)?;
}
// Bound-plan check: replay the token from the last `pic plan` (for this
// app) so the server refuses if the app changed since it was reviewed.
// node) so the server refuses if it changed since it was reviewed.
// `--force` skips it; no recorded plan means no check (apply still works
// standalone). The token is single-use — cleared after a successful apply.
let expected_token = if force {
None
} else {
crate::linkstate::read_plan(base_dir)
.filter(|l| l.app == manifest.app.slug)
.filter(|l| l.app == slug)
.map(|l| l.state_token)
};
let report = client
.apply(
&manifest.app.slug,
&bundle,
prune,
expected_token.as_deref(),
)
.apply_node(kind, &slug, &bundle, prune, expected_token.as_deref())
.await?;
crate::linkstate::clear_plan(base_dir, &manifest.app.slug);
crate::linkstate::clear_plan(base_dir, &slug);
let label = if manifest.is_group() { "group" } else { "app" };
let mut block = KvBlock::new();
block
.field("app", manifest.app.slug.clone())
.field(label, slug.clone())
.field(
"scripts",
format!(
@@ -75,7 +97,33 @@ pub async fn run(
.field(
"triggers",
format!("+{} -{}", report.triggers_created, report.triggers_deleted),
)
.field(
"vars",
format!(
"+{} ~{} -{}",
report.vars_created, report.vars_updated, report.vars_deleted
),
)
.field(
"extension-points",
format!(
"+{} ~{} -{}",
report.extension_points_created,
report.extension_points_updated,
report.extension_points_deleted
),
);
// Structural changes only happen on a tree apply; omit the line otherwise.
if report.groups_created > 0 || report.groups_reparented > 0 {
block.field(
"groups",
format!(
"+{} reparented {}",
report.groups_created, report.groups_reparented
),
);
}
for w in &report.warnings {
block.field("warning", w.clone());
}
@@ -83,6 +131,202 @@ pub async fn run(
Ok(())
}
/// `pic apply --dir <root>` (Phase 5): reconcile a whole project tree — every
/// `picloud.toml` under `root` — in ONE atomic server transaction.
#[allow(clippy::too_many_arguments)]
pub async fn run_tree(
dir: &Path,
prune: bool,
yes: bool,
force: bool,
takeover: bool,
env: Option<&str>,
approve: &[String],
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let (bundle, node_count, envs) = crate::discover::build_tree(dir, env)?;
if prune && !yes {
confirm_prune(&format!("{node_count} node(s) under {}", dir.display()))?;
}
// Per-env approval gate (§4.2, M5): if the selected env is confirm-required,
// it needs an explicit `--approve <env>` (a TTY may confirm interactively;
// `--yes` does NOT cover it). The server re-checks this authoritatively — the
// local check just fails fast with a clear message. `approved` is the set
// sent on the wire.
let approved = resolve_approvals(env, &envs, approve)?;
let token_key = crate::cmds::plan::TREE_TOKEN_KEY;
let expected_token = if force {
None
} else {
crate::linkstate::read_plan(dir)
.filter(|l| l.app == token_key)
.map(|l| l.state_token)
};
// This repo's stable project key (§7, M3): the server claims declared
// groups for it and refuses ones another project owns (unless --takeover).
let project_key = crate::linkstate::ensure_project_key(dir)
.context("establishing project identity in .picloud/")?;
let report = client
.apply_tree(
&bundle,
prune,
expected_token.as_deref(),
Some(&project_key),
takeover,
env,
&approved,
)
.await?;
crate::linkstate::clear_plan(dir, token_key);
let mut block = KvBlock::new();
block
.field("nodes", node_count.to_string())
.field(
"scripts",
format!(
"+{} ~{} -{}",
report.scripts_created, report.scripts_updated, report.scripts_deleted
),
)
.field(
"routes",
format!(
"+{} ~{} -{}",
report.routes_created, report.routes_updated, report.routes_deleted
),
)
.field(
"triggers",
format!("+{} -{}", report.triggers_created, report.triggers_deleted),
)
.field(
"vars",
format!(
"+{} ~{} -{}",
report.vars_created, report.vars_updated, report.vars_deleted
),
)
.field(
"extension-points",
format!(
"+{} ~{} -{}",
report.extension_points_created,
report.extension_points_updated,
report.extension_points_deleted
),
);
// Structural changes only happen on a tree apply; omit the line otherwise.
if report.groups_created > 0
|| report.groups_reparented > 0
|| report.groups_pruned > 0
|| report.groups_claimed > 0
|| report.groups_taken_over > 0
{
block.field(
"groups",
format!(
"+{} reparented {} pruned {}",
report.groups_created, report.groups_reparented, report.groups_pruned
),
);
if report.groups_claimed > 0 || report.groups_taken_over > 0 {
block.field(
"ownership",
format!(
"claimed {} taken-over {}",
report.groups_claimed, report.groups_taken_over
),
);
}
}
// Route templates (§4.5, M4a) — the template rows; their per-app route
// expansions are folded into the `routes` line above.
if report.route_templates_created > 0
|| report.route_templates_updated > 0
|| report.route_templates_deleted > 0
{
block.field(
"route-templates",
format!(
"+{} ~{} -{}",
report.route_templates_created,
report.route_templates_updated,
report.route_templates_deleted
),
);
}
if report.trigger_templates_created > 0
|| report.trigger_templates_updated > 0
|| report.trigger_templates_deleted > 0
{
block.field(
"trigger-templates",
format!(
"+{} ~{} -{}",
report.trigger_templates_created,
report.trigger_templates_updated,
report.trigger_templates_deleted
),
);
}
for w in &report.warnings {
block.field("warning", w.clone());
}
block.print(mode);
Ok(())
}
/// Resolve the set of environments the actor approves for this apply (§4.2, M5).
/// If the selected `env` is confirm-required (per the root manifest's
/// `[project]` policy) it must be approved — via `--approve <env>`, or an
/// interactive TTY confirmation that requires re-typing the env name. A blanket
/// `--yes` does NOT satisfy it. Returns the full approved set to send on the
/// wire (the server re-checks authoritatively). A non-gated env passes through.
fn resolve_approvals(
env: Option<&str>,
policy: &[crate::manifest::ManifestEnvironment],
approve: &[String],
) -> Result<Vec<String>> {
let mut approved: Vec<String> = approve.to_vec();
let Some(env) = env else {
return Ok(approved); // no env selected → nothing env-specific to gate
};
let gated = policy.iter().any(|e| e.name == env && e.confirm);
if !gated || approved.iter().any(|e| e == env) {
return Ok(approved);
}
// Gated and not pre-approved: prompt on a TTY, else refuse (CI must pass
// --approve explicitly; --yes is deliberately insufficient).
if std::io::stdin().is_terminal() {
eprint!(
"environment `{env}` requires explicit approval to apply. \
Type the environment name to approve, or anything else to abort: "
);
std::io::stderr().flush().ok();
let mut answer = String::new();
std::io::stdin()
.read_line(&mut answer)
.context("read approval")?;
if answer.trim() == env {
approved.push(env.to_string());
return Ok(approved);
}
anyhow::bail!("aborted: environment `{env}` not approved");
}
anyhow::bail!(
"environment `{env}` requires explicit approval: re-run with `--approve {env}` \
(a blanket `--yes` does not cover a confirm-required environment)"
);
}
/// `--prune` deletes live scripts/routes/triggers absent from the manifest —
/// irreversible. Require an explicit go-ahead: an interactive `y`, or `--yes`
/// for non-interactive/CI use. Refuse a non-interactive prune without `--yes`
@@ -97,7 +341,8 @@ fn confirm_prune(slug: &str) -> Result<()> {
}
eprint!(
"apply --prune will DELETE live scripts/routes/triggers on `{slug}` that are \
absent from the manifest. This cannot be undone. Continue? [y/N] "
absent from the manifest — and, for a tree apply, entire owned groups absent \
from the manifest. This cannot be undone. Continue? [y/N] "
);
std::io::stderr().flush().ok();
let mut answer = String::new();

View File

@@ -1,26 +1,38 @@
//! `pic config --effective` — read-only view of an app's resolved
//! configuration, with secret values masked (§4.6).
//!
//! Single-app today: "config" means the app's secrets, cross-referenced
//! against the manifest so an operator can see at a glance which declared
//! secrets are still unset and which live secrets aren't declared. Values are
//! never fetched or shown — only `<set>` / `<unset>`. The command is shaped to
//! grow an `--explain` mode and inherited `vars` once groups/vars land (the
//! Phase-3 multi-level resolution).
//! Two sections:
//! * `vars` — the resolved (group-inherited) config vars, each `key = value`
//! annotated with the owner that won (kind + depth) and its scope. Pass
//! `--explain` to also dump each var's `merged_from` provenance — the
//! ordered (depth, scope) layers that fed the resolution.
//! * `secrets` — masked statuses, cross-referenced against the manifest so an
//! operator can see which declared secrets are still unset and which live
//! secrets aren't declared. Values are never fetched or shown here; the
//! server reports only `<set>` / `<unset>` plus the owning layer.
//!
//! The vars + masked-secret owner info comes from the `/config/effective`
//! endpoint; the manifest is only used to flag `declared`/`unset` drift.
use std::collections::BTreeSet;
use std::path::Path;
use anyhow::{bail, Result};
use crate::client::Client;
use crate::client::{Client, EffectiveOwnerDto};
use crate::config;
use crate::manifest::Manifest;
use crate::output::{OutputMode, Table};
/// Render an owner as `kind@depth` (e.g. `group@1`, `app@0`).
fn owner_label(owner: &EffectiveOwnerDto) -> String {
format!("{}@{}", owner.kind, owner.depth)
}
pub async fn run(
manifest_path: &Path,
effective: bool,
explain: bool,
env: Option<&str>,
mode: OutputMode,
) -> Result<()> {
@@ -33,25 +45,61 @@ pub async fn run(
// masked report targets the app those commands would act on — not the
// base slug — when an overlay re-points slug/secrets.
let manifest = Manifest::load_with_env(manifest_path, env)?;
if manifest.is_group() {
bail!(
"`pic config --effective` resolves an APP's inherited config; \
a [group] manifest has no single effective view"
);
}
let eff = client.config_effective(manifest.slug()).await?;
// --- vars: the resolved view, with winning owner + provenance. ---
let mut vars_table = Table::new(["key", "value", "owner", "scope"]);
for (key, var) in &eff.vars {
vars_table.row([
key.clone(),
var.value.to_string(),
owner_label(&var.owner),
var.scope.clone(),
]);
}
vars_table.print(mode);
if explain {
// Provenance: the (depth, scope) layers each resolved key merged from.
let mut prov = Table::new(["key", "depth", "scope"]);
for (key, var) in &eff.vars {
for layer in &var.merged_from {
prov.row([key.clone(), layer.depth.to_string(), layer.scope.clone()]);
}
}
prov.print(mode);
}
// --- secrets: masked status, folding manifest drift + owning layer. ---
let declared: BTreeSet<String> = manifest.secrets.names.iter().cloned().collect();
let on_server: BTreeSet<String> = client
.secrets_list(&manifest.app.slug)
.await?
.secrets
.into_iter()
.map(|s| s.name)
.collect();
let on_server: BTreeSet<String> = eff.secrets.keys().cloned().collect();
let mut table = Table::new(["secret", "value", "status"]);
let mut table = Table::new(["secret", "value", "status", "owner", "scope"]);
for name in declared.union(&on_server) {
let (value, status) = match (declared.contains(name), on_server.contains(name)) {
(true, true) => ("<set>", "managed"),
(true, false) => ("<unset>", "declared, not pushed — `pic secret set`"),
(true, false) => ("<unset>", "declared, not pushed — `pic secrets set`"),
(false, true) => ("<set>", "on server, not in manifest"),
(false, false) => unreachable!("name came from one of the two sets"),
};
table.row([name.clone(), value.to_string(), status.to_string()]);
let (owner, scope) = match eff.secrets.get(name) {
Some(s) => (owner_label(&s.owner), s.scope.clone()),
None => ("-".to_string(), "-".to_string()),
};
table.row([
name.clone(),
value.to_string(),
status.to_string(),
owner,
scope,
]);
}
table.print(mode);
Ok(())

View File

@@ -87,6 +87,9 @@ pub fn run(
}
fs::write(&manifest_path, body).with_context(|| format!("writing {MANIFEST_FILE}"))?;
ensure_gitignored(dir)?;
// Mint the repo's stable project identity (§7, M3) so the first tree apply
// claims its groups under a key that survives clones/CI. Gitignored.
crate::linkstate::ensure_project_key(dir).context("minting project key in .picloud/")?;
let mut block = KvBlock::new();
block
@@ -109,11 +112,13 @@ pub fn run(
/// The minimal working project: one `hello` endpoint bound to `GET /hello`.
fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
Manifest {
app: ManifestApp {
app: Some(ManifestApp {
slug: slug.to_string(),
name: name.to_string(),
description: None,
},
}),
group: None,
project: None,
scripts: vec![ManifestScript {
name: "hello".into(),
file: "scripts/hello.rhai".into(),
@@ -137,6 +142,10 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
}],
triggers: crate::manifest::ManifestTriggers::default(),
secrets: crate::manifest::ManifestSecrets::default(),
vars: std::collections::BTreeMap::new(),
extension_points: Vec::new(),
route_templates: Vec::new(),
trigger_templates: Vec::new(),
}
}

View File

@@ -22,4 +22,5 @@ pub mod secrets;
pub mod topics;
pub mod triggers;
pub mod users;
pub mod vars;
pub mod whoami;

View File

@@ -9,11 +9,15 @@ use anyhow::{Context, Result};
use serde::Serialize;
use serde_json::{json, Map, Value};
use crate::client::{ChangeDto, Client, PlanDto};
use crate::client::{ChangeDto, Client, NodeKind, PlanDto, TreePlanDto};
use crate::config;
use crate::manifest::Manifest;
use crate::output::{OutputMode, Table};
/// Linkstate key for a whole-tree bound plan (Phase 5) — a tree has no single
/// slug, so its token is stored under this reserved marker.
pub const TREE_TOKEN_KEY: &str = "<tree>";
pub async fn run(manifest_path: &Path, env: Option<&str>, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
@@ -21,15 +25,18 @@ pub async fn run(manifest_path: &Path, env: Option<&str>, mode: OutputMode) -> R
let manifest = Manifest::load_with_env(manifest_path, env)?;
let base_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
let bundle = build_bundle(&manifest, base_dir)?;
let kind = if manifest.is_group() {
NodeKind::Group
} else {
NodeKind::App
};
let plan = client.plan(&manifest.app.slug, &bundle).await?;
let plan = client.plan_node(kind, manifest.slug(), &bundle).await?;
// Record the bound-plan token so a subsequent `pic apply` can detect the
// app changing underneath the reviewed plan (best-effort — a read-only
// node changing underneath the reviewed plan (best-effort — a read-only
// plan still succeeds if the project dir isn't writable).
if !plan.state_token.is_empty() {
if let Err(e) =
crate::linkstate::write_plan(base_dir, &manifest.app.slug, &plan.state_token)
{
if let Err(e) = crate::linkstate::write_plan(base_dir, manifest.slug(), &plan.state_token) {
eprintln!("warning: could not record plan state for `pic apply`: {e}");
}
}
@@ -37,6 +44,83 @@ pub async fn run(manifest_path: &Path, env: Option<&str>, mode: OutputMode) -> R
Ok(())
}
/// `pic plan --dir <root>` (Phase 5): diff a whole project tree — every
/// `picloud.toml` under `root` — against the live group/app subtree.
pub async fn run_tree(dir: &Path, env: Option<&str>, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let (bundle, _count, _envs) = crate::discover::build_tree(dir, env)?;
// Mint/read this repo's project key so the server can report ownership
// (§7, M3). Best-effort: a read-only dir still plans (ownership unreported).
let project_key = crate::linkstate::ensure_project_key(dir).ok();
let plan = client.plan_tree(&bundle, project_key.as_deref()).await?;
if !plan.state_token.is_empty() {
if let Err(e) = crate::linkstate::write_plan(dir, TREE_TOKEN_KEY, &plan.state_token) {
eprintln!("warning: could not record plan state for `pic apply`: {e}");
}
}
render_tree(&plan, mode);
Ok(())
}
fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
let mut table = Table::new(["node", "kind", "op", "resource", "detail"]);
for n in &plan.nodes {
let node = format!("{}:{}", n.kind, n.slug);
let groups: [(&str, &Vec<ChangeDto>); 8] = [
("script", &n.scripts),
("route", &n.routes),
("trigger", &n.triggers),
("secret", &n.secrets),
("var", &n.vars),
("extension-point", &n.extension_points),
("route-template", &n.route_templates),
("trigger-template", &n.trigger_templates),
];
for (rk, changes) in groups {
for c in changes {
table.row([
node.clone(),
rk.to_string(),
c.op.clone(),
c.key.clone(),
c.detail.clone().unwrap_or_default(),
]);
}
}
}
table.print(mode);
// Ownership diagnostics (§7, M3) — surfaced to stderr in non-JSON output
// (JSON consumers read `conflicts`/`structural_prunes` from the payload).
if mode != OutputMode::Json {
if !plan.conflicts.is_empty() {
eprintln!("\nownership conflicts (apply blocked without --takeover):");
for c in &plan.conflicts {
eprintln!(" - {} is owned by project {}", c.slug, c.owner_key);
}
}
if !plan.structural_prunes.is_empty() {
eprintln!("\ngroups absent from the manifest (deleted only with --prune):");
for slug in &plan.structural_prunes {
eprintln!(" - {slug}");
}
}
if !plan.template_blast_radius.is_empty() {
eprintln!("\nroute-template blast radius (every descendant app in the DB subtree):");
for b in &plan.template_blast_radius {
eprintln!(" - {}{} app(s)", b.group, b.affected_apps);
}
}
if !plan.approvals_required.is_empty() {
eprintln!("\nenvironments requiring explicit approval (apply with --approve <env>):");
for e in &plan.approvals_required {
eprintln!(" - {e}");
}
}
}
}
/// Assemble the wire bundle: scripts carry inlined source (read from
/// their `file`), routes pass through, triggers flatten into a tagged
/// array, secrets are names only.
@@ -95,11 +179,58 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
triggers.push(tagged("queue", s)?);
}
// Vars: key → JSON value. TOML values round-trip to JSON via serde so the
// wire shape matches the server's `serde_json::Value`.
let mut vars = Map::new();
for (k, v) in &manifest.vars {
vars.insert(
k.clone(),
serde_json::to_value(v).with_context(|| format!("encoding var `{k}`"))?,
);
}
// Extension points: name + optional default module. The server's
// `ExtPointSpec` deserializes this shape directly.
let extension_points: Vec<Value> = manifest
.extension_points
.iter()
.map(|e| {
let mut o = Map::new();
o.insert("name".into(), json!(e.name));
if let Some(d) = &e.default {
o.insert("default".into(), json!(d));
}
Value::Object(o)
})
.collect();
// Route templates (§4.5, M4a): name + the flattened route fields the
// server's `RouteTemplateSpec` (name + `#[serde(flatten)] BundleRoute`)
// deserializes. Group manifests only; the server rejects them on an app.
let route_templates = manifest
.route_templates
.iter()
.map(serde_json::to_value)
.collect::<Result<Vec<_>, _>>()?;
// Trigger templates (§4.5, M4b): name + the flattened trigger spec
// (kind/script/params) — the server's `TriggerTemplateSpec` deserializes
// `{ name, <BundleTrigger> }`. Group manifests only.
let trigger_templates = manifest
.trigger_templates
.iter()
.map(serde_json::to_value)
.collect::<Result<Vec<_>, _>>()?;
Ok(json!({
"scripts": scripts,
"routes": routes,
"triggers": triggers,
"secrets": manifest.secrets.names,
"vars": vars,
"extension_points": extension_points,
"route_templates": route_templates,
"trigger_templates": trigger_templates,
}))
}
@@ -114,11 +245,13 @@ fn tagged(kind: &str, spec: impl Serialize) -> Result<Value> {
fn render(plan: &PlanDto, mode: OutputMode) {
let mut table = Table::new(["kind", "op", "resource", "detail"]);
let groups: [(&str, &Vec<ChangeDto>); 4] = [
let groups: [(&str, &Vec<ChangeDto>); 6] = [
("script", &plan.scripts),
("route", &plan.routes),
("trigger", &plan.triggers),
("secret", &plan.secrets),
("var", &plan.vars),
("extension-point", &plan.extension_points),
];
for (kind, changes) in groups {
for c in changes {

View File

@@ -14,12 +14,12 @@ use anyhow::{Context, Result};
use picloud_shared::{DispatchMode, DocsEventOp, FilesEventOp, KvEventOp, ScriptId};
use serde::Deserialize;
use crate::client::Client;
use crate::client::{Client, VarOwnerArg};
use crate::config;
use crate::manifest::{
CronTriggerSpec, DocsTriggerSpec, FilesTriggerSpec, KvTriggerSpec, Manifest, ManifestApp,
ManifestRoute, ManifestScript, ManifestSecrets, ManifestTriggers, PubsubTriggerSpec,
QueueTriggerSpec, MANIFEST_FILE,
ManifestExtensionPoint, ManifestRoute, ManifestScript, ManifestSecrets, ManifestTriggers,
PubsubTriggerSpec, QueueTriggerSpec, MANIFEST_FILE,
};
use crate::output::{KvBlock, OutputMode};
@@ -45,7 +45,28 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
let app = client.apps_get(app_ident).await?;
let scripts = client.scripts_list_by_app(app_ident).await?;
let triggers = client.triggers_list(app_ident).await?.triggers;
let secrets = client.secrets_list(app_ident).await?.secrets;
let secrets = client
.secrets_list(VarOwnerArg::App(app_ident), None)
.await?
.secrets;
// App's OWN env-agnostic vars become the manifest `[vars]` block. Inherited
// group vars and tombstones are excluded (pull exports own rows only, §4.6);
// a value TOML can't represent (e.g. JSON null) is skipped with a warning.
let mut manifest_vars = std::collections::BTreeMap::new();
for v in client.vars_list(VarOwnerArg::App(app_ident)).await?.vars {
if v.env != "*" || v.is_tombstone {
continue;
}
match toml::Value::try_from(&v.value) {
Ok(tv) => {
manifest_vars.insert(v.key, tv);
}
Err(_) => eprintln!(
"warning: skipping var `{}` — its value can't be represented in TOML",
v.key
),
}
}
let name_by_id: HashMap<ScriptId, String> =
scripts.iter().map(|s| (s.id, s.name.clone())).collect();
@@ -189,18 +210,37 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
eprintln!("warning: skipping {s} trigger — not yet representable in the manifest");
}
// Extension points (§5.5): the app's own declarations, exported so a
// re-applied pulled manifest doesn't prune them.
let extension_points: Vec<ManifestExtensionPoint> = client
.extension_points_list(app_ident)
.await?
.into_iter()
.map(|e| ManifestExtensionPoint {
name: e.name,
default: e.default,
})
.collect();
let manifest = Manifest {
app: ManifestApp {
app: Some(ManifestApp {
slug: app.app.slug.clone(),
name: app.app.name.clone(),
description: app.app.description.clone(),
},
}),
group: None,
project: None,
scripts: manifest_scripts,
routes,
triggers: manifest_triggers,
secrets: ManifestSecrets {
names: secrets.iter().map(|s| s.name.clone()).collect(),
},
vars: manifest_vars,
extension_points,
// `pull` is app-scoped; templates are group-owned (§4.5).
route_templates: Vec::new(),
trigger_templates: Vec::new(),
};
std::fs::write(&manifest_path, manifest.to_toml()?)
@@ -209,7 +249,7 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
let mut block = KvBlock::new();
block
.field("manifest", manifest_path.display().to_string())
.field("app", manifest.app.slug.clone())
.field("app", manifest.slug().to_string())
.field("scripts", manifest.scripts.len().to_string())
.field("routes", manifest.routes.len().to_string())
.field("triggers", trigger_count(&manifest.triggers).to_string())

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?;
@@ -40,10 +57,15 @@ pub async fn ls(app: Option<&str>, mode: OutputMode) -> Result<()> {
let (apps, scripts) = tokio::try_join!(client.apps_list(), client.scripts_list_all())?;
let slug_by_id: HashMap<AppId, String> = apps.into_iter().map(|a| (a.id, a.slug)).collect();
for s in scripts {
let app_slug = slug_by_id
.get(&s.app_id)
.cloned()
.unwrap_or_else(|| "-".to_string());
// Group-owned scripts (Phase 4) have no app; show a marker rather
// than a slug. App-owned scripts resolve their slug as before.
let app_slug = match s.app_id {
Some(app_id) => slug_by_id
.get(&app_id)
.cloned()
.unwrap_or_else(|| "-".to_string()),
None => "(group)".to_string(),
};
table.row([
s.id.to_string(),
app_slug,
@@ -59,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,
@@ -84,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

@@ -1,31 +1,56 @@
//! `pic secrets` subcommands: `ls`, `set`, `rm`.
//! `pic secrets ls | set | rm | read` — manage Phase-3 group/app secrets.
//!
//! Set reads the secret value from stdin (the only safe channel —
//! inline values would leak into shell history). The value is sent
//! as a JSON string; pass `--json` to interpret stdin as raw JSON
//! (numbers, maps, …) instead.
//! Exactly one of `--group` / `--app` selects the owner (mirroring
//! `pic vars`). Set reads the secret value from stdin (the only safe
//! channel — inline values would leak into shell history). The value is
//! sent as a JSON string; pass `--json` to interpret stdin as raw JSON
//! (numbers, maps, …) instead. `--env` is only meaningful for group
//! owners (app secrets are env-agnostic).
use std::io::Read;
use anyhow::{anyhow, Context, Result};
use crate::client::Client;
use crate::client::{Client, VarOwnerArg};
use crate::config;
use crate::output::{OutputMode, Table};
pub async fn ls(app: &str, mode: OutputMode) -> Result<()> {
/// Resolve the `--group`/`--app` pair into exactly one owner.
fn owner<'a>(group: Option<&'a str>, app: Option<&'a str>) -> Result<VarOwnerArg<'a>> {
match (group, app) {
(Some(g), None) => Ok(VarOwnerArg::Group(g)),
(None, Some(a)) => Ok(VarOwnerArg::App(a)),
(Some(_), Some(_)) => Err(anyhow!("pass exactly one of --group / --app, not both")),
(None, None) => Err(anyhow!("pass one of --group / --app")),
}
}
pub async fn ls(
group: Option<&str>,
app: Option<&str>,
env: Option<&str>,
mode: OutputMode,
) -> Result<()> {
let owner = owner(group, app)?;
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let resp = client.secrets_list(app).await?;
let mut table = Table::new(["name", "updated_at"]);
let resp = client.secrets_list(owner, env).await?;
let mut table = Table::new(["name", "env", "updated_at"]);
for s in resp.secrets {
table.row([s.name, s.updated_at.to_rfc3339()]);
table.row([s.name, s.env, s.updated_at.to_rfc3339()]);
}
table.print(mode);
Ok(())
}
pub async fn set(app: &str, name: &str, as_json: bool) -> Result<()> {
pub async fn set(
group: Option<&str>,
app: Option<&str>,
name: &str,
env: Option<&str>,
as_json: bool,
) -> Result<()> {
let owner = owner(group, app)?;
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let mut buf = String::new();
@@ -41,15 +66,36 @@ pub async fn set(app: &str, name: &str, as_json: bool) -> Result<()> {
} else {
serde_json::Value::String(trimmed.to_string())
};
client.secrets_set(app, name, value).await?;
client.secrets_set(owner, name, value, env).await?;
println!("Set secret {name}");
Ok(())
}
pub async fn rm(app: &str, name: &str) -> Result<()> {
pub async fn rm(
group: Option<&str>,
app: Option<&str>,
name: &str,
env: Option<&str>,
) -> Result<()> {
let owner = owner(group, app)?;
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
client.secrets_delete(app, name).await?;
client.secrets_delete(owner, name, env).await?;
println!("Deleted secret {name}");
Ok(())
}
/// `pic secrets read --group <slug> <name>` — fetch and print a secret's
/// PLAINTEXT value. This is the ONLY command that reveals a secret value,
/// and it is gated server-side at the owning group (there is no app-secret
/// equivalent). String values print raw; JSON values print pretty.
pub async fn read(group: &str, name: &str, env: Option<&str>) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let resp = client.group_secret_read_value(group, name, env).await?;
match resp.value {
serde_json::Value::String(s) => println!("{s}"),
other => println!("{}", serde_json::to_string_pretty(&other)?),
}
Ok(())
}

View File

@@ -0,0 +1,85 @@
//! `pic vars ls | set | rm` — manage Phase-3 group/app config vars.
//!
//! Wraps `/api/v1/admin/{apps,groups}/{id}/vars*`. Exactly one of
//! `--group` / `--app` selects the owner. `ls` shows the owner's OWN rows
//! (not the resolved/inherited view). Set values are JSON strings by
//! default; `--json` parses the value as raw JSON.
use anyhow::{anyhow, Result};
use crate::client::{Client, VarOwnerArg};
use crate::config;
use crate::output::{OutputMode, Table};
/// Resolve the `--group`/`--app` pair into exactly one owner.
fn owner<'a>(group: Option<&'a str>, app: Option<&'a str>) -> Result<VarOwnerArg<'a>> {
match (group, app) {
(Some(g), None) => Ok(VarOwnerArg::Group(g)),
(None, Some(a)) => Ok(VarOwnerArg::App(a)),
(Some(_), Some(_)) => Err(anyhow!("pass exactly one of --group / --app, not both")),
(None, None) => Err(anyhow!("pass one of --group / --app")),
}
}
pub async fn ls(group: Option<&str>, app: Option<&str>, mode: OutputMode) -> Result<()> {
let owner = owner(group, app)?;
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let resp = client.vars_list(owner).await?;
let mut table = Table::new(["key", "env", "value", "tombstone", "updated_at"]);
for v in resp.vars {
table.row([
v.key,
v.env,
v.value.to_string(),
v.is_tombstone.to_string(),
v.updated_at.to_rfc3339(),
]);
}
table.print(mode);
Ok(())
}
pub async fn set(
group: Option<&str>,
app: Option<&str>,
key: &str,
value: &str,
env: Option<&str>,
as_json: bool,
tombstone: bool,
) -> Result<()> {
let owner = owner(group, app)?;
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
// A tombstone carries no value (the server stores JSON null + the
// deletion marker); otherwise parse per `--json`.
let parsed = if tombstone {
serde_json::Value::Null
} else if as_json {
serde_json::from_str(value).map_err(|e| anyhow!("parse value as JSON: {e}"))?
} else {
serde_json::Value::String(value.to_string())
};
client.vars_set(owner, key, parsed, env, tombstone).await?;
if tombstone {
println!("Set tombstone for {key}");
} else {
println!("Set var {key}");
}
Ok(())
}
pub async fn rm(
group: Option<&str>,
app: Option<&str>,
key: &str,
env: Option<&str>,
) -> Result<()> {
let owner = owner(group, app)?;
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
client.vars_delete(owner, key, env).await?;
println!("Deleted var {key}");
Ok(())
}

View File

@@ -0,0 +1,151 @@
//! Phase 5: discover a project *tree* — every `picloud.toml` under a root
//! directory — and assemble the wire tree bundle the server's `/tree/{plan,
//! apply}` endpoints consume. Each manifest becomes one node (app or group);
//! the server resolves ancestry from its own group tree, so the on-disk
//! nesting is organizational, not authoritative here.
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use serde_json::{json, Value};
use crate::cmds::plan::build_bundle;
use crate::manifest::{Manifest, MANIFEST_FILE};
/// Find every `picloud.toml` under `root` (recursively), skipping the
/// `.picloud/` link-state dir, `.git`, and `scripts/` source dirs. Per-env
/// overlays (`picloud.<env>.toml`) are not matched — only the base filename.
pub fn find_manifests(root: &Path) -> Result<Vec<PathBuf>> {
let mut out = Vec::new();
walk(root, &mut out)?;
out.sort();
Ok(out)
}
fn walk(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
let entries =
std::fs::read_dir(dir).with_context(|| format!("reading directory {}", dir.display()))?;
for entry in entries {
let entry = entry?;
let name = entry.file_name();
let ft = entry.file_type()?;
if ft.is_dir() {
if matches!(
name.to_str(),
Some(".picloud" | ".git" | "scripts" | "target")
) {
continue;
}
walk(&entry.path(), out)?;
} else if name == MANIFEST_FILE {
out.push(entry.path());
}
}
Ok(())
}
/// Build the wire tree bundle (`{ nodes: [{kind, slug, bundle}], project }`)
/// from every manifest under `root`. Returns the JSON, the node count, and the
/// root manifest's `[project]` environment policy (M5, empty if none). Rejects a
/// tree that names the same (kind, slug) twice, or declares `[project]` anywhere
/// but the root manifest.
pub fn build_tree(
root: &Path,
env: Option<&str>,
) -> Result<(Value, usize, Vec<crate::manifest::ManifestEnvironment>)> {
let paths = find_manifests(root)?;
if paths.is_empty() {
bail!(
"no {MANIFEST_FILE} found under {} — nothing to apply",
root.display()
);
}
let root_manifest = root.join(MANIFEST_FILE);
// First pass: load every manifest and index its DIRECTORY → (slug, is_group)
// so a group node's parent can be inferred from the enclosing directory.
let mut loaded: Vec<(PathBuf, Manifest)> = Vec::with_capacity(paths.len());
let mut group_dirs: HashMap<PathBuf, String> = HashMap::new();
let mut project: Option<crate::manifest::ManifestProject> = None;
for path in &paths {
let manifest = Manifest::load_with_env(path, env)
.with_context(|| format!("loading {}", path.display()))?;
// `[project]` is project-level — valid only on the tree's root manifest.
if let Some(p) = &manifest.project {
if path == &root_manifest {
project = Some(p.clone());
} else {
bail!(
"{} declares [project], which is only valid on the root manifest ({})",
path.display(),
root_manifest.display()
);
}
}
let dir = path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
if manifest.is_group() {
group_dirs.insert(dir.clone(), manifest.slug().to_string());
}
loaded.push((dir, manifest));
}
let mut nodes = Vec::with_capacity(loaded.len());
let mut seen: HashSet<(String, String)> = HashSet::new();
for (dir, manifest) in &loaded {
let bundle = build_bundle(manifest, dir)?;
let kind = if manifest.is_group() { "group" } else { "app" };
let slug = manifest.slug().to_string();
if !seen.insert((kind.to_string(), slug.clone())) {
bail!("project tree declares {kind} `{slug}` more than once");
}
let mut node = json!({ "kind": kind, "slug": slug, "bundle": bundle });
// Group nodes carry their parent (explicit `[group] parent`, else the
// nearest ancestor directory's group) + display name, so the server can
// create/reparent them (M2). App nodes ignore both server-side.
if let Some(g) = &manifest.group {
let parent = g
.parent
.clone()
.or_else(|| nearest_ancestor_group(dir, &group_dirs));
let obj = node.as_object_mut().expect("json object");
obj.insert("name".into(), json!(g.name));
if let Some(p) = parent {
obj.insert("parent_slug".into(), json!(p));
}
}
nodes.push(node);
}
let count = nodes.len();
let envs = project
.as_ref()
.map(|p| p.environments.clone())
.unwrap_or_default();
let mut bundle = json!({ "nodes": nodes });
if let Some(p) = &project {
bundle.as_object_mut().expect("json object").insert(
"project".into(),
json!({
"environments": p.environments.iter()
.map(|e| json!({ "name": e.name, "confirm": e.confirm }))
.collect::<Vec<_>>(),
}),
);
}
Ok((bundle, count, envs))
}
/// The slug of the nearest ancestor directory (above `dir`) that holds a group
/// manifest — the directory-derived parent for a nested group node.
fn nearest_ancestor_group(dir: &Path, group_dirs: &HashMap<PathBuf, String>) -> Option<String> {
let mut cur = dir.parent();
while let Some(d) = cur {
if let Some(slug) = group_dirs.get(d) {
return Some(slug.clone());
}
cur = d.parent();
}
None
}

View File

@@ -13,6 +13,59 @@ use serde::{Deserialize, Serialize};
const DIR: &str = ".picloud";
const PLAN_FILE: &str = "plan.json";
const PROJECT_FILE: &str = "project.json";
/// The repo's stable project identity (§7, M3): a random, opaque key minted on
/// first need and persisted (gitignored) in `.picloud/`. It is the server-side
/// ownership handle — the same repo keeps the same project across clones/CI
/// because the key travels in the working tree, never in a committed file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectLink {
pub key: String,
}
fn project_path(base: &Path) -> PathBuf {
base.join(DIR).join(PROJECT_FILE)
}
/// Read the project key, if one has been minted under `base/.picloud/`.
#[must_use]
pub fn read_project_key(base: &Path) -> Option<String> {
let body = fs::read(project_path(base)).ok()?;
serde_json::from_slice::<ProjectLink>(&body)
.ok()
.map(|l| l.key)
}
/// Read the project key, minting and persisting a fresh one if absent. Used by
/// the tree `plan`/`apply` paths so a repo that never ran `pic init` still gets
/// a stable ownership identity on first use. The new key is a random v4 UUID.
pub fn ensure_project_key(base: &Path) -> Result<String> {
if let Some(k) = read_project_key(base) {
return Ok(k);
}
let key = uuid::Uuid::new_v4().to_string();
write_project_key(base, &key)?;
Ok(key)
}
/// Persist `key` as the project identity, creating `.picloud/` (self-ignored)
/// if needed. Idempotent overwrite — callers usually go through
/// [`ensure_project_key`].
pub fn write_project_key(base: &Path, key: &str) -> Result<()> {
let dir = base.join(DIR);
fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
let ignore = dir.join(".gitignore");
if !ignore.exists() {
fs::write(&ignore, "*\n").context("writing .picloud/.gitignore")?;
}
let body = serde_json::to_vec_pretty(&ProjectLink {
key: key.to_string(),
})
.context("encoding .picloud/project.json")?;
fs::write(project_path(base), body).context("writing .picloud/project.json")?;
Ok(())
}
/// The recorded result of the last `pic plan`, scoped to the app it was for.
#[derive(Debug, Clone, Serialize, Deserialize)]

View File

@@ -12,6 +12,7 @@ use clap::{Args, Parser, Subcommand, ValueEnum};
mod client;
mod cmds;
mod config;
mod discover;
mod linkstate;
mod manifest;
mod output;
@@ -144,6 +145,14 @@ enum Cmd {
cmd: MembersCmd,
},
/// Config vars (Phase 3) — set / list / delete group- or app-owned
/// env-scoped vars. Values inherit down the group tree; an app value
/// overrides an inherited one (proximity wins).
Vars {
#[command(subcommand)]
cmd: VarsCmd,
},
/// Files inspection — list a collection's blobs, download bytes, or
/// delete a file. Read + delete only; writes go through scripts.
Files {
@@ -194,6 +203,11 @@ struct ApplyArgs {
/// Path to the manifest.
#[arg(long, default_value = "picloud.toml")]
file: PathBuf,
/// Phase 5: apply a whole project TREE — every `picloud.toml` under this
/// directory (groups + apps) reconciled in one atomic transaction.
/// Mutually exclusive with `--file`.
#[arg(long, conflicts_with = "file")]
dir: Option<PathBuf>,
/// Delete live scripts/routes/triggers absent from the manifest.
/// Secrets are never pruned.
#[arg(long)]
@@ -206,10 +220,19 @@ struct ApplyArgs {
/// since the last `pic plan`).
#[arg(long)]
force: bool,
/// Tree apply only (`--dir`): seize declared groups currently owned by
/// another project (§7). Requires group-admin on each contested node.
#[arg(long, requires = "dir")]
takeover: bool,
/// Merge the `picloud.<env>.toml` overlay (per-env slug + secrets) on
/// top of the base manifest before applying.
#[arg(long)]
env: Option<String>,
/// Tree apply only (`--dir`): explicitly approve applying to a
/// confirm-required environment (per the root manifest's `[project]`
/// policy, §4.2). Repeatable. A blanket `--yes` does NOT cover it.
#[arg(long = "approve", requires = "dir")]
approve: Vec<String>,
}
#[derive(Args)]
@@ -217,6 +240,10 @@ struct PlanArgs {
/// Path to the manifest.
#[arg(long, default_value = "picloud.toml")]
file: PathBuf,
/// Phase 5: plan a whole project TREE — every `picloud.toml` under this
/// directory (groups + apps). Mutually exclusive with `--file`.
#[arg(long, conflicts_with = "file")]
dir: Option<PathBuf>,
/// Merge the `picloud.<env>.toml` overlay (per-env slug + secrets) on
/// top of the base manifest before diffing.
#[arg(long)]
@@ -259,6 +286,10 @@ struct ConfigArgs {
/// Show the effective (resolved) config with secrets masked.
#[arg(long)]
effective: bool,
/// With `--effective`, also print each resolved var's `merged_from`
/// provenance — the ordered (depth, scope) layers it merged from.
#[arg(long)]
explain: bool,
/// Resolve against the `picloud.<env>.toml` overlay (per-env slug +
/// secrets), matching `pic plan --env` / `pic apply --env`.
#[arg(long)]
@@ -517,15 +548,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`,
@@ -539,8 +574,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)]
@@ -1134,31 +1175,114 @@ enum DeadLettersCmd {
#[derive(Subcommand)]
enum SecretsCmd {
/// List secret names + last-modified for an app. Values never
/// leave the server.
/// List secret names + last-modified for the owner. Values never
/// leave the server. `--env` filters group secrets (app secrets are
/// env-agnostic; the `env` column shows the scope for groups).
Ls {
/// Owning group (slug or id). Mutually exclusive with `--app`.
#[arg(long)]
app: String,
group: Option<String>,
/// Owning app (slug or id). Mutually exclusive with `--group`.
#[arg(long)]
app: Option<String>,
/// Environment scope (group owners only).
#[arg(long)]
env: Option<String>,
},
/// Set a secret. Reads the value from stdin (the only safe
/// channel — inline values would land in shell history). Pipe
/// the value in: `echo -n "mysecret" | pic secrets set --app foo my_key`.
/// For group owners, `--env` scopes the secret.
Set {
/// Owning group (slug or id). Mutually exclusive with `--app`.
#[arg(long)]
app: String,
group: Option<String>,
/// Owning app (slug or id). Mutually exclusive with `--group`.
#[arg(long)]
app: Option<String>,
name: String,
/// Environment scope (group owners only).
#[arg(long)]
env: Option<String>,
/// Treat stdin as raw JSON (numbers, maps, …) instead of a
/// string literal.
#[arg(long)]
json: bool,
},
/// Delete a secret by name.
/// Delete a secret by name (optionally env-scoped for groups).
Rm {
/// Owning group (slug or id). Mutually exclusive with `--app`.
#[arg(long)]
app: String,
group: Option<String>,
/// Owning app (slug or id). Mutually exclusive with `--group`.
#[arg(long)]
app: Option<String>,
name: String,
/// Environment scope (group owners only).
#[arg(long)]
env: Option<String>,
},
/// Fetch and print a secret's PLAINTEXT value. This is the ONLY
/// command that reveals a secret value, and it is gated server-side
/// at the owning group — hence `--group` only, no `--app`.
Read {
/// Owning group (slug or id).
#[arg(long)]
group: String,
name: String,
/// Environment scope.
#[arg(long)]
env: Option<String>,
},
}
#[derive(Subcommand)]
enum VarsCmd {
/// List the owner's OWN vars (not the resolved/inherited view).
Ls {
/// Owning group (slug or id). Mutually exclusive with `--app`.
#[arg(long)]
group: Option<String>,
/// Owning app (slug or id). Mutually exclusive with `--group`.
#[arg(long)]
app: Option<String>,
},
/// Set a var. The value is stored as a JSON string by default; pass
/// `--json` to parse it as raw JSON. `--tombstone` writes a deletion
/// marker that suppresses an inherited key.
Set {
key: String,
/// Ignored (but accepted) when `--tombstone` is set.
#[arg(default_value = "")]
value: String,
#[arg(long)]
group: Option<String>,
#[arg(long)]
app: Option<String>,
/// Environment scope (`*` = env-agnostic, the default).
#[arg(long)]
env: Option<String>,
/// Parse `value` as raw JSON instead of a string literal.
#[arg(long)]
json: bool,
/// Write a tombstone (suppress an inherited key) instead of a value.
#[arg(long)]
tombstone: bool,
},
/// Delete a var by key (optionally scoped to one environment).
Rm {
key: String,
#[arg(long)]
group: Option<String>,
#[arg(long)]
app: Option<String>,
#[arg(long)]
env: Option<String>,
},
}
@@ -1224,22 +1348,47 @@ async fn main() -> ExitCode {
}
Cmd::Logout => cmds::logout::run().await,
Cmd::Whoami => cmds::whoami::run(mode).await,
Cmd::Apply(args) => {
cmds::apply::run(
Cmd::Apply(args) => match &args.dir {
Some(dir) => {
cmds::apply::run_tree(
dir,
args.prune,
args.yes,
args.force,
args.takeover,
args.env.as_deref(),
&args.approve,
mode,
)
.await
}
None => {
cmds::apply::run(
&args.file,
args.env.as_deref(),
args.prune,
args.yes,
args.force,
mode,
)
.await
}
},
Cmd::Plan(args) => match &args.dir {
Some(dir) => cmds::plan::run_tree(dir, args.env.as_deref(), mode).await,
None => cmds::plan::run(&args.file, args.env.as_deref(), mode).await,
},
Cmd::Pull(args) => cmds::pull::run(&args.app, &args.dir, args.force, mode).await,
Cmd::Config(args) => {
cmds::config::run(
&args.file,
args.effective,
args.explain,
args.env.as_deref(),
args.prune,
args.yes,
args.force,
mode,
)
.await
}
Cmd::Plan(args) => cmds::plan::run(&args.file, args.env.as_deref(), mode).await,
Cmd::Pull(args) => cmds::pull::run(&args.app, &args.dir, args.force, mode).await,
Cmd::Config(args) => {
cmds::config::run(&args.file, args.effective, args.env.as_deref(), mode).await
}
Cmd::Init(args) => cmds::init::run(
&args.dir,
args.slug.as_deref(),
@@ -1363,15 +1512,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,
@@ -1414,7 +1564,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,
@@ -1741,14 +1892,39 @@ async fn main() -> ExitCode {
cmd: DeadLettersCmd::Resolve { app, dl_id, reason },
} => cmds::dead_letters::resolve(&app, &dl_id, &reason).await,
Cmd::Secrets {
cmd: SecretsCmd::Ls { app },
} => cmds::secrets::ls(&app, mode).await,
cmd: SecretsCmd::Ls { group, app, env },
} => cmds::secrets::ls(group.as_deref(), app.as_deref(), env.as_deref(), mode).await,
Cmd::Secrets {
cmd: SecretsCmd::Set { app, name, json },
} => cmds::secrets::set(&app, &name, json).await,
cmd:
SecretsCmd::Set {
group,
app,
name,
env,
json,
},
} => {
cmds::secrets::set(
group.as_deref(),
app.as_deref(),
&name,
env.as_deref(),
json,
)
.await
}
Cmd::Secrets {
cmd: SecretsCmd::Rm { app, name },
} => cmds::secrets::rm(&app, &name).await,
cmd:
SecretsCmd::Rm {
group,
app,
name,
env,
},
} => cmds::secrets::rm(group.as_deref(), app.as_deref(), &name, env.as_deref()).await,
Cmd::Secrets {
cmd: SecretsCmd::Read { group, name, env },
} => cmds::secrets::read(&group, &name, env.as_deref()).await,
Cmd::Members {
cmd: MembersCmd::Ls { app },
} => cmds::members::ls(&app, mode).await,
@@ -1761,6 +1937,41 @@ async fn main() -> ExitCode {
Cmd::Members {
cmd: MembersCmd::Rm { app, user_id },
} => cmds::members::rm(&app, &user_id).await,
Cmd::Vars {
cmd: VarsCmd::Ls { group, app },
} => cmds::vars::ls(group.as_deref(), app.as_deref(), mode).await,
Cmd::Vars {
cmd:
VarsCmd::Set {
key,
value,
group,
app,
env,
json,
tombstone,
},
} => {
cmds::vars::set(
group.as_deref(),
app.as_deref(),
&key,
&value,
env.as_deref(),
json,
tombstone,
)
.await
}
Cmd::Vars {
cmd:
VarsCmd::Rm {
key,
group,
app,
env,
},
} => cmds::vars::rm(group.as_deref(), app.as_deref(), &key, env.as_deref()).await,
Cmd::Files {
cmd:
FilesCmd::Ls {

View File

@@ -14,6 +14,7 @@
//! exposed declaratively). `email` triggers carry an `inbound_secret_ref`
//! (a secret name) resolved server-side at apply.
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
@@ -29,7 +30,16 @@ pub const MANIFEST_FILE: &str = "picloud.toml";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Manifest {
pub app: ManifestApp,
/// An app node declares `[app]`; a group node declares `[group]` (Phase 5).
/// Exactly one is present (enforced by [`Manifest::parse`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub app: Option<ManifestApp>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<ManifestGroup>,
/// `[project]` — project-level policy (M5), consumed only by `apply --dir`
/// and only on the tree's ROOT manifest (enforced in `build_tree`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<ManifestProject>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scripts: Vec<ManifestScript>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -38,12 +48,74 @@ pub struct Manifest {
pub triggers: ManifestTriggers,
#[serde(default, skip_serializing_if = "ManifestSecrets::is_empty")]
pub secrets: ManifestSecrets,
/// `[vars]` — app config key → value, reconciled at app scope `*`. Values
/// are non-secret and live inline (unlike `[secrets]`, which is name-only).
/// The overlay merges per-env, last-write-wins by key.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub vars: BTreeMap<String, toml::Value>,
/// `[[extension_points]]` — module names this node opens for per-tenant
/// resolution (§5.5). Allowed on both app and group manifests.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extension_points: Vec<ManifestExtensionPoint>,
/// `[[route_templates]]` — GROUP-only route declarations that fan out into a
/// concrete route on every descendant app at apply time (§4.5, M4a). String
/// fields may carry `{app_slug}`/`{env}`/`{var:NAME}` placeholders.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub route_templates: Vec<ManifestRouteTemplate>,
/// `[[trigger_templates]]` — GROUP-only trigger declarations that fan out per
/// descendant app (§4.5, M4b). Each entry is `name = …`, `kind = "cron"|…`,
/// `script = …`, plus the kind's params (string fields may carry
/// placeholders). Kept loosely-typed so one block covers all seven kinds.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub trigger_templates: Vec<ManifestTriggerTemplate>,
}
impl Manifest {
/// Parse a manifest from TOML text.
/// Parse a manifest from TOML text. Enforces the app-XOR-group invariant.
pub fn parse(text: &str) -> Result<Self> {
toml::from_str(text).context("parsing manifest TOML")
let m: Self = toml::from_str(text).context("parsing manifest TOML")?;
match (&m.app, &m.group) {
(Some(_), None) | (None, Some(_)) => {}
(Some(_), Some(_)) => {
anyhow::bail!(
"manifest declares both [app] and [group]; a node is one or the other"
)
}
(None, None) => {
anyhow::bail!("manifest declares neither [app] nor [group]")
}
}
// A group node owns only scripts + vars (+ secret names) — routes and
// triggers are app concerns. Reject them early with a clear message.
if m.group.is_some() {
if !m.routes.is_empty() {
anyhow::bail!(
"a [group] manifest cannot declare [[routes]] — routes bind to an app"
);
}
if !m.triggers.is_empty() {
anyhow::bail!(
"a [group] manifest cannot declare [[triggers]] — triggers belong to an app"
);
}
}
Ok(m)
}
/// This node's slug (app or group).
#[must_use]
pub fn slug(&self) -> &str {
match (&self.app, &self.group) {
(Some(a), _) => &a.slug,
(_, Some(g)) => &g.slug,
_ => "",
}
}
/// True iff this manifest declares a `[group]` node (Phase 5).
#[must_use]
pub fn is_group(&self) -> bool {
self.group.is_some()
}
/// Load and parse the manifest at `path`.
@@ -61,9 +133,9 @@ impl Manifest {
/// Load the base manifest, then (if `env` is set) merge the sparse
/// `picloud.<env>.toml` overlay on top — the §4.1 base+overlay model
/// where "an environment is an app". The overlay carries per-env slug +
/// secrets; scripts/routes/triggers stay in the shared base. (Rich
/// per-key `vars` resolution is Phase 3.)
/// where "an environment is an app". The overlay carries per-env slug,
/// secrets, and vars (overlay vars win per key); scripts/routes/triggers
/// stay in the shared base.
pub fn load_with_env(base_path: &Path, env: Option<&str>) -> Result<Self> {
let mut base = Self::load(base_path)?;
if let Some(env) = env {
@@ -84,17 +156,24 @@ impl Manifest {
/// Merge a sparse overlay onto this manifest: overlay `app.slug`/`name`
/// replace the base's; overlay secret names union into the base set.
fn apply_overlay(&mut self, overlay: ManifestOverlay) {
if let Some(slug) = overlay.app.slug {
self.app.slug = slug;
}
if let Some(name) = overlay.app.name {
self.app.name = name;
if let Some(app) = &mut self.app {
if let Some(slug) = overlay.app.slug {
app.slug = slug;
}
if let Some(name) = overlay.app.name {
app.name = name;
}
}
for n in overlay.secrets.names {
if !self.secrets.names.contains(&n) {
self.secrets.names.push(n);
}
}
// Overlay vars override base vars per key (the env-specific value of
// an env-agnostic default); keys only in the base are kept.
for (k, v) in overlay.vars {
self.vars.insert(k, v);
}
}
}
@@ -113,10 +192,10 @@ fn overlay_path(base_path: &Path, env: &str) -> std::path::PathBuf {
/// A sparse per-environment overlay (`picloud.<env>.toml`). Only the fields
/// that vary per environment today — slug/name and secret names.
///
/// `deny_unknown_fields`: an overlay can carry *only* `[app]` and `[secrets]`.
/// Scripts/routes/triggers belong in the shared base manifest, so a
/// `[[scripts]]`/`[[routes]]`/`[[triggers]]` table (or a typo'd key) in an
/// overlay is a mistake — error loudly rather than silently dropping it.
/// `deny_unknown_fields`: an overlay can carry *only* `[app]`, `[secrets]`,
/// and `[vars]`. Scripts/routes/triggers belong in the shared base manifest,
/// so a `[[scripts]]`/`[[routes]]`/`[[triggers]]` table (or a typo'd key) in
/// an overlay is a mistake — error loudly rather than silently dropping it.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestOverlay {
@@ -124,6 +203,8 @@ pub struct ManifestOverlay {
pub app: OverlayApp,
#[serde(default)]
pub secrets: ManifestSecrets,
#[serde(default)]
pub vars: BTreeMap<String, toml::Value>,
}
#[derive(Debug, Clone, Default, Deserialize)]
@@ -143,6 +224,42 @@ pub struct ManifestApp {
pub description: Option<String>,
}
/// A `[group]` node (Phase 5/M2): a group's own declarative content — its
/// scripts and `[vars]`. With M2, `pic apply --dir` also creates the group if
/// it doesn't exist and reparents it under `parent`. When `parent` is omitted,
/// the parent is inferred from the enclosing directory's group manifest (or the
/// instance root for a top-level group).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ManifestGroup {
pub slug: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Explicit parent group slug (M2). Overrides the directory-derived parent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
}
/// `[project]` — project-level policy (§4.2/§6, M5). Valid ONLY on the root
/// manifest of a `pic apply --dir` tree; rejected elsewhere by [`build_tree`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestProject {
/// `[[project.environments]]` — per-env apply policy.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub environments: Vec<ManifestEnvironment>,
}
/// One `[[project.environments]]` entry. `confirm = true` gates applying to this
/// env behind an explicit `pic apply --approve <name>` (M5).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestEnvironment {
pub name: String,
#[serde(default)]
pub confirm: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ManifestScript {
pub name: String,
@@ -193,6 +310,43 @@ pub struct ManifestRoute {
pub enabled: bool,
}
/// `[[route_templates]]` — a route declared once on a group, fanned out per
/// descendant app (§4.5, M4a). Same shape as [`ManifestRoute`] plus a `name`
/// (the per-group identity/upsert key).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ManifestRouteTemplate {
/// Per-group template name (identity/upsert key).
pub name: String,
pub script: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub method: Option<String>,
pub host_kind: HostKind,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub host: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host_param_name: Option<String>,
pub path_kind: PathKind,
pub path: String,
#[serde(default, skip_serializing_if = "is_sync")]
pub dispatch_mode: DispatchMode,
#[serde(
default = "picloud_shared::default_true",
skip_serializing_if = "is_true"
)]
pub enabled: bool,
}
/// `[[trigger_templates]]` — a trigger declared once on a group, fanned out per
/// descendant app (§4.5, M4b). `name` is the per-group identity; the remaining
/// keys (`kind`, `script`, kind params) are kept as a flattened table so one
/// block covers every kind, matching the server's `{ name, <BundleTrigger> }`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ManifestTriggerTemplate {
pub name: String,
#[serde(flatten)]
pub spec: toml::Value,
}
/// Triggers grouped by kind (arrays-of-tables: `[[triggers.cron]]`, …).
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ManifestTriggers {
@@ -323,6 +477,17 @@ impl ManifestSecrets {
}
}
/// `[[extension_points]]` — a module name opened for per-tenant resolution
/// (§5.5). A parent/group script importing `name` resolves it against the
/// inheriting app's module instead of the sealed lexical chain; `default`
/// names a local module used when an app provides none.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ManifestExtensionPoint {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
}
// ---- serde skip/default helpers ----
fn is_endpoint(kind: &ScriptKind) -> bool {
@@ -348,11 +513,13 @@ mod tests {
fn sample() -> Manifest {
Manifest {
app: ManifestApp {
app: Some(ManifestApp {
slug: "blog".into(),
name: "My Blog".into(),
description: Some("demo".into()),
},
}),
group: None,
project: None,
scripts: vec![
ManifestScript {
name: "create-post".into(),
@@ -409,6 +576,16 @@ mod tests {
secrets: ManifestSecrets {
names: vec!["STRIPE_KEY".into()],
},
vars: BTreeMap::from([
("region".to_string(), toml::Value::String("eu".into())),
("max-retries".to_string(), toml::Value::Integer(3)),
]),
extension_points: vec![ManifestExtensionPoint {
name: "theme".into(),
default: Some("default-theme".into()),
}],
route_templates: Vec::new(),
trigger_templates: Vec::new(),
}
}
@@ -454,11 +631,9 @@ mod tests {
)
.unwrap();
m.apply_overlay(overlay);
assert_eq!(m.app.slug, "blog-staging", "overlay slug wins");
assert_eq!(
m.app.name, "My Blog",
"base name kept when overlay omits it"
);
let app = m.app.as_ref().unwrap();
assert_eq!(app.slug, "blog-staging", "overlay slug wins");
assert_eq!(app.name, "My Blog", "base name kept when overlay omits it");
assert_eq!(
m.secrets.names,
vec!["STRIPE_KEY".to_string(), "STAGING_ONLY".to_string()],
@@ -503,21 +678,89 @@ mod tests {
#[test]
fn empty_optional_sections_omitted() {
let m = Manifest {
app: ManifestApp {
app: Some(ManifestApp {
slug: "x".into(),
name: "X".into(),
description: None,
},
}),
group: None,
project: None,
scripts: vec![],
routes: vec![],
triggers: ManifestTriggers::default(),
secrets: ManifestSecrets::default(),
vars: BTreeMap::new(),
extension_points: Vec::new(),
route_templates: Vec::new(),
trigger_templates: Vec::new(),
};
let text = m.to_toml().unwrap();
assert!(!text.contains("[[scripts]]"), "got:\n{text}");
assert!(!text.contains("triggers"), "got:\n{text}");
assert!(!text.contains("secrets"), "got:\n{text}");
assert!(!text.contains("vars"), "got:\n{text}");
assert!(!text.contains("extension_points"), "got:\n{text}");
// Still round-trips.
assert_eq!(m, Manifest::parse(&text).unwrap());
}
#[test]
fn group_manifest_parses_and_rejects_app_only_blocks() {
// A [group] node: scripts + vars, no [app].
let m = Manifest::parse(
"[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n\n\
[vars]\nregion = \"eu\"\n",
)
.expect("group manifest parses");
assert!(m.is_group());
assert_eq!(m.slug(), "acme");
assert_eq!(m.scripts.len(), 1);
// A group cannot carry routes/triggers.
let err = Manifest::parse(
"[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
[[routes]]\nscript = \"shared\"\nhost_kind = \"any\"\npath_kind = \"exact\"\npath = \"/x\"\n",
)
.expect_err("group with routes is rejected");
assert!(err.to_string().contains("routes"), "got: {err}");
// Neither / both is rejected.
Manifest::parse("[vars]\nx = 1\n").expect_err("no [app] or [group]");
Manifest::parse("[app]\nslug=\"a\"\nname=\"A\"\n[group]\nslug=\"g\"\nname=\"G\"\n")
.expect_err("both [app] and [group]");
}
#[test]
fn project_block_parses_environment_policy() {
let m = Manifest::parse(
"[app]\nslug = \"a\"\nname = \"A\"\n\n\
[[project.environments]]\nname = \"production\"\nconfirm = true\n\n\
[[project.environments]]\nname = \"staging\"\n",
)
.expect("manifest with [project] parses");
let p = m.project.expect("project present");
assert_eq!(p.environments.len(), 2);
assert_eq!(p.environments[0].name, "production");
assert!(p.environments[0].confirm);
// `confirm` defaults to false when omitted.
assert_eq!(p.environments[1].name, "staging");
assert!(!p.environments[1].confirm);
}
#[test]
fn overlay_vars_override_base_per_key() {
let mut base = sample();
base.vars
.insert("region".into(), toml::Value::String("eu".into()));
base.vars
.insert("tier".into(), toml::Value::String("base".into()));
let overlay: ManifestOverlay =
toml::from_str("[vars]\nregion = \"us\"\nextra = true\n").unwrap();
base.apply_overlay(overlay);
// overlay wins for `region`, base-only `tier` survives, overlay adds `extra`.
assert_eq!(base.vars["region"], toml::Value::String("us".into()));
assert_eq!(base.vars["tier"], toml::Value::String("base".into()));
assert_eq!(base.vars["extra"], toml::Value::Boolean(true));
}
}

View File

@@ -151,3 +151,105 @@ fn apply_rejects_bad_bundle_atomically() {
"a failed apply must leave nothing behind:\n{s}"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn apply_reconciles_app_vars() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("apply-vars");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
let _guard = AppGuard::new(&env.url, &env.token, &slug);
let dir = manifest_dir();
// The script returns the resolved `region` var verbatim, so an invoke
// reflects exactly what `apply` wrote.
fs::write(
dir.path().join("scripts/read.rhai"),
"vars::get(\"region\")",
)
.unwrap();
let manifest = |vars_block: &str| {
format!(
"[app]\nslug = \"{slug}\"\nname = \"Vars Test\"\n\n\
[[scripts]]\nname = \"read\"\nfile = \"scripts/read.rhai\"\n\n\
[[routes]]\nscript = \"read\"\nmethod = \"POST\"\n\
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/read\"\n\n\
{vars_block}"
)
};
let manifest_path = dir.path().join("picloud.toml");
// 1. Apply with `region = "eu"` → the var is created and injected.
fs::write(&manifest_path, manifest("[vars]\nregion = \"eu\"\n")).unwrap();
let out = common::pic_as(&env)
.args(["apply", "--file"])
.arg(&manifest_path)
.output()
.expect("apply");
assert!(
out.status.success(),
"apply failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let id = script_id(&env, &slug);
assert_eq!(
invoke_body(&env, &id),
serde_json::json!("eu"),
"var applied"
);
// 2. Change the value → Update (not Create).
fs::write(&manifest_path, manifest("[vars]\nregion = \"us\"\n")).unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(&manifest_path)
.assert()
.success();
assert_eq!(
invoke_body(&env, &id),
serde_json::json!("us"),
"var updated"
);
// 3. Drop the var + `--prune` → Delete; `vars::get` now returns `()`.
fs::write(&manifest_path, manifest("")).unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(&manifest_path)
.args(["--prune", "--yes"])
.assert()
.success();
assert_eq!(
invoke_body(&env, &id),
serde_json::Value::Null,
"var pruned → unresolved"
);
}
fn script_id(env: &common::TestEnv, slug: &str) -> String {
let ls = common::pic_as(env)
.args(["scripts", "ls", "--app", slug])
.output()
.expect("scripts ls");
common::parse_first_id(std::str::from_utf8(&ls.stdout).unwrap())
.expect("scripts ls should produce one row")
}
fn invoke_body(env: &common::TestEnv, id: &str) -> serde_json::Value {
let out = common::pic_as(env)
.args(["scripts", "invoke", id])
.output()
.expect("scripts invoke");
assert!(
out.status.success(),
"invoke failed: {}",
String::from_utf8_lossy(&out.stderr)
);
serde_json::from_slice(&out.stdout).expect("invoke body is JSON")
}

View File

@@ -0,0 +1,169 @@
//! M5 per-env approval gating (§4.2, §6) via `pic apply --dir`:
//! * a root manifest `[project]` block marks an environment confirm-required,
//! * applying to that env WITHOUT `--approve` is refused (a blanket `--yes`
//! does not cover it) — refused non-interactively at the CLI,
//! * `--approve <env>` (as an admin) lets it through,
//! * a non-gated environment applies with plain `--yes`,
//! * approving a gated apply needs ADMIN authority on the node — a non-admin
//! editor with `--approve` is refused server-side (403).
use std::fs;
use std::path::Path;
use tempfile::TempDir;
use crate::common;
use crate::common::cleanup::{AppGuard, GroupGuard};
use crate::common::member;
/// A single-app project dir whose root manifest declares a `[project]` policy:
/// `production` is confirm-required, `staging` is not.
fn project_dir(app: &str) -> TempDir {
let dir = TempDir::new().expect("tempdir");
// A `[vars]` entry so the app bundle has write-requiring content: an `editor`
// member must hold (and exercise) AppVarsWrite to pass authz_tree — which
// makes the admin-gate test prove the approval gate is ABOVE editor-write,
// not merely "any non-admin is refused". (Vars cascade-delete with the app,
// unlike scripts which are ON DELETE RESTRICT, so AppGuard teardown is clean.)
fs::write(
dir.path().join("picloud.toml"),
format!(
"[app]\nslug = \"{app}\"\nname = \"Gated App\"\n\n\
[[project.environments]]\nname = \"production\"\nconfirm = true\n\n\
[[project.environments]]\nname = \"staging\"\nconfirm = false\n\n\
[vars]\nregion = \"eu\"\n"
),
)
.unwrap();
// `--env <e>` requires the overlay file to exist; empty overlays keep the
// base slug (same app across envs — we're testing the gate, not env routing).
fs::write(dir.path().join("picloud.production.toml"), "").unwrap();
fs::write(dir.path().join("picloud.staging.toml"), "").unwrap();
dir
}
fn apply(env: &common::TestEnv, dir: &Path, extra: &[&str]) -> std::process::Output {
let mut cmd = common::pic_as(env);
cmd.args(["apply", "--dir"]).arg(dir).args(extra);
cmd.output().expect("apply --dir")
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn confirm_required_env_needs_explicit_approval() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("appr-g");
let app = common::unique_slug("appr-a");
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let _a = AppGuard::new(&env.url, &env.token, &app);
common::pic_as(&env)
.args(["apps", "create", &app, "--group", &group])
.assert()
.success();
let dir = project_dir(&app);
// --- production is confirm-required: --yes alone is refused. ---
let out = apply(&env, dir.path(), &["--env", "production", "--yes"]);
assert!(
!out.status.success(),
"production apply without --approve must be refused"
);
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
assert!(
err.contains("approve"),
"refusal should mention --approve:\n{err}"
);
// --- with --approve production, it applies. ---
let ok = apply(
&env,
dir.path(),
&["--env", "production", "--approve", "production"],
);
assert!(
ok.status.success(),
"approved production apply should succeed: {}",
String::from_utf8_lossy(&ok.stderr)
);
// --- staging is NOT gated: plain --yes applies. ---
let ok2 = apply(&env, dir.path(), &["--env", "staging", "--yes"]);
assert!(
ok2.status.success(),
"non-gated staging apply should succeed: {}",
String::from_utf8_lossy(&ok2.stderr)
);
// --- single-node `apply --file` to a gated env is refused (no silent
// bypass): the admin-gated approval is a `--dir` feature. ---
let single = common::pic_as(&env)
.args(["apply", "--file"])
.arg(dir.path().join("picloud.toml"))
.args(["--env", "production", "--yes"])
.output()
.expect("apply --file");
assert!(
!single.status.success(),
"single-node apply to a confirm-required env must be refused"
);
let serr = String::from_utf8_lossy(&single.stderr).to_lowercase();
assert!(
serr.contains("confirm-required") || serr.contains("--dir"),
"single-node refusal should point at --dir:\n{serr}"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn approving_a_gated_apply_requires_admin() {
// §4.2: approving a confirm-required env is admin-gated — a second gate on
// top of the editor-level write caps an ordinary apply needs. An editor who
// CAN write the app still cannot approve a gated apply.
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("appr2-g");
let app = common::unique_slug("appr2-a");
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let _a = AppGuard::new(&env.url, &env.token, &app);
common::pic_as(&env)
.args(["apps", "create", &app, "--group", &group])
.assert()
.success();
// A member with `editor` (write) on the app — enough for an ordinary apply,
// not enough to approve a gated environment.
let m = member::member_user(fx, &common::unique_username("appr"));
member::grant_membership(fx, &app, &m.id, "editor");
let member_env = common::custom_env(&fx.url, &m.token);
common::seed_credentials(&member_env, &m.username);
let dir = project_dir(&app);
let out = apply(
&member_env,
dir.path(),
&["--env", "production", "--approve", "production"],
);
assert!(
!out.status.success(),
"a non-admin editor must not be able to approve a gated apply"
);
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
assert!(
err.contains("forbidden") || err.contains("403"),
"approval denial should be an authz error:\n{err}"
);
}

View File

@@ -16,6 +16,7 @@ mod common;
mod admins;
mod api_keys;
mod apply;
mod approval;
mod apps;
mod auth;
mod config;
@@ -23,11 +24,16 @@ mod dead_letters;
mod email_queue;
mod enabled;
mod env_overlay;
mod extension_points;
mod group_modules;
mod group_scripts;
mod group_secrets;
mod groups;
mod init;
mod invoke;
mod logs;
mod output;
mod ownership;
mod plan;
mod prune;
mod pull;
@@ -36,4 +42,8 @@ mod routes;
mod scripts;
mod secrets;
mod staleness;
mod templates;
mod tree;
mod tree_shape;
mod triggers;
mod vars;

View File

@@ -38,6 +38,36 @@ impl Drop for AppGuard {
}
}
/// Deletes a script by id on drop (best-effort). Phase 4: a group-owned
/// script blocks its group's deletion (`ON DELETE RESTRICT`), so register the
/// `ScriptGuard` *after* the owning `GroupGuard` — the script drops (deletes)
/// first, leaving the group removable.
pub struct ScriptGuard {
url: String,
token: String,
id: String,
}
impl ScriptGuard {
pub fn new(url: &str, token: &str, id: &str) -> Self {
Self {
url: url.to_string(),
token: token.to_string(),
id: id.to_string(),
}
}
}
impl Drop for ScriptGuard {
fn drop(&mut self) {
let client = reqwest::blocking::Client::new();
let _ = client
.delete(format!("{}/api/v1/admin/scripts/{}", self.url, self.id))
.bearer_auth(&self.token)
.send();
}
}
pub struct UserGuard {
url: String,
token: String,

View File

@@ -78,6 +78,26 @@ pub fn grant_membership(fx: &Fixture, app_slug: &str, user_id: &str, role: &str)
);
}
/// `POST /api/v1/admin/groups/{slug}/members` — grant `role` on a group.
pub fn grant_group_membership(fx: &Fixture, group_slug: &str, user_id: &str, role: &str) {
let client = reqwest::blocking::Client::new();
let resp = client
.post(format!(
"{}/api/v1/admin/groups/{}/members",
fx.url, group_slug
))
.bearer_auth(&fx.admin_token)
.json(&json!({ "user_id": user_id, "role": role }))
.send()
.expect("grant group membership");
assert!(
resp.status().is_success(),
"grant group membership failed: {} {}",
resp.status(),
resp.text().unwrap_or_default(),
);
}
/// `PATCH /api/v1/admin/apps/{slug}/members/{user_id}` — promote/demote.
pub fn update_membership(fx: &Fixture, app_slug: &str, user_id: &str, role: &str) {
let client = reqwest::blocking::Client::new();

View File

@@ -0,0 +1,243 @@
//! Extension points (§5.5) end-to-end via `pic apply` + `invoke`:
//! * a GROUP declares a module name `theme` an extension point (with a
//! default body) and a group endpoint `render` that imports it,
//! * an app under the group provides its OWN `theme`; invoking the inherited
//! `render` in that app's context resolves the APP's `theme` (the
//! inversion) — NOT sealed to the group,
//! * an app that provides no `theme` falls back to the group's default body,
//! * `pull` round-trips the declaration (a re-applied pulled manifest is a
//! no-op, so `--prune` never silently drops it).
use std::fs;
use tempfile::TempDir;
use crate::common;
use crate::common::cleanup::{AppGuard, GroupGuard, ScriptGuard};
fn manifest_dir() -> TempDir {
let dir = TempDir::new().expect("tempdir");
fs::create_dir_all(dir.path().join("scripts")).expect("scripts dir");
dir
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn extension_point_resolves_per_app_with_default_fallback() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("ep-grp");
let app_a = common::unique_slug("ep-a");
let app_b = common::unique_slug("ep-b");
// GroupGuard first so it drops LAST (after apps + group scripts).
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let dir = manifest_dir();
// --- Group manifest: a default `theme` body, a `render` endpoint that
// imports `theme`, and the `[[extension_points]]` declaration. ---
fs::write(
dir.path().join("scripts/defaulttheme.rhai"),
r#"fn name() { "default" }"#,
)
.unwrap();
fs::write(
dir.path().join("scripts/render.rhai"),
r#"import "theme" as t; t::name()"#,
)
.unwrap();
let group_manifest = format!(
"[group]\nslug = \"{group}\"\nname = \"EP Group\"\n\n\
[[scripts]]\nname = \"defaulttheme\"\nfile = \"scripts/defaulttheme.rhai\"\nkind = \"module\"\n\n\
[[scripts]]\nname = \"render\"\nfile = \"scripts/render.rhai\"\n\n\
[[extension_points]]\nname = \"theme\"\ndefault = \"defaulttheme\"\n"
);
let group_path = dir.path().join("group.toml");
fs::write(&group_path, &group_manifest).unwrap();
let out = common::pic_as(&env)
.args(["apply", "--file"])
.arg(&group_path)
.output()
.expect("group apply");
assert!(
out.status.success(),
"group apply failed: {}",
String::from_utf8_lossy(&out.stderr)
);
// Group scripts block group deletion (RESTRICT) — guard them.
let _gr = ScriptGuard::new(
&env.url,
&env.token,
&group_script_id(&env, &group, "render"),
);
let _gd = ScriptGuard::new(
&env.url,
&env.token,
&group_script_id(&env, &group, "defaulttheme"),
);
// --- App A under the group: provides its OWN `theme` + a caller that
// invokes the inherited `render`. ---
let _ga = AppGuard::new(&env.url, &env.token, &app_a);
common::pic_as(&env)
.args(["apps", "create", &app_a, "--group", &group])
.assert()
.success();
fs::write(
dir.path().join("scripts/theme-a.rhai"),
r#"fn name() { "app-a" }"#,
)
.unwrap();
fs::write(
dir.path().join("scripts/caller.rhai"),
r#"invoke("render", #{})"#,
)
.unwrap();
let app_a_manifest = format!(
"[app]\nslug = \"{app_a}\"\nname = \"EP A\"\n\n\
[[scripts]]\nname = \"theme\"\nfile = \"scripts/theme-a.rhai\"\nkind = \"module\"\n\n\
[[scripts]]\nname = \"caller\"\nfile = \"scripts/caller.rhai\"\n"
);
let app_a_path = dir.path().join("a.toml");
fs::write(&app_a_path, &app_a_manifest).unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(&app_a_path)
.assert()
.success();
// The inherited `render` imports the ext point `theme`; in App A's context
// it resolves App A's OWN `theme`.
let caller_a = app_script_id(&env, &app_a, "caller");
assert_eq!(
invoke_body(&env, &caller_a),
serde_json::json!("app-a"),
"extension point must resolve the inheriting app's module"
);
// --- App B under the group: provides NO `theme` → render falls back to the
// group's default body. ---
let _gb = AppGuard::new(&env.url, &env.token, &app_b);
common::pic_as(&env)
.args(["apps", "create", &app_b, "--group", &group])
.assert()
.success();
let app_b_manifest = format!(
"[app]\nslug = \"{app_b}\"\nname = \"EP B\"\n\n\
[[scripts]]\nname = \"caller\"\nfile = \"scripts/caller.rhai\"\n"
);
let app_b_path = dir.path().join("b.toml");
fs::write(&app_b_path, &app_b_manifest).unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(&app_b_path)
.assert()
.success();
let caller_b = app_script_id(&env, &app_b, "caller");
assert_eq!(
invoke_body(&env, &caller_b),
serde_json::json!("default"),
"an app providing no module must fall back to the ext point's default"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn extension_point_round_trips_through_pull() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let app = common::unique_slug("ep-pull");
let _a = AppGuard::new(&env.url, &env.token, &app);
common::pic_as(&env)
.args(["apps", "create", &app])
.assert()
.success();
// Apply an app manifest that declares an extension point with a default.
let dir = manifest_dir();
fs::write(
dir.path().join("scripts/theme.rhai"),
r#"fn name() { "x" }"#,
)
.unwrap();
let manifest = format!(
"[app]\nslug = \"{app}\"\nname = \"EP Pull\"\n\n\
[[scripts]]\nname = \"theme\"\nfile = \"scripts/theme.rhai\"\nkind = \"module\"\n\n\
[[extension_points]]\nname = \"theme_slot\"\ndefault = \"theme\"\n"
);
let manifest_path = dir.path().join("picloud.toml");
fs::write(&manifest_path, &manifest).unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(&manifest_path)
.assert()
.success();
// Pull into a fresh dir, then re-plan: the extension point must round-trip
// (no Create/Delete), so `--prune` would never drop it.
let pulled = manifest_dir();
common::pic_as(&env)
.args(["pull", &app, "--dir"])
.arg(pulled.path())
.assert()
.success();
let toml = fs::read_to_string(pulled.path().join("picloud.toml")).unwrap();
assert!(
toml.contains("theme_slot"),
"pulled manifest must export the extension point:\n{toml}"
);
let plan = common::pic_as(&env)
.args(["plan", "--file"])
.arg(pulled.path().join("picloud.toml"))
.output()
.expect("plan");
let stdout = String::from_utf8_lossy(&plan.stdout);
assert!(
!stdout.to_lowercase().contains("create") && !stdout.to_lowercase().contains("delete"),
"re-planning a pulled manifest must be a no-op:\n{stdout}"
);
}
/// Id of the named script in a group (`pic scripts ls --group <g>`).
fn group_script_id(env: &common::TestEnv, group: &str, name: &str) -> String {
named_id(env, &["scripts", "ls", "--group", group], name)
}
/// Id of the named script in an app (`pic scripts ls --app <a>`).
fn app_script_id(env: &common::TestEnv, app: &str, name: &str) -> String {
named_id(env, &["scripts", "ls", "--app", app], name)
}
fn named_id(env: &common::TestEnv, args: &[&str], name: &str) -> String {
let ls = common::pic_as(env).args(args).output().expect("scripts ls");
let table = String::from_utf8(ls.stdout).unwrap();
table
.lines()
.map(common::cells)
.find(|c| c.get(2) == Some(&name))
.and_then(|c| c.first().map(|s| (*s).to_string()))
.unwrap_or_else(|| panic!("script `{name}` not found:\n{table}"))
}
fn invoke_body(env: &common::TestEnv, id: &str) -> serde_json::Value {
let out = common::pic_as(env)
.args(["scripts", "invoke", id])
.output()
.expect("scripts invoke");
assert!(
out.status.success(),
"invoke failed: {}",
String::from_utf8_lossy(&out.stderr)
);
serde_json::from_slice(&out.stdout).expect("invoke body is JSON")
}

View File

@@ -0,0 +1,5 @@
// Phase-3 group-secrets journey fixture: returns the resolved `stripe-key`
// secret verbatim so the test can assert runtime injection across the group
// chain (inherited group value) vs an app-owned proximity override. The
// value is decrypted under the resolved owner's AAD before injection.
secrets::get("stripe-key")

View File

@@ -0,0 +1,4 @@
// Phase-3 vars journey fixture: returns the resolved `region` config var
// verbatim so the test can assert on inheritance (group value) vs an app
// proximity override.
vars::get("region")

View File

@@ -0,0 +1,218 @@
//! Phase 4b group-owned modules + the lexical import resolver (§5.5), e2e via `pic`:
//! * a group owns a `module` and an endpoint that imports it; an app under the
//! group inherits the endpoint and resolves the module down the chain,
//! * **trust boundary** — the inheriting app defines a same-named module of
//! its own; the inherited group endpoint's import must STILL bind the
//! group's module (a leaf cannot shadow it),
//! * **CoW / app origin** — an app-owned endpoint importing that name resolves
//! the app's module,
//! * a manifest whose script imports a non-existent module is a `plan` error.
use std::fs;
use tempfile::TempDir;
use crate::common;
use crate::common::cleanup::{AppGuard, GroupGuard, ScriptGuard};
fn manifest_dir() -> TempDir {
let dir = TempDir::new().expect("tempdir");
fs::create_dir_all(dir.path().join("scripts")).expect("scripts dir");
dir
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn group_module_is_lexically_sealed_under_inheritance() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("gm-grp");
let child = common::unique_slug("gm-child");
// GroupGuard first so it drops LAST — after the app + group scripts.
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let dir = manifest_dir();
// Group module `util` and a group endpoint `welcome` that imports it.
fs::write(
dir.path().join("scripts/util.rhai"),
r#"fn greet(n) { "group:" + n }"#,
)
.unwrap();
common::pic_as(&env)
.args(["scripts", "deploy"])
.arg(dir.path().join("scripts/util.rhai"))
.args(["--group", &group, "--name", "util", "--kind", "module"])
.assert()
.success();
fs::write(
dir.path().join("scripts/welcome.rhai"),
r#"import "util" as u; u::greet("x")"#,
)
.unwrap();
common::pic_as(&env)
.args(["scripts", "deploy"])
.arg(dir.path().join("scripts/welcome.rhai"))
.args(["--group", &group, "--name", "welcome"])
.assert()
.success();
// Group scripts block group deletion (ON DELETE RESTRICT) — guard both.
let _gu = ScriptGuard::new(&env.url, &env.token, &group_script_id(&env, &group, "util"));
let _gw = ScriptGuard::new(
&env.url,
&env.token,
&group_script_id(&env, &group, "welcome"),
);
// App under the group; a `caller` invokes the inherited `welcome`.
let _child = AppGuard::new(&env.url, &env.token, &child);
common::pic_as(&env)
.args(["apps", "create", &child, "--group", &group])
.assert()
.success();
fs::write(
dir.path().join("scripts/caller.rhai"),
r#"invoke("welcome", #{})"#,
)
.unwrap();
common::pic_as(&env)
.args(["scripts", "deploy"])
.arg(dir.path().join("scripts/caller.rhai"))
.args(["--app", &child, "--name", "caller"])
.assert()
.success();
let caller_id = app_script_id(&env, &child, "caller");
// Inheritance: welcome resolves `util` from the group → "group:x".
assert_eq!(
invoke_body(&env, &caller_id),
serde_json::json!("group:x"),
"inherited endpoint must resolve the group's module"
);
// TRUST BOUNDARY: the app defines its OWN `util` module. The group
// endpoint's import must STILL bind the GROUP's util (sealed from below).
fs::write(
dir.path().join("scripts/apputil.rhai"),
r#"fn greet(n) { "app:" + n }"#,
)
.unwrap();
common::pic_as(&env)
.args(["scripts", "deploy"])
.arg(dir.path().join("scripts/apputil.rhai"))
.args(["--app", &child, "--name", "util", "--kind", "module"])
.assert()
.success();
assert_eq!(
invoke_body(&env, &caller_id),
serde_json::json!("group:x"),
"a leaf's same-named module must NOT shadow an inherited group endpoint's import"
);
// CoW / app origin: an app-owned endpoint importing `util` gets the APP's.
fs::write(
dir.path().join("scripts/appcaller.rhai"),
r#"import "util" as u; u::greet("y")"#,
)
.unwrap();
common::pic_as(&env)
.args(["scripts", "deploy"])
.arg(dir.path().join("scripts/appcaller.rhai"))
.args(["--app", &child, "--name", "appcaller"])
.assert()
.success();
let appcaller_id = app_script_id(&env, &child, "appcaller");
assert_eq!(
invoke_body(&env, &appcaller_id),
serde_json::json!("app:y"),
"an app-owned script's import must resolve the app's own module"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn dangling_import_is_rejected_by_plan() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let app = common::unique_slug("gm-dangle");
let _app = AppGuard::new(&env.url, &env.token, &app);
common::pic_as(&env)
.args(["apps", "create", &app])
.assert()
.success();
// A manifest whose endpoint imports a module that exists nowhere on the
// chain. `pic plan` must refuse it (the §5.5 dangling-import check) rather
// than apply a script that 404s its import at runtime.
let dir = manifest_dir();
fs::write(
dir.path().join("scripts/broken.rhai"),
r#"import "ghost" as g; g::run()"#,
)
.unwrap();
let manifest = format!(
"[app]\nslug = \"{app}\"\nname = \"Dangle\"\n\n\
[[scripts]]\nname = \"broken\"\nfile = \"scripts/broken.rhai\"\n"
);
let manifest_path = dir.path().join("picloud.toml");
fs::write(&manifest_path, &manifest).unwrap();
let out = common::pic_as(&env)
.args(["plan", "--file"])
.arg(&manifest_path)
.output()
.expect("plan");
assert!(!out.status.success(), "plan must reject a dangling import");
let stderr = String::from_utf8_lossy(&out.stderr).to_lowercase();
assert!(
stderr.contains("ghost") || stderr.contains("unknown module") || stderr.contains("import"),
"plan error should name the missing module:\n{stderr}"
);
}
/// Id of the named script in a group (`pic scripts ls --group <g>`).
fn group_script_id(env: &common::TestEnv, group: &str, name: &str) -> String {
named_id(env, &["scripts", "ls", "--group", group], name)
}
/// Id of the named script in an app (`pic scripts ls --app <a>`).
fn app_script_id(env: &common::TestEnv, app: &str, name: &str) -> String {
named_id(env, &["scripts", "ls", "--app", app], name)
}
/// Run a `scripts ls` and pick the id of the row whose name column matches.
fn named_id(env: &common::TestEnv, args: &[&str], name: &str) -> String {
let ls = common::pic_as(env).args(args).output().expect("scripts ls");
let table = String::from_utf8(ls.stdout).unwrap();
// Rows are `id\t<owner_slug>\tname\tversion\tupdated_at`.
table
.lines()
.map(common::cells)
.find(|c| c.get(2) == Some(&name))
.and_then(|c| c.first().map(|s| (*s).to_string()))
.unwrap_or_else(|| panic!("script `{name}` not found:\n{table}"))
}
fn invoke_body(env: &common::TestEnv, id: &str) -> serde_json::Value {
let out = common::pic_as(env)
.args(["scripts", "invoke", id])
.output()
.expect("scripts invoke");
assert!(
out.status.success(),
"invoke failed: {}",
String::from_utf8_lossy(&out.stderr)
);
serde_json::from_slice(&out.stdout).expect("invoke body is JSON")
}

View File

@@ -0,0 +1,320 @@
//! Phase 4-lite group-owned scripts, end to end via `pic`:
//! * a group owns an endpoint script; an app *under* the group inherits it,
//! resolving by name down the chain (`invoke`),
//! * an app's own script of the same name shadows the inherited one (CoW),
//! * an app NOT under the group cannot resolve it (isolation),
//! * a manifest binds a route to the inherited script declaratively, and
//! re-applying is an idempotent no-op.
use std::fs;
use tempfile::TempDir;
use crate::common;
use crate::common::cleanup::{AppGuard, GroupGuard, ScriptGuard};
fn manifest_dir() -> TempDir {
let dir = TempDir::new().expect("tempdir");
fs::create_dir_all(dir.path().join("scripts")).expect("scripts dir");
dir
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn group_script_is_inherited_with_cow_and_isolation() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("gs-grp");
let child = common::unique_slug("gs-child");
let orphan = common::unique_slug("gs-orphan");
// Group with an endpoint script `shared` returning a marker.
// GroupGuard first so it drops LAST — after the app + group script below.
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let dir = manifest_dir();
fs::write(dir.path().join("scripts/shared.rhai"), "\"from-group\"").unwrap();
common::pic_as(&env)
.args(["scripts", "deploy"])
.arg(dir.path().join("scripts/shared.rhai"))
.args(["--group", &group, "--name", "shared"])
.assert()
.success();
// The group script blocks group deletion (ON DELETE RESTRICT) — guard it
// so it drops before the GroupGuard.
let gscript_id = group_script_id(&env, &group);
let _gs = ScriptGuard::new(&env.url, &env.token, &gscript_id);
// An app under the group inherits `shared`. Its `caller` script invokes it.
let _child = AppGuard::new(&env.url, &env.token, &child);
common::pic_as(&env)
.args(["apps", "create", &child, "--group", &group])
.assert()
.success();
fs::write(
dir.path().join("scripts/caller.rhai"),
"invoke(\"shared\", #{})",
)
.unwrap();
common::pic_as(&env)
.args(["scripts", "deploy"])
.arg(dir.path().join("scripts/caller.rhai"))
.args(["--app", &child, "--name", "caller"])
.assert()
.success();
let caller_id = app_script_id(&env, &child, "caller");
// Inheritance: caller resolves `shared` down its chain → the group's value.
assert_eq!(
invoke_body(&env, &caller_id),
serde_json::json!("from-group"),
"inherited group script should resolve by name"
);
// CoW: the app defines its OWN `shared`; the same caller now sees it.
fs::write(dir.path().join("scripts/own.rhai"), "\"from-app\"").unwrap();
common::pic_as(&env)
.args(["scripts", "deploy"])
.arg(dir.path().join("scripts/own.rhai"))
.args(["--app", &child, "--name", "shared"])
.assert()
.success();
assert_eq!(
invoke_body(&env, &caller_id),
serde_json::json!("from-app"),
"an app's own script must shadow the inherited group one (CoW)"
);
// Isolation: an app NOT under the group cannot resolve `shared`.
let _orphan = AppGuard::new(&env.url, &env.token, &orphan);
common::pic_as(&env)
.args(["apps", "create", &orphan])
.assert()
.success();
common::pic_as(&env)
.args(["scripts", "deploy"])
.arg(dir.path().join("scripts/caller.rhai"))
.args(["--app", &orphan, "--name", "caller"])
.assert()
.success();
let orphan_caller = app_script_id(&env, &orphan, "caller");
// `shared` is not in the orphan's chain → invoke fails (the script errors).
let out = common::pic_as(&env)
.args(["scripts", "invoke", &orphan_caller])
.output()
.expect("invoke");
assert!(
!out.status.success(),
"an app outside the group must NOT reach the group's `shared`"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn manifest_binds_route_to_inherited_group_script_idempotently() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("gsr-grp");
let child = common::unique_slug("gsr-child");
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let dir = manifest_dir();
fs::write(dir.path().join("scripts/greet.rhai"), "\"hi\"").unwrap();
common::pic_as(&env)
.args(["scripts", "deploy"])
.arg(dir.path().join("scripts/greet.rhai"))
.args(["--group", &group, "--name", "greet"])
.assert()
.success();
let gscript_id = group_script_id(&env, &group);
let _gs = ScriptGuard::new(&env.url, &env.token, &gscript_id);
let _child = AppGuard::new(&env.url, &env.token, &child);
common::pic_as(&env)
.args(["apps", "create", &child, "--group", &group])
.assert()
.success();
// Manifest declares NO `greet` script — only a route binding to it. The
// apply engine must resolve `greet` to the inherited group endpoint.
let manifest = format!(
"[app]\nslug = \"{child}\"\nname = \"GSR\"\n\n\
[[routes]]\nscript = \"greet\"\nmethod = \"GET\"\n\
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/greet\"\n"
);
let manifest_path = dir.path().join("picloud.toml");
fs::write(&manifest_path, &manifest).unwrap();
// Plan: a route create that binds to `greet`, and NO script create.
let plan = String::from_utf8(
common::pic_as(&env)
.args(["plan", "--file"])
.arg(&manifest_path)
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
plan.contains("greet") && plan.contains("route"),
"plan should bind a route to the inherited `greet`:\n{plan}"
);
common::pic_as(&env)
.args(["apply", "--file"])
.arg(&manifest_path)
.assert()
.success();
// Re-plan is a clean no-op — the diff resolved the group-bound route's id
// back to `greet`, so it is not a perpetual rebind.
let replan = String::from_utf8(
common::pic_as(&env)
.args(["plan", "--file"])
.arg(&manifest_path)
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
!replan.contains("create") && !replan.contains("update"),
"re-plan after binding an inherited route must be a no-op:\n{replan}"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn inherited_bound_trigger_is_pruned_when_dropped() {
// Regression: a trigger bound to an inherited group script must still
// resolve its identity in the diff/prune path even after the manifest
// stops referencing it — otherwise `--prune` silently orphans it.
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("gst-grp");
let child = common::unique_slug("gst-child");
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let dir = manifest_dir();
fs::write(dir.path().join("scripts/work.rhai"), "\"ok\"").unwrap();
common::pic_as(&env)
.args(["scripts", "deploy"])
.arg(dir.path().join("scripts/work.rhai"))
.args(["--group", &group, "--name", "work"])
.assert()
.success();
let gscript_id = group_script_id(&env, &group);
let _gs = ScriptGuard::new(&env.url, &env.token, &gscript_id);
let _child = AppGuard::new(&env.url, &env.token, &child);
common::pic_as(&env)
.args(["apps", "create", &child, "--group", &group])
.assert()
.success();
let manifest_path = dir.path().join("picloud.toml");
// 1. Manifest binds a cron trigger to the INHERITED `work`.
let with_trigger = format!(
"[app]\nslug = \"{child}\"\nname = \"GST\"\n\n\
[[triggers.cron]]\nscript = \"work\"\nschedule = \"0 0 * * * *\"\ntimezone = \"UTC\"\n"
);
fs::write(&manifest_path, &with_trigger).unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(&manifest_path)
.assert()
.success();
let listed = String::from_utf8(
common::pic_as(&env)
.args(["triggers", "ls", "--app", &child])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
listed.contains("cron"),
"cron trigger bound to inherited `work` should exist:\n{listed}"
);
// 2. Drop the trigger + `--prune` → it must be removed (not orphaned).
let empty = format!("[app]\nslug = \"{child}\"\nname = \"GST\"\n");
fs::write(&manifest_path, &empty).unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(&manifest_path)
.args(["--prune", "--yes"])
.assert()
.success();
let after = String::from_utf8(
common::pic_as(&env)
.args(["triggers", "ls", "--app", &child])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
!after.contains("cron"),
"prune must remove the inherited-bound cron trigger:\n{after}"
);
}
/// First script id from `pic scripts ls --group <g>`.
fn group_script_id(env: &common::TestEnv, group: &str) -> String {
let ls = common::pic_as(env)
.args(["scripts", "ls", "--group", group])
.output()
.expect("scripts ls --group");
common::parse_first_id(std::str::from_utf8(&ls.stdout).unwrap())
.expect("group should have one script")
}
/// Id of the named script in an app (`pic scripts ls --app <a>`).
fn app_script_id(env: &common::TestEnv, app: &str, name: &str) -> String {
let ls = common::pic_as(env)
.args(["scripts", "ls", "--app", app])
.output()
.expect("scripts ls --app");
let table = String::from_utf8(ls.stdout).unwrap();
// Rows are `id\tapp_slug\tname\tversion\tupdated_at`; pick the wanted name.
table
.lines()
.map(common::cells)
.find(|c| c.get(2) == Some(&name))
.and_then(|c| c.first().map(|s| (*s).to_string()))
.unwrap_or_else(|| panic!("script `{name}` not found in app `{app}`:\n{table}"))
}
fn invoke_body(env: &common::TestEnv, id: &str) -> serde_json::Value {
let out = common::pic_as(env)
.args(["scripts", "invoke", id])
.output()
.expect("scripts invoke");
assert!(
out.status.success(),
"invoke failed: {}",
String::from_utf8_lossy(&out.stderr)
);
serde_json::from_slice(&out.stdout).expect("invoke body is JSON")
}

View File

@@ -0,0 +1,187 @@
//! Phase-3 group secrets, end to end via `pic`:
//!
//! 1. **Inheritance + proximity** — a group-owned secret is injected into a
//! descendant app's script via `secrets::get` (decrypted under the
//! group AAD), and an app-owned secret of the same name shadows it
//! (decrypted under the app AAD). Exercises the resolver + the dual
//! owner-AAD open path against real Postgres.
//! 2. **Masked-read boundary** — a `group_admin` reads the secret VALUE,
//! an app-only dev is denied the value (403) yet still sees the secret
//! EXISTS (masked) in `config/effective`. That is the headline §5.3
//! property: an app runs with config its own devs cannot read.
use predicates::prelude::*;
use crate::common;
use crate::common::cleanup::{AppGuard, GroupGuard};
use crate::common::member;
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn group_secret_is_injected_then_app_value_overrides() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let acme = common::unique_slug("gs-acme");
let app = common::unique_slug("gs-app");
// Group `acme` with a `stripe-key` group secret (value via stdin).
let _g_acme = GroupGuard::new(&env.url, &env.token, &acme);
common::pic_as(&env)
.args(["groups", "create", &acme])
.assert()
.success();
common::pic_as(&env)
.args(["secrets", "set", "--group", &acme, "stripe-key"])
.write_stdin("sk_group")
.assert()
.success();
// App under acme with a script that reads + returns the resolved secret.
let _app = AppGuard::new(&env.url, &env.token, &app);
common::pic_as(&env)
.args(["apps", "create", &app, "--group", &acme])
.assert()
.success();
let fixture = common::fixture_path("read-secret.rhai");
common::pic_as(&env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&app,
])
.assert()
.success();
let ls = common::pic_as(&env)
.args(["scripts", "ls", "--app", &app])
.output()
.expect("scripts ls");
let id = common::parse_first_id(std::str::from_utf8(&ls.stdout).unwrap())
.expect("scripts ls should produce one row");
// Inherited: the app has no own `stripe-key`, so the group's value is
// injected (decrypted under the GROUP AAD).
assert_eq!(
invoke_body(&env, &id),
serde_json::json!("sk_group"),
"inherited group secret"
);
// Proximity override: an app-owned `stripe-key` shadows the group value
// (decrypted under the APP AAD — proving both AAD namespaces open).
common::pic_as(&env)
.args(["secrets", "set", "--app", &app, "stripe-key"])
.write_stdin("sk_app")
.assert()
.success();
assert_eq!(
invoke_body(&env, &id),
serde_json::json!("sk_app"),
"app override"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn group_secret_value_is_masked_from_app_devs() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let acme = common::unique_slug("gsm-acme");
let app = common::unique_slug("gsm-app");
let _g_acme = GroupGuard::new(&env.url, &env.token, &acme);
common::pic_as(&env)
.args(["groups", "create", &acme])
.assert()
.success();
common::pic_as(&env)
.args(["secrets", "set", "--group", &acme, "stripe-key"])
.write_stdin("sk_live_masked")
.assert()
.success();
let _app = AppGuard::new(&env.url, &env.token, &app);
common::pic_as(&env)
.args(["apps", "create", &app, "--group", &acme])
.assert()
.success();
// An app dev: a Member granted `editor` on the app, but NO group role.
let dev = member::member_user(fx, &common::unique_username("appdev"));
let dev_env = common::custom_env(&fx.url, &dev.token);
common::seed_credentials(&dev_env, &dev.username);
member::grant_membership(fx, &app, &dev.id, "editor");
// Denied the VALUE: the value endpoint is gated GroupSecretsRead at the
// owning group, which the app dev does not hold.
common::pic_as(&dev_env)
.args(["secrets", "read", "--group", &acme, "stripe-key"])
.assert()
.failure()
.stderr(predicate::str::contains("HTTP 403"));
// But the dev DOES see it EXISTS (masked) in the app's effective config —
// status `set`, owner group, never the value. Asserted directly against
// the endpoint to avoid the CLI's manifest requirement.
let effective = reqwest::blocking::Client::new()
.get(format!(
"{}/api/v1/admin/apps/{}/config/effective",
fx.url, app
))
.bearer_auth(&dev.token)
.send()
.expect("config effective");
assert!(
effective.status().is_success(),
"dev can read effective config"
);
let body: serde_json::Value = effective.json().expect("effective json");
let masked = &body["secrets"]["stripe-key"];
assert_eq!(masked["status"], "set", "secret shown as set");
assert_eq!(masked["owner"]["kind"], "group", "owned by the group");
assert!(
masked.get("value").is_none(),
"value must never appear in effective config: {masked}"
);
// A group_admin CAN read the value.
let gadmin = member::member_user(fx, &common::unique_username("gadmin"));
let gadmin_env = common::custom_env(&fx.url, &gadmin.token);
common::seed_credentials(&gadmin_env, &gadmin.username);
common::pic_as(&env)
.args([
"groups",
"members",
"add",
&acme,
&gadmin.id,
"--role",
"app_admin",
])
.assert()
.success();
common::pic_as(&gadmin_env)
.args(["secrets", "read", "--group", &acme, "stripe-key"])
.assert()
.success()
.stdout(predicate::str::contains("sk_live_masked"));
}
/// Invoke a script via `pic scripts invoke <id>` (→ `/api/v1/execute/{id}`)
/// and parse its JSON body.
fn invoke_body(env: &common::TestEnv, id: &str) -> serde_json::Value {
let out = common::pic_as(env)
.args(["scripts", "invoke", id])
.output()
.expect("scripts invoke");
assert!(
out.status.success(),
"invoke failed: {}",
String::from_utf8_lossy(&out.stderr)
);
serde_json::from_slice(&out.stdout).expect("invoke body is JSON")
}

View File

@@ -7,7 +7,7 @@ use predicates::prelude::*;
use crate::common;
/// Pick out the data rows from `pic logs` TSV output — the header line
/// (`created_at\tstatus\tsummary`) is now always present, so the old
/// (`created_at\tsource\tstatus\tsummary`) is now always present, so the old
/// "no non-empty lines means no logs" check needs to skip it.
fn data_rows(stdout: &str) -> Vec<&str> {
stdout
@@ -63,10 +63,10 @@ fn logs_after_invoke_records_success_row() {
let cols: Vec<&str> = rows[0].split('\t').map(str::trim).collect();
assert_eq!(
cols.len(),
3,
"row should be 3 tab-delimited cells: {rows:?}"
4,
"row should be 4 tab-delimited cells (created_at, source, status, summary): {rows:?}"
);
assert_eq!(cols[1], "success");
assert_eq!(cols[2], "success");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
@@ -95,7 +95,7 @@ fn logs_records_error_for_throwing_script() {
.next()
.expect("at least one data row");
let cols: Vec<&str> = row.split('\t').map(str::trim).collect();
assert_eq!(cols[1], "error", "expected error status, got row: {row}");
assert_eq!(cols[2], "error", "expected error status, got row: {row}");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
@@ -166,7 +166,7 @@ fn logs_truncates_long_summary() {
.into_iter()
.next()
.expect("at least one data row");
let summary = row.split('\t').nth(2).expect("summary column");
let summary = row.split('\t').nth(3).expect("summary column");
assert!(
summary.ends_with('…'),
"summary should be truncated with `…`, got: {summary}"

View File

@@ -0,0 +1,207 @@
//! M3 multi-repo single-owner ownership (§7) via `pic apply --dir`:
//! * the first repo to apply a group CLAIMS it (its `.picloud/` project key
//! becomes the owner),
//! * a SECOND repo (different project key) applying the same group is
//! REJECTED with an ownership conflict,
//! * `--takeover` lets the second repo seize it (admin-gated; the fixture
//! token is instance owner), flipping ownership — proven by the first repo
//! now being the one rejected,
//! * `--prune` deletes an owned, now-undeclared, empty group; a group owned
//! by another repo is never touched.
//!
//! Each project lives in its own `TempDir`, so `pic` mints a distinct project
//! key per repo (`.picloud/project.json`) — exactly the two-repo topology §7
//! governs.
use std::fs;
use std::path::Path;
use tempfile::TempDir;
use crate::common;
use crate::common::cleanup::GroupGuard;
use crate::common::member;
/// A single-group project dir: group `slug` under `parent`, no scripts (so a
/// structural prune can delete it — a group holding scripts is RESTRICT-pinned).
fn group_dir(slug: &str, parent: &str) -> TempDir {
let dir = TempDir::new().expect("tempdir");
fs::write(
dir.path().join("picloud.toml"),
format!("[group]\nslug = \"{slug}\"\nname = \"Owned Group\"\nparent = \"{parent}\"\n"),
)
.unwrap();
dir
}
fn apply(env: &common::TestEnv, dir: &Path, extra: &[&str]) -> std::process::Output {
let mut cmd = common::pic_as(env);
cmd.args(["apply", "--dir"]).arg(dir).args(extra);
cmd.output().expect("apply --dir")
}
fn ls_groups(env: &common::TestEnv) -> String {
String::from_utf8(
common::pic_as(env)
.args(["groups", "ls"])
.output()
.expect("groups ls")
.stdout,
)
.unwrap()
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn first_repo_claims_second_is_rejected_then_takeover_flips_ownership() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("own-g");
let _g = GroupGuard::new(&env.url, &env.token, &slug);
// Repo A claims the group on first apply (it does not pre-exist → created
// AND claimed for project A in one tx).
let repo_a = group_dir(&slug, "root");
let a1 = apply(&env, repo_a.path(), &[]);
assert!(
a1.status.success(),
"repo A claim apply failed: {}",
String::from_utf8_lossy(&a1.stderr)
);
assert!(
ls_groups(&env).contains(&slug),
"group should exist after claim"
);
// Repo B (a different dir → a different project key) is rejected: the group
// is owned by project A.
let repo_b = group_dir(&slug, "root");
let b1 = apply(&env, repo_b.path(), &[]);
assert!(
!b1.status.success(),
"repo B apply must be rejected (group owned by A)"
);
let b1_err = String::from_utf8_lossy(&b1.stderr).to_lowercase();
assert!(
b1_err.contains("owned by another project") || b1_err.contains("takeover"),
"conflict message should name the ownership clash:\n{b1_err}"
);
// Repo B with --takeover succeeds (the fixture token is instance owner, so
// it holds GroupAdmin on every node) and flips ownership to project B.
let b2 = apply(&env, repo_b.path(), &["--takeover"]);
assert!(
b2.status.success(),
"repo B --takeover should succeed: {}",
String::from_utf8_lossy(&b2.stderr)
);
// Proof the flip took: repo A — formerly the owner — is now the one
// rejected without --takeover.
let a2 = apply(&env, repo_a.path(), &[]);
assert!(
!a2.status.success(),
"after takeover, repo A must now be rejected (B owns the group)"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn takeover_without_group_admin_is_forbidden() {
// §7.4 — ownership ⟂ RBAC: `--takeover` needs GroupAdmin specifically, a
// second gate beyond the write caps. A non-admin member (editor) of the
// contested group cannot seize it for their repo.
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("own-ta");
let _g = GroupGuard::new(&env.url, &env.token, &slug);
// Admin (repo A) claims the group.
let repo_a = group_dir(&slug, "root");
assert!(
apply(&env, repo_a.path(), &[]).status.success(),
"admin claim apply should succeed"
);
// A member with `editor` (not group_admin) on the group.
let m = member::member_user(fx, &common::unique_username("ta"));
member::grant_group_membership(fx, &slug, &m.id, "editor");
let member_env = common::custom_env(&fx.url, &m.token);
common::seed_credentials(&member_env, &m.username);
// The member's repo B (different project key) attempts a takeover → 403.
let repo_b = group_dir(&slug, "root");
let out = apply(&member_env, repo_b.path(), &["--takeover"]);
assert!(
!out.status.success(),
"non-admin --takeover must be forbidden"
);
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
assert!(
err.contains("forbidden") || err.contains("403"),
"takeover denial should be an authz error:\n{err}"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn prune_deletes_owned_undeclared_group_only() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let parent = common::unique_slug("own-par");
let child = common::unique_slug("own-child");
// Drop order: child (registered last → dropped first), then parent.
let _gp = GroupGuard::new(&env.url, &env.token, &parent);
let _gc = GroupGuard::new(&env.url, &env.token, &child);
// Repo declares parent + child (both empty); apply claims both.
let dir = TempDir::new().expect("tempdir");
fs::write(
dir.path().join("picloud.toml"),
format!("[group]\nslug = \"{parent}\"\nname = \"Parent\"\nparent = \"root\"\n"),
)
.unwrap();
fs::create_dir_all(dir.path().join("sub")).unwrap();
fs::write(
dir.path().join("sub/picloud.toml"),
format!("[group]\nslug = \"{child}\"\nname = \"Child\"\nparent = \"{parent}\"\n"),
)
.unwrap();
let out = apply(&env, dir.path(), &[]);
assert!(
out.status.success(),
"initial claim apply failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let groups = ls_groups(&env);
assert!(
groups.contains(&parent) && groups.contains(&child),
"both groups should exist"
);
// Drop the child manifest from the SAME repo (keeping its project key) so
// the child becomes owned-but-undeclared, then apply --prune: the empty
// child is deleted; the parent (still declared) is kept.
fs::remove_dir_all(dir.path().join("sub")).unwrap();
let out2 = apply(&env, dir.path(), &["--prune", "--yes"]);
assert!(
out2.status.success(),
"prune apply failed: {}",
String::from_utf8_lossy(&out2.stderr)
);
let groups = ls_groups(&env);
assert!(
groups.contains(&parent),
"parent must survive prune:\n{groups}"
);
assert!(
!groups.contains(&child),
"owned, undeclared, empty child must be pruned:\n{groups}"
);
}

View File

@@ -98,7 +98,7 @@ fn viewer_cannot_deploy_but_editor_can() {
])
.assert()
.success()
.stdout(predicate::str::contains("Created hello v1"));
.stdout(predicate::str::contains("hello").and(predicate::str::contains("created")));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]

View File

@@ -7,6 +7,16 @@ use predicates::prelude::*;
use crate::common;
use crate::common::cleanup::AppGuard;
/// Extract a field value from a `pic` KvBlock (`key<pad>\tvalue` per line).
/// `pic scripts deploy` prints `name`/`version`/`action` rows, not a prose
/// "Created X vN" line, so version-bump tests assert on these fields.
fn kv_field<'a>(stdout: &'a str, key: &str) -> Option<&'a str> {
stdout.lines().find_map(|l| {
let (k, v) = l.split_once('\t')?;
(k.trim() == key).then(|| v.trim())
})
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn deploy_against_unknown_app_errors() {
@@ -48,7 +58,7 @@ fn deploy_with_name_override() {
let _guard = AppGuard::new(&env.url, &env.token, &slug);
let fixture = common::fixture_path("hello.rhai");
common::pic_as(&env)
let out = common::pic_as(&env)
.args([
"scripts",
"deploy",
@@ -58,11 +68,14 @@ fn deploy_with_name_override() {
"--name",
"custom-name",
])
.assert()
.success()
.stdout(predicate::str::contains("Created custom-name v1"));
.output()
.expect("deploy v1");
let v1 = String::from_utf8(out.stdout).unwrap();
assert_eq!(kv_field(&v1, "name"), Some("custom-name"), "v1 name: {v1}");
assert_eq!(kv_field(&v1, "version"), Some("1"), "v1 version: {v1}");
assert_eq!(kv_field(&v1, "action"), Some("created"), "v1 action: {v1}");
common::pic_as(&env)
let out = common::pic_as(&env)
.args([
"scripts",
"deploy",
@@ -72,9 +85,11 @@ fn deploy_with_name_override() {
"--name",
"custom-name",
])
.assert()
.success()
.stdout(predicate::str::contains("Updated custom-name v2"));
.output()
.expect("deploy v2");
let v2 = String::from_utf8(out.stdout).unwrap();
assert_eq!(kv_field(&v2, "version"), Some("2"), "v2 version: {v2}");
assert_eq!(kv_field(&v2, "action"), Some("updated"), "v2 action: {v2}");
let out = common::pic_as(&env)
.args(["scripts", "ls", "--app", &slug])
@@ -106,8 +121,8 @@ fn deploy_bumps_version_each_redeploy() {
let _guard = AppGuard::new(&env.url, &env.token, &slug);
let fixture = common::fixture_path("hello.rhai");
for expected in ["Created hello v1", "Updated hello v2", "Updated hello v3"] {
common::pic_as(&env)
for (version, action) in [("1", "created"), ("2", "updated"), ("3", "updated")] {
let out = common::pic_as(&env)
.args([
"scripts",
"deploy",
@@ -115,9 +130,20 @@ fn deploy_bumps_version_each_redeploy() {
"--app",
&slug,
])
.assert()
.success()
.stdout(predicate::str::contains(expected));
.output()
.expect("deploy");
assert!(out.status.success(), "deploy v{version} failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
assert_eq!(
kv_field(&stdout, "version"),
Some(version),
"version: {stdout}"
);
assert_eq!(
kv_field(&stdout, "action"),
Some(action),
"action: {stdout}"
);
}
}

View File

@@ -35,7 +35,7 @@ fn set_ls_rm_round_trip() {
.expect("secrets ls");
let stdout = String::from_utf8(out.stdout).unwrap();
let header = stdout.lines().next().expect("header");
assert_eq!(common::cells(header), vec!["name", "updated_at"]);
assert_eq!(common::cells(header), vec!["name", "env", "updated_at"]);
assert!(
stdout.lines().skip(1).any(|l| l.starts_with("api_key")),
"api_key missing from ls: {stdout}"

View File

@@ -0,0 +1,562 @@
//! M4a route templates (§4.5) via `pic apply --dir`:
//! * a group declares ONE route template (`/t/{app_slug}`) bound to its
//! inherited script; the apply fans it out into a concrete route on every
//! descendant app, with `{app_slug}` resolved per app,
//! * re-apply is idempotent (no duplicate expansions — provenance),
//! * removing the template + `--prune` reaps the expansions,
//! * an expansion colliding with a hand-declared route is a hard error,
//! * `pic plan --dir` reports the blast radius.
use std::fs;
use postgres::{Client as PgClient, NoTls};
use tempfile::TempDir;
use crate::common;
use crate::common::cleanup::{AppGuard, GroupGuard, ScriptGuard};
/// Template-expanded routes (`from_template IS NOT NULL`) for the two app slugs
/// under test, as `(path, id)` — read straight from Postgres since the route
/// admin endpoint only lists app-owned-script routes (a group script isn't
/// addressable there). Scoped to `/t/<a>` and `/t/<b>` so parallel tests don't
/// interfere.
fn expansion_rows(a: &str, b: &str) -> Vec<(String, String)> {
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
let mut pg = PgClient::connect(&url, NoTls).expect("pg connect");
let want = [format!("/t/{a}"), format!("/t/{b}")];
let rows = pg
.query(
"SELECT path, id::text FROM routes \
WHERE from_template IS NOT NULL AND path = ANY($1) ORDER BY path",
&[&&want[..]],
)
.expect("query expansions");
rows.iter().map(|r| (r.get(0), r.get(1))).collect()
}
/// A tree: a root group declaring a `shared` endpoint + a `greet` route template
/// `/t/{app_slug}`, and `extra_app_toml` appended to app `a`'s manifest. Apps
/// must pre-exist under the group (created in the test before applying).
fn tree_dir(group: &str, a: &str, b: &str, template: &str, extra_app_a: &str) -> TempDir {
let dir = TempDir::new().expect("tempdir");
fs::create_dir_all(dir.path().join("scripts")).unwrap();
fs::write(
dir.path().join("scripts/shared.rhai"),
r#""hi from template""#,
)
.unwrap();
fs::write(
dir.path().join("picloud.toml"),
format!(
"[group]\nslug = \"{group}\"\nname = \"Tmpl Group\"\n\n\
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n\n{template}"
),
)
.unwrap();
for (slug, extra) in [(a, extra_app_a), (b, "")] {
fs::create_dir_all(dir.path().join(slug)).unwrap();
fs::write(
dir.path().join(slug).join("picloud.toml"),
format!("[app]\nslug = \"{slug}\"\nname = \"App\"\n{extra}"),
)
.unwrap();
}
dir
}
const TEMPLATE: &str = "[[route_templates]]\nname = \"greet\"\nscript = \"shared\"\n\
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/t/{app_slug}\"\n";
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn route_template_fans_out_per_app_idempotently_then_prunes() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("tpl-g");
let a = common::unique_slug("tpl-a");
let b = common::unique_slug("tpl-b");
// group ← (app a, app b). Guards drop apps before the group (RESTRICT).
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let _aa = AppGuard::new(&env.url, &env.token, &a);
let _ab = AppGuard::new(&env.url, &env.token, &b);
for slug in [&a, &b] {
common::pic_as(&env)
.args(["apps", "create", slug, "--group", &group])
.assert()
.success();
}
// --- Apply: the template fans out to both apps. ---
let dir = tree_dir(&group, &a, &b, TEMPLATE, "");
// Plan reports the blast radius (both descendant apps).
let plan = common::pic_as(&env)
.args(["plan", "--dir"])
.arg(dir.path())
.output()
.expect("plan --dir");
let plan_err = String::from_utf8_lossy(&plan.stderr);
assert!(
plan_err.contains("blast radius") && plan_err.contains("2 app(s)"),
"plan should report the 2-app blast radius:\n{plan_err}"
);
// --- Apply: the template fans out to both apps, one route each. ---
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.assert()
.success();
// The group script must drop before its group at teardown (RESTRICT).
let _gs = ScriptGuard::new(&env.url, &env.token, &group_script_id(&env, &group));
let first = expansion_rows(&a, &b);
let paths: Vec<&str> = first.iter().map(|(p, _)| p.as_str()).collect();
assert_eq!(
paths,
vec![format!("/t/{a}").as_str(), format!("/t/{b}").as_str()],
"each app gets its own `{{app_slug}}`-resolved route"
);
// --- Idempotent re-apply: same rows, same ids (no churn — provenance). ---
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.assert()
.success();
let second = expansion_rows(&a, &b);
assert_eq!(
first, second,
"re-apply must not churn expansions (stable ids)"
);
// --- Remove the template + --prune: expansions are reaped. Rewrite the
// SAME repo's group manifest (a fresh dir would be a different project and
// conflict on the owned group, §7). ---
fs::write(
dir.path().join("picloud.toml"),
format!(
"[group]\nslug = \"{group}\"\nname = \"Tmpl Group\"\n\n\
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n"
),
)
.unwrap();
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.args(["--prune", "--yes"])
.assert()
.success();
assert!(
expansion_rows(&a, &b).is_empty(),
"removing the template must reap its expansions"
);
}
/// M6 (§4.5): a `{var:NAME}` placeholder resolves against a var set in the SAME
/// apply (in-transaction), so a var and a template referencing it converge in
/// one `pic apply`. Before M6 the var (written earlier in the tx) was invisible
/// to expansion — the route would only pick it up on a *second* apply.
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn route_template_var_resolves_in_same_apply() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("tplv-g");
let a = common::unique_slug("tplv-a");
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let _aa = AppGuard::new(&env.url, &env.token, &a);
common::pic_as(&env)
.args(["apps", "create", &a, "--group", &group])
.assert()
.success();
// The group sets `region` AND declares a template referencing it — one
// manifest, one apply.
let dir = TempDir::new().expect("tempdir");
fs::create_dir_all(dir.path().join("scripts")).unwrap();
fs::write(dir.path().join("scripts/shared.rhai"), r#""hi""#).unwrap();
fs::write(
dir.path().join("picloud.toml"),
format!(
"[group]\nslug = \"{group}\"\nname = \"G\"\n\n\
[vars]\nregion = \"eu\"\n\n\
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n\n\
[[route_templates]]\nname = \"geo\"\nscript = \"shared\"\n\
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/tv/{{app_slug}}/{{var:region}}\"\n"
),
)
.unwrap();
fs::create_dir_all(dir.path().join(&a)).unwrap();
fs::write(
dir.path().join(&a).join("picloud.toml"),
format!("[app]\nslug = \"{a}\"\nname = \"App\"\n"),
)
.unwrap();
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.assert()
.success();
let _gs = ScriptGuard::new(&env.url, &env.token, &group_script_id(&env, &group));
// The expanded route resolved `{var:region}` → `eu` in THIS apply.
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
let mut pg = PgClient::connect(&url, NoTls).expect("pg connect");
let want = format!("/tv/{a}/eu");
let rows = pg
.query(
"SELECT path FROM routes WHERE from_template IS NOT NULL AND path = $1",
&[&want],
)
.expect("query");
assert_eq!(
rows.len(),
1,
"`{{var:region}}` must resolve to `eu` in the same apply (expected path {want})"
);
}
/// Template-expanded routes (`from_template IS NOT NULL`) whose path is one of
/// `paths`, as `path` strings — for asserting which descendant apps received an
/// expansion regardless of which apply declared them.
fn expansion_paths(paths: &[String]) -> Vec<String> {
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
let mut pg = PgClient::connect(&url, NoTls).expect("pg connect");
let rows = pg
.query(
"SELECT path FROM routes \
WHERE from_template IS NOT NULL AND path = ANY($1) ORDER BY path",
&[&paths],
)
.expect("query expansion paths");
rows.iter().map(|r| r.get(0)).collect()
}
/// `(path, id)` of template-expanded routes among `paths` — for asserting an
/// expansion is STABLE (same row id) across a re-apply, i.e. not churned.
fn expansion_rows_for(paths: &[String]) -> Vec<(String, String)> {
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
let mut pg = PgClient::connect(&url, NoTls).expect("pg connect");
let rows = pg
.query(
"SELECT path, id::text FROM routes \
WHERE from_template IS NOT NULL AND path = ANY($1) ORDER BY path",
&[&paths],
)
.expect("query expansion rows");
rows.iter().map(|r| (r.get(0), r.get(1))).collect()
}
/// M7 (§4.5): a route template fans out to EVERY descendant app in the DB
/// subtree, not only the app nodes present in the apply. An app created under
/// the group out-of-band (absent from the manifest) still receives the
/// expansion on the next apply, and removing the template reaps it there too.
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn route_template_reaches_out_of_tree_descendant() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("xrepo-g");
let sg = common::unique_slug("xrepo-sg"); // subgroup (depth-2)
let a = common::unique_slug("xrepo-a");
let c = common::unique_slug("xrepo-c"); // out-of-tree descendant (depth-1)
let d = common::unique_slug("xrepo-d"); // out-of-tree descendant (depth-2)
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let _aa = AppGuard::new(&env.url, &env.token, &a);
common::pic_as(&env)
.args(["apps", "create", &a, "--group", &group])
.assert()
.success();
// Apply a tree of just (group ← app a) with an `/x/{app_slug}` template.
let dir = TempDir::new().expect("tempdir");
fs::create_dir_all(dir.path().join("scripts")).unwrap();
fs::write(dir.path().join("scripts/shared.rhai"), r#""hi""#).unwrap();
let group_toml = |with_template: bool| {
let tmpl = if with_template {
"\n[[route_templates]]\nname = \"x\"\nscript = \"shared\"\n\
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/x/{app_slug}\"\n"
} else {
""
};
format!(
"[group]\nslug = \"{group}\"\nname = \"G\"\n\n\
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n{tmpl}"
)
};
fs::write(dir.path().join("picloud.toml"), group_toml(true)).unwrap();
fs::create_dir_all(dir.path().join(&a)).unwrap();
fs::write(
dir.path().join(&a).join("picloud.toml"),
format!("[app]\nslug = \"{a}\"\nname = \"App\"\n"),
)
.unwrap();
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.assert()
.success();
let _gs = ScriptGuard::new(&env.url, &env.token, &group_script_id(&env, &group));
assert_eq!(
expansion_paths(&[format!("/x/{a}")]).len(),
1,
"in-tree app a gets its expansion"
);
// Create app c (direct child) and a nested subgroup ← app d (depth-2),
// all OUTSIDE the manifest tree, to exercise the recursive descendant CTE.
let _ac = AppGuard::new(&env.url, &env.token, &c);
common::pic_as(&env)
.args(["apps", "create", &c, "--group", &group])
.assert()
.success();
let _sg = GroupGuard::new(&env.url, &env.token, &sg);
common::pic_as(&env)
.args(["groups", "create", &sg, "--parent", &group])
.assert()
.success();
let _ad = AppGuard::new(&env.url, &env.token, &d);
common::pic_as(&env)
.args(["apps", "create", &d, "--group", &sg])
.assert()
.success();
// Re-apply the same tree (still only group + a). M7: c (depth-1) AND d
// (depth-2, under the subgroup) both receive the expansion. `--force`
// waives the bound token (the out-of-band creates bumped structure version).
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.arg("--force")
.assert()
.success();
let want = [format!("/x/{c}"), format!("/x/{d}")];
assert_eq!(
expansion_paths(&want),
want.to_vec(),
"out-of-tree descendants at depth 1 AND 2 receive expansions"
);
// Idempotent re-apply: descendants must not churn (same row ids — provenance
// by `from_template`, not delete+recreate), same as the in-tree path.
let before = expansion_rows_for(&want);
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.arg("--force")
.assert()
.success();
assert_eq!(
before,
expansion_rows_for(&want),
"re-apply must not churn descendant expansions (stable ids)"
);
// Remove the template + prune: every descendant's expansion is reaped,
// in-tree or not, at any depth.
fs::write(dir.path().join("picloud.toml"), group_toml(false)).unwrap();
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.args(["--prune", "--yes", "--force"])
.assert()
.success();
assert!(
expansion_paths(&[format!("/x/{a}"), format!("/x/{c}"), format!("/x/{d}")]).is_empty(),
"removing the template reaps expansions on ALL descendants, any depth"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn expansion_colliding_with_hand_declared_route_is_rejected() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("tplc-g");
let a = common::unique_slug("tplc-a");
let b = common::unique_slug("tplc-b");
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let _aa = AppGuard::new(&env.url, &env.token, &a);
let _ab = AppGuard::new(&env.url, &env.token, &b);
for slug in [&a, &b] {
common::pic_as(&env)
.args(["apps", "create", slug, "--group", &group])
.assert()
.success();
}
// App `a` hand-declares the EXACT route the template would expand to (the
// template path resolves to `/t/<a>` for app a). That collision is a hard
// error — neither side silently wins.
let collide = format!(
"\n[[routes]]\nscript = \"shared\"\nhost_kind = \"any\"\n\
path_kind = \"exact\"\npath = \"/t/{a}\"\n"
);
let dir = tree_dir(&group, &a, &b, TEMPLATE, &collide);
let out = common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.output()
.expect("apply --dir");
assert!(
!out.status.success(),
"an expansion colliding with a hand-declared route must be rejected"
);
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
assert!(
err.contains("template") && (err.contains("hand") || err.contains("declare")),
"error should name the template/hand-declared collision:\n{err}"
);
}
/// Template-expanded kv triggers for the two apps, as `(collection_glob, id)`,
/// read straight from Postgres (a group-script trigger isn't listable via the
/// app trigger API). Scoped to the test's two slugs.
fn trigger_rows(a: &str, b: &str) -> Vec<(String, String)> {
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
let mut pg = PgClient::connect(&url, NoTls).expect("pg connect");
let want = [format!("{a}-data"), format!("{b}-data")];
let rows = pg
.query(
"SELECT d.collection_glob, t.id::text FROM triggers t \
JOIN kv_trigger_details d ON d.trigger_id = t.id \
WHERE t.from_template IS NOT NULL AND d.collection_glob = ANY($1) \
ORDER BY d.collection_glob",
&[&&want[..]],
)
.expect("query trigger expansions");
rows.iter().map(|r| (r.get(0), r.get(1))).collect()
}
const TRIGGER_TEMPLATE: &str = "[[trigger_templates]]\nname = \"ev\"\nkind = \"kv\"\n\
script = \"shared\"\ncollection_glob = \"{app_slug}-data\"\n\
ops = [\"insert\"]\n";
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn trigger_template_fans_out_per_app_idempotently_then_prunes() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("ttpl-g");
let a = common::unique_slug("ttpl-a");
let b = common::unique_slug("ttpl-b");
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let _aa = AppGuard::new(&env.url, &env.token, &a);
let _ab = AppGuard::new(&env.url, &env.token, &b);
for slug in [&a, &b] {
common::pic_as(&env)
.args(["apps", "create", slug, "--group", &group])
.assert()
.success();
}
// group manifest: a `shared` script + a kv trigger template using {app_slug}.
let dir = TempDir::new().expect("tempdir");
fs::create_dir_all(dir.path().join("scripts")).unwrap();
fs::write(dir.path().join("scripts/shared.rhai"), r#""x""#).unwrap();
let write_group = |with_tmpl: bool| {
let tmpl = if with_tmpl { TRIGGER_TEMPLATE } else { "" };
fs::write(
dir.path().join("picloud.toml"),
format!(
"[group]\nslug = \"{group}\"\nname = \"TT\"\n\n\
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n\n{tmpl}"
),
)
.unwrap();
};
write_group(true);
for slug in [&a, &b] {
fs::create_dir_all(dir.path().join(slug)).unwrap();
fs::write(
dir.path().join(slug).join("picloud.toml"),
format!("[app]\nslug = \"{slug}\"\nname = \"App\"\n"),
)
.unwrap();
}
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.assert()
.success();
let _gs = ScriptGuard::new(&env.url, &env.token, &group_script_id(&env, &group));
let first = trigger_rows(&a, &b);
assert_eq!(
first.iter().map(|(g, _)| g.as_str()).collect::<Vec<_>>(),
vec![format!("{a}-data").as_str(), format!("{b}-data").as_str()],
"each app gets its own {{app_slug}}-resolved kv trigger"
);
// Idempotent re-apply: same rows, same ids.
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.assert()
.success();
assert_eq!(
trigger_rows(&a, &b),
first,
"re-apply must not churn triggers"
);
// Remove the template + --prune: trigger expansions reaped.
write_group(false);
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.args(["--prune", "--yes"])
.assert()
.success();
assert!(
trigger_rows(&a, &b).is_empty(),
"removing the trigger template must reap its expansions"
);
}
fn group_script_id(env: &common::TestEnv, group: &str) -> String {
let ls = common::pic_as(env)
.args(["scripts", "ls", "--group", group])
.output()
.expect("scripts ls --group");
common::parse_first_id(std::str::from_utf8(&ls.stdout).unwrap())
.expect("group should have one script")
}

View File

@@ -0,0 +1,254 @@
//! Phase 5 project-tree apply, end to end via `pic plan/apply --dir`:
//! * a group + a nested app under it apply atomically as one tree; the app
//! binds a route to the group's inherited script (created in the SAME
//! apply); re-plan is a no-op,
//! * one invalid node aborts the whole tree (nothing written),
//! * a `pic groups reparent` between plan and apply trips the bound-plan
//! (StateMoved) check via the tree's structure version.
use std::fs;
use predicates::prelude::*;
use tempfile::TempDir;
use crate::common;
use crate::common::cleanup::{AppGuard, GroupGuard, ScriptGuard};
/// A project tree on disk: root group manifest + a nested app manifest that
/// binds `GET /greet` to the group's (inherited) `shared` script.
fn tree_dir(group: &str, app: &str, group_script_src: &str, app_extra: &str) -> TempDir {
let dir = TempDir::new().expect("tempdir");
fs::create_dir_all(dir.path().join("scripts")).unwrap();
fs::create_dir_all(dir.path().join(app).join("scripts")).unwrap();
fs::write(dir.path().join("scripts/shared.rhai"), group_script_src).unwrap();
fs::write(
dir.path().join("picloud.toml"),
format!(
"[group]\nslug = \"{group}\"\nname = \"Tree Group\"\n\n\
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n\n\
[vars]\nregion = \"eu\"\n"
),
)
.unwrap();
fs::write(
dir.path().join(app).join("picloud.toml"),
format!(
"[app]\nslug = \"{app}\"\nname = \"Tree App\"\n\n\
[[routes]]\nscript = \"shared\"\nmethod = \"GET\"\n\
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/greet\"\n{app_extra}"
),
)
.unwrap();
dir
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn tree_applies_group_and_app_atomically_then_noop() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("tr-grp");
let app = common::unique_slug("tr-app");
// Group + app must pre-exist (the manifest owns content, not tree shape).
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let _a = AppGuard::new(&env.url, &env.token, &app);
common::pic_as(&env)
.args(["apps", "create", &app, "--group", &group])
.assert()
.success();
let dir = tree_dir(&group, &app, "\"hi from the group\"", "");
// Plan the whole tree: a group script+var create and an app route create.
let plan = String::from_utf8(
common::pic_as(&env)
.args(["plan", "--dir"])
.arg(dir.path())
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
plan.contains("shared") && plan.contains("region") && plan.contains("/greet"),
"tree plan should cover both nodes:\n{plan}"
);
// Apply the whole tree in one transaction.
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.assert()
.success();
// The group node created its `shared` script (it must drop before its
// group at teardown — ON DELETE RESTRICT).
let listed = String::from_utf8(
common::pic_as(&env)
.args(["scripts", "ls", "--group", &group])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
listed.contains("shared"),
"the group node's script should exist after the tree apply:\n{listed}"
);
let gscript_id = group_script_id(&env, &group);
let _gs = ScriptGuard::new(&env.url, &env.token, &gscript_id);
// Re-plan is a clean no-op (idempotent across both nodes — incl. the app's
// route bound to the inherited group script).
let replan = String::from_utf8(
common::pic_as(&env)
.args(["plan", "--dir"])
.arg(dir.path())
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
!replan.contains("create") && !replan.contains("update"),
"re-plan of the tree must be a no-op:\n{replan}"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn tree_apply_is_atomic_on_an_invalid_node() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("tra-grp");
let app = common::unique_slug("tra-app");
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let _a = AppGuard::new(&env.url, &env.token, &app);
common::pic_as(&env)
.args(["apps", "create", &app, "--group", &group])
.assert()
.success();
// The app node carries an INVALID Rhai script → the whole tree must abort.
let dir = TempDir::new().unwrap();
fs::create_dir_all(dir.path().join("scripts")).unwrap();
fs::create_dir_all(dir.path().join(&app).join("scripts")).unwrap();
fs::write(dir.path().join("scripts/shared.rhai"), "\"ok\"").unwrap();
fs::write(dir.path().join(&app).join("scripts/bad.rhai"), "let x = ;").unwrap();
fs::write(
dir.path().join("picloud.toml"),
format!(
"[group]\nslug = \"{group}\"\nname = \"G\"\n\n\
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n"
),
)
.unwrap();
fs::write(
dir.path().join(&app).join("picloud.toml"),
format!(
"[app]\nslug = \"{app}\"\nname = \"A\"\n\n\
[[scripts]]\nname = \"bad\"\nfile = \"scripts/bad.rhai\"\n"
),
)
.unwrap();
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.assert()
.failure();
// Atomic: the valid group node's `shared` must NOT have been created.
let listed = String::from_utf8(
common::pic_as(&env)
.args(["scripts", "ls", "--group", &group])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
!listed.contains("shared"),
"a failed tree apply must leave nothing behind:\n{listed}"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn tree_reparent_between_plan_and_apply_trips_state_moved() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let parent = common::unique_slug("trp-par");
let group = common::unique_slug("trp-grp");
let app = common::unique_slug("trp-app");
// parent → group → app.
let _p = GroupGuard::new(&env.url, &env.token, &parent);
common::pic_as(&env)
.args(["groups", "create", &parent])
.assert()
.success();
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group, "--parent", &parent])
.assert()
.success();
let _a = AppGuard::new(&env.url, &env.token, &app);
common::pic_as(&env)
.args(["apps", "create", &app, "--group", &group])
.assert()
.success();
let dir = tree_dir(&group, &app, "\"x\"", "");
// Record a bound plan for the whole tree.
common::pic_as(&env)
.args(["plan", "--dir"])
.arg(dir.path())
.assert()
.success();
// Reparent the group to the instance root — bumps its structure version.
common::pic_as(&env)
.args(["groups", "reparent", &group])
.assert()
.success();
// Apply the recorded plan: the structure moved underneath it → refuse.
// (The apply aborts before any write, so no group script is created and the
// GroupGuards tear down cleanly.)
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.assert()
.failure()
.stderr(
predicate::str::contains("changed")
.or(predicate::str::contains("plan"))
.or(predicate::str::contains("409")),
);
}
fn group_script_id(env: &common::TestEnv, group: &str) -> String {
let ls = common::pic_as(env)
.args(["scripts", "ls", "--group", group])
.output()
.expect("scripts ls --group");
common::parse_first_id(std::str::from_utf8(&ls.stdout).unwrap())
.expect("group should have one script")
}

View File

@@ -0,0 +1,123 @@
//! M2 declarative tree SHAPE via `pic apply --dir`:
//! * a group manifest for a group that does NOT exist yet is CREATED under
//! its declared parent, with its content, in one atomic apply,
//! * re-applying the same tree is a no-op (the group now exists),
//! * changing the declared parent REPARENTS the group on the next apply.
use std::fs;
use tempfile::TempDir;
use crate::common;
use crate::common::cleanup::{GroupGuard, ScriptGuard};
/// Write a single-group project dir declaring group `slug` under `parent` with
/// one endpoint script.
fn group_dir(slug: &str, parent: &str) -> TempDir {
let dir = TempDir::new().expect("tempdir");
fs::create_dir_all(dir.path().join("scripts")).unwrap();
fs::write(dir.path().join("scripts/hello.rhai"), r#""hi""#).unwrap();
fs::write(
dir.path().join("picloud.toml"),
format!(
"[group]\nslug = \"{slug}\"\nname = \"Child Group\"\nparent = \"{parent}\"\n\n\
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n"
),
)
.unwrap();
dir
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn tree_apply_creates_then_reparents_a_group() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let parent = common::unique_slug("ts-par");
let child = common::unique_slug("ts-child");
// Guards drop in reverse order: parent (last) ← child (mid) ← script (first).
let _gp = GroupGuard::new(&env.url, &env.token, &parent);
let _gc = GroupGuard::new(&env.url, &env.token, &child);
common::pic_as(&env)
.args(["groups", "create", &parent])
.assert()
.success();
// --- Create: the child group does NOT exist; apply --dir creates it. ---
let dir = group_dir(&child, &parent);
let out = common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.output()
.expect("apply --dir");
assert!(
out.status.success(),
"tree apply (create) failed: {}",
String::from_utf8_lossy(&out.stderr)
);
// The group now exists with its script.
let groups = ls(&env, &["groups", "ls"]);
assert!(groups.contains(&child), "created group missing:\n{groups}");
let scripts = ls(&env, &["scripts", "ls", "--group", &child]);
let script_id = scripts
.lines()
.map(common::cells)
.find(|c| c.get(2) == Some(&"hello"))
.and_then(|c| c.first().map(|s| (*s).to_string()))
.unwrap_or_else(|| panic!("group script `hello` should exist:\n{scripts}"));
let _gs = ScriptGuard::new(&env.url, &env.token, &script_id);
// Re-applying the same tree is a no-op (group + content unchanged).
let plan_out = common::pic_as(&env)
.args(["plan", "--dir"])
.arg(dir.path())
.output()
.expect("plan --dir");
let plan_txt = String::from_utf8_lossy(&plan_out.stdout).to_lowercase();
assert!(
!plan_txt.contains("create") && !plan_txt.contains("delete"),
"re-plan of an applied tree must be a no-op:\n{plan_txt}"
);
// --- Reparent: move the child under the instance root. Rewrite the SAME
// repo's manifest (M3: a stable `.picloud/` project key per repo — a fresh
// dir would be a different project and conflict on the owned group). ---
fs::write(
dir.path().join("picloud.toml"),
format!(
"[group]\nslug = \"{child}\"\nname = \"Child Group\"\nparent = \"root\"\n\n\
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n"
),
)
.unwrap();
let dir2 = &dir;
let out = common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir2.path())
.output()
.expect("apply --dir reparent");
assert!(
out.status.success(),
"tree apply (reparent) failed: {}",
String::from_utf8_lossy(&out.stderr)
);
// Re-plan is a no-op now that the parent matches.
let plan_out = common::pic_as(&env)
.args(["plan", "--dir"])
.arg(dir2.path())
.output()
.expect("plan --dir after reparent");
let plan_txt = String::from_utf8_lossy(&plan_out.stdout).to_lowercase();
assert!(
!plan_txt.contains("create") && !plan_txt.contains("delete"),
"re-plan after reparent must be a no-op:\n{plan_txt}"
);
}
fn ls(env: &common::TestEnv, args: &[&str]) -> String {
String::from_utf8(common::pic_as(env).args(args).output().expect("ls").stdout).unwrap()
}

View File

@@ -0,0 +1,84 @@
//! Phase-3 config `vars`, end to end via `pic`: a group-owned var is
//! inherited by an app underneath it, and an app-owned var of the same key
//! overrides the inherited value (proximity wins, §3).
//!
//! Drives the real resolution path: a script does `vars::get("region")`
//! and returns it; we invoke it via `/api/v1/execute/{id}` and assert on
//! the body. First the group value flows down (inheritance), then an app
//! value shadows it (override).
use crate::common;
use crate::common::cleanup::{AppGuard, GroupGuard};
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn group_var_is_inherited_then_app_value_overrides() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let acme = common::unique_slug("v-acme");
let app = common::unique_slug("v-app");
// Group `acme`, then a `region` var on it (a JSON string "eu").
let _g_acme = GroupGuard::new(&env.url, &env.token, &acme);
common::pic_as(&env)
.args(["groups", "create", &acme])
.assert()
.success();
common::pic_as(&env)
.args(["vars", "set", "region", "eu", "--group", &acme])
.assert()
.success();
// App under acme with a script that reads + returns the resolved var.
let _app = AppGuard::new(&env.url, &env.token, &app);
common::pic_as(&env)
.args(["apps", "create", &app, "--group", &acme])
.assert()
.success();
let fixture = common::fixture_path("read-var.rhai");
common::pic_as(&env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&app,
])
.assert()
.success();
// Resolve the deployed script's id.
let ls = common::pic_as(&env)
.args(["scripts", "ls", "--app", &app])
.output()
.expect("scripts ls");
let id = common::parse_first_id(std::str::from_utf8(&ls.stdout).unwrap())
.expect("scripts ls should produce one row");
// Inherited: the app has no own `region`, so the group's "eu" resolves.
assert_eq!(invoke_body(&env, &id), serde_json::json!("eu"), "inherited");
// Proximity override: an app-owned `region` shadows the group value.
common::pic_as(&env)
.args(["vars", "set", "region", "us", "--app", &app])
.assert()
.success();
assert_eq!(invoke_body(&env, &id), serde_json::json!("us"), "override");
}
/// Invoke a script via `pic scripts invoke <id>` (→ `/api/v1/execute/{id}`)
/// and parse its JSON body.
fn invoke_body(env: &common::TestEnv, id: &str) -> serde_json::Value {
let out = common::pic_as(env)
.args(["scripts", "invoke", id])
.output()
.expect("scripts invoke");
assert!(
out.status.success(),
"invoke failed: {}",
String::from_utf8_lossy(&out.stderr)
);
serde_json::from_slice(&out.stdout).expect("invoke body is JSON")
}

View File

@@ -14,26 +14,27 @@ use picloud_manager_core::{
apps_router, attach_principal_if_present, auth_router, compile_routes, dead_letters_router,
dev_emails_router, email_inbound_router, files_admin_router, groups_router, kv_admin_router,
migrations, require_authenticated, route_admin_router, secrets_router, topics_router,
triggers_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, AdminState,
AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository,
AppMembersRepository, AppMembersState, AppRepository, ApplyService, AppsState, AuthState,
AuthzRepo, DeadLetterRepo, DeadLettersState, DevEmailState, Dispatcher, DocsServiceImpl,
EmailInboundState, EmailServiceImpl, FilesAdminState, FilesConfig, FilesServiceImpl,
FsFilesRepo, GroupMembersRepository, GroupRepository, GroupsState, HttpConfig, HttpServiceImpl,
InboundNonceDedup, KvAdminState, KvServiceImpl, OutboxEventEmitter, OutboxRepo,
PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository,
PostgresAppRepository, PostgresAppSecretsRepo, PostgresAppUserInvitationRepo,
PostgresAppUserPasswordResetRepo, PostgresAppUserRepository, PostgresAppUserRoleRepo,
PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo, PostgresDeadLetterRepo,
PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository,
PostgresExecutionLogSink, PostgresGroupMembersRepository, PostgresGroupRepository,
PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo, PostgresRouteRepository,
PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo, PostgresTriggerRepo,
PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, RouteAdminState,
RouteRepository, SandboxCeiling, ScriptRepository, SecretsConfig, SecretsServiceImpl,
SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig, TriggerRepo,
TriggersState, UsersServiceConfig, UsersServiceImpl,
triggers_router, vars_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository,
AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState,
AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository, ApplyService,
AppsState, AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState, DevEmailState, Dispatcher,
DocsServiceImpl, EmailInboundState, EmailServiceImpl, FilesAdminState, FilesConfig,
FilesServiceImpl, FsFilesRepo, GroupMembersRepository, GroupRepository, GroupsState,
HttpConfig, HttpServiceImpl, InboundNonceDedup, KvAdminState, KvServiceImpl,
OutboxEventEmitter, OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository,
PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository,
PostgresAppMembersRepository, PostgresAppRepository, PostgresAppSecretsRepo,
PostgresAppUserInvitationRepo, PostgresAppUserPasswordResetRepo, PostgresAppUserRepository,
PostgresAppUserRoleRepo, PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo,
PostgresDeadLetterRepo, PostgresDeadLetterService, PostgresDocsRepo,
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresGroupMembersRepository,
PostgresGroupRepository, PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo,
PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo,
PostgresTriggerRepo, PostgresVarsRepo, PrincipalResolver, PubsubServiceImpl,
RealtimeAuthorityImpl, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
ScriptRepository, SecretsConfig, SecretsServiceImpl, SecretsState, SubscriberTokenConfig,
TopicRepo, TopicsState, TriggerConfig, TriggerRepo, TriggersState, UsersServiceConfig,
UsersServiceImpl, VarsApiState, VarsServiceImpl,
};
use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS;
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
@@ -322,12 +323,14 @@ pub async fn build_app(
// under script-as-gate semantics.
.with_authz(authz.clone()),
);
let vars: Arc<dyn picloud_shared::VarsService> =
Arc::new(VarsServiceImpl::new(pool.clone(), authz.clone()));
let services = Services::new(
kv,
docs,
dl_service.clone(),
events,
modules,
modules.clone(),
http,
files,
pubsub,
@@ -336,6 +339,7 @@ pub async fn build_app(
users.clone(),
queue,
invoke,
vars,
);
// v1.1.9: keep the invoke depth bound aligned with the dispatcher's
// trigger-depth bound (same counter under the hood).
@@ -467,7 +471,10 @@ pub async fn build_app(
routes: route_repo.clone(),
triggers: trigger_repo.clone(),
secrets: secrets_repo.clone(),
vars: Arc::new(PostgresVarsRepo::new(pool.clone())),
apps: apps_repo.clone(),
groups: groups_repo.clone(),
modules: modules.clone(),
domains: domains_repo.clone(),
authz: authz.clone(),
validator: engine.clone(),
@@ -509,6 +516,7 @@ pub async fn build_app(
let secrets_state = SecretsState {
repo: secrets_repo,
apps: apps_repo.clone(),
groups: groups_repo.clone(),
authz: authz.clone(),
master_key,
max_value_bytes: secrets_max_value_bytes,
@@ -566,6 +574,24 @@ pub async fn build_app(
users: auth.users.clone(),
authz: authz.clone(),
};
let vars_state = VarsApiState {
vars: Arc::new(PostgresVarsRepo::new(pool.clone())),
apps: apps_repo.clone(),
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(),
authz: authz.clone(),
};
let app_users_admin_state = picloud_manager_core::AppUsersState {
apps: apps_state.apps.clone(),
authz: authz.clone(),
@@ -589,6 +615,11 @@ 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(
app_users_admin_state,
))

View File

@@ -18,7 +18,7 @@ use async_trait::async_trait;
use chrono::{DateTime, Utc};
use thiserror::Error;
use crate::{AppId, ExecutionId, ScriptId, SdkCallCx};
use crate::{AppId, ExecutionId, ScriptId, ScriptOwner, SdkCallCx};
/// Identifies the script to invoke. Accepted by `invoke()` and
/// `invoke_async()` script-side.
@@ -52,6 +52,12 @@ impl InvokeTarget {
pub struct ResolvedScript {
pub script_id: ScriptId,
pub app_id: AppId,
/// The resolved script's **defining node** (Phase 4b) — the lexical
/// origin its `import`s resolve against (§5.5). For an inherited group
/// script this is the **group**, even though `app_id` (the execution
/// boundary) is the caller's app. `None` only for a corrupt owner row;
/// the executor falls back to `App(app_id)`.
pub owner: Option<ScriptOwner>,
pub source: String,
pub updated_at: DateTime<Utc>,
/// Script.name — surfaced to the callee as `ctx.script_name`.

View File

@@ -38,6 +38,7 @@ pub mod subscriber_token;
pub mod trigger_event;
pub mod users;
pub mod validator;
pub mod vars;
pub mod version;
/// serde `default` for `enabled`-style boolean fields that should default to
@@ -75,7 +76,9 @@ pub use inbox::{
pub use invoke::{InvokeError, InvokeService, InvokeTarget, NoopInvokeService, ResolvedScript};
pub use kv::{KvError, KvListPage, KvService, NoopKvService};
pub use log_sink::{ExecutionLogSink, LogSinkError};
pub use modules::{ModuleScript, ModuleSource, ModuleSourceError, NoopModuleSource};
pub use modules::{
ExtPointResolution, ModuleScript, ModuleSource, ModuleSourceError, NoopModuleSource,
};
pub use outbox_writer::{HttpDispatchPayload, NewHttpOutbox, OutboxWriter, OutboxWriterError};
pub use pubsub::{
topic_matches, validate_topic_pattern, NoopPubsubService, PubsubError, PubsubService,
@@ -85,7 +88,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,
@@ -101,4 +104,5 @@ pub use users::{
UsersService,
};
pub use validator::{ScriptValidator, ValidatedScript, ValidationError};
pub use vars::{NoopVarsService, VarsError, VarsService};
pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION};

View File

@@ -1,51 +1,125 @@
//! `ModuleSource` — the v1.1.3 Rhai module-loading contract.
//! `ModuleSource` — the Rhai module-loading contract (v1.1.3; made
//! origin-aware in v1.2 Phase 4b).
//!
//! The executor-core `PicloudModuleResolver` calls into this trait to
//! load `kind = 'module'` scripts referenced by `import "<name>" as <alias>;`
//! statements. The Postgres impl in `manager-core` reads from the
//! `scripts` table; tests pin in-memory fakes.
//!
//! Implementations MUST derive `app_id` from `cx.app_id` and pass it
//! to every backend query. The `name` argument carries only the
//! script's name (the literal between the import quotes); the trait
//! has no way to express a cross-app lookup. That asymmetry is the
//! load-bearing cross-app isolation boundary — see `docs/sdk-shape.md`.
//! **Resolution is lexical (§5.5).** A module is resolved against the
//! module set visible at the **importing script's own defining node**
//! (`origin`), walking up the group chain from there — *not* the
//! inheriting app's effective view. So `resolve` is keyed by a
//! [`ScriptOwner`] origin, not by `cx.app_id`. This deliberately differs
//! from the SDK-call isolation boundary: a group script's `import`
//! resolves from the **group** (sealed from below — a leaf app cannot
//! shadow it), while every SDK call *inside* that module still scopes to
//! the inheriting app via `cx.app_id`. The `origin` argument is a trusted
//! dispatch-derived value (the resolved script's owner), never a
//! script-passed argument, so the cross-app isolation boundary holds.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{AppId, ScriptId, SdkCallCx};
use crate::{AppId, GroupId, ScriptId, ScriptOwner};
/// A module script as returned by `ModuleSource::lookup`. Carries only
/// the fields the resolver needs: the id (for diagnostics), the source
/// (to compile), and `updated_at` (the cache-staleness comparator).
/// A module script as returned by `ModuleSource::resolve`. Carries the
/// fields the resolver needs: the id (cache key + diagnostics), the
/// resolved module's **owner** (so nested imports inside it resolve from
/// *its* defining node), the source (to compile), and `updated_at` (the
/// cache-staleness comparator).
///
/// Ownership is polymorphic (Phase 4): exactly one of `app_id` / `group_id`
/// is set — mirroring [`crate::Script`] and the DB CHECK.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModuleScript {
pub script_id: ScriptId,
pub app_id: AppId,
/// Owning app, when app-owned. Mutually exclusive with `group_id`.
#[serde(default)]
pub app_id: Option<AppId>,
/// Owning group, when group-owned (Phase 4b group modules).
#[serde(default)]
pub group_id: Option<GroupId>,
pub name: String,
pub source: String,
pub updated_at: DateTime<Utc>,
}
/// Lookup contract used by `PicloudModuleResolver`. `lookup` MUST
/// scope by `cx.app_id`; cross-app reads must be unreachable.
impl ModuleScript {
/// The resolved module's defining node, reconstructed from the
/// polymorphic columns. Used by the resolver to seal *this* module's
/// own nested imports to its node (lexical chaining). Prefers the app
/// if a corrupt row sets both, so the hot path never panics.
#[must_use]
pub fn owner(&self) -> Option<ScriptOwner> {
match (self.app_id, self.group_id) {
(Some(a), _) => Some(ScriptOwner::App(a)),
(None, Some(g)) => Some(ScriptOwner::Group(g)),
(None, None) => None,
}
}
}
/// Outcome of an extension-point lookup (§5.5). Present when `name` is
/// declared an extension point on `origin`'s chain *and* is not shadowed by
/// a nearer concrete module of the same name — i.e. the nearest declaration
/// of `name` is the extension point, so resolution inverts to the inheriting
/// app. `default_script_id` is the optional fallback module body to use when
/// the app provides no module of `name`.
#[derive(Debug, Clone)]
pub struct ExtPointResolution {
pub default_script_id: Option<ScriptId>,
}
/// Lookup contract used by `PicloudModuleResolver`. `resolve` walks the
/// module set up the chain rooted at `origin` (the importing script's
/// defining node), nearest-owner-wins. The `origin` is trusted
/// (dispatch-derived); the `name` is the literal between the import
/// quotes. See the module docs for why this is lexical, not `cx`-scoped.
#[async_trait]
pub trait ModuleSource: Send + Sync {
/// Resolve a module script by `(cx.app_id, name)`. Returns `None`
/// when no row exists, or when a row exists but its `kind` is
/// `'endpoint'` (endpoints are never importable). The resolver
/// surfaces `None` as `ErrorModuleNotFound` to Rhai.
async fn lookup(
/// Resolve a `kind = 'module'` script named `name`, visible from the
/// `origin` node walking up its group chain (nearest depth wins).
/// Returns `None` when no module of that name exists anywhere on the
/// chain (or a row exists but its `kind` is `'endpoint'` — endpoints
/// are never importable). The resolver surfaces `None` as
/// `ErrorModuleNotFound` to Rhai.
async fn resolve(
&self,
cx: &SdkCallCx,
origin: ScriptOwner,
name: &str,
) -> Result<Option<ModuleScript>, ModuleSourceError>;
/// Extension-point check (§5.5). Returns `Some` iff the **nearest**
/// declaration of `name` on `origin`'s chain is an extension point (not
/// a concrete module). On a tie in depth a concrete module wins (so a
/// nearer/peer real module shadows the extension-point declaration). The
/// `origin` is the trusted importer node, so the inversion can only be
/// opened by a declaration on the importer's own ancestry — a descendant
/// can never make a parent's sealed import dynamic.
async fn resolve_extension_point(
&self,
origin: ScriptOwner,
name: &str,
) -> Result<Option<ExtPointResolution>, ModuleSourceError>;
/// Load the extension-point fallback module `script_id`, but ONLY if it is
/// a `kind = 'module'` script reachable on `origin`'s chain. The `origin`
/// is the trusted importer node; constraining the lookup to its chain makes
/// the resolver self-defending — a `default_script_id` that (through a bug
/// or DB-direct write) pointed at another tenant's module resolves to
/// `None` rather than executing cross-tenant code. Returns `None` if the id
/// is absent, names an endpoint, or is off-chain.
async fn resolve_by_id(
&self,
origin: ScriptOwner,
script_id: ScriptId,
) -> Result<Option<ModuleScript>, ModuleSourceError>;
}
/// Failure modes surfaced from `ModuleSource::lookup`. "Not found" is
/// Failure modes surfaced from `ModuleSource::resolve`. "Not found" is
/// not exceptional — it's `Ok(None)`.
#[derive(Debug, Error)]
pub enum ModuleSourceError {
@@ -57,7 +131,7 @@ pub enum ModuleSourceError {
}
/// Stub used by the executor-core test harness so engine integration
/// tests don't need a real DB-backed source. Every lookup returns
/// tests don't need a real DB-backed source. Every resolve returns
/// `Ok(None)` — `import "x"` always errors as "module not found"
/// under this impl.
#[derive(Debug, Default, Clone, Copy)]
@@ -65,11 +139,27 @@ pub struct NoopModuleSource;
#[async_trait]
impl ModuleSource for NoopModuleSource {
async fn lookup(
async fn resolve(
&self,
_cx: &SdkCallCx,
_origin: ScriptOwner,
_name: &str,
) -> Result<Option<ModuleScript>, ModuleSourceError> {
Ok(None)
}
async fn resolve_extension_point(
&self,
_origin: ScriptOwner,
_name: &str,
) -> Result<Option<ExtPointResolution>, ModuleSourceError> {
Ok(None)
}
async fn resolve_by_id(
&self,
_origin: ScriptOwner,
_script_id: ScriptId,
) -> Result<Option<ModuleScript>, ModuleSourceError> {
Ok(None)
}
}

View File

@@ -1,7 +1,7 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::{AppId, ScriptId, ScriptSandbox};
use crate::{AppId, GroupId, ScriptId, ScriptSandbox};
/// Semantic role of a script (v1.1.3).
///
@@ -94,10 +94,26 @@ mod kind_tests {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Script {
pub id: ScriptId,
/// Owning app. Set on create, immutable thereafter — a "move to
/// another app" is a copy+delete, not an in-place edit (snapshot
/// semantics — see blueprint §11.5).
pub app_id: AppId,
/// Owning app, when app-owned. Set on create, immutable thereafter — a
/// "move to another app" is a copy+delete, not an in-place edit
/// (snapshot semantics — see blueprint §11.5).
///
/// Phase 4 (v1.2 Hierarchies) made script ownership **polymorphic**:
/// exactly one of `app_id` / `group_id` is set (DB CHECK + [`ScriptOwner`]).
/// A group-owned script (`app_id: None`, `group_id: Some`) is a template
/// inherited by every descendant app — it has no single app, so the
/// **execution context** app is supplied by the route/trigger/caller that
/// invoked it, never read off the script. Reading `app_id` to mean "the
/// app this runs under" is therefore a bug for group scripts; use the
/// invoking surface's app_id instead.
#[serde(default)]
pub app_id: Option<AppId>,
/// Owning group, when group-owned (Phase 4). Mutually exclusive with
/// `app_id`. The script is resolved by name, nearest-owner-wins, down the
/// `apps.group_id → groups.parent_id` chain (CoW: an app's own script of
/// the same name shadows the inherited one).
#[serde(default)]
pub group_id: Option<GroupId>,
pub name: String,
pub description: Option<String>,
pub version: i32,
@@ -131,3 +147,36 @@ pub struct Script {
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Who owns a script (Phase 4). Exactly one owner — the DB enforces it with
/// a `CHECK ((group_id IS NULL) <> (app_id IS NULL))`; this enum is the
/// in-memory witness of that invariant so call sites can `match` exhaustively
/// instead of juggling two `Option`s.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ScriptOwner {
App(AppId),
Group(GroupId),
}
impl Script {
/// The script's owner, reconstructed from the polymorphic columns.
/// A row that violates the exactly-one invariant (both/neither set —
/// impossible under the CHECK) is reported as whichever is present,
/// preferring the app, so a corrupt row never panics on the hot path.
#[must_use]
pub fn owner(&self) -> Option<ScriptOwner> {
match (self.app_id, self.group_id) {
(Some(a), _) => Some(ScriptOwner::App(a)),
(None, Some(g)) => Some(ScriptOwner::Group(g)),
(None, None) => None,
}
}
/// True iff this script is owned by `app` directly (not via a group).
/// The cheap, app-owned-only ownership check — group inheritance is
/// resolved separately (chain-membership), never by this method.
#[must_use]
pub fn is_owned_by_app(&self, app: AppId) -> bool {
self.app_id == Some(app)
}
}

View File

@@ -24,7 +24,8 @@ use crate::{
KvService, ModuleSource, NoopDeadLetterService, NoopDocsService, NoopEmailService,
NoopEventEmitter, NoopFilesService, NoopHttpService, NoopInvokeService, NoopKvService,
NoopModuleSource, NoopPubsubService, NoopQueueService, NoopSecretsService, NoopUsersService,
PubsubService, QueueService, SecretsService, ServiceEventEmitter, UsersService,
NoopVarsService, PubsubService, QueueService, SecretsService, ServiceEventEmitter,
UsersService, VarsService,
};
/// SDK service bundle. See module docs for the lifecycle and the v1.1.x
@@ -107,6 +108,12 @@ pub struct Services {
/// and `invoke_async()` (fire-and-forget through the outbox).
/// Cross-app invokes are rejected at the service entry point.
pub invoke: Arc<dyn InvokeService>,
/// Group-inherited, env-scoped config (Phase 3). Scripts get
/// read-only `vars::{get,all}`; values resolve down the group tree
/// (§3). Backed by `config_resolver` over Postgres in the picloud
/// binary; `NoopVarsService` in tests that don't read config.
pub vars: Arc<dyn VarsService>,
}
impl Services {
@@ -129,6 +136,7 @@ impl Services {
users: Arc<dyn UsersService>,
queue: Arc<dyn QueueService>,
invoke: Arc<dyn InvokeService>,
vars: Arc<dyn VarsService>,
) -> Self {
Self {
kv,
@@ -144,6 +152,7 @@ impl Services {
users,
queue,
invoke,
vars,
}
}
@@ -168,6 +177,7 @@ impl Services {
Arc::new(NoopUsersService),
Arc::new(NoopQueueService),
Arc::new(NoopInvokeService),
Arc::new(NoopVarsService),
)
}
}

48
crates/shared/src/vars.rs Normal file
View File

@@ -0,0 +1,48 @@
//! `vars::*` — read-only access from scripts to the app's resolved
//! configuration (Phase 3). Values are inherited down the group tree and
//! env-filtered per the §3 resolution rule; the resolution happens in
//! manager-core (`config_resolver`). Writes go through the admin API, not
//! the SDK — scripts only read.
use std::collections::BTreeMap;
use async_trait::async_trait;
use serde_json::Value;
use crate::SdkCallCx;
#[derive(Debug, thiserror::Error)]
pub enum VarsError {
/// Caller principal lacked `AppVarsRead`. Only raised when
/// `cx.principal.is_some()` (public-HTTP scripts skip the check —
/// script-as-gate).
#[error("forbidden")]
Forbidden,
/// Postgres unavailable, malformed row, etc.
#[error("vars backend error: {0}")]
Backend(String),
}
#[async_trait]
pub trait VarsService: Send + Sync {
/// Resolve a single config key for the calling app's environment.
/// `None` if no level defines it (or a tombstone suppresses it).
async fn get(&self, cx: &SdkCallCx, key: &str) -> Result<Option<Value>, VarsError>;
/// The app's fully-resolved config map (every inherited + own key).
async fn all(&self, cx: &SdkCallCx) -> Result<BTreeMap<String, Value>, VarsError>;
}
/// All-noop fallback for engines that don't wire vars (tests). Every call
/// surfaces an explicit error rather than silently returning empty.
pub struct NoopVarsService;
#[async_trait]
impl VarsService for NoopVarsService {
async fn get(&self, _cx: &SdkCallCx, _key: &str) -> Result<Option<Value>, VarsError> {
Err(VarsError::Backend("vars is not wired in".into()))
}
async fn all(&self, _cx: &SdkCallCx) -> Result<BTreeMap<String, Value>, VarsError> {
Err(VarsError::Backend("vars is not wired in".into()))
}
}

View File

@@ -1,17 +1,23 @@
# Groups, Projects & the Declarative Project Tool
> **Status:** Draft — design discussion captured 2026-06-18, revised 2026-06-20 with resolutions
> **Status:** Active — design discussion captured 2026-06-18, revised 2026-06-20 with resolutions
> grounded in precedent (GitLab, Kustomize, Helm, Terraform, Kubernetes Server-Side Apply, Pulumi)
> and corrected against the codebase after three independent review passes (consistency, gaps,
> feasibility). Not yet scheduled into a phase.
> feasibility). **Scheduled as the *Hierarchies* track of v1.2** (blueprint §12 Phase 5). §11 Phases
> 13 (project tool, groups + hierarchy RBAC, group-inherited config) are **shipped**; Phases 46
> remain. NB: §11's own Phase 16 numbering is local to this initiative and is **distinct from the
> blueprint product-phase numbering** — "Phase 3" here = group-inherited config, not the blueprint's
> admin-auth phase.
>
> **Scope:** turns the imperative `pic` CLI into a declarative, file-based project tool, and
> introduces a server-side **groups** hierarchy so apps can share and inherit config/scripts
> without duplication.
>
> **Blueprint impact:** this reverses the §11.5 *snapshot-copy, not live-link* stance — top-down
> hierarchical inheritance (group → app) is now in scope, implemented as a *materialized, auto-
> invalidated* view (§5.1). Fold the outcome back into the blueprint before it drifts.
> hierarchical inheritance (group → app) is now in scope. *Originally* designed as a *materialized,
> auto-invalidated* view (§5.1); **Phase 3 shipped it as live, resolve-on-read inheritance** (one
> recursive CTE per call, no cache), with materialization deferred to a future optimization (see the
> Phase 3 status note in §11.3). Fold the outcome back into the blueprint before it drifts.
---
@@ -369,11 +375,54 @@ Two distinct constraints:
identity token (collection / queue / topic; for cron/email/dead-letter fall back to `{kind}-{n}`),
sanitized to the kebab regex, with `-{n}` (discovery order) guaranteeing per-app uniqueness.
> **Ergonomic debt (accepted, watch it):** because triggers don't inherit, 100 tenant apps each
> needing the same 5 triggers = 500 declarations. The fix is group trigger/route **templates** that
> fan out per descendant (a *template/instantiation* mechanism, not inheritance) — deferred, but it
> bites early if tenant cardinality is high. Pressure-test against real tenant counts before
> committing to the narrow-inheritance choice (§5.1).
> **Ergonomic debt (being paid down):** because triggers/routes don't inherit, 100 tenant apps each
> needing the same declaration = 100× the declarations. The fix is group trigger/route **templates**
> that fan out per descendant (a *template/instantiation* mechanism, not inheritance).
>
> **Status: ✅ shipped — routes (M4a) AND triggers (M4b), 2026-06-28.** Migrations `0053_templates.sql`
> (`route_templates` + `routes.from_template`) and `0054_trigger_templates.sql` (`trigger_templates` +
> `triggers.from_template`). A `[group]` manifest declares `[[route_templates]]` and/or
> `[[trigger_templates]]` (the latter a flat block: `name`, `kind`, `script`, + kind params, covering
> all seven kinds); `pic apply --dir` reconciles the template rows
> (group-only — an app declaring one is a 422) and, in tree Phase B, **fans each template out into one
> concrete `routes`/`triggers` row per descendant app node** in the apply, resolving the script binding
> nearest-owner-wins and substituting an inert placeholder set — `{app_slug}`, `{env}` (from the
> apply's selected env), `{var:NAME}` (the app's effective var; an unknown var/placeholder is a hard
> error). For triggers the placeholders resolve in every string leaf of the spec, then the typed
> trigger is rebuilt and inserted through the normal path (email secrets are resolved + re-sealed per
> app — the value never travels in a manifest). Expansion is **idempotent via `from_template`** (routes
> diff by identity create/update-as-replace/delete; triggers key one-per-template-per-app and compare
> semantic identity, so re-apply doesn't churn and a removed/disabled template reaps its expansions
> under `--prune`); an expansion colliding with a hand-declared route/trigger, or two templates
> colliding, is a hard error. `plan` reports the **blast radius** (descendant app count) and the
> `state_token` folds each template, so an edit between plan and apply trips `StateMoved`. Authz:
> declaring a template needs `GroupScriptsWrite`; an app that *receives* expansions needs
> `AppWriteRoute` / `AppManageTriggers` (and `AppSecretsRead` for an email-trigger template, which
> seals the recipient's secret) — gated even when the app declares none of its own. Each RESOLVED
> expansion is validated with the same rules its hand-declared counterpart gets — routes:
> reserved-path rejection + structural path/host parse + host-claim; triggers: the per-kind shape
> check (cron schedule, queue timeout, …) — so a `{var:…}`-injected reserved path, unclaimed strict
> host, or malformed schedule/timeout can't slip in. **Scope:** expansion targets **every descendant app
> in the DB subtree** (M7, 2026-06-28), not only the app nodes present in the apply — a template change at
> group N fans out to `subtree(N)` across repos, and removing a template reaps its expansions on those
> descendants too (the apply locks each extra descendant in `app_id` order). The actor must hold the
> per-recipient write cap (`AppWriteRoute`/`AppManageTriggers`/`AppSecretsRead`) on each descendant; the
> hierarchy-aware `effective_app_role` makes a group admin/editor implicitly authorized on its subtree, so
> this is sound rather than an escalation, and a narrowly-scoped principal is refused (naming the app).
> `{var:NAME}` resolves **in-transaction** (M6, 2026-06-28) — a var set in the *same* apply is visible to
> the template immediately, so var + template converge in one apply. A
> trigger template whose only change is a non-identity field (e.g. dispatch_mode) is a no-op on
> re-apply, mirroring the existing trigger-identity limitation (recreate to change those).
>
> **Known limitations (both "one more apply", no data risk):** (1) Template fan-out resolves against a
> node's **committed** ancestry — `apply` computes app chains before the transaction's structural
> reconcile — so a template gained or lost by a **reparent performed in the *same* apply** takes effect
> on the next apply (the same shape as "reparenting into a freshly-created group needs a second apply").
> (2) The `{env}` placeholder resolves to the apply's `--env` for **declared** apps but to the app's own
> `apps.environment` column for **cross-repo descendants**; if a template uses `{env}` and those two
> disagree, that descendant's expansion can churn between applies (and a descendant with a NULL
> `environment` + an `{env}` template aborts the apply, naming the app). Set the descendant's environment,
> or avoid `{env}` in templates fanned across heterogeneous-env subtrees.
### 4.6 Secrets & `pull`
@@ -425,6 +474,19 @@ Two distinct constraints:
- **Runtime model: a materialized effective view + versioned cache (not per-request live-resolve).**
An earlier draft said "live-resolve," which is underspecified and would fight the existing cache.
The real model:
> **⚠️ Superseded for config by what Phase 3 actually shipped (§11.3): live resolution.** For
> **vars + secrets**, PiCloud resolves on-demand via one recursive CTE per call — *no* materialized
> view, generation counter, or invalidation fan-out. There was no pre-existing config cache to
> "fight" (config is net-new, §3), and the org tree is small, so resolve-on-read is cheap and
> sidesteps the invalidation SLA below. The materialized model in this bullet stands only as the
> **deferred plan for *script-body* inheritance (Phase 4)** — where a hot per-request path may
> justify a cache — and as a future optimization for config if a deep tree ever demands it. Read the
> rest of §5.1 as "the materialization design, if/when we need it," not as Phase 3's runtime.
> **Update — Phase 4-lite (§11.4 status) also shipped live, not materialized:** group scripts are
> referenced by id on the runtime hot path and resolved by name only at bind/invoke time, so even
> script inheritance needed no materialized view or invalidation fan-out. The materialization design
> now stands purely as a future optimization, gated on a *measured* cost.
- manager-core **resolves-at-write into a materialized per-app effective view** (§3 rule applied:
sparse merge, env filter, proximity, CoW, `enabled`).
- The orchestrator/executor serve from that view, **keyed by `app_id` + a generation/version**. The
@@ -526,11 +588,25 @@ trust inversion). The rule:
extension point with no provider in a given app is an error for that app (a hard failure, joining
§4.7).
> **Residual (verified):** executor-core's `PicloudModuleResolver` is app-scoped today and ignores the
> importing script's origin (`module_resolver.rs` passes `_source` unused). Rhai *does* expose that
> origin, so the lexical-vs-dynamic split is expressible — but it requires re-keying the resolver cache
> by owner identity and adding per-import policy (sealed vs. extension point), i.e. a real
> resolver+cache redesign, not a parameter tweak. Lands with phasing step 4.
> **Resolved (Phase 4b ✅).** The **lexical (sealed-by-default)** core shipped: `ModuleScript` carries
> a polymorphic owner; `ModuleSource::resolve(origin, name)` walks the chain rooted at the importing
> node (app-rooted `CHAIN_LEVELS_CTE` or the new group-rooted CTE); the resolved script's owner threads
> through `ExecRequest.script_owner` (the executor's `default_origin`) and every dispatch + `invoke()`
> site; the `PicloudModuleResolver` reads the importing node from Rhai's `_source` (set to each module's
> owner via `AST::set_source(encode(owner))` before `eval_ast_as_new` — the lexical chaining), and the
> cache is re-keyed by resolved `ScriptId`. Group modules + group-script imports are now allowed
> (`group_scripts_api`), and the single-node apply runs the §5.5 dangling-import `plan` check.
>
> **Extension points (✅ shipped, 2026-06-26).** The opt-in dynamic-resolution branch landed: migration
> `0051` adds an owner-polymorphic `extension_points` table (optional `default_script_id` fallback);
> `ModuleSource` gained `resolve_extension_point(origin, name)` (Some only when the NEAREST declaration
> of `name` on the importer's chain is an extension point — a peer/nearer concrete module shadows it)
> and a chain-constrained `resolve_by_id(origin, id)`. The resolver checks the importer's chain first
> and, on a hit, resolves against `App(cx.app_id)` (the inheriting app), falling back to the default
> body. The decision keys on the trusted importer `origin`, so only the declaring parent can open the
> inversion — a descendant can't make a parent's sealed import dynamic or hijack one. Declared via
> `[[extension_points]] name = "..." default = "<local module>"` (app or group manifests); reconciled
> like `vars`, gated on the script-write capability, exported by `pic pull`.
### 5.6 Tree lifecycle: delete, reparent, rename
@@ -601,6 +677,26 @@ non-orphaning:
## 7. Ownership of shared nodes
> **Status: ✅ shipped (M3, 2026-06-26).** Migration `0052_owner_project.sql` adds a `projects(id, key
> UNIQUE)` table and promotes the inert `groups.owner_project` (0047) to a real FK. The CLI mints a
> stable, gitignored project key in `.picloud/project.json` (`pic init`, or lazily on first tree
> plan/apply) and presents it on every tree request. **First apply claims** each declared group for the
> project (NULL owner → stamped, in-tx, first-commit-wins under the per-node advisory lock). A second
> project applying an owned group is **refused** (`pic plan --dir` surfaces the conflict for CI;
> `pic apply --dir` returns 409) unless `pic apply --dir --takeover`, which is **GroupAdmin-gated** per
> contested node (ownership ⟂ RBAC: two independent gates, §7.4) and flips the owner. With ownership in
> place, `--prune` now also reaps **structural** nodes: a group this project owns that the manifest no
> longer declares is deleted **leaf-first** (RESTRICT-guarded, so a still-populated group aborts the
> apply rather than orphaning data) — scoped to project-owned nodes, so one repo never reaps another's
> or a UI-owned (unclaimed) node. The bound-plan token folds each declared group's owner key, so a
> claim/takeover by another repo between plan and apply trips `StateMoved`. **Attach-point ceiling**
> (§7.2) is implicit: a manifest only ever writes the nodes it declares; an ancestor parent is
> referenced read-only, never claimed or written. The GroupAdmin takeover gate is enforced **twice**:
> in `authz_tree` (pre-tx) and again **in-tx** at the ownership decision (so `--force`, which waives the
> staleness token, can't open a takeover-without-admin window). The attacker-supplied project key is
> length/charset-validated server-side before use. Journey coverage: claim → conflict → takeover → flip
> → prune, plus a non-admin `--takeover` → 403.
**Single-owner-per-node, ceilinged by the attach point.**
1. Each group node is owned by exactly one **project-root** (the repo that *manages* it — contains
@@ -800,7 +896,10 @@ Resolved items now live inline next to their topic. What genuinely remains:
- **Effective-view invalidation SLA (§5.1)** — the security-staleness guarantee at fan-out to many
descendants is unsolved; synchronous-for-security + eventual-elsewhere is the proposed shape, not a
proven one. Highest-risk open item.
proven one. *(Was the highest-risk open item.)* **Moot for config as of Phase 3:** vars/secrets ship
with **live resolution** (no cache → no staleness window; a disable/rotation is visible on the next
call). The SLA only re-arises if **script-body** materialization is built (Phase 4) or if config is
ever materialized for performance; until then there is nothing to invalidate.
- **Conflict bit vs. operational lock (§4.2)** — *decided:* phase 1 ships the `enabled` / secret-
reference **conflict bit**; the operational-lock flag is the documented fallback if the bit proves
too heavy. (Was listed as undecided; resolved here to match §11 phase 1.)
@@ -810,8 +909,11 @@ Resolved items now live inline next to their topic. What genuinely remains:
precondition to adopting the rule.
- **Inherited-membership revocation lag (§5.3)** — revoking a group admin must drop implicit admin on
every descendant app, but §5.1's synchronous-invalidation subset covers only `enabled`/secrets, not
**role revocation** — leaving an unbounded window. New residual risk; should get the same
synchronous-for-security bound, lands with phase 3's authz.
**role revocation** — leaving an unbounded window. ~~New residual risk; should get the same
synchronous-for-security bound, lands with phase 3's authz.~~ **Resolved — never materialized:**
Phase 2 RBAC and Phase 3 config both resolve roles/config **live** (no inherited-role or
effective-view cache), so a revoked group grant is gone on the next request. The lag only exists if a
cache is introduced.
- **External execution cancel (§4.2 kill-switch)** — the executor has **no external-cancel path
today** (`spawn_blocking` + self-checked deadline, verified); the kill-switch is a net-new capability
(a cancel flag polled in `on_progress`), deferred past phase 1. Until it exists, the strongest stop
@@ -879,21 +981,176 @@ Resolved items now live inline next to their topic. What genuinely remains:
tables + polymorphic owner; the group-secret AAD scheme (§5.3 caveat); masked group secrets; the
effective-view resolver + materialization + invalidation; **`config --effective --explain`** (hard
requirement, since multi-level resolution first ships here).
> **Status (Phase 3): ✅ shipped — with one deliberate architecture change: LIVE resolution, not
> materialization.** §5.1 / this bullet / §12 prescribed a *materialized* per-app effective view
> with a generation counter and fan-out invalidation. We shipped **live, on-demand resolution
> instead**: one depth-bounded recursive CTE (`config_resolver::CHAIN_LEVELS_CTE`, walking
> `apps.group_id → groups.parent_id → root`) runs per `vars::get`/`secrets::get`/`config/effective`,
> and the §3 merge/proximity/tombstone semantics run in a pure, unit-tested `resolve()` pass on the
> fetched rows. No materialized store, no generation, no invalidation protocol. Rationale: the group
> tree is small org structure, resolve-on-read is cheap, and it sidesteps §10's unsolved
> fan-out-invalidation SLA entirely. **Consequence:** §10's "effective-view invalidation SLA" and
> "inherited-membership revocation lag" are **moot for config** — with no cache, a security disable
> or secret rotation propagates immediately (same posture as Phase 2's live RBAC). Materialization
> (and a per-execution memo keyed by `cx.execution_id`) is re-scoped to a **future optimization**,
> to revisit only if a deep tree + hot path ever makes live resolution measurably costly.
>
> Shipped surface:
> - **Schema:** `0048_vars.sql` (`vars` polymorphic owner [`group_id` XOR `app_id`],
> `environment_scope`, `is_tombstone`, partial-unique indexes) + **`apps.environment TEXT NULL`**
> (the explicit realization of "an environment is an app", §2 — set at app-create, read by the
> resolver, never entering `SdkCallCx`). `0049_group_secrets.sql` reshapes the live `secrets`
> table to the **same** polymorphic-owner + `environment_scope` contract (PK → two partial-unique
> indexes); existing app rows backfill to `app_id` + scope `'*'` and decrypt byte-for-byte (the v1
> AAD excludes the scope).
> - **§3 resolver:** env-filter in SQL (`scope IN ('*', app_env)`), then proximity-first + per-key
> deep-merge / replace / tombstone in Rust. A same-level `@E` value **suppresses** the same-level
> `*` (env is eligibility, not a merge tier — §3.1), incl. the map-vs-map case.
> - **Vars:** `VarsService` + `vars::get`/`vars::all` SDK + `App/GroupVars{Read,Write}` caps +
> `{apps,groups}/{id}/vars` admin CRUD + `pic vars`.
> - **Group secrets:** owner-discriminated AAD (`secret:group:{group_id}:{name}`; app AAD
> unchanged); inherited `secrets::get` (resolves app-own + ancestor-group, decrypts under the
> resolved owner's AAD, anchored to `cx.app_id`); the **two orthogonal gates** (§5.3): runtime
> injection (no human-read check) vs. the group-gated value endpoint
> `GET /groups/{id}/secrets/{name}/value` (`GroupSecretsRead` = group_admin). `GroupSecretsWrite`
> sits at `script:write` scope (matches its editor role tier).
> - **Effective view (masked):** `GET /apps/{id}/config/effective` → resolved vars (with
> provenance) + secrets masked to `{status, owner, scope}` (value never returned). `pic config
> --effective [--explain]` renders it.
>
> Deferred (unchanged): group-*owned scripts/modules* (Phase 4), the manifest `[vars]`/`[secrets]`
> apply reconciliation (Phase 5 — Phase 3 ships CRUD API + CLI), and materialization (above).
4. **Group-inherited scripts/modules.** CoW overrides; the **scope-aware module/import resolver +
extension points** (§5.5); cache-invalidation fan-out hardening; versioning/pinning if needed.
> **Status (Phase 4-lite): ✅ shipped — group-owned ENDPOINT scripts, live-resolved (no
> materialization).** Like Phase 3's config, group scripts resolve **live**, not via a cache: a
> script is referenced by id on the runtime hot path (routes/triggers store the resolved id; the
> orchestrator's per-app route trie already scopes dispatch to one app and never reads
> `script.app_id`), and by **name** down the chain only at bind/invoke time. No materialized
> per-app body view, no generation counter, no fan-out invalidation — so §11.4's
> "cache-invalidation fan-out hardening" is **moot for Phase 4-lite** (revisit only if a measured
> hot-path cost appears).
>
> Shipped surface:
> - **Schema:** `0050_group_scripts.sql` makes `scripts` polymorphic-owned (`group_id` XOR
> `app_id`, `scripts_owner_exactly_one` CHECK, per-owner partial-unique `LOWER(name)` indexes),
> `ON DELETE RESTRICT` (code is not data — a group can't be deleted out from under its scripts).
> `Script.app_id` became `Option<AppId>` + `group_id`; the cross-app isolation backstops
> (dispatcher, invoke, trigger-bind, orchestrator `/execute/{id}`) generalized from
> `app_id == row.app_id` to **chain-membership**, fail-closed for app-owned (zero-cost in-memory
> fast path; only a group candidate pays a chain query).
> - **Resolver (reuses `CHAIN_LEVELS_CTE`):** `get_by_name_inherited(app_id, name)` (nearest-owner
> wins; app's own script shadows the inherited group one — CoW) and `is_invocable_by_app(script_id,
> app_id)` (the isolation predicate lifted to the hierarchy; sibling apps' *own* scripts are NOT
> cross-invocable — only group scripts inherit).
> - **Admin + CLI:** `GroupScripts{Read,Write}` caps (viewer+/editor+ on the group, resolved via the
> group-ancestor walk); `POST/GET /groups/{id}/scripts` (endpoint-only, self-contained — modules +
> imports rejected); the by-id `/scripts/{id}` handlers are owner-polymorphic; `pic scripts ls
> --group` / `deploy --group` (create-or-update; `--app`/`--group` mutually exclusive).
> - **`invoke("name", …)`** resolves inherited (a script runs a shared group endpoint; it always
> executes under the *caller's* app context, never the owner's).
> - **Declarative apply:** a manifest route/trigger `script = "name"` resolves to an inherited group
> endpoint (`resolve_inherited_targets` → `name_to_id`), with the diff resolving the group-bound
> route's id → name so **re-apply is idempotent**.
>
> **Phase 4b: ✅ shipped — group modules + the lexical (sealed-by-default) import resolver (§5.5).**
> Group `kind = module` scripts and group-script imports are now allowed; `import` resolves lexically
> against the importing script's **own defining node** (the group for an inherited script — a leaf
> can't shadow it; verified e2e). Mechanism: owner-polymorphic `ModuleScript`, origin-rooted
> `ModuleSource::resolve`, `ExecRequest.script_owner` threaded from every dispatch + `invoke()` site,
> `_source`-driven lexical chaining in the resolver (cache re-keyed by `ScriptId`), and the
> single-node dangling-import `plan` check. **Opt-in extension points shipped (§5.5, 2026-06-26).**
> Still deferred: **invoke-by-id** staying app-scoped (inheritance is by-name only); CoW
> is **redefine-in-app + re-apply** (no live auto-rebinding). Sharp edge to track: `routes.script_id`
> is `ON DELETE CASCADE`, so deleting a
> group script removes descendant apps' bound routes (within group-editor authority; the *group*
> delete itself stays `RESTRICT`).
5. **Project tool maps onto groups.** Nested manifests, attach point, single-owner, server-computed
tree plan, per-env approval gating.
> **Status (Phase 5): ✅ shipped — nested tree apply, tree shape, AND multi-repo ownership.** A
> directory tree of `picloud.toml` manifests (each declaring an `[app]` or `[group]` node) applies as
> ONE server-computed plan in ONE Postgres transaction. **Declarative group create/reparent shipped
> (M2, 2026-06-26)** — a manifest now owns the tree *shape*, not just node content. **Multi-repo
> single-owner / takeover + structural prune shipped (§7, M3, 2026-06-26)** — see §7 below.
> **Per-env approval-policy gating shipped (`[project.environments]`, M5, 2026-06-28)** — the root
> manifest declares which environments are confirm-required; `pic apply --dir --env <e>` to such an
> env needs an explicit `--approve <e>` (a blanket `--yes` does NOT cover it), the approval is
> admin-gated (the actor needs admin on every declared node, not just write) and audited, and the
> policy folds into the bound-plan token. The server re-derives the policy from the bundle — the CLI's
> check is convenience, the server is authoritative. Env overlays (`picloud.<env>.toml`) already exist.
> Gating is a **`--dir` (project-tree) feature**: single-node `pic apply --file --env <e>` cannot
> carry the admin-gated approval, so it **refuses** a confirm-required env and directs the user to
> `--dir` (rather than silently bypassing the gate). Like `--takeover`/blast-radius, the policy lives
> in the manifest (not persisted server-side), so whoever controls desired state can weaken it — an
> accepted trust boundary, not a privilege bug.
>
> Shipped surface:
> - **Engine:** the reconcile engine generalized from app-only to an `ApplyOwner { App | Group }`
> (a group node has only scripts + vars; routes/triggers/secret-values stay app-only). The in-tx
> reconcile is `reconcile_node_tx`, shared by single-node and tree apply. `apply_tree` locks every
> node key (sorted), reconciles **groups first** (recording each group's `name → id`), then apps —
> so an app route/trigger can bind a group script **created in the same transaction** (resolved
> nearest-ancestor-wins across the in-tree index + pre-existing ancestors; the pool resolver can't
> see uncommitted rows). One commit, one post-commit route refresh.
> - **Bound plan covers structure:** the tree token folds every node's `state_token` **plus every
> in-scope group's `structure_version`**, so a reparent (or content edit) between plan and apply
> trips `StateMoved` — the §4.2 "content **and** tree-structure version" check.
> - **API:** `POST /api/v1/admin/{groups/{id},tree}/{plan,apply}`; per-node capability gating (the
> actor must hold the read/write caps for **every** node touched).
> - **CLI:** a `[group]` manifest kind; `pic plan/apply` on one node (app or group); `pic
> plan/apply --dir <root>` discovers + applies the whole tree, with a single `<tree>` bound token.
>
> Live- and journey-validated: a group + nested app apply atomically (app route binds the group's
> same-apply script); re-plan is all-noop; one invalid node aborts the whole tree (nothing written);
> a `pic groups reparent` between plan and apply is refused. The §5.1 materialized effective-view +
> fan-out invalidation was **not** built — group config/scripts resolve **live** (Phase 3/4),
> so the tree apply needs no cache-invalidation protocol.
6. **(Much later) group-level collections/topics** — the v1.3 cross-app data-sharing problem, with a
real shared-scope authz model. Optionally, trigger/route **templates** (§4.5) if cardinality
demands.
### 11.1 Re-sequencing review (post-Phase-3)
A review after Phase 3 shipped surfaced three adjustments to the 4→6 plan; carried here as decisions
to take, not yet taken:
- **Phase 4 should resolve scripts *live*, not materialize.** §11.4 carries "cache-invalidation
fan-out hardening" as a deliverable, a holdover from the materialized-view model. Phase 3 proved
**live resolution** (recursive CTE per call) is sufficient and sidesteps the §10 invalidation SLA,
and script *bodies are already live-resolved per request today* (§5.1). So Phase 4 should resolve
group-owned scripts live too and **drop the fan-out-invalidation sub-project** unless a *measured*
hot-path cost appears. This removes the scariest item from Phase 4; the real remaining cost is the
**module/import resolver redesign** (§5.5), which is unaffected by this and stays the gating risk.
- **Consider a "Phase 5-lite" *before* Phase 4.** The declarative project tool already applies app
scripts (Phase 1) and Phase 3 shipped the group vars/secrets CRUD, so a project tool that declares
**group vars/secrets + app scripts** needs *nothing* from Phase 4 (group-owned scripts). The
highest-leverage day-to-day workflow (declarative config apply) can ship ahead of the hard
module-resolver work. Phase 4 then adds group-owned *scripts* to an already-working project tool.
- **Two deferred decisions to force, not default:**
- *Trigger/route templates (§4.5):* bundled into Phase 6 as "much later," but the per-app binding
tax bites in Phase 5 at high tenant cardinality (100 tenants × 5 triggers = 500 declarations).
Decide against **actual** target tenant counts before defaulting it late.
- *Multi-repo ownership (§7):* ~~the single-owner / attach-point / takeover machinery is substantial
and only earns its keep with a **second** managing repo~~ — **shipped (M3, 2026-06-26)**; see the
§7 status note. Project-keyed single-owner claim, GroupAdmin-gated `--takeover`, and project-scoped
structural prune all landed.
---
## 12. Contracts still to draft
- The **apply bundle / plan artifact / change-report** wire contract (what the CLI ships, what the
server persists and returns), including the conflict and blast-radius shapes.
- The **effective-view resolver** (the read primitive) — the §3 rule made executable, plus the
materialization + invalidation protocol (§5.1).
- The **effective-view resolver** (the read primitive) — the §3 rule made executable. *(Phase 3:
shipped as a **live** recursive-CTE resolver, `config_resolver`/`secrets_repo::resolve`; the
materialization + invalidation protocol of §5.1 was **not** built — see the Phase 3 status note in
§11.3. The remaining contract work here is only for script-body inheritance, Phase 4.)*
- The **full manifest schema** spelling every block (scripts, routes, the 8 trigger kinds, storage
config, env-scoped vars, secret-refs, domains, `[project.environments]` + confirm policy).
> `[project]` (M5, root manifest only) shipped: `[[project.environments]]` entries carry
> `name` + `confirm` (default `false`). A `confirm = true` env is gated — `pic apply --dir --env <name>`
> requires `--approve <name>` (admin-gated + audited; `--yes` is insufficient). The block is rejected on
> any non-root manifest. Remaining unspecified: storage config, domains, the full secret-ref shape.

Some files were not shown because too many files have changed in this diff Show More