···35353636 1. Initialize Go module with github.com/distribution/distribution/v3 and github.com/bluesky-social/indigo
3737 2. Create basic project structure
3838- 3. Set up cmd/registry/main.go that imports distribution and registers middleware
3838+ 3. Set up cmd/appview/main.go that imports distribution and registers middleware
39394040 Phase 2: Core ATProto Integration
4141···7878 Phase 6: Configuration & Deployment
79798080 13. Create registry configuration (config/config.yml)
8181- 14. Create Dockerfile for building atcr-registry binary
8181+ 14. Create Dockerfile for building atcr-appview binary
8282 16. Write README.md with usage instructions
83838484 Phase 7: Documentation
···193193 atproto:
194194 # Used by auth service to validate credentials
195195 pds_endpoint: https://bsky.social
196196- client_id: atcr-registry
196196+ client_id: atcr-appview
197197 oauth_redirect: http://localhost:8888/callback
198198199199ATProto OAuth Implementation Plan
···33 level: info
44 formatter: text
55 fields:
66- service: atcr-registry
66+ service: atcr-appview
7788# Storage is handled by external services:
99# - Manifests/Tags -> ATProto PDS (user's personal data server)
1010# - Blobs/Layers -> Hold service (default or BYOS)
1111-# The AppView (registry) should be stateless with no local storage
1111+# The AppView should be stateless with no local storage
1212#
1313# NOTE: The storage section below is required for distribution config validation
1414# but is NOT actually used - all blob operations are routed through hold service
+4-4
docker-compose.yml
···11services:
22- atcr-registry:
22+ atcr-appview:
33 build:
44 context: .
55 dockerfile: Dockerfile
66- image: atcr-registry:latest
77- container_name: atcr-registry
66+ image: atcr-appview:latest
77+ container_name: atcr-appview
88 ports:
99 - "5000:5000"
1010 environment:
···2222 networks:
2323 atcr-network:
2424 ipv4_address: 172.28.0.2
2525- # The registry should be stateless - all storage is external:
2525+ # The AppView should be stateless - all storage is external:
2626 # - Manifests/Tags -> ATProto PDS
2727 # - Blobs/Layers -> Hold service
2828 # - OAuth tokens -> Persistent volume (atcr-tokens)
+4-4
docs/API_KEY_MIGRATION.md
···326326- `pkg/auth/exchange/handler.go`
327327328328**Files to update:**
329329-- `cmd/registry/serve.go` - Remove exchange handler registration
329329+- `cmd/appview/serve.go` - Remove exchange handler registration
330330331331### Phase 3: Update UI
332332···489489</style>
490490```
491491492492-#### 3.2 Register API Key Routes (`cmd/registry/serve.go`)
492492+#### 3.2 Register API Key Routes (`cmd/appview/serve.go`)
493493494494```go
495495// In initializeUI() function, add:
···678678}
679679```
680680681681-#### 5.4 Update Registry Initialization (`cmd/registry/serve.go`)
681681+#### 5.4 Update Registry Initialization (`cmd/appview/serve.go`)
682682683683```go
684684// REMOVE session manager creation:
···800800- `pkg/appview/handlers/settings.go` - Add API key management UI
801801- `pkg/appview/templates/settings.html` - Add API key section
802802- `cmd/credential-helper/main.go` - Simplify to use API keys
803803-- `cmd/registry/serve.go` - Initialize API key store, remove session manager
803803+- `cmd/appview/serve.go` - Initialize API key store, remove session manager
804804805805### Deleted Files
806806- `pkg/auth/session/handler.go` - Session token system
+9-9
docs/APPVIEW-UI-IMPLEMENTATION.md
···1414## Project Structure
15151616```
1717-cmd/registry/
1717+cmd/appview/
1818├── main.go # Add AppView routes here
19192020pkg/appview/
···1249124912501250## Step 8: Main Integration
1251125112521252-**cmd/registry/main.go (additions):**
12521252+**cmd/appview/main.go (additions):**
1253125312541254```go
12551255package main
···17471747### Development
17481748```bash
17491749# Run migrations
17501750-go run cmd/registry/main.go migrate
17501750+go run cmd/appview/main.go migrate
1751175117521752# Start server
17531753-go run cmd/registry/main.go serve
17531753+go run cmd/appview/main.go serve
17541754```
1755175517561756### Production
17571757```bash
17581758# Build binary
17591759-go build -o bin/atcr-registry ./cmd/registry
17591759+go build -o bin/atcr-appview ./cmd/appview
1760176017611761# Run with config
17621762-./bin/atcr-registry serve config/production.yml
17621762+./bin/atcr-appview serve config/production.yml
17631763```
1764176417651765### Environment Variables
···17861786### Single Binary Deployment
17871787- All templates and static files embedded with `//go:embed`
17881788- No need to ship separate `web/` directory
17891789-- Single `atcr-registry` binary contains everything
17891789+- Single `atcr-appview` binary contains everything
17901790- Easy deployment: just copy one file
1791179117921792### Package Structure
···1807180718081808**Build:**
18091809```bash
18101810-go build -o bin/atcr-registry ./cmd/registry
18101810+go build -o bin/atcr-appview ./cmd/appview
18111811```
1812181218131813**Deploy:**
18141814```bash
18151815-scp bin/atcr-registry server:/usr/local/bin/
18151815+scp bin/atcr-appview server:/usr/local/bin/
18161816# Done! No webpack, no node_modules, no separate assets folder
18171817```
18181818
···22222323 // SailorProfileCollection is the collection name for user profiles
2424 SailorProfileCollection = "io.atcr.sailor.profile"
2525+2626+ // StarCollection is the collection name for repository stars
2727+ StarCollection = "io.atcr.sailor.star"
2528)
26292730// ManifestRecord represents a container image manifest stored in ATProto
···5962 Subject *BlobReference `json:"subject,omitempty"`
60636164 // ManifestBlob is a reference to the manifest blob stored in ATProto blob storage
6262- // This is the new way of storing manifests (replaces RawManifest)
6365 ManifestBlob *ATProtoBlobRef `json:"manifestBlob,omitempty"`
64666565- // RawManifest stores the original manifest bytes (base64 encoded) - DEPRECATED
6666- // Kept for backward compatibility with old records
6767- // New records should use ManifestBlob instead
6868- RawManifest string `json:"rawManifest,omitempty"`
6969-7067 // CreatedAt timestamp
7168 CreatedAt time.Time `json:"createdAt"`
7269}
···114111 SchemaVersion: ociData.SchemaVersion,
115112 Annotations: ociData.Annotations,
116113 // ManifestBlob will be set by the caller after uploading to blob storage
117117- // RawManifest no longer stored for new records (backward compat only)
118114 CreatedAt: time.Now(),
119115 }
120116···143139 return record, nil
144140}
145141146146-// ToOCIManifest converts the manifest record back to OCI manifest JSON
147147-// This should NOT be used directly - use manifest_store.Get() which downloads the blob
148148-// This is kept for backward compatibility only
149149-func (m *ManifestRecord) ToOCIManifest() ([]byte, error) {
150150- // New records: ManifestBlob reference (blob downloaded separately by manifest store)
151151- // This function should not be called for new records - it's a fallback only
152152-153153- // Backward compatibility: If we have the raw manifest stored, return it
154154- if m.RawManifest != "" {
155155- rawBytes, err := base64.StdEncoding.DecodeString(m.RawManifest)
156156- if err != nil {
157157- return nil, err
158158- }
159159- return rawBytes, nil
160160- }
161161-162162- // Last resort: reconstruct from fields (will have different digest!)
163163- // This should only happen for very old records
164164- ociManifest := map[string]any{
165165- "schemaVersion": m.SchemaVersion,
166166- "mediaType": m.MediaType,
167167- "config": m.Config,
168168- "layers": m.Layers,
169169- }
170170-171171- if m.Subject != nil {
172172- ociManifest["subject"] = m.Subject
173173- }
174174-175175- if len(m.Annotations) > 0 {
176176- ociManifest["annotations"] = m.Annotations
177177- }
178178-179179- return json.Marshal(ociManifest)
180180-}
181181-182142// TagRecord represents a tag pointing to a manifest
183143type TagRecord struct {
184144 // Type should be "io.atcr.tag"
···299259 UpdatedAt: now,
300260 }
301261}
262262+263263+// StarSubject represents the subject of a star (the repository being starred)
264264+type StarSubject struct {
265265+ // DID is the DID of the repository owner
266266+ DID string `json:"did"`
267267+268268+ // Repository is the name of the repository
269269+ Repository string `json:"repository"`
270270+}
271271+272272+// StarRecord represents a user starring a repository
273273+// Stored in the starrer's PDS (like Bluesky likes)
274274+type StarRecord struct {
275275+ // Type should be "io.atcr.sailor.star"
276276+ Type string `json:"$type"`
277277+278278+ // Subject is the repository being starred
279279+ Subject StarSubject `json:"subject"`
280280+281281+ // CreatedAt timestamp
282282+ CreatedAt time.Time `json:"createdAt"`
283283+}
284284+285285+// NewStarRecord creates a new star record
286286+func NewStarRecord(ownerDID, repository string) *StarRecord {
287287+ return &StarRecord{
288288+ Type: StarCollection,
289289+ Subject: StarSubject{
290290+ DID: ownerDID,
291291+ Repository: repository,
292292+ },
293293+ CreatedAt: time.Now(),
294294+ }
295295+}
296296+297297+// StarRecordKey generates a record key for a star
298298+// Uses a simple hash to ensure uniqueness and prevent duplicate stars
299299+func StarRecordKey(ownerDID, repository string) string {
300300+ // Use base64 encoding of "ownerDID/repository" as the record key
301301+ // This is deterministic and prevents duplicate stars
302302+ combined := ownerDID + "/" + repository
303303+ return base64.RawURLEncoding.EncodeToString([]byte(combined))
304304+}
+17-8
pkg/atproto/manifest_store.go
···1111 "github.com/opencontainers/go-digest"
1212)
13131414+// DatabaseMetrics interface for tracking push counts
1515+type DatabaseMetrics interface {
1616+ IncrementPushCount(did, repository string) error
1717+}
1818+1419// ManifestStore implements distribution.ManifestService
1520// It stores manifests in ATProto as records
1621type ManifestStore struct {
···2025 did string // User's DID for cache key
2126 lastFetchedHoldEndpoint string // Hold endpoint from most recently fetched manifest (for pull)
2227 blobStore distribution.BlobStore // Blob store for fetching config during push
2828+ database DatabaseMetrics // Database for metrics tracking
2329}
24302531// NewManifestStore creates a new ATProto-backed manifest store
2626-func NewManifestStore(client *Client, repository string, holdEndpoint string, did string, blobStore distribution.BlobStore) *ManifestStore {
3232+func NewManifestStore(client *Client, repository string, holdEndpoint string, did string, blobStore distribution.BlobStore, database DatabaseMetrics) *ManifestStore {
2733 return &ManifestStore{
2834 client: client,
2935 repository: repository,
3036 holdEndpoint: holdEndpoint,
3137 did: did,
3238 blobStore: blobStore,
3939+ database: database,
3340 }
3441}
3542···7582 if err != nil {
7683 return nil, fmt.Errorf("failed to download manifest blob: %w", err)
7784 }
7878- } else {
7979- // Backward compatibility: Use ToOCIManifest for old records
8080- ociManifest, err = manifestRecord.ToOCIManifest()
8181- if err != nil {
8282- return nil, fmt.Errorf("failed to convert to OCI manifest: %w", err)
8383- }
8485 }
8585-8686 // Parse the manifest based on media type
8787 // For now, we'll return the raw bytes wrapped in a manifest object
8888 // In a full implementation, you'd use distribution's manifest parsing
···143143 _, err = s.client.PutRecord(ctx, ManifestCollection, rkey, manifestRecord)
144144 if err != nil {
145145 return "", fmt.Errorf("failed to store manifest record in ATProto: %w", err)
146146+ }
147147+148148+ // Track push count (increment asynchronously to avoid blocking the response)
149149+ if s.database != nil {
150150+ go func() {
151151+ if err := s.database.IncrementPushCount(s.did, s.repository); err != nil {
152152+ fmt.Printf("WARNING: Failed to increment push count for %s/%s: %v\n", s.did, s.repository, err)
153153+ }
154154+ }()
146155 }
147156148157 // Also handle tag if specified
+15-1
pkg/middleware/registry.go
···2323// Global refresher instance (set by main.go)
2424var globalRefresher *oauth.Refresher
25252626+// Global database instance (set by main.go for pull tracking)
2727+var globalDatabase interface {
2828+ IncrementPullCount(did, repository string) error
2929+ IncrementPushCount(did, repository string) error
3030+}
3131+2632// SetGlobalRefresher sets the global OAuth refresher instance
2733func SetGlobalRefresher(refresher *oauth.Refresher) {
2834 globalRefresher = refresher
3535+}
3636+3737+// SetGlobalDatabase sets the global database instance for metrics tracking
3838+func SetGlobalDatabase(database interface {
3939+ IncrementPullCount(did, repository string) error
4040+ IncrementPushCount(did, repository string) error
4141+}) {
4242+ globalDatabase = database
2943}
30443145func init() {
···169183 // Create routing repository - routes manifests to ATProto, blobs to hold service
170184 // The registry is stateless - no local storage is used
171185 // Pass storage endpoint and DID as parameters (can't use context as it gets lost)
172172- routingRepo := storage.NewRoutingRepository(repo, atprotoClient, repositoryName, storageEndpoint, did)
186186+ routingRepo := storage.NewRoutingRepository(repo, atprotoClient, repositoryName, storageEndpoint, did, globalDatabase)
173187174188 // Cache the repository
175189 nr.repositories.Store(cacheKey, routingRepo)
+16-3
pkg/storage/proxy_blob_store.go
···3232 storageEndpoint string
3333 httpClient *http.Client
3434 did string
3535+ database DatabaseMetrics
3636+ repository string
3537}
36383739// NewProxyBlobStore creates a new proxy blob store
3838-func NewProxyBlobStore(storageEndpoint, did string) *ProxyBlobStore {
3939- fmt.Printf("DEBUG [proxy_blob_store]: NewProxyBlobStore created with endpoint=%s, did=%s\n", storageEndpoint, did)
4040+func NewProxyBlobStore(storageEndpoint, did string, database DatabaseMetrics, repository string) *ProxyBlobStore {
4141+ fmt.Printf("DEBUG [proxy_blob_store]: NewProxyBlobStore created with endpoint=%s, did=%s, repo=%s\n", storageEndpoint, did, repository)
4042 return &ProxyBlobStore{
4143 storageEndpoint: storageEndpoint,
4244 httpClient: &http.Client{
···4951 IdleConnTimeout: 90 * time.Second,
5052 },
5153 },
5252- did: did,
5454+ did: did,
5555+ database: database,
5656+ repository: repository,
5357 }
5458}
5559···177181 url, err := p.getDownloadURL(ctx, dgst)
178182 if err != nil {
179183 return err
184184+ }
185185+186186+ // Track pull count (increment asynchronously to avoid blocking the response)
187187+ if p.database != nil && p.repository != "" {
188188+ go func() {
189189+ if err := p.database.IncrementPullCount(p.did, p.repository); err != nil {
190190+ fmt.Printf("WARNING: Failed to increment pull count for %s/%s: %v\n", p.did, p.repository, err)
191191+ }
192192+ }()
180193 }
181194182195 // Redirect to presigned URL
+11-2
pkg/storage/routing_repository.go
···99 "github.com/distribution/distribution/v3"
1010)
11111212+// DatabaseMetrics interface for tracking pull/push counts
1313+type DatabaseMetrics interface {
1414+ IncrementPullCount(did, repository string) error
1515+ IncrementPushCount(did, repository string) error
1616+}
1717+1218// RoutingRepository routes manifests to ATProto and blobs to external hold service
1319// The registry (AppView) is stateless and NEVER stores blobs locally
1420type RoutingRepository struct {
···1925 did string // User's DID for authorization
2026 manifestStore *atproto.ManifestStore // Cached manifest store instance
2127 blobStore *ProxyBlobStore // Cached blob store instance
2828+ database DatabaseMetrics // Database for metrics tracking
2229}
23302431// NewRoutingRepository creates a new routing repository
···2835 repoName string,
2936 storageEndpoint string,
3037 did string,
3838+ database DatabaseMetrics,
3139) *RoutingRepository {
3240 return &RoutingRepository{
3341 Repository: baseRepo,
···3543 repositoryName: repoName,
3644 storageEndpoint: storageEndpoint,
3745 did: did,
4646+ database: database,
3847 }
3948}
4049···4554 // Ensure blob store is created first (needed for label extraction during push)
4655 blobStore := r.Blobs(ctx)
47564848- r.manifestStore = atproto.NewManifestStore(r.atprotoClient, r.repositoryName, r.storageEndpoint, r.did, blobStore)
5757+ r.manifestStore = atproto.NewManifestStore(r.atprotoClient, r.repositoryName, r.storageEndpoint, r.did, blobStore, r.database)
4958 }
50595160 // After any manifest operation, cache the hold endpoint for blob fetches
···94103 }
9510496105 // Create and cache proxy blob store
9797- r.blobStore = NewProxyBlobStore(holdEndpoint, r.did)
106106+ r.blobStore = NewProxyBlobStore(holdEndpoint, r.did, r.database, r.repositoryName)
98107 return r.blobStore
99108}
100109