Architecture and requirements
Index to Écluse's systems design: the vision, how a request flows,
and what is out of scope. Each concern's detailed design lives under architecture/.
Development practices, layout, testing, and CI live in ../CONTRIBUTING.md;
the why is in ../MOTIVATION.md. This document
and its links are the how.
These documents describe the target design, not necessarily the current code. Implementation tracks toward them; check
gitand theplanning/DAG for what has shipped.
Vision
Supply-chain attacks through malicious or hijacked publications are a
growing threat in high-volume ecosystems like npm.
Écluse (package ecluse) is a lightweight
proxy between consumers (developers, CI) and the upstream registry that
applies a configurable 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 it reaches 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 (e.g. AWS CodeArtifact or GCP Artifact Registry) and enforces policy on what may be fetched and mirrored from the public registry.
Codebase decomposition
Écluse builds as two libraries behind one ecluse.cabal,
splitting the pure capability core from the composition shell:
ecluse-core(core/src,Ecluse.Core.*): the ecosystem-agnostic core, the domain model, registry adapters, version grammars, pure and effectful rule tiers, credential refresh, queue and security primitives, the agnostic server layer (routing, response model, streaming, conditional-GET, metadata cache, serve admission, request pipeline), the telemetry instrument catalogue, and the mirror worker. It depends on the OpenTelemetry API only, never the SDK, so it carries no process-global wiring.ecluse(src,Ecluse.*): the shell that composes the core into a running proxy, the config loader, theEnvcomposition root, logging, the WAIApplication, and the telemetry SDK / OTLP export wiring.ecluseexecutable (app/Main.hs): a multicall CLI router for theserve,pilot, anddredgerroles.
The build graph enforces the boundary: the core's unit suite does not
depend on the application library, so a core module reaching into
composition fails to compile. ecluse.cabal
is the authoritative component and module map.
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."])
- Tarball/artifact, gated for one version. A private
hit streams unfiltered (already vetted); a private miss fetches the
version's public metadata, runs the rules, and either streams from
public and enqueues a mirror job or returns the error
model (403 / 503 / 500). Lockfile installs (
npm ci) hit tarball URLs directly, so the artifact path gates on its own. Mirroring is demand-driven: a job is enqueued only when an artifact is accepted here, so only versions actually pulled are mirrored. - Packument, a merge, not a fallback. Private and
public upstreams are fetched in parallel; public versions are
rule-filtered while private versions are trusted; the two combine into
one document (private wins a collision, integrity divergence is flagged,
latestis repointed to the newest survivor). A 403/503 returns only if nothing survives. Merging keeps not-yet-mirrored public versions visible so demand-driven mirroring can fire for them. See Packument merge and Applying verdicts. - Publish (
PUT /{pkg},npm publish), the one client-driven write. Accepted at the mount, checked against the operator's publish-scope allow-list (anti-shadowing, rejected before any upstream write), and relayed to the publication target with the publisher's own forwarded credential. Opt-in (a405whenECLUSE_MOUNTS__NPM__PUBLICATION_TARGETis unset). Published packages read back through the private upstream, distinct from the mirror target the worker writes with Écluse's own credential. See Publishing first-party packages.
Document map
| Document | Covers |
|---|---|
| Diagrams | Mermaid visual companion: system overview, packument / tarball / worker sequences, rules and credential lifecycles. |
| Registry Model | The four registry roles (two reads, two writes) and the
RegistryClient handle. |
| Internal Domain Model | PackageDetails and the ecosystem-agnostic signals the
rules engine consumes. |
| Web Layer | Raw-WAI front door: routing and multi-ecosystem mounts, the control/data-plane split, streaming, middleware, and graceful shutdown. |
| API Surface & Capability Manifest | The OpenAPI capability manifest and the synthesised-packument schema. |
| Rules Engine & Responses | Deny-by-default evaluation, the rule tiers, the CVE subsystem, and denial responses. |
| Cloud Backends & Mirroring | The mirror queue and the two cloud handles
(MirrorQueue, CredentialProvider); AWS and
GCP. |
| Configuration & Authentication | Environment config, outbound registry credentials, and inbound client auth. |
| Access & Credential Model | The per-mount credential strategy (passthrough /
service), edge auth, and the no-private-cache posture. |
| Security Invariants | Outbound-request and input-validation defences, canonicalisation, the host allowlist, internal-range blocking, response bounds. |
| 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. |
| Technology Stack | Library choices and the key cross-cutting decisions. |
| Release & Supply-Chain Operations | The reproducible OCI image, the publish/attest chain (provenance + SBOM), Docker Hub tokens, and CVE and freshness scanning. |
Out of scope (for now)
- Package hosting / storage (delegated to the registries).
- Mirroring to raw object storage (S3 / GCS): the mirror target is a
registry and writes go through
publishArtifact; revisit only for a non-registry mirror target. - Web UI or admin API.
- Re-specifying upstream registry protocols in the capability manifest: Écluse documents its coverage, not npm's full packument / registry contract, which clients hardcode.
- Non-npm adapters: the mount model and
RegistryClienthandle accommodate them (see Multi-ecosystem mounts), but only npm ships at launch. PyPI and RubyGems are planned. - Cloud IAM validation at the proxy edge (gateway concern).
- Local on-disk caching of artifacts (the mirror retry window is acceptable).
- GCP backends at launch: the cloud handles (mirror queue, managed-registry token) are designed for GCP, but a GCP backend is gated on the client-viability spike; AWS ships first (see Cloud Backends).