An AT Protocol Personal Data Server written in JavaScript pdsjs.dev
pds atproto
README.md

@pdsjs/oci #

Host OCI container images natively in an atproto account. Tags, manifests and layers live in the account's PDS as a dev.pdsjs.oci.repo record plus blob storage, so an image rides along in CAR backups, migrates with the account, and needs no separate registry service:

docker login pds.example.com -u alice.example.com
docker push pds.example.com/my-app:v1

docker pull pds.example.com/my-app:v1   # anonymous

How it works #

  • An OCI digest is sha256:<hex>; an atproto blob CID is CIDv1 + raw codec
    • a sha256 multihash. Both hold the same 32 hash bytes, so a layer is one blob and a blob fetch is a digest re-encoding plus getBlob. No mapping table exists.
  • Manifest bytes are stored as blobs exactly as pushed, because a digest covers the exact serialization. The record carries each manifest's media type so pulls are served with the right Content-Type.
  • One record per image repository (collection dev.pdsjs.oci.repo, rkey = name with / encoded as :). It holds the tag list and a blob reference for every stored manifest, config and layer, which keeps those blobs out of the PDS's orphan cleanup. Deleting a manifest drops the references and the orphan sweep frees the bytes.
  • Layer uploads stream through the blob writer with an incremental hash, so a layer is never held in memory whole. Monolithic and chunked uploads, cross-repository mounts, ranged blob reads and multi-arch indexes all work.

Authentication #

Pulls are anonymous. Pushes authenticate with the account password or an app password, sent the way docker login sends them; the username is the account's handle or DID. The server issues Bearer tokens from /v2/token — the flow docker requires for anonymous pulls — and also accepts Basic credentials directly, so curl -u works.

Enabling #

Node:

const server = await createServer({
  // ...
  experimental: { oci: true },
});

Cloudflare: set PDS_EXPERIMENTAL_OCI = "true".

Both mount createOciExtension() through core's extensions option. A platform that builds PersonalDataServer itself does the same:

import { createOciExtension } from '@pdsjs/oci/extension';

new PersonalDataServer({
  // ...
  extensions: [createOciExtension({ maxBlobSize: 512 * 1024 * 1024 })],
});

The registry serves under /v2/ on the PDS hostname. Docker requires TLS for any registry that is not localhost; a PDS behind HTTPS already satisfies that.

Limits #

  • Layers stream, but each layer arrives in one request unless the client chunks. A platform's per-request body cap (100 MB on the lowest Cloudflare plan) bounds the largest layer such a deployment accepts.
  • The referrers API is not served; clients fall back to the tag schema.
  • _catalog lists the first hundred repositories.