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    }
195}
196
197/// Seed a pod on the provided storage.
198///
199/// * Creates every container in `plan.containers` (idempotent — the
200///   function treats `AlreadyExists` as success).
201/// * Writes a WebID profile HTML at `<pod_base>/pods/<pubkey>/profile/card`.
202/// * Writes a root ACL document if `plan.root_acl` is supplied.
203pub async fn provision_pod<S: Storage + ?Sized>(
204    storage: &S,
205    plan: &ProvisionPlan,
206) -> Result<ProvisionOutcome, PodError> {
207    let pod_root = format!(
208        "{}/pods/{}/",
209        plan.pod_base.trim_end_matches('/'),
210        plan.pubkey
211    );
212    let webid = format!("{pod_root}profile/card#me");
213
214    // Ensure the pod root + default containers exist.
215    let mut all_containers: Vec<String> = plan.containers.to_vec();
216    all_containers.push("/".into());
217    all_containers.push("/profile/".into());
218    all_containers.push("/settings/".into());
219    // Deduplicate.
220    all_containers.sort();
221    all_containers.dedup();
222
223    let mut created = Vec::new();
224    for c in &all_containers {
225        if !is_container(c) {
226            return Err(PodError::InvalidPath(format!("not a container: {c}")));
227        }
228        // Create the `.meta` sidecar — this is the idiomatic way to
229        // materialise a bare container without a body.
230        let meta_key = format!("{}.meta", c.trim_end_matches('/'));
231        match storage
232            .put(&meta_key, Bytes::from_static(b"{}"), "application/ld+json")
233            .await
234        {
235            Ok(_) => created.push(c.clone()),
236            Err(PodError::AlreadyExists(_)) => {}
237            Err(e) => return Err(e),
238        }
239    }
240
241    // Write WebID profile.
242    let webid_html =
243        generate_webid_html(&plan.pubkey, plan.display_name.as_deref(), &plan.pod_base);
244    storage
245        .put(
246            "/profile/card",
247            Bytes::from(webid_html.into_bytes()),
248            "text/html",
249        )
250        .await?;
251
252    // Write root ACL if supplied.
253    if let Some(acl) = &plan.root_acl {
254        let body = serde_json::to_vec(acl)?;
255        storage
256            .put("/.acl", Bytes::from(body), "application/ld+json")
257            .await?;
258    }
259
260    // -------------------------------------------------------------------
261    // Type-index bootstrap (rows 14/164/166 — JSS #301 + #297).
262    // The two `*.jsonld` bodies differ only in the visibility marker.
263    // The public one gets a sibling ACL granting `foaf:Agent` read and
264    // the owner full control; the private one inherits the default
265    // (owner-only) ACL from `/settings/.acl`, so we deliberately do
266    // *not* emit a sibling for it.
267    // -------------------------------------------------------------------
268    let public_body = render_type_index_body("solid:ListedDocument");
269    storage
270        .put(
271            PUBLIC_TYPE_INDEX_PATH,
272            Bytes::from(public_body.into_bytes()),
273            "application/ld+json",
274        )
275        .await?;
276
277    let private_body = render_type_index_body("solid:UnlistedDocument");
278    storage
279        .put(
280            PRIVATE_TYPE_INDEX_PATH,
281            Bytes::from(private_body.into_bytes()),
282            "application/ld+json",
283        )
284        .await?;
285
286    // Use an absolute resource IRI so the Turtle serialiser wraps the
287    // target in `<>` (otherwise a `.` inside the path — e.g. in
288    // `.jsonld` — trips the statement splitter on round-trip).
289    let public_acl_resource_iri = format!(
290        "{}{}",
291        pod_root.trim_end_matches('/'),
292        PUBLIC_TYPE_INDEX_PATH,
293    );
294    let public_acl_doc = build_public_type_index_acl(&webid, &public_acl_resource_iri);
295    let public_acl_ttl = serialize_turtle_acl(&public_acl_doc);
296    storage
297        .put(
298            PUBLIC_TYPE_INDEX_ACL_PATH,
299            Bytes::from(public_acl_ttl.into_bytes()),
300            "text/turtle",
301        )
302        .await?;
303
304    Ok(ProvisionOutcome {
305        webid,
306        pod_root,
307        containers_created: created,
308        quota_bytes: plan.quota_bytes,
309        public_type_index: PUBLIC_TYPE_INDEX_PATH.to_string(),
310        private_type_index: PRIVATE_TYPE_INDEX_PATH.to_string(),
311        public_type_index_acl: PUBLIC_TYPE_INDEX_ACL_PATH.to_string(),
312    })
313}
314
315// ---------------------------------------------------------------------------
316// Git auto-init hook (alpha.12, rows 199-200, JSS #466/#469/#471)
317// ---------------------------------------------------------------------------
318
319/// Async hook called once after pod files are written to initialise the pod
320/// directory as a git repository.
321///
322/// The trait is **wasm32-safe** — it carries no subprocess dependency.
323/// The concrete implementation (`solid_pod_rs_git::init::GitAutoInit`)
324/// spawns `git init -b main` via `tokio::process::Command` and lives in
325/// a separate crate that is never compiled for `wasm32-unknown-unknown`.
326///
327/// Pass an implementor to [`provision_pod_ext`] to opt in.
328/// Feature-gated: only present when the `git-auto-init` Cargo feature
329/// is enabled on `solid-pod-rs`.
330#[cfg(feature = "git-auto-init")]
331#[async_trait::async_trait]
332pub trait GitInitHook: Send + Sync {
333    /// Called with the absolute filesystem path of the freshly provisioned
334    /// pod directory. Errors are **logged and swallowed** — a git-init
335    /// failure must not roll back or prevent pod creation.
336    async fn try_init_repo(&self, fs_pod_root: &std::path::Path) -> Result<(), PodError>;
337}
338
339/// Like [`provision_pod`], but accepts an optional git-init hook that runs
340/// after all pod files are written.
341///
342/// `git_hook` is `Option<(&H, &Path)>` where the `Path` is the absolute
343/// filesystem root of the pod directory (e.g.
344/// `/var/lib/pods/<pubkey>/`). Required because `provision_pod` writes
345/// through an abstract `Storage` and does not itself know the on-disk
346/// path. Callers using `fs-backend` construct this path from the same
347/// root they passed to `FsBackend::new`.
348///
349/// When `git_hook` is `None` this is identical to `provision_pod`.
350#[cfg(feature = "git-auto-init")]
351pub async fn provision_pod_ext<S, H>(
352    storage: &S,
353    plan: &ProvisionPlan,
354    git_hook: Option<(&H, &std::path::Path)>,
355) -> Result<ProvisionOutcome, PodError>
356where
357    S: Storage + ?Sized,
358    H: GitInitHook,
359{
360    let outcome = provision_pod(storage, plan).await?;
361
362    if let Some((hook, fs_pod_root)) = git_hook {
363        if let Err(e) = hook.try_init_repo(fs_pod_root).await {
364            tracing::warn!(
365                target: "solid_pod_rs::provision",
366                pubkey = %plan.pubkey,
367                path = %fs_pod_root.display(),
368                error = %e,
369                "git auto-init failed (pod created, git skipped)",
370            );
371        }
372    }
373
374    Ok(outcome)
375}
376
377// ---------------------------------------------------------------------------
378// Quota enforcement
379// ---------------------------------------------------------------------------
380
381/// Tracks per-pod byte usage against a configurable quota.
382#[derive(Debug, Clone)]
383pub struct QuotaTracker {
384    quota_bytes: Option<u64>,
385    used_bytes: std::sync::Arc<std::sync::atomic::AtomicU64>,
386}
387
388impl QuotaTracker {
389    pub fn new(quota_bytes: Option<u64>) -> Self {
390        Self {
391            quota_bytes,
392            used_bytes: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
393        }
394    }
395
396    pub fn with_initial_used(quota_bytes: Option<u64>, used: u64) -> Self {
397        Self {
398            quota_bytes,
399            used_bytes: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(used)),
400        }
401    }
402
403    /// Bytes currently accounted for.
404    pub fn used(&self) -> u64 {
405        self.used_bytes.load(std::sync::atomic::Ordering::Relaxed)
406    }
407
408    /// Configured quota, if any.
409    pub fn quota(&self) -> Option<u64> {
410        self.quota_bytes
411    }
412
413    /// Reserve `size` bytes. Returns `Err(PodError::PreconditionFailed)`
414    /// when the operation would exceed the quota, without mutating the
415    /// tracker.
416    pub fn reserve(&self, size: u64) -> Result<(), PodError> {
417        if let Some(q) = self.quota_bytes {
418            let cur = self.used();
419            if cur.saturating_add(size) > q {
420                return Err(PodError::PreconditionFailed(format!(
421                    "quota exceeded: {cur}+{size} > {q}"
422                )));
423            }
424        }
425        self.used_bytes
426            .fetch_add(size, std::sync::atomic::Ordering::Relaxed);
427        Ok(())
428    }
429
430    /// Release `size` bytes previously reserved (e.g. on DELETE).
431    pub fn release(&self, size: u64) {
432        self.used_bytes
433            .fetch_sub(size, std::sync::atomic::Ordering::Relaxed);
434    }
435}
436
437// ---------------------------------------------------------------------------
438// Admin override
439// ---------------------------------------------------------------------------
440
441/// A verified admin-override marker. The consumer crate constructs this
442/// only after validating a shared-secret header against configuration;
443/// the marker carries no data beyond its own existence.
444#[derive(Debug, Clone, Copy)]
445pub struct AdminOverride;
446
447/// Match an admin-secret header value against the configured secret.
448/// Both sides are compared with constant-time equality to avoid
449/// timing leaks. Returns `Some(AdminOverride)` on match.
450pub fn check_admin_override(
451    header: Option<&str>,
452    configured: Option<&str>,
453) -> Option<AdminOverride> {
454    let header = header?;
455    let configured = configured?;
456    if header.len() != configured.len() {
457        return None;
458    }
459    let mut acc = 0u8;
460    for (a, b) in header.bytes().zip(configured.bytes()) {
461        acc |= a ^ b;
462    }
463    if acc == 0 {
464        Some(AdminOverride)
465    } else {
466        None
467    }
468}
469
470// ---------------------------------------------------------------------------
471// Tests
472// ---------------------------------------------------------------------------
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    #[test]
479    fn quota_tracker_respects_limit() {
480        let q = QuotaTracker::new(Some(100));
481        q.reserve(40).unwrap();
482        q.reserve(40).unwrap();
483        let err = q.reserve(40).unwrap_err();
484        assert!(matches!(err, PodError::PreconditionFailed(_)));
485        assert_eq!(q.used(), 80);
486    }
487
488    #[test]
489    fn quota_tracker_release_frees_space() {
490        let q = QuotaTracker::new(Some(100));
491        q.reserve(60).unwrap();
492        q.release(30);
493        q.reserve(60).unwrap();
494        assert_eq!(q.used(), 90);
495    }
496
497    #[test]
498    fn quota_tracker_none_means_unlimited() {
499        let q = QuotaTracker::new(None);
500        q.reserve(u64::MAX / 2).unwrap();
501        q.reserve(u64::MAX / 2).unwrap();
502    }
503
504    #[test]
505    fn admin_override_matches_only_exact() {
506        let ok = check_admin_override(Some("topsecret"), Some("topsecret"));
507        assert!(ok.is_some());
508        assert!(check_admin_override(Some("topsecret "), Some("topsecret")).is_none());
509        assert!(check_admin_override(None, Some("topsecret")).is_none());
510        assert!(check_admin_override(Some("a"), None).is_none());
511    }
512
513    // -------------------------------------------------------------------
514    // Type-index bootstrap tests (rows 14/164/166).
515    // -------------------------------------------------------------------
516    #[cfg(feature = "memory-backend")]
517    mod type_index_bootstrap {
518        use super::*;
519        use crate::storage::memory::MemoryBackend;
520        use crate::wac::{evaluate_access, parse_turtle_acl, AccessMode};
521        use serde_json::Value;
522
523        async fn provision_default_pod() -> (MemoryBackend, ProvisionOutcome) {
524            let pod = MemoryBackend::new();
525            let plan = ProvisionPlan {
526                pubkey: "0123".into(),
527                display_name: Some("Alice".into()),
528                pod_base: "https://pod.example".into(),
529                containers: vec!["/media/".into()],
530                root_acl: None,
531                quota_bytes: Some(10_000),
532                #[cfg(feature = "provision-keys")]
533                provision_keys: false,
534            };
535            let outcome = provision_pod(&pod, &plan).await.unwrap();
536            (pod, outcome)
537        }
538
539        #[tokio::test]
540        async fn provision_writes_public_type_index_with_listed_document() {
541            let (pod, outcome) = provision_default_pod().await;
542            assert_eq!(
543                outcome.public_type_index, PUBLIC_TYPE_INDEX_PATH,
544                "outcome must surface the public type-index path",
545            );
546
547            let (body, meta) = pod.get(PUBLIC_TYPE_INDEX_PATH).await.unwrap();
548            assert_eq!(meta.content_type, "application/ld+json");
549
550            let parsed: Value = serde_json::from_slice(&body).expect("valid JSON-LD");
551            assert_eq!(parsed["@id"], Value::String(String::new()));
552            assert_eq!(
553                parsed["@context"]["solid"],
554                "http://www.w3.org/ns/solid/terms#"
555            );
556            let types = parsed["@type"].as_array().expect("@type is array");
557            let type_strs: Vec<&str> = types.iter().filter_map(Value::as_str).collect();
558            assert!(type_strs.contains(&"solid:TypeIndex"), "{type_strs:?}");
559            assert!(
560                type_strs.contains(&"solid:ListedDocument"),
561                "public type index missing solid:ListedDocument visibility marker: {type_strs:?}",
562            );
563            assert!(
564                !type_strs.contains(&"solid:UnlistedDocument"),
565                "public type index must not carry solid:UnlistedDocument",
566            );
567        }
568
569        #[tokio::test]
570        async fn provision_writes_private_type_index_with_unlisted_document() {
571            let (pod, outcome) = provision_default_pod().await;
572            assert_eq!(outcome.private_type_index, PRIVATE_TYPE_INDEX_PATH);
573
574            let (body, meta) = pod.get(PRIVATE_TYPE_INDEX_PATH).await.unwrap();
575            assert_eq!(meta.content_type, "application/ld+json");
576
577            let parsed: Value = serde_json::from_slice(&body).expect("valid JSON-LD");
578            assert_eq!(parsed["@id"], Value::String(String::new()));
579            let types = parsed["@type"].as_array().expect("@type is array");
580            let type_strs: Vec<&str> = types.iter().filter_map(Value::as_str).collect();
581            assert!(type_strs.contains(&"solid:TypeIndex"));
582            assert!(
583                type_strs.contains(&"solid:UnlistedDocument"),
584                "private type index missing solid:UnlistedDocument marker: {type_strs:?}",
585            );
586            assert!(
587                !type_strs.contains(&"solid:ListedDocument"),
588                "private type index must not carry solid:ListedDocument",
589            );
590        }
591
592        #[tokio::test]
593        async fn provision_writes_public_read_acl_on_public_type_index() {
594            let (pod, outcome) = provision_default_pod().await;
595            assert_eq!(outcome.public_type_index_acl, PUBLIC_TYPE_INDEX_ACL_PATH);
596
597            let (body, meta) = pod.get(PUBLIC_TYPE_INDEX_ACL_PATH).await.unwrap();
598            assert_eq!(meta.content_type, "text/turtle");
599            let text = std::str::from_utf8(&body).expect("UTF-8 turtle");
600            assert!(text.contains("@prefix acl:"));
601            assert!(text.contains("acl:Authorization"));
602            assert!(text.contains("acl:Control"));
603            assert!(text.contains("foaf:Agent"));
604        }
605
606        #[tokio::test]
607        async fn public_type_index_acl_grants_foaf_agent_read() {
608            let (pod, outcome) = provision_default_pod().await;
609            let (body, _) = pod.get(PUBLIC_TYPE_INDEX_ACL_PATH).await.unwrap();
610            let ttl = std::str::from_utf8(&body).unwrap();
611            let doc = parse_turtle_acl(ttl).expect("ACL parses");
612            // The ACL `accessTo` is the absolute IRI of the resource.
613            // Evaluate against that same string; WAC `path_matches`
614            // normalises both sides identically.
615            let resource_iri = format!(
616                "{}{}",
617                outcome.pod_root.trim_end_matches('/'),
618                PUBLIC_TYPE_INDEX_PATH,
619            );
620
621            assert!(
622                evaluate_access(Some(&doc), None, &resource_iri, AccessMode::Read, None,),
623                "public/anonymous read must be granted on publicTypeIndex.jsonld",
624            );
625            assert!(
626                !evaluate_access(Some(&doc), None, &resource_iri, AccessMode::Write, None,),
627                "anonymous must not be granted write",
628            );
629        }
630
631        #[tokio::test]
632        async fn private_type_index_has_no_sibling_acl() {
633            let (pod, _) = provision_default_pod().await;
634            let missing = "/settings/privateTypeIndex.jsonld.acl";
635            assert!(
636                !pod.exists(missing).await.unwrap(),
637                "private type index must not have a sibling ACL; must inherit /settings/.acl",
638            );
639        }
640    }
641}