{- | The composition-root wiring: turn a validated 'Config' and the
process-global credential providers into the served 'MountBinding's, failing fast
and __aggregated__ on any boot problem.

This is the __listener-free__ heart of the composition root ("Ecluse" calls it): it
holds no sockets, no network, and no real clock of its own -- the clock and the
ecosystem-to-adapter resolver are injected -- so the boot-time validation is
unit-tested without opening a listener. Its one effect is preparing each mount's rule
set ('Ecluse.Core.Rules.prepare'), which allocates per-rule engine state once at boot
(a breaker for a resilient rule; the built-in rules need none today), so binding
assembly is 'IO'; everything else stays a pure function of the validated config.

== Global providers, per-mount reference

A 'Ecluse.Core.Credential.CredentialProvider' is the service's own cloud identity,
built __once__ from the environment layer ('initCredentialProviders') and held
process-global; a mount does not carry a provider, it only __names__ which backend
it draws on (its @mtCredential@). The boot-time check is the resolution of that
reference: every distinct credential backend named across all mounts must resolve
to an __initialised__ provider, or the app halts at boot (see
@docs\/architecture\/cloud-backends.md@ → "Credential Provider"). Only the
@static@ backend has a leaf (from @ECLUSE_MIRROR_TARGET_TOKEN@); a mount naming
@codeartifact@ or @adc@ resolves to no provider and is an honest boot failure.

== Fail-fast at boot

Three boot failures are aggregated into one report so a single run shows every
problem: a rule policy that does not resolve ('PolicyBootError', surfaced by
'Ecluse.Config.loadConfig'), a configured mount whose ecosystem has no adapter
wired ('MissingAdapter'), and a mount naming a credential backend with no
initialised provider ('UnresolvedCredential'). A bad configuration is thus a
loud, immediate startup failure, never a quietly mis-enforced or half-wired state
(see @docs\/architecture\/configuration.md@ → "Validation").
-}
module Ecluse.Composition (
    -- * Global credential providers
    CredentialProviders,
    initCredentialProviders,
    initializedEcosystems,
    lookupProvider,

    -- * Mirror-target credential provider selection
    planMirrorCredential,
    resolveCodeArtifactConfig,

    -- * Boot-time wiring
    BootError (..),
    renderBootError,
    planMounts,
    composeBindings,

    -- * Mirror-queue backend selection
    MirrorQueuePlan (..),
    planMirrorQueue,
    mirrorQueuePlanWarning,
    memoryQueueBootWarning,
    memoryQueueDropWarning,
    parseEndpointUrl,

    -- * Publish-side wiring
    PublishTarget (..),
    planPublishTargets,

    -- * Config-derived runtime settings
    cacheConfigFor,
    connectionPoolSettings,
    resolveServeAdmission,
    resolvePrivateConnections,
    resolvePublicConnections,
    openFileSoftLimit,
    mirrorEnqueueBufferDepth,
    mirrorEnqueueReportInterval,

    -- * Internals exported for testing
    parseCodeArtifactHost,
) where

import Data.Char (isDigit)
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text qualified as T
import Data.Time (UTCTime)
import Network.HTTP.Client (ManagerSettings (managerConnCount))
import System.Posix.Resource (Resource (ResourceOpenFiles), ResourceLimit (ResourceLimit, ResourceLimitInfinity, ResourceLimitUnknown), ResourceLimits (softLimit), getResourceLimit)
import UnliftIO (tryAny)

import Ecluse.Config (
    AppConfig (..),
    Config (..),
    CredentialBackend (..),
    MirrorTarget (mtCredential, mtUrl),
    Mount (..),
    MountConfig (..),
    MountRegistries (..),
    PolicyError,
    QueueBackend (..),
    Url,
    renderPolicyError,
    unUrl,
 )
import Ecluse.Core.Credential (AuthToken (..), CredentialProvider, Secret, staticProvider)
import Ecluse.Core.Credential.CodeArtifact (CodeArtifactConfig (..), newCodeArtifactProvider)
import Ecluse.Core.Credential.Refresh (CredentialReporters)
import Ecluse.Core.Ecosystem (Ecosystem, ecosystemName, prefixFor)
import Ecluse.Core.Queue (MemoryQueueConfig, defaultMemoryQueueConfig)
import Ecluse.Core.Queue.Sqs (SqsConfig (sqsEndpoint), SqsEndpoint (..), defaultSqsConfig)
import Ecluse.Core.Registry.Npm qualified as Npm
import Ecluse.Core.Registry.Npm.Filter qualified as NpmFilter
import Ecluse.Core.Registry.Npm.Project qualified as NpmProject
import Ecluse.Core.Registry.Npm.Request qualified as NpmRequest
import Ecluse.Core.Wire (renderWire)

import Ecluse.Core.Rules (RuleDeps, prepare)
import Ecluse.Core.Security (Limits (Limits, maxBodyBytes, maxNestingDepth, maxVersionCount), TarballHostPolicy (AnyAllowlistedHost, SameHostAsPackument), hostAddress, splitHostPort, tarballHostGate)
import Ecluse.Core.Security.Egress (registryUrlText)
import Ecluse.Core.Server.Cache (CacheConfig (..))
import Ecluse.Core.Server.Context (MountBinding, PackumentDeps (..), PublishDeps (..))
import Ecluse.Core.Server.Metadata qualified as Metadata
import Ecluse.Core.Server.Response (HelpMessage, mkHelpMessage)
import Ecluse.Core.Text (nonBlank)

{- | Apply an explicit per-host connection bound to an HTTP manager's settings.

The public and private managers call this independently after telemetry
instrumentation, so changing the pool size cannot discard the instrumented request and
response hooks.
-}
connectionPoolSettings :: Int -> ManagerSettings -> ManagerSettings
connectionPoolSettings :: Int -> ManagerSettings -> ManagerSettings
connectionPoolSettings Int
connections ManagerSettings
settings = ManagerSettings
settings{managerConnCount = connections}

{- | The effective serve-admission capacity and its boot-log line: the explicit
@serveMaxInFlight@ when configured, else __computed from the resolved capability
count__ -- @max 8 (10 x capabilities)@.

The multiplier is empirical, not modelled. The saturation model (an admitted
metadata materialisation alternates upstream wait @W@ and CPU work @P@, so
keeping @C@ capabilities busy wants about @C x (W + P) \/ P@ in flight)
suggested ~4 per capability at a round-trip @W\/P@ of 2-3, but the load bench's
measured dose-response kept climbing well past that and levelled only near 10
per capability: a slot is held across every upstream leg plus GC pauses and
scheduling delay, so the effective @W\/P@ is nearer 9-10. The floor keeps a tiny
pod admitting a useful burst should the multiplier ever drop below it. The
capability count must be the __post-runtime-posture__ one (see "Ecluse.Runtime"),
so callers resolve this after 'Ecluse.Runtime.applyRuntimePosture' has run.

The returned line carries the decision's provenance for the standard boot log,
alongside the runtime posture lines. This bounds only __metadata materialisation__
(whole packument requests and a tarball miss's public-metadata gate). The __private__
connection pool is __not__ sized from it -- see 'resolvePrivateConnections': a trusted
tarball hit __streams outside admission__, so demand on the private pool is the inbound
hit concurrency, not the admission capacity, and tying the two would undersize that pool
under a private-hit fan-out (http-client opens throwaway connections beyond the pool,
paying a TLS handshake per overflow request).
-}
resolveServeAdmission :: Maybe Int -> Int -> (Int, Text)
resolveServeAdmission :: Maybe Int -> Int -> (Int, Text)
resolveServeAdmission Maybe Int
explicit Int
capabilities = case Maybe Int
explicit of
    Just Int
n -> (Int
n, Text
"runtime: serve admission " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Int -> Text
forall b a. (Show a, IsString b) => a -> b
show Int
n Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
" (from config)")
    Maybe Int
Nothing ->
        let computed :: Int
computed = Int -> Int -> Int
forall a. Ord a => a -> a -> a
max Int
serveAdmissionFloor (Int
serveAdmissionPerCapability Int -> Int -> Int
forall a. Num a => a -> a -> a
* Int
capabilities)
         in (Int
computed, Text
"runtime: serve admission " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Int -> Text
forall b a. (Show a, IsString b) => a -> b
show Int
computed Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
" (computed from " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Int -> Text
forall b a. (Show a, IsString b) => a -> b
show Int
capabilities Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
" capabilities)")

-- The computed-admission constants: empirically ~10 per capability (see
-- 'resolveServeAdmission'), and a floor so a tiny pod still admits a useful
-- burst if the multiplier ever drops below it.
serveAdmissionPerCapability :: Int
serveAdmissionPerCapability :: Int
serveAdmissionPerCapability = Int
10

serveAdmissionFloor :: Int
serveAdmissionFloor :: Int
serveAdmissionFloor = Int
8

{- | The effective private-upstream connection-pool size and its boot-log line: the
explicit @privateConnectionsPerHost@ when configured, else __computed from the process
file-descriptor limit__ -- @clamp 64 4096 (nofile \/ 4)@.

The private pool caches idle connections to the trusted upstream for __reuse across
concurrent private-hit tarball streams__. Those streams are __IO-bound__ and, unlike
metadata materialisation, stream __outside serve admission__, so their concurrency (and
thus the pool's real demand) is the inbound hit fan-out, not the CPU-saturation model
'resolveServeAdmission' uses -- which is exactly why this is computed from a different
datapoint and is __not__ tied to @serveMaxInFlight@ (see issue #634's incomplete
inference: the private pool also serves the un-admitted streaming path).

Each pooled connection is one file descriptor, so the file-descriptor limit is the pool's
real physical ceiling. The default takes a __quarter of the soft @RLIMIT_NOFILE@__ as the
reuse cache, floored at 'privateConnectionsFloor' so a small-limit host still reuses
connections across an install fan-out, and capped at 'privateConnectionsCap' so an
enormous-limit host does not retain an absurd idle cache to a single upstream. A larger
pool never opens more sockets than the concurrency already demands (http-client opens a
connection per in-flight request regardless); it only decides how many to __retain for
reuse__ rather than re-handshake, so sizing up is safe. An operator who knows their
fan-out can override it outright.

The returned line carries the decision's provenance for the standard boot log.
-}
resolvePrivateConnections :: Maybe Int -> Int -> (Int, Text)
resolvePrivateConnections :: Maybe Int -> Int -> (Int, Text)
resolvePrivateConnections Maybe Int
explicit Int
fdLimit = case Maybe Int
explicit of
    Just Int
n -> (Int
n, Text
"runtime: private connection pool " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Int -> Text
forall b a. (Show a, IsString b) => a -> b
show Int
n Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
" (from config)")
    Maybe Int
Nothing ->
        let computed :: Int
computed = Int -> Int
clampPrivateConnections (Int
fdLimit Int -> Int -> Int
forall a. Integral a => a -> a -> a
`div` Int
privateConnectionsFdShare)
         in (Int
computed, Text
"runtime: private connection pool " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Int -> Text
forall b a. (Show a, IsString b) => a -> b
show Int
computed Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
" (computed from file-descriptor limit " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Int -> Text
forall b a. (Show a, IsString b) => a -> b
show Int
fdLimit Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
")")

-- Clamp a computed private-pool size into the sane band: a floor so a small
-- file-descriptor limit still reuses a useful number of connections, and a cap so an
-- enormous limit does not retain an absurd idle cache to one upstream.
clampPrivateConnections :: Int -> Int
clampPrivateConnections :: Int -> Int
clampPrivateConnections = Int -> Int -> Int
forall a. Ord a => a -> a -> a
max Int
privateConnectionsFloor (Int -> Int) -> (Int -> Int) -> Int -> Int
forall b c a. (b -> c) -> (a -> b) -> a -> c
. Int -> Int -> Int
forall a. Ord a => a -> a -> a
min Int
privateConnectionsCap

-- The private pool takes a quarter of the file-descriptor budget as its reuse cache
-- (each pooled connection is one descriptor); the other three quarters stay for the
-- listener, the public pool, telemetry, the worker, and the runtime.
privateConnectionsFdShare :: Int
privateConnectionsFdShare :: Int
privateConnectionsFdShare = Int
4

privateConnectionsFloor :: Int
privateConnectionsFloor :: Int
privateConnectionsFloor = Int
64

privateConnectionsCap :: Int
privateConnectionsCap :: Int
privateConnectionsCap = Int
4096

{- | The effective public-upstream connection-pool size and its boot-log line: the
explicit @publicConnectionsPerHost@ when configured, else __computed from the process
file-descriptor limit__ -- @clamp 32 1024 (nofile \/ 8)@.

The public pool's metadata demand is small by construction (same-key misses are
single-flight-coalesced and bounded by admission), but the pool is __not__
metadata-only: the onboarding fail-over's artifact streams and the mirror worker's
back-fill fetches ride the same manager, and neither coalesces. During a cold fleet's
onboarding burst the concurrent public streams track the inbound fan-out, and
'Network.HTTP.Client.managerConnCount' is a keep-alive __retention__ cap, not a
concurrency cap: overflow opens throwaway connections, each paying a TLS handshake to
the public origin per request. So the pool is sized like the private one, from the
file-descriptor budget, at __half the private share__ (an eighth of @nofile@, from the
three quarters the private sizing reserves for everything else): the public leg is
transient by the traffic model -- the worker retires it artifact by artifact -- so it
earns retention for the burst, not the steady state. Sizing up is safe for the same
reason as the private pool: it never opens more sockets than the concurrency already
demands, only retains more for reuse.

The returned line carries the decision's provenance for the standard boot log.
-}
resolvePublicConnections :: Maybe Int -> Int -> (Int, Text)
resolvePublicConnections :: Maybe Int -> Int -> (Int, Text)
resolvePublicConnections Maybe Int
explicit Int
fdLimit = case Maybe Int
explicit of
    Just Int
n -> (Int
n, Text
"runtime: public connection pool " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Int -> Text
forall b a. (Show a, IsString b) => a -> b
show Int
n Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
" (from config)")
    Maybe Int
Nothing ->
        let computed :: Int
computed = Int -> Int
clampPublicConnections (Int
fdLimit Int -> Int -> Int
forall a. Integral a => a -> a -> a
`div` Int
publicConnectionsFdShare)
         in (Int
computed, Text
"runtime: public connection pool " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Int -> Text
forall b a. (Show a, IsString b) => a -> b
show Int
computed Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
" (computed from file-descriptor limit " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Int -> Text
forall b a. (Show a, IsString b) => a -> b
show Int
fdLimit Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
")")

-- Clamp a computed public-pool size into its sane band, for the same reasons as
-- 'clampPrivateConnections': a floor so a small limit still reuses connections
-- across an onboarding burst, a cap so an enormous limit does not retain an absurd
-- idle cache to one public origin.
clampPublicConnections :: Int -> Int
clampPublicConnections :: Int -> Int
clampPublicConnections = Int -> Int -> Int
forall a. Ord a => a -> a -> a
max Int
publicConnectionsFloor (Int -> Int) -> (Int -> Int) -> Int -> Int
forall b c a. (b -> c) -> (a -> b) -> a -> c
. Int -> Int -> Int
forall a. Ord a => a -> a -> a
min Int
publicConnectionsCap

-- The public pool takes an eighth of the file-descriptor budget: half the private
-- share, because the public leg is the transient onboarding ramp rather than the
-- steady-state workhorse, drawn from the reserve the private sizing leaves.
publicConnectionsFdShare :: Int
publicConnectionsFdShare :: Int
publicConnectionsFdShare = Int
8

publicConnectionsFloor :: Int
publicConnectionsFloor :: Int
publicConnectionsFloor = Int
32

publicConnectionsCap :: Int
publicConnectionsCap :: Int
publicConnectionsCap = Int
1024

{- | The depth of the producer-side hand-off buffer the composition root wraps in
front of the mirror queue ('Ecluse.Core.Queue.newEnqueueBuffer'). Sized to absorb a
cold @npm ci@'s enqueue burst (a lockfile fan-out enqueues one job per public-served
tarball) while bounding memory; a job dropped at the cap is re-enqueued on the next
demand for its artifact, so overflow costs a deferred mirror, never correctness.
-}
mirrorEnqueueBufferDepth :: Int
mirrorEnqueueBufferDepth :: Int
mirrorEnqueueBufferDepth = Int
1024

