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