ecluse
Safe HaskellNone
LanguageGHC2021

Ecluse.Server.Response

Description

The serve-outcome model, the per-outcome status mapping, and the agnostic shape of an error body.

Every client-facing reply is the rendering of one serve outcome — admit the request, or reject it — so that an error maps to the status a client can act on rather than a generic 403/500. The model and the per-outcome status mapping live here; the WAI layer that turns an ArtifactStatus into an actual response and streams the body is separate (see docs/architecture/web-layer.md).

This module decides the HTTP status of a refusal but holds __no body shape of its own__: the bytes a client reads an error from are an ecosystem's (npm's {"error": …} JSON, a different surface for PyPI), so a mount supplies a MountRenderer — chosen at the composition root alongside its path grammar — and the agnostic web layer never names one. appendHelp is the ecosystem-neutral part the renderer reuses: joining the operator help message onto a denial.

The outcome model

A ServeDecision is Admit or Reject with a Rejection carrying a RejectReason. A rejection is either by policy (a rule denied the version, including deny-by-default) or unavailable — the version could not be decided, carrying its Transience: whether the evaluator believes the condition will self-heal. The whole verdict pipeline (Ecluse.Rules) feeds this: a rules Decision projects to a ServeDecision via serveDecisionOf.

Status follows the cause

For a concrete artifact (one specific version) the outcome renders to a single ArtifactStatus. The load-bearing rule is __503 only when we believe it will resolve__ — a transient upstream/advisory condition invites a retry, while a permanent or internal inability to decide (WontResolve) is a 500, because retrying it cannot help and we should not invite it. A policy rejection is a 403 whose body the mount's MountRenderer shapes. A packument request has no single status — its versions are filtered and the status is chosen over the surviving set — so this module deliberately maps per outcome, not per request.

An operator help message, when configured, is appended to every denial (appendHelp) so clients are told where to ask; how the joined text is then wrapped into bytes is the mount renderer's.

Synopsis

Serve outcomes

data ServeDecision Source #

The outcome of deciding a request: serve it, or refuse it with a reason.

Every client-facing reply renders one of these. Admit carries no payload — the artifact or packument is what is then streamed; Reject carries the Rejection that explains the refusal and selects the status.

Constructors

Admit

Serve the request (the 200 stream for an artifact).

Reject Rejection

Refuse the request, with the reason and a client-facing message.

Instances

Instances details
Show ServeDecision Source # 
Instance details

Defined in Ecluse.Server.Response

Eq ServeDecision Source # 
Instance details

Defined in Ecluse.Server.Response

data Rejection Source #

A refusal: why it was refused, and an intuitive message for the client. The rejectionReason selects the HTTP status; the rejectionMessage is the human-facing text rendered into the response body.

Constructors

Rejection 

Fields

Instances

Instances details
Show Rejection Source # 
Instance details

Defined in Ecluse.Server.Response

Eq Rejection Source # 
Instance details

Defined in Ecluse.Server.Response

data RejectReason Source #

Why a request was refused.

A policy refusal is a deliberate verdict and is final for this request; an unavailability is an inability to decide and carries whether it is expected to self-heal, which is what separates a retryable 503 from a terminal 500/403.

Constructors

ByPolicy RuleName

A rule denied the version (including deny-by-default). The RuleName is the rule that decided, for the audit trail and the denial body.

Unavailable Transience

The version could not be decided — an effectful rule the evaluator needed could not be consulted (advisory source down, timeout). This is fail-closed: a never-vetted version is not admitted just because the scanner is unreachable. The Transience says whether a retry can help.

MissingIntegrity

The version's selected artifact carries __no integrity digest of any kind__ (neither an SRI nor a legacy shasum), so its bytes cannot be tied to a tamper-evident fingerprint. A version without an integrity check is inadmissible from an untrusted (public) upstream — there is nothing to detect a divergence against — so admission refuses it outright. This is a deliberate, deny-by-default admission policy, not a rule decision and not a retryable inability: it maps to a 403. The trusted private upstream is exempt; this reason never arises on that path.

BelowIntegrityFloor

The version's selected artifact carries an integrity digest, but its strongest one is weaker than the configured minimum algorithm (e.g. a legacy SHA-1 shasum only, under the default SHA-256 floor). A collision-broken digest cannot tie the bytes to a tamper-evident fingerprint, so it is inadmissible from an untrusted (public) upstream — distinct from MissingIntegrity (which has no digest at all) so the audit trail says which. A deny-by-default admission policy that maps to a 403; the trusted private upstream is exempt and this reason never arises on that path.

UpstreamInvalid