{- | How many enqueue-buffer drops or delivery failures pass between warning-log
reports at the composition root (the first is always reported, then every multiple
of this). The buffer's callbacks fire per event so the failure counter stays exact,
while a sustained flood logs one line per this many events rather than one per job.
-}
mirrorEnqueueReportInterval :: Int
mirrorEnqueueReportInterval :: Int
mirrorEnqueueReportInterval = Int
100

{- | The process soft file-descriptor limit (@RLIMIT_NOFILE@), the datapoint
'resolvePrivateConnections' sizes the private pool against. An __infinite__ or
__unknown__ limit falls back to @privateConnectionsCap x privateConnectionsFdShare@, so
the computed pool lands at the cap rather than overflowing.
-}
openFileSoftLimit :: IO Int
openFileSoftLimit :: IO Int
openFileSoftLimit = do
    limits <- Resource -> IO ResourceLimits
getResourceLimit Resource
ResourceOpenFiles
    pure $ case softLimit limits of
        ResourceLimit Integer
n -> Integer -> Int
forall a. Num a => Integer -> a
fromInteger Integer
n
        ResourceLimit
ResourceLimitInfinity -> Int
privateConnectionsCap Int -> Int -> Int
forall a. Num a => a -> a -> a
* Int
privateConnectionsFdShare
        ResourceLimit
ResourceLimitUnknown -> Int
privateConnectionsCap Int -> Int -> Int
forall a. Num a => a -> a -> a
* Int
privateConnectionsFdShare

{- | The process-global credential providers, keyed by the backend they
implement. Built __once__ at the composition root from the environment layer; a
mount references one by name and never holds its own.

The keyset (see 'initializedBackends') is the boot-check's pure surface -- a mount
that names a backend absent from it has an unresolved credential reference.
-}
newtype CredentialProviders = CredentialProviders (Map Ecosystem CredentialProvider)

{- | Build the global credential providers from the environment layer, or the
aggregated boot errors that block them. The mirror-target write provider is
selected by 'cfgCredentialProvider' (see 'planMirrorCredential'):

* @static@ -- built from @ECLUSE_MIRROR_TARGET_TOKEN@ ('cfgMirrorTargetToken') when set;
  absent, no static provider is initialised, so a mount naming @static@ fails the
  boot-time credential-reference check.
* @codeartifact@ -- the CodeArtifact inputs are resolved
  ('resolveCodeArtifactConfig'); a required input that resolves by neither an
  explicit key nor the mirror-target host is a fail-loud boot error. On success the
  generic refresh\/cache wrapper around the CodeArtifact mint leaf
  ('newCodeArtifactProvider') is built, which mints once eagerly -- so a misconfigured
  identity fails here at boot. AWS credentials are the ambient container\/task role
  (the standard chain), never an Écluse key. A mint that throws at boot (a transient
  AWS error, or a permanent one like a bad domain\/region or missing permission) is
  caught and rendered as a 'CodeArtifactMintFailed' boot error rather than escaping as
  a raw exception, so it joins the aggregated failure block.
* @gcp-artifact-registry@ -- recognised but not built in this binary, so selecting
  it is a fail-loud boot error rather than a silent fall-through.

The @static@ provider is also built whenever @ECLUSE_MIRROR_TARGET_TOKEN@ is present,
independent of the selector, so a static token never goes unused.

The 'CredentialReporters' are handed to the refreshing CodeArtifact provider so its
mint breaker and refresh outcomes record to telemetry; the static provider never
refreshes, so they do not concern it. The composition root supplies the deferred
reporters that go live once the telemetry substrate exists.
-}
initCredentialProviders :: CredentialReporters -> AppConfig -> IO (Either [BootError] CredentialProviders)
initCredentialProviders :: CredentialReporters
-> AppConfig -> IO (Either [BootError] CredentialProviders)
initCredentialProviders CredentialReporters
reporters AppConfig
app = do
    let plans :: [(Ecosystem, MountConfig,
  Either [BootError] (Maybe CodeArtifactConfig))]
plans = ((Ecosystem, MountConfig)
 -> (Ecosystem, MountConfig,
     Either [BootError] (Maybe CodeArtifactConfig)))
-> [(Ecosystem, MountConfig)]
-> [(Ecosystem, MountConfig,
     Either [BootError] (Maybe CodeArtifactConfig))]
forall a b. (a -> b) -> [a] -> [b]
map (\(Ecosystem
eco, MountConfig
mcfg) -> (Ecosystem
eco, MountConfig
mcfg, Ecosystem
-> AppConfig
-> MountConfig
-> Either [BootError] (Maybe CodeArtifactConfig)
planMirrorCredential Ecosystem
eco AppConfig
app MountConfig
mcfg)) (Map Ecosystem MountConfig -> [(Ecosystem, MountConfig)]
forall k a. Map k a -> [(k, a)]
Map.toList (AppConfig -> Map Ecosystem MountConfig
cfgMounts AppConfig
app))
    let errs :: [BootError]
errs = [[BootError]] -> [BootError]
forall (t :: * -> *) a. Foldable t => t [a] -> [a]
concat [[BootError]
e | (Ecosystem
_, MountConfig
_, Left [BootError]
e) <- [(Ecosystem, MountConfig,
  Either [BootError] (Maybe CodeArtifactConfig))]
plans]
    if Bool -> Bool
not ([BootError] -> Bool
forall a. [a] -> Bool
forall (t :: * -> *) a. Foldable t => t a -> Bool
null [BootError]
errs)
        then Either [BootError] CredentialProviders
-> IO (Either [BootError] CredentialProviders)
forall a. a -> IO a
forall (f :: * -> *) a. Applicative f => a -> f a
pure ([BootError] -> Either [BootError] CredentialProviders
forall a b. a -> Either a b
Left [BootError]
errs)
        else do
            let validPlans :: [(Ecosystem, MountConfig, Maybe CodeArtifactConfig)]
validPlans = [(Ecosystem
eco, MountConfig
mcfg, Maybe CodeArtifactConfig
mca) | (Ecosystem
eco, MountConfig
mcfg, Right Maybe CodeArtifactConfig
mca) <- [(Ecosystem, MountConfig,
  Either [BootError] (Maybe CodeArtifactConfig))]
plans]
            results <- ((Ecosystem, MountConfig, Maybe CodeArtifactConfig)
 -> IO (Either [BootError] (Ecosystem, Maybe CredentialProvider)))
-> [(Ecosystem, MountConfig, Maybe CodeArtifactConfig)]
-> IO [Either [BootError] (Ecosystem, Maybe CredentialProvider)]
forall (t :: * -> *) (f :: * -> *) a b.
(Traversable t, Applicative f) =>
(a -> f b) -> t a -> f (t b)
forall (f :: * -> *) a b.
Applicative f =>
(a -> f b) -> [a] -> f [b]
traverse (\(Ecosystem
eco, MountConfig
mcfg, Maybe CodeArtifactConfig
mca) -> CredentialReporters
-> Ecosystem
-> MountConfig
-> Maybe CodeArtifactConfig
-> IO (Either [BootError] (Ecosystem, Maybe CredentialProvider))
initProviderFor CredentialReporters
reporters Ecosystem
eco MountConfig
mcfg Maybe CodeArtifactConfig
mca) [(Ecosystem, MountConfig, Maybe CodeArtifactConfig)]
validPlans
            let (initErrs, valid) = partitionEithers results
            if not (null initErrs)
                then pure (Left (concat initErrs))
                else pure (Right (CredentialProviders (Map.fromList [(eco, p) | (eco, Just p) <- valid])))

