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>
This commit is contained in:
MechaCat02
2026-06-28 15:58:41 +02:00
parent 193336a8a6
commit 0396866698
18 changed files with 1504 additions and 37 deletions

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

@@ -88,6 +88,7 @@ async fn seed_into(
method: None,
dispatch_mode: picloud_shared::DispatchMode::Sync,
enabled: true,
from_template: None,
})
.await?;

View File

@@ -202,6 +202,10 @@ struct TreeApplyRequest {
/// 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>,
}
async fn tree_plan_handler(
@@ -237,6 +241,7 @@ async fn tree_apply_handler(
req.expected_token.as_deref(),
req.project_key.as_deref(),
req.allow_takeover,
req.env.as_deref(),
)
.await?;
Ok(Json(report))
@@ -261,6 +266,20 @@ async fn authz_tree(
Some(prune) => {
require_app_node_writes(svc, principal, app_id, &node.bundle, prune)
.await?;
// Template expansion (§4.5, M4a) writes routes INTO this
// app, so the actor needs AppWriteRoute when the app's
// chain carries route templates — even if the app itself
// declares no routes. Without this, expanding routes into
// an app a principal can't write would slip the gate.
if app_receives_template_expansions(svc, app_id, bundle).await? {
require(
svc.authz.as_ref(),
principal,
Capability::AppWriteRoute(app_id),
)
.await
.map_err(map_authz)?;
}
}
None => {
require(svc.authz.as_ref(), principal, Capability::AppRead(app_id))
@@ -467,8 +486,13 @@ async fn require_group_node_writes(
bundle: &Bundle,
prune: bool,
) -> Result<(), ApplyError> {
// Extension points gate on the script-write tier (see the app variant).
if prune || !bundle.scripts.is_empty() || !bundle.extension_points.is_empty() {
// Extension points and route templates gate on the script-write tier (see
// the app variant) — both are code-binding declarations, not viewer-tier.
if prune
|| !bundle.scripts.is_empty()
|| !bundle.extension_points.is_empty()
|| !bundle.route_templates.is_empty()
{
require(
svc.authz.as_ref(),
principal,
@@ -489,6 +513,59 @@ async fn require_group_node_writes(
Ok(())
}
/// Will route-template expansion (§4.5, M4a) write routes into `app_id` during
/// this tree apply? True iff the app's ancestor chain carries a route template,
/// counting both committed templates and ones declared by a group node in THIS
/// bundle. Drives the `AppWriteRoute` gate for expansion recipients.
async fn app_receives_template_expansions(
svc: &ApplyService,
app_id: AppId,
bundle: &TreeBundle,
) -> Result<bool, ApplyError> {
let Some(app) = svc
.apps
.get_by_id(app_id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
else {
return Ok(false);
};
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();
// Committed templates on any ancestor group.
for gid in &ancestor_ids {
if !crate::template_repo::list_for_group(&svc.pool, *gid)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
.is_empty()
{
return Ok(true);
}
}
// 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 && !node.bundle.route_templates.is_empty() {
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) {
return Ok(true);
}
}
}
}
Ok(false)
}
/// Resolve a slug-or-id path param to a `GroupId`, mapping miss → 404.
async fn resolve_group_id(
groups: &dyn GroupRepository,

File diff suppressed because it is too large Load Diff

View File

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

View File

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

@@ -0,0 +1,200 @@
//! 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(())
}

View File

@@ -298,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
@@ -311,6 +327,7 @@ table: routes
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
@@ -531,8 +548,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)
@@ -739,6 +761,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])))
@@ -841,3 +870,4 @@ constraints on vars:
0050: group scripts
0051: extension points
0052: owner project
0053: templates

View File

@@ -1272,6 +1272,7 @@ impl Client {
expected_token: Option<&str>,
project_key: Option<&str>,
allow_takeover: bool,
env: Option<&str>,
) -> Result<ApplyReportDto> {
let body = serde_json::json!({
"bundle": bundle,
@@ -1279,6 +1280,7 @@ impl Client {
"expected_token": expected_token,
"project_key": project_key,
"allow_takeover": allow_takeover,
"env": env,
});
let resp = self
.request(Method::POST, "/api/v1/admin/tree/apply")
@@ -1387,6 +1389,9 @@ pub struct TreePlanDto {
/// 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>,
}
/// A single ownership conflict (`pic plan --dir`).
@@ -1396,6 +1401,13 @@ pub struct OwnershipConflictDto {
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,
@@ -1412,6 +1424,8 @@ pub struct NodePlanDto {
pub vars: Vec<ChangeDto>,
#[serde(default)]
pub extension_points: Vec<ChangeDto>,
#[serde(default)]
pub route_templates: Vec<ChangeDto>,
}
/// Response of `POST .../apply`: counts of what changed.
@@ -1456,6 +1470,12 @@ pub struct ApplyReportDto {
#[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 warnings: Vec<String>,
}

View File

@@ -151,6 +151,7 @@ pub async fn run_tree(
expected_token.as_deref(),
Some(&project_key),
takeover,
env,
)
.await?;
crate::linkstate::clear_plan(dir, token_key);
@@ -216,6 +217,22 @@ pub async fn run_tree(
);
}
}
// 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
),
);
}
for w in &report.warnings {
block.field("warning", w.clone());
}

