feat(interceptors): hook set_if (CAS) + pic interceptors ls (§9.4 M12)
Closes the §9.4 track:
- set_if (compare-and-swap), per-app and shared, now runs the same (kv, set)
before/after interceptor as set — otherwise CAS was a silent bypass of a set
policy. The before-hook can transform the new value; the after-hook sees the
swapped bool.
- read-only pic interceptors ls --app|--group: app shows the RESOLVED chain
(every marker guarding its writes, nearest-owner-wins), group its own markers.
New apply_service::interceptor_report + InterceptorInfo, /apps|groups/{id}/
interceptors routes (AppRead/GroupScriptsRead), client + cmd mirroring
extension-points.
CLAUDE.md updated: §9.4 service interceptors are now COMPLETE (M1-M12).
Pinned by a journey: set_if of a guarded key is denied while a free key swaps,
and interceptors ls lists the kv/set guard (12 interceptor journeys green).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,8 +18,9 @@ use serde_json::json;
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::apply_service::{
|
||||
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, CollectionInfo,
|
||||
ExtensionPointInfo, NodeKind, OwnershipClaim, PlanResult, ProjectDecl, RouteTemplateInfo,
|
||||
StructureMode, SuppressionInfo, TreeBundle, TreePlanResult, TriggerTemplateInfo,
|
||||
ExtensionPointInfo, InterceptorInfo, NodeKind, OwnershipClaim, PlanResult, ProjectDecl,
|
||||
RouteTemplateInfo, StructureMode, SuppressionInfo, TreeBundle, TreePlanResult,
|
||||
TriggerTemplateInfo,
|
||||
};
|
||||
use crate::authz::{require, AuthzDenied, Capability};
|
||||
use crate::group_repo::GroupRepository;
|
||||
@@ -46,9 +47,45 @@ pub fn apply_router(service: ApplyService) -> Router {
|
||||
.route("/groups/{id}/routes", get(group_routes_handler))
|
||||
.route("/apps/{id}/suppressions", get(app_suppressions_handler))
|
||||
.route("/groups/{id}/suppressions", get(group_suppressions_handler))
|
||||
.route("/apps/{id}/interceptors", get(app_interceptors_handler))
|
||||
.route("/groups/{id}/interceptors", get(group_interceptors_handler))
|
||||
.with_state(service)
|
||||
}
|
||||
|
||||
/// Read-only §9.4 interceptor report for an app: the RESOLVED chain view (every
|
||||
/// marker guarding its writes, nearest-owner-wins). Viewer-tier `AppRead`. Backs
|
||||
/// `pic interceptors ls --app`.
|
||||
async fn app_interceptors_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<Vec<InterceptorInfo>>, ApplyError> {
|
||||
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
||||
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
let report = svc.interceptor_report(ApplyOwner::App(app_id)).await?;
|
||||
Ok(Json(report))
|
||||
}
|
||||
|
||||
/// Read-only §9.4 interceptor report for a group: its OWN declared markers.
|
||||
async fn group_interceptors_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<Vec<InterceptorInfo>>, ApplyError> {
|
||||
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupScriptsRead(group_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
let report = svc.interceptor_report(ApplyOwner::Group(group_id)).await?;
|
||||
Ok(Json(report))
|
||||
}
|
||||
|
||||
/// Read-only §11 tail suppression report for an app: the inherited templates it
|
||||
/// declines (`target_kind`, `reference`). Viewer-tier `AppRead`. Backs
|
||||
/// `pic suppress ls --app`.
|
||||
|
||||
@@ -854,6 +854,19 @@ pub struct CollectionInfo {
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
/// One row of the read-only §9.4 interceptor report (`pic interceptors ls`).
|
||||
/// For an app it is the RESOLVED chain view (markers visible on its ancestor
|
||||
/// chain, nearest-owner-wins); for a group it is the group's OWN declarations.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InterceptorInfo {
|
||||
pub service: String,
|
||||
pub op: String,
|
||||
pub phase: String,
|
||||
pub script: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub timeout_ms: Option<i32>,
|
||||
}
|
||||
|
||||
/// One row of the read-only §11 tail suppression report (`pic suppress ls
|
||||
/// --app`). `target_kind` is `trigger` | `route`; `reference` is the handler
|
||||
/// script name (trigger) or path (route) the app declines.
|
||||
@@ -3671,6 +3684,32 @@ impl ApplyService {
|
||||
/// `inherited default` / `null` = unset). For a **group**: its own declared
|
||||
/// names (no single app to resolve a provider against). Backs the read-only
|
||||
/// `extension-points ls` and the `pull` round-trip.
|
||||
/// §9.4 read-only interceptor report. For an app: the RESOLVED chain view
|
||||
/// (every marker visible on its ancestor chain, nearest-owner-wins) — what
|
||||
/// actually guards its writes. For a group: its OWN declared markers.
|
||||
pub async fn interceptor_report(
|
||||
&self,
|
||||
owner: ApplyOwner,
|
||||
) -> Result<Vec<InterceptorInfo>, ApplyError> {
|
||||
let markers = match owner {
|
||||
ApplyOwner::App(a) => crate::interceptor_repo::list_on_app_chain(&self.pool, a).await,
|
||||
ApplyOwner::Group(g) => {
|
||||
crate::interceptor_repo::list_for_owner(&self.pool, ScriptOwner::Group(g)).await
|
||||
}
|
||||
}
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
Ok(markers
|
||||
.into_iter()
|
||||
.map(|m| InterceptorInfo {
|
||||
service: m.service,
|
||||
op: m.op,
|
||||
phase: m.phase,
|
||||
script: m.script,
|
||||
timeout_ms: m.timeout_ms,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn extension_point_report(
|
||||
&self,
|
||||
owner: ApplyOwner,
|
||||
|
||||
Reference in New Issue
Block a user