-- One mount plan's provider build, the effectful half of 'initCredentialProviders':
-- no CodeArtifact selection means the static token provider when its token is set
-- (else no provider, the unresolved-reference case the boot check rejects); a
-- CodeArtifact selection mints once eagerly, a throw rendered as a
-- 'CodeArtifactMintFailed' boot error so it joins the aggregated failure block.
initProviderFor :: CredentialReporters -> Ecosystem -> MountConfig -> Maybe CodeArtifactConfig -> IO (Either [BootError] (Ecosystem, Maybe CredentialProvider))
initProviderFor :: CredentialReporters
-> Ecosystem
-> MountConfig
-> Maybe CodeArtifactConfig
-> IO (Either [BootError] (Ecosystem, Maybe CredentialProvider))
initProviderFor CredentialReporters
reporters Ecosystem
eco MountConfig
mcfg = \case
    Maybe CodeArtifactConfig
Nothing -> Either [BootError] (Ecosystem, Maybe CredentialProvider)
-> IO (Either [BootError] (Ecosystem, Maybe CredentialProvider))
forall a. a -> IO a
forall (f :: * -> *) a. Applicative f => a -> f a
pure ((Ecosystem, Maybe CredentialProvider)
-> Either [BootError] (Ecosystem, Maybe CredentialProvider)
forall a b. b -> Either a b
Right (Ecosystem
eco, MountConfig -> Maybe CredentialProvider
staticTokenProvider MountConfig
mcfg))
    Just CodeArtifactConfig
caConfig ->
        IO CredentialProvider
-> IO (Either SomeException CredentialProvider)
forall (m :: * -> *) a.
MonadUnliftIO m =>
m a -> m (Either SomeException a)
tryAny (CredentialReporters -> CodeArtifactConfig -> IO CredentialProvider
newCodeArtifactProvider CredentialReporters
reporters CodeArtifactConfig
caConfig) IO (Either SomeException CredentialProvider)
-> (Either SomeException CredentialProvider
    -> Either [BootError] (Ecosystem, Maybe CredentialProvider))
-> IO (Either [BootError] (Ecosystem, Maybe CredentialProvider))
forall (f :: * -> *) a b. Functor f => f a -> (a -> b) -> f b
<&> \case
            Left SomeException
err -> [BootError]
-> Either [BootError] (Ecosystem, Maybe CredentialProvider)
forall a b. a -> Either a b
Left [Text -> BootError
CodeArtifactMintFailed (String -> Text
forall a. ToText a => a -> Text
toText (SomeException -> String
forall e. Exception e => e -> String
displayException SomeException
err))]
            Right CredentialProvider
provider -> (Ecosystem, Maybe CredentialProvider)
-> Either [BootError] (Ecosystem, Maybe CredentialProvider)
forall a b. b -> Either a b
Right (Ecosystem
eco, CredentialProvider -> Maybe CredentialProvider
forall a. a -> Maybe a
Just CredentialProvider
provider)

-- The static mirror-target write provider, when its token
-- (ECLUSE_MIRROR_TARGET_TOKEN) is configured.
staticTokenProvider :: MountConfig -> Maybe CredentialProvider
staticTokenProvider :: MountConfig -> Maybe CredentialProvider
staticTokenProvider MountConfig
mcfg =
    MountConfig -> Maybe Secret
mntMirrorTargetToken MountConfig
mcfg Maybe Secret
-> (Secret -> CredentialProvider) -> Maybe CredentialProvider
forall (f :: * -> *) a b. Functor f => f a -> (a -> b) -> f b
<&> \Secret
token ->
        AuthToken -> CredentialProvider
staticProvider AuthToken{authSecret :: Secret
authSecret = Secret
token, authExpiresAt :: Maybe UTCTime
authExpiresAt = Maybe UTCTime
forall a. Maybe a
Nothing}

{- | The set of ecosystems that resolved to an initialised provider -- the
pure surface the boot-time credential-reference check reasons over.
-}
initializedEcosystems :: CredentialProviders -> Set Ecosystem
initializedEcosystems :: CredentialProviders -> Set Ecosystem
initializedEcosystems (CredentialProviders Map Ecosystem CredentialProvider
ps) = Map Ecosystem CredentialProvider -> Set Ecosystem
forall k a. Map k a -> Set k
Map.keysSet Map Ecosystem CredentialProvider
ps

{- | Look up the initialised provider for an ecosystem, 'Nothing' when none is
initialised (the unresolved-reference case the boot check rejects).
-}
lookupProvider :: Ecosystem -> CredentialProviders -> Maybe CredentialProvider
lookupProvider :: Ecosystem -> CredentialProviders -> Maybe CredentialProvider
lookupProvider Ecosystem
eco (CredentialProviders Map Ecosystem CredentialProvider
ps) = Ecosystem
-> Map Ecosystem CredentialProvider -> Maybe CredentialProvider
forall k a. Ord k => k -> Map k a -> Maybe a
Map.lookup Ecosystem
eco Map Ecosystem CredentialProvider
ps

{- | Decide what mirror-target write provider the environment layer selects, as the
pure half of 'initCredentialProviders': 'Nothing' when the @static@ provider is
selected (its leaf is the @ECLUSE_MIRROR_TARGET_TOKEN@ already handled there), 'Just' a
resolved 'CodeArtifactConfig' when @codeartifact@ is selected, or the aggregated
boot errors that block the selection.

The @gcp-artifact-registry@ arm is recognised but not built in this binary, so it is
a fail-loud 'MirrorCredentialProviderUnavailable' boot error -- never a silent
fall-through to a different provider, mirroring how 'planMirrorQueue' treats the GCP
queue arm.
-}
planMirrorCredential :: Ecosystem -> AppConfig -> MountConfig -> Either [BootError] (Maybe CodeArtifactConfig)
planMirrorCredential :: Ecosystem
-> AppConfig
-> MountConfig
-> Either [BootError] (Maybe CodeArtifactConfig)
planMirrorCredential Ecosystem
eco AppConfig
app MountConfig
mcfg = case MountConfig -> CredentialBackend
mntCredentialProvider MountConfig
mcfg of
    CredentialBackend
StaticCredential -> Maybe CodeArtifactConfig
-> Either [BootError] (Maybe CodeArtifactConfig)
forall a b. b -> Either a b
Right Maybe CodeArtifactConfig
forall a. Maybe a
Nothing
    CredentialBackend
CodeArtifactCredential -> CodeArtifactConfig -> Maybe CodeArtifactConfig
forall a. a -> Maybe a
Just (CodeArtifactConfig -> Maybe CodeArtifactConfig)
-> Either [BootError] CodeArtifactConfig
-> Either [BootError] (Maybe CodeArtifactConfig)
forall (f :: * -> *) a b. Functor f => (a -> b) -> f a -> f b
<$> Ecosystem
-> AppConfig
-> MountConfig
-> Either [BootError] CodeArtifactConfig
resolveCodeArtifactConfig Ecosystem
eco AppConfig
app MountConfig
mcfg
    CredentialBackend
AdcCredential -> [BootError] -> Either [BootError] (Maybe CodeArtifactConfig)
forall a b. a -> Either a b
Left [CredentialBackend -> BootError
MirrorCredentialProviderUnavailable CredentialBackend
AdcCredential]

