Skip to main content

solid_pod_rs/
provision.rs

1//! Pod provisioning — seeded containers, WebID + account scaffolding,
2//! admin override, quota enforcement.
3//!
4//! The provisioning surface is intentionally declarative: callers
5//! describe what the pod should look like (containers, ACLs, a WebID
6//! profile document) and the module wires them into a `Storage`
7//! backend. Admin-mode callers bypass ownership checks.
8//!
9//! Parity note (rows 14/164/166, JSS #301 + #297): provisioning also
10//! drops `settings/publicTypeIndex.jsonld` (typed
11//! `solid:TypeIndex + solid:ListedDocument`),
12//! `settings/privateTypeIndex.jsonld` (typed
13//! `solid:TypeIndex + solid:UnlistedDocument`) and a public-read ACL
14//! carve-out `settings/publicTypeIndex.jsonld.acl` so Solid clients
15//! can discover a freshly bootstrapped pod's public profile without
16//! fighting the default-private `/settings/.acl`.
17//!
18//! ## Git auto-init (alpha.12, rows 199-200, JSS #466/#469/#471)
19//!
20//! [`GitInitHook`] is a wasm32-safe async trait. Callers that want
21//! `git init -b main` to run after the pod files are written pass an
22//! implementor (e.g. `solid_pod_rs_git::init::GitAutoInit`) to
23//! [`provision_pod_ext`]. The trait itself lives in this crate so the
24//! type is stable; the subprocess implementation lives in
25//! `solid-pod-rs-git`, which requires `tokio::process` and is never
26//! compiled for `wasm32-unknown-unknown`.
27
28use bytes::Bytes;
29use serde::{Deserialize, Serialize};
30
31use crate::error::PodError;
32use crate::ldp::is_container;
33use crate::storage::Storage;
34use crate::wac::{serialize_turtle_acl, AclAuthorization, AclDocument, IdOrIds, IdRef};
35use crate::webid::generate_webid_html;
36
37/// Seed plan applied to a fresh pod.
38#[derive(Debug, Clone, Deserialize, Serialize)]
39pub struct ProvisionPlan {
40    /// Pubkey (hex) that owns the pod.
41    pub pubkey: String,
42    /// Optional display name for the WebID profile.
43    #[serde(default)]
44    pub display_name: Option<String>,
45    /// Public pod base URL (used to render the WebID).
46    pub pod_base: String,
47    /// Containers to create (paths must end with `/`).
48    #[serde(default)]
49    pub containers: Vec<String>,
50    /// ACL document to drop at the pod root.
51    #[serde(default)]
52    pub root_acl: Option<AclDocument>,
53    /// Bytes quota. `None` means unlimited (but a real consumer crate
54    /// is strongly encouraged to set one).
55    #[serde(default)]
56    pub quota_bytes: Option<u64>,
57    /// **JSS v0.0.190 Phase 1 port (issue #437) — scaffolded only.**
58    ///
59    /// When `true`, the multi-user provisioning path is expected to
60    /// generate a BIP-340 Schnorr secp256k1 keypair, NIP-19 bech32
61    /// encode it, write `/private/privkey.jsonld` (owner-only WAC),
62    /// and seed the WebID `nostr:pubkey` triple. Mirrors the JSS
63    /// `POST /.pods {provisionKeys: true}` body field.
64    ///
65    /// **Not wired yet.** Setting this flag today is a no-op: the
66    /// invoking code path lives in `solid_pod_rs_idp::key_provisioning`
67    /// and its body is `todo!()`. Parity row 196.
68    #[cfg(feature = "provision-keys")]
69    #[serde(default)]
70    pub provision_keys: bool,
71}
72
73impl ProvisionPlan {
74    /// Create a pod-provisioning plan with JSS-compatible defaults.
75    ///
76    /// This constructor initializes cfg-gated fields inside the crate
77    /// that owns them, so downstream crates do not need to mirror this
78    /// crate's feature flags when building a plan.
79    pub fn new(pubkey: impl Into<String>, pod_base: impl Into<String>) -> Self {
80        Self {
81            pubkey: pubkey.into(),
82            display_name: None,
83            pod_base: pod_base.into(),
84            containers: Vec::new(),
85            root_acl: None,
86            quota_bytes: None,
87            #[cfg(feature = "provision-keys")]
88            provision_keys: false,
89        }
90    }
91}
92
93/// Result of provisioning a pod.
94#[derive(Debug, Clone)]
95pub struct ProvisionOutcome {
96    pub webid: String,
97    pub pod_root: String,
98    pub containers_created: Vec<String>,
99    pub quota_bytes: Option<u64>,
100    /// Storage path of the public type-index resource
101    /// (`/settings/publicTypeIndex.jsonld`).
102    pub public_type_index: String,
103    /// Storage path of the private type-index resource
104    /// (`/settings/privateTypeIndex.jsonld`).
105    pub private_type_index: String,
106    /// Storage path of the ACL carve-out that grants public read on
107    /// the public type index (`/settings/publicTypeIndex.jsonld.acl`).
108    pub public_type_index_acl: String,
109}
110
111// ---------------------------------------------------------------------------
112// Type-index bootstrap helpers
113// ---------------------------------------------------------------------------
114
115/// Storage path of the public type-index document.
116pub const PUBLIC_TYPE_INDEX_PATH: &str = "/settings/publicTypeIndex.jsonld";
117
118/// Storage path of the private type-index document.
119pub const PRIVATE_TYPE_INDEX_PATH: &str = "/settings/privateTypeIndex.jsonld";
120
121/// Storage path of the sibling ACL for the public type-index document.
122pub const PUBLIC_TYPE_INDEX_ACL_PATH: &str = "/settings/publicTypeIndex.jsonld.acl";
123
124/// Render the JSON-LD body for a type-index document.
125///
126/// JSS writes the body literally (commit 54e4433, #301) with:
127/// - `@context` binding the `solid` namespace,
128/// - `@id` as the empty string (relative self-reference),
129/// - `@type` listing `solid:TypeIndex` plus either
130///   `solid:ListedDocument` (public) or `solid:UnlistedDocument`
131///   (private).
132fn render_type_index_body(visibility_marker: &str) -> String {
133    let body = serde_json::json!({
134        "@context": { "solid": "http://www.w3.org/ns/solid/terms#" },
135        "@id": "",
136        "@type": ["solid:TypeIndex", visibility_marker],
137    });
138    // Pretty-printed for human-readability on disk; clients parse either way.
139    serde_json::to_string_pretty(&body).expect("static type-index JSON always serialises")
140}
141
142/// Build the ACL document for `publicTypeIndex.jsonld` that grants:
143/// - the pod owner (`WebID`) `acl:Read`, `acl:Write`, `acl:Control`,
144/// - the public (`foaf:Agent`) `acl:Read` only.
145///
146/// The ACL sits on the resource itself (not the parent container), so
147/// it overrides the default-private `/settings/.acl`.
148fn build_public_type_index_acl(webid: &str, resource_path: &str) -> AclDocument {
149    let owner = AclAuthorization {
150        id: Some("#owner".into()),
151        r#type: Some("acl:Authorization".into()),
152        agent: Some(IdOrIds::Single(IdRef { id: webid.into() })),
153        agent_class: None,
154        agent_group: None,
155        origin: None,
156        access_to: Some(IdOrIds::Single(IdRef {
157            id: resource_path.into(),
158        })),
159        default: None,
160        mode: Some(IdOrIds::Multiple(vec![
161            IdRef {
162                id: "acl:Read".into(),
163            },
164            IdRef {
165                id: "acl:Write".into(),
166            },
167            IdRef {
168                id: "acl:Control".into(),
169            },
170        ])),
171        condition: None,
172    };
173    let public = AclAuthorization {
174        id: Some("#public".into()),
175        r#type: Some("acl:Authorization".into()),
176        agent: None,
177        agent_class: Some(IdOrIds::Single(IdRef {
178            id: "foaf:Agent".into(),
179        })),
180        agent_group: None,
181        origin: None,
182        access_to: Some(IdOrIds::Single(IdRef {
183            id: resource_path.into(),
184        })),
185        default: None,
186        mode: Some(IdOrIds::Single(IdRef {
187            id: "acl:Read".into(),
188        })),
189        condition: None,
190    };
191    AclDocument {
192        context: None,
193        graph: Some(vec![owner, public]),
194        inherited: false,
195    }
196}
197
198/// Seed a pod on the provided storage.
199///
200/// * Creates every container in `plan.containers` (idempotent — the
201///   function treats `AlreadyExists` as success).
202/// * Writes a WebID profile HTML at `<pod_base>/pods/<pubkey>/profile/card`.
203/// * Writes a root ACL document if `plan.root_acl` is supplied.
204pub async fn provision_pod<S: Storage + ?Sized>(
205    storage: &S,
206    plan: &ProvisionPlan,
207) -> Result<ProvisionOutcome, PodError> {
208    let pod_root = format!(
209        "{}/pods/{}/",
210        plan.pod_base.trim_end_matches('/'),
211        plan.pubkey
212    );
213    let webid = format!("{pod_root}profile/card#me");
214
215    // Ensure the pod root + default containers exist.
216    let mut all_containers: Vec<String> = plan.containers.to_vec();
217    all_containers.push("/".into());
218    all_containers.push("/profile/".into());
219    all_containers.push("/settings/".into());
220    // Deduplicate.
221    all_containers.sort();
222    all_containers.dedup();
223
224    let mut created = Vec::new();
225    for c in &all_containers {
226        if !is_container(c) {
227            return Err(PodError::InvalidPath(format!("not a container: {c}")));
228        }
229        // Create the `.meta` sidecar — this is the idiomatic way to
230        // materialise a bare container without a body.
231        let meta_key = format!("{}.meta", c.trim_end_matches('/'));
232        match storage
233            .put(&meta_key, Bytes::from_static(b"{}"), "application/ld+json")
234            .await
235        {
236            Ok(_) => created.push(c.clone()),
237            Err(PodError::AlreadyExists(_)) => {}
238            Err(e) => return Err(e),
239        }
240    }
241
242    // Write WebID profile.
243    let webid_html =
244        generate_webid_html(&plan.pubkey, plan.display_name.as_deref(), &plan.pod_base);
245    storage
246        .put(
247            "/profile/card",
248            Bytes::from(webid_html.into_bytes()),
249            "text/html",
250        )
251        .await?;
252
253    // Write root ACL if supplied.
254    if let Some(acl) = &plan.root_acl {
255        let body = serde_json::to_vec(acl)?;
256        storage
257            .put("/.acl", Bytes::from(body), "application/ld+json")
258            .await?;
259    }
260
261    // -------------------------------------------------------------------
262    // Type-index bootstrap (rows 14/164/166 — JSS #301 + #297).
263    // The two `*.jsonld` bodies differ only in the visibility marker.
264    // The public one gets a sibling ACL granting `foaf:Agent` read and
265    // the owner full control; the private one inherits the default
266    // (owner-only) ACL from `/settings/.acl`, so we deliberately do
267    // *not* emit a sibling for it.
268    // -------------------------------------------------------------------
269    let public_body = render_type_index_body("solid:ListedDocument");
270    storage
271        .put(
272            PUBLIC_TYPE_INDEX_PATH,
273            Bytes::from(public_body.into_bytes()),
274            "application/ld+json",
275        )
276        .await?;
277
278    let private_body = render_type_index_body("solid:UnlistedDocument");
279    storage
280        .put(
281            PRIVATE_TYPE_INDEX_PATH,
282            Bytes::from(private_body.into_bytes()),
283            "application/ld+json",
284        )
285        .await?;
286
287    // Use an absolute resource IRI so the Turtle serialiser wraps the
288    // target in `<>` (otherwise a `.` inside the path — e.g. in
289    // `.jsonld` — trips the statement splitter on round-trip).
290    let public_acl_resource_iri = format!(
291        "{}{}",
292        pod_root.trim_end_matches('/'),
293        PUBLIC_TYPE_INDEX_PATH,
294    );
295    let public_acl_doc = build_public_type_index_acl(&webid, &public_acl_resource_iri);
296    let public_acl_ttl = serialize_turtle_acl(&public_acl_doc);
297    storage
298        .put(
299            PUBLIC_TYPE_INDEX_ACL_PATH,
300            Bytes::from(public_acl_ttl.into_bytes()),
301            "text/turtle",
302        )
303        .await?;
304
305    Ok(ProvisionOutcome {
306        webid,
307        pod_root,
308        containers_created: created,
309        quota_bytes: plan.quota_bytes,
310        public_type_index: PUBLIC_TYPE_INDEX_PATH.to_string(),
311        private_type_index: PRIVATE_TYPE_INDEX_PATH.to_string(),
312        public_type_index_acl: PUBLIC_TYPE_INDEX_ACL_PATH.to_string(),
313    })
314}
315
316// ---------------------------------------------------------------------------
317// Git auto-init hook (alpha.12, rows 199-200, JSS #466/#469/#471)
318// ---------------------------------------------------------------------------
319
320/// Async hook called once after pod files are written to initialise the pod
321/// directory as a git repository.
322///
323/// The trait is **wasm32-safe** — it carries no subprocess dependency.
324/// The concrete implementation (`solid_pod_rs_git::init::GitAutoInit`)
325/// spawns `git init -b main` via `tokio::process::Command` and lives in
326/// a separate crate that is never compiled for `wasm32-unknown-unknown`.
327///
328/// Pass an implementor to [`provision_pod_ext`] to opt in.
329/// Feature-gated: only present when the `git-auto-init` Cargo feature
330/// is enabled on `solid-pod-rs`.
331#[cfg(feature = "git-auto-init")]
332#[async_trait::async_trait]
333pub trait GitInitHook: Send + Sync {
334    /// Called with the absolute filesystem path of the freshly provisioned
335    /// pod directory. Errors are **logged and swallowed** — a git-init
336    /// failure must not roll back or prevent pod creation.
337    async fn try_init_repo(&self, fs_pod_root: &std::path::Path) -> Result<(), PodError>;
338}
339
340/// Like [`provision_pod`], but accepts an optional git-init hook that runs
341/// after all pod files are written.
342///
343/// `git_hook` is `Option<(&H, &Path)>` where the `Path` is the absolute
344/// filesystem root of the pod directory (e.g.
345/// `/var/lib/pods/<pubkey>/`). Required because `provision_pod` writes
346/// through an abstract `Storage` and does not itself know the on-disk
347/// path. Callers using `fs-backend` construct this path from the same
348/// root they passed to `FsBackend::new`.
349///
350/// When `git_hook` is `None` this is identical to `provision_pod`.
351#[cfg(feature = "git-auto-init")]
352pub async fn provision_pod_ext<S, H>(
353    storage: &S,
354    plan: &ProvisionPlan,
355    git_hook: Option<(&H, &std::path::Path)>,
356) -> Result<ProvisionOutcome, PodError>
357where
358    S: Storage + ?Sized,
359    H: GitInitHook,
360{
361    let outcome = provision_pod(storage, plan).await?;
362
363    if let Some((hook, fs_pod_root)) = git_hook {
364        if let Err(e) = hook.try_init_repo(fs_pod_root).await {
365            tracing::warn!(
366                target: "solid_pod_rs::provision",
367                pubkey = %plan.pubkey,
368                path = %fs_pod_root.display(),
369                error = %e,
370                "git auto-init failed (pod created, git skipped)",
371            );
372        }
373    }
374
375    Ok(outcome)
376}
377
378// ---------------------------------------------------------------------------
379// Quota enforcement
380// ---------------------------------------------------------------------------
381
382/// Tracks per-pod byte usage against a configurable quota.
383#[derive(Debug, Clone)]
384pub struct QuotaTracker {
385    quota_bytes: Option<u64>,
386    used_bytes: std::sync::Arc<std::sync::atomic::AtomicU64>,
387}
388
389impl QuotaTracker {
390    pub fn new(quota_bytes: Option<u64>) -> Self {
391        Self {
392            quota_bytes,
393            used_bytes: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
394        }
395    }
396
397    pub fn with_initial_used(quota_bytes: Option<u64>, used: u64) -> Self {
398        Self {
399            quota_bytes,
400            used_bytes: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(used)),
401        }
402    }
403
404    /// Bytes currently accounted for.
405    pub fn used(&self) -> u64 {
406        self.used_bytes.load(std::sync::atomic::Ordering::Relaxed)
407    }
408
409    /// Configured quota, if any.
410    pub fn quota(&self) -> Option<u64> {
411        self.quota_bytes
412    }
413
414    /// Reserve `size` bytes. Returns `Err(PodError::PreconditionFailed)`
415    /// when the operation would exceed the quota, without mutating the
416    /// tracker.
417    pub fn reserve(&self, size: u64) -> Result<(), PodError> {
418        if let Some(q) = self.quota_bytes {
419            let cur = self.used();
420            if cur.saturating_add(size) > q {
421                return Err(PodError::PreconditionFailed(format!(
422                    "quota exceeded: {cur}+{size} > {q}"
423                )));
424            }
425        }
426        self.used_bytes
427            .fetch_add(size, std::sync::atomic::Ordering::Relaxed);
428        Ok(())
429    }
430
431    /// Release `size` bytes previously reserved (e.g. on DELETE).
432    pub fn release(&self, size: u64) {
433        self.used_bytes
434            .fetch_sub(size, std::sync::atomic::Ordering::Relaxed);
435    }
436}
437
438// ---------------------------------------------------------------------------
439// Admin override
440// ---------------------------------------------------------------------------
441
442/// A verified admin-override marker. The consumer crate constructs this
443/// only after validating a shared-secret header against configuration;
444/// the marker carries no data beyond its own existence.
445#[derive(Debug, Clone, Copy)]
446pub struct AdminOverride;
447
448/// Match an admin-secret header value against the configured secret.
449/// Both sides are compared with constant-time equality to avoid
450/// timing leaks. Returns `Some(AdminOverride)` on match.
451pub fn check_admin_override(
452    header: Option<&str>,
453    configured: Option<&str>,
454) -> Option<AdminOverride> {
455    let header = header?;
456    let configured = configured?;
457    if header.len() != configured.len() {
458        return None;
459    }
460    let mut acc = 0u8;
461    for (a, b) in header.bytes().zip(configured.bytes()) {
462        acc |= a ^ b;
463    }
464    if acc == 0 {
465        Some(AdminOverride)
466    } else {
467        None
468    }
469}
470
471// ---------------------------------------------------------------------------
472// Tests
473// ---------------------------------------------------------------------------
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    #[test]
480    fn quota_tracker_respects_limit() {
481        let q = QuotaTracker::new(Some(100));
482        q.reserve(40).unwrap();
483        q.reserve(40).unwrap();
484        let err = q.reserve(40).unwrap_err();
485        assert!(matches!(err, PodError::PreconditionFailed(_)));
486        assert_eq!(q.used(), 80);
487    }
488
489    #[test]
490    fn quota_tracker_release_frees_space() {
491        let q = QuotaTracker::new(Some(100));
492        q.reserve(60).unwrap();
493        q.release(30);
494        q.reserve(60).unwrap();
495        assert_eq!(q.used(), 90);
496    }
497
498    #[test]
499    fn quota_tracker_none_means_unlimited() {
500        let q = QuotaTracker::new(None);
501        q.reserve(u64::MAX / 2).unwrap();
502        q.reserve(u64::MAX / 2).unwrap();
503    }
504
505    #[test]
506    fn admin_override_matches_only_exact() {
507        let ok = check_admin_override(Some("topsecret"), Some("topsecret"));
508        assert!(ok.is_some());
509        assert!(check_admin_override(Some("topsecret "), Some("topsecret")).is_none());
510        assert!(check_admin_override(None, Some("topsecret")).is_none());
511        assert!(check_admin_override(Some("a"), None).is_none());
512    }
513
514    // -------------------------------------------------------------------
515    // Type-index bootstrap tests (rows 14/164/166).
516    // -------------------------------------------------------------------
517    #[cfg(feature = "memory-backend")]
518    mod type_index_bootstrap {
519        use super::*;
520        use crate::storage::memory::MemoryBackend;
521        use crate::wac::{evaluate_access, parse_turtle_acl, AccessMode};
522        use serde_json::Value;
523
524        async fn provision_default_pod() -> (MemoryBackend, ProvisionOutcome) {
525            let pod = MemoryBackend::new();
526            let plan = ProvisionPlan {
527                pubkey: "0123".into(),
528                display_name: Some("Alice".into()),
529                pod_base: "https://pod.example".into(),
530                containers: vec!["/media/".into()],
531                root_acl: None,
532                quota_bytes: Some(10_000),
533                #[cfg(feature = "provision-keys")]
534                provision_keys: false,
535            };
536            let outcome = provision_pod(&pod, &plan).await.unwrap();
537            (pod, outcome)
538        }
539
540        #[tokio::test]
541        async fn provision_writes_public_type_index_with_listed_document() {
542            let (pod, outcome) = provision_default_pod().await;
543            assert_eq!(
544                outcome.public_type_index, PUBLIC_TYPE_INDEX_PATH,
545                "outcome must surface the public type-index path",
546            );
547
548            let (body, meta) = pod.get(PUBLIC_TYPE_INDEX_PATH).await.unwrap();
549            assert_eq!(meta.content_type, "application/ld+json");
550
551            let parsed: Value = serde_json::from_slice(&body).expect("valid JSON-LD");
552            assert_eq!(parsed["@id"], Value::String(String::new()));
553            assert_eq!(
554                parsed["@context"]["solid"],
555                "http://www.w3.org/ns/solid/terms#"
556            );
557            let types = parsed["@type"].as_array().expect("@type is array");
558            let type_strs: Vec<&str> = types.iter().filter_map(Value::as_str).collect();
559            assert!(type_strs.contains(&"solid:TypeIndex"), "{type_strs:?}");
560            assert!(
561                type_strs.contains(&"solid:ListedDocument"),
562                "public type index missing solid:ListedDocument visibility marker: {type_strs:?}",
563            );
564            assert!(
565                !type_strs.contains(&"solid:UnlistedDocument"),
566                "public type index must not carry solid:UnlistedDocument",
567            );
568        }
569
570        #[tokio::test]
571        async fn provision_writes_private_type_index_with_unlisted_document() {
572            let (pod, outcome) = provision_default_pod().await;
573            assert_eq!(outcome.private_type_index, PRIVATE_TYPE_INDEX_PATH);
574
575            let (body, meta) = pod.get(PRIVATE_TYPE_INDEX_PATH).await.unwrap();
576            assert_eq!(meta.content_type, "application/ld+json");
577
578            let parsed: Value = serde_json::from_slice(&body).expect("valid JSON-LD");
579            assert_eq!(parsed["@id"], Value::String(String::new()));
580            let types = parsed["@type"].as_array().expect("@type is array");
581            let type_strs: Vec<&str> = types.iter().filter_map(Value::as_str).collect();
582            assert!(type_strs.contains(&"solid:TypeIndex"));
583            assert!(
584                type_strs.contains(&"solid:UnlistedDocument"),
585                "private type index missing solid:UnlistedDocument marker: {type_strs:?}",
586            );
587            assert!(
588                !type_strs.contains(&"solid:ListedDocument"),
589                "private type index must not carry solid:ListedDocument",
590            );
591        }
592
593        #[tokio::test]
594        async fn provision_writes_public_read_acl_on_public_type_index() {
595            let (pod, outcome) = provision_default_pod().await;
596            assert_eq!(outcome.public_type_index_acl, PUBLIC_TYPE_INDEX_ACL_PATH);
597
598            let (body, meta) = pod.get(PUBLIC_TYPE_INDEX_ACL_PATH).await.unwrap();
599            assert_eq!(meta.content_type, "text/turtle");
600            let text = std::str::from_utf8(&body).expect("UTF-8 turtle");
601            assert!(text.contains("@prefix acl:"));
602            assert!(text.contains("acl:Authorization"));
603            assert!(text.contains("acl:Control"));
604            assert!(text.contains("foaf:Agent"));
605        }
606
607        #[tokio::test]
608        async fn public_type_index_acl_grants_foaf_agent_read() {
609            let (pod, outcome) = provision_default_pod().await;
610            let (body, _) = pod.get(PUBLIC_TYPE_INDEX_ACL_PATH).await.unwrap();
611            let ttl = std::str::from_utf8(&body).unwrap();
612            let doc = parse_turtle_acl(ttl).expect("ACL parses");
613            // The ACL `accessTo` is the absolute IRI of the resource.
614            // Evaluate against that same string; WAC `path_matches`
615            // normalises both sides identically.
616            let resource_iri = format!(
617                "{}{}",
618                outcome.pod_root.trim_end_matches('/'),
619                PUBLIC_TYPE_INDEX_PATH,
620            );
621
622            assert!(
623                evaluate_access(Some(&doc), None, &resource_iri, AccessMode::Read, None,),
624                "public/anonymous read must be granted on publicTypeIndex.jsonld",
625            );
626            assert!(
627                !evaluate_access(Some(&doc), None, &resource_iri, AccessMode::Write, None,),
628                "anonymous must not be granted write",
629            );
630        }
631
632        #[tokio::test]
633        async fn private_type_index_has_no_sibling_acl() {
634            let (pod, _) = provision_default_pod().await;
635            let missing = "/settings/privateTypeIndex.jsonld.acl";
636            assert!(
637                !pod.exists(missing).await.unwrap(),
638                "private type index must not have a sibling ACL; must inherit /settings/.acl",
639            );
640        }
641    }
642}