feat(hierarchies): extension points — opt-in dynamic module resolution (§5.5)
M1 of the remaining-hierarchies work. An extension point lets a PARENT/group
node opt a module name out of sealed lexical resolution: a parent script's
`import "x"` then resolves against the INHERITING app's module (with a declared
default body as fallback) instead of the parent's sealed chain — so each
descendant app supplies its own `x`. Only the declaring parent can open the
inversion; a descendant can never make a parent's sealed import dynamic or
hijack one (the "is this an extension point" decision keys on the importer's
trusted defining node, never a script-passed value).
Resolver (executor-core):
- `ModuleSource` gains `resolve_extension_point(origin, name)` and a
chain-constrained `resolve_by_id(origin, id)`. The resolve seam checks for an
ext point on the importer's chain first; on a hit it resolves against
`App(cx.app_id)`, else the declared default, else ModuleNotFound. The Postgres
query returns Some only when the NEAREST declaration of the name is an ext
point (a peer/nearer concrete module shadows it). A group origin can't reach
app-owned rows — the trust boundary holds. Cache stays id-keyed (no bleed).
Apply (manager-core, mirrors the `vars` pattern):
- migration 0051: owner-polymorphic `extension_points` table (group XOR app),
optional `default_script_id` FK ON DELETE SET NULL, per-owner LOWER(name)
unique indexes.
- Bundle/Plan/CurrentState gain extension_points; `diff_extension_points`
(name-based, default change = Update), reconcile (upsert after scripts so the
default resolves) + prune, `validate_bundle` (module-name shape; default must
be a local module), `check_imports_resolve` (a declared/on-chain ext point
satisfies an import), and the state_token fold. Writes gate on the
script-write capability (AppWriteScript / GroupScriptsWrite).
- GET /apps|groups/{id}/extension-points so `pic pull` round-trips them — a
re-applied pulled manifest is all-NoOp, so `--prune` can't silently drop one.
CLI: `[[extension_points]]` manifest table; plan/apply build + render; pull
exports them.
Tests: 5 resolver units (inversion, default fallback, no-provider error,
no-hijack of a sealed import, no cross-tenant cache bleed), 2 diff/state-token
units, 2 journeys (per-app resolution + default fallback via invoke; pull
round-trip). Schema golden re-blessed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@ use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::post,
|
||||
routing::{get, post},
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use picloud_shared::{AppId, GroupId, Principal};
|
||||
@@ -17,8 +17,8 @@ use serde_json::json;
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::apply_service::{
|
||||
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, NodeKind, PlanResult,
|
||||
TreeBundle, TreePlanResult,
|
||||
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, ExtPointView,
|
||||
NodeKind, PlanResult, TreeBundle, TreePlanResult,
|
||||
};
|
||||
use crate::authz::{require, AuthzDenied, Capability};
|
||||
use crate::group_repo::GroupRepository;
|
||||
@@ -30,11 +30,53 @@ pub fn apply_router(service: ApplyService) -> Router {
|
||||
.route("/apps/{id}/apply", post(apply_handler))
|
||||
.route("/groups/{id}/plan", post(group_plan_handler))
|
||||
.route("/groups/{id}/apply", post(group_apply_handler))
|
||||
.route("/apps/{id}/extension-points", get(app_ext_points_handler))
|
||||
.route(
|
||||
"/groups/{id}/extension-points",
|
||||
get(group_ext_points_handler),
|
||||
)
|
||||
.route("/tree/plan", post(tree_plan_handler))
|
||||
.route("/tree/apply", post(tree_apply_handler))
|
||||
.with_state(service)
|
||||
}
|
||||
|
||||
/// `GET .../apps/{id}/extension-points` — list the app's OWN extension-point
|
||||
/// declarations (for `pic pull`). Read-only; viewer-tier (`AppRead`).
|
||||
async fn app_ext_points_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<Vec<ExtPointView>>, ApplyError> {
|
||||
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
||||
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
Ok(Json(
|
||||
svc.list_extension_points(ApplyOwner::App(app_id)).await?,
|
||||
))
|
||||
}
|
||||
|
||||
/// `GET .../groups/{id}/extension-points` — list the group's OWN extension-point
|
||||
/// declarations. Read-only; viewer-tier (`GroupScriptsRead`).
|
||||
async fn group_ext_points_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<Vec<ExtPointView>>, ApplyError> {
|
||||
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupScriptsRead(group_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
Ok(Json(
|
||||
svc.list_extension_points(ApplyOwner::Group(group_id))
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ApplyRequest {
|
||||
pub bundle: Bundle,
|
||||
@@ -232,7 +274,11 @@ async fn require_app_node_writes(
|
||||
require(svc.authz.as_ref(), principal, Capability::AppRead(app_id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
if prune || !bundle.scripts.is_empty() {
|
||||
// Extension points are module-resolution declarations, so they gate on the
|
||||
// same script-write tier as modules — without this, an ext-point-only
|
||||
// bundle (no `default`, so no script) would pass with viewer-tier `AppRead`
|
||||
// and let a reader open the §5.5 import trust boundary.
|
||||
if prune || !bundle.scripts.is_empty() || !bundle.extension_points.is_empty() {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
@@ -292,7 +338,8 @@ async fn require_group_node_writes(
|
||||
bundle: &Bundle,
|
||||
prune: bool,
|
||||
) -> Result<(), ApplyError> {
|
||||
if prune || !bundle.scripts.is_empty() {
|
||||
// Extension points gate on the script-write tier (see the app variant).
|
||||
if prune || !bundle.scripts.is_empty() || !bundle.extension_points.is_empty() {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
|
||||
Reference in New Issue
Block a user