{- | Resolve the CodeArtifact inputs for the mirror-target token, or the aggregated
boot errors naming each input that could not be resolved.

Each required input is resolved __(a) from its explicit @MIRROR_TARGET_CODEARTIFACT_*@
key, else (b) by parsing the mirror-target URL host__ of the form
@{domain}-{owner}.d.codeartifact.{region}.amazonaws.com@ (the documented host
fallback). The region resolves explicit key → host → @AWS_REGION@: the endpoint host
encodes the domain's authoritative region, so it outranks the process-wide
@AWS_REGION@ (a cross-region deploy mints against the domain's region, not the
caller's). The mirror-target URL is the resolved one -- an unset @ECLUSE_MIRROR_TARGET@
has already folded onto the private upstream -- so a private-upstream CodeArtifact
endpoint is parsed too. The optional token-duration carries through
('cfgMirrorCodeArtifactTokenDuration').

The @{owner}@ is a 12-digit AWS account id: a resolved owner (from either source)
that is not 12 digits is a fail-loud 'CodeArtifactConfigInvalid' error, and a host
whose tail after the last hyphen is not an account id is not a CodeArtifact endpoint
at all (so it falls through to the named-key check). If a required input resolves by
__neither__ source, that is a fail-loud 'CodeArtifactConfigMissing' boot error naming
the exact key the operator must set, aggregated so one run reports every problem.
-}
resolveCodeArtifactConfig :: Ecosystem -> AppConfig -> MountConfig -> Either [BootError] CodeArtifactConfig
resolveCodeArtifactConfig :: Ecosystem
-> AppConfig
-> MountConfig
-> Either [BootError] CodeArtifactConfig
resolveCodeArtifactConfig Ecosystem
eco AppConfig
app MountConfig
mcfg =
    case [Either BootError Text] -> ([BootError], [Text])
forall a b. [Either a b] -> ([a], [b])
partitionEithers [Either BootError Text
domainE, Either BootError Text
ownerE, Either BootError Text
regionE] of
        ([], [Text
domain, Text
owner, Text
region]) ->
            CodeArtifactConfig -> Either [BootError] CodeArtifactConfig
forall a b. b -> Either a b
Right
                CodeArtifactConfig
                    { caRegion :: Text
caRegion = Text
region
                    , caDomain :: Text
caDomain = Text
domain
                    , caDomainOwner :: Maybe Text
caDomainOwner = Text -> Maybe Text
forall a. a -> Maybe a
Just Text
owner
                    , caDurationSeconds :: Maybe Natural
caDurationSeconds = MountConfig -> Maybe Natural
mntMirrorCodeArtifactTokenDuration MountConfig
mcfg
                    }
        ([BootError]
errs, [Text]
_) -> [BootError] -> Either [BootError] CodeArtifactConfig
forall a b. a -> Either a b
Left [BootError]
errs
  where
    -- An unset mirror target falls back to the private upstream (the fold the
    -- Haddock above describes); with neither set the parse yields 'Nothing', so
    -- a still-unresolved input is reported as its missing explicit key.
    parsed :: Maybe (Text, Text, Text)
    parsed :: Maybe (Text, Text, Text)
parsed =
        Text -> Maybe (Text, Text, Text)
parseCodeArtifactHost (Text -> Maybe (Text, Text, Text))
-> (RegistryUrl -> Text) -> RegistryUrl -> Maybe (Text, Text, Text)
forall b c a. (b -> c) -> (a -> b) -> a -> c
. Text -> Text
hostAddress (Text -> Text) -> (RegistryUrl -> Text) -> RegistryUrl -> Text
forall b c a. (b -> c) -> (a -> b) -> a -> c
. RegistryUrl -> Text
registryUrlText
            (RegistryUrl -> Maybe (Text, Text, Text))
-> Maybe RegistryUrl -> Maybe (Text, Text, Text)
forall (m :: * -> *) a b. Monad m => (a -> m b) -> m a -> m b
=<< (MountConfig -> Maybe RegistryUrl
mntMirrorTarget MountConfig
mcfg Maybe RegistryUrl -> Maybe RegistryUrl -> Maybe RegistryUrl
forall a. Maybe a -> Maybe a -> Maybe a
forall (f :: * -> *) a. Alternative f => f a -> f a -> f a
<|> MountConfig -> Maybe RegistryUrl
mntPrivateUpstream MountConfig
mcfg)

    resolve :: Text -> [Maybe Text] -> Either BootError Text
    resolve :: Text -> [Maybe Text] -> Either BootError Text
resolve Text
key [Maybe Text]
candidates =
        Either BootError Text
-> (Text -> Either BootError Text)
-> Maybe Text
-> Either BootError Text
forall b a. b -> (a -> b) -> Maybe a -> b
maybe (BootError -> Either BootError Text
forall a b. a -> Either a b
Left (Text -> BootError
CodeArtifactConfigMissing (Ecosystem -> Text -> Text
mountEnvKey Ecosystem
eco Text
key))) Text -> Either BootError Text
forall a b. b -> Either a b
Right ([Maybe Text] -> Maybe Text
forall (t :: * -> *) (f :: * -> *) a.
(Foldable t, Alternative f) =>
t (f a) -> f a
asum ((Maybe Text -> Maybe Text) -> [Maybe Text] -> [Maybe Text]
forall a b. (a -> b) -> [a] -> [b]
map (Maybe Text -> (Text -> Maybe Text) -> Maybe Text
forall a b. Maybe a -> (a -> Maybe b) -> Maybe b
forall (m :: * -> *) a b. Monad m => m a -> (a -> m b) -> m b
>>= Text -> Maybe Text
nonBlank) [Maybe Text]
candidates))

    domainE :: Either BootError Text
domainE = Text -> [Maybe Text] -> Either BootError Text
resolve Text
"MIRROR_CODE_ARTIFACT_DOMAIN" [MountConfig -> Maybe Text
mntMirrorCodeArtifactDomain MountConfig
mcfg, (Text, Text, Text) -> Text
forall {a} {b} {c}. (a, b, c) -> a
fst3 ((Text, Text, Text) -> Text)
-> Maybe (Text, Text, Text) -> Maybe Text
forall (f :: * -> *) a b. Functor f => (a -> b) -> f a -> f b
<$> Maybe (Text, Text, Text)
parsed]
    ownerE :: Either BootError Text
ownerE =
        Text -> [Maybe Text] -> Either BootError Text
resolve Text
"MIRROR_CODE_ARTIFACT_DOMAIN_OWNER" [MountConfig -> Maybe Text
mntMirrorCodeArtifactDomainOwner MountConfig
mcfg, (Text, Text, Text) -> Text
forall {a} {b} {c}. (a, b, c) -> b
snd3 ((Text, Text, Text) -> Text)
-> Maybe (Text, Text, Text) -> Maybe Text
forall (f :: * -> *) a b. Functor f => (a -> b) -> f a -> f b
<$> Maybe (Text, Text, Text)
parsed]
            Either BootError Text
-> (Text -> Either BootError Text) -> Either BootError Text
forall a b.
Either BootError a
-> (a -> Either BootError b) -> Either BootError b
forall (m :: * -> *) a b. Monad m => m a -> (a -> m b) -> m b
>>= Text -> Text -> Either BootError Text
validateAccountId (Ecosystem -> Text -> Text
mountEnvKey Ecosystem
eco Text
"MIRROR_CODE_ARTIFACT_DOMAIN_OWNER")
    regionE :: Either BootError Text
regionE = Text -> [Maybe Text] -> Either BootError Text
resolve Text
"MIRROR_CODE_ARTIFACT_REGION" [MountConfig -> Maybe Text
mntMirrorCodeArtifactRegion MountConfig
mcfg, (Text, Text, Text) -> Text
forall {a} {b} {c}. (a, b, c) -> c
thd3 ((Text, Text, Text) -> Text)
-> Maybe (Text, Text, Text) -> Maybe Text
forall (f :: * -> *) a b. Functor f => (a -> b) -> f a -> f b
<$> Maybe (Text, Text, Text)
parsed, AppConfig -> Maybe Text
cfgAwsRegion AppConfig
app]

    fst3 :: (a, b, c) -> a
fst3 (a
a, b
_, c
_) = a
a
    snd3 :: (a, b, c) -> b
snd3 (a
_, b
b, c
_) = b
b
    thd3 :: (a, b, c) -> c
thd3 (a
_, b
_, c
c) = c
c

-- The full environment key of a mount-scoped setting
-- (ECLUSE_MOUNTS__{ECOSYSTEM}__{KEY}), as the operator must set it.
mountEnvKey :: Ecosystem -> Text -> Text
mountEnvKey :: Ecosystem -> Text -> Text
mountEnvKey Ecosystem
eco Text
key = Text
"ECLUSE_MOUNTS__" Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text -> Text
T.toUpper (Ecosystem -> Text
ecosystemName Ecosystem
eco) Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
"__" Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
key

-- A resolved owner must be a 12-digit AWS account id (an explicit key can supply a
-- malformed one; a host owner is already validated by 'parseCodeArtifactHost').
validateAccountId :: Text -> Text -> Either BootError Text
validateAccountId :: Text -> Text -> Either BootError Text
validateAccountId Text
key Text
owner
    | Text -> Bool
isAccountId Text
owner = Text -> Either BootError Text
forall a b. b -> Either a b
Right Text
owner
    | Bool
otherwise = BootError -> Either BootError Text
forall a b. a -> Either a b
Left (Text -> Text -> BootError
CodeArtifactConfigInvalid Text
key Text
"expected a 12-digit AWS account id")

-- Whether a value is a 12-digit AWS account id.
isAccountId :: Text -> Bool
isAccountId :: Text -> Bool
isAccountId Text
t = Text -> Int -> Ordering
T.compareLength Text
t Int
12 Ordering -> Ordering -> Bool
forall a. Eq a => a -> a -> Bool
== Ordering
EQ Bool -> Bool -> Bool
&& (Char -> Bool) -> Text -> Bool
T.all Char -> Bool
isDigit Text
t

{- Parse a CodeArtifact npm endpoint host into its (domain, owner, region). The host
shape is @{domain}-{owner}.d.codeartifact.{region}.amazonaws.com@; the @{owner}@ is the
12-digit account id after the __last__ hyphen of the first label, so a domain may
itself contain hyphens. 'Nothing' for any host that is not this shape -- including one
whose tail after the last hyphen is not an account id, so a hyphen-bearing
non-CodeArtifact host never mis-parses into a bogus owner. -}
parseCodeArtifactHost :: Text -> Maybe (Text, Text, Text)
parseCodeArtifactHost :: Text -> Maybe (Text, Text, Text)
parseCodeArtifactHost Text
host = do
    [domainOwner, regionTail] <- [Text] -> Maybe [Text]
forall a. a -> Maybe a
Just (HasCallStack => Text -> Text -> [Text]
Text -> Text -> [Text]
T.splitOn Text
".d.codeartifact." Text
host)
    region <- nonBlank =<< T.stripSuffix ".amazonaws.com" regionTail
    let (domainDash, owner) = T.breakOnEnd "-" domainOwner
    domain <- nonBlank (T.dropEnd 1 domainDash)
    guard (isAccountId owner)
    pure (domain, owner, region)

{- | A reason the composition root refuses to start. Every case is a __fail-loud__
boot failure; they are aggregated so a single run reports every problem an
operator must fix.
-}
data BootError
    = -- | A rule policy did not resolve (surfaced by 'loadConfig').
      PolicyBootError PolicyError
    | {- | A configured mount's ecosystem has no adapter wired, so it
      cannot be served (a loud miss, never a silent drop). Carries the ecosystem.
      -}
      MissingAdapter Ecosystem
    | {- | A mount names a credential backend with no initialised provider. Carries
      the ecosystem of the mount and the unresolved backend.
      -}
      UnresolvedCredential Ecosystem CredentialBackend
    | {- | The configured mirror-queue backend has no implementation compiled into
      this binary, so no queue can be built for it. Carries the unavailable backend.
      An honest refusal -- never a silent fall-through to a different backend.
      -}
      QueueProviderUnavailable QueueBackend
    | {- | The SQS mirror-queue backend was selected but no AWS region was supplied
      (@AWS_REGION@), so the queue cannot be scoped to a region.
      -}
      QueueRegionMissing
    | {- | A cloud mirror-queue backend (e.g. @sqs@) was selected but no
      @ECLUSE_QUEUE_URL@ was supplied, so there is no queue to send jobs to. The
      in-memory backend does not raise this -- it has no external queue.
      -}
      QueueUrlMissing QueueBackend
    | {- | The configured SQS endpoint override (@AWS_ENDPOINT_URL_SQS@ \/
      @AWS_ENDPOINT_URL@) is not a parseable endpoint URL. Carries the offending value.
      -}
      QueueEndpointMalformed Text
    | {- | The selected mirror-target credential provider has no implementation
      compiled into this binary. Carries the unavailable provider. An honest refusal,
      never a silent fall-through.
      -}
      MirrorCredentialProviderUnavailable CredentialBackend
    | {- | A required CodeArtifact input for the mirror-target token could not be
      resolved from either its explicit key or the mirror-target host. Carries the
      name of the key the operator must set.
      -}
      CodeArtifactConfigMissing Text
    | {- | A CodeArtifact input resolved but is malformed (e.g. a domain owner that is
      not a 12-digit AWS account id). Carries the key and a reason.
      -}
      CodeArtifactConfigInvalid Text Text
    | {- | The eager boot-time CodeArtifact mint threw -- a transient AWS error (worth a
      retry) or a permanent one (a bad domain\/region or missing permission, to be
      fixed). Carries the rendered exception so the cause is legible and aggregated.
      -}
      CodeArtifactMintFailed Text
    | {- | A publication target was configured (@ECLUSE_PUBLICATION_TARGET@) but no
      publish-scope allow-list (@ECLUSE_PUBLISH_SCOPES@) was supplied, so the anti-shadowing
      guard would have nothing to enforce. Refused at boot rather than defaulting to an
      empty allow-list (which would deny every publish) or an open one (which would let
      a client shadow any public name).
      -}
      PublishScopesMissing Ecosystem
    | {- | A static publish credential (@ECLUSE_PUBLICATION_TARGET_TOKEN@) was configured
      without a verifiable inbound edge (@ECLUSE_AUTH_TOKEN@). Écluse would otherwise
      substitute its own standing write credential for a publishing caller who forwards
      none, so an unauthenticated request could publish within the configured scopes
      under Écluse's own identity. Refused at boot so an internal publish credential
      paired with an open edge is unrepresentable -- the write-side counterpart of the
      fail-closed read identity.
      -}
      PublishStaticCredentialNeedsEdge Ecosystem
    deriving stock (BootError -> BootError -> Bool
(BootError -> BootError -> Bool)
-> (BootError -> BootError -> Bool) -> Eq BootError
forall a. (a -> a -> Bool) -> (a -> a -> Bool) -> Eq a
$c== :: BootError -> BootError -> Bool
== :: BootError -> BootError -> Bool
$c/= :: BootError -> BootError -> Bool
/= :: BootError -> BootError -> Bool
Eq, Int -> BootError -> ShowS
[BootError] -> ShowS
BootError -> String
(Int -> BootError -> ShowS)
-> (BootError -> String)
-> ([BootError] -> ShowS)
-> Show BootError
forall a.
(Int -> a -> ShowS) -> (a -> String) -> ([a] -> ShowS) -> Show a
$cshowsPrec :: Int -> BootError -> ShowS
showsPrec :: Int -> BootError -> ShowS
$cshow :: BootError -> String
show :: BootError -> String
$cshowList :: [BootError] -> ShowS
showList :: [BootError] -> ShowS
Show)

-- | Render a 'BootError' as a human-facing line for the aggregated failure block.
renderBootError :: BootError -> Text
renderBootError :: BootError -> Text
renderBootError = \case
    PolicyBootError PolicyError
err -> PolicyError -> Text
renderPolicyError PolicyError
err
    MissingAdapter Ecosystem
eco ->
        Text
"mount " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Ecosystem -> Text
ecosystemName Ecosystem
eco Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
" has no adapter wired in this build"
    UnresolvedCredential Ecosystem
eco CredentialBackend
backend ->
        Text
"mount "
            Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Ecosystem -> Text
ecosystemName Ecosystem
eco
            Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
" names credential source "
            Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> CredentialBackend -> Text
forall a. (Eq a, WireVocab a) => a -> Text
renderWire CredentialBackend
backend
            Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
", not initialised in this build"
    QueueProviderUnavailable QueueBackend
backend ->
        Text
"mirror queue provider "
            Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> QueueBackend -> Text
forall a. (Eq a, WireVocab a) => a -> Text
renderWire QueueBackend
backend
            Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
" is not available in this build"
    BootError
QueueRegionMissing ->
        Text
"mirror queue provider "
            Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> QueueBackend -> Text
forall a. (Eq a, WireVocab a) => a -> Text
renderWire QueueBackend
SqsQueue
            Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
" requires AWS_REGION to be set"
    QueueUrlMissing QueueBackend
backend ->
        Text
"mirror queue provider "
            Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> QueueBackend -> Text
forall a. (Eq a, WireVocab a) => a -> Text
renderWire QueueBackend
backend
            Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
" requires ECLUSE_QUEUE_URL to be set"
    QueueEndpointMalformed Text
url ->
        Text
"the SQS endpoint override (AWS_ENDPOINT_URL_SQS / AWS_ENDPOINT_URL) is not a valid endpoint URL: " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
url
    MirrorCredentialProviderUnavailable CredentialBackend
backend ->
        Text
"mirror-target credential provider "
            Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> CredentialBackend -> Text
forall a. (Eq a, WireVocab a) => a -> Text
renderWire CredentialBackend
backend
            Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
" is not available in this build"
    CodeArtifactConfigMissing Text
key ->
        Text
"mirror-target credential provider codeartifact requires "
            Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
key
            Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
" (set it explicitly, or use a CodeArtifact ECLUSE_MIRROR_TARGET it can be parsed from)"
    CodeArtifactConfigInvalid Text
key Text
reason ->
        Text
"mirror-target credential provider codeartifact: " Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
key Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
" is invalid (" Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
reason Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
")"
    CodeArtifactMintFailed Text
detail ->
        Text
"mirror-target credential provider codeartifact failed to mint an initial token at boot: "
            Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
detail
            Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
" (a transient AWS error may clear on retry; a permanent one -- bad domain/region or missing permission -- must be fixed)"
    PublishScopesMissing Ecosystem
eco ->
        Text
"ECLUSE_MOUNTS__" Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text -> Text
T.toUpper (String -> Text
T.pack (Ecosystem -> String
forall b a. (Show a, IsString b) => a -> b
show Ecosystem
eco)) Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
"__PUBLICATION_TARGET is set but ECLUSE_MOUNTS__" Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text -> Text
T.toUpper (String -> Text
T.pack (Ecosystem -> String
forall b a. (Show a, IsString b) => a -> b
show Ecosystem
eco)) Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
"__PUBLISH_SCOPES is empty: a publication target needs a publish-scope allow-list (e.g. @acme) for the anti-shadowing guard."
    PublishStaticCredentialNeedsEdge Ecosystem
eco ->
        Text
"ECLUSE_MOUNTS__" Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text -> Text
T.toUpper (String -> Text
T.pack (Ecosystem -> String
forall b a. (Show a, IsString b) => a -> b
show Ecosystem
eco)) Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
"__PUBLICATION_TARGET_TOKEN is set but ECLUSE_AUTH_TOKEN is not: a static publish credential needs a verifiable inbound edge."

{- | Validate the environment layer and optional document into the served mount
bindings, or the aggregated boot errors. The composition root's single entry: it
runs 'loadConfig' (whose policy errors become 'PolicyBootError's) and then
'composeBindings', so policy, missing-adapter, and unresolved-credential failures
all surface from one call.

The ecosystem-to-adapter resolver, the wall-clock source, and the rules' boot-bound
capabilities are injected (the composition root supplies @mountBindingFor@,
'Data.Time.getCurrentTime', and each ecosystem's 'RuleDeps'), so this validation
opens no socket. The capabilities are per ecosystem because a mount's rules must
borrow /their/ ecosystem's advisory database, never a neighbour's.
It is 'IO' only because 'composeBindings' 'prepare's each mount's rules (allocating
per-rule engine state once at boot).
-}
planMounts ::
    (Ecosystem -> Maybe PackumentDeps -> Maybe PublishDeps -> Maybe MountBinding) ->
    IO UTCTime ->
    (Ecosystem -> RuleDeps) ->
    CredentialProviders ->
    Config ->
    IO (Either [BootError] [MountBinding])
planMounts :: (Ecosystem
 -> Maybe PackumentDeps -> Maybe PublishDeps -> Maybe MountBinding)
-> IO UTCTime
-> (Ecosystem -> RuleDeps)
-> CredentialProviders
-> Config
-> IO (Either [BootError] [MountBinding])
planMounts = (Ecosystem
 -> Maybe PackumentDeps -> Maybe PublishDeps -> Maybe MountBinding)
-> IO UTCTime
-> (Ecosystem -> RuleDeps)
-> CredentialProviders
-> Config
-> IO (Either [BootError] [MountBinding])
composeBindings

{- | Turn a validated 'Config' into the served 'MountBinding's, or the aggregated
boot errors. For each mount, in ecosystem order: its credential reference must
resolve to an initialised provider, and its ecosystem must resolve to an adapter
(through the injected resolver, fed real 'PackumentDeps' so the packument route is
served rather than the @501@ stub). Errors aggregate across every mount.
-}
composeBindings ::
    (Ecosystem -> Maybe PackumentDeps -> Maybe PublishDeps -> Maybe MountBinding) ->
    IO UTCTime ->
    (Ecosystem -> RuleDeps) ->
    CredentialProviders ->
    Config ->
    IO (Either [BootError] [MountBinding])
composeBindings :: (Ecosystem
 -> Maybe PackumentDeps -> Maybe PublishDeps -> Maybe MountBinding)
-> IO UTCTime
-> (Ecosystem -> RuleDeps)
-> CredentialProviders
-> Config
-> IO (Either [BootError] [MountBinding])
composeBindings Ecosystem
-> Maybe PackumentDeps -> Maybe PublishDeps -> Maybe MountBinding
resolveAdapter IO UTCTime
clock Ecosystem -> RuleDeps
ruleDepsFor CredentialProviders
providers Config
config = do
    let ([BootError]
pubErrs, Map Ecosystem (Maybe PublishDeps)
pubDepsMap) = case Map Ecosystem (Either [BootError] (Maybe PublishDeps))
-> Either [BootError] (Map Ecosystem (Maybe PublishDeps))
forall (t :: * -> *) (m :: * -> *) a.
(Traversable t, Monad m) =>
t (m a) -> m (t a)
forall (m :: * -> *) a.
Monad m =>
Map Ecosystem (m a) -> m (Map Ecosystem a)
sequence ((Ecosystem
 -> MountConfig -> Either [BootError] (Maybe PublishDeps))
-> Map Ecosystem MountConfig
-> Map Ecosystem (Either [BootError] (Maybe PublishDeps))
forall k a b. (k -> a -> b) -> Map k a -> Map k b
Map.mapWithKey (\Ecosystem
eco MountConfig
mcfg -> Ecosystem
-> AppConfig
-> MountConfig
-> Limits
-> Maybe HelpMessage
-> Either [BootError] (Maybe PublishDeps)
publishDepsFor Ecosystem
eco AppConfig
app MountConfig
mcfg Limits
limits Maybe HelpMessage
helpMessage) (AppConfig -> Map Ecosystem MountConfig
cfgMounts AppConfig
app)) of
            Left [BootError]
errs -> ([BootError]
errs, Map Ecosystem (Maybe PublishDeps)
forall k a. Map k a
Map.empty)
            Right Map Ecosystem (Maybe PublishDeps)
m -> ([], Map Ecosystem (Maybe PublishDeps)
m)
    -- Each resolved mount paired with its environment-layer 'MountConfig':
    -- 'Ecluse.Config.loadConfig' derives 'configMounts' from 'cfgMounts' entry for
    -- entry, so the two maps share a keyset and the pairing is total.
    let mounts :: [(Mount, MountConfig)]
mounts = Map Ecosystem (Mount, MountConfig) -> [(Mount, MountConfig)]
forall k a. Map k a -> [a]
Map.elems ((Mount -> MountConfig -> (Mount, MountConfig))
-> Map Ecosystem Mount
-> Map Ecosystem MountConfig
-> Map Ecosystem (Mount, MountConfig)
forall k a b c.
Ord k =>
(a -> b -> c) -> Map k a -> Map k b -> Map k c
Map.intersectionWith (,) (Config -> Map Ecosystem Mount
configMounts Config
config) (AppConfig -> Map Ecosystem MountConfig
cfgMounts AppConfig
app))
    bindingResults <- ((Mount, MountConfig) -> IO (Either [BootError] MountBinding))
-> [(Mount, MountConfig)] -> IO [Either [BootError] MountBinding]
forall (t :: * -> *) (f :: * -> *) a b.
(Traversable t, Applicative f) =>
(a -> f b) -> t a -> f (t b)
forall (f :: * -> *) a b.
Applicative f =>
(a -> f b) -> [a] -> f [b]
traverse (\(Mount
mount, MountConfig
mcfg) -> Maybe PublishDeps
-> Mount -> MountConfig -> IO (Either [BootError] MountBinding)
bindingFor (Maybe (Maybe PublishDeps) -> Maybe PublishDeps
forall (m :: * -> *) a. Monad m => m (m a) -> m a
join (Ecosystem
-> Map Ecosystem (Maybe PublishDeps) -> Maybe (Maybe PublishDeps)
forall k a. Ord k => k -> Map k a -> Maybe a
Map.lookup (Mount -> Ecosystem
mountEcosystem Mount
mount) Map Ecosystem (Maybe PublishDeps)
pubDepsMap)) Mount
mount MountConfig
mcfg) [(Mount, MountConfig)]
mounts
    pure $ case (pubErrs, partitionEithers bindingResults) of
        ([], ([], [MountBinding]
bindings)) -> [MountBinding] -> Either [BootError] [MountBinding]
forall a b. b -> Either a b
Right [MountBinding]
bindings
        ([BootError]
_, ([[BootError]]
errs, [MountBinding]
_)) -> [BootError] -> Either [BootError] [MountBinding]
forall a b. a -> Either a b
Left ([BootError]
pubErrs [BootError] -> [BootError] -> [BootError]
forall a. Semigroup a => a -> a -> a
<> [[BootError]] -> [BootError]
forall (t :: * -> *) a. Foldable t => t [a] -> [a]
concat [[BootError]]
errs)
  where
    app :: AppConfig
    app :: AppConfig
app = Config -> AppConfig
configApp Config
config

    -- The response-bound budget every mount enforces on its upstream fetches and
    -- decodes (security.md invariant 4), assembled from the validated environment
    -- ceilings. Carried onto each mount's deps so the data plane reads the metadata
    -- body bounded, and refuses an over-deep or version-flooded document fail-closed.
    limits :: Limits
    limits :: Limits
limits =
        Limits
            { maxBodyBytes :: Int
maxBodyBytes = AppConfig -> Int
cfgMaxResponseBytes AppConfig
app
            , maxVersionCount :: Int
maxVersionCount = AppConfig -> Int
cfgMaxVersionCount AppConfig
app
            , maxNestingDepth :: Int
maxNestingDepth = AppConfig -> Int
cfgMaxNestingDepth AppConfig
app
            }

    -- The operator help message, derived from the environment layer like the
    -- inbound token, so every mount's denials carry it.
    helpMessage :: Maybe HelpMessage
    helpMessage :: Maybe HelpMessage
helpMessage = Text -> HelpMessage
mkHelpMessage (Text -> HelpMessage) -> Maybe Text -> Maybe HelpMessage
forall (f :: * -> *) a b. Functor f => (a -> b) -> f a -> f b
<$> AppConfig -> Maybe Text
cfgHelpMessage AppConfig
app

    {- Resolve one mount to its binding, or the boot errors that block it. Both the
    credential reference and the adapter are checked even when one already failed,
    so a mount missing both reports both in one run rather than one at a time. The
    resolved publish dependencies (shared across mounts) are passed to the adapter so
    the binding carries the first-party publish wiring. -}
    bindingFor :: Maybe PublishDeps -> Mount -> MountConfig -> IO (Either [BootError] MountBinding)
    bindingFor :: Maybe PublishDeps
-> Mount -> MountConfig -> IO (Either [BootError] MountBinding)
bindingFor Maybe PublishDeps
pubDeps Mount
mount MountConfig
mcfg = do
        deps <- Mount -> MountConfig -> IO PackumentDeps
packumentDepsFor Mount
mount MountConfig
mcfg
        pure $ case (credentialError providers mount, resolveAdapter (mountEcosystem mount) (Just deps) pubDeps) of
            (Maybe BootError
Nothing, Just MountBinding
binding) -> MountBinding -> Either [BootError] MountBinding
forall a b. b -> Either a b
Right MountBinding
binding
            (Maybe BootError
mCredErr, Maybe MountBinding
mBinding) ->
                [BootError] -> Either [BootError] MountBinding
forall a b. a -> Either a b
Left (Maybe BootError -> [BootError]
forall a. Maybe a -> [a]
maybeToList Maybe BootError
mCredErr [BootError] -> [BootError] -> [BootError]
forall a. Semigroup a => a -> a -> a
<> [Ecosystem -> BootError
MissingAdapter (Mount -> Ecosystem
mountEcosystem Mount
mount) | Maybe MountBinding -> Bool
forall a. Maybe a -> Bool
isNothing Maybe MountBinding
mBinding])

    {- Build a mount's 'PackumentDeps' from its registries, resolved rules, the
    inbound edge token, the injected clock, and the operator help message. The
    mount's externally-visible base URL drives the @dist.tarball@ rewrite: an
    __absolute__ URL under @ECLUSE_PUBLIC_URL@ (@{public}\/npm\/{pkg}\/-\/{file}@)
    when one is configured, so an @npm@ client fetches the artifact back through the
    proxy on the gated path; otherwise the relative prefix path (@\/npm@), retained
    for compatibility -- but note @npm@ cannot consume a relative @dist.tarball@ (it
    reads a leading slash as a @file:@ path), so a real install path must set
    @ECLUSE_PUBLIC_URL@ (see @mountBaseUrl@ and
    @docs\/architecture\/hosting.md@ → "URL rewriting"). -}
    packumentDepsFor :: Mount -> MountConfig -> IO PackumentDeps
    packumentDepsFor :: Mount -> MountConfig -> IO PackumentDeps
packumentDepsFor Mount
mount MountConfig
mcfg = do
        -- Prepare the resolved policy into the engine's runtime rules, closing the
        -- injected 'RuleDeps' into them; an effectful rule (AllowIfRemediatesCve)
        -- gets its resilience policy and breaker allocated here, once per mount.
        prepared <- RuleDeps -> [PrecededRule] -> IO [PreparedRule]
prepare (Ecosystem -> RuleDeps
ruleDepsFor (Mount -> Ecosystem
mountEcosystem Mount
mount)) (Mount -> [PrecededRule]
mountPolicy Mount
mount)
        let regs = Mount -> MountRegistries
mountRegistries Mount
mount
        pure
            PackumentDeps
                { pdPrivateBaseUrl = registryUrlText (regPrivateUpstream regs)
                , pdPublicBaseUrl = registryUrlText (regPublicUpstream regs)
                , pdMountBaseUrl = mountBaseUrl (cfgPublicUrl app) (mountEcosystem mount)
                , pdMirrorTarget = registryUrlText (mtUrl (regMirrorTarget regs))
                , pdRules = prepared
                , pdTarballHostPolicy = tarballHostPolicyFor mcfg
                , -- The operator-configured ranges extending the fixed internal-range block on
                  -- the dist.tarball host gate; the same list applies to every mount, since which
                  -- internal ranges exist on an operator's network is a deployment-wide fact.
                  pdAdditionalBlockedRanges = cfgAdditionalBlockedRanges app
                , -- The tarball-host gate's mount-constant inputs (allowlist + private and
                  -- public hosts), extracted once here so the hot artifact path parses no
                  -- URL and rebuilds no host set per request.
                  pdTarballHostGate =
                    tarballHostGate
                        (registryUrlText (regPrivateUpstream regs))
                        (registryUrlText (regPublicUpstream regs))
                        (registryUrlText (mtUrl (regMirrorTarget regs)))
                , pdLimits = limits
                , pdInboundToken = cfgAuthToken app
                , pdNow = clock
                , pdHelp = helpMessage
                , -- The global public-integrity admission floor, validated at config
                  -- load, carried onto every mount's deps so the public gate refuses
                  -- a below-floor version.
                  pdMinIntegrity = cfgMinPublicIntegrity app
                , -- The global trusted-integrity admission floor (default SHA-256,
                  -- loosenable below it), carried onto every mount's deps so the
                  -- trusted gate drops a below-floor private version from the listing
                  -- and gates the private artifact serve.
                  pdMinTrustedIntegrity = cfgMinTrustedIntegrity app
                , pdNewMetadataClient = \TracingPort
t MetricsPort
p Upstream
u ManifestCaching
c PackageName -> MetadataError -> IO ()
f1 PackageName -> [InvalidEntry] -> IO ()
f2 PackageName -> IO ()
f3 Limits
l Manager
m Text
b Maybe Secret
s -> TracingPort
-> MetricsPort
-> Upstream
-> ManifestCaching
-> (PackageName -> MetadataError -> IO ())
-> (PackageName -> [InvalidEntry] -> IO ())
-> (PackageName -> IO ())
-> NpmClientConfig
-> MetadataClient
Metadata.newNpmMetadataClient TracingPort
t MetricsPort
p Upstream
u ManifestCaching
c PackageName -> MetadataError -> IO ()
f1 PackageName -> [InvalidEntry] -> IO ()
f2 PackageName -> IO ()
f3 (Text -> Manager -> Maybe Secret -> Limits -> NpmClientConfig
Npm.NpmClientConfig Text
b Manager
m Maybe Secret
s Limits
l)
                , pdBuildArtifactRequestByFile = \Limits
_ Manager
_ Text
t Maybe Secret
s -> Text
-> Maybe Secret
-> PackageName
-> Text
-> Either UrlFormationError Request
NpmRequest.artifactRequestByFile Text
t Maybe Secret
s
                , pdBuildArtifactRequestByUrl = \Limits
_ Manager
_ Text
t Maybe Secret
s -> Text -> Maybe Secret -> Text -> Either UrlFormationError Request
NpmRequest.artifactRequestByUrl Text
t Maybe Secret
s
                , pdAssemble = NpmFilter.assembleMergedPackument
                }

