···88 "key": "any",
99 "record": {
1010 "type": "object",
1111- "required": ["repository", "tag", "manifestDigest", "createdAt"],
1111+ "required": ["repository", "tag", "createdAt"],
1212 "properties": {
1313 "repository": {
1414 "type": "string",
···2020 "description": "Tag name (e.g., 'latest', 'v1.0.0', '12-slim')",
2121 "maxLength": 128
2222 },
2323+ "manifest": {
2424+ "type": "string",
2525+ "format": "at-uri",
2626+ "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."
2727+ },
2328 "manifestDigest": {
2429 "type": "string",
2525- "description": "Digest of the manifest this tag points to (e.g., 'sha256:...')"
3030+ "description": "DEPRECATED: Digest of the manifest (e.g., 'sha256:...'). Kept for backward compatibility with old records. New records should use 'manifest' field instead."
2631 },
2732 "createdAt": {
2833 "type": "string",
+7-1
pkg/appview/jetstream/backfill.go
···400400 return fmt.Errorf("failed to unmarshal tag: %w", err)
401401 }
402402403403+ // Extract digest from tag record (tries manifest field first, falls back to manifestDigest)
404404+ manifestDigest, err := tagRecord.GetManifestDigest()
405405+ if err != nil {
406406+ return fmt.Errorf("failed to get manifest digest from tag record: %w", err)
407407+ }
408408+403409 // Insert or update tag
404410 return db.UpsertTag(b.db, &db.Tag{
405411 DID: did,
406412 Repository: tagRecord.Repository,
407413 Tag: tagRecord.Tag,
408408- Digest: tagRecord.ManifestDigest,
414414+ Digest: manifestDigest,
409415 CreatedAt: tagRecord.UpdatedAt,
410416 })
411417}
+7-1
pkg/appview/jetstream/worker.go
···560560 return nil
561561 }
562562563563+ // Extract digest from tag record (tries manifest field first, falls back to manifestDigest)
564564+ manifestDigest, err := tagRecord.GetManifestDigest()
565565+ if err != nil {
566566+ return fmt.Errorf("failed to get manifest digest from tag record: %w", err)
567567+ }
568568+563569 // Insert or update tag
564570 return db.UpsertTag(w.db, &db.Tag{
565571 DID: commit.DID,
566572 Repository: tagRecord.Repository,
567573 Tag: tagRecord.Tag,
568568- Digest: tagRecord.ManifestDigest,
574574+ Digest: manifestDigest,
569575 CreatedAt: tagRecord.UpdatedAt,
570576 })
571577}
+1-1
pkg/appview/middleware/registry.go
···310310 HoldDID: holdDID,
311311 PDSEndpoint: pdsEndpoint,
312312 Repository: repositoryName,
313313- ServiceToken: serviceToken, // Cached service token from middleware validation
313313+ ServiceToken: serviceToken, // Cached service token from middleware validation
314314 ATProtoClient: atprotoClient,
315315 Database: nr.database,
316316 Authorizer: nr.authorizer,
+5
pkg/atproto/client.go
···661661662662 return &didDoc, nil
663663}
664664+665665+// DID returns the DID associated with this client
666666+func (c *Client) DID() string {
667667+ return c.did
668668+}
+80-9
pkg/atproto/lexicon.go
···241241 // Tag is the tag name (e.g., "latest", "v1.0.0")
242242 Tag string `json:"tag"`
243243244244- // ManifestDigest is the digest of the manifest this tag points to
245245- ManifestDigest string `json:"manifestDigest"`
244244+ // Manifest is the AT-URI of the manifest this tag points to
245245+ // Format: at://did:plc:xyz/io.atcr.manifest/abc123
246246+ // Preferred over ManifestDigest for new records
247247+ Manifest string `json:"manifest,omitempty"`
248248+249249+ // ManifestDigest is the digest of the manifest this tag points to (DEPRECATED)
250250+ // Kept for backward compatibility with old records
251251+ // New records should use Manifest field instead
252252+ ManifestDigest string `json:"manifestDigest,omitempty"`
246253247254 // UpdatedAt timestamp
248255 UpdatedAt time.Time `json:"updatedAt"`
249256}
250257251251-// NewTagRecord creates a new tag record
252252-func NewTagRecord(repository, tag, manifestDigest string) *TagRecord {
258258+// NewTagRecord creates a new tag record with manifest AT-URI
259259+// did: The DID of the user (e.g., "did:plc:xyz123")
260260+// repository: The repository name (e.g., "myapp")
261261+// tag: The tag name (e.g., "latest", "v1.0.0")
262262+// manifestDigest: The manifest digest (e.g., "sha256:abc123...")
263263+func NewTagRecord(did, repository, tag, manifestDigest string) *TagRecord {
264264+ // Build AT-URI for the manifest
265265+ // Format: at://did:plc:xyz/io.atcr.manifest/<digest-without-sha256-prefix>
266266+ manifestURI := BuildManifestURI(did, manifestDigest)
267267+253268 return &TagRecord{
254254- Type: TagCollection,
255255- Repository: repository,
256256- Tag: tag,
257257- ManifestDigest: manifestDigest,
258258- UpdatedAt: time.Now(),
269269+ Type: TagCollection,
270270+ Repository: repository,
271271+ Tag: tag,
272272+ Manifest: manifestURI,
273273+ // Note: ManifestDigest is not set for new records (only for backward compat with old records)
274274+ UpdatedAt: time.Now(),
259275 }
260276}
261277···410426// isDID checks if a string is a DID (starts with "did:")
411427func isDID(s string) bool {
412428 return len(s) > 4 && s[:4] == "did:"
429429+}
430430+431431+// BuildManifestURI creates an AT-URI for a manifest record
432432+// did: The DID of the user (e.g., "did:plc:xyz123")
433433+// manifestDigest: The manifest digest (e.g., "sha256:abc123...")
434434+// Returns: AT-URI in format "at://did:plc:xyz/io.atcr.manifest/<digest-without-sha256-prefix>"
435435+func BuildManifestURI(did, manifestDigest string) string {
436436+ // Remove the "sha256:" prefix from the digest to get the rkey
437437+ rkey := strings.TrimPrefix(manifestDigest, "sha256:")
438438+ return fmt.Sprintf("at://%s/%s/%s", did, ManifestCollection, rkey)
439439+}
440440+441441+// ParseManifestURI extracts the digest from a manifest AT-URI
442442+// manifestURI: AT-URI in format "at://did:plc:xyz/io.atcr.manifest/<digest-without-sha256-prefix>"
443443+// Returns: Full digest with "sha256:" prefix (e.g., "sha256:abc123...")
444444+func ParseManifestURI(manifestURI string) (string, error) {
445445+ // Expected format: at://did:plc:xyz/io.atcr.manifest/<rkey>
446446+ if !strings.HasPrefix(manifestURI, "at://") {
447447+ return "", fmt.Errorf("invalid AT-URI format: must start with 'at://'")
448448+ }
449449+450450+ // Remove "at://" prefix
451451+ remainder := strings.TrimPrefix(manifestURI, "at://")
452452+453453+ // Split by "/"
454454+ parts := strings.Split(remainder, "/")
455455+ if len(parts) != 3 {
456456+ return "", fmt.Errorf("invalid AT-URI format: expected 3 parts (did/collection/rkey), got %d", len(parts))
457457+ }
458458+459459+ // Validate collection
460460+ if parts[1] != ManifestCollection {
461461+ return "", fmt.Errorf("invalid AT-URI: expected collection %s, got %s", ManifestCollection, parts[1])
462462+ }
463463+464464+ // The rkey is the digest without the "sha256:" prefix
465465+ // Add it back to get the full digest
466466+ rkey := parts[2]
467467+ return "sha256:" + rkey, nil
468468+}
469469+470470+// GetManifestDigest extracts the digest from a TagRecord, preferring the manifest field
471471+// Returns the digest with "sha256:" prefix (e.g., "sha256:abc123...")
472472+func (t *TagRecord) GetManifestDigest() (string, error) {
473473+ // Prefer the new manifest field
474474+ if t.Manifest != "" {
475475+ return ParseManifestURI(t.Manifest)
476476+ }
477477+478478+ // Fall back to the legacy manifestDigest field
479479+ if t.ManifestDigest != "" {
480480+ return t.ManifestDigest, nil
481481+ }
482482+483483+ return "", fmt.Errorf("tag record has neither manifest nor manifestDigest field")
413484}
414485415486// =============================================================================
···184184 for _, option := range options {
185185 if tagOpt, ok := option.(distribution.WithTagOption); ok {
186186 tag := tagOpt.Tag
187187- tagRecord := NewTagRecord(s.repository, tag, dgst.String())
187187+ tagRecord := NewTagRecord(s.client.DID(), s.repository, tag, dgst.String())
188188 tagRKey := repositoryTagToRKey(s.repository, tag)
189189 _, err = s.client.PutRecord(ctx, TagCollection, tagRKey, tagRecord)
190190 if err != nil {
+21-4
pkg/atproto/tag_store.go
···4040 return distribution.Descriptor{}, fmt.Errorf("failed to unmarshal tag record: %w", err)
4141 }
42424343+ // Extract manifest digest (tries manifest field first, falls back to manifestDigest)
4444+ manifestDigest, err := tagRecord.GetManifestDigest()
4545+ if err != nil {
4646+ return distribution.Descriptor{}, fmt.Errorf("failed to get manifest digest from tag record: %w", err)
4747+ }
4848+4349 // Parse manifest digest
4444- dgst, err := digest.Parse(tagRecord.ManifestDigest)
5050+ dgst, err := digest.Parse(manifestDigest)
4551 if err != nil {
4652 return distribution.Descriptor{}, fmt.Errorf("invalid manifest digest in tag record: %w", err)
4753 }
···55615662// Tag associates a tag with a descriptor (manifest digest)
5763func (s *TagStore) Tag(ctx context.Context, tag string, desc distribution.Descriptor) error {
5858- // Create tag record
5959- tagRecord := NewTagRecord(s.repository, tag, desc.Digest.String())
6464+ // Create tag record with manifest AT-URI
6565+ tagRecord := NewTagRecord(s.client.DID(), s.repository, tag, desc.Digest.String())
60666167 // Store in ATProto
6268 rkey := repositoryTagToRKey(s.repository, tag)
···116122 }
117123118124 // Only include tags for this repository that match the digest
119119- if tagRecord.Repository == s.repository && tagRecord.ManifestDigest == desc.Digest.String() {
125125+ if tagRecord.Repository != s.repository {
126126+ continue
127127+ }
128128+129129+ // Extract digest from tag record (tries manifest field first, falls back to manifestDigest)
130130+ manifestDigest, err := tagRecord.GetManifestDigest()
131131+ if err != nil {
132132+ // Skip records with invalid manifest references
133133+ continue
134134+ }
135135+136136+ if manifestDigest == desc.Digest.String() {
120137 tags = append(tags, tagRecord.Tag)
121138 }
122139 }
+74-2
pkg/atproto/tag_store_test.go
···128128 }
129129}
130130131131+// TestTagStore_Get_BackwardCompatibility tests reading old tag records with only manifestDigest field
132132+func TestTagStore_Get_BackwardCompatibility(t *testing.T) {
133133+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
134134+ // Old tag record with only manifestDigest (no manifest field)
135135+ response := `{
136136+ "uri": "at://did:plc:test123/io.atcr.tag/myapp_latest",
137137+ "cid": "bafytest",
138138+ "value": {
139139+ "$type": "io.atcr.tag",
140140+ "repository": "myapp",
141141+ "tag": "latest",
142142+ "manifestDigest": "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
143143+ "updatedAt": "2025-01-01T00:00:00Z"
144144+ }
145145+ }`
146146+ w.WriteHeader(http.StatusOK)
147147+ w.Write([]byte(response))
148148+ }))
149149+ defer server.Close()
150150+151151+ client := NewClient(server.URL, "did:plc:test123", "test-token")
152152+ store := NewTagStore(client, "myapp")
153153+154154+ desc, err := store.Get(context.Background(), "latest")
155155+ if err != nil {
156156+ t.Fatalf("Get() error = %v, should handle old records", err)
157157+ }
158158+159159+ if desc.Digest.String() != "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" {
160160+ t.Errorf("Digest = %v, want sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", desc.Digest.String())
161161+ }
162162+}
163163+164164+// TestTagStore_Get_NewManifestField tests reading new tag records with manifest field
165165+func TestTagStore_Get_NewManifestField(t *testing.T) {
166166+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
167167+ // New tag record with manifest field
168168+ response := `{
169169+ "uri": "at://did:plc:test123/io.atcr.tag/myapp_latest",
170170+ "cid": "bafytest",
171171+ "value": {
172172+ "$type": "io.atcr.tag",
173173+ "repository": "myapp",
174174+ "tag": "latest",
175175+ "manifest": "at://did:plc:test123/io.atcr.manifest/fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321",
176176+ "updatedAt": "2025-01-01T00:00:00Z"
177177+ }
178178+ }`
179179+ w.WriteHeader(http.StatusOK)
180180+ w.Write([]byte(response))
181181+ }))
182182+ defer server.Close()
183183+184184+ client := NewClient(server.URL, "did:plc:test123", "test-token")
185185+ store := NewTagStore(client, "myapp")
186186+187187+ desc, err := store.Get(context.Background(), "latest")
188188+ if err != nil {
189189+ t.Fatalf("Get() error = %v", err)
190190+ }
191191+192192+ if desc.Digest.String() != "sha256:fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321" {
193193+ t.Errorf("Digest = %v, want sha256:fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321", desc.Digest.String())
194194+ }
195195+}
196196+131197// TestTagStore_Tag tests creating/updating a tag
132198func TestTagStore_Tag(t *testing.T) {
133199 tests := []struct {
···226292 if sentTagRecord.Tag != tt.tag {
227293 t.Errorf("Tag = %v, want %v", sentTagRecord.Tag, tt.tag)
228294 }
229229- if sentTagRecord.ManifestDigest != tt.digest.String() {
230230- t.Errorf("ManifestDigest = %v, want %v", sentTagRecord.ManifestDigest, tt.digest.String())
295295+ // New records should have manifest field
296296+ expectedURI := BuildManifestURI("did:plc:test123", tt.digest.String())
297297+ if sentTagRecord.Manifest != expectedURI {
298298+ t.Errorf("Manifest = %v, want %v", sentTagRecord.Manifest, expectedURI)
299299+ }
300300+ // New records should NOT have manifestDigest field
301301+ if sentTagRecord.ManifestDigest != "" {
302302+ t.Errorf("ManifestDigest should be empty for new records, got %v", sentTagRecord.ManifestDigest)
231303 }
232304 }
233305 })