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

Configure Feed

Select the types of activity you want to include in your feed.

add backlinks to tags

+371 -29
+7 -2
lexicons/io/atcr/tag.json
··· 8 8 "key": "any", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["repository", "tag", "manifestDigest", "createdAt"], 11 + "required": ["repository", "tag", "createdAt"], 12 12 "properties": { 13 13 "repository": { 14 14 "type": "string", ··· 20 20 "description": "Tag name (e.g., 'latest', 'v1.0.0', '12-slim')", 21 21 "maxLength": 128 22 22 }, 23 + "manifest": { 24 + "type": "string", 25 + "format": "at-uri", 26 + "description": "AT-URI of the manifest this tag points to (e.g., 'at://did:plc:xyz/io.atcr.manifest/abc123'). Preferred over manifestDigest for new records." 27 + }, 23 28 "manifestDigest": { 24 29 "type": "string", 25 - "description": "Digest of the manifest this tag points to (e.g., 'sha256:...')" 30 + "description": "DEPRECATED: Digest of the manifest (e.g., 'sha256:...'). Kept for backward compatibility with old records. New records should use 'manifest' field instead." 26 31 }, 27 32 "createdAt": { 28 33 "type": "string",
+7 -1
pkg/appview/jetstream/backfill.go
··· 400 400 return fmt.Errorf("failed to unmarshal tag: %w", err) 401 401 } 402 402 403 + // Extract digest from tag record (tries manifest field first, falls back to manifestDigest) 404 + manifestDigest, err := tagRecord.GetManifestDigest() 405 + if err != nil { 406 + return fmt.Errorf("failed to get manifest digest from tag record: %w", err) 407 + } 408 + 403 409 // Insert or update tag 404 410 return db.UpsertTag(b.db, &db.Tag{ 405 411 DID: did, 406 412 Repository: tagRecord.Repository, 407 413 Tag: tagRecord.Tag, 408 - Digest: tagRecord.ManifestDigest, 414 + Digest: manifestDigest, 409 415 CreatedAt: tagRecord.UpdatedAt, 410 416 }) 411 417 }
+7 -1
pkg/appview/jetstream/worker.go
··· 560 560 return nil 561 561 } 562 562 563 + // Extract digest from tag record (tries manifest field first, falls back to manifestDigest) 564 + manifestDigest, err := tagRecord.GetManifestDigest() 565 + if err != nil { 566 + return fmt.Errorf("failed to get manifest digest from tag record: %w", err) 567 + } 568 + 563 569 // Insert or update tag 564 570 return db.UpsertTag(w.db, &db.Tag{ 565 571 DID: commit.DID, 566 572 Repository: tagRecord.Repository, 567 573 Tag: tagRecord.Tag, 568 - Digest: tagRecord.ManifestDigest, 574 + Digest: manifestDigest, 569 575 CreatedAt: tagRecord.UpdatedAt, 570 576 }) 571 577 }
+1 -1
pkg/appview/middleware/registry.go
··· 310 310 HoldDID: holdDID, 311 311 PDSEndpoint: pdsEndpoint, 312 312 Repository: repositoryName, 313 - ServiceToken: serviceToken, // Cached service token from middleware validation 313 + ServiceToken: serviceToken, // Cached service token from middleware validation 314 314 ATProtoClient: atprotoClient, 315 315 Database: nr.database, 316 316 Authorizer: nr.authorizer,
+5
pkg/atproto/client.go
··· 661 661 662 662 return &didDoc, nil 663 663 } 664 + 665 + // DID returns the DID associated with this client 666 + func (c *Client) DID() string { 667 + return c.did 668 + }
+80 -9
pkg/atproto/lexicon.go
··· 241 241 // Tag is the tag name (e.g., "latest", "v1.0.0") 242 242 Tag string `json:"tag"` 243 243 244 - // ManifestDigest is the digest of the manifest this tag points to 245 - ManifestDigest string `json:"manifestDigest"` 244 + // Manifest is the AT-URI of the manifest this tag points to 245 + // Format: at://did:plc:xyz/io.atcr.manifest/abc123 246 + // Preferred over ManifestDigest for new records 247 + Manifest string `json:"manifest,omitempty"` 248 + 249 + // ManifestDigest is the digest of the manifest this tag points to (DEPRECATED) 250 + // Kept for backward compatibility with old records 251 + // New records should use Manifest field instead 252 + ManifestDigest string `json:"manifestDigest,omitempty"` 246 253 247 254 // UpdatedAt timestamp 248 255 UpdatedAt time.Time `json:"updatedAt"` 249 256 } 250 257 251 - // NewTagRecord creates a new tag record 252 - func NewTagRecord(repository, tag, manifestDigest string) *TagRecord { 258 + // NewTagRecord creates a new tag record with manifest AT-URI 259 + // did: The DID of the user (e.g., "did:plc:xyz123") 260 + // repository: The repository name (e.g., "myapp") 261 + // tag: The tag name (e.g., "latest", "v1.0.0") 262 + // manifestDigest: The manifest digest (e.g., "sha256:abc123...") 263 + func NewTagRecord(did, repository, tag, manifestDigest string) *TagRecord { 264 + // Build AT-URI for the manifest 265 + // Format: at://did:plc:xyz/io.atcr.manifest/<digest-without-sha256-prefix> 266 + manifestURI := BuildManifestURI(did, manifestDigest) 267 + 253 268 return &TagRecord{ 254 - Type: TagCollection, 255 - Repository: repository, 256 - Tag: tag, 257 - ManifestDigest: manifestDigest, 258 - UpdatedAt: time.Now(), 269 + Type: TagCollection, 270 + Repository: repository, 271 + Tag: tag, 272 + Manifest: manifestURI, 273 + // Note: ManifestDigest is not set for new records (only for backward compat with old records) 274 + UpdatedAt: time.Now(), 259 275 } 260 276 } 261 277 ··· 410 426 // isDID checks if a string is a DID (starts with "did:") 411 427 func isDID(s string) bool { 412 428 return len(s) > 4 && s[:4] == "did:" 429 + } 430 + 431 + // BuildManifestURI creates an AT-URI for a manifest record 432 + // did: The DID of the user (e.g., "did:plc:xyz123") 433 + // manifestDigest: The manifest digest (e.g., "sha256:abc123...") 434 + // Returns: AT-URI in format "at://did:plc:xyz/io.atcr.manifest/<digest-without-sha256-prefix>" 435 + func BuildManifestURI(did, manifestDigest string) string { 436 + // Remove the "sha256:" prefix from the digest to get the rkey 437 + rkey := strings.TrimPrefix(manifestDigest, "sha256:") 438 + return fmt.Sprintf("at://%s/%s/%s", did, ManifestCollection, rkey) 439 + } 440 + 441 + // ParseManifestURI extracts the digest from a manifest AT-URI 442 + // manifestURI: AT-URI in format "at://did:plc:xyz/io.atcr.manifest/<digest-without-sha256-prefix>" 443 + // Returns: Full digest with "sha256:" prefix (e.g., "sha256:abc123...") 444 + func ParseManifestURI(manifestURI string) (string, error) { 445 + // Expected format: at://did:plc:xyz/io.atcr.manifest/<rkey> 446 + if !strings.HasPrefix(manifestURI, "at://") { 447 + return "", fmt.Errorf("invalid AT-URI format: must start with 'at://'") 448 + } 449 + 450 + // Remove "at://" prefix 451 + remainder := strings.TrimPrefix(manifestURI, "at://") 452 + 453 + // Split by "/" 454 + parts := strings.Split(remainder, "/") 455 + if len(parts) != 3 { 456 + return "", fmt.Errorf("invalid AT-URI format: expected 3 parts (did/collection/rkey), got %d", len(parts)) 457 + } 458 + 459 + // Validate collection 460 + if parts[1] != ManifestCollection { 461 + return "", fmt.Errorf("invalid AT-URI: expected collection %s, got %s", ManifestCollection, parts[1]) 462 + } 463 + 464 + // The rkey is the digest without the "sha256:" prefix 465 + // Add it back to get the full digest 466 + rkey := parts[2] 467 + return "sha256:" + rkey, nil 468 + } 469 + 470 + // GetManifestDigest extracts the digest from a TagRecord, preferring the manifest field 471 + // Returns the digest with "sha256:" prefix (e.g., "sha256:abc123...") 472 + func (t *TagRecord) GetManifestDigest() (string, error) { 473 + // Prefer the new manifest field 474 + if t.Manifest != "" { 475 + return ParseManifestURI(t.Manifest) 476 + } 477 + 478 + // Fall back to the legacy manifestDigest field 479 + if t.ManifestDigest != "" { 480 + return t.ManifestDigest, nil 481 + } 482 + 483 + return "", fmt.Errorf("tag record has neither manifest nor manifestDigest field") 413 484 } 414 485 415 486 // =============================================================================
+163 -3
pkg/atproto/lexicon_test.go
··· 267 267 } 268 268 269 269 func TestNewTagRecord(t *testing.T) { 270 + did := "did:plc:test123" 270 271 before := time.Now() 271 - record := NewTagRecord("myapp", "latest", "sha256:abc123") 272 + record := NewTagRecord(did, "myapp", "latest", "sha256:abc123") 272 273 after := time.Now() 273 274 274 275 if record.Type != TagCollection { ··· 283 284 t.Errorf("Tag = %v, want latest", record.Tag) 284 285 } 285 286 286 - if record.ManifestDigest != "sha256:abc123" { 287 - t.Errorf("ManifestDigest = %v, want sha256:abc123", record.ManifestDigest) 287 + // New records should have manifest field (AT-URI) 288 + expectedURI := "at://did:plc:test123/io.atcr.manifest/abc123" 289 + if record.Manifest != expectedURI { 290 + t.Errorf("Manifest = %v, want %v", record.Manifest, expectedURI) 291 + } 292 + 293 + // New records should NOT have manifestDigest field 294 + if record.ManifestDigest != "" { 295 + t.Errorf("ManifestDigest should be empty for new records, got %v", record.ManifestDigest) 288 296 } 289 297 290 298 if record.UpdatedAt.Before(before) || record.UpdatedAt.After(after) { 291 299 t.Errorf("UpdatedAt = %v, want between %v and %v", record.UpdatedAt, before, after) 300 + } 301 + } 302 + 303 + func TestBuildManifestURI(t *testing.T) { 304 + tests := []struct { 305 + name string 306 + did string 307 + manifestDigest string 308 + want string 309 + }{ 310 + { 311 + name: "standard digest", 312 + did: "did:plc:abc123", 313 + manifestDigest: "sha256:def456", 314 + want: "at://did:plc:abc123/io.atcr.manifest/def456", 315 + }, 316 + { 317 + name: "long digest", 318 + did: "did:web:hold.example.com", 319 + manifestDigest: "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", 320 + want: "at://did:web:hold.example.com/io.atcr.manifest/abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", 321 + }, 322 + } 323 + 324 + for _, tt := range tests { 325 + t.Run(tt.name, func(t *testing.T) { 326 + got := BuildManifestURI(tt.did, tt.manifestDigest) 327 + if got != tt.want { 328 + t.Errorf("BuildManifestURI() = %v, want %v", got, tt.want) 329 + } 330 + }) 331 + } 332 + } 333 + 334 + func TestParseManifestURI(t *testing.T) { 335 + tests := []struct { 336 + name string 337 + manifestURI string 338 + want string 339 + wantErr bool 340 + }{ 341 + { 342 + name: "valid URI", 343 + manifestURI: "at://did:plc:abc123/io.atcr.manifest/def456", 344 + want: "sha256:def456", 345 + wantErr: false, 346 + }, 347 + { 348 + name: "valid URI with did:web", 349 + manifestURI: "at://did:web:hold.example.com/io.atcr.manifest/xyz789", 350 + want: "sha256:xyz789", 351 + wantErr: false, 352 + }, 353 + { 354 + name: "invalid prefix", 355 + manifestURI: "https://example.com/manifest", 356 + want: "", 357 + wantErr: true, 358 + }, 359 + { 360 + name: "wrong collection", 361 + manifestURI: "at://did:plc:abc123/io.atcr.tag/def456", 362 + want: "", 363 + wantErr: true, 364 + }, 365 + { 366 + name: "too few parts", 367 + manifestURI: "at://did:plc:abc123/io.atcr.manifest", 368 + want: "", 369 + wantErr: true, 370 + }, 371 + { 372 + name: "too many parts", 373 + manifestURI: "at://did:plc:abc123/io.atcr.manifest/def456/extra", 374 + want: "", 375 + wantErr: true, 376 + }, 377 + } 378 + 379 + for _, tt := range tests { 380 + t.Run(tt.name, func(t *testing.T) { 381 + got, err := ParseManifestURI(tt.manifestURI) 382 + if (err != nil) != tt.wantErr { 383 + t.Errorf("ParseManifestURI() error = %v, wantErr %v", err, tt.wantErr) 384 + return 385 + } 386 + if got != tt.want { 387 + t.Errorf("ParseManifestURI() = %v, want %v", got, tt.want) 388 + } 389 + }) 390 + } 391 + } 392 + 393 + func TestTagRecord_GetManifestDigest(t *testing.T) { 394 + tests := []struct { 395 + name string 396 + record TagRecord 397 + want string 398 + wantErr bool 399 + }{ 400 + { 401 + name: "new record with manifest field", 402 + record: TagRecord{ 403 + Manifest: "at://did:plc:test123/io.atcr.manifest/abc123", 404 + }, 405 + want: "sha256:abc123", 406 + wantErr: false, 407 + }, 408 + { 409 + name: "old record with manifestDigest field", 410 + record: TagRecord{ 411 + ManifestDigest: "sha256:def456", 412 + }, 413 + want: "sha256:def456", 414 + wantErr: false, 415 + }, 416 + { 417 + name: "prefers manifest over manifestDigest", 418 + record: TagRecord{ 419 + Manifest: "at://did:plc:test123/io.atcr.manifest/abc123", 420 + ManifestDigest: "sha256:def456", 421 + }, 422 + want: "sha256:abc123", 423 + wantErr: false, 424 + }, 425 + { 426 + name: "no fields set", 427 + record: TagRecord{}, 428 + want: "", 429 + wantErr: true, 430 + }, 431 + { 432 + name: "invalid manifest URI", 433 + record: TagRecord{ 434 + Manifest: "invalid-uri", 435 + }, 436 + want: "", 437 + wantErr: true, 438 + }, 439 + } 440 + 441 + for _, tt := range tests { 442 + t.Run(tt.name, func(t *testing.T) { 443 + got, err := tt.record.GetManifestDigest() 444 + if (err != nil) != tt.wantErr { 445 + t.Errorf("GetManifestDigest() error = %v, wantErr %v", err, tt.wantErr) 446 + return 447 + } 448 + if got != tt.want { 449 + t.Errorf("GetManifestDigest() = %v, want %v", got, tt.want) 450 + } 451 + }) 292 452 } 293 453 } 294 454
+1 -1
pkg/atproto/manifest_store.go
··· 184 184 for _, option := range options { 185 185 if tagOpt, ok := option.(distribution.WithTagOption); ok { 186 186 tag := tagOpt.Tag 187 - tagRecord := NewTagRecord(s.repository, tag, dgst.String()) 187 + tagRecord := NewTagRecord(s.client.DID(), s.repository, tag, dgst.String()) 188 188 tagRKey := repositoryTagToRKey(s.repository, tag) 189 189 _, err = s.client.PutRecord(ctx, TagCollection, tagRKey, tagRecord) 190 190 if err != nil {
+21 -4
pkg/atproto/tag_store.go
··· 40 40 return distribution.Descriptor{}, fmt.Errorf("failed to unmarshal tag record: %w", err) 41 41 } 42 42 43 + // Extract manifest digest (tries manifest field first, falls back to manifestDigest) 44 + manifestDigest, err := tagRecord.GetManifestDigest() 45 + if err != nil { 46 + return distribution.Descriptor{}, fmt.Errorf("failed to get manifest digest from tag record: %w", err) 47 + } 48 + 43 49 // Parse manifest digest 44 - dgst, err := digest.Parse(tagRecord.ManifestDigest) 50 + dgst, err := digest.Parse(manifestDigest) 45 51 if err != nil { 46 52 return distribution.Descriptor{}, fmt.Errorf("invalid manifest digest in tag record: %w", err) 47 53 } ··· 55 61 56 62 // Tag associates a tag with a descriptor (manifest digest) 57 63 func (s *TagStore) Tag(ctx context.Context, tag string, desc distribution.Descriptor) error { 58 - // Create tag record 59 - tagRecord := NewTagRecord(s.repository, tag, desc.Digest.String()) 64 + // Create tag record with manifest AT-URI 65 + tagRecord := NewTagRecord(s.client.DID(), s.repository, tag, desc.Digest.String()) 60 66 61 67 // Store in ATProto 62 68 rkey := repositoryTagToRKey(s.repository, tag) ··· 116 122 } 117 123 118 124 // Only include tags for this repository that match the digest 119 - if tagRecord.Repository == s.repository && tagRecord.ManifestDigest == desc.Digest.String() { 125 + if tagRecord.Repository != s.repository { 126 + continue 127 + } 128 + 129 + // Extract digest from tag record (tries manifest field first, falls back to manifestDigest) 130 + manifestDigest, err := tagRecord.GetManifestDigest() 131 + if err != nil { 132 + // Skip records with invalid manifest references 133 + continue 134 + } 135 + 136 + if manifestDigest == desc.Digest.String() { 120 137 tags = append(tags, tagRecord.Tag) 121 138 } 122 139 }
+74 -2
pkg/atproto/tag_store_test.go
··· 128 128 } 129 129 } 130 130 131 + // TestTagStore_Get_BackwardCompatibility tests reading old tag records with only manifestDigest field 132 + func TestTagStore_Get_BackwardCompatibility(t *testing.T) { 133 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 134 + // Old tag record with only manifestDigest (no manifest field) 135 + response := `{ 136 + "uri": "at://did:plc:test123/io.atcr.tag/myapp_latest", 137 + "cid": "bafytest", 138 + "value": { 139 + "$type": "io.atcr.tag", 140 + "repository": "myapp", 141 + "tag": "latest", 142 + "manifestDigest": "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", 143 + "updatedAt": "2025-01-01T00:00:00Z" 144 + } 145 + }` 146 + w.WriteHeader(http.StatusOK) 147 + w.Write([]byte(response)) 148 + })) 149 + defer server.Close() 150 + 151 + client := NewClient(server.URL, "did:plc:test123", "test-token") 152 + store := NewTagStore(client, "myapp") 153 + 154 + desc, err := store.Get(context.Background(), "latest") 155 + if err != nil { 156 + t.Fatalf("Get() error = %v, should handle old records", err) 157 + } 158 + 159 + if desc.Digest.String() != "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" { 160 + t.Errorf("Digest = %v, want sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", desc.Digest.String()) 161 + } 162 + } 163 + 164 + // TestTagStore_Get_NewManifestField tests reading new tag records with manifest field 165 + func TestTagStore_Get_NewManifestField(t *testing.T) { 166 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 167 + // New tag record with manifest field 168 + response := `{ 169 + "uri": "at://did:plc:test123/io.atcr.tag/myapp_latest", 170 + "cid": "bafytest", 171 + "value": { 172 + "$type": "io.atcr.tag", 173 + "repository": "myapp", 174 + "tag": "latest", 175 + "manifest": "at://did:plc:test123/io.atcr.manifest/fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321", 176 + "updatedAt": "2025-01-01T00:00:00Z" 177 + } 178 + }` 179 + w.WriteHeader(http.StatusOK) 180 + w.Write([]byte(response)) 181 + })) 182 + defer server.Close() 183 + 184 + client := NewClient(server.URL, "did:plc:test123", "test-token") 185 + store := NewTagStore(client, "myapp") 186 + 187 + desc, err := store.Get(context.Background(), "latest") 188 + if err != nil { 189 + t.Fatalf("Get() error = %v", err) 190 + } 191 + 192 + if desc.Digest.String() != "sha256:fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321" { 193 + t.Errorf("Digest = %v, want sha256:fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321", desc.Digest.String()) 194 + } 195 + } 196 + 131 197 // TestTagStore_Tag tests creating/updating a tag 132 198 func TestTagStore_Tag(t *testing.T) { 133 199 tests := []struct { ··· 226 292 if sentTagRecord.Tag != tt.tag { 227 293 t.Errorf("Tag = %v, want %v", sentTagRecord.Tag, tt.tag) 228 294 } 229 - if sentTagRecord.ManifestDigest != tt.digest.String() { 230 - t.Errorf("ManifestDigest = %v, want %v", sentTagRecord.ManifestDigest, tt.digest.String()) 295 + // New records should have manifest field 296 + expectedURI := BuildManifestURI("did:plc:test123", tt.digest.String()) 297 + if sentTagRecord.Manifest != expectedURI { 298 + t.Errorf("Manifest = %v, want %v", sentTagRecord.Manifest, expectedURI) 299 + } 300 + // New records should NOT have manifestDigest field 301 + if sentTagRecord.ManifestDigest != "" { 302 + t.Errorf("ManifestDigest should be empty for new records, got %v", sentTagRecord.ManifestDigest) 231 303 } 232 304 } 233 305 })
+1 -1
pkg/auth/session.go
··· 1 1 package auth 2 2 3 3 import ( 4 + "atcr.io/pkg/atproto" 4 5 "bytes" 5 6 "context" 6 7 "crypto/sha256" ··· 11 12 "net/http" 12 13 "sync" 13 14 "time" 14 - "atcr.io/pkg/atproto" 15 15 16 16 "github.com/bluesky-social/indigo/atproto/identity" 17 17 "github.com/bluesky-social/indigo/atproto/syntax"
+3 -3
pkg/auth/token/cache.go
··· 140 140 } 141 141 142 142 return map[string]interface{}{ 143 - "total_entries": len(globalServiceTokens), 144 - "valid_tokens": validCount, 145 - "expired_tokens": expiredCount, 143 + "total_entries": len(globalServiceTokens), 144 + "valid_tokens": validCount, 145 + "expired_tokens": expiredCount, 146 146 } 147 147 } 148 148
+1 -1
pkg/hold/oci/xrpc_test.go
··· 11 11 "strconv" 12 12 "testing" 13 13 14 - "atcr.io/pkg/hold/pds" 15 14 "atcr.io/pkg/atproto" 15 + "atcr.io/pkg/hold/pds" 16 16 "atcr.io/pkg/s3" 17 17 "github.com/distribution/distribution/v3/registry/storage/driver/factory" 18 18 _ "github.com/distribution/distribution/v3/registry/storage/driver/filesystem"