-- The resolved tarball-host policy of a mount, from the secure-default
-- environment toggle: honour a cross-host dist.tarball only when explicitly
-- opted in (and even then, never past the allowlist or the internal block).
tarballHostPolicyFor :: MountConfig -> TarballHostPolicy
tarballHostPolicyFor :: MountConfig -> TarballHostPolicy
tarballHostPolicyFor MountConfig
mcfg =
    if MountConfig -> Bool
mntRespectUpstreamTarballHost MountConfig
mcfg
        then TarballHostPolicy
AnyAllowlistedHost
        else TarballHostPolicy
SameHostAsPackument

-- The credential reference of a mount: an error when the named backend is not
-- initialised, nothing when it resolves.
credentialError :: CredentialProviders -> Mount -> Maybe BootError
credentialError :: CredentialProviders -> Mount -> Maybe BootError
credentialError CredentialProviders
providers Mount
mount =
    let backend :: CredentialBackend
backend = MirrorTarget -> CredentialBackend
mtCredential (MountRegistries -> MirrorTarget
regMirrorTarget (Mount -> MountRegistries
mountRegistries Mount
mount))
     in if Mount -> Ecosystem
mountEcosystem Mount
mount Ecosystem -> Set Ecosystem -> Bool
forall a. Ord a => a -> Set a -> Bool
`Set.member` CredentialProviders -> Set Ecosystem
initializedEcosystems CredentialProviders
providers
            then Maybe BootError
