Skip to main content

What If npm Ran on AT Protocol?

While I was at the Web Engines Hackfest last week an idea managed to get lodged into my brain and I ended up spending much of the very long series of flights home working through the details. The idea: what would a package registry look like if it were built on AT Protocol?

I'm not a registry expert. I have spent years working on Node.js internals and on protocol-level infrastructure, but I have never operated a package registry at scale. There are operational realities of running something like npmjs.com that I am likely under-considering here. So this is just a thought experiment from someone who works adjacent to the problem, not a production blueprint from someone who has run one.

That said, however, the more I pulled on the thread, the more interesting it got. Some of the mappings between atproto's primitives and registry concerns are surprisingly straightforward. Some of the problems are genuinely hard. And a few things emerged that I was not expecting -- architectural properties that the centralized model cannot easily replicate.

The mapping

For those unfamiliar, atproto is the protocol that powers Bluesky. It is a federated protocol built around a few key primitives: Decentralized Identifiers (DIDs) for identity, Personal Data Servers (PDS) for storage, lexicon-typed records for structured data, a Merkle Search Tree (MST) for cryptographic integrity, a relay firehose for real-time data distribution, App Views for aggregation and presentation, and a labeling system for moderation and trust signals.

These map onto package registry concerns naturally:

npm concernatproto primitive
User identityDIDs + domain-verified handles
Package namespace / scopingHandle-based naming (@jsnell.dev/my-package)
Package metadataLexicon-typed records in user repos
Tarball storageBlobs attached to records
The "registry"App View (aggregator / indexer)
Real-time publish propagationRelay firehose
Security advisoriesLabels

One philosophical shift is important. In npm today, you publish to a registry. In this model, you publish in your own repo, and registries discover you. The registry becomes a read-side aggregator, not an authority. Your package data lives on your PDS, signed by your DID, under your control.

A package record might look something like:

lexicon sketches
// Package declaration (in publisher's repo)
app.npm.package.record
  name: "my-library"
  description: "A useful library"
  license: "MIT"
  repository: "https://github.com/jsnell/my-library"
 
// Version record (in publisher's repo)
app.npm.package.version
  package: at-uri           // ref to the package record
  version: "1.2.3"          // semver
  manifest: bytes           // full package.json
  tarball: blob             // the .tgz
  integrity: "sha512-..."   // SRI hash
  sourceRepo: string        // declared authoritative source
 
// Publication attestation (in individual's repo, for org publishes)
app.npm.publish.attestation
  org: did:plc:orgdid
  package: at-uri
  version: "1.2.3"
  integrity: "sha512-..."

These are sketches, not a specification. The point is that the data model maps naturally onto atproto's record and blob primitives without forcing anything.

Naming

In npm today, lodash is a globally unique name. First come, first served. This is the source of typosquatting, name squatting, and an endless stream of "who owns this name" disputes.

In the atproto model, there is no global namespace. Every package is scoped to a DID, and DIDs map to domain-verified handles. Your handle IS your scope. @jsnell.dev/lodash is my lodash, in my repo. @someone-else.dev/lodash is theirs. There is no collision, no squatting, no ambiguity. This is similar to the approach that JSR takes and honestly it's one of the things that JSR gets absolutely correct. There should be no global namespace for packages.

This is a deliberate design choice, not a limitation. npm install foo becomes npm install @jsnell.me/foo -- a few extra characters.

Handles can change -- domains lapse, people move -- but this is a display concern, not a security concern. Lockfiles pin DIDs, not handles. The cryptographic identity is stable even when the human-readable name is not.

Organizations

npm has organizations. Multiple people can publish @babel/core. Teams have per-package permissions. How does this work when atproto's DIDs are individual identities with no native delegation mechanism? Organizations can run their own PDS.

Projects like Babel or Node.js, or companies like Cloudflare and Vercel already run infrastructure. Adding a PDS is not too much of a stretch. The org has its own DID. All of the org's package records live in the org's repo. Individual developers authenticate to the org's PDS, and the PDS handles authorization internally. "Who can publish what" is access control on a service the org already runs

But we still want individual attribution for supply chain forensics. The publish flow can produce two records on two independent systems:

The version record in the org's repo, signed by the org's DID. And a publication attestation in the individual developer's own repo, signed by their own DID. Both records reference the same version and integrity hash. Both are independently verifiable.

This matters because if the org's PDS is compromised, an attacker can create version records in the org's repo -- but they cannot create matching attestation records in individual developers' repos. Those are on different PDS instances, with different infrastructure, and different signing keys. An attack is detectable: a version exists without a valid individual attestation. The attacker would need to compromise both the org's PDS and an individual developer's PDS.

npm does not have this property today. Compromising the registry, or a single auth token, is enough.