A responding upstream returned an invalid response for the requested package — its packument self-reported a name for a different package, so that origin is untrusted for this request and its contribution is dropped. It is not a policy verdict and not a retryable inability but a gateway fault: when no origin yields a valid packument and a responding one was invalid this way, the packument request maps to a 502. Distinct from a genuine absence (no such package at all), which is not refused this way. Arises on the packument path only — the artifact path never validates a packument name.

Instances

Instances details
Show RejectReason Source # 
Instance details

Defined in Ecluse.Server.Response

Eq RejectReason Source # 
Instance details

Defined in Ecluse.Server.Response

data Transience Source #

Whether an unavailability is expected to resolve on its own.

This is the single distinction the serve status mapping turns on: a transient cause (WillResolve) is worth retrying (a 503); a permanent or internal one (WontResolve) is not, so it must not be dressed up as a retryable 503 (it is a 500). The effectful tier sets it from the nature of the failure: an upstream outage, rate limit, timeout, or open breaker is transient; an internal or parse fault is not.

Constructors

WillResolve (Maybe RetryAfter)

Transient — a retry may succeed (an advisory source briefly down, a timeout, an open circuit breaker). The optional RetryAfter is the delay to suggest to the client.

WontResolve

Not expected to self-heal (an internal or parse error). Retrying cannot help, so the request is a 500, never a 503.

Instances

Instances details
Show Transience Source # 
Instance details

Defined in Ecluse.Rules.Types

Eq Transience Source # 
Instance details

Defined in Ecluse.Rules.Types

newtype RetryAfter Source #

A Retry-After delay, in whole seconds. A 'newtype' so a raw count of seconds is never confused with some other integer when it reaches the response header.

Constructors

RetryAfter Int 

newtype RuleName Source #

The name of the rule that decided a refusal, carried for the audit trail and the denial body. A 'newtype' over the ruleName text so a rule identity is not just any string.

Constructors

RuleName Text 

Instances

Instances details
Show RuleName Source # 
Instance details

Defined in Ecluse.Server.Response

Eq RuleName Source # 
Instance details

Defined in Ecluse.Server.Response

Ord RuleName Source # 
Instance details

Defined in Ecluse.Server.Response

serveDecisionOf :: PackageDetails -> Decision -> ServeDecision Source #

Project a rules Decision (see Ecluse.Rules) into a serve outcome. Pure and total.

An Approved decision admits; a Denied or DeniedByDefault decision rejects ByPolicy, naming the deciding rule and carrying the human-readable renderDecision as the message. An Undecidable decision (a needed effectful rule could not be consulted) rejects as Unavailable, carrying its Transience so the status mapping can choose 503 vs 500fail-closed, exactly as a denial removes a version, but flagged retryable when the cause may self-heal. Only an approval admits.

Concrete-artifact status

data ArtifactStatus Source #

The HTTP status a concrete-artifact request renders to. A domain sum type (not a raw code) so the mapping is total and the WAI layer reads off an exhaustive set; artifactStatusCode gives the numeric code.

A packument request has no single status — its versions are filtered and a status is chosen over the survivors — so this type models only the concrete-artifact case.

Constructors

Ok

200 — admitted; the artifact is streamed.

Forbidden

403 — refused by policy; the body is shaped by the mount's MountRenderer.

Unavailable' (Maybe RetryAfter)

503 — a transient inability to decide; the RetryAfter, if known, becomes the Retry-After header.

ServerError

500 — a permanent or internal inability to decide; not retryable.

NotFound

404 — the upstream did not have the artifact (forwarded miss).

artifactStatus :: ServeDecision -> ArtifactStatus Source #

Map a serve outcome to its concrete-artifact status. Pure and total.

403 for a policy refusal; 503 when an unavailability WillResolve (a retry may help); 500 when it WontResolve. __503 only when we believe it will resolve__ — a permanent or internal inability is a 500, since retrying it cannot help. A 404 upstream miss is not a serve decision (the version exists unless upstream says otherwise), so it is not produced here.

artifactStatusCode :: ArtifactStatus -> Int Source #

The numeric HTTP status code for an ArtifactStatus. Pure and total.

Packument status (over the merged survivor set)

data PackumentStatus Source #

The HTTP status a packument request renders to, chosen once the merged survivor set is known. A packument has no single per-version status — its versions are filtered and merged across upstreams — so the status is chosen __over the survivors__: with at least one survivor the document is served; with none, the status follows the most recoverable cause among the exclusions (see packumentStatus).

A domain sum (not a raw code) so the mapping is total and the WAI layer reads an exhaustive set; packumentStatusCode gives the numeric code. There is no 404: a packument whose versions were all withheld is not a miss — the package exists, so a genuine upstream absence (no such package at all) is a separate concern of the serve layer, decided before the merge.

