feat(interceptors): §9.4 service interceptors — thin KV allow/deny slice
Smallest honest vertical slice of §9.4: a `[[interceptors]]` block (app OR
group) binds a script to run BEFORE `kv::set`/`delete`; it reads the operation
context (`ctx.request.body`: service, action, collection, key, value, caller
ids) and returns `#{ allowed, reason }` — `allowed == false` denies the op (the
write never runs, the caller gets a runtime error).
Reuses two existing mechanisms rather than inventing new ones:
- Registration mirrors extension points (§5.5): a marker table
`0073_interceptors.sql` (owner-polymorphic app_id/group_id XOR, keyed
(service, op) → script), `interceptor_repo` (insert/delete/list + the
nearest-owner-wins `resolve_before` chain walk), reconciled through the
declarative apply exactly like `vars` (create/update/delete, prunable).
- Execution reuses the `invoke()` re-entry path: the new `InterceptorService`
(shared trait + Postgres-backed impl) only RESOLVES the script name (keeping
executor-core Postgres-free); the executor's `sdk::interceptor::run_before`
resolves that name and runs it via `run_resolved_blocking` (extracted from
`invoke_blocking` — shared depth bound + AST cache). An un-hooked write pays
one indexed `Ok(None)` resolve; no interceptor ⇒ zero overhead.
Nearest-owner-wins so an app overrides a group's interceptor, and a group
interceptor is inherited by every descendant app — the chain walk is the
isolation boundary (a sibling subtree never matches). `validate_bundle_for`
restricts the MVP to `service = "kv"`, `op ∈ {set, delete}`, one marker per
(service, op).
Deferred (documented in §9.4): the `data` transform return, services other
than kv, `after_*` hooks, chaining + circular-dependency guard, the timeout
policy, and a `pic interceptors ls` read surface (needs a server route).
Pinned by `tests/interceptors.rs` (deny blocks the write; allow passes;
group→app inheritance), schema snapshot re-blessed. 154/154 journeys pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1738,6 +1738,8 @@ pub struct PlanDto {
|
||||
pub suppressions: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub workflows: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub interceptors: Vec<ChangeDto>,
|
||||
/// Fingerprint of the live state this plan was computed against; carried
|
||||
/// in `.picloud/` and replayed to `apply` for the bound-plan check.
|
||||
#[serde(default)]
|
||||
@@ -1815,6 +1817,8 @@ pub struct NodePlanDto {
|
||||
pub suppressions: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub workflows: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub interceptors: Vec<ChangeDto>,
|
||||
/// §7 M3: this node's ownership outcome under the tree's `[project]`.
|
||||
#[serde(default)]
|
||||
pub ownership: Option<OwnershipPreviewDto>,
|
||||
@@ -1880,6 +1884,12 @@ pub struct ApplyReportDto {
|
||||
#[serde(default)]
|
||||
pub workflows_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub interceptors_created: u32,
|
||||
#[serde(default)]
|
||||
pub interceptors_updated: u32,
|
||||
#[serde(default)]
|
||||
pub interceptors_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -155,6 +155,15 @@ pub async fn run(
|
||||
"+{} ~{} -{}",
|
||||
report.workflows_created, report.workflows_updated, report.workflows_deleted
|
||||
),
|
||||
)
|
||||
.field(
|
||||
"interceptors",
|
||||
format!(
|
||||
"+{} ~{} -{}",
|
||||
report.interceptors_created,
|
||||
report.interceptors_updated,
|
||||
report.interceptors_deleted
|
||||
),
|
||||
);
|
||||
for w in &report.warnings {
|
||||
block.field("warning", w.clone());
|
||||
@@ -283,6 +292,15 @@ pub async fn run_tree(
|
||||
"+{} ~{} -{}",
|
||||
report.workflows_created, report.workflows_updated, report.workflows_deleted
|
||||
),
|
||||
)
|
||||
.field(
|
||||
"interceptors",
|
||||
format!(
|
||||
"+{} ~{} -{}",
|
||||
report.interceptors_created,
|
||||
report.interceptors_updated,
|
||||
report.interceptors_deleted
|
||||
),
|
||||
);
|
||||
for w in &report.warnings {
|
||||
block.field("warning", w.clone());
|
||||
|
||||
@@ -144,6 +144,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
|
||||
vars: std::collections::BTreeMap::new(),
|
||||
suppress: crate::manifest::ManifestSuppress::default(),
|
||||
workflows: Vec::new(),
|
||||
interceptors: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
|
||||
]);
|
||||
}
|
||||
}
|
||||
let groups: [(&str, &Vec<ChangeDto>); 9] = [
|
||||
let groups: [(&str, &Vec<ChangeDto>); 10] = [
|
||||
("script", &n.scripts),
|
||||
("route", &n.routes),
|
||||
("trigger", &n.triggers),
|
||||
@@ -116,6 +116,7 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
|
||||
("collection", &n.collections),
|
||||
("suppression", &n.suppressions),
|
||||
("workflow", &n.workflows),
|
||||
("interceptor", &n.interceptors),
|
||||
];
|
||||
for (rk, changes) in groups {
|
||||
for c in changes {
|
||||
@@ -252,6 +253,17 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
|
||||
.iter()
|
||||
.map(workflow_to_wire)
|
||||
.collect::<Result<Vec<_>>>()?,
|
||||
// §9.4: expand each `[[interceptors]]` entry's `ops` into one wire
|
||||
// marker per (service, op).
|
||||
"interceptors": manifest
|
||||
.interceptors
|
||||
.iter()
|
||||
.flat_map(|i| {
|
||||
i.ops.iter().map(move |op| {
|
||||
json!({ "service": i.service, "op": op, "script": i.script })
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -295,7 +307,7 @@ fn render(plan: &PlanDto, mode: OutputMode) {
|
||||
]);
|
||||
}
|
||||
}
|
||||
let groups: [(&str, &Vec<ChangeDto>); 9] = [
|
||||
let groups: [(&str, &Vec<ChangeDto>); 10] = [
|
||||
("script", &plan.scripts),
|
||||
("route", &plan.routes),
|
||||
("trigger", &plan.triggers),
|
||||
@@ -305,6 +317,7 @@ fn render(plan: &PlanDto, mode: OutputMode) {
|
||||
("collection", &plan.collections),
|
||||
("suppression", &plan.suppressions),
|
||||
("workflow", &plan.workflows),
|
||||
("interceptor", &plan.interceptors),
|
||||
];
|
||||
for (kind, changes) in groups {
|
||||
for c in changes {
|
||||
|
||||
@@ -268,6 +268,9 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
vars: manifest_vars,
|
||||
suppress: crate::manifest::ManifestSuppress::default(),
|
||||
workflows: workflows.iter().map(wire_workflow_to_manifest).collect(),
|
||||
// `pull` does not round-trip interceptor markers yet (read surface TBD);
|
||||
// an interceptor authored in the manifest survives re-apply regardless.
|
||||
interceptors: Vec::new(),
|
||||
};
|
||||
|
||||
std::fs::write(&manifest_path, manifest.to_toml()?)
|
||||
|
||||
@@ -64,6 +64,29 @@ pub struct Manifest {
|
||||
/// App-owned; a `[group]` carrying them is rejected server-side.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub workflows: Vec<ManifestWorkflow>,
|
||||
/// `[[interceptors]]` (§9.4) — before-op allow/deny hooks. Each names a
|
||||
/// `script` and the `ops` it guards on a `service` (MVP: `service = "kv"`,
|
||||
/// `ops ⊆ [set, delete]`). Authored on an app OR group node.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub interceptors: Vec<ManifestInterceptor>,
|
||||
}
|
||||
|
||||
/// One `[[interceptors]]` entry — an interceptor script and the operations it
|
||||
/// guards. `ops` expands to one wire marker per `(service, op)` at apply.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ManifestInterceptor {
|
||||
/// Name of the interceptor script (resolved on the node's chain at run time).
|
||||
pub script: String,
|
||||
/// The guarded service. MVP: only `"kv"`.
|
||||
#[serde(default = "default_interceptor_service")]
|
||||
pub service: String,
|
||||
/// The guarded operations, e.g. `["set", "delete"]`.
|
||||
pub ops: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_interceptor_service() -> String {
|
||||
"kv".to_string()
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
@@ -783,6 +806,7 @@ mod tests {
|
||||
]),
|
||||
suppress: ManifestSuppress::default(),
|
||||
workflows: Vec::new(),
|
||||
interceptors: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -890,6 +914,7 @@ mod tests {
|
||||
vars: BTreeMap::new(),
|
||||
suppress: ManifestSuppress::default(),
|
||||
workflows: Vec::new(),
|
||||
interceptors: Vec::new(),
|
||||
};
|
||||
let text = m.to_toml().unwrap();
|
||||
assert!(!text.contains("[[scripts]]"), "got:\n{text}");
|
||||
|
||||
Reference in New Issue
Block a user