forall a. Maybe a
Nothing
            else BootError -> Maybe BootError
forall a. a -> Maybe a
Just (Ecosystem -> CredentialBackend -> BootError
UnresolvedCredential (Mount -> Ecosystem
mountEcosystem Mount
mount) CredentialBackend
backend)

-- A mount's externally-visible base URL for the dist.tarball rewrite. Absolute
-- under ECLUSE_PUBLIC_URL when set (so a served tarball is a full URL an npm
-- client can fetch); otherwise the relative prefix path, retained for
-- compatibility. A trailing slash on the configured URL is dropped so the join
-- with the leading-slash mount path yields exactly one separator.
mountBaseUrl :: Maybe Url -> Ecosystem -> Text
mountBaseUrl :: Maybe Url -> Ecosystem -> Text
mountBaseUrl Maybe Url
publicUrl Ecosystem
eco =
    case Maybe Url
publicUrl of
        Maybe Url
Nothing -> Ecosystem -> Text
mountBasePath Ecosystem
eco
        Just Url
public -> (Char -> Bool) -> Text -> Text
T.dropWhileEnd (Char -> Char -> Bool
forall a. Eq a => a -> a -> Bool
== Char
'/') (Url -> Text
unUrl Url
public) Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Ecosystem -> Text
mountBasePath Ecosystem
eco

-- The mount's externally-visible base path, derived from its ecosystem prefix
-- (@npm@ → @\/npm@): a leading slash and the prefix segments joined, so it is the
-- relative path a client's registry endpoint maps onto.
mountBasePath :: Ecosystem -> Text
mountBasePath :: Ecosystem -> Text
mountBasePath Ecosystem
eco = Text
"/" Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text -> [Text] -> Text
T.intercalate Text
"/" (NonEmpty Text -> [Text]
forall a. NonEmpty a -> [a]
forall (t :: * -> *) a. Foldable t => t a -> [a]
toList (Ecosystem -> NonEmpty Text
prefixFor Ecosystem
eco))

{- | Validate the first-party publish dependencies from the environment layer, shared
across the (single-ecosystem) mounts: 'Nothing' when no publication target is configured
(the publish path is off -- a @PUT \/{pkg}@ is then @405@), 'Just' when one is set and
valid, or the accumulated fail-loud publish boot errors when not -- 'PublishScopesMissing'
when a target is set without a publish-scope allow-list, and\/or
'PublishStaticCredentialNeedsEdge' when a static publish credential is set without a
verifiable inbound edge -- reported together rather than one reboot at a time. The target's
URL, the scopes, and the static fallback credential are the publish env layer; the
response bounds ('Limits') and help message are shared with the read paths and passed in.
-}
publishDepsFor :: Ecosystem -> AppConfig -> MountConfig -> Limits -> Maybe HelpMessage -> Either [BootError] (Maybe PublishDeps)
publishDepsFor :: Ecosystem
-> AppConfig
-> MountConfig
-> Limits
-> Maybe HelpMessage
-> Either [BootError] (Maybe PublishDeps)
publishDepsFor Ecosystem
eco AppConfig
app MountConfig
mcfg Limits
limits Maybe HelpMessage
helpMessage = case MountConfig -> Maybe RegistryUrl
mntPublicationTarget MountConfig
mcfg of
    Maybe RegistryUrl
Nothing -> Maybe PublishDeps -> Either [BootError] (Maybe PublishDeps)
forall a b. b -> Either a b
Right Maybe PublishDeps
forall a. Maybe a
Nothing
    Just RegistryUrl
url -> case Ecosystem -> MountConfig -> Maybe Secret -> [BootError]
publishBootErrors Ecosystem
eco MountConfig
mcfg Maybe Secret
inboundToken of
        [] ->
            Maybe PublishDeps -> Either [BootError] (Maybe PublishDeps)
forall a b. b -> Either a b
Right
                ( PublishDeps -> Maybe PublishDeps
forall a. a -> Maybe a
Just
                    PublishDeps
                        { pubTargetUrl :: Text
pubTargetUrl = RegistryUrl -> Text
registryUrlText RegistryUrl
url
                        , pubScopes :: [Scope]
pubScopes = MountConfig -> [Scope]
mntPublishScopes MountConfig
mcfg
                        , pubStaticToken :: Maybe Secret
pubStaticToken = MountConfig -> Maybe Secret
mntPublicationTargetToken MountConfig
mcfg
                        , pubInboundToken :: Maybe Secret
pubInboundToken = Maybe Secret
inboundToken
                        , pubLimits :: Limits
pubLimits = Limits
limits
                        , pubHelp :: Maybe HelpMessage
pubHelp = Maybe HelpMessage
helpMessage
                        , pubRelayPublish :: Limits
-> Manager
-> Text
-> Maybe Secret
-> PackageName
-> ByteString
-> IO (Either UrlFormationError PublishRelayResponse)
pubRelayPublish = \Limits
l Manager
m Text
t Maybe Secret
s -> NpmClientConfig
-> PackageName
-> ByteString
-> IO (Either UrlFormationError PublishRelayResponse)
Npm.relayPublishDocument (Text -> Manager -> Maybe Secret -> Limits -> NpmClientConfig
Npm.NpmClientConfig Text
t Manager
m Maybe Secret
s Limits
l)
                        , pubCanonicaliseName :: Text -> Maybe PackageName
pubCanonicaliseName = Either ParseError PackageName -> Maybe PackageName
forall l r. Either l r -> Maybe r
rightToMaybe (Either ParseError PackageName -> Maybe PackageName)
-> (Text -> Either ParseError PackageName)
-> Text
-> Maybe PackageName
forall b c a. (b -> c) -> (a -> b) -> a -> c
. Text -> Either ParseError PackageName
NpmProject.projectName
                        }
                )
        [BootError]