Constructors

PackumentOk

200 — at least one version survived; the merged, filtered packument is served.

PackumentForbidden

403 — no version survived and every exclusion was a policy denial; the response body collects the denial reasons.

PackumentUnavailable (Maybe RetryAfter)

503 — no version survived, but at least one exclusion may self-heal (a transient rule outcome, or a needed upstream that was unavailable), so a retry may yet yield survivors. The RetryAfter, if any was suggested, becomes the Retry-After header.

PackumentBadGateway

502 — no version survived because a responding upstream returned an invalid response (a packument self-reporting a different package's name), and no origin yielded a valid packument. A gateway fault, distinct from a genuine absence (no such package) and from a retryable outage: the upstream answered, but with a document for the wrong package.

PackumentServerError

500 — no version survived, no exclusion is retryable, and at least one is a permanent or internal inability to decide; retrying cannot help.

packumentStatus :: [ServeDecision] -> PackumentStatus Source #

Choose a packument's status from the per-version serve outcomes weighed for it: the Admits for surviving versions (trusted, or rule-approved) and the Rejects for excluded ones — plus any Reject a needed-but-unavailable upstream contributes. Pure and total.

Any Admit means the merged document has a survivor, so it is served (PackumentOk). With no survivor the status follows the most recoverable cause among the exclusions, so a retry is invited exactly when it might produce survivors:

  • any Unavailable WillResolve503, suggesting the longest RetryAfter any such cause asked for (so every transient cause has likely cleared by then);
  • else any UpstreamInvalid502 (a responding upstream returned a packument for a different package; ranked above the terminal 500/403 because it names a concrete, actionable gateway fault, but below the retryable 503 since a transient origin may yet come back with a valid document);
  • else any Unavailable WontResolve500 (a permanent inability — a retry cannot help, so it is not dressed up as a retryable 503);
  • else every exclusion is a deny-by-default cause — a ByPolicy rule denial or an admission refusal (MissingIntegrity or BelowIntegrityFloor), __including the degenerate empty input__ → 403: there is nothing to serve and nothing invites a retry.

Never 404: the versions existed and were withheld (see PackumentStatus).

packumentStatusCode :: PackumentStatus -> Int Source #

The numeric HTTP status code for a PackumentStatus. Pure and total.

longestRetry :: [Maybe RetryAfter] -> Maybe RetryAfter Source #

The longest suggested RetryAfter among transient causes, or Nothing when none of them suggested a delay.

Denial rendering

data HelpMessage Source #

An operator-configured message appended to every denial — typically where to ask for help (e.g. a support channel). Stored trimmed of surrounding whitespace so it joins the denial text with a single separating space and an all-blank value contributes nothing.

Instances

Instances details
Show HelpMessage Source # 
Instance details

Defined in Ecluse.Server.Response

Eq HelpMessage Source # 
Instance details

Defined in Ecluse.Server.Response

mkHelpMessage :: Text -> HelpMessage Source #

Build a HelpMessage, trimming surrounding whitespace.

unHelpMessage :: HelpMessage -> Text Source #

The trimmed help-message text.

appendHelp :: Maybe HelpMessage -> Text -> Text Source #

Append a non-blank operator HelpMessage to a denial message, separated by a single space; a blank or absent help message contributes nothing.

This is the ecosystem-neutral part of denial rendering — every ecosystem appends the operator's help text the same way. How the joined text is then wrapped into body bytes is the mount's MountRenderer.

data RenderedBody Source #

A rendered error body: its Content-Type and the bytes.

The agnostic serve layer chooses the HTTP status; the body shape — JSON, plain text, HTML — is the mount's, so a MountRenderer returns this pair and the WAI layer reads the content type off it rather than assuming one.

Constructors

RenderedBody 

Fields

Instances

Instances details
Show RenderedBody Source # 
Instance details

Defined in Ecluse.Server.Response

Eq RenderedBody Source # 
Instance details

Defined in Ecluse.Server.Response

newtype MountRenderer Source #

A mount's ecosystem-specific error renderer — the Handle that keeps the npm {"error": …} shape (and any other ecosystem's) out of the agnostic web layer.

The status machinery here is ecosystem-agnostic, but the body a client reads an error from is not: an npm client expects a JSON {"error": …} object, a PyPI client a different surface. Each mount supplies a renderer, chosen at the composition root alongside its path grammar, so the web layer holds no body shape of its own. renderError shapes a denial or meta-route error (a 403/404/501 body) from the optional operator help message and the human-facing reason.