The trust model

This is the part that matters most, and it requires honesty about what atproto's cryptography actually proves and where the gaps are.

Cryptographic integrity

Every record in an atproto repo is part of a Merkle Search Tree. The root of the MST is signed by the repo's signing key. This proves that the holder of this DID's signing key authorized this record and that the record has not been tampered with in transit.

It does NOT prove that the key holder has not been compromised, that the code is safe, that the code matches the source repository, or that the build process was clean.

Identity

DIDs are stable, portable identifiers. Handles are domain-verified via DNS. If @jsnell.me publishes a package, anyone can verify that the DID behind that handle controls jsnell.me. This is stronger than npm usernames, which are just strings in a database.

Most developers will use a hosted PDS rather than running their own. The hosted PDS holds your signing key. This gives the PDS provider the same power that npmjs.com has today -- they could publish on your behalf. The "your keys, your data" narrative is only true for self-hosters.

This is real but approachable. For organizations running their own PDS, it is the right model -- the org should control its own key. For individuals, split key schemes are well-understood cryptography: the PDS holds one share, the publisher's client holds another, neither can sign unilaterally. This would require protocol work that does not exist today, but the techniques are not new.

Build provenance

atproto's signing proves WHO published a package. It says nothing about WHERE the code came from. For that, you need Sigstore-style provenance attestations that tie a tarball to a specific CI build and git commit. These are complementary, not competing. You would layer provenance on top of atproto the same way npm layers it on top of the npm registry.

The package record can explicitly declare its source repository, signed by the publisher's DID. Now you have a three-way chain: the atproto record says "DID X publishes package Z from repo Y." The Sigstore attestation says "this tarball was built from commit C in repo Y." Verification confirms the declared source matches the attested build source.

npm's provenance can tell you "built from this repo" but there is no strong binding between the npm account and the repository. Someone could fork a repo, set up a CI workflow, and publish with technically valid provenance from the wrong source. In the atproto model, the publisher's signed record declares the authoritative source. A build from a different repo produces a detectable mismatch. atproto signing is not redundant with Sigstore -- it provides the identity-to-source binding that Sigstore alone cannot.

Name resolution

The App View maps package names to DIDs. This is actually a vulnerability.

Consider the attack: a developer runs npm install popular-package. The App View resolves popular-package to did:plc:attacker. The attacker's DID has properly signed records for popular-package. The client verifies signatures -- they check out. The developer installs malicious code that passes every cryptographic check.

The crypto is correct but the naming is wrong.

The mitigation is to make name resolution itself auditable through the same two-party attestation pattern used for org publishing. When a name-to-DID mapping is created, two records are produced: a name claim in the publisher's repo, and a matching name grant in the App View's repo (the App View has its own DID). Both are signed. Both flow through the firehose. Anyone can audit them.

The core rule is that a mapping without both records should be distrusted.

For name transfers, the pattern extends naturally. When a package changes hands, the old publisher creates a transfer record. The new publisher creates a claim. The App View creates a change record referencing both. The absence of the old publisher's transfer record is a strong signal that the name changed without the original owner's consent. Compare this to npm's npm transfer, which is invisible to consumers.

Lockfiles pin DIDs, not handles. First install trusts the App View's resolution (plus attestation verification). Subsequent installs verify against the lockfile. If the DID changes, the lockfile check fails and requires manual review. This is the same trust-on-first-use model as SSH known_hosts, improved by the public auditability of the attestation records.

Labels

Labels in atproto offer something npm genuinely does not have but services like npmx are starting to explore.

atproto's labeling system allows independent services to publish typed labels on records or DIDs. Labels are consumed by App Views and clients. Users choose which labelers to trust. For a package registry, labels could represent security advisories, malware flags, audit results, deprecation notices, quality metrics.

npm's advisory system is a single centralized database owned by GitHub. One entity decides what qualifies as an advisory, what the severity is, what versions are affected. If they are slow or wrong or have conflicts of interest, there is no alternative.

With atproto labels, multiple independent security teams can run labeler services. Your company's security team can run a labeler with stricter policies. Open source security foundations can provide independent assessments. Disagreement between labelers is visible and auditable. No single entity gatekeeps what counts as a security issue.

Scale is not a concern here. Bluesky's labeling infrastructure handles far greater volume than npm's publish rate. Package labeling is also more batch-friendly -- labels are applied at publish time, not in a real-time social feed. The workload is lighter than what atproto already handles.

Revocation

npm can remove a malicious package immediately preventing new installations. Existing installs still have the malicious code and must be updated separately.

In the atproto model, clients can proactively check revocation labels before install. A labeler flags a package, the client checks labels during resolution, and refuses to install flagged versions. There is propagation lag between a labeler flagging and all clients seeing it, but this is inherent in any distributed system -- including npm, where advisories take time to reach all consumers.

