-- SPDX-FileCopyrightText: 2026 Alexandra de Wit -- -- SPDX-License-Identifier: MIT {- | The npm __read and relay data plane__: the effectful metadata fetch and the first-party publish relay over @http-client@. This module is the network half of the npm read-side protocol boundary. Where "Ecluse.Core.Registry.Npm.Wire" and "Ecluse.Core.Registry.Npm.Project" are the pure decode and projection, this is the side-effecting exchange: 'fetchMetadataFormBounded' reads a metadata document bounded with every failure in its typed channel, and 'relayPublishDocument' forwards a client's own publish to the publication target. The mirror write is not here: its protocol codec lives in "Ecluse.Core.Registry.Npm.Publish" and executes through the shared transport ("Ecluse.Core.Registry.Publish"). It speaks the npm registry protocol directly with @http-client@, __never__ @amazonka@: the control plane (the @GetAuthorizationToken@ mint, the mirror queue) is @amazonka@'s job behind separate handles, but the data plane: fetch metadata, stream a tarball, publish: is ordinary HTTPS+JSON, identical across every npm-speaking backend. Keeping the streaming path off @amazonka@'s @conduit@/@ResourceT@ machinery is exactly what makes bounded-memory artifact proxying tractable. == Streaming and buffering The artifact request builders ('Ecluse.Core.Registry.Npm.Request.artifactRequestByFile' and 'Ecluse.Core.Registry.Npm.Request.artifactRequestByUrl') mark their requests __non-decompressing__ so a tarball is opaque binary that must reach the client byte-for-byte, and are exposed so the web layer can relay the open body __without buffering the whole artifact in memory__. The mirror worker, which must read the whole artifact to verify its integrity before publishing, buffers it (bounded) through 'Ecluse.Core.Worker.Fetch.fetchArtifactBytes' instead. == Authentication Every request here carries an __injected__ bearer token (or none); this module never originates credential policy. Which token to send on which request is the request pipeline's authority model, decided upstream of this module. -} module Ecluse.Core.Registry.Npm ( -- * Construction NpmClientConfig (..), -- * Bounded metadata fetch fetchMetadataFormBounded, -- * First-party publish relay relayPublishDocument, ) where import Network.HTTP.Client (Manager) import Ecluse.Core.Credential (Secret) import Ecluse.Core.Package (PackageName) import Ecluse.Core.Registry ( FetchFault (FetchUrlUnformable), PublishRelayFault (RelayUrlUnformable), PublishRelayResponse, RegistryResponse, ) import Ecluse.Core.Registry.Exchange (boundedFetch, boundedRelay) import Ecluse.Core.Registry.Npm.Publish (publishRequest) import Ecluse.Core.Registry.Npm.Request ( MetadataForm, Validators, metadataRequest, ) import Ecluse.Core.Security (Limits) {- | Everything this data plane needs to talk to one npm-speaking registry: the base URL, the shared HTTP 'Manager', and an optional injected bearer token. The 'Manager' is shared (it owns the connection pool), so it is taken rather than built here: the same one the composition root reuses across requests. The token is whatever the request pipeline decided this client should present; this module never chooses it. -} data NpmClientConfig = NpmClientConfig { NpmClientConfig -> Text npmBaseUrl :: Text {- ^ The registry base URL (e.g. the public registry, or a CodeArtifact npm endpoint). The package path is appended to it. -} , NpmClientConfig -> Manager npmManager :: Manager -- ^ The shared @http-client@ 'Manager' to issue requests through. , NpmClientConfig -> Maybe Secret npmToken :: Maybe Secret -- ^ An injected bearer token to attach, or 'Nothing' for anonymous requests. , NpmClientConfig -> Limits npmLimits :: Limits {- ^ The response-bound budget enforced on a metadata fetch: 'fetchMetadataFormBounded' reads the body through 'Ecluse.Core.Security.boundedRead' against 'Ecluse.Core.Security.maxBodyBytes', aborting fail-closed past the cap rather than buffering an unbounded body. -} } {- | Fetch a package's metadata in the requested 'MetadataForm', relaying any conditional-GET 'Validators', reporting __every__ fetch failure as a 'Ecluse.Core.Registry.FetchFault' value: an unformable request URL, a response-bound breach, or a transport fault ('classifyTransport' folds the @http-client@ exception into the typed channel at this edge). Total: no fetch failure escapes as an exception, so the serve read adapter ("Ecluse.Core.Registry.Npm.Metadata") threads it straight into its own typed channel with no throw-then-catch round-trip. The body is read __chunk-by-chunk through 'Ecluse.Core.Security.boundedRead'__ against the config's 'npmLimits', not buffered whole: a hostile or compromised upstream returning a body larger than 'Ecluse.Core.Security.maxBodyBytes' is refused __fail-closed__ as a 'FetchBoundExceeded' rather than exhausting memory. The transport wrap covers the __whole__ exchange, the body read included: metadata is buffered before anything is served, so a connection lost mid-body is still a pre-commit fault with a value representation, not a half-delivered response. -} fetchMetadataFormBounded :: NpmClientConfig -> MetadataForm -> Validators -> PackageName -> IO (Either FetchFault RegistryResponse) fetchMetadataFormBounded :: NpmClientConfig -> MetadataForm -> Validators -> PackageName -> IO (Either FetchFault RegistryResponse) fetchMetadataFormBounded NpmClientConfig config MetadataForm form Validators validators PackageName name = case Text -> Maybe Secret -> MetadataForm -> Validators -> PackageName -> Either UrlFormationError Request metadataRequest (NpmClientConfig -> Text npmBaseUrl NpmClientConfig config) (NpmClientConfig -> Maybe Secret npmToken NpmClientConfig config) MetadataForm form Validators validators PackageName name of Left UrlFormationError urlErr -> Either FetchFault RegistryResponse -> IO (Either FetchFault RegistryResponse) forall a. a -> IO a forall (f :: * -> *) a. Applicative f => a -> f a pure (FetchFault -> Either FetchFault RegistryResponse forall a b. a -> Either a b Left (UrlFormationError -> FetchFault FetchUrlUnformable UrlFormationError urlErr)) Right Request request -> Manager -> Limits -> Request -> IO (Either FetchFault RegistryResponse) boundedFetch (NpmClientConfig -> Manager npmManager NpmClientConfig config) (NpmClientConfig -> Limits npmLimits NpmClientConfig config) Request request {- | Relay a client's npm publish document to the publication target and return the target's own response: the first-party publish primitive behind the @PUT /{pkg}@ serve path. -} relayPublishDocument :: NpmClientConfig -> PackageName -> ByteString -> IO (Either PublishRelayFault PublishRelayResponse) relayPublishDocument :: NpmClientConfig -> PackageName -> ByteString -> IO (Either PublishRelayFault PublishRelayResponse) relayPublishDocument NpmClientConfig config PackageName name ByteString document = case Text -> Maybe Secret -> PackageName -> ByteString -> Either UrlFormationError Request publishRequest (NpmClientConfig -> Text npmBaseUrl NpmClientConfig config) (NpmClientConfig -> Maybe Secret npmToken NpmClientConfig config) PackageName name ByteString document of Left UrlFormationError urlErr -> Either PublishRelayFault PublishRelayResponse -> IO (Either PublishRelayFault PublishRelayResponse) forall a. a -> IO a forall (f :: * -> *) a. Applicative f => a -> f a pure (PublishRelayFault -> Either PublishRelayFault PublishRelayResponse forall a b. a -> Either a b Left (UrlFormationError -> PublishRelayFault RelayUrlUnformable UrlFormationError urlErr)) Right Request request -> Manager -> Limits -> Request -> IO (Either PublishRelayFault PublishRelayResponse) boundedRelay (NpmClientConfig -> Manager npmManager NpmClientConfig config) (NpmClientConfig -> Limits npmLimits NpmClientConfig config) Request request