View File

@@ -143,6 +143,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
secrets: crate::manifest::ManifestSecrets::default(),
vars: std::collections::BTreeMap::new(),
extension_points: Vec::new(),
route_templates: Vec::new(),
}
}

View File

@@ -67,13 +67,14 @@ 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>); 6] = [
let groups: [(&str, &Vec<ChangeDto>); 7] = [
("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),
];
for (rk, changes) in groups {
for c in changes {
@@ -104,6 +105,12 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
eprintln!(" - {slug}");
}
}
if !plan.template_blast_radius.is_empty() {
eprintln!("\nroute-template blast radius (apps IN THIS apply that get expansions):");
for b in &plan.template_blast_radius {
eprintln!(" - {}{} app(s)", b.group, b.affected_apps);
}
}
}
}
@@ -190,6 +197,15 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
})
.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<_>, _>>()?;
Ok(json!({
"scripts": scripts,
"routes": routes,
@@ -197,6 +213,7 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
"secrets": manifest.secrets.names,
"vars": vars,
"extension_points": extension_points,
"route_templates": route_templates,
}))
}

View File

@@ -237,6 +237,8 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
},
vars: manifest_vars,
extension_points,
// `pull` is app-scoped; route templates are group-owned (§4.5, M4a).
route_templates: Vec::new(),
};
std::fs::write(&manifest_path, manifest.to_toml()?)

View File

@@ -53,6 +53,11 @@ pub struct Manifest {
/// 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>,
}
impl Manifest {
@@ -275,6 +280,32 @@ 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,
}
/// Triggers grouped by kind (arrays-of-tables: `[[triggers.cron]]`, …).
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ManifestTriggers {
@@ -511,6 +542,7 @@ mod tests {
name: "theme".into(),
default: Some("default-theme".into()),
}],
route_templates: Vec::new(),
}
}
@@ -615,6 +647,7 @@ mod tests {
secrets: ManifestSecrets::default(),
vars: BTreeMap::new(),
extension_points: Vec::new(),
route_templates: Vec::new(),
};
let text = m.to_toml().unwrap();
assert!(!text.contains("[[scripts]]"), "got:\n{text}");

View File

@@ -41,6 +41,7 @@ mod routes;
mod scripts;
mod secrets;
mod staleness;
mod templates;
mod tree;
mod tree_shape;
mod triggers;

View File

@@ -0,0 +1,218 @@
//! 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"
);
}
#[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}"
);
}
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

@@ -375,11 +375,31 @@ 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: ROUTE templates ✅ shipped (M4a, 2026-06-28).** Migration `0053_templates.sql` adds a
> `route_templates` table (owned by a group) and a `routes.from_template` provenance column. A
> `[group]` manifest declares `[[route_templates]]`; `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` 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). Expansion is **idempotent via `from_template`** (diffed create/update-as-replace/delete, so
> re-apply doesn't churn and a removed/disabled template reaps its routes under `--prune`); an
> expansion colliding with a hand-declared route, 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` (gated even when the app declares no routes
> of its own). Each RESOLVED expansion is validated with the same rules a hand-declared route gets —
> reserved-path rejection, structural path/host parse, and host-claim check — so a `{var:…}`-injected
> reserved path or an unclaimed strict host can't slip in. **Scope:** expansion targets app NODES
> present in the tree apply (the common `pic apply --dir` case), not yet every descendant in another
> repo — deleting a template leaves expansions in out-of-apply descendants until they're re-applied
> (the apply warns); `{var:NAME}` resolves against *committed* vars (a var set in the same apply lands
> next apply). **Trigger templates (M4b) remain** — they reuse this engine over the `triggers` insert
> path (the seven kinds + email-secret sealing).
### 4.6 Secrets & `pull`