···661662 return &didDoc, nil
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 // Tag is the tag name (e.g., "latest", "v1.0.0")
242 Tag string `json:"tag"`
243244- // ManifestDigest is the digest of the manifest this tag points to
245- ManifestDigest string `json:"manifestDigest"`
0000000246247 // UpdatedAt timestamp
248 UpdatedAt time.Time `json:"updatedAt"`
249}
250251-// NewTagRecord creates a new tag record
252-func NewTagRecord(repository, tag, manifestDigest string) *TagRecord {
00000000253 return &TagRecord{
254- Type: TagCollection,
255- Repository: repository,
256- Tag: tag,
257- ManifestDigest: manifestDigest,
258- UpdatedAt: time.Now(),
0259 }
260}
261···410// isDID checks if a string is a DID (starts with "did:")
411func isDID(s string) bool {
412 return len(s) > 4 && s[:4] == "did:"
0000000000000000000000000000000000000000000000000000000413}
414415// =============================================================================
···241 // Tag is the tag name (e.g., "latest", "v1.0.0")
242 Tag string `json:"tag"`
243244+ // 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"`
253254 // UpdatedAt timestamp
255 UpdatedAt time.Time `json:"updatedAt"`
256}
257258+// 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+268 return &TagRecord{
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(),
275 }
276}
277···426// isDID checks if a string is a DID (starts with "did:")
427func isDID(s string) bool {
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")
484}
485486// =============================================================================
+163-3
pkg/atproto/lexicon_test.go
···267}
268269func TestNewTagRecord(t *testing.T) {
0270 before := time.Now()
271- record := NewTagRecord("myapp", "latest", "sha256:abc123")
272 after := time.Now()
273274 if record.Type != TagCollection {
···283 t.Errorf("Tag = %v, want latest", record.Tag)
284 }
285286- if record.ManifestDigest != "sha256:abc123" {
287- t.Errorf("ManifestDigest = %v, want sha256:abc123", record.ManifestDigest)
0000000288 }
289290 if record.UpdatedAt.Before(before) || record.UpdatedAt.After(after) {
291 t.Errorf("UpdatedAt = %v, want between %v and %v", record.UpdatedAt, before, after)
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000292 }
293}
294
···184 for _, option := range options {
185 if tagOpt, ok := option.(distribution.WithTagOption); ok {
186 tag := tagOpt.Tag
187- tagRecord := NewTagRecord(s.repository, tag, dgst.String())
188 tagRKey := repositoryTagToRKey(s.repository, tag)
189 _, err = s.client.PutRecord(ctx, TagCollection, tagRKey, tagRecord)
190 if err != nil {
···184 for _, option := range options {
185 if tagOpt, ok := option.(distribution.WithTagOption); ok {
186 tag := tagOpt.Tag
187+ tagRecord := NewTagRecord(s.client.DID(), s.repository, tag, dgst.String())
188 tagRKey := repositoryTagToRKey(s.repository, tag)
189 _, err = s.client.PutRecord(ctx, TagCollection, tagRKey, tagRecord)
190 if err != nil {
+21-4
pkg/atproto/tag_store.go
···40 return distribution.Descriptor{}, fmt.Errorf("failed to unmarshal tag record: %w", err)
41 }
4200000043 // Parse manifest digest
44- dgst, err := digest.Parse(tagRecord.ManifestDigest)
45 if err != nil {
46 return distribution.Descriptor{}, fmt.Errorf("invalid manifest digest in tag record: %w", err)
47 }
···5556// Tag associates a tag with a descriptor (manifest digest)
57func (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())
6061 // Store in ATProto
62 rkey := repositoryTagToRKey(s.repository, tag)
···116 }
117118 // Only include tags for this repository that match the digest
119- if tagRecord.Repository == s.repository && tagRecord.ManifestDigest == desc.Digest.String() {
00000000000120 tags = append(tags, tagRecord.Tag)
121 }
122 }
···40 return distribution.Descriptor{}, fmt.Errorf("failed to unmarshal tag record: %w", err)
41 }
4243+ // 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+49 // Parse manifest digest
50+ dgst, err := digest.Parse(manifestDigest)
51 if err != nil {
52 return distribution.Descriptor{}, fmt.Errorf("invalid manifest digest in tag record: %w", err)
53 }
···6162// Tag associates a tag with a descriptor (manifest digest)
63func (s *TagStore) Tag(ctx context.Context, tag string, desc distribution.Descriptor) error {
64+ // Create tag record with manifest AT-URI
65+ tagRecord := NewTagRecord(s.client.DID(), s.repository, tag, desc.Digest.String())
6667 // Store in ATProto
68 rkey := repositoryTagToRKey(s.repository, tag)
···122 }
123124 // Only include tags for this repository that match the digest
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() {
137 tags = append(tags, tagRecord.Tag)
138 }
139 }