This is a real trade-off. Making revocation checks part of the install flow is tractable. Accepting the propagation lag is a cost of the architecture.

Server-side resolution

Performance is a genuine concern with this model. Or maybe an opportunity. We'd have to experiment and see.

npm's client-side resolution is chatty. The client fetches metadata for each dependency, resolves version constraints, discovers transitive dependencies, fetches more metadata, and repeats until the full tree is resolved. That is a lot of sequential round trips.

The App View architecture enables a different approach. The client sends its dependency list to the App View. The App View resolves the entire tree server-side -- and it already has all metadata pre-cached via the firehose, so resolution is local data access, not network fetches. It returns an optimized manifest: the complete resolved tree, CDN tarball URLs, integrity hashes, attestation status. One round trip instead of hundreds.

The firehose pre-caching is the key enabler here. The App View does not fetch on demand. It is continuously accumulating data as it is published. Server-side resolution is always against local data.

This sounds like a compelling direction but it is theoretical. There are real performance costs in attestation verification, cross-PDS data assembly, and firehose ingestion latency that will not be fully understood until someone builds it and measures. The architecture enables approaches that could match or beat npm's performance, or could be massively slower. We do not know for sure until we try.

Private packages

atproto repos are public. Every record is visible. Private packages need the opposite: restricted visibility, zero metadata leakage. Even encrypted tarballs leak metadata -- the existence of @company/internal-auth-service@2.3.1 and its dependency graph reveals architecture and priorities.

The answer is that private packages are a separate network, not a feature of the public network.

The company runs its own PDS, relay, and App View, not connected to the public relay network. Same protocol, same lexicons, same tooling. Isolated. The company's App View indexes both internal packages and the public firehose for public dependencies. The client talks to one endpoint and gets both worlds.

This gives you tooling consistency across public and private packages, the same attestation model internally, and a clean transition path when a package is open-sourced -- it is already in atproto format, just connect the PDS to the public relay.

What is genuinely hard

Some things remain genuinely difficult in this model.

The PDS key-holder problem for individuals is real today. Split-key schemes are a path forward, but they require protocol work that does not exist yet. For now, individual developers on hosted PDS providers are trusting their provider the same way they trust npmjs.com.

Revocation propagation is slower than in a centralized system. The trade-off against censorship resistance is real and has no clean resolution.

We do not know the performance characteristics. Server-side resolution is promising. Firehose pre-caching is promising. But promising is not proven.

Running a PDS is work. The org-as-PDS model is natural for companies and large open source projects. For smaller projects, it is overhead they may not want. Fortunately there are a growing number of PDS-providers that are making this easier.

I have said I am not a registry expert, and I mean it. There are likely operational realities of package registry management that I have not considered here. Package signing key rotation, mirror consistency, abuse prevention, legal compliance for package takedowns -- these are real problems that I am sure someone will point out in the replies. Good. That is part of the exercise.

What is genuinely novel

Several properties emerge from this architecture that the centralized model cannot easily replicate.

Composable trust. Multiple independent labeler services instead of one centralized advisory database. Your company runs a labeler with strict policies. Open source foundations run public labelers. You choose which to trust. Disagreement is visible.

No single point of compromise. The two-party attestation pattern for org publishing means compromising one system is detectable and insufficient. The attacker needs both the org's PDS and an individual developer's PDS.

Structural dependency confusion resistance. DID-scoped naming eliminates the class of attacks that rely on name collision across registries. The names are unambiguously different at the protocol level.

Transparent ownership. Package transfers are visible because the DID changes. No silent npm transfer. The event-stream style attack -- where a maintainer is social-engineered into transferring a package to an attacker -- becomes visible to every consumer because the DID changes.

Portable identity. Your publishing identity follows you across PDS providers. It is tied to your DID, not to a vendor's database. Change providers without losing your publication history.

Auditable name resolution. Name-to-DID mappings are attested and publicly auditable. Absence of attestation is a trust signal. Changes are visible through the firehose.

Identity-to-source binding. The publisher's signed record declares the authoritative source repo, closing the loop that Sigstore alone cannot -- the binding between "who published this" and "where the code comes from."

Where this leads

Who knows where it leads! It's just an idea I couldn't get out of my head until I wrote it down -- which I knew was going to be the case the moment it took up residency in my brain. It's been a fun exercise in What If and helped pass the time on the 12 hour flight back from Barcelona. But there are properties here -- composable trust, two-party attestation, auditable naming, identity-to-source binding -- that do not exist in the current npm ecosystem and could not be easily added to it. That is worth thinking about, even if nobody ever builds the thing.