errs -> [BootError] -> Either [BootError] (Maybe PublishDeps)
forall a b. a -> Either a b
Left [BootError]
errs
  where
    inboundToken :: Maybe Secret
    inboundToken :: Maybe Secret
inboundToken = AppConfig -> Maybe Secret
cfgAuthToken AppConfig
app

-- The accumulated fail-loud publish boot errors for a configured publication
-- target: a missing publish-scope allow-list, and a static publish credential
-- without a verifiable inbound edge, reported together.
publishBootErrors :: Ecosystem -> MountConfig -> Maybe Secret -> [BootError]
publishBootErrors :: Ecosystem -> MountConfig -> Maybe Secret -> [BootError]
publishBootErrors Ecosystem
eco MountConfig
mcfg Maybe Secret
inboundToken = [Maybe BootError] -> [BootError]
forall a. [Maybe a] -> [a]
catMaybes [Maybe BootError
scopesError, Maybe BootError
edgeError]
  where
    scopesError, edgeError :: Maybe BootError
    scopesError :: Maybe BootError
scopesError
        | [Scope] -> Bool
forall a. [a] -> Bool
forall (t :: * -> *) a. Foldable t => t a -> Bool
null (MountConfig -> [Scope]
mntPublishScopes MountConfig
mcfg) = BootError -> Maybe BootError
forall a. a -> Maybe a
Just (Ecosystem -> BootError
PublishScopesMissing Ecosystem
eco)
        | Bool
otherwise = Maybe BootError
forall a. Maybe a
Nothing
    edgeError :: Maybe BootError
edgeError
        | Maybe Secret -> Bool
forall a. Maybe a -> Bool
isJust (MountConfig -> Maybe Secret
mntPublicationTargetToken MountConfig
mcfg) Bool -> Bool -> Bool
&& Maybe Secret -> Bool
forall a. Maybe a -> Bool
isNothing Maybe Secret
inboundToken = BootError -> Maybe BootError
forall a. a -> Maybe a
Just (Ecosystem -> BootError
PublishStaticCredentialNeedsEdge Ecosystem
eco)
        | Bool
otherwise = Maybe BootError
forall a. Maybe a
Nothing

