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