Files
at-container-registry/pkg/atproto/lexicon.go
T

681 lines
25 KiB
Go

package atproto
//go:generate go run generate.go
import (
"crypto/sha256"
"encoding/base32"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"
)
// Collection names for ATProto records
const (
// ManifestCollection is the collection name for container manifests
ManifestCollection = "io.atcr.manifest"
// TagCollection is the collection name for image tags
TagCollection = "io.atcr.tag"
// HoldCrewCollection is the collection name for hold crew (membership) - LEGACY BYOS model
// Stored in owner's PDS for BYOS holds
HoldCrewCollection = "io.atcr.hold.crew"
// CaptainCollection is the collection name for captain records (hold ownership) - EMBEDDED PDS model
// Stored in hold's embedded PDS (singleton record at rkey "self")
CaptainCollection = "io.atcr.hold.captain"
// CrewCollection is the collection name for crew records (access control) - EMBEDDED PDS model
// Stored in hold's embedded PDS (one record per member)
// Note: Uses same collection name as HoldCrewCollection but stored in different PDS (hold's PDS vs owner's PDS)
CrewCollection = "io.atcr.hold.crew"
// LayerCollection is the collection name for container layer metadata
// Stored in hold's embedded PDS to track which layers are stored
LayerCollection = "io.atcr.hold.layer"
// StatsCollection is the collection name for repository statistics
// Stored in hold's embedded PDS to track pull/push counts per owner+repo
StatsCollection = "io.atcr.hold.stats"
// TangledProfileCollection is the collection name for tangled profiles
// Stored in hold's embedded PDS (singleton record at rkey "self")
TangledProfileCollection = "sh.tangled.actor.profile"
// BskyPostCollection is the collection name for Bluesky posts
BskyPostCollection = "app.bsky.feed.post"
// SailorProfileCollection is the collection name for user profiles
SailorProfileCollection = "io.atcr.sailor.profile"
// StarCollection is the collection name for repository stars
StarCollection = "io.atcr.sailor.star"
// RepoPageCollection is the collection name for repository page metadata
// Stored in user's PDS with rkey = repository name
RepoPageCollection = "io.atcr.repo.page"
)
// ManifestRecord represents a container image manifest stored in ATProto
// This follows the OCI image manifest specification but stored as an ATProto record
type ManifestRecord struct {
// Type should be "io.atcr.manifest"
Type string `json:"$type"`
// Repository is the name of the repository (e.g., "myapp")
Repository string `json:"repository"`
// Digest is the content digest (e.g., "sha256:abc123...")
Digest string `json:"digest"`
// HoldDID is the DID of the hold service where blobs are stored
// This is the primary reference for hold resolution
// e.g., "did:web:hold01.atcr.io"
HoldDID string `json:"holdDid,omitempty"`
// HoldEndpoint is the hold service endpoint URL where blobs are stored (DEPRECATED)
// Kept for backward compatibility with manifests created before DID migration
// New manifests should use HoldDID instead
// This is a historical reference that doesn't change even if user's default hold changes
HoldEndpoint string `json:"holdEndpoint,omitempty"`
// MediaType is the OCI media type (e.g., "application/vnd.oci.image.manifest.v1+json")
MediaType string `json:"mediaType"`
// SchemaVersion is the OCI schema version (typically 2)
SchemaVersion int `json:"schemaVersion"`
// Config references the image configuration blob (for image manifests)
// Nil for manifest lists/indexes
Config *BlobReference `json:"config,omitempty"`
// Layers references the filesystem layers (for image manifests)
// Empty for manifest lists/indexes
Layers []BlobReference `json:"layers,omitempty"`
// Manifests references other manifests (for manifest lists/indexes)
// Empty for image manifests
Manifests []ManifestReference `json:"manifests,omitempty"`
// Annotations contains arbitrary metadata
Annotations map[string]string `json:"annotations,omitempty"`
// Subject references another manifest (for attestations, signatures, etc.)
Subject *BlobReference `json:"subject,omitempty"`
// ManifestBlob is a reference to the manifest blob stored in ATProto blob storage
ManifestBlob *ATProtoBlobRef `json:"manifestBlob,omitempty"`
// CreatedAt timestamp
CreatedAt time.Time `json:"createdAt"`
}
// BlobReference represents a reference to a blob (layer or config)
// Blobs are stored in S3 and referenced by digest
type BlobReference struct {
// MediaType of the blob
MediaType string `json:"mediaType"`
// Digest is the content digest (e.g., "sha256:abc123...")
Digest string `json:"digest"`
// Size in bytes
Size int64 `json:"size"`
// URLs where the blob can be retrieved (S3 URLs)
URLs []string `json:"urls,omitempty"`
// Annotations for the blob
Annotations map[string]string `json:"annotations,omitempty"`
}
// ManifestReference represents a reference to a manifest in a manifest list/index
type ManifestReference struct {
// MediaType of the referenced manifest
MediaType string `json:"mediaType"`
// Digest is the content digest (e.g., "sha256:abc123...")
Digest string `json:"digest"`
// Size in bytes
Size int64 `json:"size"`
// Platform describes the platform/architecture this manifest is for
Platform *Platform `json:"platform,omitempty"`
// Annotations for the manifest reference
Annotations map[string]string `json:"annotations,omitempty"`
}
// Platform describes the platform (OS/architecture) for a manifest
type Platform struct {
// Architecture is the CPU architecture (e.g., "amd64", "arm64", "arm")
Architecture string `json:"architecture"`
// OS is the operating system (e.g., "linux", "windows", "darwin")
OS string `json:"os"`
// OSVersion is the optional OS version
OSVersion string `json:"os.version,omitempty"`
// OSFeatures is an optional list of OS features
OSFeatures []string `json:"os.features,omitempty"`
// Variant is the optional CPU variant (e.g., "v7" for ARM)
Variant string `json:"variant,omitempty"`
}
// NewManifestRecord creates a new manifest record from OCI manifest JSON
func NewManifestRecord(repository, digest string, ociManifest []byte) (*ManifestRecord, error) {
// Parse the OCI manifest
var ociData struct {
SchemaVersion int `json:"schemaVersion"`
MediaType string `json:"mediaType"`
Config json.RawMessage `json:"config,omitempty"`
Layers []json.RawMessage `json:"layers,omitempty"`
Manifests []json.RawMessage `json:"manifests,omitempty"`
Subject json.RawMessage `json:"subject,omitempty"`
Annotations map[string]string `json:"annotations,omitempty"`
}
if err := json.Unmarshal(ociManifest, &ociData); err != nil {
return nil, err
}
// Detect manifest type based on media type
isManifestList := strings.Contains(ociData.MediaType, "manifest.list") ||
strings.Contains(ociData.MediaType, "image.index")
// Validate: must have either (config+layers) OR (manifests), never both
hasImageFields := len(ociData.Config) > 0 || len(ociData.Layers) > 0
hasIndexFields := len(ociData.Manifests) > 0
if hasImageFields && hasIndexFields {
return nil, fmt.Errorf("manifest cannot have both image fields (config/layers) and index fields (manifests)")
}
if !hasImageFields && !hasIndexFields {
return nil, fmt.Errorf("manifest must have either image fields (config/layers) or index fields (manifests)")
}
record := &ManifestRecord{
Type: ManifestCollection,
Repository: repository,
Digest: digest,
MediaType: ociData.MediaType,
SchemaVersion: ociData.SchemaVersion,
Annotations: ociData.Annotations,
// ManifestBlob will be set by the caller after uploading to blob storage
CreatedAt: time.Now(),
}
if isManifestList {
// Parse manifest list/index
record.Manifests = make([]ManifestReference, len(ociData.Manifests))
for i, m := range ociData.Manifests {
if err := json.Unmarshal(m, &record.Manifests[i]); err != nil {
return nil, fmt.Errorf("failed to parse manifest reference %d: %w", i, err)
}
}
} else {
// Parse image manifest
if len(ociData.Config) > 0 {
var config BlobReference
if err := json.Unmarshal(ociData.Config, &config); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
record.Config = &config
}
// Parse layers
record.Layers = make([]BlobReference, len(ociData.Layers))
for i, layer := range ociData.Layers {
if err := json.Unmarshal(layer, &record.Layers[i]); err != nil {
return nil, fmt.Errorf("failed to parse layer %d: %w", i, err)
}
}
}
// Parse subject if present (works for both types)
if len(ociData.Subject) > 0 {
var subject BlobReference
if err := json.Unmarshal(ociData.Subject, &subject); err != nil {
return nil, err
}
record.Subject = &subject
}
return record, nil
}
// TagRecord represents a tag pointing to a manifest
type TagRecord struct {
// Type should be "io.atcr.tag"
Type string `json:"$type"`
// Repository is the name of the repository
Repository string `json:"repository"`
// Tag is the tag name (e.g., "latest", "v1.0.0")
Tag string `json:"tag"`
// Manifest is the AT-URI of the manifest this tag points to
// Format: at://did:plc:xyz/io.atcr.manifest/abc123
// Preferred over ManifestDigest for new records
Manifest string `json:"manifest,omitempty"`
// ManifestDigest is the digest of the manifest this tag points to (DEPRECATED)
// Kept for backward compatibility with old records
// New records should use Manifest field instead
ManifestDigest string `json:"manifestDigest,omitempty"`
// UpdatedAt timestamp
UpdatedAt time.Time `json:"updatedAt"`
}
// NewTagRecord creates a new tag record with manifest AT-URI
// did: The DID of the user (e.g., "did:plc:xyz123")
// repository: The repository name (e.g., "myapp")
// tag: The tag name (e.g., "latest", "v1.0.0")
// manifestDigest: The manifest digest (e.g., "sha256:abc123...")
func NewTagRecord(did, repository, tag, manifestDigest string) *TagRecord {
// Build AT-URI for the manifest
// Format: at://did:plc:xyz/io.atcr.manifest/<digest-without-sha256-prefix>
manifestURI := BuildManifestURI(did, manifestDigest)
return &TagRecord{
Type: TagCollection,
Repository: repository,
Tag: tag,
Manifest: manifestURI,
// Note: ManifestDigest is not set for new records (only for backward compat with old records)
UpdatedAt: time.Now(),
}
}
// HoldRecord represents a storage hold definition (BYOS)
// Users create these records to define where their blobs should be stored
type HoldRecord struct {
// Type should be "io.atcr.hold"
Type string `json:"$type"`
// Endpoint is the URL of the hold service
// e.g., "https://hold1.example.com"
Endpoint string `json:"endpoint"`
// Owner is the DID of the hold owner
Owner string `json:"owner"`
// Public controls whether this hold allows public blob reads (pulls) without auth
// Writes always require crew membership
Public bool `json:"public"`
// CreatedAt timestamp
CreatedAt time.Time `json:"createdAt"`
}
// SailorProfileRecord represents a user's profile with registry preferences
// Stored in the user's PDS to configure default hold and other settings
type SailorProfileRecord struct {
// Type should be "io.atcr.sailor.profile"
Type string `json:"$type"`
// DefaultHold is the default hold DID for blob storage
// Can be a DID (e.g., "did:web:hold01.atcr.io") or legacy URL
// URLs are migrated to DIDs on user login
// If null/empty, user has opted out of defaults
DefaultHold string `json:"defaultHold,omitempty"`
// CreatedAt timestamp
CreatedAt time.Time `json:"createdAt"`
// UpdatedAt timestamp
UpdatedAt time.Time `json:"updatedAt"`
}
// NewSailorProfileRecord creates a new sailor profile record
func NewSailorProfileRecord(defaultHold string) *SailorProfileRecord {
now := time.Now()
return &SailorProfileRecord{
Type: SailorProfileCollection,
DefaultHold: defaultHold,
CreatedAt: now,
UpdatedAt: now,
}
}
// RepoPageRecord represents repository page metadata (description + avatar)
// Stored in the user's PDS with rkey = repository name
// Users can edit this directly in their PDS to customize their repository page
type RepoPageRecord struct {
// Type should be "io.atcr.repo.page"
Type string `json:"$type"`
// Repository is the name of the repository (e.g., "myapp")
Repository string `json:"repository"`
// Description is the markdown README/description content
Description string `json:"description,omitempty"`
// Avatar is the repository avatar/icon blob reference
Avatar *ATProtoBlobRef `json:"avatar,omitempty"`
// CreatedAt timestamp
CreatedAt time.Time `json:"createdAt"`
// UpdatedAt timestamp
UpdatedAt time.Time `json:"updatedAt"`
}
// NewRepoPageRecord creates a new repo page record
func NewRepoPageRecord(repository, description string, avatar *ATProtoBlobRef) *RepoPageRecord {
now := time.Now()
return &RepoPageRecord{
Type: RepoPageCollection,
Repository: repository,
Description: description,
Avatar: avatar,
CreatedAt: now,
UpdatedAt: now,
}
}
// StarSubject represents the subject of a star (the repository being starred)
type StarSubject struct {
// DID is the DID of the repository owner
DID string `json:"did"`
// Repository is the name of the repository
Repository string `json:"repository"`
}
// StarRecord represents a user starring a repository
// Stored in the starrer's PDS (like Bluesky likes)
type StarRecord struct {
// Type should be "io.atcr.sailor.star"
Type string `json:"$type"`
// Subject is the repository being starred
Subject StarSubject `json:"subject"`
// CreatedAt timestamp
CreatedAt time.Time `json:"createdAt"`
}
// NewStarRecord creates a new star record
func NewStarRecord(ownerDID, repository string) *StarRecord {
return &StarRecord{
Type: StarCollection,
Subject: StarSubject{
DID: ownerDID,
Repository: repository,
},
CreatedAt: time.Now(),
}
}
// StarRecordKey generates a record key for a star
// Uses a simple hash to ensure uniqueness and prevent duplicate stars
func StarRecordKey(ownerDID, repository string) string {
// Use base64 encoding of "ownerDID/repository" as the record key
// This is deterministic and prevents duplicate stars
combined := ownerDID + "/" + repository
return base64.RawURLEncoding.EncodeToString([]byte(combined))
}
// ParseStarRecordKey decodes a star record key back to ownerDID and repository
func ParseStarRecordKey(rkey string) (ownerDID, repository string, err error) {
decoded, err := base64.RawURLEncoding.DecodeString(rkey)
if err != nil {
return "", "", fmt.Errorf("failed to decode star rkey: %w", err)
}
parts := strings.SplitN(string(decoded), "/", 2)
if len(parts) != 2 {
return "", "", fmt.Errorf("invalid star rkey format: %s", string(decoded))
}
return parts[0], parts[1], nil
}
// ResolveHoldDIDFromURL converts a hold endpoint URL to a did:web DID
// This ensures that different representations of the same hold are deduplicated:
// - http://172.28.0.3:8080 → did:web:172.28.0.3:8080
// - http://hold01.atcr.io → did:web:hold01.atcr.io
// - https://hold01.atcr.io → did:web:hold01.atcr.io
// - did:web:hold01.atcr.io → did:web:hold01.atcr.io (passthrough)
func ResolveHoldDIDFromURL(holdURL string) string {
// Handle empty URLs
if holdURL == "" {
return ""
}
// If already a DID, return as-is
if IsDID(holdURL) {
return holdURL
}
// Parse URL to get hostname
holdURL = strings.TrimPrefix(holdURL, "http://")
holdURL = strings.TrimPrefix(holdURL, "https://")
holdURL = strings.TrimSuffix(holdURL, "/")
// Extract hostname (remove path if present)
parts := strings.Split(holdURL, "/")
hostname := parts[0]
// Convert to did:web
// did:web uses hostname directly (port included if non-standard)
return "did:web:" + hostname
}
// IsDID checks if a string is a DID (starts with "did:")
func IsDID(s string) bool {
return len(s) > 4 && s[:4] == "did:"
}
// RepositoryTagToRKey converts a repository and tag to an ATProto record key
// ATProto record keys must match: ^[a-zA-Z0-9._~-]{1,512}$
func RepositoryTagToRKey(repository, tag string) string {
// Combine repository and tag to create a unique key
// Replace invalid characters: slashes become tildes (~)
// We use tilde instead of dash to avoid ambiguity with repository names that contain hyphens
key := fmt.Sprintf("%s_%s", repository, tag)
// Replace / with ~ (slash not allowed in rkeys, tilde is allowed and unlikely in repo names)
key = strings.ReplaceAll(key, "/", "~")
return key
}
// RKeyToRepositoryTag converts an ATProto record key back to repository and tag
// This is the inverse of RepositoryTagToRKey
// Note: If the tag contains underscores, this will split on the LAST underscore
func RKeyToRepositoryTag(rkey string) (repository, tag string) {
// Find the last underscore to split repository and tag
lastUnderscore := strings.LastIndex(rkey, "_")
if lastUnderscore == -1 {
// No underscore found - treat entire string as tag with empty repository
return "", rkey
}
repository = rkey[:lastUnderscore]
tag = rkey[lastUnderscore+1:]
// Convert tildes back to slashes in repository (tilde was used to encode slashes)
repository = strings.ReplaceAll(repository, "~", "/")
return repository, tag
}
// BuildManifestURI creates an AT-URI for a manifest record
// did: The DID of the user (e.g., "did:plc:xyz123")
// manifestDigest: The manifest digest (e.g., "sha256:abc123...")
// Returns: AT-URI in format "at://did:plc:xyz/io.atcr.manifest/<digest-without-sha256-prefix>"
func BuildManifestURI(did, manifestDigest string) string {
// Remove the "sha256:" prefix from the digest to get the rkey
rkey := strings.TrimPrefix(manifestDigest, "sha256:")
return fmt.Sprintf("at://%s/%s/%s", did, ManifestCollection, rkey)
}
// ParseManifestURI extracts the digest from a manifest AT-URI
// manifestURI: AT-URI in format "at://did:plc:xyz/io.atcr.manifest/<digest-without-sha256-prefix>"
// Returns: Full digest with "sha256:" prefix (e.g., "sha256:abc123...")
func ParseManifestURI(manifestURI string) (string, error) {
// Expected format: at://did:plc:xyz/io.atcr.manifest/<rkey>
if !strings.HasPrefix(manifestURI, "at://") {
return "", fmt.Errorf("invalid AT-URI format: must start with 'at://'")
}
// Remove "at://" prefix
remainder := strings.TrimPrefix(manifestURI, "at://")
// Split by "/"
parts := strings.Split(remainder, "/")
if len(parts) != 3 {
return "", fmt.Errorf("invalid AT-URI format: expected 3 parts (did/collection/rkey), got %d", len(parts))
}
// Validate collection
if parts[1] != ManifestCollection {
return "", fmt.Errorf("invalid AT-URI: expected collection %s, got %s", ManifestCollection, parts[1])
}
// The rkey is the digest without the "sha256:" prefix
// Add it back to get the full digest
rkey := parts[2]
return "sha256:" + rkey, nil
}
// GetManifestDigest extracts the digest from a TagRecord, preferring the manifest field
// Returns the digest with "sha256:" prefix (e.g., "sha256:abc123...")
func (t *TagRecord) GetManifestDigest() (string, error) {
// Prefer the new manifest field
if t.Manifest != "" {
return ParseManifestURI(t.Manifest)
}
// Fall back to the legacy manifestDigest field
if t.ManifestDigest != "" {
return t.ManifestDigest, nil
}
return "", fmt.Errorf("tag record has neither manifest nor manifestDigest field")
}
// =============================================================================
// Embedded PDS Types (Hold Service)
// =============================================================================
// CaptainRecord represents the hold's ownership and metadata
// Collection: io.atcr.hold.captain (singleton record at rkey "self")
// Stored in the hold's embedded PDS to identify the hold owner and settings
// Uses CBOR encoding for efficient storage in hold's carstore
type CaptainRecord struct {
Type string `json:"$type" cborgen:"$type"`
Owner string `json:"owner" cborgen:"owner"` // DID of hold owner
Public bool `json:"public" cborgen:"public"` // Public read access
AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var)
DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
Region string `json:"region,omitempty" cborgen:"region,omitempty"` // S3 region (optional)
Provider string `json:"provider,omitempty" cborgen:"provider,omitempty"` // Deployment provider (optional)
}
// CrewRecord represents a crew member in the hold
// Collection: io.atcr.hold.crew (one record per member)
// Stored in the hold's embedded PDS for access control
// Uses CBOR encoding for efficient storage in hold's carstore
// Note: Same collection name as HoldCrewRecord but stored in hold's PDS (not owner's PDS)
type CrewRecord struct {
Type string `json:"$type" cborgen:"$type"`
Member string `json:"member" cborgen:"member"`
Role string `json:"role" cborgen:"role"`
Permissions []string `json:"permissions" cborgen:"permissions"`
AddedAt string `json:"addedAt" cborgen:"addedAt"` // RFC3339 timestamp
}
// LayerRecord represents metadata about a container layer stored in the hold
// Collection: io.atcr.hold.layer
// Stored in the hold's embedded PDS for tracking and analytics
// Uses CBOR encoding for efficient storage in hold's carstore
type LayerRecord struct {
Type string `json:"$type" cborgen:"$type"`
Digest string `json:"digest" cborgen:"digest"` // Layer digest (e.g., "sha256:abc123...")
Size int64 `json:"size" cborgen:"size"` // Size in bytes
MediaType string `json:"mediaType" cborgen:"mediaType"` // Media type (e.g., "application/vnd.oci.image.layer.v1.tar+gzip")
Repository string `json:"repository" cborgen:"repository"` // Repository this layer belongs to
UserDID string `json:"userDid" cborgen:"userDid"` // DID of user who uploaded this layer
UserHandle string `json:"userHandle" cborgen:"userHandle"` // Handle of user (for display purposes)
CreatedAt string `json:"createdAt" cborgen:"createdAt"` // RFC3339 timestamp
}
// NewLayerRecord creates a new layer record
func NewLayerRecord(digest string, size int64, mediaType, repository, userDID, userHandle string) *LayerRecord {
return &LayerRecord{
Type: LayerCollection,
Digest: digest,
Size: size,
MediaType: mediaType,
Repository: repository,
UserDID: userDID,
UserHandle: userHandle,
CreatedAt: time.Now().Format(time.RFC3339),
}
}
// StatsRecord represents repository statistics stored in the hold's PDS
// Collection: io.atcr.hold.stats
// Stored in the hold's embedded PDS for tracking manifest pull/push counts
// Uses CBOR encoding for efficient storage in hold's carstore
// RKey is deterministic: base32(sha256(ownerDID + "/" + repository)[:16])
type StatsRecord struct {
Type string `json:"$type" cborgen:"$type"`
OwnerDID string `json:"ownerDid" cborgen:"ownerDid"` // DID of the image owner (e.g., "did:plc:xyz123")
Repository string `json:"repository" cborgen:"repository"` // Repository name (e.g., "myapp")
PullCount int64 `json:"pullCount" cborgen:"pullCount"` // Number of manifest downloads
LastPull string `json:"lastPull,omitempty" cborgen:"lastPull,omitempty"`
PushCount int64 `json:"pushCount" cborgen:"pushCount"` // Number of manifest uploads
LastPush string `json:"lastPush,omitempty" cborgen:"lastPush,omitempty"`
UpdatedAt string `json:"updatedAt" cborgen:"updatedAt"` // RFC3339 timestamp
}
// NewStatsRecord creates a new stats record
func NewStatsRecord(ownerDID, repository string) *StatsRecord {
return &StatsRecord{
Type: StatsCollection,
OwnerDID: ownerDID,
Repository: repository,
PullCount: 0,
PushCount: 0,
UpdatedAt: time.Now().Format(time.RFC3339),
}
}
// StatsRecordKey generates a deterministic record key for stats
// Uses base32 encoding of first 16 bytes of SHA-256 hash of "ownerDID/repository"
// This ensures same owner+repo always maps to same rkey
func StatsRecordKey(ownerDID, repository string) string {
combined := ownerDID + "/" + repository
hash := sha256.Sum256([]byte(combined))
// Use first 16 bytes (128 bits) for collision resistance
// Encode with base32 (alphanumeric, lowercase, no padding) for ATProto rkey compatibility
return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(hash[:16]))
}
// TangledProfileRecord represents a Tangled profile for the hold
// Collection: sh.tangled.actor.profile (singleton record at rkey "self")
// Stored in the hold's embedded PDS
// Uses CBOR encoding for efficient storage in hold's carstore
type TangledProfileRecord struct {
Type string `json:"$type" cborgen:"$type"`
Links []string `json:"links" cborgen:"links"`
Stats []string `json:"stats" cborgen:"stats"`
Bluesky bool `json:"bluesky" cborgen:"bluesky"`
Location string `json:"location" cborgen:"location"`
Description string `json:"description" cborgen:"description"`
PinnedRepositories []string `json:"pinnedRepositories" cborgen:"pinnedRepositories"`
}