mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-20 09:14:16 +00:00
681 lines
25 KiB
Go
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"`
|
|
}
|