diff --git a/crates/manager-core/migrations/0053_templates.sql b/crates/manager-core/migrations/0053_templates.sql new file mode 100644 index 0000000..8251835 --- /dev/null +++ b/crates/manager-core/migrations/0053_templates.sql @@ -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); diff --git a/crates/manager-core/src/app_bootstrap.rs b/crates/manager-core/src/app_bootstrap.rs index 4a2df42..0a2ac5a 100644 --- a/crates/manager-core/src/app_bootstrap.rs +++ b/crates/manager-core/src/app_bootstrap.rs @@ -88,6 +88,7 @@ async fn seed_into( method: None, dispatch_mode: picloud_shared::DispatchMode::Sync, enabled: true, + from_template: None, }) .await?; diff --git a/crates/manager-core/src/apply_api.rs b/crates/manager-core/src/apply_api.rs index 678ee30..2d7610b 100644 --- a/crates/manager-core/src/apply_api.rs +++ b/crates/manager-core/src/apply_api.rs @@ -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, } 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 { + 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 = 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, diff --git a/crates/manager-core/src/apply_service.rs b/crates/manager-core/src/apply_service.rs index 93cb280..82c20e4 100644 --- a/crates/manager-core/src/apply_service.rs +++ b/crates/manager-core/src/apply_service.rs @@ -89,6 +89,22 @@ pub struct Bundle { /// owns the importing script. #[serde(default)] pub extension_points: Vec, + /// Route templates (§4.5, M4a): GROUP-only declarations that fan out into a + /// concrete per-`app_id` route on every descendant app at apply time. Empty + /// (and rejected) on an app node — an app declares concrete `routes`. + #[serde(default)] + pub route_templates: Vec, +} + +/// A route template: a route declared once on a group, expanded into one +/// concrete route per descendant app (§4.5). Same fields as a [`BundleRoute`] +/// plus an explicit `name` (the per-group identity/upsert key). String fields +/// may carry `{app_slug}`/`{env}`/`{var:NAME}` placeholders resolved per app. +#[derive(Debug, Clone, Deserialize)] +pub struct RouteTemplateSpec { + pub name: String, + #[serde(flatten)] + pub route: BundleRoute, } /// Desired extension point — a module name opened for dynamic, per-tenant @@ -344,6 +360,11 @@ pub struct Plan { pub vars: Vec, #[serde(default)] pub extension_points: Vec, + /// Route-template changes on a GROUP node (§4.5, M4a). On an APP node this + /// instead carries the per-app route EXPANSIONS the templates produce + /// (keyed by the resolved route, detail noting the source template). + #[serde(default)] + pub route_templates: Vec, } impl Plan { @@ -357,6 +378,7 @@ impl Plan { .chain(&self.secrets) .chain(&self.vars) .chain(&self.extension_points) + .chain(&self.route_templates) .all(|c| c.op == Op::NoOp) } } @@ -436,6 +458,19 @@ pub struct TreePlanResult { /// what `apply --prune` would delete (leaf-first, RESTRICT-guarded). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub structural_prunes: Vec, + /// Blast radius of route templates (§4.5, M4a): per group declaring + /// templates, how many descendant app nodes in THIS apply will receive + /// expansions. Lets CI gauge the fan-out before applying. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub template_blast_radius: Vec, +} + +/// One group's route-template fan-out within the current apply. +#[derive(Debug, Clone, Serialize)] +pub struct TemplateBlastRadius { + pub group: String, + /// Count of descendant app nodes in this apply that will expand templates. + pub affected_apps: usize, } /// One ownership conflict: a declared group already claimed by another project. @@ -465,6 +500,9 @@ pub struct CurrentState { /// name)`. The default is resolved back to its module name (not the raw /// id) so the diff is name-based and stable across re-applies. pub extension_points: Vec<(String, Option)>, + /// A GROUP owner's OWN route templates (§4.5, M4a), for the name-based diff. + /// Empty for an app owner (apps don't own templates). + pub route_templates: Vec, } // ---------------------------------------------------------------------------- @@ -592,16 +630,30 @@ impl ApplyService { bundle: &Bundle, inherited_endpoints: &HashSet, ) -> Result<(), ApplyError> { - if matches!(owner, ApplyOwner::Group(_)) { - if !bundle.routes.is_empty() { - return Err(ApplyError::Invalid( - "a group manifest cannot declare routes — routes bind to an app".into(), - )); + match owner { + ApplyOwner::Group(_) => { + if !bundle.routes.is_empty() { + return Err(ApplyError::Invalid( + "a group manifest cannot declare routes — routes bind to an app".into(), + )); + } + if !bundle.triggers.is_empty() { + return Err(ApplyError::Invalid( + "a group manifest cannot declare triggers — triggers belong to an app" + .into(), + )); + } + // Templates ARE allowed on a group — validate each (§4.5 M4a). + validate_route_templates(&bundle.route_templates)?; } - if !bundle.triggers.is_empty() { - return Err(ApplyError::Invalid( - "a group manifest cannot declare triggers — triggers belong to an app".into(), - )); + ApplyOwner::App(_) => { + if !bundle.route_templates.is_empty() { + return Err(ApplyError::Invalid( + "an app manifest cannot declare route_templates — templates live on a \ + group and fan out to its descendant apps" + .into(), + )); + } } } self.validate_bundle(bundle, inherited_endpoints) @@ -773,7 +825,8 @@ impl ApplyService { // Routes only exist on app nodes (a group bundle has none, so this // loop is empty for a group — validated above). let app_id = owner.app_id().expect("routes only exist on app nodes"); - insert_bundle_route(&mut *tx, app_id, sid, br).await?; + // Hand-declared route (the app's own manifest) — no template stamp. + insert_bundle_route(&mut *tx, app_id, sid, br, None).await?; if is_update { report.routes_updated += 1; } else { @@ -877,6 +930,38 @@ impl ApplyService { } } + // 3d. Route templates (§4.5, M4a) — GROUP-owned. Reconcile the template + // ROWS here; the per-app route expansion they drive happens later in + // apply_tree Phase B. An app node's `plan.route_templates` carries + // expansions (handled by the expansion engine), never template CRUD, so + // this block is guarded group-only. + if let ApplyOwner::Group(gid) = owner { + let bundle_rt: HashMap = bundle + .route_templates + .iter() + .map(|t| (t.name.to_lowercase(), t)) + .collect(); + for ch in &plan.route_templates { + match ch.op { + Op::Create => { + let spec = bundle_rt[&ch.key.to_lowercase()]; + crate::template_repo::insert_tx(&mut *tx, gid, spec) + .await + .map_err(map_group_err)?; + report.route_templates_created += 1; + } + Op::Update => { + let spec = bundle_rt[&ch.key.to_lowercase()]; + crate::template_repo::update_tx(&mut *tx, gid, spec) + .await + .map_err(map_group_err)?; + report.route_templates_updated += 1; + } + Op::NoOp | Op::Delete => {} + } + } + } + // 4. Prune (only with --prune): delete stale triggers, then scripts // (route removals already happened in the route delete-pass above). // Secret pruning is deliberately deferred (destructive + irreversible): @@ -961,6 +1046,20 @@ impl ApplyService { report.extension_points_deleted += 1; } } + + // Route templates dropped from a group manifest are deleted (their + // expanded routes are reaped by the expansion engine, which sees the + // template gone). Group-only, mirroring the reconcile guard above. + if let ApplyOwner::Group(gid) = owner { + for ch in &plan.route_templates { + if ch.op == Op::Delete { + crate::template_repo::delete_tx(&mut *tx, gid, &ch.key) + .await + .map_err(map_group_err)?; + report.route_templates_deleted += 1; + } + } + } } Ok(name_to_id) } @@ -1129,6 +1228,7 @@ impl ApplyService { state_token: pt.token, conflicts: pt.conflicts, structural_prunes: pt.prune_candidates.into_iter().map(|(_, s)| s).collect(), + template_blast_radius: pt.template_blast_radius, }) } @@ -1150,6 +1250,7 @@ impl ApplyService { expected_token: Option<&str>, project_key: Option<&str>, allow_takeover: bool, + env: Option<&str>, ) -> Result { let actor = principal.user_id; let pt = self.prepare_tree(bundle, project_key).await?; @@ -1306,19 +1407,39 @@ impl ApplyService { let inherited = self .tree_inherited_for_app(&p.app_chain, &p.node.bundle, &group_script_index) .await?; - self.reconcile_node_tx( - &mut tx, - p.owner, - &p.node.bundle, - &p.plan, - &p.current, - &p.current_names, - &inherited, - prune, - actor, - &mut report, - ) - .await?; + let name_to_id = self + .reconcile_node_tx( + &mut tx, + p.owner, + &p.node.bundle, + &p.plan, + &p.current, + &p.current_names, + &inherited, + prune, + actor, + &mut report, + ) + .await?; + // Template expansion (§4.5, M4a): fan ancestor route templates + // out into this app's concrete routes AFTER its own scripts + + // routes are reconciled (so a template can bind the app's own + // script and collide-check against hand-declared routes). + if let ApplyOwner::App(app_id) = p.owner { + let hand_declared_keys = bundle_route_keys(&p.node.bundle); + self.expand_route_templates_tx( + &mut tx, + app_id, + &p.node.slug, + env, + &p.app_chain, + &name_to_id, + &group_script_index, + &hand_declared_keys, + &mut report, + ) + .await?; + } } } @@ -1339,6 +1460,18 @@ impl ApplyService { } } + // §4.5 M4a scope: expansion reaping only runs for app nodes IN this + // apply. A deleted template's expansions in descendant apps NOT in this + // tree survive until those apps are re-applied — warn so the operator + // isn't surprised that a pruned template left live routes elsewhere. + if report.route_templates_deleted > 0 { + report.warnings.push( + "deleted route template(s): expanded routes in descendant apps not included in \ + this apply remain until those apps are re-applied (`pic apply --dir` over them)" + .into(), + ); + } + tx.commit() .await .map_err(|e| ApplyError::Backend(e.to_string()))?; @@ -1766,6 +1899,24 @@ impl ApplyService { } } + // Blast radius (§4.2, M4a): per group node declaring route templates, + // how many app nodes in this apply sit under it (will get expansions). + let mut template_blast_radius: Vec = Vec::new(); + for n in &bundle.nodes { + if n.kind == NodeKind::Group && !n.bundle.route_templates.is_empty() { + if let Some(gid) = group_id_by_slug.get(&n.slug.to_lowercase()).copied() { + let affected_apps = prepared + .iter() + .filter(|p| p.app_chain.contains(&gid)) + .count(); + template_blast_radius.push(TemplateBlastRadius { + group: n.slug.clone(), + affected_apps, + }); + } + } + } + token_parts.sort_unstable(); Ok(PreparedTree { prepared, @@ -1775,6 +1926,7 @@ impl ApplyService { existing_group_nodes, conflicts, prune_candidates, + template_blast_radius, }) } @@ -1848,6 +2000,247 @@ impl ApplyService { Ok(out) } + /// Resolve a template's `script_name` to an id in `app_id`'s context: the + /// app's own (just-reconciled) scripts first, then in-tx group scripts along + /// the chain (nearest-first), then committed inherited scripts (pool). This + /// mirrors the nearest-owner-wins binding the declarative route path uses. + async fn resolve_template_script( + &self, + name: &str, + app_id: AppId, + own_name_to_id: &HashMap, + app_chain: &[GroupId], + group_script_index: &HashMap>, + ) -> Result { + let lc = name.to_lowercase(); + if let Some(id) = own_name_to_id.get(&lc) { + return Ok(*id); + } + for gid in app_chain { + if let Some(id) = group_script_index.get(gid).and_then(|m| m.get(&lc)) { + return Ok(*id); + } + } + match self + .scripts + .get_by_name_inherited(app_id, name) + .await + .map_err(|e| ApplyError::Backend(e.to_string()))? + { + Some(s) => Ok(s.id), + None => Err(ApplyError::Invalid(format!( + "route template binds to script `{name}`, which is not declared on the app or any \ + ancestor group" + ))), + } + } + + /// Expand the route templates visible to one descendant app (§4.5, M4a): + /// fan each chain template out into a concrete per-`app_id` route, stamped + /// with `from_template`. Idempotent — diffs the desired expansions against + /// the app's existing `from_template` rows (create / update-as-replace / + /// delete), so re-apply is a no-op and a removed template reaps its routes. + /// A template expanding to a route the app ALSO declares by hand, or two + /// templates expanding to the same identity, is a hard error. + #[allow(clippy::too_many_arguments, clippy::too_many_lines)] + async fn expand_route_templates_tx( + &self, + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + app_id: AppId, + app_slug: &str, + env: Option<&str>, + app_chain: &[GroupId], + own_name_to_id: &HashMap, + group_script_index: &HashMap>, + hand_declared_keys: &HashSet, + report: &mut ApplyReport, + ) -> Result<(), ApplyError> { + // Visible templates: nearest-wins by name across the ancestor chain. + let templates = crate::template_repo::list_for_groups_tx(tx, app_chain) + .await + .map_err(map_group_err)?; + if templates.is_empty() { + // Still must reap any orphaned expansions (templates all removed). + return self + .reap_orphan_expansions_tx(tx, app_id, &HashMap::new(), report) + .await; + } + let depth: HashMap = + app_chain.iter().enumerate().map(|(i, g)| (*g, i)).collect(); + let mut by_name: HashMap = HashMap::new(); + for t in &templates { + let lc = t.name.to_lowercase(); + let d = depth.get(&t.group_id).copied().unwrap_or(usize::MAX); + let nearer = by_name + .get(&lc) + .is_none_or(|e| depth.get(&e.group_id).copied().unwrap_or(usize::MAX) > d); + if nearer { + by_name.insert(lc, t); + } + } + + // Effective vars for `{var:NAME}` — committed state (a var set in this + // same apply is visible to templates on the NEXT apply, not this one). + let vars = { + let cands = crate::config_resolver::fetch_var_candidates(&self.pool, app_id) + .await + .map_err(|e| ApplyError::Backend(e.to_string()))?; + crate::config_resolver::resolve(cands).0 + }; + + // Build the desired expansion set (identity → resolved route + source). + let mut desired: HashMap = HashMap::new(); + for t in by_name.values() { + if !t.enabled { + continue; // a disabled template produces no route + } + let route = BundleRoute { + script: t.script_name.clone(), + method: opt_resolve(t.method.as_deref(), app_slug, env, &vars)?, + host_kind: t.host_kind, + host: resolve_placeholders(&t.host, app_slug, env, &vars)?, + host_param_name: opt_resolve(t.host_param_name.as_deref(), app_slug, env, &vars)?, + path_kind: t.path_kind, + path: resolve_placeholders(&t.path, app_slug, env, &vars)?, + dispatch_mode: t.dispatch_mode, + enabled: true, + }; + // Validate the RESOLVED route with the same rules a hand-declared + // route gets (reserved path, structural path/host parse, host-claim) + // — placeholders mean these can only be checked post-substitution, + // and an expansion must not write a route the app couldn't declare + // itself (e.g. a `{var:…}`-injected reserved path or an unclaimed + // strict host). + reject_reserved_path(&route.path) + .map_err(|e| ApplyError::Invalid(format!("route template `{}`: {e}", t.name)))?; + pattern::parse_path(route.path_kind, &route.path).map_err(|e| { + ApplyError::Invalid(format!( + "route template `{}` path `{}`: {e}", + t.name, route.path + )) + })?; + pattern::parse_host( + route.host_kind, + &route.host, + route.host_param_name.as_deref(), + ) + .map_err(|e| { + ApplyError::Invalid(format!( + "route template `{}` host `{}`: {e}", + t.name, route.host + )) + })?; + crate::route_admin::validate_route_host_against_app( + self.domains.as_ref(), + app_id, + route.host_kind, + &route.host, + ) + .await + .map_err(|e| { + ApplyError::Invalid(format!("route template `{}` host claim: {e}", t.name)) + })?; + let key = route_key( + route.method.as_deref(), + route.host_kind, + &route.host, + route.path_kind, + &route.path, + ); + if hand_declared_keys.contains(&key) { + return Err(ApplyError::Invalid(format!( + "route template `{}` expands to a route the app `{app_slug}` also declares by \ + hand ({key}); remove one of them", + t.name + ))); + } + if let Some((prev, _, _)) = desired.get(&key) { + if *prev != t.id { + return Err(ApplyError::Invalid(format!( + "two route templates expand to the same route ({key}) on app `{app_slug}`" + ))); + } + } + let sid = self + .resolve_template_script( + &t.script_name, + app_id, + own_name_to_id, + app_chain, + group_script_index, + ) + .await?; + desired.insert(key, (t.id, sid, route)); + } + + self.reap_orphan_expansions_tx(tx, app_id, &desired, report) + .await?; + + // Create or update-as-replace each desired expansion. + let existing = self.load_template_expansions_tx(tx, app_id).await?; + for (key, (tid, sid, route)) in &desired { + match existing.get(key) { + Some(ex) + if ex.script_id == sid.into_inner() + && ex.dispatch_mode == route.dispatch_mode.as_str() + && ex.enabled == route.enabled + && ex.host_param_name == route.host_param_name => + { + // Unchanged — idempotent no-op. + } + Some(ex) => { + delete_route_tx(&mut *tx, ex.id).await.map_err(map_repo)?; + insert_bundle_route(&mut *tx, app_id, *sid, route, Some(*tid)).await?; + report.routes_updated += 1; + } + None => { + insert_bundle_route(&mut *tx, app_id, *sid, route, Some(*tid)).await?; + report.routes_created += 1; + } + } + } + Ok(()) + } + + /// Delete this app's template-expanded routes whose identity is no longer in + /// `desired` (template removed, disabled, or its placeholders now resolve + /// elsewhere). Hand-declared routes (`from_template IS NULL`) are untouched. + async fn reap_orphan_expansions_tx( + &self, + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + app_id: AppId, + desired: &HashMap, + report: &mut ApplyReport, + ) -> Result<(), ApplyError> { + let existing = self.load_template_expansions_tx(tx, app_id).await?; + for (key, ex) in &existing { + if !desired.contains_key(key) { + delete_route_tx(&mut *tx, ex.id).await.map_err(map_repo)?; + report.routes_deleted += 1; + } + } + Ok(()) + } + + /// This app's template-expanded routes (`from_template IS NOT NULL`), keyed + /// by route identity, read in-tx so the expansion diff sees prior writes. + async fn load_template_expansions_tx( + &self, + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + app_id: AppId, + ) -> Result, ApplyError> { + let rows: Vec = sqlx::query_as( + "SELECT id, script_id, host_kind, host, host_param_name, path_kind, path, \ + method, dispatch_mode, enabled \ + FROM routes WHERE app_id = $1 AND from_template IS NOT NULL", + ) + .bind(app_id.into_inner()) + .fetch_all(&mut **tx) + .await + .map_err(|e| ApplyError::Backend(e.to_string()))?; + Ok(rows.into_iter().map(|r| (r.identity(), r)).collect()) + } + async fn resolve_app(&self, ident: &str) -> Result { crate::app_repo::resolve_app(self.apps.as_ref(), ident) .await @@ -2320,10 +2713,11 @@ impl ApplyService { .list_for_app(app_id) .await .map_err(|e| ApplyError::Backend(e.to_string()))?, - self.routes - .list_for_app(app_id) - .await - .map_err(|e| ApplyError::Backend(e.to_string()))?, + // HAND-DECLARED routes only: template expansions (`from_template + // IS NOT NULL`, §4.5 M4) are managed by the expansion engine, not + // the app's own route diff — otherwise an expansion absent from + // the app manifest would diff as a stray Delete and be pruned. + self.hand_declared_routes(app_id).await?, self.triggers .list_for_app(app_id) .await @@ -2356,6 +2750,15 @@ impl ApplyService { .map(|r| (r.key, r.value)) .collect(); let extension_points = self.load_extension_points(owner).await?; + // Route templates are group-owned only (§4.5, M4a). + let route_templates = match owner { + ApplyOwner::Group(group_id) => { + crate::template_repo::list_for_group(&self.pool, group_id) + .await + .map_err(map_group_err)? + } + ApplyOwner::App(_) => Vec::new(), + }; Ok(CurrentState { scripts, routes, @@ -2363,9 +2766,32 @@ impl ApplyService { secret_names, vars, extension_points, + route_templates, }) } + /// An app's HAND-DECLARED routes — its full route list minus template + /// expansions (`from_template IS NOT NULL`, §4.5 M4). The app's own route + /// diff reconciles only these; expansions are the expansion engine's job. + async fn hand_declared_routes(&self, app_id: AppId) -> Result, ApplyError> { + let expansions: Vec<(Uuid,)> = + sqlx::query_as("SELECT id FROM routes WHERE app_id = $1 AND from_template IS NOT NULL") + .bind(app_id.into_inner()) + .fetch_all(&self.pool) + .await + .map_err(|e| ApplyError::Backend(e.to_string()))?; + let expansion_ids: HashSet = expansions.into_iter().map(|(id,)| id).collect(); + let all = self + .routes + .list_for_app(app_id) + .await + .map_err(|e| ApplyError::Backend(e.to_string()))?; + Ok(all + .into_iter() + .filter(|r| !expansion_ids.contains(&r.id)) + .collect()) + } + /// The owner's OWN extension-point declarations, each with its default /// module's NAME (resolved via the FK) so the diff is name-based. Read /// directly off the pool (no repo trait — the pure `compute_diff` tests @@ -2468,9 +2894,248 @@ fn compute_diff_with_names( secrets: diff_secrets(current, bundle), vars: diff_vars(current, bundle), extension_points: diff_extension_points(current, bundle), + // GROUP route-template diff. On an app node both sides are empty (apps + // own no templates); the per-app expansions are appended in prepare_tree. + route_templates: diff_route_templates(current, bundle), } } +/// Diff a group's route templates by name (case-insensitive), value-sensitive: +/// a changed field is an `Update`, a live template absent from the manifest a +/// `Delete` (applied under `--prune`). Mirrors `diff_extension_points`. +fn diff_route_templates(current: &CurrentState, bundle: &Bundle) -> Vec { + let live: HashMap = current + .route_templates + .iter() + .map(|t| (t.name.to_lowercase(), t)) + .collect(); + let desired: HashSet = bundle + .route_templates + .iter() + .map(|t| t.name.to_lowercase()) + .collect(); + + let mut out = Vec::new(); + for t in &bundle.route_templates { + match live.get(&t.name.to_lowercase()) { + Some(cur) if route_template_unchanged(t, cur) => out.push(ResourceChange { + op: Op::NoOp, + key: t.name.clone(), + detail: None, + }), + Some(_) => out.push(ResourceChange { + op: Op::Update, + key: t.name.clone(), + detail: Some("template changed".into()), + }), + None => out.push(ResourceChange { + op: Op::Create, + key: t.name.clone(), + detail: None, + }), + } + } + for cur in ¤t.route_templates { + if !desired.contains(&cur.name.to_lowercase()) { + out.push(ResourceChange { + op: Op::Delete, + key: cur.name.clone(), + detail: None, + }); + } + } + out +} + +/// Validate a group's route templates: each needs a name (unique per group, +/// case-insensitive), a script, a path; placeholder tokens must be from the +/// allowed inert set. Pure — unit-tested. +fn validate_route_templates(templates: &[RouteTemplateSpec]) -> Result<(), ApplyError> { + let mut seen: HashSet = HashSet::new(); + for t in templates { + if t.name.trim().is_empty() { + return Err(ApplyError::Invalid("a route template needs a name".into())); + } + if !seen.insert(t.name.to_lowercase()) { + return Err(ApplyError::Invalid(format!( + "duplicate route template name `{}`", + t.name + ))); + } + let r = &t.route; + if r.script.trim().is_empty() { + return Err(ApplyError::Invalid(format!( + "route template `{}` needs a script", + t.name + ))); + } + if r.path.trim().is_empty() { + return Err(ApplyError::Invalid(format!( + "route template `{}` needs a path", + t.name + ))); + } + scan_placeholders(&r.path, validate_placeholder_token)?; + scan_placeholders(&r.host, validate_placeholder_token)?; + if let Some(m) = &r.method { + scan_placeholders(m, validate_placeholder_token)?; + } + if let Some(hp) = &r.host_param_name { + scan_placeholders(hp, validate_placeholder_token)?; + } + } + Ok(()) +} + +/// Invoke `f` on the inner token of each `{…}` placeholder in `s`. An unbalanced +/// `{` is a hard error (so a typo can't silently pass through to a live route). +fn scan_placeholders( + s: &str, + mut f: impl FnMut(&str) -> Result<(), ApplyError>, +) -> Result<(), ApplyError> { + let mut rest = s; + while let Some(open) = rest.find('{') { + let after = &rest[open + 1..]; + let close = after.find('}').ok_or_else(|| { + ApplyError::Invalid(format!("unterminated `{{` placeholder in `{s}`")) + })?; + f(&after[..close])?; + rest = &after[close + 1..]; + } + Ok(()) +} + +/// The inert placeholder set (§4.5): `{app_slug}`, `{env}`, `{var:NAME}`. +fn validate_placeholder_token(token: &str) -> Result<(), ApplyError> { + if token == "app_slug" || token == "env" { + return Ok(()); + } + if let Some(name) = token.strip_prefix("var:") { + if !name.is_empty() + && name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') + { + return Ok(()); + } + } + Err(ApplyError::Invalid(format!( + "unknown placeholder `{{{token}}}` (allowed: {{app_slug}}, {{env}}, {{var:NAME}})" + ))) +} + +/// Substitute placeholders for one descendant app. `env` is the apply's selected +/// environment (None ⇒ `{env}` is an error); `vars` is the app's effective vars. +fn resolve_placeholders( + s: &str, + app_slug: &str, + env: Option<&str>, + vars: &std::collections::BTreeMap, +) -> Result { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(open) = rest.find('{') { + out.push_str(&rest[..open]); + let after = &rest[open + 1..]; + let close = after.find('}').ok_or_else(|| { + ApplyError::Invalid(format!("unterminated `{{` placeholder in `{s}`")) + })?; + let token = &after[..close]; + let val = if token == "app_slug" { + app_slug.to_string() + } else if token == "env" { + env.ok_or_else(|| { + ApplyError::Invalid( + "a route template uses `{env}` but no environment was selected (pass --env)" + .into(), + ) + })? + .to_string() + } else if let Some(name) = token.strip_prefix("var:") { + match vars.get(name) { + Some(serde_json::Value::String(s)) => s.clone(), + Some(v) => v.to_string(), + None => { + return Err(ApplyError::Invalid(format!( + "route template references `{{var:{name}}}` but app `{app_slug}` has no \ + effective var `{name}`" + ))) + } + } + } else { + return Err(ApplyError::Invalid(format!( + "unknown placeholder `{{{token}}}`" + ))); + }; + out.push_str(&val); + rest = &after[close + 1..]; + } + out.push_str(rest); + Ok(out) +} + +/// `resolve_placeholders` lifted over an optional field. +fn opt_resolve( + s: Option<&str>, + app_slug: &str, + env: Option<&str>, + vars: &std::collections::BTreeMap, +) -> Result, ApplyError> { + match s { + Some(v) => Ok(Some(resolve_placeholders(v, app_slug, env, vars)?)), + None => Ok(None), + } +} + +/// A template-expanded route loaded in-tx for the expansion diff, with its +/// identity-tuple parsed so it keys the same way `route_key` does. +#[derive(sqlx::FromRow)] +struct ExpansionRow { + id: Uuid, + script_id: Uuid, + host_kind: String, + host: String, + host_param_name: Option, + path_kind: String, + path: String, + method: Option, + dispatch_mode: String, + enabled: bool, +} + +impl ExpansionRow { + fn identity(&self) -> String { + let hk = match self.host_kind.as_str() { + "strict" => HostKind::Strict, + "wildcard" => HostKind::Wildcard, + _ => HostKind::Any, + }; + let pk = match self.path_kind.as_str() { + "prefix" => PathKind::Prefix, + "param" => PathKind::Param, + _ => PathKind::Exact, + }; + route_key(self.method.as_deref(), hk, &self.host, pk, &self.path) + } +} + +/// Field-by-field equality between a desired route template and its live row. +fn route_template_unchanged( + spec: &RouteTemplateSpec, + cur: &crate::template_repo::RouteTemplate, +) -> bool { + let r = &spec.route; + r.script.eq_ignore_ascii_case(&cur.script_name) + && r.method == cur.method + && r.host_kind == cur.host_kind + && r.host == cur.host + && r.host_param_name == cur.host_param_name + && r.path_kind == cur.path_kind + && r.path == cur.path + && r.dispatch_mode == cur.dispatch_mode + && r.enabled == cur.enabled +} + /// Diff extension points by name. The "value" is the default module name, so a /// changed default is an `Update` (like vars, the declaration lives in the /// manifest). Live declarations absent from the manifest are `Delete` (applied @@ -3140,6 +3805,24 @@ fn current_trigger_identity(t: &Trigger, name_by_id: &HashMap) } } +/// The route-identity keys of a bundle's HAND-declared routes — the set a +/// template expansion is collision-checked against (§4.5, M4a). +fn bundle_route_keys(bundle: &Bundle) -> HashSet { + bundle + .routes + .iter() + .map(|r| { + route_key( + r.method.as_deref(), + r.host_kind, + &r.host, + r.path_kind, + &r.path, + ) + }) + .collect() +} + fn route_key( method: Option<&str>, host_kind: HostKind, @@ -3373,6 +4056,15 @@ pub struct ApplyReport { /// Owned-but-undeclared groups deleted by structural prune (`--prune`). #[serde(default)] pub groups_pruned: u32, + /// Route-template rows reconciled on group nodes (§4.5, M4a). Per-app route + /// EXPANSIONS those templates produce are counted in `routes_created` / + /// `routes_deleted` (they are concrete routes). + #[serde(default)] + pub route_templates_created: u32, + #[serde(default)] + pub route_templates_updated: u32, + #[serde(default)] + pub route_templates_deleted: u32, #[serde(skip_serializing_if = "Vec::is_empty")] pub warnings: Vec, } @@ -3421,6 +4113,8 @@ struct PreparedTree<'a> { conflicts: Vec, /// Owned-but-undeclared groups: `(id, slug)` — structural-prune candidates. prune_candidates: Vec<(GroupId, String)>, + /// Route-template blast radius per declaring group (§4.5, M4a). + template_blast_radius: Vec, } /// FNV-1a over a set of already-sorted per-resource token parts — the same @@ -3454,6 +4148,7 @@ async fn insert_bundle_route( app_id: AppId, script_id: ScriptId, br: &BundleRoute, + from_template: Option, ) -> Result<(), ApplyError> { let new = NewRoute { app_id, @@ -3466,6 +4161,7 @@ async fn insert_bundle_route( method: br.method.clone(), dispatch_mode: br.dispatch_mode, enabled: br.enabled, + from_template, }; insert_route_tx(tx, &new).await.map_err(map_repo)?; Ok(()) @@ -3683,6 +4379,22 @@ fn state_token_with_names( default.as_deref().unwrap_or("") )); } + // Route templates (group-owned, §4.5 M4a): value-sensitive, so editing a + // template between plan and apply trips StateMoved at the owning group node. + for t in ¤t.route_templates { + parts.push(format!( + "rt|{}|{}|{:?}|{}|{:?}|{}|{:?}|{}|{}", + t.name.to_lowercase(), + t.script_name.to_lowercase(), + t.host_kind, + t.host, + t.path_kind, + t.path, + t.dispatch_mode, + t.method.as_deref().unwrap_or(""), + t.enabled, + )); + } // Order-independent: sort the per-resource tokens before hashing. parts.sort_unstable(); let mut h: u64 = 0xcbf2_9ce4_8422_2325; @@ -3764,6 +4476,7 @@ mod tests { secrets: vec!["S".into()], vars: std::collections::BTreeMap::new(), extension_points: Vec::new(), + route_templates: Vec::new(), }; let plan = compute_diff(¤t, &bundle); assert_eq!(plan.scripts.len(), 1); @@ -3787,6 +4500,7 @@ mod tests { secrets: vec!["S".into()], vars: std::collections::BTreeMap::new(), extension_points: Vec::new(), + route_templates: Vec::new(), }; let plan = compute_diff(¤t, &bundle); assert!(plan.is_noop(), "expected all no-op, got {plan:?}"); @@ -3805,6 +4519,7 @@ mod tests { secrets: vec![], vars: std::collections::BTreeMap::new(), extension_points: Vec::new(), + route_templates: Vec::new(), }; let plan = compute_diff(¤t, &bundle); assert_eq!(plan.scripts[0].op, Op::Update); @@ -3824,6 +4539,7 @@ mod tests { secrets: vec![], vars: std::collections::BTreeMap::new(), extension_points: Vec::new(), + route_templates: Vec::new(), }; let plan = compute_diff(¤t, &bundle); assert_eq!(plan.scripts[0].op, Op::Delete); @@ -3871,6 +4587,7 @@ mod tests { secrets: vec![], vars: std::collections::BTreeMap::new(), extension_points: Vec::new(), + route_templates: Vec::new(), }; let plan = compute_diff(¤t, &bundle); assert_eq!(plan.routes[0].op, Op::Update); @@ -3884,6 +4601,7 @@ mod tests { secrets: vec![], vars: std::collections::BTreeMap::new(), extension_points: Vec::new(), + route_templates: Vec::new(), } } @@ -4214,6 +4932,61 @@ mod tests { assert_eq!(format_conflicts(&[]), ""); } + #[test] + fn resolve_placeholders_substitutes_the_inert_set() { + let mut vars = std::collections::BTreeMap::new(); + vars.insert("region".to_string(), serde_json::json!("eu")); + assert_eq!( + resolve_placeholders( + "/t/{app_slug}/{env}/{var:region}", + "blog", + Some("prod"), + &vars + ) + .unwrap(), + "/t/blog/prod/eu" + ); + // `{env}` with no env selected is an error, not a silent blank. + assert!(resolve_placeholders("/{env}", "blog", None, &vars).is_err()); + // An unknown var is an error (no silent empty expansion). + assert!(resolve_placeholders("/{var:nope}", "blog", Some("prod"), &vars).is_err()); + // An unterminated placeholder is rejected. + assert!(resolve_placeholders("/{app_slug", "blog", None, &vars).is_err()); + } + + #[test] + fn validate_placeholder_token_rejects_unknown() { + assert!(validate_placeholder_token("app_slug").is_ok()); + assert!(validate_placeholder_token("env").is_ok()); + assert!(validate_placeholder_token("var:API_KEY").is_ok()); + assert!(validate_placeholder_token("var:").is_err()); + assert!(validate_placeholder_token("tenant").is_err()); + assert!(validate_placeholder_token("var:bad name").is_err()); + } + + #[test] + fn validate_route_templates_catches_dups_and_bad_placeholders() { + let mk = |name: &str, path: &str| RouteTemplateSpec { + name: name.into(), + route: BundleRoute { + script: "h".into(), + method: None, + host_kind: HostKind::Any, + host: String::new(), + host_param_name: None, + path_kind: PathKind::Exact, + path: path.into(), + dispatch_mode: DispatchMode::Sync, + enabled: true, + }, + }; + assert!(validate_route_templates(&[mk("a", "/x/{app_slug}")]).is_ok()); + // Duplicate name (case-insensitive). + assert!(validate_route_templates(&[mk("a", "/x"), mk("A", "/y")]).is_err()); + // Unknown placeholder. + assert!(validate_route_templates(&[mk("a", "/x/{tenant}")]).is_err()); + } + #[test] fn state_token_ignores_dead_letter_triggers() { // The diff ignores dead-letter triggers (not manifest-representable), diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs index 29108e3..89dfcf3 100644 --- a/crates/manager-core/src/lib.rs +++ b/crates/manager-core/src/lib.rs @@ -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; diff --git a/crates/manager-core/src/route_admin.rs b/crates/manager-core/src/route_admin.rs index b7f36f3..e6b57ab 100644 --- a/crates/manager-core/src/route_admin.rs +++ b/crates/manager-core/src/route_admin.rs @@ -241,6 +241,8 @@ async fn create_route( 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?; diff --git a/crates/manager-core/src/route_repo.rs b/crates/manager-core/src/route_repo.rs index e5ea465..699b56e 100644 --- a/crates/manager-core/src/route_repo.rs +++ b/crates/manager-core/src/route_repo.rs @@ -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, } #[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 { diff --git a/crates/manager-core/src/template_repo.rs b/crates/manager-core/src/template_repo.rs new file mode 100644 index 0000000..f817204 --- /dev/null +++ b/crates/manager-core/src/template_repo.rs @@ -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, + pub host_kind: HostKind, + pub host: String, + pub host_param_name: Option, + 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, + host_kind: String, + host: String, + host_param_name: Option, + path_kind: String, + path: String, + dispatch_mode: String, + enabled: bool, +} + +impl From 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, 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, Error> { + if group_ids.is_empty() { + return Ok(Vec::new()); + } + let ids: Vec = 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(()) +} diff --git a/crates/manager-core/tests/expected_schema.txt b/crates/manager-core/tests/expected_schema.txt index 7462b9d..8ebb7dd 100644 --- a/crates/manager-core/tests/expected_schema.txt +++ b/crates/manager-core/tests/expected_schema.txt @@ -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 diff --git a/crates/picloud-cli/src/client.rs b/crates/picloud-cli/src/client.rs index 648c220..d2dbeaa 100644 --- a/crates/picloud-cli/src/client.rs +++ b/crates/picloud-cli/src/client.rs @@ -1272,6 +1272,7 @@ impl Client { expected_token: Option<&str>, project_key: Option<&str>, allow_takeover: bool, + env: Option<&str>, ) -> Result { 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, + /// Route-template fan-out per declaring group (§4.5, M4a). + #[serde(default)] + pub template_blast_radius: Vec, } /// 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, #[serde(default)] pub extension_points: Vec, + #[serde(default)] + pub route_templates: Vec, } /// 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, } diff --git a/crates/picloud-cli/src/cmds/apply.rs b/crates/picloud-cli/src/cmds/apply.rs index 4587932..a1eb38e 100644 --- a/crates/picloud-cli/src/cmds/apply.rs +++ b/crates/picloud-cli/src/cmds/apply.rs @@ -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()); } diff --git a/crates/picloud-cli/src/cmds/init.rs b/crates/picloud-cli/src/cmds/init.rs index f20753f..ab64648 100644 --- a/crates/picloud-cli/src/cmds/init.rs +++ b/crates/picloud-cli/src/cmds/init.rs @@ -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(), } } diff --git a/crates/picloud-cli/src/cmds/plan.rs b/crates/picloud-cli/src/cmds/plan.rs index 03b2b6d..f534c9c 100644 --- a/crates/picloud-cli/src/cmds/plan.rs +++ b/crates/picloud-cli/src/cmds/plan.rs @@ -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); 6] = [ + let groups: [(&str, &Vec); 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 { }) .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::, _>>()?; + Ok(json!({ "scripts": scripts, "routes": routes, @@ -197,6 +213,7 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result { "secrets": manifest.secrets.names, "vars": vars, "extension_points": extension_points, + "route_templates": route_templates, })) } diff --git a/crates/picloud-cli/src/cmds/pull.rs b/crates/picloud-cli/src/cmds/pull.rs index 2ba4364..dd145ac 100644 --- a/crates/picloud-cli/src/cmds/pull.rs +++ b/crates/picloud-cli/src/cmds/pull.rs @@ -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()?) diff --git a/crates/picloud-cli/src/manifest.rs b/crates/picloud-cli/src/manifest.rs index b362f92..5bba959 100644 --- a/crates/picloud-cli/src/manifest.rs +++ b/crates/picloud-cli/src/manifest.rs @@ -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, + /// `[[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, } 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, + 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, + 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}"); diff --git a/crates/picloud-cli/tests/cli.rs b/crates/picloud-cli/tests/cli.rs index b82636a..8556fc1 100644 --- a/crates/picloud-cli/tests/cli.rs +++ b/crates/picloud-cli/tests/cli.rs @@ -41,6 +41,7 @@ mod routes; mod scripts; mod secrets; mod staleness; +mod templates; mod tree; mod tree_shape; mod triggers; diff --git a/crates/picloud-cli/tests/templates.rs b/crates/picloud-cli/tests/templates.rs new file mode 100644 index 0000000..2811aa9 --- /dev/null +++ b/crates/picloud-cli/tests/templates.rs @@ -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/` and `/t/` 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/` 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") +} diff --git a/docs/design/groups-and-project-tool.md b/docs/design/groups-and-project-tool.md index 5993cb4..5fbdb77 100644 --- a/docs/design/groups-and-project-tool.md +++ b/docs/design/groups-and-project-tool.md @@ -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`