Architecture and requirements

Index to Écluse's systems design: what it is, how a request flows, and what is out of scope. Each concern's detailed design lives under architecture/. Development practices, layout, testing, and CI are in ../CONTRIBUTING.md; the why is in ../MOTIVATION.md. This document and its links are the how.

Écluse is a supply-chain policy proxy for package registries. It sits between consumers (developers, CI) and the upstream registry and applies a deny-by-default policy before any package reaches a build, without hosting packages itself. The name is French for a canal lock: the controlled passage every dependency clears before a build. The goal is resilience, mitigating the blast radius of a bad publish, not malware detection.

Écluse is not a registry. It delegates storage to the operator's backend (AWS CodeArtifact today) and enforces policy on what may be fetched from, and mirrored from, the public registry.

Codebase decomposition

Écluse builds as three libraries behind one ecluse.cabal:

The dependency arrow points inward only (ecluseecluse-runtimeecluse-core), so a core module reaching outward fails to compile; ecluse.cabal and the README architecture section are the authoritative module map. The ecluse executable (app/Main.hs) is a thin multicall router for the proxy, pilot, and dredger roles, plus check-config, which resolves the configuration and prints the posture without booting anything.

The stack at a glance

Écluse is Haskell (GHC 9.10), built and pinned through Nix flakes; ecluse.cabal and flake.lock are the dependency authority. The server is raw WAI on warp, chosen for direct control over routing and memory-bounded streaming rather than a web framework. The data plane is http-client; JSON is aeson, with owned schemas codec-derived via autodocodec so the wire format and the documented schema cannot drift. Cloud integration is AWS today (amazonka: SQS mirror queue, CodeArtifact token mint, STS workload identity, S3 advisory storage), with GCP on the roadmap. Observability is opt-in OpenTelemetry over OTLP. Tests run hspec and hedgehog, with cloud integration against emulators and no real credentials.

System overview

A single Écluse binary runs the HTTP server and an in-process mirror worker over a shared, handle-based Env. The data plane (metadata and artifact bytes) is http-client; the control plane (queue, token mint) sits behind the MirrorQueue and CredentialProvider handles. Solid edges are synchronous request-path, dotted are best-effort or asynchronous.

flowchart LR
    DEV["Developer / CI<br/>(npm, npm ci)"]

    subgraph ecluse["Écluse (single binary)"]
        direction TB
        WEB["Web layer<br/>router, streaming, middleware"]
        RULES["Rules engine<br/>deny-by-default"]
        CACHE["Metadata cache<br/>short-TTL, in-memory"]
        SYNC["Advisory sync<br/>in-memory OSV index"]
        WORKER["Mirror worker<br/>in-process, supervised"]
    end

    subgraph registries["Registries (npm protocol)"]
        PRIV["Private upstream<br/>e.g. CodeArtifact"]
        PUB["Public upstream<br/>registry.npmjs.org"]
        MIRROR["Mirror target<br/>managed npm registry"]
        PUBT["Publication target<br/>first-party publishes (opt-in)"]
    end

    subgraph handles["Cloud handles"]
        QUEUE["MirrorQueue<br/>SQS / Pub/Sub"]
        CRED["CredentialProvider<br/>mint + refresh token"]
    end

    OSV["OSV advisory exports"]

    DEV -->|"packument / tarball / publish"| WEB
    WEB --> RULES
    WEB --> CACHE
    WEB -->|"read: client token forwarded"| PRIV
    WEB -->|"read: anonymous"| PUB
    WEB -->|"publish (write): client token forwarded"| PUBT
    WEB -.->|"enqueue (best-effort)"| QUEUE
    RULES -.->|"reads index"| SYNC
    SYNC -->|"periodic pull"| OSV
    WORKER -->|"receive / ack"| QUEUE
    WORKER -->|"fetch artifact"| PUB
    WORKER -->|"token"| CRED
    WORKER -->|"publish (write)"| MIRROR

Request lifecycle

The three request shapes use the upstreams differently: a tarball falls back, a packument merges, and a publish writes through.

flowchart TD
    C(["Client request"]) --> K{"packument, tarball, or publish?"}

    K -->|"tarball"| T1["Fetch from private upstream"]
    T1 -->|"2xx hit"| TSV(["Stream unfiltered. Done."])
    T1 -->|"miss"| T2["Fetch version metadata from public<br/>+ evaluate rules (deny by default)"]
    T2 -->|"Denied / Unavailable"| TD(["403 / 503 / 500. Done."])
    T2 -->|"Admitted"| T3["Stream from public + enqueue mirror job<br/>(non-blocking)"]
    T3 --> TSV2(["Serve immediately. Done."])

    K -->|"packument"| P1["Fetch private + public in parallel"]
    P1 --> P2["Trust private versions;<br/>gate public versions (rules, deny by default)"]
    P2 --> P3["Merge (private wins; flag divergence),<br/>filter, repoint latest"]
    P3 -->|"survivors"| PSV(["Serve merged packument. Done."])
    P3 -->|"none survive"| PD(["403 / 503. Done."])

    K -->|"publish (PUT)"| W1{"ECLUSE_MOUNTS__NPM__PUBLICATION_TARGET set?"}
    W1 -->|"no"| W405(["405 Method Not Allowed. Done."])
    W1 -->|"yes"| W2["Enforce publish-scope allow-list<br/>(anti-shadowing)"]
    W2 -->|"out of scope"| WR(["4xx, no upstream write. Done."])
    W2 -->|"in scope"| W3["Write to publication target<br/>(client token forwarded)"]
    W3 --> WSV(["npm success. Done."])

Document map

Document Covers
Registry model The four registry roles (two reads, two writes), the domain vocabulary, and the registry abstraction.
Web layer Raw-WAI front door: routing, mounts, the capability manifest, the control/data-plane split, streaming, and graceful shutdown.
Rules engine and responses Deny-by-default evaluation, the rule tiers, the CVE subsystem, and denial responses.
Cloud backends and mirroring The mirror queue and the two cloud handles (MirrorQueue, CredentialProvider); AWS today, GCP planned.
Configuration and authentication Environment config, outbound registry credentials, and inbound client auth.
Access and credential model How reads are credentialled (the caller's credential is forwarded, public reads anonymous), why the private origin is never cached, and the planned edge-auth model.
Security invariants Outbound-request and input-validation defences: canonicalisation, the host allowlist, internal-range blocking, and response bounds.
Fault model Failures as typed values, the confined-exception pattern, the two outer edges, and the disposition vocabulary.
Threat model The STRIDE register, generated from the Threat Dragon model (threat-modelling/ecluse.json); the single source of truth for the system's threats.
Observability Opt-in OpenTelemetry/OTLP tracing and metrics; Datadog optional.
Release and supply-chain operations The reproducible OCI image, the publish/attest chain (provenance + SBOM), and CVE and freshness scanning.

Out of scope