A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go

fix issue with mismatched scopes locally

evan.jarrett.net 1f72d907 abf48407

verified
+145 -21
+2 -1
Dockerfile.appview
··· 39 39 org.opencontainers.image.documentation="https://tangled.org/@evan.jarrett.net/at-container-registry" \ 40 40 org.opencontainers.image.licenses="MIT" \ 41 41 org.opencontainers.image.version="0.1.0" \ 42 - io.atcr.icon="https://imgs.blue/evan.jarrett.net/1TpTNrRelfloN2emuWZDrWmPT0o93bAjEnozjD6UPgoVV9m4" 42 + io.atcr.icon="https://imgs.blue/evan.jarrett.net/1TpTNrRelfloN2emuWZDrWmPT0o93bAjEnozjD6UPgoVV9m4" \ 43 + io.atcr.readme="https://tangled.org/@evan.jarrett.net/at-container-registry/raw/main/docs/appview.md" 43 44 44 45 ENTRYPOINT ["/atcr-appview"] 45 46 CMD ["serve"]
+13 -9
cmd/appview/serve.go
··· 154 154 // The extraction function normalizes URLs to DIDs for consistency 155 155 defaultHoldDID := appview.ExtractDefaultHoldDID(config) 156 156 157 + // Extract test mode from config (needed for OAuth scope configuration) 158 + testMode := appview.ExtractTestMode(config) 159 + if testMode { 160 + fmt.Println("TEST_MODE enabled - will use HTTP for local DID resolution and transition:generic scope") 161 + } 162 + 157 163 // Create OAuth app (indigo client) 158 - oauthApp, err := oauth.NewApp(baseURL, oauthStore, defaultHoldDID) 164 + oauthApp, err := oauth.NewApp(baseURL, oauthStore, defaultHoldDID, testMode) 159 165 if err != nil { 160 166 return fmt.Errorf("failed to create OAuth app: %w", err) 161 167 } 162 - fmt.Println("Using full OAuth scopes (including blob: scope)") 168 + if testMode { 169 + fmt.Println("Using OAuth scopes with transition:generic (test mode)") 170 + } else { 171 + fmt.Println("Using OAuth scopes with RPC scope (production mode)") 172 + } 163 173 164 174 // Invalidate sessions with mismatched scopes on startup 165 175 // This ensures all users have the latest required scopes after deployment 166 - desiredScopes := oauth.GetDefaultScopes(defaultHoldDID) 176 + desiredScopes := oauth.GetDefaultScopes(defaultHoldDID, testMode) 167 177 invalidatedCount, err := oauthStore.InvalidateSessionsWithMismatchedScopes(context.Background(), desiredScopes) 168 178 if err != nil { 169 179 fmt.Printf("Warning: Failed to invalidate sessions with mismatched scopes: %v\n", err) ··· 185 195 // Set global database for pull/push metrics tracking 186 196 metricsDB := db.NewMetricsDB(uiDatabase) 187 197 middleware.SetGlobalDatabase(metricsDB) 188 - 189 - // Extract test mode from config 190 - testMode := appview.ExtractTestMode(config) 191 - if testMode { 192 - fmt.Println("TEST_MODE enabled - will use HTTP for local DID resolution") 193 - } 194 198 195 199 // Create RemoteHoldAuthorizer for hold authorization with caching 196 200 holdAuthorizer := auth.NewRemoteHoldAuthorizer(uiDatabase, testMode)
+104
docs/appview.md
··· 1 + # ATCR AppView 2 + 3 + The **AppView** is the OCI-compliant registry server for ATCR (ATProto Container Registry). It provides the Docker Registry HTTP API V2 and a web interface for browsing container images. 4 + 5 + ## What is AppView? 6 + 7 + AppView serves as the central registry server that: 8 + 9 + - **Serves OCI Distribution API** - Compatible with Docker, containerd, podman, and other OCI clients 10 + - **Resolves ATProto identities** - Converts handles and DIDs to PDS endpoints 11 + - **Routes manifests** - Stores container manifests as ATProto records in users' Personal Data Servers 12 + - **Routes blobs** - Proxies blob operations to hold services (S3-compatible storage) 13 + - **Provides web UI** - Browse, search, and star repositories 14 + 15 + ## Image Format 16 + 17 + Container images use ATProto identities: 18 + 19 + ``` 20 + atcr.io/alice.bsky.social/myapp:latest 21 + atcr.io/did:plc:xyz123/myapp:latest 22 + ``` 23 + 24 + ## Using ATCR 25 + 26 + ### Push Images 27 + 28 + ```bash 29 + # Install credential helper 30 + curl -fsSL https://atcr.io/install.sh | bash 31 + 32 + # Configure Docker (add to ~/.docker/config.json) 33 + { 34 + "credHelpers": { 35 + "atcr.io": "atcr" 36 + } 37 + } 38 + 39 + # Push images (authenticates automatically) 40 + docker tag myapp:latest atcr.io/yourhandle/myapp:latest 41 + docker push atcr.io/yourhandle/myapp:latest 42 + ``` 43 + 44 + ### Pull Images 45 + 46 + ```bash 47 + # Public images (no auth required) 48 + docker pull atcr.io/alice.bsky.social/myapp:latest 49 + 50 + # Private images (automatic OAuth authentication) 51 + docker pull atcr.io/yourhandle/private-app:latest 52 + ``` 53 + 54 + ## Running Your Own AppView 55 + 56 + Deploy your own registry instance with Docker Compose: 57 + 58 + ```bash 59 + # Create configuration 60 + cp .env.appview.example .env.appview 61 + # Edit .env.appview with your settings 62 + 63 + # Start services 64 + docker-compose up -d 65 + ``` 66 + 67 + ### Configuration 68 + 69 + Key environment variables: 70 + 71 + - `ATCR_HTTP_ADDR` - HTTP listen address (default: `:5000`) 72 + - `ATCR_BASE_URL` - Public URL for OAuth/JWT realm 73 + - `ATCR_DEFAULT_HOLD_DID` - Default hold service DID for blob storage (required) 74 + - `ATCR_UI_ENABLED` - Enable web interface (default: `true`) 75 + - `JETSTREAM_URL` - ATProto event stream URL for real-time updates 76 + 77 + See [deployment documentation](https://tangled.org/@evan.jarrett.net/at-container-registry/blob/main/deploy/README.md) for production setup. 78 + 79 + ## Features 80 + 81 + - ✅ **OCI-compliant** - Full Docker Registry API V2 support 82 + - ✅ **ATProto OAuth** - Secure authentication with DPoP 83 + - ✅ **Decentralized storage** - Manifests stored in users' PDS 84 + - ✅ **Web UI** - Browse repositories, view tags, search images 85 + - ✅ **Real-time updates** - Jetstream integration for live indexing 86 + - ✅ **Multi-arch support** - ARM64, AMD64, and other platforms 87 + - ✅ **BYOS** - Bring Your Own Storage via hold services 88 + 89 + ## Storage Architecture 90 + 91 + **Hybrid model:** 92 + - **Manifests** → ATProto records in user's PDS (small JSON metadata) 93 + - **Blobs** → Hold services with S3-compatible backends (large binary layers) 94 + 95 + This design keeps metadata portable and federated while leveraging cheap blob storage for layers. 96 + 97 + ## License 98 + 99 + MIT 100 + 101 + --- 102 + 103 + **Documentation:** https://tangled.org/@evan.jarrett.net/at-container-registry 104 + **Source Code:** https://tangled.org/@evan.jarrett.net/at-container-registry
+2 -2
go.mod
··· 20 20 github.com/ipld/go-car v0.6.1-0.20230509095817-92d28eb23ba4 21 21 github.com/klauspost/compress v1.18.0 22 22 github.com/mattn/go-sqlite3 v1.14.32 23 + github.com/microcosm-cc/bluemonday v1.0.27 23 24 github.com/multiformats/go-multihash v0.2.3 24 25 github.com/opencontainers/go-digest v1.0.0 25 26 github.com/spf13/cobra v1.8.0 26 27 github.com/whyrusleeping/cbor-gen v0.3.1 28 + github.com/yuin/goldmark v1.7.13 27 29 go.opentelemetry.io/otel v1.32.0 28 30 go.yaml.in/yaml/v4 v4.0.0-rc.2 29 31 golang.org/x/crypto v0.39.0 ··· 87 89 github.com/jmespath/go-jmespath v0.4.0 // indirect 88 90 github.com/klauspost/cpuid/v2 v2.2.7 // indirect 89 91 github.com/mattn/go-isatty v0.0.20 // indirect 90 - github.com/microcosm-cc/bluemonday v1.0.27 // indirect 91 92 github.com/minio/sha256-simd v1.0.1 // indirect 92 93 github.com/mr-tron/base58 v1.2.0 // indirect 93 94 github.com/multiformats/go-base32 v0.1.0 // indirect ··· 108 109 github.com/sirupsen/logrus v1.9.3 // indirect 109 110 github.com/spaolacci/murmur3 v1.1.0 // indirect 110 111 github.com/spf13/pflag v1.0.5 // indirect 111 - github.com/yuin/goldmark v1.7.13 // indirect 112 112 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 113 113 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 114 114 go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 // indirect
+1 -1
pkg/appview/readme/fetcher.go
··· 29 29 // Configure markdown renderer with GitHub-flavored markdown 30 30 md := goldmark.New( 31 31 goldmark.WithExtensions( 32 - extension.GFM, // GitHub Flavored Markdown 32 + extension.GFM, // GitHub Flavored Markdown 33 33 extension.Typographer, // Smart quotes, dashes, etc. 34 34 ), 35 35 goldmark.WithParserOptions(
+20 -7
pkg/auth/oauth/client.go
··· 20 20 } 21 21 22 22 // NewApp creates a new OAuth app for ATCR with default scopes 23 - func NewApp(baseURL string, store oauth.ClientAuthStore, holdDid string) (*App, error) { 24 - return NewAppWithScopes(baseURL, store, GetDefaultScopes(holdDid)) 23 + func NewApp(baseURL string, store oauth.ClientAuthStore, holdDid string, testMode bool) (*App, error) { 24 + return NewAppWithScopes(baseURL, store, GetDefaultScopes(holdDid, testMode)) 25 25 } 26 26 27 27 // NewAppWithScopes creates a new OAuth app for ATCR with custom scopes ··· 120 120 } 121 121 122 122 // GetDefaultScopes returns the default OAuth scopes for ATCR registry operations 123 - func GetDefaultScopes(did string) []string { 124 - return []string{ 123 + // testMode determines whether to use transition:generic (test) or rpc scopes (production) 124 + func GetDefaultScopes(did string, testMode bool) []string { 125 + scopes := []string{ 125 126 "atproto", 126 - "transition:generic", 127 127 // Image manifest types (single-arch) 128 128 "blob:application/vnd.oci.image.manifest.v1+json", 129 129 "blob:application/vnd.docker.distribution.manifest.v2+json", ··· 132 132 "blob:application/vnd.docker.distribution.manifest.list.v2+json", 133 133 // OCI artifact manifests (for cosign signatures, SBOMs, attestations) 134 134 "blob:application/vnd.cncf.oras.artifact.manifest.v1+json", 135 - fmt.Sprintf("rpc:com.atproto.repo.getRecord?aud=%s#atcr_hold", did), 135 + } 136 + 137 + // In test mode: use transition:generic (local dev with test PDS) 138 + // In production: use rpc scope for service auth 139 + if testMode { 140 + scopes = append(scopes, "transition:generic") 141 + } else { 142 + scopes = append(scopes, fmt.Sprintf("rpc:com.atproto.repo.getRecord?aud=%s#atcr_hold", did)) 143 + } 144 + 145 + // Add repo scopes 146 + scopes = append(scopes, 136 147 fmt.Sprintf("repo:%s", atproto.ManifestCollection), 137 148 fmt.Sprintf("repo:%s", atproto.TagCollection), 138 149 fmt.Sprintf("repo:%s", atproto.StarCollection), 139 150 fmt.Sprintf("repo:%s", atproto.SailorProfileCollection), 140 - } 151 + ) 152 + 153 + return scopes 141 154 } 142 155 143 156 // ScopesMatch checks if two scope lists are equivalent (order-independent)
+3 -1
pkg/auth/oauth/interactive.go
··· 33 33 } 34 34 35 35 // Create OAuth app with custom scopes (or defaults if nil) 36 + // Interactive flows are typically for production use (credential helper, etc.) 37 + // so we default to testMode=false 36 38 var app *App 37 39 if scopes != nil { 38 40 app, err = NewAppWithScopes(baseURL, store, scopes) 39 41 } else { 40 - app, err = NewApp(baseURL, store, "*") 42 + app, err = NewApp(baseURL, store, "*", false) 41 43 } 42 44 if err != nil { 43 45 return nil, fmt.Errorf("failed to create OAuth app: %w", err)
test-e2e.sh scripts/test-e2e.sh