{- | One ecosystem's resolved __publish__ target: the mirror-target endpoint the
mirror worker writes approved artifacts to, paired with the credential provider
that mints its bearer token.

This is the publish side of the per-ecosystem composition (the serve side is the
mount's 'PackumentDeps'). The worker's single consumer builds a registry-protocol
client from these -- the endpoint as its base URL, the provider's token as its
bearer -- so the publish client is resolved here at the composition root rather than
re-derived per request.
-}
data PublishTarget = PublishTarget
    { PublishTarget -> Ecosystem
ptEcosystem :: Ecosystem
    -- ^ The ecosystem this publish target serves.
    , PublishTarget -> Text
ptMirrorUrl :: Text
    -- ^ The mirror-target endpoint approved artifacts are published to.
    , PublishTarget -> CredentialProvider
ptCredentials :: CredentialProvider
    -- ^ The provider minting the mirror-target write token.
    }

{- | Resolve each configured mount to its publish target, or the aggregated boot
errors. The publish side of 'planMounts': it validates the same config and resolves
each mount's mirror-target endpoint and write credential, so the worker's publish
client can be built at the composition root.

An unresolved credential reference is the same fail-loud boot error 'composeBindings'
reports for the serve side, so the two surfaces never disagree on what is wired.
-}
planPublishTargets ::
    CredentialProviders ->
    Config ->
    Either [BootError] [PublishTarget]
planPublishTargets :: CredentialProviders -> Config -> Either [BootError] [PublishTarget]
planPublishTargets = CredentialProviders -> Config -> Either [BootError] [PublishTarget]
composePublishTargets

-- Resolve every mount's publish target from a validated config, aggregating an
-- unresolved-credential error per mount (the same check 'composeBindings' applies).
composePublishTargets ::
    CredentialProviders ->
    Config ->
    Either [BootError] [PublishTarget]
composePublishTargets :: CredentialProviders -> Config -> Either [BootError] [PublishTarget]
composePublishTargets CredentialProviders
providers Config
config =
    case [Either [BootError] PublishTarget]
-> ([[BootError]], [PublishTarget])
forall a b. [Either a b] -> ([a], [b])
partitionEithers ((Mount -> Either [BootError] PublishTarget)
-> [Mount] -> [Either [BootError] PublishTarget]
forall a b. (a -> b) -> [a] -> [b]
map (CredentialProviders -> Mount -> Either [BootError] PublishTarget
publishTargetFor CredentialProviders
providers) (Map Ecosystem Mount -> [Mount]
forall k a. Map k a -> [a]
Map.elems (Config -> Map Ecosystem Mount
configMounts Config
config))) of
        ([], [PublishTarget]
targets) -> [PublishTarget] -> Either [BootError] [PublishTarget]
forall a b. b -> Either a b
Right [PublishTarget]
targets
        ([[BootError]]
errs, [PublishTarget]
_) -> [BootError] -> Either [BootError] [PublishTarget]
forall a b. a -> Either a b
Left ([[BootError]] -> [BootError]
forall (t :: * -> *) a. Foldable t => t [a] -> [a]
concat [[BootError]]
errs)

-- One mount's publish target: its mirror-target endpoint paired with the
-- initialised write provider, or the same unresolved-credential boot error the
-- serve side reports.
publishTargetFor :: CredentialProviders -> Mount -> Either [BootError] PublishTarget
publishTargetFor :: CredentialProviders -> Mount -> Either [BootError] PublishTarget
publishTargetFor CredentialProviders
providers Mount
mount =
    case Ecosystem -> CredentialProviders -> Maybe CredentialProvider
lookupProvider (Mount -> Ecosystem
mountEcosystem Mount
mount) CredentialProviders
providers of
        Just CredentialProvider
provider ->
            PublishTarget -> Either [BootError] PublishTarget
forall a b. b -> Either a b
Right
                PublishTarget
                    { ptEcosystem :: Ecosystem
ptEcosystem = Mount -> Ecosystem
mountEcosystem Mount
mount
                    , ptMirrorUrl :: Text
ptMirrorUrl = RegistryUrl -> Text
registryUrlText (MirrorTarget -> RegistryUrl
mtUrl MirrorTarget
target)
                    , ptCredentials :: CredentialProvider
ptCredentials = CredentialProvider
provider
                    }
        Maybe CredentialProvider
Nothing ->
            [BootError] -> Either [BootError] PublishTarget
forall a b. a -> Either a b
Left [Ecosystem -> CredentialBackend -> BootError
UnresolvedCredential (Mount -> Ecosystem
mountEcosystem Mount
mount) (MirrorTarget -> CredentialBackend
mtCredential MirrorTarget
target)]
  where
    target :: MirrorTarget
target = MountRegistries -> MirrorTarget
regMirrorTarget (Mount -> MountRegistries
mountRegistries Mount
mount)

{- | Which mirror-queue backend the composition root will build, resolved from
config: the durable AWS @sqs@ backend (with its 'SqsConfig'), or the bounded
best-effort in-memory backend (with its 'MemoryQueueConfig'). The pure decision
'planMirrorQueue' yields; the composition root pattern-matches it to make the one
constructor call, and 'mirrorQueuePlanWarning' tells it whether a boot warning is due.
-}
data MirrorQueuePlan
    = -- | The durable AWS SQS backend, built by @Ecluse.Core.Queue.Sqs.newSqsQueue@.
      SqsBackend SqsConfig
    | {- | The bounded in-memory backend, built by
      'Ecluse.Core.Queue.newBoundedInMemoryQueue'. Non-durable and best-effort -- boot warns.
      -}
      MemoryBackend MemoryQueueConfig
    deriving stock (MirrorQueuePlan -> MirrorQueuePlan -> Bool
(MirrorQueuePlan -> MirrorQueuePlan -> Bool)
-> (MirrorQueuePlan -> MirrorQueuePlan -> Bool)
-> Eq MirrorQueuePlan
forall a. (a -> a -> Bool) -> (a -> a -> Bool) -> Eq a
$c== :: MirrorQueuePlan -> MirrorQueuePlan -> Bool
== :: MirrorQueuePlan -> MirrorQueuePlan -> Bool
$c/= :: MirrorQueuePlan -> MirrorQueuePlan -> Bool
/= :: MirrorQueuePlan -> MirrorQueuePlan -> Bool
Eq, Int -> MirrorQueuePlan -> ShowS
[MirrorQueuePlan] -> ShowS
MirrorQueuePlan -> String
(Int -> MirrorQueuePlan -> ShowS)
-> (MirrorQueuePlan -> String)
-> ([MirrorQueuePlan] -> ShowS)
-> Show MirrorQueuePlan
forall a.
(Int -> a -> ShowS) -> (a -> String) -> ([a] -> ShowS) -> Show a
$cshowsPrec :: Int -> MirrorQueuePlan -> ShowS
showsPrec :: Int -> MirrorQueuePlan -> ShowS
$cshow :: MirrorQueuePlan -> String
show :: MirrorQueuePlan -> String
$cshowList :: [MirrorQueuePlan] -> ShowS
showList :: [MirrorQueuePlan] -> ShowS
Show)

{- | Select the mirror-queue backend from the environment layer, yielding the
'MirrorQueuePlan' the composition root builds the queue from, or the aggregated boot
errors that block it.

This is the pure half of the queue's backend choice -- the single place that knows
which backends this binary can build. The AWS @sqs@ backend resolves to a
'SqsBackend' carrying its 'SqsConfig' (the queue URL and region, with the provider
knobs at their defaults); the composition root passes that to
@Ecluse.Core.Queue.Sqs.newSqsQueue@. The @memory@ backend resolves to a 'MemoryBackend'
carrying its depth cap, built in-process with no cloud queue (@ECLUSE_QUEUE_URL@ and
@AWS_REGION@ are not consulted for it) -- an explicit operator choice for a simple,
single-node, or air-gapped deployment, never an automatic fallback (which would
soften the fail-loud-on-misconfig posture); the composition root emits the
'memoryQueueBootWarning' on selection. The GCP @pubsub@ arm is recognised but not
built, so it is a fail-loud 'QueueProviderUnavailable' boot error rather than a
silent fall-through. @ECLUSE_QUEUE_URL@ is optional at the env layer; it is required
__here__ for @sqs@ (the jobs need a queue), so a missing one is a fail-loud
'QueueUrlMissing' boot error, and a missing @AWS_REGION@ under @sqs@ is a
'QueueRegionMissing' boot error -- the @sqs@ arm aggregates the region, queue-URL, and
endpoint failures, and the whole result is a list so it aggregates with the rest of
the boot-time validation.

When an endpoint override is configured (@AWS_ENDPOINT_URL_SQS@, else
@AWS_ENDPOINT_URL@ -- the AWS-SDK-standard variables), it is parsed into the
backend's 'SqsEndpoint' so the released image can target a local emulator
(@ministack@) or a VPC endpoint without a test-only code path; a malformed override URL is
a fail-loud 'QueueEndpointMalformed' boot error. With no override, the SQS backend
uses AWS's default endpoint and credential resolution.
-}
planMirrorQueue :: AppConfig -> Either [BootError] MirrorQueuePlan
planMirrorQueue :: AppConfig -> Either [BootError] MirrorQueuePlan
planMirrorQueue AppConfig
env = case AppConfig -> QueueBackend
cfgQueueBackend AppConfig
env of
    QueueBackend
PubSubQueue -> [BootError] -> Either [BootError] MirrorQueuePlan
forall a b. a -> Either a b
Left [QueueBackend -> BootError
QueueProviderUnavailable QueueBackend
PubSubQueue]
    -- The in-memory backend needs no cloud queue: ECLUSE_QUEUE_URL and AWS_REGION are
    -- not consulted, so it can never fail on a missing one.
    QueueBackend
MemoryQueue -> MirrorQueuePlan -> Either [BootError] MirrorQueuePlan
forall a b. b -> Either a b
Right (MemoryQueueConfig -> MirrorQueuePlan
MemoryBackend (Int -> MemoryQueueConfig
defaultMemoryQueueConfig (AppConfig -> Int
cfgQueueMemoryMaxDepth AppConfig
env)))
    QueueBackend
SqsQueue -> case (Either BootError Text
regionE, Either BootError Url
urlE, AppConfig -> Either [BootError] (Maybe SqsEndpoint)
resolveSqsEndpoint AppConfig
env) of
        (Right Text
region, Right Url
url, Right Maybe SqsEndpoint
endpoint) ->
            MirrorQueuePlan -> Either [BootError] MirrorQueuePlan
forall a b. b -> Either a b
Right (SqsConfig -> MirrorQueuePlan
SqsBackend (Text -> Text -> SqsConfig
defaultSqsConfig (Url -> Text
unUrl Url
url) Text
region){sqsEndpoint = endpoint})
        (Either BootError Text
_, Either BootError Url
_, Either [BootError] (Maybe SqsEndpoint)
endpointE) ->
            -- Aggregate every SQS-resolution failure (missing region, missing queue
            -- URL, malformed endpoint) so one boot reports them all at once.
            [BootError] -> Either [BootError] MirrorQueuePlan
forall a b. a -> Either a b
Left ([Either BootError ()] -> [BootError]
forall a b. [Either a b] -> [a]
lefts [Either BootError Text -> Either BootError ()
forall (f :: * -> *) a. Functor f => f a -> f ()
void Either BootError Text
regionE, Either BootError Url -> Either BootError ()
forall (f :: * -> *) a. Functor f => f a -> f ()
void Either BootError Url
urlE] [BootError] -> [BootError] -> [BootError]
forall a. Semigroup a => a -> a -> a
<> [BootError]
-> Either [BootError] (Maybe SqsEndpoint) -> [BootError]
forall a b. a -> Either a b -> a
fromLeft [] Either [BootError] (Maybe SqsEndpoint)
endpointE)
  where
    -- AWS_REGION, required to scope the SQS queue; a blank value is treated as absent.
    regionE :: Either BootError Text
    regionE :: Either BootError Text
regionE = case Text -> Text
T.strip (Text -> Text) -> Maybe Text -> Maybe Text
forall (f :: * -> *) a b. Functor f => (a -> b) -> f a -> f b
<$> AppConfig -> Maybe Text
cfgAwsRegion AppConfig
env of
        Just Text
region | Bool -> Bool
not (Text -> Bool
T.null Text
region) -> Text -> Either BootError Text
forall a b. b -> Either a b
Right Text
region
        Maybe Text
_ -> BootError -> Either BootError Text
forall a b. a -> Either a b
Left BootError
QueueRegionMissing

    -- ECLUSE_QUEUE_URL is optional at the env layer; it is required here for SQS (the
    -- jobs need a queue to be sent to), an absent one being a fail-loud boot error.
    urlE :: Either BootError Url
    urlE :: Either BootError Url
urlE = Either BootError Url
-> (Url -> Either BootError Url)
-> Maybe Url
-> Either BootError Url
forall b a. b -> (a -> b) -> Maybe a -> b
maybe (BootError -> Either BootError Url
forall a b. a -> Either a b
Left (QueueBackend -> BootError
QueueUrlMissing QueueBackend
SqsQueue)) Url -> Either BootError Url
forall a b. b -> Either a b
Right (AppConfig -> Maybe Url
cfgQueueUrl AppConfig
env)

{- | The loud boot warning a 'MirrorQueuePlan' warrants before its queue is built, or
'Nothing' for a durable backend that needs none. The composition root logs the
'Just' at @WarningS@ on selection, so an operator who chose the in-memory backend is
told plainly that the mirror is non-durable -- never a silent surprise.
-}
mirrorQueuePlanWarning :: MirrorQueuePlan -> Maybe Text
mirrorQueuePlanWarning :: MirrorQueuePlan -> Maybe Text
mirrorQueuePlanWarning = \case
    SqsBackend SqsConfig
_ -> Maybe Text
forall a. Maybe a
Nothing
    MemoryBackend MemoryQueueConfig
_ -> Text -> Maybe Text
forall a. a -> Maybe a
Just Text
memoryQueueBootWarning

{- | The boot warning emitted when the in-memory mirror-queue backend is selected: it
states plainly that the mirror is in-memory, non-durable, and best-effort, and that a
lost job is re-mirrored on the next demand (so there is no data loss, only deferred
mirroring), so the choice is never mistaken for a durable cloud backend.
-}
memoryQueueBootWarning :: Text
memoryQueueBootWarning :: Text
memoryQueueBootWarning =
    Text
"mirror queue provider 'memory' selected: the mirror queue is IN-MEMORY, NON-DURABLE, and BEST-EFFORT. "
        Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
"Jobs are dropped on cap overflow and lost on restart or redeploy; each is re-mirrored on the next "
        Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
"demand (no data loss, only deferred mirroring). Use a durable backend ('sqs') for a production mirror "
        Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
"that must not shed under load."

{- | The cap-overflow drop warning for the in-memory backend, carrying the running
total of dropped jobs (this report is rate-limited at the queue, so it does not fire
per dropped job). A note on a one-line follow-up: a drop __metric__
(@ecluse.mirror.*@, S26 PR2) hooks in alongside this log once that catalogue lands.
-}
memoryQueueDropWarning :: Int -> Text
memoryQueueDropWarning :: Int -> Text
memoryQueueDropWarning Int
dropped =
    Text
"mirror queue at capacity: dropped a mirror job (drop-newest); "
        Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Int -> Text
forall b a. (Show a, IsString b) => a -> b
show Int
dropped
        Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
" job(s) dropped so far. Each is re-mirrored on the next demand; raise "
        Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
"ECLUSE_QUEUE_MEMORY_MAX_DEPTH to shed fewer under load."

{- Resolve the optional SQS endpoint override into an 'SqsEndpoint', or 'Nothing' for
AWS's default resolution. The AWS-SDK-standard @AWS_ENDPOINT_URL_SQS@ takes precedence
over the generic @AWS_ENDPOINT_URL@; the override URL is parsed into its TLS flag,
host, and port, and the request signing keys are taken from the standard
@AWS_ACCESS_KEY_ID@\/@AWS_SECRET_ACCESS_KEY@ (an emulator is off the ambient chain).
A malformed override URL is a fail-loud boot error. -}
resolveSqsEndpoint :: AppConfig -> Either [BootError] (Maybe SqsEndpoint)
resolveSqsEndpoint :: AppConfig -> Either [BootError] (Maybe SqsEndpoint)
resolveSqsEndpoint AppConfig
env =
    case Text -> Maybe Text
nonBlank (Text -> Maybe Text) -> Maybe Text -> Maybe Text
forall (m :: * -> *) a b. Monad m => (a -> m b) -> m a -> m b
=<< AppConfig -> Maybe Text
cfgAwsEndpointUrlSqs AppConfig
env of
        Maybe Text
Nothing -> Maybe SqsEndpoint -> Either [BootError] (Maybe SqsEndpoint)
forall a b. b -> Either a b
Right Maybe SqsEndpoint
forall a. Maybe a
Nothing
        Just Text
url -> case Text -> Maybe (Bool, Text, Int)
parseEndpointUrl Text
url of
            Maybe (Bool, Text, Int)
Nothing -> [BootError] -> Either [BootError] (Maybe SqsEndpoint)
forall a b. a -> Either a b
Left [Text -> BootError
QueueEndpointMalformed Text
url]
            Just (Bool
secure, Text
host, Int
port) ->
                Maybe SqsEndpoint -> Either [BootError] (Maybe SqsEndpoint)
forall a b. b -> Either a b
Right (SqsEndpoint -> Maybe SqsEndpoint
forall a. a -> Maybe a
Just SqsEndpoint{endpointSecure :: Bool
endpointSecure = Bool
secure, endpointHost :: Text
endpointHost = Text
host, endpointPort :: Int
endpointPort = Int
port})

{- Parse an endpoint URL into its (TLS flag, host, port). The scheme picks the TLS
flag and the default port (443\/80) when none is given; an absent scheme or a
non-numeric port yields 'Nothing'. The @host[:port]@ authority is split by the
shared bracket-aware 'Ecluse.Core.Security.splitHostPort', so a bracketed IPv6 literal
(@[::1]:4566@) is split on its closing bracket, not on an inner colon, and the host
is returned without brackets -- the same primitive the data-plane host extractor
uses, so the two cannot drift on an authority edge case. -}
parseEndpointUrl :: Text -> Maybe (Bool, Text, Int)
parseEndpointUrl :: Text -> Maybe (Bool, Text, Int)
parseEndpointUrl Text
raw = do
    (secure, afterScheme) <-
        ((Bool
True,) (Text -> (Bool, Text)) -> Maybe Text -> Maybe (Bool, Text)
forall (f :: * -> *) a b. Functor f => (a -> b) -> f a -> f b
<$> Text -> Text -> Maybe Text
T.stripPrefix Text
"https://" Text
raw) Maybe (Bool, Text) -> Maybe (Bool, Text) -> Maybe (Bool, Text)
forall a. Maybe a -> Maybe a -> Maybe a
forall (f :: * -> *) a. Alternative f => f a -> f a -> f a
<|> ((Bool
False,) (Text -> (Bool, Text)) -> Maybe Text -> Maybe (Bool, Text)
forall (f :: * -> *) a b. Functor f => (a -> b) -> f a -> f b
<$> Text -> Text -> Maybe Text
T.stripPrefix Text
"http://" Text
raw)
    let authority = (Char -> Bool) -> Text -> Text
T.takeWhile (Char -> String -> Bool
forall (f :: * -> *) a.
(Foldable f, DisallowElem f, Eq a) =>
a -> f a -> Bool
`notElem` [Char
'/', Char
'?', Char
'#']) Text
afterScheme
    (hostText, portText) <- splitHostPort authority
    host <- nonBlank hostText
    port <- case T.stripPrefix ":" portText of
        Maybe Text
Nothing -> Int -> Maybe Int
forall a. a -> Maybe a
Just (if Bool
secure then Int
443 else Int
80)
        Just Text
digits -> String -> Maybe Int
forall a. Read a => String -> Maybe a
readMaybe (Text -> String
forall a. ToString a => a -> String
toString Text
digits)
    pure (secure, host, port)

{- | The metadata-cache tunables drawn from the validated environment layer -- its
TTL and entry bound -- so a deployment's cache settings flow from config rather than
the built-in defaults (see "Ecluse.Core.Server.Cache").
-}
cacheConfigFor :: AppConfig -> CacheConfig
cacheConfigFor :: AppConfig -> CacheConfig
cacheConfigFor AppConfig
env =
    CacheConfig
        { cacheTtl :: NominalDiffTime
cacheTtl = AppConfig -> NominalDiffTime
cfgCacheTtl AppConfig
env
        , cacheMaxEntries :: Int
cacheMaxEntries = AppConfig -> Int
cfgCacheMaxEntries AppConfig
env
        , cacheMaxBytes :: Int
cacheMaxBytes = AppConfig -> Int
cfgCacheMaxBytes AppConfig
env
        }