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