Skip to main content

boatramp_core/
deploy.rs

1//! Content-addressed deployments with atomic activation.
2//!
3//! A **deployment** is an immutable [`Manifest`] mapping site paths to content
4//! hashes. File contents ("blobs") are stored once, keyed by their SHA-256, in
5//! a [`Storage`] backend; the manifest and the per-site "current" pointer live
6//! in a [`KvStore`].
7//!
8//! Publishing is therefore:
9//! 1. upload any blobs the server is missing (dedup is automatic — identical
10//!    bytes share a key),
11//! 2. store the manifest, and
12//! 3. atomically point the site at it.
13//!
14//! Because the pointer write is a single atomic KV operation, a reader always
15//! sees either the previous deployment or the new one in full — never a
16//! half-published mix. Rollback is just pointing the site at an older manifest.
17
18use crate::time::now_unix;
19use std::collections::{BTreeMap, BTreeSet};
20use std::sync::{Arc, Mutex};
21
22use futures::StreamExt;
23use sha2::{Digest, Sha256};
24
25use crate::config::SiteConfig;
26use crate::domain_verify::{DomainVerification, VerificationMethod};
27use crate::error::DeployError;
28use crate::kv::{KvStore, WriteOp};
29use crate::project::{DomainOwner, ProjectRef};
30use crate::site::SiteName;
31use crate::{ByteStream, GetObject, PutMeta, Storage, StorageError};
32
33// The per-file descriptor, its precompressed variants, the immutable
34// content-addressed `Manifest`, and `sha256_hex` are wasm-clean wire types in
35// `boatramp-types` (so the edge Worker + web console share one definition);
36// re-exported so `boatramp_core::deploy::{FileEntry, Variant, Manifest,
37// sha256_hex}` are unchanged. `Manifest`'s methods now return `ConfigError`,
38// which converts to `DeployError` via the existing `From` at `?` sites.
39pub use boatramp_types::file::{FileEntry, Variant};
40pub use boatramp_types::manifest::{sha256_hex, Manifest};
41
42// The deployment **wire structs** — provenance metadata, activation history,
43// and the GC/scrub reports — are pure serde wire types in `boatramp-types` (so
44// the server, CLI, and web console share one definition); re-exported so
45// `boatramp_core::deploy::{DeployMeta, …, ScrubReport}` are unchanged. The
46// `DeployStore` plumbing below that produces them stays here.
47pub use boatramp_types::deploy::{
48    BlobMismatch, BlobReadError, DeployMeta, DeployMetaInput, DeploymentList, GcReport,
49    HistoryEntry, ScrubReport,
50};
51
52/// Tuning for a garbage-collection pass: a safety grace window plus a retention
53/// policy. The defaults are conservative — no retention pressure (all history is
54/// kept) and no grace window — so callers opt into pruning aggressiveness.
55#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
56pub struct GcOptions {
57    /// Never collect a manifest first-seen within this many seconds, even if it
58    /// is unreferenced. This protects an in-flight deploy whose blobs/manifest
59    /// are uploaded but not yet activated (so not yet reachable from history).
60    pub grace_secs: u64,
61    /// Keep at most this many most-recent history entries per site as
62    /// retention-protected (beyond `current` and aliases, which are always
63    /// kept). `None` keeps the entire history.
64    pub keep_last: Option<usize>,
65    /// Also keep any history entry activated within this many seconds, even
66    /// beyond `keep_last`. `None` applies no age-based retention.
67    pub keep_age_secs: Option<u64>,
68}
69
70/// Most recent activations retained per site.
71const MAX_HISTORY: usize = 100;
72
73/// Canonicalize a routing host: trimmed, no trailing dot, lower-cased. Host names
74/// are case-insensitive and a trailing dot is a legal FQDN form, so the routing
75/// index — and the host-uniqueness guard that protects it — must key on one
76/// canonical form. Otherwise a case- or dot-variant (`Example.com`, `example.com.`)
77/// would write a *second* `domain/<host>` entry and slip past the hijack guard,
78/// then route real traffic when a client sends that exact `Host`.
79fn canon_host(host: &str) -> String {
80    crate::host::Host::new(host).routing_key()
81}
82
83/// Whether `key` is a sharded blob key (`ab/<64 hex>`). Used so GC never
84/// touches objects it did not write, even in a shared bucket.
85fn is_blob_key(key: &str) -> bool {
86    match key.split_once('/') {
87        Some((shard, hash)) => {
88            shard.len() == 2
89                && hash.len() == 64
90                && shard.bytes().all(|b| b.is_ascii_hexdigit())
91                && hash.bytes().all(|b| b.is_ascii_hexdigit())
92        }
93        None => false,
94    }
95}
96
97/// Ties a blob [`Storage`] to a metadata [`KvStore`] to provide
98/// content-addressed deployments and atomic activation.
99#[derive(Clone)]
100pub struct DeployStore {
101    storage: Arc<dyn Storage>,
102    kv: Arc<dyn KvStore>,
103    /// Serializes host-claim read-modify-writes on the domain routing index
104    /// (`set_site_config` / `attach_verified_domain`), so a `domain/<host>`
105    /// mapping can't be checked-then-overwritten across an `await` and one site
106    /// can't race in to hijack another's domain. Process-local: airtight on a
107    /// single node; a Raft cluster needs the same as a consensus-level
108    /// conditional (noted on [`set_site_config`](Self::set_site_config)).
109    domain_claim_lock: Arc<futures::lock::Mutex<()>>,
110}
111
112mod keys {
113    //! The KV keyspace, collected in one place (mirrors [`boatramp_types::function::keys`]),
114    //! so the persisted layout is legible at a glance instead of scattered through
115    //! [`DeployStore`]'s methods. The strings are the on-disk keyspace — changing one is
116    //! a migration, not a refactor.
117    //!
118    //! Two classes, split by the 0.2.0 **project** re-keying:
119    //!
120    //! - **Project-scoped** (mutable, per-name): a [`ProjectRef`] first arg puts the
121    //!   record under `project/<proj>/…`. The compiler enforces the project is
122    //!   supplied — a site name can't be passed where a project is meant.
123    //! - **Global** (unchanged): content-addressed dedup-shared bodies (`manifests/`,
124    //!   `meta/`, blobs, `siteconfig/`, `daemonconfig/`) — a content hash is a
125    //!   self-authenticating capability, so bodies dedup across projects — and the
126    //!   global-uniqueness domain-routing index (`domain/`, `wildcard/`,
127    //!   `httpchallenge/`), whose **value** now carries the owning `(project, site)`.
128
129    use crate::project::ProjectRef;
130
131    /// A content-addressed manifest: `manifests/<id>` (global CAS).
132    pub fn manifest(id: &str) -> String {
133        format!("manifests/{id}")
134    }
135
136    /// A deployment's [`DeployMeta`](super::DeployMeta): `meta/<id>` (global CAS).
137    pub fn meta(id: &str) -> String {
138        format!("meta/{id}")
139    }
140
141    /// A site's active deployment pointer: `project/<proj>/current/<site>`.
142    pub fn current(project: ProjectRef<'_>, site: &str) -> String {
143        format!("project/{project}/current/{site}")
144    }
145
146    /// The prefix listing a project's active-deployment pointers.
147    pub fn current_prefix(project: ProjectRef<'_>) -> String {
148        format!("project/{project}/current/")
149    }
150
151    /// A named alias → deployment id: `project/<proj>/alias/<site>/<name>`.
152    pub fn alias(project: ProjectRef<'_>, site: &str, name: &str) -> String {
153        format!("project/{project}/alias/{site}/{name}")
154    }
155
156    /// The prefix listing a site's aliases: `project/<proj>/alias/<site>/`.
157    pub fn alias_prefix(project: ProjectRef<'_>, site: &str) -> String {
158        format!("project/{project}/alias/{site}/")
159    }
160
161    /// The prefix listing **every** alias in a project (all sites).
162    pub fn alias_project_prefix(project: ProjectRef<'_>) -> String {
163        format!("project/{project}/alias/")
164    }
165
166    /// Sharded blob key, e.g. `ab/abcdef...`, to avoid one huge directory (global CAS).
167    pub fn blob(hash: &str) -> String {
168        if hash.len() >= 2 {
169            format!("{}/{}", &hash[..2], hash)
170        } else {
171            hash.to_string()
172        }
173    }
174
175    /// The **mutable pointer** for a site: `project/<proj>/site/<site>` → the content
176    /// hash of its current `SiteConfig`. Tiny; the only key that changes on a config
177    /// edit, so it's the only thing the shared-mode invalidation feed must carry.
178    pub fn site_pointer(project: ProjectRef<'_>, site: &str) -> String {
179        format!("project/{project}/site/{site}")
180    }
181
182    /// The prefix listing a project's site-config pointers.
183    pub fn site_prefix(project: ProjectRef<'_>) -> String {
184        format!("project/{project}/site/")
185    }
186
187    /// The **immutable, content-addressed** config body:
188    /// `siteconfig/<hash>` → the `SiteConfig` JSON. Keyed by its own hash, so it
189    /// never changes under a key and is safe to cache forever; identical configs
190    /// across sites (and projects) dedup to one blob. Global CAS.
191    pub fn site_config_blob(hash: &str) -> String {
192        format!("siteconfig/{hash}")
193    }
194
195    /// A registered exact host → owner: `domain/<canon-host>`. The **key** is global
196    /// (hosts are globally unique); the stored **value** is the owning
197    /// `(project, site)` (see [`DomainOwner`](crate::project::DomainOwner)).
198    pub fn domain(host: &str) -> String {
199        format!("domain/{}", super::canon_host(host))
200    }
201
202    /// A registered wildcard suffix → owner: `wildcard/<canon-suffix>` (global key,
203    /// owner in the value).
204    pub fn wildcard(suffix: &str) -> String {
205        format!("wildcard/{}", super::canon_host(suffix))
206    }
207
208    /// A pending domain-ownership challenge:
209    /// `project/<proj>/domainverify/<site>/<verify-host>`.
210    pub fn domain_verification(project: ProjectRef<'_>, site: &str, host: &str) -> String {
211        format!(
212            "project/{project}/domainverify/{site}/{}",
213            crate::domain_verify::normalize_host(host)
214        )
215    }
216
217    /// The prefix listing a site's pending domain challenges.
218    pub fn domain_verification_prefix(project: ProjectRef<'_>, site: &str) -> String {
219        format!("project/{project}/domainverify/{site}/")
220    }
221
222    /// The prefix listing **every** pending domain challenge in a project.
223    pub fn domain_verification_project_prefix(project: ProjectRef<'_>) -> String {
224        format!("project/{project}/domainverify/")
225    }
226
227    /// Index key mapping an **HTTP challenge** `(host, token)` → its owner, so the
228    /// unauthenticated self-serve edge route is an O(1) lookup rather than an O(N)
229    /// scan of every site's challenges (a flood-amplification vector). The token
230    /// is a 128-bit random, so carrying it in the key is safe. Global key; the value
231    /// carries the owning `(project, site)`.
232    pub fn http_challenge_index(host: &str, token: &str) -> String {
233        format!(
234            "httpchallenge/{}/{token}",
235            crate::domain_verify::normalize_host(host)
236        )
237    }
238
239    /// The immutable, content-addressed daemon-config body: `daemonconfig/<hash>`
240    /// (global CAS + a control-plane singleton).
241    pub fn daemon_config_blob(hash: &str) -> String {
242        format!("daemonconfig/{hash}")
243    }
244
245    /// A site's activation history list: `project/<proj>/history/<site>`.
246    pub fn history(project: ProjectRef<'_>, site: &str) -> String {
247        format!("project/{project}/history/{site}")
248    }
249
250    /// The prefix listing a project's activation-history lists.
251    pub fn history_prefix(project: ProjectRef<'_>) -> String {
252        format!("project/{project}/history/")
253    }
254
255    /// The root prefix under which **all** project-scoped records live. A cross-
256    /// project fan-out (`_all` list variants, GC) discovers the project set by
257    /// scanning this and splitting out the segment after it.
258    pub const PROJECT_ROOT: &str = "project/";
259
260    /// The project name owning a project-scoped `key` (the segment right after
261    /// [`PROJECT_ROOT`]), or `None` if `key` is not project-scoped.
262    pub fn project_of_key(key: &str) -> Option<&str> {
263        key.strip_prefix(PROJECT_ROOT)
264            .and_then(|rest| rest.split('/').next())
265            .filter(|p| !p.is_empty())
266    }
267}
268
269impl DeployStore {
270    /// Build a deploy store over a blob `storage` and a metadata `kv`.
271    pub fn new(storage: Arc<dyn Storage>, kv: Arc<dyn KvStore>) -> Self {
272        Self {
273            storage,
274            kv,
275            domain_claim_lock: Arc::new(futures::lock::Mutex::new(())),
276        }
277    }
278
279    /// Readiness probe: confirm the metadata backend is reachable with a cheap
280    /// read (a missing key is fine — it still proves the backend answered). The
281    /// blob backend is exercised per-request rather than probed here.
282    pub async fn ready(&self) -> Result<(), DeployError> {
283        self.kv.get("__readyz_probe__").await?;
284        Ok(())
285    }
286
287    /// Store a manifest (idempotent) and return its deployment id.
288    pub async fn put_manifest(&self, manifest: &Manifest) -> Result<String, DeployError> {
289        self.put_manifest_with(manifest, DeployMetaInput::default())
290            .await
291    }
292
293    /// Store a manifest (idempotent) and record/refresh its [`DeployMeta`].
294    ///
295    /// `created_at` is set on first store and preserved across re-deploys of the
296    /// same content (so the GC grace window measures true age); sizes are
297    /// recomputed from the manifest, and the client-supplied provenance fields
298    /// are merged in (a later deploy of identical content can update its source/
299    /// message without resetting `created_at`).
300    pub async fn put_manifest_with(
301        &self,
302        manifest: &Manifest,
303        input: DeployMetaInput,
304    ) -> Result<String, DeployError> {
305        let id = manifest.id()?;
306
307        // Compute the metadata first (it merges with any prior record), then
308        // commit the manifest and its metadata together: one durable flush,
309        // and a reader never sees the manifest without its companion meta.
310        let existing = self.get_meta(&id).await?;
311        let created_at = existing
312            .as_ref()
313            .map(|m| m.created_at)
314            .unwrap_or_else(now_unix);
315        let meta = DeployMeta {
316            version: crate::SCHEMA_VERSION,
317            created_at,
318            file_count: manifest.files.len() as u64,
319            total_size: manifest.files.values().map(|entry| entry.size).sum(),
320            source: input
321                .source
322                .or_else(|| existing.as_ref().and_then(|m| m.source.clone())),
323            branch: input
324                .branch
325                .or_else(|| existing.as_ref().and_then(|m| m.branch.clone())),
326            author: input
327                .author
328                .or_else(|| existing.as_ref().and_then(|m| m.author.clone())),
329            message: input
330                .message
331                .or_else(|| existing.as_ref().and_then(|m| m.message.clone())),
332            tag: input
333                .tag
334                .or_else(|| existing.as_ref().and_then(|m| m.tag.clone())),
335            // Tags replace wholesale when supplied (so a re-deploy can retag);
336            // an empty input preserves the prior set, mirroring the fields above.
337            tags: if input.tags.is_empty() {
338                existing.map(|m| m.tags).unwrap_or_default()
339            } else {
340                input.tags
341            },
342        };
343        self.kv
344            .write_batch(vec![
345                WriteOp::Put(keys::manifest(&id), manifest.to_bytes()?),
346                WriteOp::Put(keys::meta(&id), serde_json::to_vec(&meta)?),
347            ])
348            .await?;
349        Ok(id)
350    }
351
352    /// Fetch a deployment's [`DeployMeta`], if recorded.
353    pub async fn get_meta(&self, id: &str) -> Result<Option<DeployMeta>, DeployError> {
354        match self.kv.get(&keys::meta(id)).await? {
355            Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
356            None => Ok(None),
357        }
358    }
359
360    /// Fetch a manifest by deployment id.
361    pub async fn get_manifest(&self, id: &str) -> Result<Option<Manifest>, DeployError> {
362        match self.kv.get(&keys::manifest(id)).await? {
363            Some(bytes) => Ok(Some(Manifest::from_bytes(&bytes)?)),
364            None => Ok(None),
365        }
366    }
367
368    /// Resolve a deployment-id **prefix** to the one full id it uniquely names
369    /// (an exact id resolves to itself). Used for the wildcard preview host form
370    /// `<id>.deploy.<host>`, where the id rides as a DNS label — capped at 63
371    /// chars, shorter than a full 64-hex content hash — so operators use a
372    /// prefix. Returns `None` if nothing matches; `Err(Ambiguous)` if the prefix
373    /// is not unique (the caller should treat that as not-found).
374    pub async fn resolve_manifest_id(&self, prefix: &str) -> Result<Option<String>, DeployError> {
375        // Exact hit first (the common case; avoids a scan).
376        if self.kv.get(&keys::manifest(prefix)).await?.is_some() {
377            return Ok(Some(prefix.to_string()));
378        }
379        let key_prefix = keys::manifest(prefix);
380        let strip = "manifests/".len();
381        let keys = self.kv.list_prefix(&key_prefix).await?;
382        let mut ids = keys.iter().map(|key| &key[strip..]);
383        match (ids.next(), ids.next()) {
384            (Some(only), None) => Ok(Some(only.to_string())),
385            (Some(_), Some(_)) => Err(DeployError::Ambiguous(prefix.to_string())),
386            _ => Ok(None),
387        }
388    }
389
390    /// Whether a blob with `hash` is already stored.
391    pub async fn has_blob(&self, hash: &str) -> Result<bool, DeployError> {
392        match self.storage.head(&keys::blob(hash)).await {
393            Ok(_) => Ok(true),
394            Err(StorageError::NotFound(_)) => Ok(false),
395            Err(err) => Err(err.into()),
396        }
397    }
398
399    /// The blob hashes from `manifest` that the store is missing.
400    pub async fn missing_blobs(&self, manifest: &Manifest) -> Result<Vec<String>, DeployError> {
401        let mut missing = Vec::new();
402        for hash in manifest.blob_hashes() {
403            if !self.has_blob(&hash).await? {
404                missing.push(hash);
405            }
406        }
407        Ok(missing)
408    }
409
410    /// Stream a blob into storage, verifying it hashes to `hash`.
411    ///
412    /// The bytes are hashed as they pass through to the backend (never fully
413    /// buffered). A mismatch deletes the partial blob and errors.
414    pub async fn put_blob(&self, hash: &str, body: ByteStream) -> Result<(), DeployError> {
415        let hasher = Arc::new(Mutex::new(Sha256::new()));
416        let tap = hasher.clone();
417        let verified: ByteStream = body
418            .map(move |chunk| {
419                if let Ok(bytes) = &chunk {
420                    tap.lock().unwrap().update(bytes);
421                }
422                chunk
423            })
424            .boxed();
425
426        let key = keys::blob(hash);
427        self.storage.put(&key, verified, PutMeta::default()).await?;
428
429        let actual = hex::encode(hasher.lock().unwrap().clone().finalize());
430        if actual != hash {
431            let _ = self.storage.delete(&key).await;
432            return Err(DeployError::HashMismatch {
433                expected: hash.to_string(),
434                actual,
435            });
436        }
437        Ok(())
438    }
439
440    /// Open a blob for streaming reads.
441    pub async fn open_blob(&self, hash: &str) -> Result<GetObject, DeployError> {
442        Ok(self.storage.get(&keys::blob(hash)).await?)
443    }
444
445    /// Open a byte range of a blob for streaming reads (HTTP `Range`).
446    pub async fn open_blob_range(
447        &self,
448        hash: &str,
449        offset: u64,
450        len: Option<u64>,
451    ) -> Result<GetObject, DeployError> {
452        Ok(self
453            .storage
454            .get_range(&keys::blob(hash), offset, len)
455            .await?)
456    }
457
458    /// A site's [`SiteConfig`], if it has been set. Reads the `site/<site>`
459    /// pointer, then the immutable `siteconfig/<hash>` body it names.
460    pub async fn get_site_config(
461        &self,
462        project: ProjectRef<'_>,
463        site: &str,
464    ) -> Result<Option<SiteConfig>, DeployError> {
465        let Some(hash) = self.kv.get(&keys::site_pointer(project, site)).await? else {
466            return Ok(None);
467        };
468        let hash = String::from_utf8_lossy(&hash).into_owned();
469        match self.kv.get(&keys::site_config_blob(&hash)).await? {
470            Some(bytes) => Ok(Some(SiteConfig::from_json(&bytes)?)),
471            // A dangling pointer (body GC'd out from under it) reads as unset.
472            None => Ok(None),
473        }
474    }
475
476    /// Store a site's [`SiteConfig`] and rebuild its host → site index entries
477    /// (so `resolve_site_by_host` can route by `Host`).
478    ///
479    /// Content-addressed: the config body is written
480    /// once under `siteconfig/<hash>` (immutable, dedup'd) and the mutable
481    /// `site/<site>` pointer is flipped to it. Only the tiny pointer changes, so
482    /// the shared-mode invalidation surface is the pointer, not the body. The
483    /// whole change (drop old index, write body, flip pointer, write new index)
484    /// commits as one atomic batch.
485    ///
486    /// **Host uniqueness (hijack guard):** a host — or wildcard suffix — already
487    /// mapped to a *different* site is refused with [`DeployError::Conflict`]
488    /// rather than silently overwritten. Without this, any site-writer could
489    /// point another site's live domain at their own site (last-writer-wins
490    /// takeover). The read-check and the write are serialized by a process-local
491    /// lock so they can't be interleaved across an `await`. This is airtight on a
492    /// single node; a Raft cluster holds it per node, and a cross-node claim race
493    /// would additionally need a consensus-level conditional apply — a documented
494    /// follow-up, not reachable in the dominant single-node topology.
495    pub async fn set_site_config(
496        &self,
497        project: ProjectRef<'_>,
498        site: &str,
499        config: &SiteConfig,
500    ) -> Result<(), DeployError> {
501        let _claim = self.domain_claim_lock.lock().await;
502        self.set_site_config_locked(project, site, config).await
503    }
504
505    /// [`set_site_config`](Self::set_site_config) assuming the domain-claim lock
506    /// is **already held** — so [`attach_verified_domain`](Self::attach_verified_domain)
507    /// can extend a config under the same lock without re-entering it (the lock
508    /// is not reentrant).
509    async fn set_site_config_locked(
510        &self,
511        project: ProjectRef<'_>,
512        site: &str,
513        config: &SiteConfig,
514    ) -> Result<(), DeployError> {
515        let owner = DomainOwner::new(project.as_str(), site);
516        // Refuse any host/wildcard already claimed by another (project, site) before
517        // writing anything (the hijack guard). A host this site already owns, or one
518        // that is unclaimed, passes.
519        for host in config.domains.exact_hosts() {
520            self.ensure_host_claimable(&keys::domain(host), host, &owner)
521                .await?;
522        }
523        for wildcard in &config.domains.wildcards {
524            if let Some(suffix) = wildcard.strip_prefix("*.") {
525                self.ensure_host_claimable(&keys::wildcard(suffix), wildcard, &owner)
526                    .await?;
527            }
528        }
529
530        let body = config.to_json()?;
531        let hash = sha256_hex(&body);
532
533        let mut ops = Vec::new();
534        if let Some(old) = self.get_site_config(project, site).await? {
535            for host in old.domains.exact_hosts() {
536                ops.push(WriteOp::Delete(keys::domain(host)));
537            }
538            for wildcard in &old.domains.wildcards {
539                if let Some(suffix) = wildcard.strip_prefix("*.") {
540                    ops.push(WriteOp::Delete(keys::wildcard(suffix)));
541                }
542            }
543        }
544
545        // Immutable body (idempotent put) + the mutable pointer flip.
546        ops.push(WriteOp::Put(keys::site_config_blob(&hash), body));
547        ops.push(WriteOp::Put(
548            keys::site_pointer(project, site),
549            hash.into_bytes(),
550        ));
551
552        // The domain-routing index value carries the owning `(project, site)` so a
553        // request `Host` resolves to both (the key stays global — hosts are unique).
554        let owner_bytes = owner.to_bytes();
555        for host in config.domains.exact_hosts() {
556            ops.push(WriteOp::Put(keys::domain(host), owner_bytes.clone()));
557        }
558        for wildcard in &config.domains.wildcards {
559            if let Some(suffix) = wildcard.strip_prefix("*.") {
560                ops.push(WriteOp::Put(keys::wildcard(suffix), owner_bytes.clone()));
561            }
562        }
563        self.kv.write_batch(ops).await?;
564        Ok(())
565    }
566
567    /// Error with [`DeployError::Conflict`] if index `key` (a `domain/<host>` or
568    /// `wildcard/<suffix>` entry) is already held by an owner other than `owner`.
569    /// `label` is the host as written, for the message. Must be called with the
570    /// domain-claim lock held. The stored value is a tolerant
571    /// [`DomainOwner`] (a bare-string legacy value reads as the `default` project).
572    async fn ensure_host_claimable(
573        &self,
574        key: &str,
575        label: &str,
576        owner: &DomainOwner,
577    ) -> Result<(), DeployError> {
578        if let Some(bytes) = self.kv.get(key).await? {
579            let held = DomainOwner::from_bytes(&bytes);
580            if &held != owner {
581                return Err(DeployError::Conflict(format!(
582                    "{label} is already attached to site `{}` in project `{}`",
583                    held.site, held.project
584                )));
585            }
586        }
587        Ok(())
588    }
589
590    /// Resolve a request `Host` to its owning `(project, site)`: exact match first,
591    /// then wildcard suffixes from most specific to least (so `*.example.com`
592    /// matches `a.b.example.com`). The index value is a tolerant [`DomainOwner`]
593    /// (a legacy bare-string value reads as the `default` project).
594    pub async fn resolve_site_by_host(
595        &self,
596        host: &str,
597    ) -> Result<Option<DomainOwner>, DeployError> {
598        // Canonicalize once so the `Host` a client sends matches the canonical
599        // key written by `set_site_config` regardless of case / trailing dot.
600        let host = canon_host(host);
601        let host = host.as_str();
602        if let Some(bytes) = self.kv.get(&keys::domain(host)).await? {
603            return Ok(Some(DomainOwner::from_bytes(&bytes)));
604        }
605        let mut rest = host;
606        while let Some((_, parent)) = rest.split_once('.') {
607            if let Some(bytes) = self.kv.get(&keys::wildcard(parent)).await? {
608                return Ok(Some(DomainOwner::from_bytes(&bytes)));
609            }
610            rest = parent;
611        }
612        Ok(None)
613    }
614
615    /// Every known site name in `project`, sorted and de-duplicated. A site is
616    /// "known" if it has a current deployment, a config, or activation history —
617    /// so a configured-but-not-yet-deployed site (or vice versa) still appears.
618    /// Backs `GET /api/sites`. (Broader than [`list_sites`](Self::list_sites),
619    /// which is just the currently-deployed sites the scheduler runs.)
620    pub async fn all_sites(&self, project: ProjectRef<'_>) -> Result<Vec<String>, DeployError> {
621        let mut sites = BTreeSet::new();
622        for prefix in [
623            keys::current_prefix(project),
624            keys::site_prefix(project),
625            keys::history_prefix(project),
626        ] {
627            for key in self.kv.list_prefix(&prefix).await? {
628                if let Some(name) = key.strip_prefix(&prefix) {
629                    if !name.is_empty() {
630                        sites.insert(name.to_string());
631                    }
632                }
633            }
634        }
635        Ok(sites.into_iter().collect())
636    }
637
638    /// Every `(project, site)` known across **all** projects — the cross-project
639    /// fan-out backing the scheduler and admin surfaces. Discovers the project set
640    /// by scanning [`keys::PROJECT_ROOT`], then unions each project's `all_sites`.
641    pub async fn all_sites_all(&self) -> Result<Vec<(String, String)>, DeployError> {
642        let mut out = Vec::new();
643        for project in self.discover_projects().await? {
644            for site in self.all_sites(ProjectRef::new(&project)).await? {
645                out.push((project.clone(), site));
646            }
647        }
648        Ok(out)
649    }
650
651    /// The distinct project names with any record in the store (the segment after
652    /// [`keys::PROJECT_ROOT`]). Independent of whether a `projectmeta/<name>`
653    /// pointer exists, so a fan-out reaches projects created only implicitly (e.g.
654    /// pre-migration data re-keyed under `default`).
655    pub async fn discover_projects(&self) -> Result<Vec<String>, DeployError> {
656        let mut projects = BTreeSet::new();
657        for key in self.kv.list_prefix(keys::PROJECT_ROOT).await? {
658            if let Some(p) = keys::project_of_key(&key) {
659                projects.insert(p.to_string());
660            }
661        }
662        Ok(projects.into_iter().collect())
663    }
664
665    // ---- Top-level functions (PLAN-faas FA-2) --------------------------------
666    // Independently-versioned functions stored as one JSON record per name under
667    // `project/<proj>/functions/<name>`, referencing content-addressed component
668    // blobs. Reuses the blob store + KV; the deploy/alias immutability model applies
669    // (a version id is its component's content hash).
670
671    /// Store a top-level function's record (its versions, active, aliases).
672    pub async fn put_function(
673        &self,
674        project: ProjectRef<'_>,
675        f: &crate::function::Function,
676    ) -> Result<(), DeployError> {
677        let bytes = serde_json::to_vec(f).map_err(|e| DeployError::Serde(e.to_string()))?;
678        self.kv
679            .put(
680                &crate::function::keys::meta(project.as_str(), &f.name),
681                bytes,
682            )
683            .await?;
684        Ok(())
685    }
686
687    /// Load a stored function record, if any.
688    pub async fn get_function(
689        &self,
690        project: ProjectRef<'_>,
691        name: &str,
692    ) -> Result<Option<crate::function::Function>, DeployError> {
693        match self
694            .kv
695            .get(&crate::function::keys::meta(project.as_str(), name))
696            .await?
697        {
698            Some(bytes) => Ok(Some(
699                serde_json::from_slice(&bytes).map_err(|e| DeployError::Serde(e.to_string()))?,
700            )),
701            None => Ok(None),
702        }
703    }
704
705    /// List all stored (top-level) functions in `project`.
706    ///
707    /// Only the `…/functions/<name>` *meta* keys are function records; the
708    /// `…/functions/<name>/{versions,alias,triggers,invocations,idem}/…` sub-keys
709    /// are skipped by requiring the suffix to hold no further `/`.
710    pub async fn list_stored_functions(
711        &self,
712        project: ProjectRef<'_>,
713    ) -> Result<Vec<crate::function::Function>, DeployError> {
714        let prefix = crate::function::keys::functions_prefix(project.as_str());
715        let mut out = Vec::new();
716        for key in self.kv.list_prefix(&prefix).await? {
717            if key[prefix.len()..].contains('/') {
718                continue;
719            }
720            if let Some(bytes) = self.kv.get(&key).await? {
721                if let Ok(f) = serde_json::from_slice(&bytes) {
722                    out.push(f);
723                }
724            }
725        }
726        Ok(out)
727    }
728
729    /// Delete a stored function. Returns whether it existed. The component blobs
730    /// are content-addressed + shared, so they are left to `prune`.
731    pub async fn delete_function(
732        &self,
733        project: ProjectRef<'_>,
734        name: &str,
735    ) -> Result<bool, DeployError> {
736        let key = crate::function::keys::meta(project.as_str(), name);
737        let existed = self.kv.get(&key).await?.is_some();
738        self.kv.delete(&key).await?;
739        Ok(existed)
740    }
741
742    // ---- function triggers (FA-3 scheduled / FA-5 event sources) ------------
743
744    /// Persist (create or replace) a stored trigger on a function.
745    pub async fn put_trigger(
746        &self,
747        project: ProjectRef<'_>,
748        function: &str,
749        trigger: &crate::function::FunctionTrigger,
750    ) -> Result<(), DeployError> {
751        let bytes = serde_json::to_vec(trigger).map_err(|e| DeployError::Serde(e.to_string()))?;
752        self.kv
753            .put(
754                &crate::function::keys::trigger(project.as_str(), function, &trigger.id),
755                bytes,
756            )
757            .await?;
758        Ok(())
759    }
760
761    /// Load one stored trigger, if any.
762    pub async fn get_trigger(
763        &self,
764        project: ProjectRef<'_>,
765        function: &str,
766        id: &str,
767    ) -> Result<Option<crate::function::FunctionTrigger>, DeployError> {
768        match self
769            .kv
770            .get(&crate::function::keys::trigger(
771                project.as_str(),
772                function,
773                id,
774            ))
775            .await?
776        {
777            Some(bytes) => Ok(Some(
778                serde_json::from_slice(&bytes).map_err(|e| DeployError::Serde(e.to_string()))?,
779            )),
780            None => Ok(None),
781        }
782    }
783
784    /// List a function's stored triggers.
785    pub async fn list_triggers(
786        &self,
787        project: ProjectRef<'_>,
788        function: &str,
789    ) -> Result<Vec<crate::function::FunctionTrigger>, DeployError> {
790        let prefix = crate::function::keys::triggers_prefix(project.as_str(), function);
791        let mut out = Vec::new();
792        for key in self.kv.list_prefix(&prefix).await? {
793            if let Some(bytes) = self.kv.get(&key).await? {
794                if let Ok(t) = serde_json::from_slice(&bytes) {
795                    out.push(t);
796                }
797            }
798        }
799        Ok(out)
800    }
801
802    /// Delete a stored trigger. Returns whether it existed.
803    pub async fn delete_trigger(
804        &self,
805        project: ProjectRef<'_>,
806        function: &str,
807        id: &str,
808    ) -> Result<bool, DeployError> {
809        let key = crate::function::keys::trigger(project.as_str(), function, id);
810        let existed = self.kv.get(&key).await?.is_some();
811        self.kv.delete(&key).await?;
812        Ok(existed)
813    }
814
815    // ---- function invocations (FA-3) ---------------------------------------
816
817    /// Persist (create or update) an invocation record.
818    pub async fn put_invocation(
819        &self,
820        project: ProjectRef<'_>,
821        inv: &crate::function::Invocation,
822    ) -> Result<(), DeployError> {
823        let bytes = serde_json::to_vec(inv).map_err(|e| DeployError::Serde(e.to_string()))?;
824        self.kv
825            .put(
826                &crate::function::keys::invocation(project.as_str(), &inv.function, &inv.id),
827                bytes,
828            )
829            .await?;
830        Ok(())
831    }
832
833    /// Load one invocation record, if any.
834    pub async fn get_invocation(
835        &self,
836        project: ProjectRef<'_>,
837        function: &str,
838        id: &str,
839    ) -> Result<Option<crate::function::Invocation>, DeployError> {
840        match self
841            .kv
842            .get(&crate::function::keys::invocation(
843                project.as_str(),
844                function,
845                id,
846            ))
847            .await?
848        {
849            Some(bytes) => Ok(Some(
850                serde_json::from_slice(&bytes).map_err(|e| DeployError::Serde(e.to_string()))?,
851            )),
852            None => Ok(None),
853        }
854    }
855
856    /// List a function's invocation records (queue scan / poll listing).
857    pub async fn list_invocations(
858        &self,
859        project: ProjectRef<'_>,
860        function: &str,
861    ) -> Result<Vec<crate::function::Invocation>, DeployError> {
862        let prefix = crate::function::keys::invocations_prefix(project.as_str(), function);
863        let mut out = Vec::new();
864        for key in self.kv.list_prefix(&prefix).await? {
865            if let Some(bytes) = self.kv.get(&key).await? {
866                if let Ok(inv) = serde_json::from_slice(&bytes) {
867                    out.push(inv);
868                }
869            }
870        }
871        Ok(out)
872    }
873
874    /// Bind an idempotency key to an invocation id (the dedup pointer). The value
875    /// is the raw invocation id.
876    pub async fn put_idempotency(
877        &self,
878        project: ProjectRef<'_>,
879        function: &str,
880        key: &str,
881        invocation_id: &str,
882    ) -> Result<(), DeployError> {
883        self.kv
884            .put(
885                &crate::function::keys::idempotency(project.as_str(), function, key),
886                invocation_id.as_bytes().to_vec(),
887            )
888            .await?;
889        Ok(())
890    }
891
892    /// Resolve an idempotency key to its invocation id, if one was recorded.
893    pub async fn get_idempotency(
894        &self,
895        project: ProjectRef<'_>,
896        function: &str,
897        key: &str,
898    ) -> Result<Option<String>, DeployError> {
899        match self
900            .kv
901            .get(&crate::function::keys::idempotency(
902                project.as_str(),
903                function,
904                key,
905            ))
906            .await?
907        {
908            Some(bytes) => Ok(Some(String::from_utf8_lossy(&bytes).into_owned())),
909            None => Ok(None),
910        }
911    }
912
913    // ---- function metering (FA-4) ------------------------------------------
914
915    /// The usage aggregate for a function, if any has been recorded.
916    pub async fn get_metering(
917        &self,
918        project: ProjectRef<'_>,
919        function: &str,
920    ) -> Result<Option<crate::function::Metering>, DeployError> {
921        match self
922            .kv
923            .get(&crate::function::keys::metering(project.as_str(), function))
924            .await?
925        {
926            Some(bytes) => Ok(Some(
927                serde_json::from_slice(&bytes).map_err(|e| DeployError::Serde(e.to_string()))?,
928            )),
929            None => Ok(None),
930        }
931    }
932
933    /// Persist a function's usage aggregate.
934    pub async fn put_metering(
935        &self,
936        project: ProjectRef<'_>,
937        metering: &crate::function::Metering,
938    ) -> Result<(), DeployError> {
939        let bytes = serde_json::to_vec(metering).map_err(|e| DeployError::Serde(e.to_string()))?;
940        self.kv
941            .put(
942                &crate::function::keys::metering(project.as_str(), &metering.function),
943                bytes,
944            )
945            .await?;
946        Ok(())
947    }
948
949    /// List every function's usage aggregate in `project` (the `functions usage`
950    /// fan-out).
951    pub async fn list_metering(
952        &self,
953        project: ProjectRef<'_>,
954    ) -> Result<Vec<crate::function::Metering>, DeployError> {
955        let prefix = crate::function::keys::metering_prefix(project.as_str());
956        let mut out = Vec::new();
957        for key in self.kv.list_prefix(&prefix).await? {
958            if let Some(bytes) = self.kv.get(&key).await? {
959                if let Ok(m) = serde_json::from_slice(&bytes) {
960                    out.push(m);
961                }
962            }
963        }
964        Ok(out)
965    }
966
967    // ---- blob-change notification ledger (FA-5b2) --------------------------
968
969    /// Record the cloud notification pipeline provisioned for a function's
970    /// blob-change trigger, so it can be retracted later.
971    pub async fn put_managed_notification(
972        &self,
973        project: ProjectRef<'_>,
974        record: &crate::blob_notify::ManagedNotification,
975    ) -> Result<(), DeployError> {
976        let bytes = record
977            .to_json()
978            .map_err(|e| DeployError::Serde(e.to_string()))?;
979        self.kv
980            .put(
981                &crate::blob_notify::blobnotify_key(
982                    project.as_str(),
983                    &record.function,
984                    &record.prefix,
985                ),
986                bytes,
987            )
988            .await?;
989        Ok(())
990    }
991
992    /// The notification ledger entry for `(function, prefix)`, if any.
993    pub async fn get_managed_notification(
994        &self,
995        project: ProjectRef<'_>,
996        function: &str,
997        prefix: &str,
998    ) -> Result<Option<crate::blob_notify::ManagedNotification>, DeployError> {
999        match self
1000            .kv
1001            .get(&crate::blob_notify::blobnotify_key(
1002                project.as_str(),
1003                function,
1004                prefix,
1005            ))
1006            .await?
1007        {
1008            Some(bytes) => Ok(Some(
1009                crate::blob_notify::ManagedNotification::from_json(&bytes)
1010                    .map_err(|e| DeployError::Serde(e.to_string()))?,
1011            )),
1012            None => Ok(None),
1013        }
1014    }
1015
1016    /// All notification ledger entries for a function.
1017    pub async fn list_managed_notifications(
1018        &self,
1019        project: ProjectRef<'_>,
1020        function: &str,
1021    ) -> Result<Vec<crate::blob_notify::ManagedNotification>, DeployError> {
1022        let prefix = crate::blob_notify::blobnotify_function_prefix(project.as_str(), function);
1023        let mut out = Vec::new();
1024        for key in self.kv.list_prefix(&prefix).await? {
1025            if let Some(bytes) = self.kv.get(&key).await? {
1026                if let Ok(record) = crate::blob_notify::ManagedNotification::from_json(&bytes) {
1027                    out.push(record);
1028                }
1029            }
1030        }
1031        Ok(out)
1032    }
1033
1034    /// Drop a notification ledger entry (after its resources are retracted).
1035    pub async fn remove_managed_notification(
1036        &self,
1037        project: ProjectRef<'_>,
1038        function: &str,
1039        prefix: &str,
1040    ) -> Result<(), DeployError> {
1041        self.kv
1042            .delete(&crate::blob_notify::blobnotify_key(
1043                project.as_str(),
1044                function,
1045                prefix,
1046            ))
1047            .await?;
1048        Ok(())
1049    }
1050
1051    // ---- workflows (FA-6) --------------------------------------------------
1052
1053    /// Persist a workflow definition.
1054    pub async fn put_workflow(
1055        &self,
1056        project: ProjectRef<'_>,
1057        workflow: &crate::workflow::Workflow,
1058    ) -> Result<(), DeployError> {
1059        let bytes = serde_json::to_vec(workflow).map_err(|e| DeployError::Serde(e.to_string()))?;
1060        self.kv
1061            .put(
1062                &crate::workflow::keys::definition(project.as_str(), &workflow.name),
1063                bytes,
1064            )
1065            .await?;
1066        Ok(())
1067    }
1068
1069    /// Load a workflow definition, if any.
1070    pub async fn get_workflow(
1071        &self,
1072        project: ProjectRef<'_>,
1073        name: &str,
1074    ) -> Result<Option<crate::workflow::Workflow>, DeployError> {
1075        match self
1076            .kv
1077            .get(&crate::workflow::keys::definition(project.as_str(), name))
1078            .await?
1079        {
1080            Some(bytes) => Ok(Some(
1081                serde_json::from_slice(&bytes).map_err(|e| DeployError::Serde(e.to_string()))?,
1082            )),
1083            None => Ok(None),
1084        }
1085    }
1086
1087    /// List all workflow definitions in `project` (skips the
1088    /// `…/workflows/<name>/runs/…` sub-keys by requiring the suffix to hold no
1089    /// further `/`).
1090    pub async fn list_workflows(
1091        &self,
1092        project: ProjectRef<'_>,
1093    ) -> Result<Vec<crate::workflow::Workflow>, DeployError> {
1094        let prefix = crate::workflow::keys::definitions_prefix(project.as_str());
1095        let mut out = Vec::new();
1096        for key in self.kv.list_prefix(&prefix).await? {
1097            if key[prefix.len()..].contains('/') {
1098                continue;
1099            }
1100            if let Some(bytes) = self.kv.get(&key).await? {
1101                if let Ok(w) = serde_json::from_slice(&bytes) {
1102                    out.push(w);
1103                }
1104            }
1105        }
1106        Ok(out)
1107    }
1108
1109    /// Delete a workflow definition. Returns whether it existed. Runs are left in
1110    /// place (terminal history); `prune` removes them.
1111    pub async fn delete_workflow(
1112        &self,
1113        project: ProjectRef<'_>,
1114        name: &str,
1115    ) -> Result<bool, DeployError> {
1116        let key = crate::workflow::keys::definition(project.as_str(), name);
1117        let existed = self.kv.get(&key).await?.is_some();
1118        self.kv.delete(&key).await?;
1119        Ok(existed)
1120    }
1121
1122    /// Persist (create or update) a workflow run.
1123    pub async fn put_workflow_run(
1124        &self,
1125        project: ProjectRef<'_>,
1126        run: &crate::workflow::WorkflowRun,
1127    ) -> Result<(), DeployError> {
1128        let bytes = serde_json::to_vec(run).map_err(|e| DeployError::Serde(e.to_string()))?;
1129        self.kv
1130            .put(
1131                &crate::workflow::keys::run(project.as_str(), &run.workflow, &run.id),
1132                bytes,
1133            )
1134            .await?;
1135        Ok(())
1136    }
1137
1138    /// Load one workflow run, if any.
1139    pub async fn get_workflow_run(
1140        &self,
1141        project: ProjectRef<'_>,
1142        workflow: &str,
1143        id: &str,
1144    ) -> Result<Option<crate::workflow::WorkflowRun>, DeployError> {
1145        match self
1146            .kv
1147            .get(&crate::workflow::keys::run(project.as_str(), workflow, id))
1148            .await?
1149        {
1150            Some(bytes) => Ok(Some(
1151                serde_json::from_slice(&bytes).map_err(|e| DeployError::Serde(e.to_string()))?,
1152            )),
1153            None => Ok(None),
1154        }
1155    }
1156
1157    /// List a workflow's runs (the executor drain scan / poll listing).
1158    pub async fn list_workflow_runs(
1159        &self,
1160        project: ProjectRef<'_>,
1161        workflow: &str,
1162    ) -> Result<Vec<crate::workflow::WorkflowRun>, DeployError> {
1163        let prefix = crate::workflow::keys::runs_prefix(project.as_str(), workflow);
1164        let mut out = Vec::new();
1165        for key in self.kv.list_prefix(&prefix).await? {
1166            if let Some(bytes) = self.kv.get(&key).await? {
1167                if let Ok(run) = serde_json::from_slice(&bytes) {
1168                    out.push(run);
1169                }
1170            }
1171        }
1172        Ok(out)
1173    }
1174
1175    /// The ownership-verification challenge for `(site, host)`, if one exists.
1176    pub async fn get_domain_verification(
1177        &self,
1178        project: ProjectRef<'_>,
1179        site: &SiteName,
1180        host: &str,
1181    ) -> Result<Option<DomainVerification>, DeployError> {
1182        match self
1183            .kv
1184            .get(&keys::domain_verification(project, site.as_str(), host))
1185            .await?
1186        {
1187            Some(bytes) => Ok(Some(DomainVerification::from_json(&bytes)?)),
1188            None => Ok(None),
1189        }
1190    }
1191
1192    /// All ownership challenges for `site` (pending and verified), by host.
1193    pub async fn list_domain_verifications(
1194        &self,
1195        project: ProjectRef<'_>,
1196        site: &SiteName,
1197    ) -> Result<Vec<DomainVerification>, DeployError> {
1198        let prefix = keys::domain_verification_prefix(project, site.as_str());
1199        let mut out = Vec::new();
1200        for key in self.kv.list_prefix(&prefix).await? {
1201            if let Some(bytes) = self.kv.get(&key).await? {
1202                out.push(DomainVerification::from_json(&bytes)?);
1203            }
1204        }
1205        out.sort_by(|a, b| a.host.cmp(&b.host));
1206        Ok(out)
1207    }
1208
1209    /// Every ownership challenge across **all** projects, each paired with its
1210    /// owning `(project, site)` — the enumeration behind the auto-complete
1211    /// reconcile loop. Fans out over [`discover_projects`](Self::discover_projects)
1212    /// and scans each project's `domainverify/` keyspace, so a challenge started
1213    /// before a site's first publish (its site isn't in
1214    /// [`all_sites`](Self::all_sites) yet) is still found and can self-heal.
1215    pub async fn list_all_domain_verifications(
1216        &self,
1217    ) -> Result<Vec<(String, String, DomainVerification)>, DeployError> {
1218        let mut out = Vec::new();
1219        for project in self.discover_projects().await? {
1220            let pref = ProjectRef::new(&project);
1221            let scan = keys::domain_verification_project_prefix(pref);
1222            for key in self.kv.list_prefix(&scan).await? {
1223                // Key shape: `project/<proj>/domainverify/<site>/<host>`; the site
1224                // is the first segment after the scan prefix (no `/` in a site name).
1225                let Some((site, _host)) = key
1226                    .strip_prefix(&scan)
1227                    .and_then(|rest| rest.split_once('/'))
1228                else {
1229                    continue;
1230                };
1231                if let Some(bytes) = self.kv.get(&key).await? {
1232                    out.push((
1233                        project.clone(),
1234                        site.to_string(),
1235                        DomainVerification::from_json(&bytes)?,
1236                    ));
1237                }
1238            }
1239        }
1240        Ok(out)
1241    }
1242
1243    /// Find a **pending HTTP** ownership challenge matching `(host, token)`
1244    /// across every project/site — the lookup behind the self-serve edge route
1245    /// `/.well-known/boatramp-domain-verification/<token>`. Matches on the
1246    /// normalized host, the HTTP method, an exact token match, and a non-expired
1247    /// challenge (`now_unix` gates the TTL), so a host pointed at this server can
1248    /// prove ownership before it is attached and deployed — closing the
1249    /// verify-before-attach chicken-and-egg. Returns the challenge so the caller
1250    /// can echo its token back.
1251    pub async fn find_pending_http_challenge(
1252        &self,
1253        host: &str,
1254        token: &str,
1255        now_unix: u64,
1256    ) -> Result<Option<DomainVerification>, DeployError> {
1257        let host = crate::domain_verify::normalize_host(host);
1258        // O(1): the `(host, token)` index names the owning `(project, site)`
1259        // directly (tolerant of the legacy bare-site value). Then load and **fully
1260        // re-validate** the challenge — so a stale index entry (left by a method
1261        // change / new token) can never serve the wrong thing.
1262        let Some(owner_bytes) = self
1263            .kv
1264            .get(&keys::http_challenge_index(&host, token))
1265            .await?
1266        else {
1267            return Ok(None);
1268        };
1269        let owner = DomainOwner::from_bytes(&owner_bytes);
1270        let site = SiteName::new(owner.site);
1271        let Some(v) = self
1272            .get_domain_verification(ProjectRef::new(&owner.project), &site, &host)
1273            .await?
1274        else {
1275            return Ok(None);
1276        };
1277        if v.method == VerificationMethod::Http
1278            && v.host == host
1279            && v.matches(token)
1280            && !v.is_expired(now_unix)
1281        {
1282            Ok(Some(v))
1283        } else {
1284            Ok(None)
1285        }
1286    }
1287
1288    async fn put_domain_verification(
1289        &self,
1290        project: ProjectRef<'_>,
1291        site: &SiteName,
1292        verification: &DomainVerification,
1293    ) -> Result<(), DeployError> {
1294        let mut ops = vec![WriteOp::Put(
1295            keys::domain_verification(project, site.as_str(), &verification.host),
1296            verification.to_json()?,
1297        )];
1298        // Maintain the self-serve `(host, token)` index for HTTP challenges. Its
1299        // value carries the owning `(project, site)` so the self-serve edge route
1300        // can resolve the project-scoped challenge. A stale entry left by a later
1301        // method/token change is harmless — the lookup re-validates the loaded
1302        // challenge — so no delete-on-replace is needed here.
1303        if verification.method == VerificationMethod::Http {
1304            ops.push(WriteOp::Put(
1305                keys::http_challenge_index(&verification.host, &verification.token),
1306                DomainOwner::new(project.as_str(), site.as_str()).to_bytes(),
1307            ));
1308        }
1309        self.kv.write_batch(ops).await?;
1310        Ok(())
1311    }
1312
1313    // ---- managed-DNS ledger (records boatramp pointed at the server) ----------
1314
1315    /// The managed-DNS ledger for `(site, host)`, if boatramp has pointed it.
1316    pub async fn get_managed_dns(
1317        &self,
1318        project: ProjectRef<'_>,
1319        site: &SiteName,
1320        host: &str,
1321    ) -> Result<Option<crate::dns_managed::ManagedDns>, DeployError> {
1322        match self
1323            .kv
1324            .get(&crate::dns_managed::dnsmanaged_key(
1325                project.as_str(),
1326                site.as_str(),
1327                host,
1328            ))
1329            .await?
1330        {
1331            Some(bytes) => Ok(Some(crate::dns_managed::ManagedDns::from_json(&bytes)?)),
1332            None => Ok(None),
1333        }
1334    }
1335
1336    /// Record (create/replace) the managed-DNS ledger entry for a host.
1337    pub async fn set_managed_dns(
1338        &self,
1339        project: ProjectRef<'_>,
1340        site: &SiteName,
1341        ledger: &crate::dns_managed::ManagedDns,
1342    ) -> Result<(), DeployError> {
1343        self.kv
1344            .put(
1345                &crate::dns_managed::dnsmanaged_key(project.as_str(), site.as_str(), &ledger.host),
1346                ledger.to_json()?,
1347            )
1348            .await?;
1349        Ok(())
1350    }
1351
1352    /// Drop a host's managed-DNS ledger entry (after its records are retracted).
1353    pub async fn remove_managed_dns(
1354        &self,
1355        project: ProjectRef<'_>,
1356        site: &SiteName,
1357        host: &str,
1358    ) -> Result<(), DeployError> {
1359        self.kv
1360            .delete(&crate::dns_managed::dnsmanaged_key(
1361                project.as_str(),
1362                site.as_str(),
1363                host,
1364            ))
1365            .await?;
1366        Ok(())
1367    }
1368
1369    /// All managed-DNS ledger entries for `site` (the reconcile sweep reads these
1370    /// to retract records whose host is no longer attached).
1371    pub async fn list_managed_dns(
1372        &self,
1373        project: ProjectRef<'_>,
1374        site: &SiteName,
1375    ) -> Result<Vec<crate::dns_managed::ManagedDns>, DeployError> {
1376        let prefix = crate::dns_managed::dnsmanaged_site_prefix(project.as_str(), site.as_str());
1377        let mut out = Vec::new();
1378        for key in self.kv.list_prefix(&prefix).await? {
1379            if let Some(bytes) = self.kv.get(&key).await? {
1380                out.push(crate::dns_managed::ManagedDns::from_json(&bytes)?);
1381            }
1382        }
1383        out.sort_by(|a, b| a.host.cmp(&b.host));
1384        Ok(out)
1385    }
1386
1387    /// Start (or restart) an ownership challenge for `(site, host)`.
1388    ///
1389    /// Returns the existing challenge if one is already pending under the same
1390    /// method (so re-running `domain add` shows the same token instead of
1391    /// invalidating an in-progress setup); otherwise mints a fresh one. A
1392    /// challenge that's already `verified` is returned untouched.
1393    pub async fn start_domain_verification(
1394        &self,
1395        project: ProjectRef<'_>,
1396        site: &SiteName,
1397        host: &str,
1398        method: VerificationMethod,
1399        now_unix: u64,
1400    ) -> Result<DomainVerification, DeployError> {
1401        if let Some(existing) = self.get_domain_verification(project, site, host).await? {
1402            if existing.verified || existing.method == method {
1403                return Ok(existing);
1404            }
1405        }
1406        // Cap the pending set per site. An unbounded number of pending challenges
1407        // would let a tenant seed many hosts that the auto-complete reconcile loop
1408        // then re-probes every tick (a low-and-slow outbound-probe amplifier to
1409        // arbitrary public hosts). A generous ceiling bounds both storage and the
1410        // per-tick fan-out without limiting real use. (Re-running `domain add` on an
1411        // existing host returns early above, so this only gates genuinely-new hosts.)
1412        const MAX_PENDING_VERIFICATIONS_PER_SITE: usize = 64;
1413        let pending = self
1414            .list_domain_verifications(project, site)
1415            .await?
1416            .into_iter()
1417            .filter(|v| !v.verified && !v.is_expired(now_unix))
1418            .count();
1419        if pending >= MAX_PENDING_VERIFICATIONS_PER_SITE {
1420            return Err(DeployError::Conflict(format!(
1421                "too many pending domain verifications for site {site} \
1422                 (max {MAX_PENDING_VERIFICATIONS_PER_SITE}); verify or remove some first"
1423            )));
1424        }
1425        let verification = DomainVerification::new(host, method, now_unix);
1426        self.put_domain_verification(project, site, &verification)
1427            .await?;
1428        Ok(verification)
1429    }
1430
1431    /// Whether `(site, host)` has a confirmed ownership challenge.
1432    pub async fn is_domain_verified(
1433        &self,
1434        project: ProjectRef<'_>,
1435        site: &SiteName,
1436        host: &str,
1437    ) -> Result<bool, DeployError> {
1438        Ok(self
1439            .get_domain_verification(project, site, host)
1440            .await?
1441            .is_some_and(|v| v.verified))
1442    }
1443
1444    /// Mark `(site, host)`'s challenge verified and persist it. Errors if no
1445    /// challenge has been started.
1446    pub async fn mark_domain_verified(
1447        &self,
1448        project: ProjectRef<'_>,
1449        site: &SiteName,
1450        host: &str,
1451    ) -> Result<DomainVerification, DeployError> {
1452        let mut verification = self
1453            .get_domain_verification(project, site, host)
1454            .await?
1455            .ok_or_else(|| {
1456                DeployError::NotFound(format!("no verification challenge for {host}"))
1457            })?;
1458        verification.verified = true;
1459        self.put_domain_verification(project, site, &verification)
1460            .await?;
1461        Ok(verification)
1462    }
1463
1464    /// Drop the verification record for `(site, host)` (when detaching a host).
1465    /// Returns whether one existed.
1466    pub async fn remove_domain_verification(
1467        &self,
1468        project: ProjectRef<'_>,
1469        site: &SiteName,
1470        host: &str,
1471    ) -> Result<bool, DeployError> {
1472        let Some(v) = self.get_domain_verification(project, site, host).await? else {
1473            return Ok(false);
1474        };
1475        let mut ops = vec![WriteOp::Delete(keys::domain_verification(
1476            project,
1477            site.as_str(),
1478            host,
1479        ))];
1480        if v.method == VerificationMethod::Http {
1481            ops.push(WriteOp::Delete(keys::http_challenge_index(
1482                &v.host, &v.token,
1483            )));
1484        }
1485        self.kv.write_batch(ops).await?;
1486        Ok(true)
1487    }
1488
1489    /// Attach a verified `host` to the site's [`SiteConfig`] so it routes by
1490    /// `Host` and becomes eligible for ACME. The host's *kind* is inferred:
1491    /// a `*.`-prefixed host is a wildcard; otherwise it becomes the primary if
1492    /// the site has none, else an alias.
1493    ///
1494    /// Refuses an unverified host — this is the server-enforced gate that keeps
1495    /// unowned domains out of routing and out of cert issuance. (The wildcard /
1496    /// primary base name is verified; see [`normalize_host`].)
1497    ///
1498    /// [`normalize_host`]: crate::domain_verify::normalize_host
1499    pub async fn attach_verified_domain(
1500        &self,
1501        project: ProjectRef<'_>,
1502        site: &SiteName,
1503        host: &str,
1504    ) -> Result<SiteConfig, DeployError> {
1505        if !self.is_domain_verified(project, site, host).await? {
1506            return Err(DeployError::NotFound(format!(
1507                "{host} is not verified for {site}; run domain verification first"
1508            )));
1509        }
1510        // A wildcard needs the stronger DNS proof — an HTTP token at the base host
1511        // proves control of one name, not the whole subtree (ACME requires DNS-01
1512        // for a wildcard cert for the same reason).
1513        if host.trim_start().starts_with("*.")
1514            && self
1515                .get_domain_verification(project, site, host)
1516                .await?
1517                .map(|v| v.method)
1518                != Some(VerificationMethod::Dns)
1519        {
1520            return Err(DeployError::Conflict(format!(
1521                "wildcard {host} must be verified via DNS \
1522                 (an HTTP token proves only the base host, not the subtree)"
1523            )));
1524        }
1525        // Hold the claim lock across the whole read-modify-write so a concurrent
1526        // attach (to this site or another) can't interleave between our read and
1527        // the index write. `set_site_config_locked` runs under the same lock.
1528        let _claim = self.domain_claim_lock.lock().await;
1529        let mut config = self
1530            .get_site_config(project, site.as_str())
1531            .await?
1532            .unwrap_or_default();
1533        let domains = &mut config.domains;
1534        if let Some(suffix) = host.strip_prefix("*.") {
1535            let wildcard = format!("*.{}", suffix.trim_end_matches('.').to_ascii_lowercase());
1536            if !domains.wildcards.contains(&wildcard) {
1537                domains.wildcards.push(wildcard);
1538            }
1539        } else {
1540            let host = host.trim().trim_end_matches('.').to_ascii_lowercase();
1541            if domains.primary.is_none() {
1542                domains.primary = Some(host);
1543            } else if domains.primary.as_deref() != Some(host.as_str())
1544                && !domains.aliases.contains(&host)
1545            {
1546                domains.aliases.push(host);
1547            }
1548        }
1549        self.set_site_config_locked(project, site.as_str(), &config)
1550            .await?;
1551        Ok(config)
1552    }
1553
1554    /// Point a named alias (`staging`, `preview-pr-42`, …) at a deployment id.
1555    ///
1556    /// Like [`activate`](Self::activate), this refuses a deployment whose blobs
1557    /// are not all present, so an alias never resolves to an incomplete deploy.
1558    /// Aliased deployments are retention-protected from garbage collection.
1559    pub async fn set_alias(
1560        &self,
1561        project: ProjectRef<'_>,
1562        site: &str,
1563        name: &str,
1564        id: &str,
1565    ) -> Result<(), DeployError> {
1566        let manifest = self
1567            .get_manifest(id)
1568            .await?
1569            .ok_or_else(|| DeployError::NotFound(format!("deployment {id}")))?;
1570        let missing = self.missing_blobs(&manifest).await?;
1571        if !missing.is_empty() {
1572            return Err(DeployError::Incomplete(missing));
1573        }
1574        self.kv
1575            .put(&keys::alias(project, site, name), id.as_bytes().to_vec())
1576            .await?;
1577        Ok(())
1578    }
1579
1580    /// Resolve a named alias to its deployment id, if set.
1581    pub async fn get_alias(
1582        &self,
1583        project: ProjectRef<'_>,
1584        site: &str,
1585        name: &str,
1586    ) -> Result<Option<String>, DeployError> {
1587        match self.kv.get(&keys::alias(project, site, name)).await? {
1588            Some(bytes) => Ok(Some(String::from_utf8_lossy(&bytes).into_owned())),
1589            None => Ok(None),
1590        }
1591    }
1592
1593    /// Remove a named alias; returns whether one existed.
1594    pub async fn remove_alias(
1595        &self,
1596        project: ProjectRef<'_>,
1597        site: &str,
1598        name: &str,
1599    ) -> Result<bool, DeployError> {
1600        let key = keys::alias(project, site, name);
1601        let existed = self.kv.get(&key).await?.is_some();
1602        if existed {
1603            self.kv.delete(&key).await?;
1604        }
1605        Ok(existed)
1606    }
1607
1608    /// All of a site's named aliases as `name → deployment id`, sorted by name.
1609    pub async fn list_aliases(
1610        &self,
1611        project: ProjectRef<'_>,
1612        site: &str,
1613    ) -> Result<BTreeMap<String, String>, DeployError> {
1614        let prefix = keys::alias_prefix(project, site);
1615        let mut out = BTreeMap::new();
1616        for key in self.kv.list_prefix(&prefix).await? {
1617            if let Some(bytes) = self.kv.get(&key).await? {
1618                let name = key.strip_prefix(&prefix).unwrap_or(&key).to_string();
1619                out.insert(name, String::from_utf8_lossy(&bytes).into_owned());
1620            }
1621        }
1622        Ok(out)
1623    }
1624
1625    /// Store metadata for an issued token (`authz/tokens/<id>`). The
1626    /// token itself is never stored — only this record, for `token ls`.
1627    /// Minting needs the root private key, so it happens in the
1628    /// caller (the API route / CLI), which then records the metadata here.
1629    pub async fn put_token_meta(&self, meta: &crate::authz::TokenMeta) -> Result<(), DeployError> {
1630        self.kv
1631            .put(
1632                &crate::authz::token_meta_key(&meta.revocation_id),
1633                serde_json::to_vec(meta)?,
1634            )
1635            .await?;
1636        Ok(())
1637    }
1638
1639    /// List metadata for all issued, non-revoked tokens.
1640    pub async fn list_token_meta(&self) -> Result<Vec<crate::authz::TokenMeta>, DeployError> {
1641        let mut out = Vec::new();
1642        for key in self.kv.list_prefix(crate::authz::TOKEN_META_PREFIX).await? {
1643            if let Some(bytes) = self.kv.get(&key).await? {
1644                if let Ok(meta) = serde_json::from_slice::<crate::authz::TokenMeta>(&bytes) {
1645                    out.push(meta);
1646                }
1647            }
1648        }
1649        Ok(out)
1650    }
1651
1652    /// Revoke an issued token by its revocation id (or a unique id prefix):
1653    /// write the `authz/revoked/<id>` marker and drop its metadata. Returns
1654    /// whether a matching token was found. The marker makes every node deny the
1655    /// token (and its attenuations) on the next request.
1656    pub async fn revoke_token(&self, id_or_prefix: &str) -> Result<bool, DeployError> {
1657        let ids: Vec<String> = self
1658            .list_token_meta()
1659            .await?
1660            .into_iter()
1661            .map(|m| m.revocation_id)
1662            .collect();
1663        let matches: Vec<&String> = ids
1664            .iter()
1665            .filter(|id| id.starts_with(id_or_prefix))
1666            .collect();
1667        if let [id] = matches.as_slice() {
1668            self.kv
1669                .put(&crate::authz::revoked_key(id), Vec::new())
1670                .await?;
1671            self.kv.delete(&crate::authz::token_meta_key(id)).await?;
1672            Ok(true)
1673        } else {
1674            Ok(false)
1675        }
1676    }
1677
1678    /// Whether a first-token bootstrap secret (identified by its SHA-256 hex) has
1679    /// already been redeemed. The marker persists, so a spent secret stays spent
1680    /// across restarts; rotating the secret yields a fresh hash that re-enables
1681    /// bootstrap (the recovery path).
1682    pub async fn bootstrap_consumed(&self, secret_hash: &str) -> Result<bool, DeployError> {
1683        Ok(self
1684            .kv
1685            .get(&crate::authz::bootstrap_key(secret_hash))
1686            .await?
1687            .is_some())
1688    }
1689
1690    /// Mark a bootstrap secret (by SHA-256 hex) consumed — single-use.
1691    pub async fn mark_bootstrap_consumed(&self, secret_hash: &str) -> Result<(), DeployError> {
1692        self.kv
1693            .put(&crate::authz::bootstrap_key(secret_hash), Vec::new())
1694            .await?;
1695        Ok(())
1696    }
1697
1698    /// Read the stored RBAC `AuthzPolicy` (`authz/policy`), or `None` when the
1699    /// built-in default is in effect.
1700    pub async fn get_authz_policy(&self) -> Result<Option<crate::authz::AuthzPolicy>, DeployError> {
1701        match self.kv.get(crate::authz::POLICY_KEY).await? {
1702            Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
1703            None => Ok(None),
1704        }
1705    }
1706
1707    /// Store the RBAC `AuthzPolicy`. The caller validates it first (the server
1708    /// route compiles it before storing); a write rides the existing cache
1709    /// invalidation so every node picks it up.
1710    pub async fn set_authz_policy(
1711        &self,
1712        policy: &crate::authz::AuthzPolicy,
1713    ) -> Result<(), DeployError> {
1714        self.kv
1715            .put(crate::authz::POLICY_KEY, serde_json::to_vec(policy)?)
1716            .await?;
1717        Ok(())
1718    }
1719
1720    /// Trust an additional **root anchor** (`auth rotate-root`): a `TokenPublicKey`
1721    /// (`alg:hex`) accepted alongside the configured primary root, for a
1722    /// make-before-break rotation. Replicated to every node through the control
1723    /// plane, so no per-node edit is needed.
1724    pub async fn add_root_anchor(&self, pubkey: &str) -> Result<(), DeployError> {
1725        self.kv
1726            .put(&crate::authz::root_anchor_key(pubkey), Vec::new())
1727            .await?;
1728        Ok(())
1729    }
1730
1731    /// Retire a previously-added root anchor (the old key, after propagation).
1732    pub async fn remove_root_anchor(&self, pubkey: &str) -> Result<(), DeployError> {
1733        self.kv
1734            .delete(&crate::authz::root_anchor_key(pubkey))
1735            .await?;
1736        Ok(())
1737    }
1738
1739    /// The currently-trusted extra root anchors (the `alg:hex` public keys).
1740    pub async fn list_root_anchors(&self) -> Result<Vec<String>, DeployError> {
1741        Ok(self
1742            .kv
1743            .list_prefix(crate::authz::ROOT_ANCHOR_PREFIX)
1744            .await?
1745            .iter()
1746            .filter_map(|k| {
1747                k.strip_prefix(crate::authz::ROOT_ANCHOR_PREFIX)
1748                    .map(String::from)
1749            })
1750            .collect())
1751    }
1752
1753    // ---- Dynamic daemon config --------------------------------------------
1754
1755    /// The `daemon/current` pointer key → the active generation hash.
1756    const DAEMON_CURRENT_KEY: &'static str = "daemon/current";
1757    /// The `daemon/history` key → JSON array of prior generation hashes (rollback).
1758    const DAEMON_HISTORY_KEY: &'static str = "daemon/history";
1759    /// How many prior generations the rollback ring retains.
1760    const DAEMON_HISTORY_MAX: usize = 20;
1761
1762    /// The active daemon-config **generation** (the `daemon/current` hash), if any.
1763    /// This is the value nodes report so an operator can confirm convergence.
1764    pub async fn daemon_config_generation(&self) -> Result<Option<String>, DeployError> {
1765        Ok(self
1766            .kv
1767            .get(Self::DAEMON_CURRENT_KEY)
1768            .await?
1769            .map(|b| String::from_utf8_lossy(&b).into_owned()))
1770    }
1771
1772    /// The active dynamic daemon config, if any (`None` = none set ⇒ the server
1773    /// runs on the pure file baseline).
1774    pub async fn get_daemon_config(
1775        &self,
1776    ) -> Result<Option<crate::daemon_config::DaemonConfig>, DeployError> {
1777        let Some(hash) = self.daemon_config_generation().await? else {
1778            return Ok(None);
1779        };
1780        match self.kv.get(&keys::daemon_config_blob(&hash)).await? {
1781            Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
1782            // Dangling pointer (body GC'd) reads as unset → baseline.
1783            None => Ok(None),
1784        }
1785    }
1786
1787    /// The rollback history (oldest → newest prior generation hashes; excludes the
1788    /// current generation).
1789    pub async fn daemon_config_history(&self) -> Result<Vec<String>, DeployError> {
1790        match self.kv.get(Self::DAEMON_HISTORY_KEY).await? {
1791            Some(bytes) => Ok(serde_json::from_slice(&bytes)?),
1792            None => Ok(Vec::new()),
1793        }
1794    }
1795
1796    /// Store a new daemon config: write the content-addressed body, push the
1797    /// current generation onto the bounded history, and flip the `daemon/current`
1798    /// pointer — all as one atomic batch. **The caller validates first** (the
1799    /// server route runs [`DaemonConfig::validate`](crate::daemon_config::DaemonConfig::validate)
1800    /// before this). Returns the new generation hash.
1801    pub async fn set_daemon_config(
1802        &self,
1803        config: &crate::daemon_config::DaemonConfig,
1804    ) -> Result<String, DeployError> {
1805        let body = serde_json::to_vec(config)?;
1806        let hash = sha256_hex(&body);
1807        let mut history = self.daemon_config_history().await?;
1808        if let Some(current) = self.daemon_config_generation().await? {
1809            if current != hash {
1810                history.push(current);
1811                if history.len() > Self::DAEMON_HISTORY_MAX {
1812                    let overflow = history.len() - Self::DAEMON_HISTORY_MAX;
1813                    history.drain(0..overflow);
1814                }
1815            }
1816        }
1817        let ops = vec![
1818            WriteOp::Put(keys::daemon_config_blob(&hash), body),
1819            WriteOp::Put(
1820                Self::DAEMON_HISTORY_KEY.to_string(),
1821                serde_json::to_vec(&history)?,
1822            ),
1823            WriteOp::Put(
1824                Self::DAEMON_CURRENT_KEY.to_string(),
1825                hash.clone().into_bytes(),
1826            ),
1827        ];
1828        self.kv.write_batch(ops).await?;
1829        Ok(hash)
1830    }
1831
1832    /// Roll back to the previous generation: pop the history and flip the pointer,
1833    /// atomically. Returns the hash rolled back to, or `None` if there is no
1834    /// history. Reverting past the last dynamic config falls back to the file
1835    /// baseline (which already booted successfully — the known-good floor).
1836    pub async fn rollback_daemon_config(&self) -> Result<Option<String>, DeployError> {
1837        let mut history = self.daemon_config_history().await?;
1838        let Some(prev) = history.pop() else {
1839            return Ok(None);
1840        };
1841        let ops = vec![
1842            WriteOp::Put(
1843                Self::DAEMON_HISTORY_KEY.to_string(),
1844                serde_json::to_vec(&history)?,
1845            ),
1846            WriteOp::Put(
1847                Self::DAEMON_CURRENT_KEY.to_string(),
1848                prev.clone().into_bytes(),
1849            ),
1850        ];
1851        self.kv.write_batch(ops).await?;
1852        Ok(Some(prev))
1853    }
1854
1855    // ---- Compute workloads ------------------------------------------------
1856
1857    /// Store an immutable, content-addressed [`ComputeSpec`](crate::compute::ComputeSpec)
1858    /// at `computever/<hash>` (idempotent), returning its hash.
1859    pub async fn put_compute_spec(
1860        &self,
1861        spec: &crate::compute::ComputeSpec,
1862    ) -> Result<String, DeployError> {
1863        let id = spec.id();
1864        self.kv
1865            .put(&crate::compute::spec_key(&id), serde_json::to_vec(spec)?)
1866            .await?;
1867        Ok(id)
1868    }
1869
1870    /// Read a compute spec by its content hash.
1871    pub async fn get_compute_spec(
1872        &self,
1873        hash: &str,
1874    ) -> Result<Option<crate::compute::ComputeSpec>, DeployError> {
1875        match self.kv.get(&crate::compute::spec_key(hash)).await? {
1876            Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
1877            None => Ok(None),
1878        }
1879    }
1880
1881    /// Set (replacing) a workload's desired state at
1882    /// `project/<proj>/compute/<name>`. Activation is this pointer write — atomic,
1883    /// like a deployment's `current`.
1884    pub async fn set_compute_workload(
1885        &self,
1886        project: ProjectRef<'_>,
1887        workload: &crate::compute::ComputeWorkload,
1888    ) -> Result<(), DeployError> {
1889        self.kv
1890            .put(
1891                &crate::compute::workload_key(project.as_str(), &workload.name),
1892                serde_json::to_vec(workload)?,
1893            )
1894            .await?;
1895        Ok(())
1896    }
1897
1898    /// Read a workload's desired state.
1899    pub async fn get_compute_workload(
1900        &self,
1901        project: ProjectRef<'_>,
1902        name: &str,
1903    ) -> Result<Option<crate::compute::ComputeWorkload>, DeployError> {
1904        match self
1905            .kv
1906            .get(&crate::compute::workload_key(project.as_str(), name))
1907            .await?
1908        {
1909            Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
1910            None => Ok(None),
1911        }
1912    }
1913
1914    /// List a project's compute workloads' desired state.
1915    pub async fn list_compute_workloads(
1916        &self,
1917        project: ProjectRef<'_>,
1918    ) -> Result<Vec<crate::compute::ComputeWorkload>, DeployError> {
1919        let prefix = crate::compute::workloads_prefix(project.as_str());
1920        let mut out = Vec::new();
1921        for key in self.kv.list_prefix(&prefix).await? {
1922            if let Some(bytes) = self.kv.get(&key).await? {
1923                if let Ok(w) = serde_json::from_slice::<crate::compute::ComputeWorkload>(&bytes) {
1924                    out.push(w);
1925                }
1926            }
1927        }
1928        Ok(out)
1929    }
1930
1931    /// List **every** compute workload across **all** projects, each paired with
1932    /// its owning project — the cross-project fan-out the scheduler runs.
1933    pub async fn list_compute_workloads_all(
1934        &self,
1935    ) -> Result<Vec<(String, crate::compute::ComputeWorkload)>, DeployError> {
1936        let mut out = Vec::new();
1937        for project in self.discover_projects().await? {
1938            for w in self
1939                .list_compute_workloads(ProjectRef::new(&project))
1940                .await?
1941            {
1942                out.push((project.clone(), w));
1943            }
1944        }
1945        Ok(out)
1946    }
1947
1948    /// Remove a workload's desired state (the executor then stops its replicas).
1949    /// Returns whether one existed.
1950    pub async fn delete_compute_workload(
1951        &self,
1952        project: ProjectRef<'_>,
1953        name: &str,
1954    ) -> Result<bool, DeployError> {
1955        let key = crate::compute::workload_key(project.as_str(), name);
1956        let existed = self.kv.get(&key).await?.is_some();
1957        if existed {
1958            self.kv.delete(&key).await?;
1959        }
1960        Ok(existed)
1961    }
1962
1963    /// Persist a replica's observed state at
1964    /// `project/<proj>/compute_state/<workload>/<replica>` (the reconcile loop's
1965    /// record + the gateway's upstream source).
1966    pub async fn set_replica_state(
1967        &self,
1968        project: ProjectRef<'_>,
1969        state: &crate::compute::ObservedInstance,
1970    ) -> Result<(), DeployError> {
1971        self.kv
1972            .put(
1973                &crate::compute::replica_state_key(
1974                    project.as_str(),
1975                    &state.handle.workload,
1976                    state.handle.replica,
1977                ),
1978                serde_json::to_vec(state)?,
1979            )
1980            .await?;
1981        Ok(())
1982    }
1983
1984    /// List a workload's observed replica states.
1985    pub async fn list_replica_states(
1986        &self,
1987        project: ProjectRef<'_>,
1988        workload: &str,
1989    ) -> Result<Vec<crate::compute::ObservedInstance>, DeployError> {
1990        let mut out = Vec::new();
1991        for key in self
1992            .kv
1993            .list_prefix(&crate::compute::replica_state_prefix(
1994                project.as_str(),
1995                workload,
1996            ))
1997            .await?
1998        {
1999            if let Some(bytes) = self.kv.get(&key).await? {
2000                if let Ok(state) =
2001                    serde_json::from_slice::<crate::compute::ObservedInstance>(&bytes)
2002                {
2003                    out.push(state);
2004                }
2005            }
2006        }
2007        Ok(out)
2008    }
2009
2010    /// List **all** observed replica states across every project's workloads (the
2011    /// gateway's dynamic-pool source). Fans out over
2012    /// [`discover_projects`](Self::discover_projects).
2013    pub async fn list_all_replica_states(
2014        &self,
2015    ) -> Result<Vec<crate::compute::ObservedInstance>, DeployError> {
2016        let mut out = Vec::new();
2017        for project in self.discover_projects().await? {
2018            let prefix = crate::compute::replica_states_project_prefix(&project);
2019            for key in self.kv.list_prefix(&prefix).await? {
2020                if let Some(bytes) = self.kv.get(&key).await? {
2021                    if let Ok(state) =
2022                        serde_json::from_slice::<crate::compute::ObservedInstance>(&bytes)
2023                    {
2024                        out.push(state);
2025                    }
2026                }
2027            }
2028        }
2029        Ok(out)
2030    }
2031
2032    /// Remove a replica's observed state.
2033    pub async fn delete_replica_state(
2034        &self,
2035        project: ProjectRef<'_>,
2036        workload: &str,
2037        replica: u32,
2038    ) -> Result<(), DeployError> {
2039        self.kv
2040            .delete(&crate::compute::replica_state_key(
2041                project.as_str(),
2042                workload,
2043                replica,
2044            ))
2045            .await?;
2046        Ok(())
2047    }
2048
2049    // ---- Projects (the owning Workspace boundary, 0.2.0) -------------------
2050    // A project is content-addressed + atomically activated exactly like a site: an
2051    // immutable `projectver/<hash>` spec body, a mutable `projectmeta/<name>` pointer,
2052    // and a bounded history ring. Membership is positional — the `project/<name>/`
2053    // prefix is the authoritative member set (this is what `delete_project` scans).
2054    // `owner/<kind>/<name>` is a migration-built derived hint, not maintained here and
2055    // not consulted for any decision (see the `boatramp_types::project` module docs).
2056
2057    /// Create or update a project: store its content-addressed spec body (idempotent)
2058    /// and flip the `projectmeta/<name>` pointer to it, recording the prior pointer in
2059    /// the history ring for rollback.
2060    pub async fn put_project(&self, p: &crate::project::Project) -> Result<String, DeployError> {
2061        let hash = p.id();
2062        let body = serde_json::to_vec(p).map_err(|e| DeployError::Serde(e.to_string()))?;
2063        let pointer = crate::project::pointer_key(&p.name);
2064        // Record the currently-active version in history (most-recent first, capped),
2065        // so a `rollback_project` can restore it.
2066        let mut history: Vec<String> =
2067            match self.kv.get(&crate::project::history_key(&p.name)).await? {
2068                Some(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
2069                None => Vec::new(),
2070            };
2071        if let Some(current) = self.kv.get(&pointer).await? {
2072            let current = String::from_utf8_lossy(&current).into_owned();
2073            if current != hash {
2074                history.retain(|h| h != &current);
2075                history.insert(0, current);
2076                history.truncate(MAX_HISTORY);
2077            }
2078        }
2079        self.kv
2080            .write_batch(vec![
2081                WriteOp::Put(crate::project::spec_key(&hash), body),
2082                WriteOp::Put(pointer, hash.clone().into_bytes()),
2083                WriteOp::Put(
2084                    crate::project::history_key(&p.name),
2085                    serde_json::to_vec(&history).map_err(|e| DeployError::Serde(e.to_string()))?,
2086                ),
2087            ])
2088            .await?;
2089        Ok(hash)
2090    }
2091
2092    /// The canonical record for the reserved `default` project — the single source
2093    /// of its shape, shared by [`Self::ensure_default_project`], the migration's
2094    /// `EnsureDefaultProject` step, and the reader-side backstop in
2095    /// [`Self::get_project`] / [`Self::list_projects`].
2096    pub fn default_project_record() -> crate::project::Project {
2097        crate::project::Project {
2098            version: crate::SCHEMA_VERSION,
2099            name: crate::project::DEFAULT_PROJECT.to_string(),
2100            created_at: now_unix(),
2101            meta: crate::project::ProjectMeta::default(),
2102            config: crate::project::ProjectConfig::default(),
2103            secrets_ref: None,
2104        }
2105    }
2106
2107    /// Idempotently materialize the reserved `default` project's entity record, so
2108    /// `project ls` / `project show default` reflect it on a fresh install just as
2109    /// they do on a migrated store. Presence-checked and content-addressed, so it is
2110    /// safe to call on every boot; in cluster mode the write forwards to the leader
2111    /// and concurrent callers converge (identical body). Returns whether it created
2112    /// the record. The reserved name is defined to *always* exist — this makes that
2113    /// true in the store, not only in the reader backstop below.
2114    pub async fn ensure_default_project(&self) -> Result<bool, DeployError> {
2115        let pointer = crate::project::pointer_key(crate::project::DEFAULT_PROJECT);
2116        if self.kv.get(&pointer).await?.is_some() {
2117            return Ok(false);
2118        }
2119        let default = Self::default_project_record();
2120        let hash = default.id();
2121        let body = serde_json::to_vec(&default).map_err(|e| DeployError::Serde(e.to_string()))?;
2122        self.kv
2123            .write_batch(vec![
2124                WriteOp::Put(crate::project::spec_key(&hash), body),
2125                WriteOp::Put(pointer, hash.into_bytes()),
2126            ])
2127            .await?;
2128        Ok(true)
2129    }
2130
2131    /// Cheap existence check for a project entity — whether its `projectmeta/<name>`
2132    /// pointer is present. Used by the project-scope middleware to reject an
2133    /// operation on a project that was never created (rather than silently
2134    /// manufacturing a ghost). The reserved `default` always exists.
2135    pub async fn project_exists(&self, name: &str) -> Result<bool, DeployError> {
2136        if name == crate::project::DEFAULT_PROJECT {
2137            return Ok(true);
2138        }
2139        Ok(self
2140            .kv
2141            .get(&crate::project::pointer_key(name))
2142            .await?
2143            .is_some())
2144    }
2145
2146    /// Load a project's active version, if it exists.
2147    pub async fn get_project(
2148        &self,
2149        name: &str,
2150    ) -> Result<Option<crate::project::Project>, DeployError> {
2151        let Some(hash) = self.kv.get(&crate::project::pointer_key(name)).await? else {
2152            // Reader-side backstop: the reserved `default` project always exists
2153            // conceptually, even before the boot-time ensure has run (or on a
2154            // read-only replica), so `project show default` never 404s.
2155            if name == crate::project::DEFAULT_PROJECT {
2156                return Ok(Some(Self::default_project_record()));
2157            }
2158            return Ok(None);
2159        };
2160        let hash = String::from_utf8_lossy(&hash).into_owned();
2161        match self.kv.get(&crate::project::spec_key(&hash)).await? {
2162            Some(bytes) => Ok(Some(
2163                serde_json::from_slice(&bytes).map_err(|e| DeployError::Serde(e.to_string()))?,
2164            )),
2165            // dangling pointer (body GC'd) reads as absent — except `default`, which
2166            // always resolves (same backstop as the missing-pointer case above).
2167            None if name == crate::project::DEFAULT_PROJECT => {
2168                Ok(Some(Self::default_project_record()))
2169            }
2170            None => Ok(None),
2171        }
2172    }
2173
2174    /// Every declared project, sorted by name. (A project may also exist only
2175    /// *implicitly* as a `project/<name>/` key prefix without a `projectmeta`
2176    /// pointer — see [`discover_projects`](Self::discover_projects) for that union.)
2177    pub async fn list_projects(&self) -> Result<Vec<crate::project::Project>, DeployError> {
2178        let mut out = Vec::new();
2179        for key in self.kv.list_prefix(crate::project::POINTER_PREFIX).await? {
2180            let Some(name) = key.strip_prefix(crate::project::POINTER_PREFIX) else {
2181                continue;
2182            };
2183            if !name.is_empty() {
2184                if let Some(p) = self.get_project(name).await? {
2185                    out.push(p);
2186                }
2187            }
2188        }
2189        // Reader-side backstop: the reserved `default` project always exists, even
2190        // before the boot-time ensure has run, so `project ls` never omits it.
2191        if !out
2192            .iter()
2193            .any(|p| p.name == crate::project::DEFAULT_PROJECT)
2194        {
2195            out.push(Self::default_project_record());
2196        }
2197        out.sort_by(|a, b| a.name.cmp(&b.name));
2198        Ok(out)
2199    }
2200
2201    /// Delete a project's pointer + history. Refuses (with [`DeployError::Conflict`])
2202    /// while the project still owns any resource — a project is deleted only once
2203    /// empty, so a stray site/function/compute can't be silently orphaned. The
2204    /// content-addressed spec bodies are shared + left to `prune`. Deleting the
2205    /// reserved `default` project is refused outright.
2206    pub async fn delete_project(&self, name: &str) -> Result<bool, DeployError> {
2207        if name == crate::project::DEFAULT_PROJECT {
2208            return Err(DeployError::Conflict(
2209                "the `default` project cannot be deleted".to_string(),
2210            ));
2211        }
2212        let existed = self
2213            .kv
2214            .get(&crate::project::pointer_key(name))
2215            .await?
2216            .is_some();
2217        // Refuse if any owned resource remains under `project/<name>/`.
2218        if !self
2219            .kv
2220            .list_prefix(&crate::project::resource_prefix(name))
2221            .await?
2222            .is_empty()
2223        {
2224            return Err(DeployError::Conflict(format!(
2225                "project `{name}` still owns resources; delete its sites/functions/compute first"
2226            )));
2227        }
2228        self.kv
2229            .write_batch(vec![
2230                WriteOp::Delete(crate::project::pointer_key(name)),
2231                WriteOp::Delete(crate::project::history_key(name)),
2232            ])
2233            .await?;
2234        Ok(existed)
2235    }
2236
2237    /// Atomically point `site` at deployment `id`.
2238    ///
2239    /// Refuses to activate a deployment whose blobs are not all present.
2240    pub async fn activate(
2241        &self,
2242        project: ProjectRef<'_>,
2243        site: &str,
2244        id: &str,
2245    ) -> Result<(), DeployError> {
2246        let manifest = self
2247            .get_manifest(id)
2248            .await?
2249            .ok_or_else(|| DeployError::NotFound(format!("deployment {id}")))?;
2250        let missing = self.missing_blobs(&manifest).await?;
2251        if !missing.is_empty() {
2252            return Err(DeployError::Incomplete(missing));
2253        }
2254
2255        // The atomic switch: a single KV write, so readers see the old or new
2256        // deployment in full, never a partial state.
2257        self.kv
2258            .put(&keys::current(project, site), id.as_bytes().to_vec())
2259            .await?;
2260
2261        // Record the activation. Best-effort: `current` above is the source of
2262        // truth, so a history-write failure must not fail an activation that
2263        // already took effect.
2264        let _ = self.record_history(project, site, id).await;
2265        Ok(())
2266    }
2267
2268    /// Prepend `id` to `site`'s activation history, de-duplicating by id and
2269    /// keeping at most [`MAX_HISTORY`] entries.
2270    async fn record_history(
2271        &self,
2272        project: ProjectRef<'_>,
2273        site: &str,
2274        id: &str,
2275    ) -> Result<(), DeployError> {
2276        let mut history = self.history(project, site).await.unwrap_or_default();
2277        history.retain(|entry| entry.id != id);
2278        history.insert(
2279            0,
2280            HistoryEntry {
2281                id: id.to_string(),
2282                at: now_unix(),
2283                meta: None,
2284            },
2285        );
2286        history.truncate(MAX_HISTORY);
2287        self.kv
2288            .put(&keys::history(project, site), serde_json::to_vec(&history)?)
2289            .await?;
2290        Ok(())
2291    }
2292
2293    /// A site's activation history, most recent first.
2294    pub async fn history(
2295        &self,
2296        project: ProjectRef<'_>,
2297        site: &str,
2298    ) -> Result<Vec<HistoryEntry>, DeployError> {
2299        match self.kv.get(&keys::history(project, site)).await? {
2300            Some(bytes) => Ok(serde_json::from_slice(&bytes)?),
2301            None => Ok(Vec::new()),
2302        }
2303    }
2304
2305    /// A site's current deployment plus its activation history, each history
2306    /// entry joined with its [`DeployMeta`] provenance (when recorded).
2307    pub async fn deployments(
2308        &self,
2309        project: ProjectRef<'_>,
2310        site: &str,
2311    ) -> Result<DeploymentList, DeployError> {
2312        let mut deployments = self.history(project, site).await?;
2313        for entry in &mut deployments {
2314            entry.meta = self.get_meta(&entry.id).await?;
2315        }
2316        Ok(DeploymentList {
2317            current: self.current_id(project, site).await?,
2318            deployments,
2319        })
2320    }
2321
2322    /// Deployment ids that must survive garbage collection: every `current`
2323    /// pointer, every named alias, and the history entries the retention policy
2324    /// in `opts` keeps (most-recent `keep_last` and/or anything within
2325    /// `keep_age_secs`; with neither set, the entire history).
2326    ///
2327    /// **Cross-project union.** Blob and manifest bodies are global CAS, shared
2328    /// across projects; reachability is therefore the **union** over every
2329    /// project's live set — a body is collectable only if *no* project references
2330    /// it. This fans out over [`discover_projects`](Self::discover_projects) and
2331    /// scans each project's `history/`, `current/`, and `alias/` families.
2332    async fn live_deployment_ids(&self, opts: &GcOptions) -> Result<BTreeSet<String>, DeployError> {
2333        let mut ids = BTreeSet::new();
2334        let now = now_unix();
2335        for project in self.discover_projects().await? {
2336            let pref = ProjectRef::new(&project);
2337            for key in self.kv.list_prefix(&keys::history_prefix(pref)).await? {
2338                if let Some(bytes) = self.kv.get(&key).await? {
2339                    if let Ok(history) = serde_json::from_slice::<Vec<HistoryEntry>>(&bytes) {
2340                        for (idx, entry) in history.iter().enumerate() {
2341                            let within_count = opts.keep_last.is_none_or(|n| idx < n);
2342                            let within_age = opts
2343                                .keep_age_secs
2344                                .is_some_and(|age| now.saturating_sub(entry.at) <= age);
2345                            if within_count || within_age {
2346                                ids.insert(entry.id.clone());
2347                            }
2348                        }
2349                    }
2350                }
2351            }
2352            // `current` pointers and named aliases are always live.
2353            for prefix in [keys::current_prefix(pref), keys::alias_project_prefix(pref)] {
2354                for key in self.kv.list_prefix(&prefix).await? {
2355                    if let Some(bytes) = self.kv.get(&key).await? {
2356                        ids.insert(String::from_utf8_lossy(&bytes).into_owned());
2357                    }
2358                }
2359            }
2360        }
2361        Ok(ids)
2362    }
2363
2364    /// Whether `id`'s manifest was first seen within the grace window — i.e. it
2365    /// may be an in-flight (uploaded-but-not-yet-activated) deploy that is not
2366    /// yet reachable. Manifests with no recorded `created_at` (pre-dating the
2367    /// metadata feature) are not grace-protected.
2368    async fn within_grace(
2369        &self,
2370        id: &str,
2371        now: u64,
2372        opts: &GcOptions,
2373    ) -> Result<bool, DeployError> {
2374        if opts.grace_secs == 0 {
2375            return Ok(false);
2376        }
2377        match self.get_meta(id).await? {
2378            Some(meta) => Ok(now.saturating_sub(meta.created_at) < opts.grace_secs),
2379            None => Ok(false),
2380        }
2381    }
2382
2383    /// Garbage-collect deployments unreachable from any site's `current`
2384    /// pointer, alias, or retained history, and the blobs no surviving
2385    /// deployment references.
2386    ///
2387    /// Equivalent to [`collect_garbage_with`](Self::collect_garbage_with) with
2388    /// default options (keep all history, no grace window).
2389    pub async fn collect_garbage(&self, prune: bool) -> Result<GcReport, DeployError> {
2390        self.collect_garbage_with(prune, GcOptions::default()).await
2391    }
2392
2393    /// Garbage-collect under an explicit retention policy and grace window.
2394    ///
2395    /// A manifest survives if it is reachable (see
2396    /// [`live_deployment_ids`](Self::live_deployment_ids)) **or** was first seen
2397    /// within `opts.grace_secs` — the latter protects an in-flight deploy whose
2398    /// manifest is stored and blobs are uploading but which is not yet
2399    /// activated. A blob survives if any surviving manifest references it.
2400    ///
2401    /// With `prune == false` nothing is deleted; the [`GcReport`] describes what
2402    /// *would* be removed.
2403    pub async fn collect_garbage_with(
2404        &self,
2405        prune: bool,
2406        opts: GcOptions,
2407    ) -> Result<GcReport, DeployError> {
2408        let live_ids = self.live_deployment_ids(&opts).await?;
2409        let now = now_unix();
2410
2411        let manifest_keys = self.kv.list_prefix("manifests/").await?;
2412        let manifests_total = manifest_keys.len();
2413        let mut referenced: BTreeSet<String> = BTreeSet::new();
2414        let mut orphan_manifests: Vec<String> = Vec::new();
2415        for key in &manifest_keys {
2416            let id = key.strip_prefix("manifests/").unwrap_or(key);
2417            let protected = live_ids.contains(id) || self.within_grace(id, now, &opts).await?;
2418            if protected {
2419                if let Some(bytes) = self.kv.get(key).await? {
2420                    if let Ok(manifest) = Manifest::from_bytes(&bytes) {
2421                        referenced.extend(manifest.blob_hashes());
2422                    }
2423                }
2424            } else {
2425                orphan_manifests.push(key.clone());
2426            }
2427        }
2428
2429        let blobs = self.storage.list("").await?;
2430        let blobs_total = blobs.len();
2431        let mut blobs_removed = 0;
2432        let mut bytes_reclaimed = 0;
2433        for meta in &blobs {
2434            if !is_blob_key(&meta.key) {
2435                continue;
2436            }
2437            let hash = meta.key.rsplit('/').next().unwrap_or(&meta.key);
2438            if !referenced.contains(hash) {
2439                blobs_removed += 1;
2440                bytes_reclaimed += meta.size.unwrap_or(0);
2441                if prune {
2442                    self.storage.delete(&meta.key).await?;
2443                }
2444            }
2445        }
2446
2447        // Orphaned content-addressed config bodies: a `siteconfig/<hash>` no
2448        // longer pointed to by any `site/<site>` (left behind by a config edit;
2449        // dedup means a body shared by several sites stays while any references
2450        // it). Tiny KV entries, cleaned under `prune` (not counted in the
2451        // blob/manifest totals, which are deploy-content concepts).
2452        if prune {
2453            // Union of site-config hashes referenced by **any** project's site
2454            // pointers — a `siteconfig/<hash>` body is global CAS, so it stays
2455            // while any project references it.
2456            let mut referenced_configs: BTreeSet<String> = BTreeSet::new();
2457            for project in self.discover_projects().await? {
2458                let site_prefix = keys::site_prefix(ProjectRef::new(&project));
2459                for pointer in self.kv.list_prefix(&site_prefix).await? {
2460                    if let Some(bytes) = self.kv.get(&pointer).await? {
2461                        referenced_configs.insert(String::from_utf8_lossy(&bytes).into_owned());
2462                    }
2463                }
2464            }
2465            for key in self.kv.list_prefix("siteconfig/").await? {
2466                let hash = key.strip_prefix("siteconfig/").unwrap_or(&key);
2467                if !referenced_configs.contains(hash) {
2468                    self.kv.delete(&key).await?;
2469                }
2470            }
2471        }
2472
2473        let manifests_removed = orphan_manifests.len();
2474        if prune {
2475            for key in &orphan_manifests {
2476                self.kv.delete(key).await?;
2477                // Drop the companion metadata record alongside the manifest.
2478                if let Some(id) = key.strip_prefix("manifests/") {
2479                    let _ = self.kv.delete(&keys::meta(id)).await;
2480                }
2481            }
2482        }
2483
2484        Ok(GcReport {
2485            manifests_total,
2486            manifests_removed,
2487            blobs_total,
2488            blobs_removed,
2489            bytes_reclaimed,
2490        })
2491    }
2492
2493    /// Verify every stored blob still hashes to its key — an integrity scrub
2494    /// that detects bit-rot or tampering. Each blob is streamed
2495    /// through a hasher (never fully buffered); read-only (never deletes). The
2496    /// serving path can't reject a corrupt blob without buffering, so this
2497    /// verification is performed offline.
2498    pub async fn scrub_blobs(&self) -> Result<ScrubReport, DeployError> {
2499        let blobs = self.storage.list("").await?;
2500        let mut report = ScrubReport::default();
2501        for meta in &blobs {
2502            if !is_blob_key(&meta.key) {
2503                continue;
2504            }
2505            report.checked += 1;
2506            let expected = meta
2507                .key
2508                .rsplit('/')
2509                .next()
2510                .unwrap_or(meta.key.as_str())
2511                .to_string();
2512            match self.hash_stored_object(&meta.key).await {
2513                Ok(actual) if actual == expected => {}
2514                Ok(actual) => report.mismatched.push(BlobMismatch {
2515                    key: meta.key.clone(),
2516                    expected,
2517                    actual,
2518                }),
2519                Err(err) => report.errors.push(BlobReadError {
2520                    key: meta.key.clone(),
2521                    error: err.to_string(),
2522                }),
2523            }
2524        }
2525        Ok(report)
2526    }
2527
2528    /// Drop these keys from the control-plane KV's local cache (shared-mode
2529    /// **push** invalidation). A Cloudflare DO /
2530    /// Queue (or any pusher) calls this — via the `/api/cache/invalidate`
2531    /// endpoint — when a peer changed those keys, for real-time invalidation
2532    /// without waiting on the poll interval. A no-op on an uncached/Raft store.
2533    pub fn invalidate_cache_keys(&self, keys: &[String]) {
2534        self.kv.invalidate_keys(keys);
2535    }
2536
2537    /// Drop the entire control-plane KV cache (the coarse fallback / `SIGHUP`
2538    /// equivalent over HTTP).
2539    pub fn invalidate_cache(&self) {
2540        self.kv.invalidate_cache();
2541    }
2542
2543    /// The key-free status (domain + expiry) of every cluster-managed cert in
2544    /// the control plane (`cert/<domain>`). Empty when certs
2545    /// live in a file cache instead (single-node `acme`). Never returns key
2546    /// material. Sorted by domain.
2547    pub async fn cert_status(&self) -> Result<Vec<crate::cert::CertStatus>, DeployError> {
2548        let mut out = Vec::new();
2549        for key in self.kv.list_prefix("cert/").await? {
2550            let domain = key.strip_prefix("cert/").unwrap_or(&key).to_string();
2551            if let Some(bytes) = self.kv.get(&key).await? {
2552                if let Ok(cert) = serde_json::from_slice::<crate::cert::StoredCert>(&bytes) {
2553                    out.push(crate::cert::CertStatus {
2554                        domain,
2555                        not_after_unix: cert.not_after_unix,
2556                    });
2557                }
2558            }
2559        }
2560        out.sort_by(|a, b| a.domain.cmp(&b.domain));
2561        Ok(out)
2562    }
2563
2564    /// Stream a stored object through a SHA-256 hasher and return the hex digest.
2565    async fn hash_stored_object(&self, key: &str) -> Result<String, DeployError> {
2566        let mut body = self.storage.get(key).await?.body;
2567        let mut hasher = Sha256::new();
2568        while let Some(chunk) = body.next().await {
2569            hasher.update(&chunk?);
2570        }
2571        Ok(hex::encode(hasher.finalize()))
2572    }
2573
2574    /// Every site in `project` that has a current deployment (i.e. a
2575    /// `project/<proj>/current/<site>` pointer). Used by the background scheduler
2576    /// to find which sites' consumers and crons to run.
2577    pub async fn list_sites(&self, project: ProjectRef<'_>) -> Result<Vec<String>, DeployError> {
2578        let prefix = keys::current_prefix(project);
2579        let keys = self.kv.list_prefix(&prefix).await?;
2580        Ok(keys
2581            .into_iter()
2582            .filter_map(|k| k.strip_prefix(&prefix).map(str::to_string))
2583            .collect())
2584    }
2585
2586    /// Every `(project, site)` with a current deployment across **all** projects —
2587    /// the cross-project fan-out for the scheduler/operator.
2588    pub async fn list_sites_all(&self) -> Result<Vec<(String, String)>, DeployError> {
2589        let mut out = Vec::new();
2590        for project in self.discover_projects().await? {
2591            for site in self.list_sites(ProjectRef::new(&project)).await? {
2592                out.push((project.clone(), site));
2593            }
2594        }
2595        Ok(out)
2596    }
2597
2598    /// Delete a site and its routing/config state (the Kubernetes operator's
2599    /// `Site` finalizer). Removes the config pointer, the current-deployment
2600    /// pointer, activation history, aliases, the domain-routing entries the site
2601    /// owns (so its hosts free up), and any pending domain verifications — all
2602    /// within `project`. The content-addressed deployment manifests + blobs are
2603    /// shared and left to `prune`. Idempotent (deleting an absent site is a no-op).
2604    pub async fn delete_site(
2605        &self,
2606        project: ProjectRef<'_>,
2607        site: &str,
2608    ) -> Result<(), DeployError> {
2609        use crate::kv::WriteOp;
2610        // Hold the domain-claim lock so a concurrent attach can't race the routing
2611        // deletes and leave a dangling `domain/*` → deleted-site entry.
2612        let _claim = self.domain_claim_lock.lock().await;
2613        let mut batch = vec![
2614            WriteOp::Delete(keys::site_pointer(project, site)),
2615            WriteOp::Delete(keys::current(project, site)),
2616            WriteOp::Delete(keys::history(project, site)),
2617        ];
2618        if let Some(config) = self.get_site_config(project, site).await? {
2619            for host in config.domains.exact_hosts() {
2620                batch.push(WriteOp::Delete(keys::domain(host)));
2621            }
2622            for wildcard in &config.domains.wildcards {
2623                if let Some(suffix) = wildcard.strip_prefix("*.") {
2624                    batch.push(WriteOp::Delete(keys::wildcard(suffix)));
2625                }
2626            }
2627        }
2628        for key in self
2629            .kv
2630            .list_prefix(&keys::alias_prefix(project, site))
2631            .await?
2632        {
2633            batch.push(WriteOp::Delete(key));
2634        }
2635        for key in self
2636            .kv
2637            .list_prefix(&keys::domain_verification_prefix(project, site))
2638            .await?
2639        {
2640            batch.push(WriteOp::Delete(key));
2641        }
2642        self.kv.write_batch(batch).await?;
2643        Ok(())
2644    }
2645
2646    /// The deployment id currently serving `site`, if any.
2647    pub async fn current_id(
2648        &self,
2649        project: ProjectRef<'_>,
2650        site: &str,
2651    ) -> Result<Option<String>, DeployError> {
2652        match self.kv.get(&keys::current(project, site)).await? {
2653            Some(bytes) => Ok(Some(String::from_utf8_lossy(&bytes).into_owned())),
2654            None => Ok(None),
2655        }
2656    }
2657
2658    /// The manifest currently serving `site`, if any.
2659    pub async fn current_manifest(
2660        &self,
2661        project: ProjectRef<'_>,
2662        site: &str,
2663    ) -> Result<Option<Manifest>, DeployError> {
2664        match self.current_id(project, site).await? {
2665            Some(id) => self.get_manifest(&id).await,
2666            None => Ok(None),
2667        }
2668    }
2669
2670    /// Resolve a request `path` against `site`'s current deployment.
2671    ///
2672    /// Applies a directory-index fallback: an empty/trailing-slash path, or a
2673    /// path with no matching file, falls back to `<path>/index.html`.
2674    pub async fn resolve(
2675        &self,
2676        project: ProjectRef<'_>,
2677        site: &str,
2678        path: &str,
2679    ) -> Result<Option<FileEntry>, DeployError> {
2680        let Some(manifest) = self.current_manifest(project, site).await? else {
2681            return Ok(None);
2682        };
2683        Ok(lookup(&manifest, path))
2684    }
2685}
2686
2687/// Look up `path` in `manifest`, applying the directory-index fallback.
2688fn lookup(manifest: &Manifest, path: &str) -> Option<FileEntry> {
2689    let trimmed = path.trim_start_matches('/');
2690    if let Some(entry) = manifest.files.get(trimmed) {
2691        return Some(entry.clone());
2692    }
2693    let index = if trimmed.is_empty() {
2694        "index.html".to_string()
2695    } else {
2696        format!("{}/index.html", trimmed.trim_end_matches('/'))
2697    };
2698    manifest.files.get(&index).cloned()
2699}
2700
2701#[cfg(test)]
2702mod tests {
2703    use super::*;
2704    use crate::config::DeployConfig;
2705    use crate::ObjectMeta;
2706
2707    fn entry(hash: &str) -> FileEntry {
2708        FileEntry {
2709            hash: hash.to_string(),
2710            size: 0,
2711            content_type: None,
2712            variants: BTreeMap::new(),
2713        }
2714    }
2715
2716    #[test]
2717    fn manifest_id_is_deterministic() {
2718        let mut a = Manifest::default();
2719        a.files.insert("index.html".into(), entry("aa"));
2720        a.files.insert("style.css".into(), entry("bb"));
2721
2722        let mut b = Manifest::default();
2723        // Insertion order differs; id must not.
2724        b.files.insert("style.css".into(), entry("bb"));
2725        b.files.insert("index.html".into(), entry("aa"));
2726
2727        assert_eq!(a.id().unwrap(), b.id().unwrap());
2728    }
2729
2730    #[test]
2731    fn manifest_carries_schema_version_and_reads_legacy() {
2732        // New manifests are stamped with the current version.
2733        let manifest = Manifest::default();
2734        assert_eq!(manifest.version, crate::SCHEMA_VERSION);
2735        assert!(manifest.to_bytes().unwrap().starts_with(b"{\"version\":1"));
2736
2737        // A version-less document (pre-field) still reads as v1.
2738        let legacy = br#"{"files":{},"config":{}}"#;
2739        assert_eq!(Manifest::from_bytes(legacy).unwrap().version, 1);
2740    }
2741
2742    #[test]
2743    fn directory_index_fallback() {
2744        let mut m = Manifest::default();
2745        m.files.insert("index.html".into(), entry("root"));
2746        m.files.insert("blog/index.html".into(), entry("blog"));
2747
2748        assert_eq!(lookup(&m, "").unwrap().hash, "root");
2749        assert_eq!(lookup(&m, "/").unwrap().hash, "root");
2750        assert_eq!(lookup(&m, "blog").unwrap().hash, "blog");
2751        assert_eq!(lookup(&m, "blog/").unwrap().hash, "blog");
2752        assert!(lookup(&m, "missing.html").is_none());
2753    }
2754
2755    /// A do-nothing blob backend, so we can exercise the KV-only site-config and
2756    /// host-routing logic without a real `Storage`.
2757    struct NullStorage;
2758
2759    #[async_trait::async_trait]
2760    impl Storage for NullStorage {
2761        async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
2762            Err(StorageError::NotFound(String::new()))
2763        }
2764        async fn get_range(
2765            &self,
2766            _: &str,
2767            _: u64,
2768            _: Option<u64>,
2769        ) -> Result<GetObject, StorageError> {
2770            Err(StorageError::NotFound(String::new()))
2771        }
2772        async fn put(
2773            &self,
2774            _: &str,
2775            _: ByteStream,
2776            _: PutMeta,
2777        ) -> Result<ObjectMeta, StorageError> {
2778            Err(StorageError::unsupported("null"))
2779        }
2780        async fn head(&self, _: &str) -> Result<ObjectMeta, StorageError> {
2781            Err(StorageError::NotFound(String::new()))
2782        }
2783        async fn delete(&self, _: &str) -> Result<(), StorageError> {
2784            Ok(())
2785        }
2786        async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
2787            Ok(Vec::new())
2788        }
2789    }
2790
2791    /// An in-memory blob store, so a GC test can observe real blob deletion.
2792    #[derive(Default)]
2793    struct MemStorage {
2794        objects: Mutex<std::collections::HashMap<String, Vec<u8>>>,
2795    }
2796
2797    #[async_trait::async_trait]
2798    impl Storage for MemStorage {
2799        async fn get(&self, key: &str) -> Result<GetObject, StorageError> {
2800            let bytes = self
2801                .objects
2802                .lock()
2803                .unwrap()
2804                .get(key)
2805                .cloned()
2806                .ok_or_else(|| StorageError::NotFound(key.to_string()))?;
2807            let size = bytes.len() as u64;
2808            let body: ByteStream =
2809                futures::stream::once(async move { Ok(bytes::Bytes::from(bytes)) }).boxed();
2810            Ok(GetObject {
2811                meta: ObjectMeta {
2812                    key: key.to_string(),
2813                    size: Some(size),
2814                    ..Default::default()
2815                },
2816                body,
2817            })
2818        }
2819        async fn get_range(
2820            &self,
2821            key: &str,
2822            _: u64,
2823            _: Option<u64>,
2824        ) -> Result<GetObject, StorageError> {
2825            self.get(key).await
2826        }
2827        async fn put(
2828            &self,
2829            key: &str,
2830            mut body: ByteStream,
2831            _: PutMeta,
2832        ) -> Result<ObjectMeta, StorageError> {
2833            let mut buf = Vec::new();
2834            while let Some(chunk) = body.next().await {
2835                buf.extend_from_slice(&chunk?);
2836            }
2837            let size = buf.len() as u64;
2838            self.objects.lock().unwrap().insert(key.to_string(), buf);
2839            Ok(ObjectMeta {
2840                key: key.to_string(),
2841                size: Some(size),
2842                ..Default::default()
2843            })
2844        }
2845        async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
2846            let map = self.objects.lock().unwrap();
2847            let bytes = map
2848                .get(key)
2849                .ok_or_else(|| StorageError::NotFound(key.to_string()))?;
2850            Ok(ObjectMeta {
2851                key: key.to_string(),
2852                size: Some(bytes.len() as u64),
2853                ..Default::default()
2854            })
2855        }
2856        async fn delete(&self, key: &str) -> Result<(), StorageError> {
2857            self.objects.lock().unwrap().remove(key);
2858            Ok(())
2859        }
2860        async fn list(&self, prefix: &str) -> Result<Vec<ObjectMeta>, StorageError> {
2861            Ok(self
2862                .objects
2863                .lock()
2864                .unwrap()
2865                .keys()
2866                .filter(|k| k.starts_with(prefix))
2867                .map(|k| ObjectMeta {
2868                    key: k.clone(),
2869                    ..Default::default()
2870                })
2871                .collect())
2872        }
2873    }
2874
2875    /// A single blob (`hash`) written to storage.
2876    fn once_bytes(b: &'static [u8]) -> ByteStream {
2877        futures::stream::once(async move { Ok(bytes::Bytes::from_static(b)) }).boxed()
2878    }
2879
2880    /// A manifest referencing the given `(path, blob-hash)` files.
2881    fn manifest_with(files: &[(&str, &str)]) -> Manifest {
2882        let mut m = Manifest::default();
2883        for (path, hash) in files {
2884            m.files.insert(
2885                (*path).to_string(),
2886                FileEntry {
2887                    hash: (*hash).to_string(),
2888                    size: 1,
2889                    content_type: None,
2890                    variants: Default::default(),
2891                },
2892            );
2893        }
2894        m
2895    }
2896
2897    #[tokio::test]
2898    async fn function_storage_versioning_alias_rollback() {
2899        use crate::function::{Function, FunctionConfig, Lifecycle, Owner};
2900        use crate::kv::MemoryKv;
2901
2902        let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
2903        assert!(store
2904            .list_stored_functions(ProjectRef::DEFAULT)
2905            .await
2906            .unwrap()
2907            .is_empty());
2908
2909        let mut f = Function::new(
2910            "resize",
2911            Owner::Project("acme".into()),
2912            "hashA",
2913            FunctionConfig::default(),
2914            Lifecycle::Independent,
2915            1,
2916        );
2917        store.put_function(ProjectRef::DEFAULT, &f).await.unwrap();
2918        assert_eq!(
2919            store
2920                .get_function(ProjectRef::DEFAULT, "resize")
2921                .await
2922                .unwrap()
2923                .unwrap()
2924                .active,
2925            "hashA"
2926        );
2927        assert_eq!(
2928            store
2929                .list_stored_functions(ProjectRef::DEFAULT)
2930                .await
2931                .unwrap()
2932                .len(),
2933            1
2934        );
2935
2936        // A new version + an alias, persisted and read back.
2937        f.upsert_version("hashB", Lifecycle::Independent, 2);
2938        f.set_alias("prod", "hashA").unwrap();
2939        store.put_function(ProjectRef::DEFAULT, &f).await.unwrap();
2940        let got = store
2941            .get_function(ProjectRef::DEFAULT, "resize")
2942            .await
2943            .unwrap()
2944            .unwrap();
2945        assert_eq!(got.active, "hashB");
2946        assert_eq!(got.aliases.get("prod").map(String::as_str), Some("hashA"));
2947
2948        // Delete is idempotent + reports prior existence.
2949        assert!(store
2950            .delete_function(ProjectRef::DEFAULT, "resize")
2951            .await
2952            .unwrap());
2953        assert!(store
2954            .get_function(ProjectRef::DEFAULT, "resize")
2955            .await
2956            .unwrap()
2957            .is_none());
2958        assert!(!store
2959            .delete_function(ProjectRef::DEFAULT, "resize")
2960            .await
2961            .unwrap());
2962    }
2963
2964    #[tokio::test]
2965    async fn function_invocation_and_idempotency_storage() {
2966        use crate::function::{
2967            Function, FunctionConfig, Invocation, InvocationResult, InvocationStatus, InvokeMode,
2968            Lifecycle, Owner,
2969        };
2970        use crate::kv::MemoryKv;
2971
2972        let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
2973        // A function whose invocation sub-keys must NOT leak into the function list.
2974        let f = Function::new(
2975            "greeter",
2976            Owner::Project("acme".into()),
2977            "hashA",
2978            FunctionConfig::default(),
2979            Lifecycle::Independent,
2980            1,
2981        );
2982        store.put_function(ProjectRef::DEFAULT, &f).await.unwrap();
2983
2984        let mut inv = Invocation {
2985            id: "inv-1".into(),
2986            function: "greeter".into(),
2987            version: "hashA".into(),
2988            mode: InvokeMode::Async,
2989            status: InvocationStatus::Queued,
2990            idempotency_key: Some("key-1".into()),
2991            attempts: 0,
2992            request_b64: None,
2993            request_content_type: None,
2994            result: None,
2995            created: 10,
2996            updated: 10,
2997        };
2998        store
2999            .put_invocation(ProjectRef::DEFAULT, &inv)
3000            .await
3001            .unwrap();
3002        store
3003            .put_idempotency(ProjectRef::DEFAULT, "greeter", "key-1", "inv-1")
3004            .await
3005            .unwrap();
3006
3007        // Read back, resolve the idempotency pointer, and list.
3008        assert_eq!(
3009            store
3010                .get_invocation(ProjectRef::DEFAULT, "greeter", "inv-1")
3011                .await
3012                .unwrap()
3013                .unwrap()
3014                .status,
3015            InvocationStatus::Queued
3016        );
3017        assert_eq!(
3018            store
3019                .get_idempotency(ProjectRef::DEFAULT, "greeter", "key-1")
3020                .await
3021                .unwrap(),
3022            Some("inv-1".to_string())
3023        );
3024        assert_eq!(
3025            store
3026                .list_invocations(ProjectRef::DEFAULT, "greeter")
3027                .await
3028                .unwrap()
3029                .len(),
3030            1
3031        );
3032        // The invocation + idempotency sub-keys must not be mistaken for functions.
3033        assert_eq!(
3034            store
3035                .list_stored_functions(ProjectRef::DEFAULT)
3036                .await
3037                .unwrap()
3038                .len(),
3039            1
3040        );
3041
3042        // Transition to a terminal, captured result.
3043        inv.status = InvocationStatus::Succeeded;
3044        inv.attempts = 1;
3045        inv.result = Some(InvocationResult {
3046            status: 200,
3047            content_type: Some("text/plain".into()),
3048            body_b64: "aGVsbG8=".into(),
3049        });
3050        inv.updated = 20;
3051        store
3052            .put_invocation(ProjectRef::DEFAULT, &inv)
3053            .await
3054            .unwrap();
3055        let got = store
3056            .get_invocation(ProjectRef::DEFAULT, "greeter", "inv-1")
3057            .await
3058            .unwrap()
3059            .unwrap();
3060        assert!(got.is_terminal());
3061        assert_eq!(got.result.unwrap().status, 200);
3062
3063        // An unrecorded key resolves to nothing.
3064        assert!(store
3065            .get_idempotency(ProjectRef::DEFAULT, "greeter", "absent")
3066            .await
3067            .unwrap()
3068            .is_none());
3069    }
3070
3071    #[tokio::test]
3072    async fn notification_ledger_provisions_and_retracts_through_the_store() {
3073        use crate::blob_notify::{ManagedResource, ProvisionTier};
3074        use crate::blob_provision::{ensure_watch, retract_watch, ProvisionError, WatchProvider};
3075        use crate::kv::MemoryKv;
3076
3077        // A minimal provider standing in for a cloud SDK.
3078        struct StoreMock;
3079        #[async_trait::async_trait]
3080        impl WatchProvider for StoreMock {
3081            fn name(&self) -> &str {
3082                "mock"
3083            }
3084            fn recipe(&self, _prefix: &str) -> String {
3085                String::new()
3086            }
3087            async fn provision(
3088                &self,
3089                _prefix: &str,
3090            ) -> Result<Vec<ManagedResource>, ProvisionError> {
3091                Ok(vec![ManagedResource::new("queue", "q-1")])
3092            }
3093            async fn verify(&self, _prefix: &str) -> Result<bool, ProvisionError> {
3094                Ok(true)
3095            }
3096            async fn retract(&self, _res: &[ManagedResource]) -> Result<(), ProvisionError> {
3097                Ok(())
3098            }
3099        }
3100
3101        let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
3102        assert!(store
3103            .get_managed_notification(ProjectRef::DEFAULT, "ingest", "uploads/")
3104            .await
3105            .unwrap()
3106            .is_none());
3107
3108        // Provision through the real store (its `LedgerSink` impl) → recorded.
3109        let provider = StoreMock;
3110        let out = ensure_watch(
3111            &provider,
3112            ProvisionTier::Provision,
3113            "ingest",
3114            "uploads/",
3115            &store,
3116            7,
3117        )
3118        .await
3119        .unwrap();
3120        assert!(matches!(
3121            out,
3122            crate::blob_provision::ProvisionOutcome::Ready
3123        ));
3124        let record = store
3125            .get_managed_notification(ProjectRef::DEFAULT, "ingest", "uploads/")
3126            .await
3127            .unwrap()
3128            .expect("the pipeline is recorded in the store ledger");
3129        assert_eq!(record.provider, "mock");
3130        assert_eq!(
3131            store
3132                .list_managed_notifications(ProjectRef::DEFAULT, "ingest")
3133                .await
3134                .unwrap()
3135                .len(),
3136            1
3137        );
3138
3139        // Retract removes the ledger entry.
3140        retract_watch(&provider, &record, &store).await.unwrap();
3141        assert!(store
3142            .get_managed_notification(ProjectRef::DEFAULT, "ingest", "uploads/")
3143            .await
3144            .unwrap()
3145            .is_none());
3146    }
3147
3148    #[tokio::test]
3149    async fn workflow_definition_and_run_storage() {
3150        use crate::kv::MemoryKv;
3151        use crate::workflow::{Step, Workflow, WorkflowRun};
3152
3153        let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
3154        assert!(store
3155            .get_workflow(ProjectRef::DEFAULT, "etl")
3156            .await
3157            .unwrap()
3158            .is_none());
3159        assert!(store
3160            .list_workflows(ProjectRef::DEFAULT)
3161            .await
3162            .unwrap()
3163            .is_empty());
3164
3165        let wf = Workflow {
3166            name: "etl".into(),
3167            steps: vec![
3168                Step {
3169                    id: "a".into(),
3170                    function: "extract".into(),
3171                    depends_on: vec![],
3172                    retry: Default::default(),
3173                    compensate: None,
3174                },
3175                Step {
3176                    id: "b".into(),
3177                    function: "load".into(),
3178                    depends_on: vec!["a".into()],
3179                    retry: Default::default(),
3180                    compensate: None,
3181                },
3182            ],
3183        };
3184        store.put_workflow(ProjectRef::DEFAULT, &wf).await.unwrap();
3185        assert_eq!(
3186            store
3187                .get_workflow(ProjectRef::DEFAULT, "etl")
3188                .await
3189                .unwrap()
3190                .unwrap(),
3191            wf
3192        );
3193        assert_eq!(
3194            store
3195                .list_workflows(ProjectRef::DEFAULT)
3196                .await
3197                .unwrap()
3198                .len(),
3199            1
3200        );
3201
3202        // A run is stored under the workflow's runs prefix — not mistaken for a def.
3203        let run = WorkflowRun::start(&wf, "r1", None, 5);
3204        store
3205            .put_workflow_run(ProjectRef::DEFAULT, &run)
3206            .await
3207            .unwrap();
3208        assert_eq!(
3209            store
3210                .get_workflow_run(ProjectRef::DEFAULT, "etl", "r1")
3211                .await
3212                .unwrap()
3213                .unwrap(),
3214            run
3215        );
3216        assert_eq!(
3217            store
3218                .list_workflow_runs(ProjectRef::DEFAULT, "etl")
3219                .await
3220                .unwrap()
3221                .len(),
3222            1
3223        );
3224        // The run sub-key must not appear in the definition list.
3225        assert_eq!(
3226            store
3227                .list_workflows(ProjectRef::DEFAULT)
3228                .await
3229                .unwrap()
3230                .len(),
3231            1
3232        );
3233
3234        // Delete reports prior existence + is idempotent.
3235        assert!(store
3236            .delete_workflow(ProjectRef::DEFAULT, "etl")
3237            .await
3238            .unwrap());
3239        assert!(store
3240            .get_workflow(ProjectRef::DEFAULT, "etl")
3241            .await
3242            .unwrap()
3243            .is_none());
3244        assert!(!store
3245            .delete_workflow(ProjectRef::DEFAULT, "etl")
3246            .await
3247            .unwrap());
3248    }
3249
3250    #[tokio::test]
3251    async fn function_trigger_storage_round_trips() {
3252        use crate::function::{
3253            Function, FunctionConfig, FunctionTrigger, Lifecycle, Owner, TriggerKind,
3254        };
3255        use crate::kv::MemoryKv;
3256
3257        let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
3258        let f = Function::new(
3259            "worker",
3260            Owner::Project("acme".into()),
3261            "hashA",
3262            FunctionConfig::default(),
3263            Lifecycle::Independent,
3264            1,
3265        );
3266        store.put_function(ProjectRef::DEFAULT, &f).await.unwrap();
3267        assert!(store
3268            .list_triggers(ProjectRef::DEFAULT, "worker")
3269            .await
3270            .unwrap()
3271            .is_empty());
3272
3273        let cron = FunctionTrigger {
3274            id: "tick".into(),
3275            kind: TriggerKind::Cron {
3276                schedule: "* * * * *".into(),
3277                overlap: Default::default(),
3278            },
3279            last_fired_minute: None,
3280        };
3281        store
3282            .put_trigger(ProjectRef::DEFAULT, "worker", &cron)
3283            .await
3284            .unwrap();
3285        let queue = FunctionTrigger {
3286            id: "jobs".into(),
3287            kind: TriggerKind::Queue {
3288                topic: "jobs".into(),
3289            },
3290            last_fired_minute: None,
3291        };
3292        store
3293            .put_trigger(ProjectRef::DEFAULT, "worker", &queue)
3294            .await
3295            .unwrap();
3296
3297        assert_eq!(
3298            store
3299                .list_triggers(ProjectRef::DEFAULT, "worker")
3300                .await
3301                .unwrap()
3302                .len(),
3303            2
3304        );
3305        assert_eq!(
3306            store
3307                .get_trigger(ProjectRef::DEFAULT, "worker", "tick")
3308                .await
3309                .unwrap()
3310                .unwrap(),
3311            cron
3312        );
3313        // Trigger sub-keys don't leak into the function list.
3314        assert_eq!(
3315            store
3316                .list_stored_functions(ProjectRef::DEFAULT)
3317                .await
3318                .unwrap()
3319                .len(),
3320            1
3321        );
3322
3323        // Dedup state persists through an update.
3324        let mut fired = cron.clone();
3325        fired.last_fired_minute = Some(42);
3326        store
3327            .put_trigger(ProjectRef::DEFAULT, "worker", &fired)
3328            .await
3329            .unwrap();
3330        assert_eq!(
3331            store
3332                .get_trigger(ProjectRef::DEFAULT, "worker", "tick")
3333                .await
3334                .unwrap()
3335                .unwrap()
3336                .last_fired_minute,
3337            Some(42)
3338        );
3339
3340        // Delete reports prior existence + is idempotent.
3341        assert!(store
3342            .delete_trigger(ProjectRef::DEFAULT, "worker", "tick")
3343            .await
3344            .unwrap());
3345        assert!(!store
3346            .delete_trigger(ProjectRef::DEFAULT, "worker", "tick")
3347            .await
3348            .unwrap());
3349        assert_eq!(
3350            store
3351                .list_triggers(ProjectRef::DEFAULT, "worker")
3352                .await
3353                .unwrap()
3354                .len(),
3355            1
3356        );
3357    }
3358
3359    #[tokio::test]
3360    async fn function_metering_storage_is_tenant_isolated() {
3361        use crate::function::{Metering, MeteringSample};
3362        use crate::kv::MemoryKv;
3363
3364        let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
3365        assert!(store
3366            .get_metering(ProjectRef::DEFAULT, "a")
3367            .await
3368            .unwrap()
3369            .is_none());
3370
3371        let mut ma = Metering::new("a");
3372        ma.record(
3373            &MeteringSample {
3374                success: true,
3375                duration_ms: 4,
3376                bytes_in: 1,
3377                bytes_out: 2,
3378            },
3379            10,
3380        );
3381        store.put_metering(ProjectRef::DEFAULT, &ma).await.unwrap();
3382
3383        let mut mb = Metering::new("b");
3384        mb.record(
3385            &MeteringSample {
3386                success: false,
3387                duration_ms: 9,
3388                bytes_in: 0,
3389                bytes_out: 0,
3390            },
3391            11,
3392        );
3393        store.put_metering(ProjectRef::DEFAULT, &mb).await.unwrap();
3394
3395        // Each function's aggregate is stored + read independently.
3396        assert_eq!(
3397            store
3398                .get_metering(ProjectRef::DEFAULT, "a")
3399                .await
3400                .unwrap()
3401                .unwrap()
3402                .successes,
3403            1
3404        );
3405        assert_eq!(
3406            store
3407                .get_metering(ProjectRef::DEFAULT, "b")
3408                .await
3409                .unwrap()
3410                .unwrap()
3411                .failures,
3412            1
3413        );
3414        let all = store.list_metering(ProjectRef::DEFAULT).await.unwrap();
3415        assert_eq!(all.len(), 2);
3416    }
3417
3418    #[tokio::test]
3419    async fn managed_dns_ledger_round_trip_and_retract() {
3420        use crate::dns_managed::{ManagedDns, ManagedRecord};
3421        use crate::kv::MemoryKv;
3422
3423        let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
3424        assert!(store
3425            .get_managed_dns(
3426                ProjectRef::DEFAULT,
3427                &SiteName::new("blog"),
3428                "www.example.com"
3429            )
3430            .await
3431            .unwrap()
3432            .is_none());
3433
3434        let ledger = ManagedDns::new(
3435            "www.example.com",
3436            "cloudflare",
3437            vec![ManagedRecord {
3438                kind: "A".into(),
3439                name: "www.example.com".into(),
3440                value: "203.0.113.7".into(),
3441                ttl: 300,
3442            }],
3443            10,
3444        );
3445        store
3446            .set_managed_dns(ProjectRef::DEFAULT, &SiteName::new("blog"), &ledger)
3447            .await
3448            .unwrap();
3449        // Lookup normalizes the host, so a differently-cased/dotted query hits it.
3450        assert_eq!(
3451            store
3452                .get_managed_dns(
3453                    ProjectRef::DEFAULT,
3454                    &SiteName::new("blog"),
3455                    "WWW.example.com."
3456                )
3457                .await
3458                .unwrap(),
3459            Some(ledger.clone())
3460        );
3461        assert_eq!(
3462            store
3463                .list_managed_dns(ProjectRef::DEFAULT, &SiteName::new("blog"))
3464                .await
3465                .unwrap(),
3466            vec![ledger]
3467        );
3468
3469        store
3470            .remove_managed_dns(
3471                ProjectRef::DEFAULT,
3472                &SiteName::new("blog"),
3473                "www.example.com",
3474            )
3475            .await
3476            .unwrap();
3477        assert!(store
3478            .list_managed_dns(ProjectRef::DEFAULT, &SiteName::new("blog"))
3479            .await
3480            .unwrap()
3481            .is_empty());
3482    }
3483
3484    #[tokio::test]
3485    async fn site_config_round_trip_and_host_routing() {
3486        use crate::config::{DomainConfig, SiteConfig};
3487        use crate::kv::MemoryKv;
3488
3489        let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
3490        let config = SiteConfig {
3491            domains: DomainConfig {
3492                primary: Some("example.com".into()),
3493                aliases: vec!["www.example.com".into()],
3494                wildcards: vec!["*.example.com".into()],
3495                ..Default::default()
3496            },
3497            ..Default::default()
3498        };
3499        store
3500            .set_site_config(ProjectRef::DEFAULT, "blog", &config)
3501            .await
3502            .unwrap();
3503
3504        let resolved = |host: &'static str| {
3505            let store = store.clone();
3506            async move {
3507                store
3508                    .resolve_site_by_host(host)
3509                    .await
3510                    .unwrap()
3511                    .map(|o| o.site)
3512            }
3513        };
3514        assert_eq!(resolved("example.com").await.as_deref(), Some("blog")); // exact primary
3515        assert_eq!(resolved("www.example.com").await.as_deref(), Some("blog")); // exact alias
3516        assert_eq!(resolved("api.example.com").await.as_deref(), Some("blog")); // wildcard
3517        assert_eq!(resolved("a.b.example.com").await.as_deref(), Some("blog")); // wildcard, deep
3518        assert_eq!(resolved("other.com").await, None);
3519
3520        // Clearing the domains drops the index entries.
3521        store
3522            .set_site_config(ProjectRef::DEFAULT, "blog", &SiteConfig::default())
3523            .await
3524            .unwrap();
3525        assert_eq!(resolved("example.com").await, None);
3526    }
3527
3528    #[tokio::test]
3529    async fn site_config_is_content_addressed_and_dedups() {
3530        use crate::config::SiteConfig;
3531        use crate::kv::MemoryKv;
3532
3533        let kv = Arc::new(MemoryKv::new());
3534        let store = DeployStore::new(Arc::new(NullStorage), kv.clone());
3535
3536        // Distinguish the sites by a *non-domain* field: two sites can't share a
3537        // domain (the host-uniqueness guard refuses it), but they can share an
3538        // identical body — which is what this test is about.
3539        let mut cfg = SiteConfig::default();
3540        cfg.security.https_redirect = true;
3541        // Two different sites with identical config dedup to one body blob.
3542        store
3543            .set_site_config(ProjectRef::DEFAULT, "s1", &cfg)
3544            .await
3545            .unwrap();
3546        let mut cfg2 = cfg.clone();
3547        cfg2.security.https_redirect = false;
3548        store
3549            .set_site_config(ProjectRef::DEFAULT, "s2", &cfg2)
3550            .await
3551            .unwrap();
3552        store
3553            .set_site_config(ProjectRef::DEFAULT, "s3", &cfg)
3554            .await
3555            .unwrap(); // identical to s1
3556
3557        // Pointers exist for each site; bodies are deduped (s1 == s3 → one blob).
3558        let bodies = kv.list_prefix("siteconfig/").await.unwrap();
3559        assert_eq!(bodies.len(), 2, "s1/s3 share a body; s2 distinct");
3560        let pointers = kv.list_prefix("project/default/site/").await.unwrap();
3561        assert_eq!(pointers.len(), 3);
3562
3563        // Round-trips.
3564        assert!(
3565            store
3566                .get_site_config(ProjectRef::DEFAULT, "s1")
3567                .await
3568                .unwrap()
3569                .unwrap()
3570                .security
3571                .https_redirect
3572        );
3573        assert_eq!(
3574            store
3575                .get_site_config(ProjectRef::DEFAULT, "missing")
3576                .await
3577                .unwrap(),
3578            None
3579        );
3580
3581        // Editing s1 flips its pointer and orphans its old body; GC reclaims it
3582        // (s3 still references it, so it survives until s3 changes too).
3583        let mut edited = cfg.clone();
3584        edited.security.frame_options = Some("DENY".into());
3585        store
3586            .set_site_config(ProjectRef::DEFAULT, "s1", &edited)
3587            .await
3588            .unwrap();
3589        store.collect_garbage(true).await.unwrap();
3590        // s1's old body is still referenced by s3 → not collected.
3591        assert_eq!(kv.list_prefix("siteconfig/").await.unwrap().len(), 3);
3592        // Now change s3 too; the old shared body becomes orphaned and is GC'd.
3593        store
3594            .set_site_config(ProjectRef::DEFAULT, "s3", &edited)
3595            .await
3596            .unwrap();
3597        store.collect_garbage(true).await.unwrap();
3598        let remaining = kv.list_prefix("siteconfig/").await.unwrap();
3599        assert_eq!(remaining.len(), 2, "orphaned shared body reclaimed");
3600        // Everything still reads correctly after GC.
3601        assert!(
3602            store
3603                .get_site_config(ProjectRef::DEFAULT, "s1")
3604                .await
3605                .unwrap()
3606                .unwrap()
3607                .security
3608                .https_redirect
3609        );
3610        assert!(
3611            !store
3612                .get_site_config(ProjectRef::DEFAULT, "s2")
3613                .await
3614                .unwrap()
3615                .unwrap()
3616                .security
3617                .https_redirect
3618        );
3619    }
3620
3621    #[tokio::test]
3622    async fn cert_status_lists_domains_and_expiry_without_keys() {
3623        use crate::cert::StoredCert;
3624        use crate::kv::MemoryKv;
3625
3626        let kv = Arc::new(MemoryKv::new());
3627        let store = DeployStore::new(Arc::new(NullStorage), kv.clone());
3628        // Two stored certs (as the cluster cert store writes them) + an unrelated key.
3629        for (domain, not_after) in [("b.example.com", 2000u64), ("a.example.com", 1000u64)] {
3630            let cert = StoredCert::new("CHAINPEM", "KEYPEM", not_after);
3631            kv.put(
3632                &crate::cert::cert_key(domain),
3633                serde_json::to_vec(&cert).unwrap(),
3634            )
3635            .await
3636            .unwrap();
3637        }
3638        kv.put("site/x/config", b"{}".to_vec()).await.unwrap();
3639
3640        let status = store.cert_status().await.unwrap();
3641        assert_eq!(status.len(), 2);
3642        // Sorted by domain; carries expiry, never key material.
3643        assert_eq!(status[0].domain, "a.example.com");
3644        assert_eq!(status[0].not_after_unix, 1000);
3645        assert_eq!(status[1].domain, "b.example.com");
3646    }
3647
3648    #[tokio::test]
3649    async fn domain_verification_gates_attachment() {
3650        use crate::domain_verify::VerificationMethod;
3651
3652        let store = store();
3653
3654        // Start a challenge; re-starting under the same method is idempotent.
3655        let v1 = store
3656            .start_domain_verification(
3657                ProjectRef::DEFAULT,
3658                &SiteName::new("blog"),
3659                "example.com",
3660                VerificationMethod::Dns,
3661                100,
3662            )
3663            .await
3664            .unwrap();
3665        let v2 = store
3666            .start_domain_verification(
3667                ProjectRef::DEFAULT,
3668                &SiteName::new("blog"),
3669                "example.com",
3670                VerificationMethod::Dns,
3671                200,
3672            )
3673            .await
3674            .unwrap();
3675        assert_eq!(v1.token, v2.token, "same method → same pending token");
3676        assert!(!store
3677            .is_domain_verified(ProjectRef::DEFAULT, &SiteName::new("blog"), "example.com")
3678            .await
3679            .unwrap());
3680
3681        // Unverified hosts cannot be attached — the gate.
3682        assert!(store
3683            .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("blog"), "example.com")
3684            .await
3685            .is_err());
3686
3687        // Verify, then attach: the host enters routing as the primary.
3688        store
3689            .mark_domain_verified(ProjectRef::DEFAULT, &SiteName::new("blog"), "example.com")
3690            .await
3691            .unwrap();
3692        assert!(store
3693            .is_domain_verified(ProjectRef::DEFAULT, &SiteName::new("blog"), "example.com")
3694            .await
3695            .unwrap());
3696        store
3697            .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("blog"), "example.com")
3698            .await
3699            .unwrap();
3700        assert_eq!(
3701            store
3702                .resolve_site_by_host("example.com")
3703                .await
3704                .unwrap()
3705                .map(|o| o.site)
3706                .as_deref(),
3707            Some("blog")
3708        );
3709        // A second verified host becomes an alias, not the primary.
3710        store
3711            .start_domain_verification(
3712                ProjectRef::DEFAULT,
3713                &SiteName::new("blog"),
3714                "www.example.com",
3715                VerificationMethod::Http,
3716                300,
3717            )
3718            .await
3719            .unwrap();
3720        store
3721            .mark_domain_verified(
3722                ProjectRef::DEFAULT,
3723                &SiteName::new("blog"),
3724                "www.example.com",
3725            )
3726            .await
3727            .unwrap();
3728        let config = store
3729            .attach_verified_domain(
3730                ProjectRef::DEFAULT,
3731                &SiteName::new("blog"),
3732                "www.example.com",
3733            )
3734            .await
3735            .unwrap();
3736        assert_eq!(config.domains.primary.as_deref(), Some("example.com"));
3737        assert_eq!(config.domains.aliases, vec!["www.example.com".to_string()]);
3738
3739        // A wildcard is verified at its base name and attached as a wildcard.
3740        store
3741            .start_domain_verification(
3742                ProjectRef::DEFAULT,
3743                &SiteName::new("blog"),
3744                "*.example.com",
3745                VerificationMethod::Dns,
3746                400,
3747            )
3748            .await
3749            .unwrap();
3750        // The challenge keys on the base host, so the wildcard shares it.
3751        assert!(store
3752            .is_domain_verified(ProjectRef::DEFAULT, &SiteName::new("blog"), "*.example.com")
3753            .await
3754            .unwrap());
3755        let config = store
3756            .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("blog"), "*.example.com")
3757            .await
3758            .unwrap();
3759        assert_eq!(config.domains.wildcards, vec!["*.example.com".to_string()]);
3760
3761        // Listing surfaces every challenge; removing drops the record.
3762        assert_eq!(
3763            store
3764                .list_domain_verifications(ProjectRef::DEFAULT, &SiteName::new("blog"))
3765                .await
3766                .unwrap()
3767                .len(),
3768            2
3769        );
3770        assert!(store
3771            .remove_domain_verification(ProjectRef::DEFAULT, &SiteName::new("blog"), "example.com")
3772            .await
3773            .unwrap());
3774        assert!(!store
3775            .is_domain_verified(ProjectRef::DEFAULT, &SiteName::new("blog"), "example.com")
3776            .await
3777            .unwrap());
3778    }
3779
3780    /// The host-uniqueness hijack guard: a host already routed to one site cannot
3781    /// be claimed by another — neither via `attach_verified_domain` nor via a
3782    /// direct `set_site_config` — so a site-writer can't steal another's domain.
3783    #[tokio::test]
3784    async fn host_cannot_be_hijacked_across_sites() {
3785        use crate::config::{DomainConfig, SiteConfig};
3786        use crate::domain_verify::VerificationMethod;
3787
3788        let store = store();
3789
3790        // Site `a` legitimately verifies + attaches `shared.example`.
3791        store
3792            .start_domain_verification(
3793                ProjectRef::DEFAULT,
3794                &SiteName::new("a"),
3795                "shared.example",
3796                VerificationMethod::Http,
3797                100,
3798            )
3799            .await
3800            .unwrap();
3801        store
3802            .mark_domain_verified(ProjectRef::DEFAULT, &SiteName::new("a"), "shared.example")
3803            .await
3804            .unwrap();
3805        store
3806            .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("a"), "shared.example")
3807            .await
3808            .unwrap();
3809        assert_eq!(
3810            store
3811                .resolve_site_by_host("shared.example")
3812                .await
3813                .unwrap()
3814                .map(|o| o.site)
3815                .as_deref(),
3816            Some("a")
3817        );
3818
3819        // Site `b` verifies the same host (imagine control briefly changed hands,
3820        // or a stale challenge) and tries to attach — it must be refused, not
3821        // silently steal the live mapping.
3822        store
3823            .start_domain_verification(
3824                ProjectRef::DEFAULT,
3825                &SiteName::new("b"),
3826                "shared.example",
3827                VerificationMethod::Http,
3828                200,
3829            )
3830            .await
3831            .unwrap();
3832        store
3833            .mark_domain_verified(ProjectRef::DEFAULT, &SiteName::new("b"), "shared.example")
3834            .await
3835            .unwrap();
3836        let err = store
3837            .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("b"), "shared.example")
3838            .await
3839            .expect_err("second site must not hijack an attached host");
3840        assert!(matches!(err, DeployError::Conflict(_)), "got {err:?}");
3841
3842        // The direct config path is guarded too (this is what a raw
3843        // `PUT /config` with a stolen domain would hit).
3844        let stolen = SiteConfig {
3845            domains: DomainConfig {
3846                primary: Some("shared.example".into()),
3847                ..Default::default()
3848            },
3849            ..Default::default()
3850        };
3851        let err = store
3852            .set_site_config(ProjectRef::DEFAULT, "b", &stolen)
3853            .await
3854            .expect_err("set_site_config must refuse another site's host");
3855        assert!(matches!(err, DeployError::Conflict(_)), "got {err:?}");
3856
3857        // The original owner is untouched.
3858        assert_eq!(
3859            store
3860                .resolve_site_by_host("shared.example")
3861                .await
3862                .unwrap()
3863                .map(|o| o.site)
3864                .as_deref(),
3865            Some("a")
3866        );
3867
3868        // Re-writing the *same* site's own config (no owner change) is fine — the
3869        // guard only fires across sites.
3870        let readd = SiteConfig {
3871            domains: DomainConfig {
3872                primary: Some("shared.example".into()),
3873                aliases: vec!["www.shared.example".into()],
3874                ..Default::default()
3875            },
3876            ..Default::default()
3877        };
3878        store
3879            .set_site_config(ProjectRef::DEFAULT, "a", &readd)
3880            .await
3881            .unwrap();
3882        assert_eq!(
3883            store
3884                .resolve_site_by_host("www.shared.example")
3885                .await
3886                .unwrap()
3887                .map(|o| o.site)
3888                .as_deref(),
3889            Some("a")
3890        );
3891    }
3892
3893    /// The hijack guard folds case + trailing dot: a variant-cased or
3894    /// dot-suffixed host can't write a second routing key past the guard, and
3895    /// routing resolves any casing to the one owner.
3896    #[tokio::test]
3897    async fn host_uniqueness_is_case_and_dot_insensitive() {
3898        use crate::config::{DomainConfig, SiteConfig};
3899        use crate::domain_verify::VerificationMethod;
3900
3901        let store = store();
3902        // Site `a` legitimately attaches `example.com`.
3903        store
3904            .start_domain_verification(
3905                ProjectRef::DEFAULT,
3906                &SiteName::new("a"),
3907                "example.com",
3908                VerificationMethod::Http,
3909                100,
3910            )
3911            .await
3912            .unwrap();
3913        store
3914            .mark_domain_verified(ProjectRef::DEFAULT, &SiteName::new("a"), "example.com")
3915            .await
3916            .unwrap();
3917        store
3918            .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("a"), "example.com")
3919            .await
3920            .unwrap();
3921
3922        // Site `b` tries to claim case / trailing-dot variants of the same host.
3923        for variant in ["Example.COM", "example.com.", "EXAMPLE.com."] {
3924            let cfg = SiteConfig {
3925                domains: DomainConfig {
3926                    primary: Some(variant.into()),
3927                    ..Default::default()
3928                },
3929                ..Default::default()
3930            };
3931            let err = store
3932                .set_site_config(ProjectRef::DEFAULT, "b", &cfg)
3933                .await
3934                .expect_err("variant claim must be refused");
3935            assert!(
3936                matches!(err, DeployError::Conflict(_)),
3937                "variant {variant:?} must Conflict, got {err:?}"
3938            );
3939        }
3940
3941        // Routing folds case + trailing dot to the one owner.
3942        for h in ["example.com", "Example.com", "EXAMPLE.COM", "example.com."] {
3943            assert_eq!(
3944                store
3945                    .resolve_site_by_host(h)
3946                    .await
3947                    .unwrap()
3948                    .map(|o| o.site)
3949                    .as_deref(),
3950                Some("a"),
3951                "host {h:?} must resolve to site a"
3952            );
3953        }
3954    }
3955
3956    /// The self-serve edge lookup: a pending HTTP challenge is found by
3957    /// (host, token), and only then — not for the wrong token/host, a DNS
3958    /// challenge, or an expired one.
3959    #[tokio::test]
3960    async fn self_serve_challenge_lookup_matches_pending_http_only() {
3961        use crate::domain_verify::{VerificationMethod, CHALLENGE_TTL_SECS};
3962
3963        let store = store();
3964        let v = store
3965            .start_domain_verification(
3966                ProjectRef::DEFAULT,
3967                &SiteName::new("docs"),
3968                "docs.example",
3969                VerificationMethod::Http,
3970                1_000,
3971            )
3972            .await
3973            .unwrap();
3974
3975        // Exact (host, token) within the TTL → found.
3976        let found = store
3977            .find_pending_http_challenge("docs.example", &v.token, 1_000)
3978            .await
3979            .unwrap();
3980        assert_eq!(
3981            found.as_ref().map(|f| f.token.clone()),
3982            Some(v.token.clone())
3983        );
3984        // A trailing dot / uppercase host still normalizes to a match.
3985        assert!(store
3986            .find_pending_http_challenge("Docs.Example.", &v.token, 1_000)
3987            .await
3988            .unwrap()
3989            .is_some());
3990
3991        // Wrong token, wrong host → no match (never leaks another host's token).
3992        assert!(store
3993            .find_pending_http_challenge("docs.example", "not-the-token", 1_000)
3994            .await
3995            .unwrap()
3996            .is_none());
3997        assert!(store
3998            .find_pending_http_challenge("other.example", &v.token, 1_000)
3999            .await
4000            .unwrap()
4001            .is_none());
4002
4003        // Past the TTL → refused (a stale token can't be redeemed forever).
4004        assert!(store
4005            .find_pending_http_challenge("docs.example", &v.token, 1_000 + CHALLENGE_TTL_SECS + 1)
4006            .await
4007            .unwrap()
4008            .is_none());
4009
4010        // A DNS-method challenge is never served over the HTTP edge route.
4011        let dv = store
4012            .start_domain_verification(
4013                ProjectRef::DEFAULT,
4014                &SiteName::new("dns-site"),
4015                "dns.example",
4016                VerificationMethod::Dns,
4017                1_000,
4018            )
4019            .await
4020            .unwrap();
4021        assert!(store
4022            .find_pending_http_challenge("dns.example", &dv.token, 1_000)
4023            .await
4024            .unwrap()
4025            .is_none());
4026    }
4027
4028    /// A wildcard can only be attached with DNS proof; an HTTP token at the base
4029    /// host is refused. And a stale HTTP self-serve index entry (left by a later
4030    /// method change) never serves the wrong challenge — the lookup re-validates.
4031    #[tokio::test]
4032    async fn wildcard_requires_dns_and_stale_index_is_safe() {
4033        use crate::domain_verify::VerificationMethod;
4034
4035        let store = store();
4036
4037        // HTTP-verify the base host, then try to attach the wildcard → refused.
4038        let http = store
4039            .start_domain_verification(
4040                ProjectRef::DEFAULT,
4041                &SiteName::new("s"),
4042                "*.example.com",
4043                VerificationMethod::Http,
4044                100,
4045            )
4046            .await
4047            .unwrap();
4048        store
4049            .mark_domain_verified(ProjectRef::DEFAULT, &SiteName::new("s"), "*.example.com")
4050            .await
4051            .unwrap();
4052        let err = store
4053            .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("s"), "*.example.com")
4054            .await
4055            .expect_err("wildcard with only HTTP proof must be refused");
4056        assert!(matches!(err, DeployError::Conflict(_)), "got {err:?}");
4057
4058        // Drop it and re-verify via DNS → the wildcard now attaches. This replaces
4059        // the record (the old HTTP token's self-serve index entry is dropped on
4060        // remove), and re-proves via DNS.
4061        store
4062            .remove_domain_verification(ProjectRef::DEFAULT, &SiteName::new("s"), "*.example.com")
4063            .await
4064            .unwrap();
4065        // The removed HTTP token is no longer self-servable.
4066        assert!(store
4067            .find_pending_http_challenge("example.com", &http.token, 100)
4068            .await
4069            .unwrap()
4070            .is_none());
4071        store
4072            .start_domain_verification(
4073                ProjectRef::DEFAULT,
4074                &SiteName::new("s"),
4075                "*.example.com",
4076                VerificationMethod::Dns,
4077                200,
4078            )
4079            .await
4080            .unwrap();
4081        store
4082            .mark_domain_verified(ProjectRef::DEFAULT, &SiteName::new("s"), "*.example.com")
4083            .await
4084            .unwrap();
4085        let cfg = store
4086            .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("s"), "*.example.com")
4087            .await
4088            .unwrap();
4089        assert_eq!(cfg.domains.wildcards, vec!["*.example.com".to_string()]);
4090
4091        // Stale-index safety: an HTTP challenge whose record is later switched to
4092        // DNS (without removal) leaves a dangling token index; the lookup loads
4093        // the current (DNS) record and refuses to serve the old HTTP token.
4094        let h2 = store
4095            .start_domain_verification(
4096                ProjectRef::DEFAULT,
4097                &SiteName::new("s2"),
4098                "host.example",
4099                VerificationMethod::Http,
4100                300,
4101            )
4102            .await
4103            .unwrap();
4104        assert!(store
4105            .find_pending_http_challenge("host.example", &h2.token, 300)
4106            .await
4107            .unwrap()
4108            .is_some());
4109        store
4110            .start_domain_verification(
4111                ProjectRef::DEFAULT,
4112                &SiteName::new("s2"),
4113                "host.example",
4114                VerificationMethod::Dns,
4115                300,
4116            )
4117            .await
4118            .unwrap();
4119        assert!(
4120            store
4121                .find_pending_http_challenge("host.example", &h2.token, 300)
4122                .await
4123                .unwrap()
4124                .is_none(),
4125            "a stale HTTP index must not serve a token whose record is now DNS"
4126        );
4127    }
4128
4129    #[tokio::test]
4130    async fn pending_verifications_are_capped_per_site() {
4131        let store = store();
4132        // Seed the cap's worth of distinct pending hosts — all accepted.
4133        for i in 0..64 {
4134            store
4135                .start_domain_verification(
4136                    ProjectRef::DEFAULT,
4137                    &SiteName::new("site"),
4138                    &format!("h{i}.example"),
4139                    VerificationMethod::Http,
4140                    100,
4141                )
4142                .await
4143                .unwrap();
4144        }
4145        // One more genuinely-new host is refused (bounds the reconcile fan-out).
4146        let err = store
4147            .start_domain_verification(
4148                ProjectRef::DEFAULT,
4149                &SiteName::new("site"),
4150                "overflow.example",
4151                VerificationMethod::Http,
4152                100,
4153            )
4154            .await
4155            .expect_err("the 65th pending host must be rejected");
4156        assert!(matches!(err, DeployError::Conflict(_)), "got {err:?}");
4157        // Re-running an EXISTING host still works (returns early before the cap).
4158        store
4159            .start_domain_verification(
4160                ProjectRef::DEFAULT,
4161                &SiteName::new("site"),
4162                "h0.example",
4163                VerificationMethod::Http,
4164                100,
4165            )
4166            .await
4167            .expect("re-running an existing challenge is not capped");
4168        // A different site has its own budget.
4169        store
4170            .start_domain_verification(
4171                ProjectRef::DEFAULT,
4172                &SiteName::new("other"),
4173                "fresh.example",
4174                VerificationMethod::Http,
4175                100,
4176            )
4177            .await
4178            .expect("a different site is unaffected");
4179    }
4180
4181    fn store() -> DeployStore {
4182        use crate::kv::MemoryKv;
4183        DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()))
4184    }
4185
4186    #[tokio::test]
4187    async fn default_project_is_visible_on_a_fresh_store_and_ensure_is_idempotent() {
4188        let s = store();
4189        let default = crate::project::DEFAULT_PROJECT;
4190
4191        // Reader backstop: even before any write, `default` resolves and lists.
4192        assert!(
4193            s.get_project(default).await.unwrap().is_some(),
4194            "`project show default` must never 404, even on a fresh store"
4195        );
4196        let listed = s.list_projects().await.unwrap();
4197        assert_eq!(
4198            listed.iter().filter(|p| p.name == default).count(),
4199            1,
4200            "`project ls` must show exactly one `default` on a fresh store"
4201        );
4202        // A non-existent project is still absent (the backstop is default-only).
4203        assert!(s.get_project("nope").await.unwrap().is_none());
4204
4205        // project_exists (the middleware's ghost guard): default always exists;
4206        // an uncreated project does not, so a write to it is rejected upstream.
4207        assert!(s.project_exists(default).await.unwrap());
4208        assert!(!s.project_exists("nope").await.unwrap());
4209
4210        // Boot ensure materializes the record once, then is a no-op.
4211        assert!(
4212            s.ensure_default_project().await.unwrap(),
4213            "first ensure creates"
4214        );
4215        assert!(
4216            !s.ensure_default_project().await.unwrap(),
4217            "second ensure is idempotent (presence-checked)"
4218        );
4219
4220        // After materialization the record is real (not the backstop) and still unique.
4221        let listed = s.list_projects().await.unwrap();
4222        assert_eq!(
4223            listed.iter().filter(|p| p.name == default).count(),
4224            1,
4225            "materializing `default` must not duplicate it in the listing"
4226        );
4227        assert_eq!(s.get_project(default).await.unwrap().unwrap().name, default);
4228    }
4229
4230    #[tokio::test]
4231    async fn daemon_config_store_round_trips_and_rolls_back() {
4232        use crate::daemon_config::DaemonConfig;
4233        let s = store();
4234        // None set → baseline.
4235        assert!(s.get_daemon_config().await.unwrap().is_none());
4236        assert!(s.daemon_config_generation().await.unwrap().is_none());
4237
4238        // Set gen 1.
4239        let g1cfg = DaemonConfig {
4240            default_site: Some("one".into()),
4241            ..Default::default()
4242        };
4243        let g1 = s.set_daemon_config(&g1cfg).await.unwrap();
4244        assert_eq!(
4245            s.daemon_config_generation().await.unwrap().as_deref(),
4246            Some(g1.as_str())
4247        );
4248        assert_eq!(s.get_daemon_config().await.unwrap().unwrap(), g1cfg);
4249        assert!(s.daemon_config_history().await.unwrap().is_empty());
4250
4251        // Set gen 2 → gen 1 goes to history.
4252        let g2cfg = DaemonConfig {
4253            default_site: Some("two".into()),
4254            ..Default::default()
4255        };
4256        let g2 = s.set_daemon_config(&g2cfg).await.unwrap();
4257        assert_ne!(g1, g2);
4258        assert_eq!(s.daemon_config_history().await.unwrap(), vec![g1.clone()]);
4259
4260        // Rollback → back to gen 1.
4261        let rolled = s.rollback_daemon_config().await.unwrap();
4262        assert_eq!(rolled.as_deref(), Some(g1.as_str()));
4263        assert_eq!(
4264            s.daemon_config_generation().await.unwrap().as_deref(),
4265            Some(g1.as_str())
4266        );
4267        assert_eq!(s.get_daemon_config().await.unwrap().unwrap(), g1cfg);
4268        // No further history → rollback is a no-op signal.
4269        assert!(s.rollback_daemon_config().await.unwrap().is_none());
4270    }
4271
4272    #[tokio::test]
4273    async fn compute_store_round_trips() {
4274        use crate::compute::{
4275            ComputeSpec, ComputeWorkload, PlacementConstraints, RestartPolicy, RootSource,
4276        };
4277        let s = store();
4278        let spec = ComputeSpec {
4279            version: crate::SCHEMA_VERSION,
4280            root: RootSource::Rootfs("r".repeat(64)),
4281            kernel: "k".repeat(64),
4282            kernel_cmdline: None,
4283            vcpus: 1,
4284            mem_mib: 256,
4285            entrypoint: vec!["/app".into()],
4286            env: Default::default(),
4287            port: 8080,
4288            restart: RestartPolicy::Always,
4289            scale_to_zero: false,
4290            volumes: vec![],
4291            writable_root: false,
4292            isolation: Default::default(),
4293            prefer_backend: None,
4294            bindings: vec![],
4295        };
4296        // Content-addressed spec: storing returns the hash; re-reads match.
4297        let hash = s.put_compute_spec(&spec).await.unwrap();
4298        assert_eq!(hash, spec.id());
4299        assert_eq!(s.get_compute_spec(&hash).await.unwrap(), Some(spec));
4300        assert!(s.get_compute_spec("deadbeef").await.unwrap().is_none());
4301
4302        // Workload desired state: set / get / list / delete.
4303        let workload = ComputeWorkload {
4304            version: crate::SCHEMA_VERSION,
4305            name: "api".into(),
4306            active: hash.clone(),
4307            replicas: 3,
4308            placement: PlacementConstraints::default(),
4309        };
4310        s.set_compute_workload(ProjectRef::DEFAULT, &workload)
4311            .await
4312            .unwrap();
4313        assert_eq!(
4314            s.get_compute_workload(ProjectRef::DEFAULT, "api")
4315                .await
4316                .unwrap(),
4317            Some(workload)
4318        );
4319        assert_eq!(
4320            s.list_compute_workloads(ProjectRef::DEFAULT)
4321                .await
4322                .unwrap()
4323                .len(),
4324            1
4325        );
4326        assert!(s
4327            .delete_compute_workload(ProjectRef::DEFAULT, "api")
4328            .await
4329            .unwrap());
4330        assert!(!s
4331            .delete_compute_workload(ProjectRef::DEFAULT, "api")
4332            .await
4333            .unwrap());
4334        assert!(s
4335            .list_compute_workloads(ProjectRef::DEFAULT)
4336            .await
4337            .unwrap()
4338            .is_empty());
4339    }
4340
4341    /// A distinct, blob-free manifest (distinguished only by its config), so it
4342    /// activates without a real blob backend.
4343    fn empty_manifest(clean_urls: bool) -> Manifest {
4344        Manifest {
4345            config: DeployConfig {
4346                clean_urls,
4347                ..DeployConfig::default()
4348            },
4349            ..Default::default()
4350        }
4351    }
4352
4353    #[tokio::test]
4354    async fn deploy_meta_records_sizes_and_merges_provenance() {
4355        let store = store();
4356        let mut manifest = Manifest::default();
4357        manifest.files.insert("index.html".into(), {
4358            let mut e = entry("aa");
4359            e.size = 10;
4360            e
4361        });
4362        manifest.files.insert("app.js".into(), {
4363            let mut e = entry("bb");
4364            e.size = 32;
4365            e
4366        });
4367
4368        // First store carries provenance.
4369        let id = store
4370            .put_manifest_with(
4371                &manifest,
4372                DeployMetaInput {
4373                    source: Some("abc123".into()),
4374                    message: Some("first".into()),
4375                    ..Default::default()
4376                },
4377            )
4378            .await
4379            .unwrap();
4380        let meta = store.get_meta(&id).await.unwrap().unwrap();
4381        assert_eq!(meta.file_count, 2);
4382        assert_eq!(meta.total_size, 42);
4383        assert_eq!(meta.source.as_deref(), Some("abc123"));
4384        let created = meta.created_at;
4385
4386        // Re-store with empty input preserves created_at and prior provenance.
4387        store
4388            .put_manifest_with(&manifest, DeployMetaInput::default())
4389            .await
4390            .unwrap();
4391        let meta = store.get_meta(&id).await.unwrap().unwrap();
4392        assert_eq!(meta.created_at, created);
4393        assert_eq!(meta.source.as_deref(), Some("abc123"));
4394        assert_eq!(meta.message.as_deref(), Some("first"));
4395    }
4396
4397    #[tokio::test]
4398    async fn aliases_round_trip_and_guard_completeness() {
4399        let store = store();
4400        let manifest = empty_manifest(false);
4401        let id = store.put_manifest(&manifest).await.unwrap();
4402
4403        // Unknown deployment cannot be aliased.
4404        assert!(matches!(
4405            store
4406                .set_alias(ProjectRef::DEFAULT, "blog", "staging", "deadbeef")
4407                .await,
4408            Err(DeployError::NotFound(_))
4409        ));
4410
4411        store
4412            .set_alias(ProjectRef::DEFAULT, "blog", "staging", &id)
4413            .await
4414            .unwrap();
4415        assert_eq!(
4416            store
4417                .get_alias(ProjectRef::DEFAULT, "blog", "staging")
4418                .await
4419                .unwrap(),
4420            Some(id.clone())
4421        );
4422        let aliases = store
4423            .list_aliases(ProjectRef::DEFAULT, "blog")
4424            .await
4425            .unwrap();
4426        assert_eq!(aliases.get("staging"), Some(&id));
4427
4428        assert!(store
4429            .remove_alias(ProjectRef::DEFAULT, "blog", "staging")
4430            .await
4431            .unwrap());
4432        assert!(!store
4433            .remove_alias(ProjectRef::DEFAULT, "blog", "staging")
4434            .await
4435            .unwrap());
4436        assert_eq!(
4437            store
4438                .get_alias(ProjectRef::DEFAULT, "blog", "staging")
4439                .await
4440                .unwrap(),
4441            None
4442        );
4443    }
4444
4445    #[tokio::test]
4446    async fn retention_keep_last_collects_older_history() {
4447        let store = store();
4448        // Three distinct deployments, activated oldest→newest.
4449        let m1 = empty_manifest(false);
4450        let m2 = empty_manifest(true);
4451        let mut m3 = empty_manifest(true);
4452        m3.config.trailing_slash = crate::config::TrailingSlash::Always;
4453        let id1 = store.put_manifest(&m1).await.unwrap();
4454        let id2 = store.put_manifest(&m2).await.unwrap();
4455        let id3 = store.put_manifest(&m3).await.unwrap();
4456        store
4457            .activate(ProjectRef::DEFAULT, "blog", &id1)
4458            .await
4459            .unwrap();
4460        store
4461            .activate(ProjectRef::DEFAULT, "blog", &id2)
4462            .await
4463            .unwrap();
4464        store
4465            .activate(ProjectRef::DEFAULT, "blog", &id3)
4466            .await
4467            .unwrap();
4468
4469        // Default: full history kept, nothing collectable.
4470        let report = store.collect_garbage(false).await.unwrap();
4471        assert_eq!(report.manifests_removed, 0);
4472
4473        // keep_last = 1 keeps only the most recent (== current id3); id1 and id2
4474        // become orphans. An alias to id1 rescues it.
4475        store
4476            .set_alias(ProjectRef::DEFAULT, "blog", "pinned", &id1)
4477            .await
4478            .unwrap();
4479        let report = store
4480            .collect_garbage_with(
4481                false,
4482                GcOptions {
4483                    keep_last: Some(1),
4484                    ..Default::default()
4485                },
4486            )
4487            .await
4488            .unwrap();
4489        assert_eq!(report.manifests_removed, 1); // only id2 (id1 aliased, id3 current)
4490    }
4491
4492    #[tokio::test]
4493    async fn grace_window_protects_in_flight_manifest() {
4494        let store = store();
4495        // An uploaded-but-never-activated manifest: an orphan.
4496        let id = store.put_manifest(&empty_manifest(false)).await.unwrap();
4497        assert!(store.get_manifest(&id).await.unwrap().is_some());
4498
4499        // With a grace window it is protected (treated as in-flight)...
4500        let report = store
4501            .collect_garbage_with(
4502                false,
4503                GcOptions {
4504                    grace_secs: 3600,
4505                    ..Default::default()
4506                },
4507            )
4508            .await
4509            .unwrap();
4510        assert_eq!(report.manifests_removed, 0);
4511
4512        // ...without one, it is collectable.
4513        let report = store.collect_garbage(false).await.unwrap();
4514        assert_eq!(report.manifests_removed, 1);
4515    }
4516
4517    #[tokio::test]
4518    async fn resolve_manifest_id_exact_prefix_and_missing() {
4519        let store = store();
4520        let id = store.put_manifest(&empty_manifest(true)).await.unwrap();
4521
4522        // Exact id resolves to itself.
4523        assert_eq!(
4524            store.resolve_manifest_id(&id).await.unwrap().as_deref(),
4525            Some(id.as_str())
4526        );
4527        // A unique prefix (the DNS-label use case) resolves to the full id.
4528        assert_eq!(
4529            store
4530                .resolve_manifest_id(&id[..16])
4531                .await
4532                .unwrap()
4533                .as_deref(),
4534            Some(id.as_str())
4535        );
4536        // An unknown prefix resolves to nothing.
4537        assert!(store
4538            .resolve_manifest_id("ffffffffffffffff")
4539            .await
4540            .unwrap()
4541            .is_none());
4542    }
4543
4544    #[tokio::test]
4545    async fn delete_site_removes_config_routing_and_aliases() {
4546        let store = store();
4547        let mut cfg = SiteConfig::default();
4548        cfg.domains.primary = Some("blog.example".into());
4549        cfg.domains.wildcards = vec!["*.preview.blog.example".into()];
4550        store
4551            .set_site_config(ProjectRef::DEFAULT, "blog", &cfg)
4552            .await
4553            .unwrap();
4554        // A real deployment so the alias points at something valid.
4555        let id = store.put_manifest(&empty_manifest(true)).await.unwrap();
4556        store
4557            .set_alias(ProjectRef::DEFAULT, "blog", "stable", &id)
4558            .await
4559            .unwrap();
4560
4561        // Present: config stored + its hosts route to it.
4562        assert!(store
4563            .get_site_config(ProjectRef::DEFAULT, "blog")
4564            .await
4565            .unwrap()
4566            .is_some());
4567        assert_eq!(
4568            store
4569                .resolve_site_by_host("blog.example")
4570                .await
4571                .unwrap()
4572                .map(|o| o.site)
4573                .as_deref(),
4574            Some("blog")
4575        );
4576
4577        store
4578            .delete_site(ProjectRef::DEFAULT, "blog")
4579            .await
4580            .unwrap();
4581
4582        // Gone: config pointer, domain routing (host freed), aliases.
4583        assert!(store
4584            .get_site_config(ProjectRef::DEFAULT, "blog")
4585            .await
4586            .unwrap()
4587            .is_none());
4588        assert!(store
4589            .resolve_site_by_host("blog.example")
4590            .await
4591            .unwrap()
4592            .is_none());
4593        assert!(store
4594            .list_aliases(ProjectRef::DEFAULT, "blog")
4595            .await
4596            .unwrap()
4597            .is_empty());
4598
4599        // Idempotent — deleting an absent site is a no-op.
4600        store
4601            .delete_site(ProjectRef::DEFAULT, "blog")
4602            .await
4603            .unwrap();
4604    }
4605
4606    #[tokio::test]
4607    async fn gc_blob_reachability_is_a_cross_project_union() {
4608        use crate::kv::MemoryKv;
4609        // A real blob store so blob deletion is observable.
4610        let store = DeployStore::new(Arc::new(MemStorage::default()), Arc::new(MemoryKv::new()));
4611
4612        // Three blobs: `shared` (referenced by project acme), `keep` (project shop's
4613        // current), and `dead` (referenced by no live deployment anywhere). The blob
4614        // key is the content hash, so hash the actual bytes (put_blob verifies it).
4615        let shared = sha256_hex(b"shared-blob");
4616        let keep = sha256_hex(b"keep-blob");
4617        let dead = sha256_hex(b"dead-blob");
4618        store
4619            .put_blob(&shared, once_bytes(b"shared-blob"))
4620            .await
4621            .unwrap();
4622        store
4623            .put_blob(&keep, once_bytes(b"keep-blob"))
4624            .await
4625            .unwrap();
4626        store
4627            .put_blob(&dead, once_bytes(b"dead-blob"))
4628            .await
4629            .unwrap();
4630
4631        let m_shared = manifest_with(&[("index.html", &shared)]);
4632        let m_keep = manifest_with(&[("index.html", &keep)]);
4633        let m_dead = manifest_with(&[("old.html", &dead)]);
4634        let id_shared = store.put_manifest(&m_shared).await.unwrap();
4635        let id_keep = store.put_manifest(&m_keep).await.unwrap();
4636        let id_dead = store.put_manifest(&m_dead).await.unwrap();
4637
4638        // Project "acme": current → the shared manifest (its only reference to `shared`).
4639        let acme = ProjectRef::new("acme");
4640        store.activate(acme, "site", &id_shared).await.unwrap();
4641        // Project "shop": activated the shared manifest first, then `keep`, so with
4642        // keep_last=1 the shared manifest is an ORPHAN *within shop* — its survival
4643        // depends entirely on acme still referencing it (the cross-project union).
4644        let shop = ProjectRef::new("shop");
4645        store.activate(shop, "site", &id_shared).await.unwrap();
4646        store.activate(shop, "site", &id_keep).await.unwrap();
4647        // `m_dead` is stored but never activated in any project.
4648
4649        let report = store
4650            .collect_garbage_with(
4651                true,
4652                GcOptions {
4653                    keep_last: Some(1),
4654                    ..Default::default()
4655                },
4656            )
4657            .await
4658            .unwrap();
4659
4660        // The truly-dead manifest and its unshared blob are collected.
4661        assert!(store.get_manifest(&id_dead).await.unwrap().is_none());
4662        assert!(
4663            !store.has_blob(&dead).await.unwrap(),
4664            "dead blob is reclaimed"
4665        );
4666        assert_eq!(report.manifests_removed, 1, "only the dead manifest goes");
4667
4668        // The shared manifest + blob SURVIVE: orphaned in shop but current in acme.
4669        // A per-project (non-union) GC would have wrongly collected them.
4670        assert!(
4671            store.get_manifest(&id_shared).await.unwrap().is_some(),
4672            "shared manifest kept by acme's reference"
4673        );
4674        assert!(
4675            store.has_blob(&shared).await.unwrap(),
4676            "shared blob kept — reachability is the union across projects"
4677        );
4678        // shop's current is untouched.
4679        assert!(store.has_blob(&keep).await.unwrap());
4680        assert_eq!(
4681            store.current_id(acme, "site").await.unwrap().as_deref(),
4682            Some(id_shared.as_str())
4683        );
4684    }
4685
4686    #[tokio::test]
4687    async fn same_site_name_in_two_projects_is_isolated() {
4688        use crate::kv::MemoryKv;
4689        let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
4690        let acme = ProjectRef::new("acme");
4691        let shop = ProjectRef::new("shop");
4692
4693        // Two projects each have a site literally named "www" — independent records.
4694        let id_a = store.put_manifest(&empty_manifest(false)).await.unwrap();
4695        let id_b = store.put_manifest(&empty_manifest(true)).await.unwrap();
4696        store.activate(acme, "www", &id_a).await.unwrap();
4697        store.activate(shop, "www", &id_b).await.unwrap();
4698
4699        // Distinct current pointers — no cross-talk.
4700        assert_eq!(
4701            store.current_id(acme, "www").await.unwrap().as_deref(),
4702            Some(id_a.as_str())
4703        );
4704        assert_eq!(
4705            store.current_id(shop, "www").await.unwrap().as_deref(),
4706            Some(id_b.as_str())
4707        );
4708        assert_ne!(id_a, id_b);
4709
4710        // Distinct configs + aliases keyed under each project.
4711        let mut cfg_a = SiteConfig::default();
4712        cfg_a.domains.primary = Some("acme.example".into());
4713        store.set_site_config(acme, "www", &cfg_a).await.unwrap();
4714        let mut cfg_b = SiteConfig::default();
4715        cfg_b.domains.primary = Some("shop.example".into());
4716        store.set_site_config(shop, "www", &cfg_b).await.unwrap();
4717        store
4718            .set_alias(acme, "www", "staging", &id_a)
4719            .await
4720            .unwrap();
4721
4722        assert_eq!(
4723            store
4724                .get_site_config(acme, "www")
4725                .await
4726                .unwrap()
4727                .unwrap()
4728                .domains
4729                .primary
4730                .as_deref(),
4731            Some("acme.example")
4732        );
4733        assert_eq!(
4734            store
4735                .get_site_config(shop, "www")
4736                .await
4737                .unwrap()
4738                .unwrap()
4739                .domains
4740                .primary
4741                .as_deref(),
4742            Some("shop.example")
4743        );
4744        // acme's alias is not visible under shop.
4745        assert!(store
4746            .get_alias(acme, "www", "staging")
4747            .await
4748            .unwrap()
4749            .is_some());
4750        assert!(store
4751            .get_alias(shop, "www", "staging")
4752            .await
4753            .unwrap()
4754            .is_none());
4755        assert!(store.list_aliases(shop, "www").await.unwrap().is_empty());
4756
4757        // Each host resolves to its owning (project, site); deleting one leaves the other.
4758        assert_eq!(
4759            store.resolve_site_by_host("acme.example").await.unwrap(),
4760            Some(DomainOwner::new("acme", "www"))
4761        );
4762        assert_eq!(
4763            store.resolve_site_by_host("shop.example").await.unwrap(),
4764            Some(DomainOwner::new("shop", "www"))
4765        );
4766        store.delete_site(acme, "www").await.unwrap();
4767        assert!(store.get_site_config(acme, "www").await.unwrap().is_none());
4768        assert!(
4769            store.get_site_config(shop, "www").await.unwrap().is_some(),
4770            "deleting acme/www must not touch shop/www"
4771        );
4772        assert!(store.current_id(shop, "www").await.unwrap().is_some());
4773    }
4774
4775    #[tokio::test]
4776    async fn project_entity_crud_and_delete_guard() {
4777        use crate::project::Project;
4778        let store = store();
4779        let acme = Project {
4780            version: crate::SCHEMA_VERSION,
4781            name: "acme".into(),
4782            created_at: 1,
4783            meta: Default::default(),
4784            config: Default::default(),
4785            secrets_ref: None,
4786        };
4787        // Create + read back; content-addressed id round-trips.
4788        let hash = store.put_project(&acme).await.unwrap();
4789        assert_eq!(hash, acme.id());
4790        assert_eq!(store.get_project("acme").await.unwrap(), Some(acme.clone()));
4791        assert!(store.get_project("ghost").await.unwrap().is_none());
4792        // `default` is always present (the always-exists backstop), alongside acme.
4793        let names: Vec<String> = store
4794            .list_projects()
4795            .await
4796            .unwrap()
4797            .into_iter()
4798            .map(|p| p.name)
4799            .collect();
4800        assert_eq!(names, vec!["acme".to_string(), "default".to_string()]);
4801
4802        // Delete refuses while the project owns a resource…
4803        store
4804            .set_site_config(ProjectRef::new("acme"), "www", &SiteConfig::default())
4805            .await
4806            .unwrap();
4807        assert!(matches!(
4808            store.delete_project("acme").await,
4809            Err(DeployError::Conflict(_))
4810        ));
4811        // …and succeeds once it is empty.
4812        store
4813            .delete_site(ProjectRef::new("acme"), "www")
4814            .await
4815            .unwrap();
4816        assert!(store.delete_project("acme").await.unwrap());
4817        assert!(store.get_project("acme").await.unwrap().is_none());
4818
4819        // The reserved default project can never be deleted.
4820        assert!(matches!(
4821            store.delete_project("default").await,
4822            Err(DeployError::Conflict(_))
4823        ));
4824    }
4825}