LCOV - code coverage report
Current view: top level - antares-api/src - state.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 92.5 % 827 765
Test Date: 2026-11-02 13:21:27 Functions: 62.4 % 287 179

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! Shared application state.
       3              : 
       4              : use antares_jsonld::Loader;
       5              : #[cfg(feature = "test-kit")]
       6              : use antares_sql::store::{any::AnyStore, Store};
       7              : use antares_store::{CurrentStateDriver, TemporalDriver};
       8              : use std::sync::Arc;
       9              : // Clock rule: std Instant panics on wasm32.
      10              : #[cfg(not(target_arch = "wasm32"))]
      11              : use std::time::Instant;
      12              : #[cfg(target_arch = "wasm32")]
      13              : use web_time::Instant;
      14              : 
      15              : /// ANTARES_TEMPORAL_RECORD — the auto-recording gate on the write path.
      16              : /// Direct temporal-API writes (POST /temporal/entities…) are never gated:
      17              : /// this decides what the ENTITY endpoints leave behind as history.
      18              : #[derive(Clone, Copy, Debug, PartialEq, Eq)]
      19              : pub enum TemporalRecord {
      20              :     /// `all` (default): every changed attribute instance.
      21              :     All,
      22              :     /// `observed`: only instances carrying `observedAt` — the spec's own
      23              :     /// measurement axis (4.5.7); metadata-shaped writes leave no history.
      24              :     Observed,
      25              :     /// `none`: nothing is auto-recorded; the temporal endpoints still serve
      26              :     /// what the temporal API was given directly (unlike ANTARES_TEMPORAL=none,
      27              :     /// which turns the temporal seam off entirely).
      28              :     None,
      29              :     /// `ruled`: history is opt-in. An Entity records only
      30              :     /// when its own `historyRetention`, the retention rule of one of its
      31              :     /// types or its Tenant's `*` rule gives a duration or `forever`; a type
      32              :     /// no rule names keeps its current state only.
      33              :     Ruled,
      34              : }
      35              : 
      36              : impl std::str::FromStr for TemporalRecord {
      37              :     type Err = String;
      38           20 :     fn from_str(s: &str) -> Result<Self, String> {
      39           20 :         match s {
      40           20 :             "all" => Ok(Self::All),
      41            6 :             "observed" => Ok(Self::Observed),
      42            4 :             "none" => Ok(Self::None),
      43            2 :             "ruled" => Ok(Self::Ruled),
      44            2 :             other => Err(format!(
      45            2 :                 "ANTARES_TEMPORAL_RECORD: unknown mode {other} (all|observed|none|ruled)"
      46            2 :             )),
      47              :         }
      48           20 :     }
      49              : }
      50              : 
      51              : /// Behaviour profile (docs/conformance/profiles.md): where the two
      52              : /// spec versions or the conformance suites accept two answers, the one this
      53              : /// deployment gives. Startup configuration only, never per request; the
      54              : /// default of every member is the behaviour the pinned ETSI suite asserts.
      55              : /// No member relaxes authorization, tenant isolation or an input limit.
      56              : #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
      57              : pub struct Profile {
      58              :     /// `ANTARES_NON_OBJECT_BODY=bad-request-data`: a JSON body that is no
      59              :     /// object (`[]`, `[1,2,3]`) where an object is expected answers
      60              :     /// BadRequestData (TS 104 175 8.2.3 second bullet, forge all-fixes,
      61              :     /// Coraine) instead of InvalidRequest (ETSI 001_02_02).
      62              :     pub non_object_bad_request_data: bool,
      63              :     /// `ANTARES_FED_WRITE_SOURCE_404=ignore`: on a distributed write, a
      64              :     /// source's 404 beside a success counts as "no data" (204, forge
      65              :     /// all-fixes, Coraine) instead of a part of the 207 (ETSI D002_02_*_inc).
      66              :     pub fed_write_ignore_404: bool,
      67              :     /// `ANTARES_IMPLICIT_TENANTS=listed`: only the operations TS 104 175
      68              :     /// 7.8.2 lists create a Tenant; Batch Upsert on an unknown Tenant
      69              :     /// answers NonexistentTenant (Coraine tenant_implicit_create).
      70              :     pub implicit_tenants_listed_only: bool,
      71              : }
      72              : 
      73              : impl Profile {
      74              :     /// Reads the profile keys; an unknown value is a startup error.
      75           12 :     pub fn from_env() -> Result<Self, String> {
      76           36 :         let get = |k: &str| std::env::var(k).ok();
      77           12 :         Self::from_vars(
      78           12 :             get("ANTARES_NON_OBJECT_BODY"),
      79           12 :             get("ANTARES_FED_WRITE_SOURCE_404"),
      80           12 :             get("ANTARES_IMPLICIT_TENANTS"),
      81              :         )
      82           12 :     }
      83              : 
      84              :     /// [`Profile::from_env`] over given values (unset = `None`).
      85           20 :     pub fn from_vars(
      86           20 :         non_object_body: Option<String>,
      87           20 :         fed_write_source_404: Option<String>,
      88           20 :         implicit_tenants: Option<String>,
      89           20 :     ) -> Result<Self, String> {
      90              :         Ok(Self {
      91           20 :             non_object_bad_request_data: profile_choice(
      92           20 :                 "ANTARES_NON_OBJECT_BODY",
      93           20 :                 non_object_body,
      94           20 :                 ["invalid-request", "bad-request-data"],
      95            0 :             )?,
      96           20 :             fed_write_ignore_404: profile_choice(
      97           20 :                 "ANTARES_FED_WRITE_SOURCE_404",
      98           20 :                 fed_write_source_404,
      99           20 :                 ["report", "ignore"],
     100            2 :             )?,
     101           18 :             implicit_tenants_listed_only: profile_choice(
     102           18 :                 "ANTARES_IMPLICIT_TENANTS",
     103           18 :                 implicit_tenants,
     104           18 :                 ["with-upsert", "listed"],
     105            0 :             )?,
     106              :         })
     107           20 :     }
     108              : }
     109              : 
     110              : /// `false` for the default value (or unset/empty), `true` for the other.
     111           58 : fn profile_choice(
     112           58 :     key: &str,
     113           58 :     value: Option<String>,
     114           58 :     [default, other]: [&str; 2],
     115           58 : ) -> Result<bool, String> {
     116           58 :     match value.as_deref().map(str::trim) {
     117           44 :         None | Some("") => Ok(false),
     118           14 :         Some(v) if v == default => Ok(false),
     119            8 :         Some(v) if v == other => Ok(true),
     120            2 :         Some(v) => Err(format!("{key}: unknown value {v:?} ({default}|{other})")),
     121              :     }
     122           58 : }
     123              : 
     124              : /// History gate 3's set: the (Tenant, expanded type) pairs that
     125              : /// record no history, shared by every clone of the state.
     126              : pub type HistoryOff = Arc<std::sync::RwLock<HistoryRules>>;
     127              : 
     128              : /// When an attribute recorded `onChange` writes a row: when its value moved past `deadband` (a number) or
     129              : /// `min_distance` metres (a Point), or differs at all (anything else), or
     130              : /// `heartbeat` passed since its last row.
     131              : #[derive(Debug, Clone, Copy, PartialEq)]
     132              : pub struct OnChange {
     133              :     pub heartbeat: std::time::Duration,
     134              :     pub deadband: Option<f64>,
     135              :     pub min_distance: Option<f64>,
     136              : }
     137              : 
     138              : /// How an attribute records history. Unset, it is `every` (a row per
     139              : /// changed instance), and `observed` under `ANTARES_TEMPORAL_RECORD=ruled`.
     140              : #[derive(Debug, Clone, Copy, PartialEq)]
     141              : pub enum RecordMode {
     142              :     /// `every`: a row per changed instance.
     143              :     Every,
     144              :     /// `never`: no row, for a bookkeeping attribute such as
     145              :     /// `dateModified`.
     146              :     Never,
     147              :     /// `onChange`: a row when the value moved or the heartbeat passed.
     148              :     OnChange(OnChange),
     149              :     /// `observed`: a row only for an instance whose `observedAt` is
     150              :     /// newer than the attribute's last row, so a value re-sent with its old
     151              :     /// `observedAt`, or sent with none, adds nothing.
     152              :     Observed,
     153              : }
     154              : 
     155              : /// How one type records history, for the type and per expanded attribute:
     156              : /// `None` takes the broker's default ([`RecordMode`]).
     157              : #[derive(Debug, Default, Clone, PartialEq)]
     158              : pub struct Recording {
     159              :     pub default: Option<RecordMode>,
     160              :     pub attributes: std::collections::HashMap<String, Option<RecordMode>>,
     161              : }
     162              : 
     163              : impl Recording {
     164              :     /// How `attr` records: its own setting, else the type's.
     165              :     #[must_use]
     166        52920 :     pub fn of(&self, attr: &str) -> Option<RecordMode> {
     167        52920 :         self.attributes.get(attr).copied().unwrap_or(self.default)
     168        52920 :     }
     169              : 
     170              :     /// Whether some attribute records `onChange`.
     171              :     #[must_use]
     172          128 :     pub fn has_on_change(&self) -> bool {
     173          128 :         std::iter::once(&self.default)
     174          128 :             .chain(self.attributes.values())
     175          132 :             .any(|m| matches!(m, Some(RecordMode::OnChange(_))))
     176          128 :     }
     177              : }
     178              : 
     179              : /// What the write path needs of the Tenants' history retention: which writes record none.
     180              : #[derive(Debug, Default, Clone, PartialEq)]
     181              : pub struct HistoryRules {
     182              :     /// (Tenant, type) pairs whose rule says `history: "off"`.
     183              :     pub off: std::collections::HashSet<(String, String)>,
     184              :     /// (Tenant, type) pairs with a history rule of their own (a duration,
     185              :     /// `off` or `forever`), which their Tenant's `*` rule does not reach.
     186              :     pub ruled: std::collections::HashSet<(String, String)>,
     187              :     /// Tenants whose `*` rule says `history: "off"`.
     188              :     pub space_off: std::collections::HashSet<String>,
     189              :     /// Tenants whose `*` rule gives a duration or `forever`: under
     190              :     /// `ANTARES_TEMPORAL_RECORD=ruled` every type without a rule of its own
     191              :     /// records.
     192              :     pub space_on: std::collections::HashSet<String>,
     193              :     /// (Tenant, type) pairs whose rule records some attribute other than
     194              :     /// `every`.
     195              :     pub recording: std::collections::HashMap<(String, String), Recording>,
     196              : }
     197              : 
     198              : impl HistoryRules {
     199              :     /// No rule turns any history off.
     200              :     #[must_use]
     201       137584 :     pub fn is_empty(&self) -> bool {
     202       137584 :         self.off.is_empty() && self.space_off.is_empty()
     203       137584 :     }
     204              : }
     205              : 
     206              : /// The Tenants the store recently answered "exists" for. The 5.5.10
     207              : /// wall asks about the request's Tenant ahead of every operation that
     208              : /// names one, and nearly every answer is "exists", so a name it holds skips
     209              : /// the store for [`KnownTenants::TTL`]. It keeps positive answers only: a
     210              : /// write can create a Tenant at any moment, so a "does not exist" is never
     211              : /// kept. A purge on this process forgets the name at once; a purge on
     212              : /// another process sharing the store is seen once the entry ages out.
     213              : #[derive(Debug, Default)]
     214              : pub struct KnownTenants(std::sync::Mutex<std::collections::HashMap<String, Instant>>);
     215              : 
     216              : impl KnownTenants {
     217              :     /// How long a remembered "exists" stands in for the store's answer.
     218              :     pub const TTL: std::time::Duration = std::time::Duration::from_secs(30);
     219              :     /// The most names held at once (ADR-0001's 10 000-tenant target): a
     220              :     /// full map takes no new name until its old entries age out.
     221              :     pub const CAP: usize = 10_000;
     222              : 
     223          982 :     fn map(&self) -> std::sync::MutexGuard<'_, std::collections::HashMap<String, Instant>> {
     224          982 :         self.0
     225          982 :             .lock()
     226          982 :             .unwrap_or_else(std::sync::PoisonError::into_inner)
     227          982 :     }
     228              : 
     229              :     /// Did the store say this Tenant exists within the last [`Self::TTL`]?
     230              :     #[must_use]
     231          756 :     pub fn holds(&self, tenant: &antares_model::TenantId) -> bool {
     232          756 :         self.map()
     233          756 :             .get(tenant.as_str())
     234          756 :             .is_some_and(|at| at.elapsed() < Self::TTL)
     235          756 :     }
     236              : 
     237              :     /// The store just said this Tenant exists.
     238          198 :     pub fn remember(&self, tenant: &antares_model::TenantId) {
     239          198 :         let mut map = self.map();
     240          198 :         if map.len() >= Self::CAP && !map.contains_key(tenant.as_str()) {
     241            0 :             map.retain(|_, at| at.elapsed() < Self::TTL);
     242            0 :             if map.len() >= Self::CAP {
     243            0 :                 return;
     244            0 :             }
     245          198 :         }
     246          198 :         map.insert(tenant.as_str().to_owned(), Instant::now());
     247          198 :     }
     248              : 
     249              :     /// The Tenant was purged: the next request asks the store again.
     250           28 :     pub fn forget(&self, tenant: &antares_model::TenantId) {
     251           28 :         self.map().remove(tenant.as_str());
     252           28 :     }
     253              : }
     254              : 
     255              : /// The handler `wire` installs for a notification that arrives on the
     256              : /// internal distributed-subscription endpoint (5.8.1.4).
     257              : pub type CsourceNotification = Arc<
     258              :     dyn for<'a> Fn(
     259              :             &'a AppState,
     260              :             &'a antares_model::TenantId,
     261              :             &'a str,
     262              :             Option<&'a str>,
     263              :             &'a [serde_json::Value],
     264              :         ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>>
     265              :         + Send
     266              :         + Sync,
     267              : >;
     268              : 
     269              : /// The request headers an in-process call carries from its caller
     270              : /// ([`AppState::call`]): the two that select WHICH data the operation runs
     271              : /// against, and the one that says what its terms mean. Everything else is
     272              : /// the façade's own to set — an inner request is not the outer one.
     273              : static PROPAGATED: [axum::http::HeaderName; 3] = [
     274              :     axum::http::HeaderName::from_static("ngsild-tenant"),
     275              :     axum::http::HeaderName::from_static("ngsild-snapshot"),
     276              :     axum::http::HeaderName::from_static("link"),
     277              : ];
     278              : 
     279              : /// How many [`AppState::call`] frames the running task is inside: zero
     280              : /// outside an in-process call. A caller that must not reach the router past
     281              : /// [`crate::bounds::MAX_IN_PROCESS_CALL_DEPTH`] reads it to answer for
     282              : /// itself rather than take the router's 500.
     283              : #[must_use]
     284          112 : pub fn in_process_depth() -> usize {
     285          112 :     INPROCESS_DEPTH.try_with(|d| *d).unwrap_or(0)
     286          112 : }
     287              : 
     288              : tokio::task_local! {
     289              :     /// How many [`AppState::call`] frames this task is already inside, held
     290              :     /// against [`crate::bounds::MAX_IN_PROCESS_CALL_DEPTH`]. Absent outside a
     291              :     /// façade call, which reads as zero.
     292              :     pub(crate) static INPROCESS_DEPTH: usize;
     293              : }
     294              : 
     295              : /// The broker's state as every handler and layer receives it. Cloned at
     296              : /// every middleware layer and extractor of every request, so a clone is
     297              : /// three reference counts: the per-process part sits behind one `Arc`
     298              : /// ([`Shared`], read through `Deref`), and only what a narrowed state
     299              : /// changes, the view and its in-process router memo, is held per state.
     300              : /// Writing a shared field (`DerefMut`) copies the shared part when another
     301              : /// state holds it, so a write made before serving is never seen by a state
     302              : /// cloned earlier, as it was when every field was copied.
     303              : #[derive(Clone)]
     304              : pub struct AppState {
     305              :     shared: Arc<Shared>,
     306              :     /// The router [`AppState::call`] serves in-process requests through,
     307              :     /// built on first use. Empty until a façade actually calls: building it
     308              :     /// costs about 1.5 ms, which is worth memoizing per state and not worth
     309              :     /// paying in a host that never makes an in-process call.
     310              :     pub(crate) inbound: Arc<std::sync::OnceLock<axum::Router>>,
     311              : }
     312              : 
     313              : /// The per-process part of [`AppState`]: every handle and setting a state
     314              : /// and its narrowed copies share.
     315              : #[derive(Clone)]
     316              : pub struct Shared {
     317              :     pub store: Arc<dyn CurrentStateDriver>,
     318              :     /// The temporal driver — by default the same backend instance as
     319              :     /// `store`; a deployment may load a different one (or none).
     320              :     pub temporal: Arc<dyn TemporalDriver>,
     321              :     /// What the current-state driver is CALLED, reported by `/q/health` (NOT
     322              :     /// by `/info/sourceIdentity`, which is a spec resource) — a name, not an
     323              :     /// enumeration: a driver from outside this workspace mounts the same way
     324              :     /// as one from inside, and nothing here branches on it.
     325              :     pub store_name: String,
     326              :     /// The temporal backend `/q/health` names: the store's own mode when one
     327              :     /// instance serves both seams, `None` when history is off (`NoTemporal`);
     328              :     /// a second backend overwrites it after construction.
     329              :     pub temporal_name: Option<String>,
     330              :     pub loader: Arc<Loader>,
     331              :     pub started: Instant,
     332              :     /// Startup timestamp (createdAt of the built-in core @context entry).
     333              :     pub started_at: String,
     334              :     pub host_alias: String,
     335              :     /// Default page size for queries without an explicit limit.
     336              :     pub default_limit: usize,
     337              :     /// Hard ceiling on limit (TooManyResults guard).
     338              :     pub max_limit: usize,
     339              :     /// 6.3.10: the most instances of one Attribute a temporal response
     340              :     /// serves ([`crate::bounds::temporal_instance_limit`]); the ETSI suite's
     341              :     /// brokers and its unit cases run with 9.
     342              :     pub temporal_instance_limit: usize,
     343              :     /// The instances of one Attribute served when no lastN/firstN is asked
     344              :     /// ([`crate::bounds::temporal_default_instances`]), at most the ceiling.
     345              :     pub temporal_default_instances: usize,
     346              :     /// The most bytes of one in-process answer a handler buffers
     347              :     /// ([`crate::bounds::MAX_ANSWER_BYTES`]).
     348              :     pub max_answer_bytes: usize,
     349              :     /// One shared outbound client, timeouts set at construction.
     350              :     pub http: antares_jsonld::HttpClient,
     351              :     /// Federation-forwarding client — longer deadline: the ETSI mock replies
     352              :     /// to unstubbed forwards only when the robot side wakes (up to ~5 s).
     353              :     pub fed_http: antares_jsonld::HttpClient,
     354              :     /// Notification bindings by `endpoint.uri` scheme (6.3.8, 7.2). The
     355              :     /// only way a delivery transport is chosen; `with_sink` adds one.
     356              :     pub sinks: Arc<antares_notifier::SinkRegistry>,
     357              :     /// HTTP surfaces mounted outside the NGSI-LD API root, each under its
     358              :     /// own reserved prefix; `with_surface` adds one. The admin surface is
     359              :     /// here by default.
     360              :     pub surfaces: Arc<Vec<Box<dyn crate::ApiSurface>>>,
     361              :     /// The transport every federation forward asks before the socket:
     362              :     /// shared by every clone, so a state made before the
     363              :     /// transport was registered (the one an `Ops` holds) forwards through it
     364              :     /// too. Empty in vanilla: every forward is HTTP.
     365              :     pub(crate) forward_transport: Arc<std::sync::OnceLock<Arc<dyn crate::ForwardTransport>>>,
     366              :     /// Bounds-wall rejection counters (exported by /q/health).
     367              :     pub limits: Arc<crate::bounds::LimitStats>,
     368              :     /// `GET /q/tenants/{tenant}` sizes by tenant with the instant they were
     369              :     /// measured: a size is a scan of the tenant, so it is reused for
     370              :     /// [`crate::TENANT_BYTES_TTL`]. One entry per existing tenant
     371              :     /// asked about; a purge drops its entry.
     372              :     pub tenant_bytes: Arc<std::sync::Mutex<std::collections::HashMap<String, crate::BytesEntry>>>,
     373              :     /// The 5.5.10 wall's memory of Tenants that exist.
     374              :     pub known_tenants: Arc<KnownTenants>,
     375              :     /// Allocator stats provider (set by the broker; None in tests/wasm).
     376              :     pub mem_stats: Option<Arc<dyn Fn() -> serde_json::Value + Send + Sync>>,
     377              :     /// Bus state provider for /q/health (`bus: {mode, connected,
     378              :     /// reconnects}`) — installed by the nats wiring only, so the member is
     379              :     /// absent for bus=local.
     380              :     pub bus_stats: Option<Arc<dyn Fn() -> serde_json::Value + Send + Sync>>,
     381              :     /// One egress policy for notifications and federation forwards
     382              :     /// (scheme allowlist, private-range deny, per-destination breakers).
     383              :     pub egress: Arc<crate::egress::Egress>,
     384              :     /// The policy engine every operation is asked about (ADR-0020), or
     385              :     /// `None` when a deployment attached none with
     386              :     /// [`AppState::with_policy`] — the default, where the broker behaves
     387              :     /// exactly as it did before the seam existed. `None` is not an instance
     388              :     /// of `AllowAll`: the gate takes its answer without building a subject,
     389              :     /// boxing a future or arming a timer, so a broker with no engine pays
     390              :     /// nothing for the seam.
     391              :     pub policy: Option<Arc<dyn crate::policy::PolicyEngine>>,
     392              :     /// The `/q` admin credential ([`AppState::with_admin_auth`]); `None`
     393              :     /// serves `/q` without one, as the broker did.
     394              :     pub admin_auth: Option<Arc<crate::admin_auth::AdminAuth>>,
     395              :     /// Set the moment SIGTERM arrives, BEFORE the listener stops
     396              :     /// accepting — `/q/health` then answers 503 DRAINING so the load balancer
     397              :     /// takes this instance out while its socket still works.
     398              :     pub draining: Arc<std::sync::atomic::AtomicBool>,
     399              :     /// Change batches the matcher queue accepted and has not finished
     400              :     /// delivering. A shutdown drain waits for zero before the pool closes.
     401              :     pub pending_changes: Arc<std::sync::atomic::AtomicUsize>,
     402              :     /// Called after every Subscription and Context Source Registration
     403              :     /// Subscription CUD, so the mirror the matcher reads follows the store.
     404              :     /// On the bus the wiring pushes the change into the KV mirror bucket;
     405              :     /// in local mode it applies to this process's own mirror.
     406              :     #[allow(clippy::type_complexity)] // a boxed callback, named where it is installed
     407              :     pub sub_sync: Option<
     408              :         Arc<
     409              :             dyn Fn(&antares_model::TenantId, antares_store::Kind, &str, Option<&serde_json::Value>)
     410              :                 + Send
     411              :                 + Sync,
     412              :         >,
     413              :     >,
     414              :     /// bus=nats: the KV-watched compiled-subscription mirror the matcher
     415              :     /// reads, so the hot path never touches Postgres. `None` in local
     416              :     /// mode (the matcher reads the store directly).
     417              :     pub sub_mirror: Option<Arc<crate::mirror::SubMirror>>,
     418              :     /// bus=local: takes the entity changes one request buffered (the history
     419              :     /// layer hands them over after the handler) so the matcher sees a batch
     420              :     /// request as one unit. `None` until the local pipeline is wired.
     421              :     pub change_flush: Option<Arc<dyn Fn(Vec<crate::mirror::Change>) + Send + Sync>>,
     422              :     /// Called after every Registration CUD: bus=nats publishes the delta on
     423              :     /// `ANTARES_REGISTRY`, bus=local applies it to `reg_mirror` in process.
     424              :     /// `None` while no mirror is wired.
     425              :     #[allow(clippy::type_complexity)] // a boxed callback, named where it is installed
     426              :     pub reg_sync: Option<
     427              :         Arc<dyn Fn(&antares_model::TenantId, &str, Option<&serde_json::Value>) + Send + Sync>,
     428              :     >,
     429              :     /// The ONE per-process compiled registration mirror, delta-fed from
     430              :     /// `ANTARES_REGISTRY` (bus=nats) or by `reg_sync` (bus=local); expiry
     431              :     /// stays filtered at the single yield point (`federation::matching_regs`).
     432              :     /// `None` until wired or when its hydrate failed: the store is read then.
     433              :     pub reg_mirror: Option<Arc<crate::mirror::DocMirror>>,
     434              :     /// 5.2.34 (bus=nats): shares a cooldown stamp with the other api pods —
     435              :     /// a per-process stamp re-dials a failed source from every pod behind
     436              :     /// the LB. Seconds-scale state: broadcast on the
     437              :     /// registry stream, deliberately not persisted. `None` in local mode.
     438              :     #[allow(clippy::type_complexity)] // a boxed callback, named where it is installed
     439              :     pub reg_fail_sync: Option<Arc<dyn Fn(&str, bool) + Send + Sync>>,
     440              :     /// Renders the Prometheus text format for /q/metrics. Installed by
     441              :     /// the broker (the only crate that knows an exporter exists);
     442              :     /// `None` = 404, the facade calls elsewhere stay no-ops.
     443              :     pub metrics_render: Option<Arc<dyn Fn() -> String + Send + Sync>>,
     444              :     /// 5.8.1.4 consumer half: a notification addressed to the internal
     445              :     /// `urn:antares:distsub:` endpoint never leaves the broker, it re-enters
     446              :     /// it. The delivery path hands it here instead of naming the module that
     447              :     /// owns distributed subscriptions; `wire` installs the handler. `None`
     448              :     /// drops such a notification, which is what a broker that never created
     449              :     /// an internal subscription should do with one.
     450              :     pub csource_notification: Option<CsourceNotification>,
     451              :     /// True only under bus=nats (set by the broker's wiring): multiple
     452              :     /// processes share the store, so interval-subscription firings must be
     453              :     /// claimed single-winner. bus=local keeps the direct path — a
     454              :     /// claim there would disturb the 046_12 bookkeeping ordering for
     455              :     /// nothing.
     456              :     pub nats: bool,
     457              :     /// History gate 2 (ANTARES_TEMPORAL_RECORD): which changed attribute
     458              :     /// instances the write path records into history. Default `All`, which
     459              :     /// the ETSI temporal suites assume.
     460              :     pub temporal_record: TemporalRecord,
     461              :     /// Behaviour profile keys ([`Profile`]); the broker sets it from the env.
     462              :     pub profile: Profile,
     463              :     /// The mesh identity of the gateway whose provenance headers the broker
     464              :     /// believes, set by an embedding binary; `None` believes nobody.
     465              :     pub provenance_from: Option<String>,
     466              :     /// A request to the NGSI-LD API that carries no subject header is served
     467              :     /// only with the admin bearer token
     468              :     /// ([`crate::admin_auth::subject_or_admin`]). `false` (the default)
     469              :     /// serves it as before; a deployment sets it when it names subject
     470              :     /// headers.
     471              :     pub subject_required: bool,
     472              :     /// History gate 3: the (Tenant, expanded type) pairs whose
     473              :     /// retention rule says `history: "off"`. The views reload replaces the
     474              :     /// set; an Entity with any of these types records no attribute instance.
     475              :     pub history_off: HistoryOff,
     476              :     /// The base URL remote Context Sources reach THIS broker at — used as
     477              :     /// the notification endpoint of forwarded subscription copies
     478              :     /// (5.8.1.4). ANTARES_PUBLIC_URL, defaulting to
     479              :     /// http://{host_alias}:{ANTARES_HTTP_PORT} (portless when 80/unset).
     480              :     pub public_url: String,
     481              :     /// 5.5.15 resource-pressure signal: max snapshots per tenant — above it
     482              :     /// the lowest-snapshotPriority snapshots are evicted. Snapshot documents
     483              :     /// themselves live in the store (Kind::Snapshot) so persistent modes
     484              :     /// survive restarts.
     485              :     pub snapshot_cap: usize,
     486              :     /// How a notification is delivered: attempts, backoff, age ceiling.
     487              :     /// Default = one attempt (5.8.6 as written).
     488              :     pub delivery: antares_notifier::DeliveryPolicy,
     489              : }
     490              : 
     491              : impl std::ops::Deref for AppState {
     492              :     type Target = Shared;
     493              : 
     494      2701409 :     fn deref(&self) -> &Shared {
     495      2701409 :         &self.shared
     496      2701409 :     }
     497              : }
     498              : 
     499              : impl std::ops::DerefMut for AppState {
     500         9440 :     fn deref_mut(&mut self) -> &mut Shared {
     501              :         // the memoised in-process router captured the settings about to
     502              :         // change: a later call must build one over the new ones
     503         9440 :         self.inbound = Arc::new(std::sync::OnceLock::new());
     504         9440 :         Arc::make_mut(&mut self.shared)
     505         9440 :     }
     506              : }
     507              : 
     508              : impl AppState {
     509              :     /// This state's broker, told apart from the other brokers of the
     510              :     /// process: its narrowed copies share it, and no live broker shares it
     511              :     /// with another.
     512         9385 :     pub(crate) fn broker(&self) -> usize {
     513         9385 :         Arc::as_ptr(&self.shared).addr()
     514         9385 :     }
     515              : 
     516              :     /// Whether `other` is this state: the same broker.
     517              :     #[must_use]
     518          177 :     pub fn same(&self, other: &Self) -> bool {
     519          177 :         self.broker() == other.broker()
     520          177 :     }
     521              : 
     522              :     /// The built-in store: in-memory, or — under the reserved harness
     523              :     /// variable `ANTARES_TEST_STORE=file` — a fresh on-disk redb store per
     524              :     /// state, so the same test binary proves the durable backend without a
     525              :     /// second copy of every test. The broker's own boot path never calls
     526              :     /// this; it composes from `ANTARES_STORE` in `with_drivers`.
     527              :     #[cfg(feature = "test-kit")]
     528         4638 :     pub fn new(host_alias: String) -> Self {
     529              :         #[cfg(not(target_arch = "wasm32"))]
     530         4638 :         if std::env::var("ANTARES_TEST_STORE").as_deref() == Ok("file") {
     531            0 :             let dir = std::env::temp_dir()
     532            0 :                 .join("antares-test-store")
     533            0 :                 .join(uuid::Uuid::new_v4().simple().to_string());
     534              :             // reachable only through the harness variable read above; a
     535              :             // harness that cannot open its own store has nothing to run
     536              :             #[allow(clippy::expect_used)]
     537            0 :             let store = Store::open_file(&dir).expect("open the redb test store");
     538            0 :             return Self::with_store(host_alias, Arc::new(AnyStore::Mem(store)), "file");
     539         4638 :         }
     540         4638 :         Self::with_store(
     541         4638 :             host_alias,
     542         4638 :             Arc::new(AnyStore::Mem(Store::default())),
     543         4638 :             "memory",
     544              :         )
     545         4638 :     }
     546              : 
     547              :     /// Convenience over the built-in backends: one `AnyStore` serves as
     548              :     /// both drivers.
     549              :     #[cfg(feature = "test-kit")]
     550         4732 :     pub fn with_store(host_alias: String, store: Arc<AnyStore>, store_name: &str) -> Self {
     551         4732 :         let temporal: Arc<dyn TemporalDriver> = store.clone();
     552         4732 :         Self::with_drivers(host_alias, store, temporal, store_name)
     553         4732 :     }
     554              : 
     555         4814 :     pub fn with_drivers(
     556         4814 :         host_alias: String,
     557         4814 :         store: Arc<dyn CurrentStateDriver>,
     558         4814 :         temporal: Arc<dyn TemporalDriver>,
     559         4814 :         store_name: &str,
     560         4814 :     ) -> Self {
     561              :         // One policy value, read once, shared by every outbound path —
     562              :         // the gate (scheme/breakers) and the clients (DNS pinning, redirect
     563              :         // cap) can never disagree about what is allowed.
     564         4814 :         let egress_policy = antares_jsonld::EgressPolicy::from_env();
     565              :         // Cached-@context rows are the ONE source of truth for 5.13
     566              :         // existence — wire the write-through HERE so every composition
     567              :         // (native binary, wasm, tests) gets it; a composition that forgets
     568              :         // the writer is the "expiry checked in some paths" disease with
     569              :         // @contexts instead of expiry.
     570         4814 :         let loader = Arc::new(Loader::new());
     571              :         {
     572         4814 :             let store = store.clone();
     573         4814 :             loader.set_cache_writer(Arc::new(move |tenant, url, ctx_value| {
     574          320 :                 let store = store.clone();
     575          320 :                 Box::pin(async move {
     576          320 :                     if hosted_row_id(&*store, tenant, url).await.is_some() {
     577            0 :                         return; // broker-local (Hosted/Implicit) URLs are not Cached entries
     578          320 :                     }
     579          320 :                     let id = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url.as_bytes());
     580              :                     // A refetch (staleness, delete+reload) must keep the row's
     581              :                     // identity and hit counters — only the body is new — and
     582              :                     // it swaps the body under the row lock, so a bump racing
     583              :                     // it keeps its count. Only a row that is not there is
     584              :                     // written whole.
     585              :                     // The row this writes is `Cached` and belongs to no Tenant
     586              :                     // (ADR-0021), but the call still acts for the Tenant whose
     587              :                     // resolution triggered the fetch: that is what lets the guard
     588              :                     // above see this Tenant's own Hosted rows.
     589          320 :                     let id = id.to_string();
     590          320 :                     let body = serde_json::json!({"@context": ctx_value});
     591          320 :                     let refetched = store
     592          320 :                         .context_mutate(
     593          320 :                             tenant,
     594          320 :                             &id,
     595          320 :                             Box::new(|row| {
     596            0 :                                 row.as_object_mut()
     597            0 :                                     .ok_or(())?
     598            0 :                                     .insert("body".into(), body.clone());
     599            0 :                                 Ok(())
     600            0 :                             }),
     601              :                         )
     602          320 :                         .await;
     603          320 :                     if matches!(refetched, Ok(Some(Ok(())))) {
     604            0 :                         return;
     605          320 :                     }
     606          320 :                     let doc = serde_json::json!({
     607          320 :                         "url": url,
     608          320 :                         "localId": id,
     609          320 :                         "kind": "Cached",
     610          320 :                         "createdAt": now_iso(),
     611          320 :                         "numberOfHits": 0,
     612          320 :                         "lastUsage": null,
     613          320 :                         "body": body,
     614              :                     });
     615          320 :                     if let Err(e) = store.context_put(tenant, &id, doc).await {
     616              :                         // a client-named @context URL may carry userinfo
     617            0 :                         tracing::warn!(
     618              :                             "@context write-through failed for {}: {e}",
     619            0 :                             antares_notifier::redact_userinfo(url)
     620              :                         );
     621          320 :                     }
     622          320 :                 })
     623          320 :             }));
     624              :         }
     625              :         // 5.13.3.5: hit counters live in the SHARED row, not per instance
     626              :         // — behind a load balancer per-instance counters split-brain.
     627              :         // A bump that finds the row gone reports
     628              :         // a cross-instance delete; the loader then drops its warm copies so
     629              :         // the delete is honoured everywhere (5.13.5.4). Pinned core contexts
     630              :         // have no row and are never evicted.
     631              :         {
     632         4814 :             let store = store.clone();
     633        32136 :             loader.set_usage_bump(Arc::new(move |tenant, url| {
     634        32136 :                 let store = store.clone();
     635        32136 :                 Box::pin(async move {
     636        32136 :                     if Loader::is_pinned_core(url) {
     637        11428 :                         return true;
     638        20708 :                     }
     639        20708 :                     let id = match hosted_row_id(&*store, tenant, url).await {
     640          122 :                         Some(id) => id,
     641        20586 :                         None => uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url.as_bytes())
     642        20586 :                             .to_string(),
     643              :                     };
     644              :                     // under the row lock: a bump racing a delete leaves the row
     645              :                     // deleted, and two racing bumps both count
     646        20708 :                     let bumped = store
     647        20708 :                         .context_mutate(
     648        20708 :                             tenant,
     649        20708 :                             &id,
     650        20708 :                             Box::new(|row| {
     651        20704 :                                 let o = row.as_object_mut().ok_or(())?;
     652        20704 :                                 let hits = o
     653        20704 :                                     .get("numberOfHits")
     654        20704 :                                     .and_then(serde_json::Value::as_u64)
     655        20704 :                                     .unwrap_or(0)
     656        20704 :                                     .saturating_add(1);
     657        20704 :                                 o.insert("numberOfHits".into(), serde_json::json!(hits));
     658        20704 :                                 o.insert("lastUsage".into(), serde_json::json!(now_iso()));
     659        20704 :                                 Ok(())
     660        20704 :                             }),
     661              :                         )
     662        20708 :                         .await;
     663        20704 :                     match bumped {
     664        20704 :                         Ok(Some(Ok(()))) => true,
     665              :                         // a row that cannot carry the counters is left
     666              :                         // uncounted, and still exists: it must not be evicted
     667            0 :                         Ok(Some(Err(()))) => true,
     668            4 :                         Ok(None) => false,
     669              :                         // a store hiccup must never evict a healthy cache entry
     670            0 :                         Err(e) => {
     671            0 :                             tracing::warn!(
     672              :                                 "@context hit bump failed for {}: {e}",
     673            0 :                                 antares_notifier::redact_userinfo(url)
     674              :                             );
     675            0 :                             true
     676              :                         }
     677              :                     }
     678        32136 :                 })
     679        32136 :             }));
     680              :         }
     681              :         // 5.13.1: an @context this broker HOSTS is served from its row, not
     682              :         // fetched. Its warm copy in the loader is only a cache — a restart
     683              :         // reloads Cached rows alone, and the bounded cache may evict it at
     684              :         // any moment — and the URL under it is minted from the request Host
     685              :         // header, so a miss would otherwise send the broker to whatever
     686              :         // address a client put there for the term mappings of its own
     687              :         // Tenant's payloads. The row carries the owner, so 5.5.10 still
     688              :         // decides who may resolve it.
     689              :         {
     690         4814 :             let store = store.clone();
     691         4814 :             loader.set_local_lookup(Arc::new(move |tenant, url| {
     692          404 :                 let store = store.clone();
     693          404 :                 Box::pin(async move {
     694              :                     // A miss for a named Tenant still asks the DEFAULT one: a
     695              :                     // fetch of this URL would reach the serve endpoint with no
     696              :                     // NGSILD-Tenant, get the default Tenant's row and cache it
     697              :                     // for everyone. Found here, it keeps its owner.
     698          404 :                     let default = antares_model::TenantId::default();
     699          404 :                     let (_, row) = match hosted_row(&*store, tenant, url).await {
     700           20 :                         Some(found) => found,
     701          384 :                         None if tenant.is_some_and(|t| *t != default) => {
     702           14 :                             hosted_row(&*store, Some(&default), url).await?
     703              :                         }
     704          370 :                         None => return None,
     705              :                     };
     706              :                     // Ownership is one rule for the whole broker (ADR-0021): a
     707              :                     // Cached row is a copy of a public document and belongs to no
     708              :                     // Tenant, everything else belongs to its `owner` — with the
     709              :                     // DEFAULT Tenant for a row written before that member
     710              :                     // existed, which is the Tenant that lists, serves and deletes
     711              :                     // it.
     712           28 :                     let owner = antares_store::context_row_owner(&row)
     713           28 :                         .and_then(|o| antares_model::TenantId::new_internal(o).ok());
     714           28 :                     Some((
     715           28 :                         owner,
     716           28 :                         row.pointer("/body/@context").cloned().unwrap_or_default(),
     717           28 :                     ))
     718          404 :                 })
     719          404 :             }));
     720              :         }
     721              :         // 5.8.1.4: this URL is handed to peer brokers as the notification
     722              :         // endpoint for distributed subscriptions — the default must carry
     723              :         // the HTTP port or peers dial port 80 (ETSI-matrix ADV_02 shape).
     724         4814 :         let public_url =
     725         4814 :             std::env::var("ANTARES_PUBLIC_URL").unwrap_or_else(|_| {
     726         4672 :                 match std::env::var("ANTARES_HTTP_PORT") {
     727           12 :                     Ok(p) if p != "80" => format!("http://{host_alias}:{p}"),
     728         4662 :                     _ => format!("http://{host_alias}"),
     729              :                 }
     730         4672 :             });
     731         4814 :         let temporal_name = temporal.supported().then(|| store_name.to_owned());
     732              :         // 6.3.8 is the mandatory binding; clause 7's MQTT one is optional and
     733              :         // registers only when compiled in. Both go through the registry, so
     734              :         // notification delivery never names a transport.
     735         4814 :         let http = outbound_client(
     736         4814 :             egress_policy,
     737         4814 :             std::time::Duration::from_secs(slow_factor().saturating_mul(5)),
     738              :         );
     739         4814 :         let mut sinks = antares_notifier::SinkRegistry::default();
     740         4814 :         sinks.register(Box::new(antares_notifier::HttpSink::new(http.clone())));
     741              :         #[cfg(feature = "mqtt")]
     742         4814 :         sinks.register(Box::new(antares_notifier::mqtt::MqttSink::default()));
     743         4814 :         Self {
     744         4814 :             inbound: Arc::new(std::sync::OnceLock::new()),
     745         4814 :             shared: Arc::new(Shared {
     746         4814 :                 store,
     747         4814 :                 temporal,
     748         4814 :                 store_name: store_name.to_owned(),
     749         4814 :                 temporal_name,
     750         4814 :                 loader,
     751         4814 :                 started: Instant::now(),
     752         4814 :                 started_at: now_iso(),
     753         4814 :                 host_alias,
     754         4814 :                 default_limit: *crate::bounds::DEFAULT_LIMIT,
     755         4814 :                 max_limit: *crate::bounds::MAX_LIMIT,
     756         4814 :                 temporal_instance_limit: crate::bounds::temporal_instance_limit(),
     757         4814 :                 temporal_default_instances: crate::bounds::temporal_default_instances(),
     758         4814 :                 max_answer_bytes: crate::bounds::MAX_ANSWER_BYTES,
     759         4814 :                 http: http.clone(),
     760         4814 :                 fed_http: outbound_client(
     761         4814 :                     egress_policy,
     762         4814 :                     std::time::Duration::from_secs(slow_factor().saturating_mul(8)),
     763         4814 :                 ),
     764         4814 :                 sinks: Arc::new(sinks),
     765         4814 :                 forward_transport: Arc::new(std::sync::OnceLock::new()),
     766         4814 :                 surfaces: Arc::new(vec![Box::new(crate::Admin)]),
     767         4814 :                 limits: Arc::new(crate::bounds::LimitStats::default()),
     768         4814 :                 tenant_bytes: Arc::default(),
     769         4814 :                 known_tenants: Arc::default(),
     770         4814 :                 mem_stats: None,
     771         4814 :                 bus_stats: None,
     772         4814 :                 egress: Arc::new(crate::egress::Egress::new(egress_policy)),
     773         4814 :                 policy: None,
     774         4814 :                 admin_auth: None,
     775         4814 :                 draining: Arc::new(std::sync::atomic::AtomicBool::new(false)),
     776         4814 :                 pending_changes: Arc::default(),
     777         4814 :                 sub_sync: None,
     778         4814 :                 sub_mirror: None,
     779         4814 :                 change_flush: None,
     780         4814 :                 reg_sync: None,
     781         4814 :                 reg_fail_sync: None,
     782         4814 :                 reg_mirror: None,
     783         4814 :                 metrics_render: None,
     784         4814 :                 csource_notification: None,
     785         4814 :                 nats: false,
     786         4814 :                 temporal_record: TemporalRecord::All,
     787         4814 :                 profile: Profile::default(),
     788         4814 :                 provenance_from: None,
     789         4814 :                 subject_required: false,
     790         4814 :                 history_off: HistoryOff::default(),
     791         4814 :                 public_url,
     792         4814 :                 snapshot_cap: 1024,
     793         4814 :                 delivery: antares_notifier::DeliveryPolicy::default(),
     794         4814 :             }),
     795         4814 :         }
     796         4814 :     }
     797              : 
     798              :     /// Attach a policy engine (ADR-0020). One engine per broker, chosen at
     799              :     /// startup: an engine that could be swapped per request would make what
     800              :     /// a caller may see a function of when they asked. Call before the
     801              :     /// state is shared.
     802              :     #[must_use]
     803          966 :     pub fn with_policy(mut self, engine: Arc<dyn crate::policy::PolicyEngine>) -> Self {
     804          966 :         self.policy = Some(engine);
     805          966 :         self
     806          966 :     }
     807              : 
     808              :     /// Puts every `/q` route except health and readiness (and metrics, when
     809              :     /// `auth` leaves them open) behind `auth`'s bearer secret. Set before
     810              :     /// the router is built: the admin surface reads it when it mounts.
     811              :     #[must_use]
     812           18 :     pub fn with_admin_auth(mut self, auth: crate::admin_auth::AdminAuth) -> Self {
     813           18 :         self.admin_auth = Some(Arc::new(auth));
     814           18 :         self
     815           18 :     }
     816              : 
     817              :     /// The engine `/q/health` reports. No engine reports the built-in name:
     818              :     /// the decision an operator reads is the one the broker takes.
     819           86 :     pub fn policy_name(&self) -> &str {
     820           86 :         self.policy
     821           86 :             .as_ref()
     822           86 :             .map_or(crate::policy::BUILT_IN_NAME, |e| e.name())
     823           86 :     }
     824              : 
     825              :     /// Register one more notification binding (6.3.8). A deployment adds a
     826              :     /// sink for a scheme this workspace does not ship; endpoints naming that
     827              :     /// scheme then validate and deliver through it. Call before the state is
     828              :     /// shared — a clone already handed out keeps the registry it was made
     829              :     /// with.
     830              :     ///
     831              :     /// Not behind `test-kit`, unlike the mirror accessors beside it: this is
     832              :     /// the sink seam itself (ADR-0016), the call `docs/src/extending.md`
     833              :     /// tells a deployment to make, and `examples/plugin-example` is the host
     834              :     /// that proves it from outside. A release library without it has no
     835              :     /// notification binding seam at all.
     836              :     #[must_use]
     837           16 :     pub fn with_sink(mut self, sink: Box<dyn antares_notifier::NotificationSink>) -> Self {
     838           16 :         match Arc::get_mut(&mut self.sinks) {
     839           16 :             Some(reg) => reg.register(sink),
     840            0 :             None => tracing::warn!("sink registry already shared; binding not registered"),
     841              :         }
     842           16 :         self
     843           16 :     }
     844              : 
     845              :     /// Register the transport every federation forward asks before the
     846              :     /// socket. A
     847              :     /// forward it answers `None` for goes over HTTP as before. Every clone
     848              :     /// of this state shares the one slot, before or after this call. The
     849              :     /// transport usually holds such a clone, so it lives as long as the
     850              :     /// state's last clone does: register it in a host that keeps its state
     851              :     /// for the life of the process, as the broker does.
     852              :     ///
     853              :     /// # Errors
     854              :     /// When a transport is already registered: a second one would leave
     855              :     /// the choice between them to registration order.
     856           40 :     pub fn with_forward_transport(
     857           40 :         self,
     858           40 :         transport: Arc<dyn crate::ForwardTransport>,
     859           40 :     ) -> Result<Self, String> {
     860           40 :         self.forward_transport
     861           40 :             .set(transport)
     862           40 :             .map_err(|_| "a forward transport is already registered".to_owned())?;
     863           38 :         Ok(self)
     864           40 :     }
     865              : 
     866              :     /// Mount one more HTTP surface (`/q`, `/x`, or below `/x`). A prefix
     867              :     /// outside those, or one that overlaps a surface already mounted, is an
     868              :     /// error the caller is expected to make fatal: a surface that could
     869              :     /// shadow a spec resource would make conformance a function of
     870              :     /// deployment configuration, and two surfaces on one prefix would leave
     871              :     /// the winner to route-matching order. Call before the state is shared.
     872           76 :     pub fn with_surface(mut self, s: Box<dyn crate::ApiSurface>) -> Result<Self, String> {
     873           76 :         crate::surface::check_prefix(s.prefix())?;
     874           64 :         if let Some(clash) = self
     875           64 :             .surfaces
     876           64 :             .iter()
     877           64 :             .find(|m| crate::surface::overlaps(m.prefix(), s.prefix()))
     878              :         {
     879            8 :             return Err(format!(
     880            8 :                 "api surface {:?} claims {:?}, already served by {:?} at {:?}",
     881            8 :                 s.name(),
     882            8 :                 s.prefix(),
     883            8 :                 clash.name(),
     884            8 :                 clash.prefix()
     885            8 :             ));
     886           56 :         }
     887           56 :         match Arc::get_mut(&mut self.surfaces) {
     888           56 :             Some(v) => v.push(s),
     889            0 :             None => return Err("api surfaces already shared; register before serving".into()),
     890              :         }
     891           56 :         Ok(self)
     892           76 :     }
     893              : 
     894              :     /// Replace the mounted surfaces with a deployment's own selection —
     895              :     /// what a binary reads out of its configuration, rather than what the
     896              :     /// default mounting put there. Same prefix rules as `with_surface`, and
     897              :     /// a selection may leave admin out: `/q` is then not served at all.
     898           18 :     pub fn with_surfaces(mut self, list: Vec<Box<dyn crate::ApiSurface>>) -> Result<Self, String> {
     899           18 :         self.surfaces = Arc::new(Vec::new());
     900           20 :         for s in list {
     901           20 :             self = self.with_surface(s)?;
     902              :         }
     903           16 :         Ok(self)
     904           18 :     }
     905              : 
     906              :     /// Serve one request through this broker's own router, in process.
     907              :     ///
     908              :     /// This is the seam a façade for another standard (SensorThings, OGC
     909              :     /// API, WFS, OData) is built on: the façade is an [`crate::ApiSurface`]
     910              :     /// under `/x/<standard>` that translates its own request into an NGSI-LD
     911              :     /// one and calls this. There is no second data path — the inner request
     912              :     /// takes the same route as one off the socket, so negotiation, the
     913              :     /// bounds wall, tenancy, the policy seam, history and notifications all
     914              :     /// apply exactly once and exactly as they do for an NGSI-LD client.
     915              :     ///
     916              :     /// `caller` is the outer request's headers, and the ones that decide
     917              :     /// WHICH data an operation runs against are copied into the inner
     918              :     /// request when it does not set them itself:
     919              :     ///
     920              :     /// - `NGSILD-Tenant` (6.3.14) — a façade that forgot it would answer
     921              :     ///   every caller out of the default tenant, so it is not left to the
     922              :     ///   façade to remember;
     923              :     /// - `NGSILD-Snapshot` (6.3.22) — for the same reason: a façade called
     924              :     ///   inside a snapshot request must not quietly serve live data;
     925              :     /// - `Link` (6.3.5) — the `@context` the caller supplied, so a term
     926              :     ///   means the same thing on both sides of the translation;
     927              :     /// - every header `ANTARES_POLICY_SUBJECT_HEADERS` names, so the policy
     928              :     ///   engine is asked about the caller rather than about the façade.
     929              :     ///
     930              :     /// All values of a copied header are carried, never just the first: a
     931              :     /// repeated `NGSILD-Tenant` is `BadRequestData` (6.3.14), and a façade
     932              :     /// must not be the place where a repeat is laundered into a single valid
     933              :     /// value.
     934              :     ///
     935              :     /// `&self` on purpose — an inner call runs while the outer handler is
     936              :     /// suspended on it, and the router clone is an `Arc` bump over the same
     937              :     /// state. The router itself is built once per state, on the first call:
     938              :     /// building one costs about 1.5 ms, which no façade should pay per
     939              :     /// request. After that first call the state counts as shared, so
     940              :     /// `with_surface` and the other builders refuse — which is the rule
     941              :     /// they already state.
     942          100 :     pub async fn call(
     943          100 :         &self,
     944          100 :         caller: &axum::http::HeaderMap,
     945          100 :         req: axum::http::Request<axum::body::Body>,
     946          100 :     ) -> axum::response::Response {
     947              :         use axum::response::IntoResponse as _;
     948              :         // A façade route that translates into a request its own surface
     949              :         // serves would call itself for as long as the stack lasts, and
     950              :         // every frame builds a router. The chain is counted per task, so a
     951              :         // façade over a façade is served and a loop is not.
     952          100 :         let depth = in_process_depth();
     953          100 :         if depth >= crate::bounds::MAX_IN_PROCESS_CALL_DEPTH {
     954            4 :             return crate::negotiate::ApiError::from(antares_model::NgsiError::InternalError(
     955            4 :                 format!(
     956            4 :                     "in-process call depth exceeded {}",
     957            4 :                     crate::bounds::MAX_IN_PROCESS_CALL_DEPTH
     958            4 :                 ),
     959            4 :             ))
     960            4 :             .into_response();
     961           96 :         }
     962           96 :         let router = self.inbound.get_or_init(|| {
     963              :             // Every layer of a built router captured an `AppState` clone, so
     964              :             // the memo owns states. The clone it is built from must NOT be
     965              :             // able to reach THIS cell: a state that could would hold itself
     966              :             // alive for the life of the process and pin its store handle
     967              :             // with it — a file store would keep its lock after the host
     968              :             // dropped everything. A fresh cell means the memo of a nested
     969              :             // façade call hangs off this one and dies with it.
     970           60 :             let mut inner = self.clone();
     971           60 :             inner.inbound = Arc::new(std::sync::OnceLock::new());
     972           60 :             crate::router(inner)
     973           60 :         });
     974           96 :         let (mut parts, body) = req.into_parts();
     975          288 :         for name in &PROPAGATED {
     976          288 :             if parts.headers.contains_key(name) {
     977            0 :                 continue;
     978          288 :             }
     979          288 :             for v in caller.get_all(name) {
     980           36 :                 parts.headers.append(name.clone(), v.clone());
     981           36 :             }
     982              :         }
     983           96 :         for name in crate::policy::SUBJECT_HEADERS.iter() {
     984           88 :             let Ok(name) = axum::http::HeaderName::from_bytes(name.as_bytes()) else {
     985            0 :                 continue;
     986              :             };
     987           88 :             if parts.headers.contains_key(&name) {
     988            0 :                 continue;
     989           88 :             }
     990           88 :             for v in caller.get_all(&name) {
     991            8 :                 parts.headers.append(name.clone(), v.clone());
     992            8 :             }
     993              :         }
     994           96 :         let req = axum::http::Request::from_parts(parts, body);
     995           96 :         match INPROCESS_DEPTH
     996           96 :             .scope(
     997           96 :                 depth.saturating_add(1),
     998           96 :                 tower::ServiceExt::oneshot(router.clone(), req),
     999              :             )
    1000           96 :             .await
    1001              :         {
    1002           96 :             Ok(r) => r,
    1003              :             // `Router`'s error is `Infallible`; the arm stays honest rather
    1004              :             // than unwrapping (the workspace denies unwrap outside tests).
    1005            0 :             Err(_) => crate::negotiate::ApiError::from(antares_model::NgsiError::InternalError(
    1006            0 :                 "the in-process router failed".into(),
    1007            0 :             ))
    1008            0 :             .into_response(),
    1009              :         }
    1010          100 :     }
    1011              : 
    1012              :     /// [`AppState::call`] on a task of its own, at the caller's in-process
    1013              :     /// depth. A chain of calls each awaiting the next polls every frame
    1014              :     /// inside the one before it, and a few federation handlers deep that
    1015              :     /// is more stack than a worker thread has; on its own task each frame
    1016              :     /// is polled from the scheduler, as a request over HTTP is. Dropping
    1017              :     /// the returned future (a forward's timeout) aborts the task.
    1018              :     ///
    1019              :     /// The new task inherits no view: the call runs through `view` when one
    1020              :     /// is given ([`crate::policy::through_view`]), so the engine decides a
    1021              :     /// forward under a grant's view as a request through that view,
    1022              :     /// and never by the view of the request that triggered the forward.
    1023              :     #[cfg(not(target_arch = "wasm32"))]
    1024            0 :     pub async fn call_on_task(
    1025            0 :         &self,
    1026            0 :         view: Option<&str>,
    1027            0 :         caller: axum::http::HeaderMap,
    1028            0 :         req: axum::http::Request<axum::body::Body>,
    1029            0 :     ) -> axum::response::Response {
    1030              :         use axum::response::IntoResponse as _;
    1031              :         /// Aborts the task when the forward waiting on it is dropped.
    1032              :         struct AbortOnDrop(tokio::task::AbortHandle);
    1033              :         impl Drop for AbortOnDrop {
    1034            0 :             fn drop(&mut self) {
    1035            0 :                 self.0.abort();
    1036            0 :             }
    1037              :         }
    1038            0 :         let st = self.clone();
    1039            0 :         let view = view.map(str::to_owned);
    1040            0 :         let task = tokio::spawn(INPROCESS_DEPTH.scope(in_process_depth(), async move {
    1041            0 :             match view {
    1042            0 :                 Some(id) => crate::policy::through_view(&id, st.call(&caller, req)).await,
    1043            0 :                 None => st.call(&caller, req).await,
    1044              :             }
    1045            0 :         }));
    1046            0 :         let _abort = AbortOnDrop(task.abort_handle());
    1047            0 :         task.await.unwrap_or_else(|_| {
    1048            0 :             crate::negotiate::ApiError::from(antares_model::NgsiError::InternalError(
    1049            0 :                 "the in-process call failed".into(),
    1050            0 :             ))
    1051            0 :             .into_response()
    1052            0 :         })
    1053            0 :     }
    1054              : 
    1055              :     /// Temporal auto-recording happens synchronously in the write path in
    1056              :     /// EVERY bus mode (read-your-writes) — but only when the loaded
    1057              :     /// temporal driver actually records anything.
    1058       152318 :     pub fn record_locally(&self) -> bool {
    1059       152318 :         self.temporal.supported()
    1060       152318 :     }
    1061              : 
    1062              :     /// Fire the subscription-sync hook for one written row. `kind` decides
    1063              :     /// what the mirror does with it: a Subscription is indexed as a document,
    1064              :     /// a Context Source Registration Subscription only wakes the interval
    1065              :     /// sweep (5.11.7) — it is matched against registrations, not entities.
    1066         2066 :     pub(crate) fn sub_changed(
    1067         2066 :         &self,
    1068         2066 :         tenant: &antares_model::TenantId,
    1069         2066 :         kind: antares_store::Kind,
    1070         2066 :         id: &str,
    1071         2066 :         doc: Option<&serde_json::Value>,
    1072         2066 :     ) {
    1073         2066 :         if let Some(h) = &self.sub_sync {
    1074         1956 :             h(tenant, kind, id, doc);
    1075         1956 :         }
    1076         2066 :     }
    1077              : 
    1078              :     /// 5.2.34: stamp the per-registration cooldown locally AND on the other
    1079              :     /// api pods (no-op half in local mode). Keyed per tenant — the id alone
    1080              :     /// is client-chosen per tenant (5.5.10) and must not gate a neighbour.
    1081            2 :     pub(crate) fn reg_cooldown_stamp(
    1082            2 :         &self,
    1083            2 :         tenant: &antares_model::TenantId,
    1084            2 :         reg_id: &str,
    1085            2 :         ok: bool,
    1086            2 :     ) {
    1087            2 :         let key = crate::egress::reg_key(tenant.as_str(), reg_id);
    1088            2 :         self.egress.reg_record(&key, ok);
    1089            2 :         if let Some(h) = &self.reg_fail_sync {
    1090            0 :             // the broadcast carries the COMPOSED key; receiving pods stamp it
    1091            0 :             // verbatim, so their lookups agree without re-deriving anything
    1092            0 :             h(&key, ok);
    1093            2 :         }
    1094            2 :     }
    1095              : 
    1096              :     /// Fire the registration-delta hook (no-op in local mode).
    1097         4270 :     pub(crate) fn reg_changed(
    1098         4270 :         &self,
    1099         4270 :         tenant: &antares_model::TenantId,
    1100         4270 :         id: &str,
    1101         4270 :         doc: Option<&serde_json::Value>,
    1102         4270 :     ) {
    1103         4270 :         if let Some(h) = &self.reg_sync {
    1104         2206 :             h(tenant, id, doc);
    1105         2742 :         }
    1106         4270 :     }
    1107              : }
    1108              : 
    1109              : /// The ONE outbound-client construction for this crate (timeouts at
    1110              : /// construction). Title-case headers are an http1 knob and timeouts are
    1111              : /// client-level knobs — both native-only; the browser's fetch supplies its
    1112              : /// own transport on wasm32.
    1113              : /// Outbound timeouts stretch 10× when the test binary runs under a
    1114              : /// sanitizer (ANTARES_TEST_SANITIZER, set by the strict workflow):
    1115              : /// ThreadSanitizer slows every thread, and 371 tests sharing one runner
    1116              : /// pushed loopback forwards past the 2 s connect / 5 s total limits —
    1117              : /// each run failing a different test with a 504. Production is unchanged.
    1118        21006 : pub fn slow_factor() -> u64 {
    1119        21006 :     antares_jsonld::slow_factor()
    1120        21006 : }
    1121              : 
    1122         9628 : fn outbound_client(
    1123         9628 :     policy: antares_jsonld::EgressPolicy,
    1124         9628 :     total: std::time::Duration,
    1125         9628 : ) -> antares_jsonld::HttpClient {
    1126         9628 :     let b = antares_jsonld::with_timeouts(
    1127         9628 :         antares_jsonld::client_builder(policy),
    1128         9628 :         std::time::Duration::from_secs(slow_factor().saturating_mul(2)),
    1129         9628 :         total,
    1130              :     );
    1131              :     // the suite's notification receiver asserts header names
    1132              :     // case-sensitively ("Link", "X-Additional-Key")
    1133              :     #[cfg(not(target_arch = "wasm32"))]
    1134         9628 :     let b = b.http1_title_case_headers();
    1135              :     // reqwest fails to build only when the process cannot initialise its TLS
    1136              :     // backend: no outbound request of any kind can be made after that
    1137              :     #[allow(clippy::expect_used)]
    1138         9628 :     let c = b.build().expect("http client");
    1139         9628 :     antares_jsonld::wrap_client(c)
    1140         9628 : }
    1141              : 
    1142              : /// 5.13.1: an @context this broker HOSTS (Hosted or ImplicitlyCreated) is
    1143              : /// identified by its stored row, never by the URL's shape — a peer broker or
    1144              : /// an attacker can serve a document under the same resource path, and such a
    1145              : /// URL is external to us (a Cached entry). The row records the URL it was
    1146              : /// minted under, so that is what decides: a URL whose trailing segment names
    1147              : /// a stored row but whose origin is somebody else's names a document this
    1148              : /// broker does not host, and the stored row — another Tenant's, as often as
    1149              : /// not — must not be read, counted or rewritten for it. Returns the local
    1150              : /// row id when the URL is the one the row was minted under.
    1151        21446 : async fn hosted_row(
    1152        21446 :     store: &dyn CurrentStateDriver,
    1153        21446 :     tenant: Option<&antares_model::TenantId>,
    1154        21446 :     url: &str,
    1155        21446 : ) -> Option<(String, serde_json::Value)> {
    1156        21446 :     let (_, seg) = url.rsplit_once("/ngsi-ld/v1/jsonldContexts/")?;
    1157          190 :     let seg = seg.split(['?', '#']).next().unwrap_or(seg);
    1158          190 :     if seg.is_empty() || seg.contains('/') {
    1159            0 :         return None;
    1160          190 :     }
    1161          190 :     let row = store
    1162          190 :         .context_get(tenant, seg)
    1163          190 :         .await
    1164          190 :         .ok()
    1165          190 :         .flatten()
    1166          190 :         .filter(|row| row["url"].as_str() == Some(url))?;
    1167          150 :     Some((seg.to_owned(), row))
    1168        21446 : }
    1169              : 
    1170        21028 : async fn hosted_row_id(
    1171        21028 :     store: &dyn CurrentStateDriver,
    1172        21028 :     tenant: Option<&antares_model::TenantId>,
    1173        21028 :     url: &str,
    1174        21028 : ) -> Option<String> {
    1175        21028 :     hosted_row(store, tenant, url).await.map(|(id, _)| id)
    1176        21028 : }
    1177              : 
    1178              : /// Server-managed timestamp, ISO 8601 UTC with milliseconds.
    1179       209845 : pub fn now_iso() -> String {
    1180       209845 :     chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
    1181       209845 : }
    1182              : 
    1183              : #[cfg(test)]
    1184              : mod jsonld_context_locality_5_13 {
    1185              :     use super::*;
    1186              : 
    1187              :     /// Serve one @context document under `path` on a loopback port, counting
    1188              :     /// fetches (the negative half: a warm copy must NOT be refetched).
    1189           24 :     fn context_server(path: &str) -> (String, Arc<std::sync::atomic::AtomicUsize>) {
    1190           24 :         let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
    1191           24 :         let port = listener.local_addr().expect("addr").port();
    1192           24 :         let fetches = Arc::new(std::sync::atomic::AtomicUsize::new(0));
    1193           24 :         let n = fetches.clone();
    1194           24 :         std::thread::spawn(move || {
    1195           24 :             for stream in listener.incoming() {
    1196           12 :                 let Ok(mut s) = stream else { continue };
    1197           12 :                 n.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
    1198           12 :                 let body = r#"{"@context":{"peerTemp":"http://example.org/peerTemp"}}"#;
    1199           12 :                 let resp = format!(
    1200              :                     "HTTP/1.1 200 OK\r\nContent-Type: application/ld+json\r\n\
    1201              :                      Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
    1202           12 :                     body.len()
    1203              :                 );
    1204              :                 use std::io::{Read, Write};
    1205           12 :                 let mut buf = [0u8; 2048];
    1206           12 :                 let _ = s.read(&mut buf);
    1207           12 :                 let _ = s.write_all(resp.as_bytes());
    1208              :             }
    1209           24 :         });
    1210           24 :         (format!("http://127.0.0.1:{port}{path}"), fetches)
    1211           24 :     }
    1212              : 
    1213           16 :     fn fetch_count(c: &Arc<std::sync::atomic::AtomicUsize>) -> usize {
    1214           16 :         c.load(std::sync::atomic::Ordering::SeqCst)
    1215           16 :     }
    1216              : 
    1217              :     /// 5.13.1: a Cached @context is one this broker fetched from elsewhere.
    1218              :     /// Which URLs this broker HOSTS is a property of the stored rows, not of
    1219              :     /// the URL path, so a peer's @context served under the same resource path
    1220              :     /// is persisted as Cached and never mistaken for a row deleted through
    1221              :     /// another instance (5.13.5.4).
    1222              :     #[tokio::test]
    1223            4 :     async fn a_peer_context_url_under_the_local_path_is_cached() {
    1224            4 :         let st = AppState::new("me".into());
    1225            4 :         let (url, fetches) = context_server("/ngsi-ld/v1/jsonldContexts/peer-ctx");
    1226            4 :         let user = serde_json::json!(url);
    1227            4 :         st.loader.resolve(&user).await.expect("resolve");
    1228            4 :         let id = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url.as_bytes()).to_string();
    1229            4 :         let row = st
    1230            4 :             .store
    1231            4 :             .context_get(None, &id)
    1232            4 :             .await
    1233            4 :             .expect("store")
    1234            4 :             .expect("the fetched @context is persisted as a Cached row");
    1235            4 :         assert_eq!(row["kind"], "Cached");
    1236            4 :         assert_eq!(row["url"], serde_json::json!(url));
    1237            4 :         st.loader.resolve(&user).await.expect("resolve again");
    1238            4 :         assert_eq!(
    1239            4 :             fetch_count(&fetches),
    1240              :             1,
    1241              :             "a warm, still-existing @context must not be refetched"
    1242              :         );
    1243            4 :         assert_eq!(
    1244            4 :             st.store
    1245            4 :                 .context_get(None, &id)
    1246            4 :                 .await
    1247            4 :                 .expect("store")
    1248            4 :                 .expect("row")["numberOfHits"],
    1249            4 :             serde_json::json!(2),
    1250            4 :             "both counted uses land on the shared row (5.13.3.5)"
    1251            4 :         );
    1252            4 :     }
    1253              : 
    1254              :     /// the default Tenant's Hosted @context is not fetched for
    1255              :     /// another Tenant through the broker's own (tenant-less) serve URL.
    1256              :     #[tokio::test]
    1257            4 :     async fn a_default_tenant_context_is_not_fetched_for_another_tenant() {
    1258            4 :         let st = AppState::new("me".into());
    1259            4 :         let owner = antares_model::TenantId::default();
    1260            4 :         let (url, fetches) = context_server("/ngsi-ld/v1/jsonldContexts/local-d");
    1261            4 :         st.store
    1262            4 :             .context_put(
    1263            4 :                 Some(&owner),
    1264            4 :                 "local-d",
    1265            4 :                 serde_json::json!({
    1266            4 :                     "url": url, "localId": "local-d", "kind": "Hosted",
    1267            4 :                     "owner": owner.as_str(), "createdAt": now_iso(),
    1268            4 :                     "body": {"@context": {"peerTemp": "http://example.org/peerTemp"}},
    1269            4 :                 }),
    1270            4 :             )
    1271            4 :             .await
    1272            4 :             .expect("seed hosted row");
    1273            4 :         let beta = antares_model::TenantId::new("beta").expect("tenant");
    1274            4 :         let user = serde_json::json!(url);
    1275            4 :         for _ in 0..2 {
    1276            8 :             let e = st.loader.resolve_for(&beta, &user).await;
    1277            8 :             assert!(
    1278            8 :                 matches!(e, Err(antares_model::NgsiError::LdContextNotAvailable(_))),
    1279              :                 "{e:?}"
    1280              :             );
    1281              :         }
    1282            4 :         assert_eq!(fetch_count(&fetches), 0, "never fetched over HTTP");
    1283            4 :         st.loader
    1284            4 :             .resolve_for(&owner, &user)
    1285            4 :             .await
    1286            4 :             .expect("owner resolves");
    1287            4 :     }
    1288              : 
    1289              :     /// a Cached @context serves every Tenant, so a named Tenant
    1290              :     /// may neither delete nor reload it; the default Tenant may.
    1291              :     #[tokio::test]
    1292            4 :     async fn only_the_default_tenant_deletes_a_cached_context() {
    1293            4 :         let st = AppState::new("me".into());
    1294            4 :         let (url, _) = context_server("/shared.jsonld");
    1295            4 :         let alpha = antares_model::TenantId::new("alpha").expect("tenant");
    1296            4 :         st.loader
    1297            4 :             .resolve_for(&alpha, &serde_json::json!(url))
    1298            4 :             .await
    1299            4 :             .expect("cached");
    1300            4 :         let path = format!(
    1301              :             "/ngsi-ld/v1/jsonldContexts/{}",
    1302            4 :             url.replace(':', "%3A").replace('/', "%2F")
    1303              :         );
    1304           16 :         let send = |method: &str, uri: String, tenant: Option<&str>| {
    1305           16 :             let mut b = axum::http::Request::builder().method(method).uri(uri);
    1306           16 :             if let Some(t) = tenant {
    1307            8 :                 b = b.header("NGSILD-Tenant", t);
    1308            8 :             }
    1309           16 :             let req = b.body(axum::body::Body::empty()).expect("request");
    1310           16 :             tower::ServiceExt::oneshot(crate::router(st.clone()), req)
    1311           16 :         };
    1312            8 :         for uri in [path.clone(), format!("{path}?reload=true")] {
    1313            8 :             let refused = send("DELETE", uri, Some("beta")).await.expect("response");
    1314            8 :             assert_eq!(refused.status(), axum::http::StatusCode::FORBIDDEN);
    1315              :         }
    1316            4 :         let details = send("GET", format!("{path}?details=true"), None)
    1317            4 :             .await
    1318            4 :             .expect("response");
    1319            4 :         assert_eq!(
    1320            4 :             details.status(),
    1321              :             axum::http::StatusCode::OK,
    1322              :             "the row is kept"
    1323              :         );
    1324            4 :         let deleted = send("DELETE", path, None).await.expect("response");
    1325            4 :         assert_eq!(deleted.status(), axum::http::StatusCode::NO_CONTENT);
    1326            4 :     }
    1327              : 
    1328              :     /// a Tenant purge drops its Hosted @context from the loader
    1329              :     /// too, so the name recreated is not served the old term mappings.
    1330              :     #[tokio::test]
    1331            4 :     async fn a_purged_tenants_hosted_context_resolves_no_more() {
    1332            4 :         let st = AppState::new("me".into());
    1333            4 :         let t = antares_model::TenantId::new("purgectx").expect("tenant");
    1334            4 :         let (url, fetches) = context_server("/ngsi-ld/v1/jsonldContexts/local-p");
    1335            4 :         st.store
    1336            4 :             .context_put(
    1337            4 :                 Some(&t),
    1338            4 :                 "local-p",
    1339            4 :                 serde_json::json!({
    1340            4 :                     "url": url, "localId": "local-p", "kind": "Hosted",
    1341            4 :                     "owner": t.as_str(), "createdAt": now_iso(),
    1342            4 :                     "body": {"@context": {"peerTemp": "http://example.org/peerTemp"}},
    1343            4 :                 }),
    1344            4 :             )
    1345            4 :             .await
    1346            4 :             .expect("seed hosted row");
    1347            4 :         let user = serde_json::json!(url);
    1348            4 :         st.loader
    1349            4 :             .resolve_for(&t, &user)
    1350            4 :             .await
    1351            4 :             .expect("owner resolves");
    1352            8 :         let req = |m: &str, uri: &str, body: &str| {
    1353            8 :             axum::http::Request::builder()
    1354            8 :                 .method(m)
    1355            8 :                 .uri(uri)
    1356            8 :                 .header("NGSILD-Tenant", "purgectx")
    1357            8 :                 .header("Content-Type", "application/json")
    1358            8 :                 .header("Content-Length", body.len())
    1359            8 :                 .body(axum::body::Body::from(body.to_owned()))
    1360            8 :                 .expect("request")
    1361            8 :         };
    1362            4 :         let entity = r#"{"id": "urn:ngsi-ld:Room:p", "type": "Room"}"#;
    1363            4 :         let made = tower::ServiceExt::oneshot(
    1364            4 :             crate::router(st.clone()),
    1365            4 :             req("POST", "/ngsi-ld/v1/entities", entity),
    1366            4 :         )
    1367            4 :         .await
    1368            4 :         .expect("response");
    1369            4 :         assert_eq!(made.status(), axum::http::StatusCode::CREATED);
    1370            4 :         let purged = tower::ServiceExt::oneshot(
    1371            4 :             crate::router(st.clone()),
    1372            4 :             req("DELETE", "/q/tenants/purgectx", ""),
    1373            4 :         )
    1374            4 :         .await
    1375            4 :         .expect("response");
    1376            4 :         assert_eq!(purged.status(), axum::http::StatusCode::NO_CONTENT);
    1377              :         // the purge released the loader's entry for the row while it still
    1378              :         // listed it; run after the store purge it listed nothing
    1379            4 :         assert!(st.loader.usage_get(&url).await.is_none());
    1380            4 :         assert_eq!(fetch_count(&fetches), 0);
    1381            4 :     }
    1382              : 
    1383              :     /// 5.13.3.5 counts uses of an @context this broker hosts on its own
    1384              :     /// stored row — no second, Cached copy of the same document, and no
    1385              :     /// fetch of its URL: the row holds the document (5.13.1 "Hosted").
    1386              :     #[tokio::test]
    1387            4 :     async fn a_hosted_context_is_counted_on_its_own_row() {
    1388            4 :         let st = AppState::new("me".into());
    1389            4 :         let owner = antares_model::TenantId::default();
    1390            4 :         let (url, fetches) = context_server("/ngsi-ld/v1/jsonldContexts/local-1");
    1391              :         // no `owner` member: a row written before it existed belongs to the
    1392              :         // default Tenant, which is the one resolving below
    1393            4 :         st.store
    1394            4 :             .context_put(
    1395            4 :                 Some(&owner),
    1396            4 :                 "local-1",
    1397            4 :                 serde_json::json!({
    1398            4 :                     "url": url,
    1399            4 :                     "localId": "local-1",
    1400            4 :                     "kind": "Hosted",
    1401            4 :                     "createdAt": now_iso(),
    1402            4 :                     "body": {"@context": {"peerTemp": "http://example.org/peerTemp"}},
    1403            4 :                 }),
    1404            4 :             )
    1405            4 :             .await
    1406            4 :             .expect("seed hosted row");
    1407            4 :         let user = serde_json::json!(url);
    1408            4 :         st.loader.resolve_for(&owner, &user).await.expect("resolve");
    1409            4 :         st.loader
    1410            4 :             .resolve_for(&owner, &user)
    1411            4 :             .await
    1412            4 :             .expect("resolve again");
    1413            4 :         let cached = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url.as_bytes()).to_string();
    1414            4 :         assert!(
    1415            4 :             st.store
    1416            4 :                 .context_get(None, &cached)
    1417            4 :                 .await
    1418            4 :                 .expect("store")
    1419            4 :                 .is_none(),
    1420              :             "a hosted @context must not be duplicated as a Cached row"
    1421              :         );
    1422            4 :         assert_eq!(
    1423            4 :             st.store
    1424            4 :                 .context_get(Some(&owner), "local-1")
    1425            4 :                 .await
    1426            4 :                 .expect("store")
    1427            4 :                 .expect("row")["numberOfHits"],
    1428            4 :             serde_json::json!(2)
    1429              :         );
    1430            4 :         assert_eq!(
    1431            4 :             fetch_count(&fetches),
    1432            4 :             0,
    1433            4 :             "a hosted @context is read from its row, never fetched over the network"
    1434            4 :         );
    1435            4 :     }
    1436              : 
    1437              :     /// 5.13.1: what this broker HOSTS comes from its own store, not from the
    1438              :     /// network. The in-process copy is a cache, and a miss — a restart (only
    1439              :     /// `Cached` rows are preloaded) or an eviction from the bounded document
    1440              :     /// cache — must not turn into an outbound GET of a URL a client chose:
    1441              :     /// the URL is minted from the request's `Host` header when
    1442              :     /// `ANTARES_PUBLIC_URL` is unset, so a spoofed one would send the broker
    1443              :     /// to the spoofer for the term mappings that expand that Tenant's
    1444              :     /// payloads. 5.5.10 still holds through the store: the row's owner is
    1445              :     /// the only Tenant it resolves for.
    1446              :     #[tokio::test]
    1447            4 :     async fn a_hosted_context_resolves_from_the_store_after_its_copy_is_gone() {
    1448            4 :         let st = AppState::new("me".into());
    1449            4 :         let alpha = antares_model::TenantId::new("alpha").expect("tenant");
    1450            4 :         let beta = antares_model::TenantId::new("beta").expect("tenant");
    1451              :         // a dead port: anything resolved can only have come from the row
    1452            4 :         let url = "http://127.0.0.1:9/ngsi-ld/v1/jsonldContexts/hosted-1";
    1453            4 :         st.store
    1454            4 :             .context_put(
    1455            4 :                 Some(&alpha),
    1456            4 :                 "hosted-1",
    1457            4 :                 serde_json::json!({
    1458            4 :                     "url": url,
    1459            4 :                     "localId": "hosted-1",
    1460            4 :                     "kind": "Hosted",
    1461            4 :                     "createdAt": now_iso(),
    1462            4 :                     "owner": "alpha",
    1463            4 :                     "body": {"@context": {"secret": "https://alpha.example/secret"}},
    1464            4 :                 }),
    1465            4 :             )
    1466            4 :             .await
    1467            4 :             .expect("seed hosted row");
    1468            4 :         let user = serde_json::json!(url);
    1469            4 :         let ctx = st
    1470            4 :             .loader
    1471            4 :             .resolve_for(&alpha, &user)
    1472            4 :             .await
    1473            4 :             .expect("the owning Tenant resolves its stored @context");
    1474            4 :         assert_eq!(ctx.expand_key("secret"), "https://alpha.example/secret");
    1475            4 :         let err = st
    1476            4 :             .loader
    1477            4 :             .resolve_for(&beta, &user)
    1478            4 :             .await
    1479            4 :             .expect_err("another Tenant may not resolve it (5.5.10)");
    1480            4 :         assert!(
    1481            4 :             matches!(err, antares_model::NgsiError::LdContextNotAvailable(_)),
    1482            4 :             "got {err:?}"
    1483            4 :         );
    1484            4 :     }
    1485              : 
    1486              :     /// 5.13.1 + 5.5.10: a stored @context with no `owner` member is not
    1487              :     /// ownerless. `contexts.rs` `row_visible` reads a row written before that
    1488              :     /// member existed as the DEFAULT Tenant's — it is listed, served and
    1489              :     /// deleted through that Tenant alone — so resolving one must answer the
    1490              :     /// same way. Read as "belongs to no Tenant" it would expand every other
    1491              :     /// Tenant's payloads with the default Tenant's private term mappings.
    1492              :     #[tokio::test]
    1493            4 :     async fn a_stored_context_without_an_owner_belongs_to_the_default_tenant() {
    1494            4 :         let st = AppState::new("me".into());
    1495            4 :         let default = antares_model::TenantId::new("default").expect("tenant");
    1496            4 :         let other = antares_model::TenantId::new("beta").expect("tenant");
    1497            4 :         let url = "http://127.0.0.1:9/ngsi-ld/v1/jsonldContexts/legacy-1";
    1498            4 :         st.store
    1499            4 :             .context_put(
    1500            4 :                 Some(&default),
    1501            4 :                 "legacy-1",
    1502            4 :                 serde_json::json!({
    1503            4 :                     "url": url,
    1504            4 :                     "localId": "legacy-1",
    1505            4 :                     "kind": "Hosted",
    1506            4 :                     "createdAt": now_iso(),
    1507            4 :                     "body": {"@context": {"legacy": "https://legacy.example/term"}},
    1508            4 :                 }),
    1509            4 :             )
    1510            4 :             .await
    1511            4 :             .expect("seed a row from before the owner member");
    1512            4 :         let user = serde_json::json!(url);
    1513            4 :         let ctx = st
    1514            4 :             .loader
    1515            4 :             .resolve_for(&default, &user)
    1516            4 :             .await
    1517            4 :             .expect("the Tenant the row belongs to resolves it");
    1518            4 :         assert_eq!(ctx.expand_key("legacy"), "https://legacy.example/term");
    1519            4 :         let err = st
    1520            4 :             .loader
    1521            4 :             .resolve_for(&other, &user)
    1522            4 :             .await
    1523            4 :             .expect_err("no other Tenant may resolve it (5.5.10)");
    1524            4 :         assert!(
    1525            4 :             matches!(err, antares_model::NgsiError::LdContextNotAvailable(_)),
    1526            4 :             "got {err:?}"
    1527            4 :         );
    1528            4 :     }
    1529              : 
    1530              :     /// 5.13.1 again, with the local id of a row that exists: what this broker
    1531              :     /// hosts is the row MINTED under that URL, so a document served from
    1532              :     /// somewhere else under the same resource path — with the local id of a
    1533              :     /// Hosted @context another Tenant added — is external. It becomes a
    1534              :     /// Cached row of its own, and the Tenant's row is neither read for its
    1535              :     /// mappings nor counted against.
    1536              :     #[tokio::test]
    1537            4 :     async fn a_peer_url_reusing_a_stored_local_id_leaves_that_row_alone() {
    1538            4 :         let st = AppState::new("me".into());
    1539            4 :         let (url, _) = context_server("/ngsi-ld/v1/jsonldContexts/alpha-1");
    1540            4 :         let row = serde_json::json!({
    1541            4 :             "url": "https://broker.example/ngsi-ld/v1/jsonldContexts/alpha-1",
    1542            4 :             "localId": "alpha-1",
    1543            4 :             "kind": "Hosted",
    1544            4 :             "createdAt": now_iso(),
    1545            4 :             "owner": "alpha",
    1546            4 :             "body": {"@context": {"a": "http://example.org/a"}},
    1547              :         });
    1548            4 :         let alpha = antares_model::TenantId::new("alpha").expect("tenant");
    1549            4 :         st.store
    1550            4 :             .context_put(Some(&alpha), "alpha-1", row.clone())
    1551            4 :             .await
    1552            4 :             .expect("seed hosted row");
    1553            4 :         st.loader
    1554            4 :             .resolve_for(&antares_model::TenantId::default(), &serde_json::json!(url))
    1555            4 :             .await
    1556            4 :             .expect("resolve");
    1557            4 :         assert_eq!(
    1558            4 :             st.store
    1559            4 :                 .context_get(Some(&alpha), "alpha-1")
    1560            4 :                 .await
    1561            4 :                 .expect("store")
    1562            4 :                 .expect("row"),
    1563              :             row,
    1564              :             "another Tenant's row must be untouched by a peer URL"
    1565              :         );
    1566            4 :         let cached = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url.as_bytes()).to_string();
    1567            4 :         assert_eq!(
    1568            4 :             st.store
    1569            4 :                 .context_get(None, &cached)
    1570            4 :                 .await
    1571            4 :                 .expect("store")
    1572            4 :                 .expect("row")["kind"],
    1573            4 :             serde_json::json!("Cached"),
    1574            4 :             "the fetched document is this broker's Cached copy (5.13.1)"
    1575            4 :         );
    1576            4 :     }
    1577              : }
    1578              : 
    1579              : #[cfg(all(test, feature = "test-kit"))]
    1580              : mod clone_cost {
    1581              :     use super::*;
    1582              : 
    1583              :     /// every middleware layer and extractor of a request clones the
    1584              :     /// state; a clone copies no String, no client and no handle.
    1585              :     #[test]
    1586            4 :     fn a_clone_shares_the_per_process_part() {
    1587            4 :         let a = AppState::new("antares-test".into());
    1588            4 :         let b = a.clone();
    1589            4 :         assert!(std::ptr::eq(&a.http, &b.http), "the HTTP client is copied");
    1590            4 :         assert!(std::ptr::eq(&a.fed_http, &b.fed_http));
    1591            4 :         assert!(std::ptr::eq(a.public_url.as_ptr(), b.public_url.as_ptr()));
    1592            4 :         assert!(std::ptr::eq(a.store_name.as_ptr(), b.store_name.as_ptr()));
    1593            4 :     }
    1594              : 
    1595              :     /// A setting written before serving is the writer's own: a state cloned
    1596              :     /// earlier keeps what it had, as it did when every field was copied.
    1597              :     #[test]
    1598            4 :     fn a_write_after_a_clone_is_not_seen_by_the_clone() {
    1599            4 :         let a = AppState::new("antares-test".into());
    1600            4 :         let mut c = a.clone();
    1601            4 :         c.max_limit = 7;
    1602            4 :         assert_eq!((a.max_limit, c.max_limit), (1000, 7));
    1603            4 :         assert!(!std::ptr::eq(&a.http, &c.http));
    1604            4 :     }
    1605              : 
    1606              :     /// a setting written after an in-process call reaches the
    1607              :     /// next call; the memoised router of the clone was built over the old
    1608              :     /// settings and answered `limit=5` as if `max_limit` were still 1000.
    1609              :     #[tokio::test]
    1610            4 :     async fn a_write_after_a_call_reaches_the_next_call() {
    1611            8 :         let call = |st: &AppState| {
    1612            8 :             let req = axum::http::Request::get("/ngsi-ld/v1/entities?type=T&limit=5")
    1613            8 :                 .body(axum::body::Body::empty())
    1614            8 :                 .expect("request");
    1615            8 :             let st = st.clone();
    1616            8 :             async move { st.call(&axum::http::HeaderMap::new(), req).await.status() }
    1617            8 :         };
    1618            4 :         let a = AppState::new("antares-test".into());
    1619            4 :         assert_eq!(call(&a).await, axum::http::StatusCode::OK);
    1620            4 :         let mut c = a.clone();
    1621            4 :         c.max_limit = 1;
    1622            4 :         assert_eq!(call(&c).await, axum::http::StatusCode::FORBIDDEN);
    1623            4 :     }
    1624              : }
        

Generated by: LCOV version 2.0-1