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

322 lines
9.4 KiB
Go

package atproto
import (
"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"
// HoldCollection is the collection name for storage holds (BYOS)
HoldCollection = "io.atcr.hold"
// HoldCrewCollection is the collection name for hold crew (membership)
HoldCrewCollection = "io.atcr.hold.crew"
// 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"
)
// 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"`
// HoldEndpoint is the hold service endpoint where blobs are stored
// This is a historical reference that doesn't change even if user's default hold changes
HoldEndpoint string `json:"holdEndpoint"`
// 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
Config BlobReference `json:"config"`
// Layers references the filesystem layers
Layers []BlobReference `json:"layers"`
// 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"`
}
// 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"`
Layers []json.RawMessage `json:"layers"`
Subject json.RawMessage `json:"subject,omitempty"`
Annotations map[string]string `json:"annotations,omitempty"`
}
if err := json.Unmarshal(ociManifest, &ociData); err != nil {
return nil, err
}
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(),
}
// Parse config
if err := json.Unmarshal(ociData.Config, &record.Config); err != nil {
return nil, err
}
// 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, err
}
}
// Parse subject if present
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"`
// ManifestDigest is the digest of the manifest this tag points to
ManifestDigest string `json:"manifestDigest"`
// UpdatedAt timestamp
UpdatedAt time.Time `json:"updatedAt"`
}
// NewTagRecord creates a new tag record
func NewTagRecord(repository, tag, manifestDigest string) *TagRecord {
return &TagRecord{
Type: TagCollection,
Repository: repository,
Tag: tag,
ManifestDigest: manifestDigest,
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"`
}
// NewHoldRecord creates a new hold record
func NewHoldRecord(endpoint, owner string, public bool) *HoldRecord {
return &HoldRecord{
Type: HoldCollection,
Endpoint: endpoint,
Owner: owner,
Public: public,
CreatedAt: time.Now(),
}
}
// HoldCrewRecord represents membership in a storage hold
// Stored in the hold owner's PDS (not the crew member's PDS) to ensure owner maintains full control
// Owner can add/remove crew members by creating/deleting these records in their own PDS
type HoldCrewRecord struct {
// Type should be "io.atcr.hold.crew"
Type string `json:"$type"`
// Hold is the AT URI of the hold record
// e.g., "at://did:plc:owner/io.atcr.hold/hold1"
Hold string `json:"hold"`
// Member is the DID of the crew member
Member string `json:"member"`
// Role defines permissions: "owner", "write", "read"
Role string `json:"role"`
// AddedAt timestamp
AddedAt time.Time `json:"createdAt"`
}
// NewHoldCrewRecord creates a new hold crew record
func NewHoldCrewRecord(hold, member, role string) *HoldCrewRecord {
return &HoldCrewRecord{
Type: HoldCrewCollection,
Hold: hold,
Member: member,
Role: role,
AddedAt: time.Now(),
}
}
// 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 endpoint for blob storage
// 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,
}
}
// 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
}