mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-04-20 00:20:31 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a546a9beca | ||
|
|
59e507fe61 | ||
|
|
2d2fb7906e |
@@ -276,16 +276,17 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
var holdDID string
|
||||
if profile != nil && profile.DefaultHold != "" {
|
||||
if profile != nil && profile.DefaultHold != nil && *profile.DefaultHold != "" {
|
||||
defaultHold := *profile.DefaultHold
|
||||
// Check if defaultHold is a URL (needs migration)
|
||||
if strings.HasPrefix(profile.DefaultHold, "http://") || strings.HasPrefix(profile.DefaultHold, "https://") {
|
||||
slog.Debug("Migrating hold URL to DID", "component", "appview/callback", "did", did, "hold_url", profile.DefaultHold)
|
||||
if strings.HasPrefix(defaultHold, "http://") || strings.HasPrefix(defaultHold, "https://") {
|
||||
slog.Debug("Migrating hold URL to DID", "component", "appview/callback", "did", did, "hold_url", defaultHold)
|
||||
|
||||
// Resolve URL to DID
|
||||
holdDID := atproto.ResolveHoldDIDFromURL(profile.DefaultHold)
|
||||
holdDID = atproto.ResolveHoldDIDFromURL(defaultHold)
|
||||
|
||||
// Update profile with DID
|
||||
profile.DefaultHold = holdDID
|
||||
profile.DefaultHold = &holdDID
|
||||
if err := storage.UpdateProfile(ctx, client, profile); err != nil {
|
||||
slog.Warn("Failed to update profile with hold DID", "component", "appview/callback", "did", did, "error", err)
|
||||
} else {
|
||||
@@ -293,7 +294,7 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
} else {
|
||||
// Already a DID - use it
|
||||
holdDID = profile.DefaultHold
|
||||
holdDID = defaultHold
|
||||
}
|
||||
// Register crew regardless of migration (outside the migration block)
|
||||
// Run in background to avoid blocking OAuth callback if hold is offline
|
||||
|
||||
@@ -62,7 +62,9 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
data.Profile.Handle = user.Handle
|
||||
data.Profile.DID = user.DID
|
||||
data.Profile.PDSEndpoint = user.PDSEndpoint
|
||||
data.Profile.DefaultHold = profile.DefaultHold
|
||||
if profile.DefaultHold != nil {
|
||||
data.Profile.DefaultHold = *profile.DefaultHold
|
||||
}
|
||||
|
||||
if err := h.Templates.ExecuteTemplate(w, "settings", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
@@ -94,8 +96,9 @@ func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
|
||||
profile = atproto.NewSailorProfileRecord(holdEndpoint)
|
||||
} else {
|
||||
// Update existing profile
|
||||
profile.DefaultHold = holdEndpoint
|
||||
profile.UpdatedAt = time.Now()
|
||||
profile.DefaultHold = &holdEndpoint
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
profile.UpdatedAt = &now
|
||||
}
|
||||
|
||||
// Save profile
|
||||
|
||||
@@ -164,12 +164,12 @@ func (b *BackfillWorker) backfillRepo(ctx context.Context, did, collection strin
|
||||
// Track what we found for deletion reconciliation
|
||||
switch collection {
|
||||
case atproto.ManifestCollection:
|
||||
var manifestRecord atproto.ManifestRecord
|
||||
var manifestRecord atproto.Manifest
|
||||
if err := json.Unmarshal(record.Value, &manifestRecord); err == nil {
|
||||
foundManifestDigests = append(foundManifestDigests, manifestRecord.Digest)
|
||||
}
|
||||
case atproto.TagCollection:
|
||||
var tagRecord atproto.TagRecord
|
||||
var tagRecord atproto.Tag
|
||||
if err := json.Unmarshal(record.Value, &tagRecord); err == nil {
|
||||
foundTags = append(foundTags, struct{ Repository, Tag string }{
|
||||
Repository: tagRecord.Repository,
|
||||
@@ -177,10 +177,15 @@ func (b *BackfillWorker) backfillRepo(ctx context.Context, did, collection strin
|
||||
})
|
||||
}
|
||||
case atproto.StarCollection:
|
||||
var starRecord atproto.StarRecord
|
||||
var starRecord atproto.SailorStar
|
||||
if err := json.Unmarshal(record.Value, &starRecord); err == nil {
|
||||
key := fmt.Sprintf("%s/%s", starRecord.Subject.DID, starRecord.Subject.Repository)
|
||||
foundStars[key] = starRecord.CreatedAt
|
||||
key := fmt.Sprintf("%s/%s", starRecord.Subject.Did, starRecord.Subject.Repository)
|
||||
// Parse CreatedAt string to time.Time
|
||||
createdAt, parseErr := time.Parse(time.RFC3339, starRecord.CreatedAt)
|
||||
if parseErr != nil {
|
||||
createdAt = time.Now()
|
||||
}
|
||||
foundStars[key] = createdAt
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,57 +364,12 @@ func (b *BackfillWorker) queryCaptainRecord(ctx context.Context, holdDID string)
|
||||
|
||||
// reconcileAnnotations ensures annotations come from the newest manifest in each repository
|
||||
// This fixes the out-of-order backfill issue where older manifests can overwrite newer annotations
|
||||
// NOTE: Currently disabled because the generated Manifest_Annotations type doesn't support
|
||||
// arbitrary key-value pairs. Would need to update lexicon schema with "unknown" type.
|
||||
func (b *BackfillWorker) reconcileAnnotations(ctx context.Context, did string, pdsClient *atproto.Client) error {
|
||||
// Get all repositories for this DID
|
||||
repositories, err := db.GetRepositoriesForDID(b.db, did)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get repositories: %w", err)
|
||||
}
|
||||
|
||||
for _, repo := range repositories {
|
||||
// Find newest manifest for this repository
|
||||
newestManifest, err := db.GetNewestManifestForRepo(b.db, did, repo)
|
||||
if err != nil {
|
||||
slog.Warn("Backfill failed to get newest manifest for repo", "did", did, "repository", repo, "error", err)
|
||||
continue // Skip on error
|
||||
}
|
||||
|
||||
// Fetch the full manifest record from PDS using the digest as rkey
|
||||
rkey := strings.TrimPrefix(newestManifest.Digest, "sha256:")
|
||||
record, err := pdsClient.GetRecord(ctx, atproto.ManifestCollection, rkey)
|
||||
if err != nil {
|
||||
slog.Warn("Backfill failed to fetch manifest record for repo", "did", did, "repository", repo, "error", err)
|
||||
continue // Skip on error
|
||||
}
|
||||
|
||||
// Parse manifest record
|
||||
var manifestRecord atproto.ManifestRecord
|
||||
if err := json.Unmarshal(record.Value, &manifestRecord); err != nil {
|
||||
slog.Warn("Backfill failed to parse manifest record for repo", "did", did, "repository", repo, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Update annotations from newest manifest only
|
||||
if len(manifestRecord.Annotations) > 0 {
|
||||
// Filter out empty annotations
|
||||
hasData := false
|
||||
for _, value := range manifestRecord.Annotations {
|
||||
if value != "" {
|
||||
hasData = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if hasData {
|
||||
err = db.UpsertRepositoryAnnotations(b.db, did, repo, manifestRecord.Annotations)
|
||||
if err != nil {
|
||||
slog.Warn("Backfill failed to reconcile annotations for repo", "did", did, "repository", repo, "error", err)
|
||||
} else {
|
||||
slog.Info("Backfill reconciled annotations for repo from newest manifest", "did", did, "repository", repo, "digest", newestManifest.Digest)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Re-enable once lexicon supports annotations as map[string]string
|
||||
// For now, skip annotation reconciliation as the generated type is an empty struct
|
||||
_ = did
|
||||
_ = pdsClient
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ func (p *Processor) EnsureUser(ctx context.Context, did string) error {
|
||||
// Returns the manifest ID for further processing (layers/references)
|
||||
func (p *Processor) ProcessManifest(ctx context.Context, did string, recordData []byte) (int64, error) {
|
||||
// Unmarshal manifest record
|
||||
var manifestRecord atproto.ManifestRecord
|
||||
var manifestRecord atproto.Manifest
|
||||
if err := json.Unmarshal(recordData, &manifestRecord); err != nil {
|
||||
return 0, fmt.Errorf("failed to unmarshal manifest: %w", err)
|
||||
}
|
||||
@@ -110,10 +110,19 @@ func (p *Processor) ProcessManifest(ctx context.Context, did string, recordData
|
||||
// Extract hold DID from manifest (with fallback for legacy manifests)
|
||||
// New manifests use holdDid field (DID format)
|
||||
// Old manifests use holdEndpoint field (URL format) - convert to DID
|
||||
holdDID := manifestRecord.HoldDID
|
||||
if holdDID == "" && manifestRecord.HoldEndpoint != "" {
|
||||
var holdDID string
|
||||
if manifestRecord.HoldDid != nil && *manifestRecord.HoldDid != "" {
|
||||
holdDID = *manifestRecord.HoldDid
|
||||
} else if manifestRecord.HoldEndpoint != nil && *manifestRecord.HoldEndpoint != "" {
|
||||
// Legacy manifest - convert URL to DID
|
||||
holdDID = atproto.ResolveHoldDIDFromURL(manifestRecord.HoldEndpoint)
|
||||
holdDID = atproto.ResolveHoldDIDFromURL(*manifestRecord.HoldEndpoint)
|
||||
}
|
||||
|
||||
// Parse CreatedAt string to time.Time
|
||||
createdAt, err := time.Parse(time.RFC3339, manifestRecord.CreatedAt)
|
||||
if err != nil {
|
||||
// Fall back to current time if parsing fails
|
||||
createdAt = time.Now()
|
||||
}
|
||||
|
||||
// Prepare manifest for insertion (WITHOUT annotation fields)
|
||||
@@ -122,9 +131,9 @@ func (p *Processor) ProcessManifest(ctx context.Context, did string, recordData
|
||||
Repository: manifestRecord.Repository,
|
||||
Digest: manifestRecord.Digest,
|
||||
MediaType: manifestRecord.MediaType,
|
||||
SchemaVersion: manifestRecord.SchemaVersion,
|
||||
SchemaVersion: int(manifestRecord.SchemaVersion),
|
||||
HoldEndpoint: holdDID,
|
||||
CreatedAt: manifestRecord.CreatedAt,
|
||||
CreatedAt: createdAt,
|
||||
// Annotations removed - stored separately in repository_annotations table
|
||||
}
|
||||
|
||||
@@ -154,24 +163,11 @@ func (p *Processor) ProcessManifest(ctx context.Context, did string, recordData
|
||||
}
|
||||
}
|
||||
|
||||
// Update repository annotations ONLY if manifest has at least one non-empty annotation
|
||||
if manifestRecord.Annotations != nil {
|
||||
hasData := false
|
||||
for _, value := range manifestRecord.Annotations {
|
||||
if value != "" {
|
||||
hasData = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if hasData {
|
||||
// Replace all annotations for this repository
|
||||
err = db.UpsertRepositoryAnnotations(p.db, did, manifestRecord.Repository, manifestRecord.Annotations)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to upsert annotations: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Note: Repository annotations are currently disabled because the generated
|
||||
// Manifest_Annotations type doesn't support arbitrary key-value pairs.
|
||||
// The lexicon would need to use "unknown" type for annotations to support this.
|
||||
// TODO: Re-enable once lexicon supports annotations as map[string]string
|
||||
_ = manifestRecord.Annotations
|
||||
|
||||
// Insert manifest references or layers
|
||||
if isManifestList {
|
||||
@@ -184,19 +180,20 @@ func (p *Processor) ProcessManifest(ctx context.Context, did string, recordData
|
||||
|
||||
if ref.Platform != nil {
|
||||
platformArch = ref.Platform.Architecture
|
||||
platformOS = ref.Platform.OS
|
||||
platformVariant = ref.Platform.Variant
|
||||
platformOSVersion = ref.Platform.OSVersion
|
||||
}
|
||||
|
||||
// Detect attestation manifests from annotations
|
||||
isAttestation := false
|
||||
if ref.Annotations != nil {
|
||||
if refType, ok := ref.Annotations["vnd.docker.reference.type"]; ok {
|
||||
isAttestation = refType == "attestation-manifest"
|
||||
platformOS = ref.Platform.Os
|
||||
if ref.Platform.Variant != nil {
|
||||
platformVariant = *ref.Platform.Variant
|
||||
}
|
||||
if ref.Platform.OsVersion != nil {
|
||||
platformOSVersion = *ref.Platform.OsVersion
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Attestation detection via annotations is currently disabled
|
||||
// because the generated Manifest_ManifestReference_Annotations type
|
||||
// doesn't support arbitrary key-value pairs.
|
||||
isAttestation := false
|
||||
|
||||
if err := db.InsertManifestReference(p.db, &db.ManifestReference{
|
||||
ManifestID: manifestID,
|
||||
Digest: ref.Digest,
|
||||
@@ -235,7 +232,7 @@ func (p *Processor) ProcessManifest(ctx context.Context, did string, recordData
|
||||
// ProcessTag processes a tag record and stores it in the database
|
||||
func (p *Processor) ProcessTag(ctx context.Context, did string, recordData []byte) error {
|
||||
// Unmarshal tag record
|
||||
var tagRecord atproto.TagRecord
|
||||
var tagRecord atproto.Tag
|
||||
if err := json.Unmarshal(recordData, &tagRecord); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal tag: %w", err)
|
||||
}
|
||||
@@ -245,20 +242,27 @@ func (p *Processor) ProcessTag(ctx context.Context, did string, recordData []byt
|
||||
return fmt.Errorf("failed to get manifest digest from tag record: %w", err)
|
||||
}
|
||||
|
||||
// Parse CreatedAt string to time.Time
|
||||
tagCreatedAt, err := time.Parse(time.RFC3339, tagRecord.CreatedAt)
|
||||
if err != nil {
|
||||
// Fall back to current time if parsing fails
|
||||
tagCreatedAt = time.Now()
|
||||
}
|
||||
|
||||
// Insert or update tag
|
||||
return db.UpsertTag(p.db, &db.Tag{
|
||||
DID: did,
|
||||
Repository: tagRecord.Repository,
|
||||
Tag: tagRecord.Tag,
|
||||
Digest: manifestDigest,
|
||||
CreatedAt: tagRecord.UpdatedAt,
|
||||
CreatedAt: tagCreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// ProcessStar processes a star record and stores it in the database
|
||||
func (p *Processor) ProcessStar(ctx context.Context, did string, recordData []byte) error {
|
||||
// Unmarshal star record
|
||||
var starRecord atproto.StarRecord
|
||||
var starRecord atproto.SailorStar
|
||||
if err := json.Unmarshal(recordData, &starRecord); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal star: %w", err)
|
||||
}
|
||||
@@ -266,27 +270,33 @@ func (p *Processor) ProcessStar(ctx context.Context, did string, recordData []by
|
||||
// The DID here is the starrer (user who starred)
|
||||
// The subject contains the owner DID and repository
|
||||
// Star count will be calculated on demand from the stars table
|
||||
return db.UpsertStar(p.db, did, starRecord.Subject.DID, starRecord.Subject.Repository, starRecord.CreatedAt)
|
||||
// Parse the CreatedAt string to time.Time
|
||||
createdAt, err := time.Parse(time.RFC3339, starRecord.CreatedAt)
|
||||
if err != nil {
|
||||
// Fall back to current time if parsing fails
|
||||
createdAt = time.Now()
|
||||
}
|
||||
return db.UpsertStar(p.db, did, starRecord.Subject.Did, starRecord.Subject.Repository, createdAt)
|
||||
}
|
||||
|
||||
// ProcessSailorProfile processes a sailor profile record
|
||||
// This is primarily used by backfill to cache captain records for holds
|
||||
func (p *Processor) ProcessSailorProfile(ctx context.Context, did string, recordData []byte, queryCaptainFn func(context.Context, string) error) error {
|
||||
// Unmarshal sailor profile record
|
||||
var profileRecord atproto.SailorProfileRecord
|
||||
var profileRecord atproto.SailorProfile
|
||||
if err := json.Unmarshal(recordData, &profileRecord); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal sailor profile: %w", err)
|
||||
}
|
||||
|
||||
// Skip if no default hold set
|
||||
if profileRecord.DefaultHold == "" {
|
||||
if profileRecord.DefaultHold == nil || *profileRecord.DefaultHold == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Convert hold URL/DID to canonical DID
|
||||
holdDID := atproto.ResolveHoldDIDFromURL(profileRecord.DefaultHold)
|
||||
holdDID := atproto.ResolveHoldDIDFromURL(*profileRecord.DefaultHold)
|
||||
if holdDID == "" {
|
||||
slog.Warn("Invalid hold reference in profile", "component", "processor", "did", did, "default_hold", profileRecord.DefaultHold)
|
||||
slog.Warn("Invalid hold reference in profile", "component", "processor", "did", did, "default_hold", *profileRecord.DefaultHold)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,11 @@ import (
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// ptrString returns a pointer to the given string
|
||||
func ptrString(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
// setupTestDB creates an in-memory SQLite database for testing
|
||||
func setupTestDB(t *testing.T) *sql.DB {
|
||||
database, err := sql.Open("sqlite3", ":memory:")
|
||||
@@ -143,28 +148,22 @@ func TestProcessManifest_ImageManifest(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create test manifest record
|
||||
manifestRecord := &atproto.ManifestRecord{
|
||||
manifestRecord := &atproto.Manifest{
|
||||
Repository: "test-app",
|
||||
Digest: "sha256:abc123",
|
||||
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
||||
SchemaVersion: 2,
|
||||
HoldEndpoint: "did:web:hold01.atcr.io",
|
||||
CreatedAt: time.Now(),
|
||||
Config: &atproto.BlobReference{
|
||||
HoldEndpoint: ptrString("did:web:hold01.atcr.io"),
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
Config: &atproto.Manifest_BlobReference{
|
||||
Digest: "sha256:config123",
|
||||
Size: 1234,
|
||||
},
|
||||
Layers: []atproto.BlobReference{
|
||||
Layers: []atproto.Manifest_BlobReference{
|
||||
{Digest: "sha256:layer1", Size: 5000, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip"},
|
||||
{Digest: "sha256:layer2", Size: 3000, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip"},
|
||||
},
|
||||
Annotations: map[string]string{
|
||||
"org.opencontainers.image.title": "Test App",
|
||||
"org.opencontainers.image.description": "A test application",
|
||||
"org.opencontainers.image.source": "https://github.com/test/app",
|
||||
"org.opencontainers.image.licenses": "MIT",
|
||||
"io.atcr.icon": "https://example.com/icon.png",
|
||||
},
|
||||
// Annotations disabled - generated Manifest_Annotations is empty struct
|
||||
}
|
||||
|
||||
// Marshal to bytes for ProcessManifest
|
||||
@@ -193,25 +192,8 @@ func TestProcessManifest_ImageManifest(t *testing.T) {
|
||||
t.Errorf("Expected 1 manifest, got %d", count)
|
||||
}
|
||||
|
||||
// Verify annotations were stored in repository_annotations table
|
||||
var title, source string
|
||||
err = database.QueryRow("SELECT value FROM repository_annotations WHERE did = ? AND repository = ? AND key = ?",
|
||||
"did:plc:test123", "test-app", "org.opencontainers.image.title").Scan(&title)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to query title annotation: %v", err)
|
||||
}
|
||||
if title != "Test App" {
|
||||
t.Errorf("title = %q, want %q", title, "Test App")
|
||||
}
|
||||
|
||||
err = database.QueryRow("SELECT value FROM repository_annotations WHERE did = ? AND repository = ? AND key = ?",
|
||||
"did:plc:test123", "test-app", "org.opencontainers.image.source").Scan(&source)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to query source annotation: %v", err)
|
||||
}
|
||||
if source != "https://github.com/test/app" {
|
||||
t.Errorf("source = %q, want %q", source, "https://github.com/test/app")
|
||||
}
|
||||
// Note: Annotations verification disabled - generated Manifest_Annotations is empty struct
|
||||
// TODO: Re-enable when lexicon uses "unknown" type for annotations
|
||||
|
||||
// Verify layers were inserted
|
||||
var layerCount int
|
||||
@@ -242,31 +224,31 @@ func TestProcessManifest_ManifestList(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create test manifest list record
|
||||
manifestRecord := &atproto.ManifestRecord{
|
||||
manifestRecord := &atproto.Manifest{
|
||||
Repository: "test-app",
|
||||
Digest: "sha256:list123",
|
||||
MediaType: "application/vnd.oci.image.index.v1+json",
|
||||
SchemaVersion: 2,
|
||||
HoldEndpoint: "did:web:hold01.atcr.io",
|
||||
CreatedAt: time.Now(),
|
||||
Manifests: []atproto.ManifestReference{
|
||||
HoldEndpoint: ptrString("did:web:hold01.atcr.io"),
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
Manifests: []atproto.Manifest_ManifestReference{
|
||||
{
|
||||
Digest: "sha256:amd64manifest",
|
||||
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
||||
Size: 1000,
|
||||
Platform: &atproto.Platform{
|
||||
Platform: &atproto.Manifest_Platform{
|
||||
Architecture: "amd64",
|
||||
OS: "linux",
|
||||
Os: "linux",
|
||||
},
|
||||
},
|
||||
{
|
||||
Digest: "sha256:arm64manifest",
|
||||
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
||||
Size: 1100,
|
||||
Platform: &atproto.Platform{
|
||||
Platform: &atproto.Manifest_Platform{
|
||||
Architecture: "arm64",
|
||||
OS: "linux",
|
||||
Variant: "v8",
|
||||
Os: "linux",
|
||||
Variant: ptrString("v8"),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -326,11 +308,11 @@ func TestProcessTag(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create test tag record (using ManifestDigest field for simplicity)
|
||||
tagRecord := &atproto.TagRecord{
|
||||
tagRecord := &atproto.Tag{
|
||||
Repository: "test-app",
|
||||
Tag: "latest",
|
||||
ManifestDigest: "sha256:abc123",
|
||||
UpdatedAt: time.Now(),
|
||||
ManifestDigest: ptrString("sha256:abc123"),
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
// Marshal to bytes for ProcessTag
|
||||
@@ -368,7 +350,7 @@ func TestProcessTag(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test upserting same tag with new digest
|
||||
tagRecord.ManifestDigest = "sha256:newdigest"
|
||||
tagRecord.ManifestDigest = ptrString("sha256:newdigest")
|
||||
recordBytes, err = json.Marshal(tagRecord)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal tag: %v", err)
|
||||
@@ -407,12 +389,12 @@ func TestProcessStar(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create test star record
|
||||
starRecord := &atproto.StarRecord{
|
||||
Subject: atproto.StarSubject{
|
||||
DID: "did:plc:owner123",
|
||||
starRecord := &atproto.SailorStar{
|
||||
Subject: atproto.SailorStar_Subject{
|
||||
Did: "did:plc:owner123",
|
||||
Repository: "test-app",
|
||||
},
|
||||
CreatedAt: time.Now(),
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
// Marshal to bytes for ProcessStar
|
||||
@@ -466,13 +448,13 @@ func TestProcessManifest_Duplicate(t *testing.T) {
|
||||
p := NewProcessor(database, false)
|
||||
ctx := context.Background()
|
||||
|
||||
manifestRecord := &atproto.ManifestRecord{
|
||||
manifestRecord := &atproto.Manifest{
|
||||
Repository: "test-app",
|
||||
Digest: "sha256:abc123",
|
||||
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
||||
SchemaVersion: 2,
|
||||
HoldEndpoint: "did:web:hold01.atcr.io",
|
||||
CreatedAt: time.Now(),
|
||||
HoldEndpoint: ptrString("did:web:hold01.atcr.io"),
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
// Marshal to bytes for ProcessManifest
|
||||
@@ -518,13 +500,13 @@ func TestProcessManifest_EmptyAnnotations(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Manifest with nil annotations
|
||||
manifestRecord := &atproto.ManifestRecord{
|
||||
manifestRecord := &atproto.Manifest{
|
||||
Repository: "test-app",
|
||||
Digest: "sha256:abc123",
|
||||
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
||||
SchemaVersion: 2,
|
||||
HoldEndpoint: "did:web:hold01.atcr.io",
|
||||
CreatedAt: time.Now(),
|
||||
HoldEndpoint: ptrString("did:web:hold01.atcr.io"),
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
Annotations: nil,
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -505,41 +504,22 @@ func (nr *NamespaceResolver) findHoldDID(ctx context.Context, did, pdsEndpoint s
|
||||
slog.Warn("Failed to read profile", "did", did, "error", err)
|
||||
}
|
||||
|
||||
if profile != nil && profile.DefaultHold != "" {
|
||||
if profile != nil && profile.DefaultHold != nil && *profile.DefaultHold != "" {
|
||||
defaultHold := *profile.DefaultHold
|
||||
// Profile exists with defaultHold set
|
||||
// In test mode, verify it's reachable before using it
|
||||
if nr.testMode {
|
||||
if nr.isHoldReachable(ctx, profile.DefaultHold) {
|
||||
return profile.DefaultHold
|
||||
if nr.isHoldReachable(ctx, defaultHold) {
|
||||
return defaultHold
|
||||
}
|
||||
slog.Debug("User's defaultHold unreachable, falling back to default", "component", "registry/middleware/testmode", "default_hold", profile.DefaultHold)
|
||||
slog.Debug("User's defaultHold unreachable, falling back to default", "component", "registry/middleware/testmode", "default_hold", defaultHold)
|
||||
return nr.defaultHoldDID
|
||||
}
|
||||
return profile.DefaultHold
|
||||
return defaultHold
|
||||
}
|
||||
|
||||
// Profile doesn't exist or defaultHold is null/empty
|
||||
// Check for user's own hold records
|
||||
records, err := client.ListRecords(ctx, atproto.HoldCollection, 10)
|
||||
if err != nil {
|
||||
// Failed to query holds, use default
|
||||
return nr.defaultHoldDID
|
||||
}
|
||||
|
||||
// Find the first hold record
|
||||
for _, record := range records {
|
||||
var holdRecord atproto.HoldRecord
|
||||
if err := json.Unmarshal(record.Value, &holdRecord); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Return the endpoint from the first hold (normalize to DID if URL)
|
||||
if holdRecord.Endpoint != "" {
|
||||
return atproto.ResolveHoldDIDFromURL(holdRecord.Endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
// No profile defaultHold and no own hold records - use AppView default
|
||||
// Legacy io.atcr.hold records are no longer supported - use AppView default
|
||||
return nr.defaultHoldDID
|
||||
}
|
||||
|
||||
|
||||
@@ -204,30 +204,15 @@ func TestFindHoldDID_SailorProfile(t *testing.T) {
|
||||
assert.Equal(t, "did:web:user.hold.io", holdDID, "should use sailor profile's defaultHold")
|
||||
}
|
||||
|
||||
// TestFindHoldDID_LegacyHoldRecords tests legacy hold record discovery
|
||||
func TestFindHoldDID_LegacyHoldRecords(t *testing.T) {
|
||||
// Start a mock PDS server that returns hold records
|
||||
// TestFindHoldDID_NoProfile tests fallback to default hold when no profile exists
|
||||
func TestFindHoldDID_NoProfile(t *testing.T) {
|
||||
// Start a mock PDS server that returns 404 for profile
|
||||
mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/xrpc/com.atproto.repo.getRecord" {
|
||||
// Profile not found
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/xrpc/com.atproto.repo.listRecords" {
|
||||
// Return hold record
|
||||
holdRecord := atproto.NewHoldRecord("https://legacy.hold.io", "alice", true)
|
||||
recordJSON, _ := json.Marshal(holdRecord)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"records": []any{
|
||||
map[string]any{
|
||||
"uri": "at://did:plc:test123/io.atcr.hold/abc123",
|
||||
"value": json.RawMessage(recordJSON),
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer mockPDS.Close()
|
||||
@@ -239,13 +224,14 @@ func TestFindHoldDID_LegacyHoldRecords(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
holdDID := resolver.findHoldDID(ctx, "did:plc:test123", mockPDS.URL)
|
||||
|
||||
// Legacy URL should be converted to DID
|
||||
assert.Equal(t, "did:web:legacy.hold.io", holdDID, "should use legacy hold record and convert to DID")
|
||||
// Should fall back to default hold DID when no profile exists
|
||||
// Note: Legacy io.atcr.hold records are no longer supported
|
||||
assert.Equal(t, "did:web:default.atcr.io", holdDID, "should fall back to default hold DID")
|
||||
}
|
||||
|
||||
// TestFindHoldDID_Priority tests the priority order
|
||||
// TestFindHoldDID_Priority tests that profile takes priority over default
|
||||
func TestFindHoldDID_Priority(t *testing.T) {
|
||||
// Start a mock PDS server that returns both profile and hold records
|
||||
// Start a mock PDS server that returns profile
|
||||
mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/xrpc/com.atproto.repo.getRecord" {
|
||||
// Return sailor profile with defaultHold (highest priority)
|
||||
@@ -256,21 +242,6 @@ func TestFindHoldDID_Priority(t *testing.T) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/xrpc/com.atproto.repo.listRecords" {
|
||||
// Return hold record (should be ignored since profile exists)
|
||||
holdRecord := atproto.NewHoldRecord("https://legacy.hold.io", "alice", true)
|
||||
recordJSON, _ := json.Marshal(holdRecord)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"records": []any{
|
||||
map[string]any{
|
||||
"uri": "at://did:plc:test123/io.atcr.hold/abc123",
|
||||
"value": json.RawMessage(recordJSON),
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer mockPDS.Close()
|
||||
|
||||
@@ -8,11 +8,9 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"github.com/distribution/distribution/v3"
|
||||
@@ -61,29 +59,29 @@ func (s *ManifestStore) Get(ctx context.Context, dgst digest.Digest, options ...
|
||||
}
|
||||
}
|
||||
|
||||
var manifestRecord atproto.ManifestRecord
|
||||
var manifestRecord atproto.Manifest
|
||||
if err := json.Unmarshal(record.Value, &manifestRecord); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal manifest record: %w", err)
|
||||
}
|
||||
|
||||
// Store the hold DID for subsequent blob requests during pull
|
||||
// Prefer HoldDID (new format) with fallback to HoldEndpoint (legacy URL format)
|
||||
// Prefer HoldDid (new format) with fallback to HoldEndpoint (legacy URL format)
|
||||
// The routing repository will cache this for concurrent blob fetches
|
||||
s.mu.Lock()
|
||||
if manifestRecord.HoldDID != "" {
|
||||
if manifestRecord.HoldDid != nil && *manifestRecord.HoldDid != "" {
|
||||
// New format: DID reference (preferred)
|
||||
s.lastFetchedHoldDID = manifestRecord.HoldDID
|
||||
} else if manifestRecord.HoldEndpoint != "" {
|
||||
s.lastFetchedHoldDID = *manifestRecord.HoldDid
|
||||
} else if manifestRecord.HoldEndpoint != nil && *manifestRecord.HoldEndpoint != "" {
|
||||
// Legacy format: URL reference - convert to DID
|
||||
s.lastFetchedHoldDID = atproto.ResolveHoldDIDFromURL(manifestRecord.HoldEndpoint)
|
||||
s.lastFetchedHoldDID = atproto.ResolveHoldDIDFromURL(*manifestRecord.HoldEndpoint)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
var ociManifest []byte
|
||||
|
||||
// New records: Download blob from ATProto blob storage
|
||||
if manifestRecord.ManifestBlob != nil && manifestRecord.ManifestBlob.Ref.Link != "" {
|
||||
ociManifest, err = s.ctx.ATProtoClient.GetBlob(ctx, manifestRecord.ManifestBlob.Ref.Link)
|
||||
if manifestRecord.ManifestBlob != nil && manifestRecord.ManifestBlob.Ref.Defined() {
|
||||
ociManifest, err = s.ctx.ATProtoClient.GetBlob(ctx, manifestRecord.ManifestBlob.Ref.String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to download manifest blob: %w", err)
|
||||
}
|
||||
@@ -136,7 +134,9 @@ func (s *ManifestStore) Put(ctx context.Context, manifest distribution.Manifest,
|
||||
|
||||
// Set the blob reference, hold DID, and hold endpoint
|
||||
manifestRecord.ManifestBlob = blobRef
|
||||
manifestRecord.HoldDID = s.ctx.HoldDID // Primary reference (DID)
|
||||
if s.ctx.HoldDID != "" {
|
||||
manifestRecord.HoldDid = &s.ctx.HoldDID // Primary reference (DID)
|
||||
}
|
||||
|
||||
// Extract Dockerfile labels from config blob and add to annotations
|
||||
// Only for image manifests (not manifest lists which don't have config blobs)
|
||||
@@ -163,7 +163,7 @@ func (s *ManifestStore) Put(ctx context.Context, manifest distribution.Manifest,
|
||||
if !exists {
|
||||
platform := "unknown"
|
||||
if ref.Platform != nil {
|
||||
platform = fmt.Sprintf("%s/%s", ref.Platform.OS, ref.Platform.Architecture)
|
||||
platform = fmt.Sprintf("%s/%s", ref.Platform.Os, ref.Platform.Architecture)
|
||||
}
|
||||
slog.Warn("Manifest list references non-existent child manifest",
|
||||
"repository", s.ctx.Repository,
|
||||
@@ -174,23 +174,11 @@ func (s *ManifestStore) Put(ctx context.Context, manifest distribution.Manifest,
|
||||
}
|
||||
}
|
||||
|
||||
if !isManifestList && s.blobStore != nil && manifestRecord.Config != nil && manifestRecord.Config.Digest != "" {
|
||||
labels, err := s.extractConfigLabels(ctx, manifestRecord.Config.Digest)
|
||||
if err != nil {
|
||||
// Log error but don't fail the push - labels are optional
|
||||
slog.Warn("Failed to extract config labels", "error", err)
|
||||
} else {
|
||||
// Initialize annotations map if needed
|
||||
if manifestRecord.Annotations == nil {
|
||||
manifestRecord.Annotations = make(map[string]string)
|
||||
}
|
||||
|
||||
// Copy labels to annotations (Dockerfile LABELs → manifest annotations)
|
||||
maps.Copy(manifestRecord.Annotations, labels)
|
||||
|
||||
slog.Debug("Extracted labels from config blob", "count", len(labels))
|
||||
}
|
||||
}
|
||||
// Note: Label extraction from config blob is currently disabled because the generated
|
||||
// Manifest_Annotations type doesn't support arbitrary keys. The lexicon schema would
|
||||
// need to use "unknown" type for annotations to support dynamic key-value pairs.
|
||||
// TODO: Update lexicon schema if label extraction is needed.
|
||||
_ = isManifestList // silence unused variable warning for now
|
||||
|
||||
// Store manifest record in ATProto
|
||||
rkey := digestToRKey(dgst)
|
||||
@@ -317,7 +305,7 @@ func (s *ManifestStore) extractConfigLabels(ctx context.Context, configDigestStr
|
||||
|
||||
// notifyHoldAboutManifest notifies the hold service about a manifest upload
|
||||
// This enables the hold to create layer records and Bluesky posts
|
||||
func (s *ManifestStore) notifyHoldAboutManifest(ctx context.Context, manifestRecord *atproto.ManifestRecord, tag, manifestDigest string) error {
|
||||
func (s *ManifestStore) notifyHoldAboutManifest(ctx context.Context, manifestRecord *atproto.Manifest, tag, manifestDigest string) error {
|
||||
// Skip if no service token configured (e.g., anonymous pulls)
|
||||
if s.ctx.ServiceToken == "" {
|
||||
return nil
|
||||
@@ -367,7 +355,7 @@ func (s *ManifestStore) notifyHoldAboutManifest(ctx context.Context, manifestRec
|
||||
}
|
||||
if m.Platform != nil {
|
||||
mData["platform"] = map[string]any{
|
||||
"os": m.Platform.OS,
|
||||
"os": m.Platform.Os,
|
||||
"architecture": m.Platform.Architecture,
|
||||
}
|
||||
}
|
||||
@@ -426,41 +414,16 @@ func (s *ManifestStore) notifyHoldAboutManifest(ctx context.Context, manifestRec
|
||||
|
||||
// refreshReadmeCache refreshes the README cache for this manifest if it has io.atcr.readme annotation
|
||||
// This should be called asynchronously after manifest push to keep README content fresh
|
||||
func (s *ManifestStore) refreshReadmeCache(ctx context.Context, manifestRecord *atproto.ManifestRecord) {
|
||||
// NOTE: Currently disabled because the generated Manifest_Annotations type doesn't support
|
||||
// arbitrary key-value pairs. Would need to update lexicon schema with "unknown" type.
|
||||
func (s *ManifestStore) refreshReadmeCache(ctx context.Context, manifestRecord *atproto.Manifest) {
|
||||
// Skip if no README cache configured
|
||||
if s.ctx.ReadmeCache == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if no annotations or no README URL
|
||||
if manifestRecord.Annotations == nil {
|
||||
return
|
||||
}
|
||||
|
||||
readmeURL, ok := manifestRecord.Annotations["io.atcr.readme"]
|
||||
if !ok || readmeURL == "" {
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("Refreshing README cache", "did", s.ctx.DID, "repository", s.ctx.Repository, "url", readmeURL)
|
||||
|
||||
// Invalidate the cached entry first
|
||||
if err := s.ctx.ReadmeCache.Invalidate(readmeURL); err != nil {
|
||||
slog.Warn("Failed to invalidate README cache", "url", readmeURL, "error", err)
|
||||
// Continue anyway - Get() will still fetch fresh content
|
||||
}
|
||||
|
||||
// Fetch fresh content to populate cache
|
||||
// Use context with timeout to avoid hanging on slow/dead URLs
|
||||
ctxWithTimeout, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := s.ctx.ReadmeCache.Get(ctxWithTimeout, readmeURL)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to refresh README cache", "url", readmeURL, "error", err)
|
||||
// Not a critical error - cache will be refreshed on next page view
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("README cache refreshed successfully", "url", readmeURL)
|
||||
// TODO: Re-enable once lexicon supports annotations as map[string]string
|
||||
// The generated Manifest_Annotations is an empty struct that doesn't support map access.
|
||||
// For now, README cache refresh on push is disabled.
|
||||
_ = manifestRecord // silence unused variable warning
|
||||
}
|
||||
|
||||
@@ -171,15 +171,19 @@ func TestManifestStore_GetLastFetchedHoldDID(t *testing.T) {
|
||||
store := NewManifestStore(ctx, nil)
|
||||
|
||||
// Simulate what happens in Get() when parsing a manifest record
|
||||
var manifestRecord atproto.ManifestRecord
|
||||
manifestRecord.HoldDID = tt.manifestHoldDID
|
||||
manifestRecord.HoldEndpoint = tt.manifestHoldURL
|
||||
var manifestRecord atproto.Manifest
|
||||
if tt.manifestHoldDID != "" {
|
||||
manifestRecord.HoldDid = &tt.manifestHoldDID
|
||||
}
|
||||
if tt.manifestHoldURL != "" {
|
||||
manifestRecord.HoldEndpoint = &tt.manifestHoldURL
|
||||
}
|
||||
|
||||
// Mimic the hold DID extraction logic from Get()
|
||||
if manifestRecord.HoldDID != "" {
|
||||
store.lastFetchedHoldDID = manifestRecord.HoldDID
|
||||
} else if manifestRecord.HoldEndpoint != "" {
|
||||
store.lastFetchedHoldDID = atproto.ResolveHoldDIDFromURL(manifestRecord.HoldEndpoint)
|
||||
if manifestRecord.HoldDid != nil && *manifestRecord.HoldDid != "" {
|
||||
store.lastFetchedHoldDID = *manifestRecord.HoldDid
|
||||
} else if manifestRecord.HoldEndpoint != nil && *manifestRecord.HoldEndpoint != "" {
|
||||
store.lastFetchedHoldDID = atproto.ResolveHoldDIDFromURL(*manifestRecord.HoldEndpoint)
|
||||
}
|
||||
|
||||
got := store.GetLastFetchedHoldDID()
|
||||
@@ -368,7 +372,7 @@ func TestManifestStore_Exists(t *testing.T) {
|
||||
name: "manifest exists",
|
||||
digest: "sha256:abc123",
|
||||
serverStatus: http.StatusOK,
|
||||
serverResp: `{"uri":"at://did:plc:test123/io.atcr.manifest/abc123","cid":"bafytest","value":{}}`,
|
||||
serverResp: `{"uri":"at://did:plc:test123/io.atcr.manifest/abc123","cid":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku","value":{}}`,
|
||||
wantExists: true,
|
||||
wantErr: false,
|
||||
},
|
||||
@@ -433,7 +437,7 @@ func TestManifestStore_Get(t *testing.T) {
|
||||
digest: "sha256:abc123",
|
||||
serverResp: `{
|
||||
"uri":"at://did:plc:test123/io.atcr.manifest/abc123",
|
||||
"cid":"bafytest",
|
||||
"cid":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku",
|
||||
"value":{
|
||||
"$type":"io.atcr.manifest",
|
||||
"repository":"myapp",
|
||||
@@ -443,7 +447,7 @@ func TestManifestStore_Get(t *testing.T) {
|
||||
"mediaType":"application/vnd.oci.image.manifest.v1+json",
|
||||
"manifestBlob":{
|
||||
"$type":"blob",
|
||||
"ref":{"$link":"bafytest"},
|
||||
"ref":{"$link":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"},
|
||||
"mimeType":"application/vnd.oci.image.manifest.v1+json",
|
||||
"size":100
|
||||
}
|
||||
@@ -477,7 +481,9 @@ func TestManifestStore_Get(t *testing.T) {
|
||||
"holdEndpoint":"https://hold02.atcr.io",
|
||||
"mediaType":"application/vnd.oci.image.manifest.v1+json",
|
||||
"manifestBlob":{
|
||||
"ref":{"$link":"bafylegacy"},
|
||||
"$type":"blob",
|
||||
"ref":{"$link":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"},
|
||||
"mimeType":"application/json",
|
||||
"size":100
|
||||
}
|
||||
}
|
||||
@@ -559,7 +565,7 @@ func TestManifestStore_Get_HoldDIDTracking(t *testing.T) {
|
||||
"holdDid":"did:web:hold01.atcr.io",
|
||||
"holdEndpoint":"https://hold01.atcr.io",
|
||||
"mediaType":"application/vnd.oci.image.manifest.v1+json",
|
||||
"manifestBlob":{"ref":{"$link":"bafytest"},"size":100}
|
||||
"manifestBlob":{"$type":"blob","ref":{"$link":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"},"mimeType":"application/json","size":100}
|
||||
}
|
||||
}`,
|
||||
expectedHoldDID: "did:web:hold01.atcr.io",
|
||||
@@ -572,7 +578,7 @@ func TestManifestStore_Get_HoldDIDTracking(t *testing.T) {
|
||||
"$type":"io.atcr.manifest",
|
||||
"holdEndpoint":"https://hold02.atcr.io",
|
||||
"mediaType":"application/vnd.oci.image.manifest.v1+json",
|
||||
"manifestBlob":{"ref":{"$link":"bafytest"},"size":100}
|
||||
"manifestBlob":{"$type":"blob","ref":{"$link":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"},"mimeType":"application/json","size":100}
|
||||
}
|
||||
}`,
|
||||
expectedHoldDID: "did:web:hold02.atcr.io",
|
||||
@@ -646,7 +652,7 @@ func TestManifestStore_Get_OnlyCountsGETRequests(t *testing.T) {
|
||||
"$type":"io.atcr.manifest",
|
||||
"holdDid":"did:web:hold01.atcr.io",
|
||||
"mediaType":"application/vnd.oci.image.manifest.v1+json",
|
||||
"manifestBlob":{"ref":{"$link":"bafytest"},"size":100}
|
||||
"manifestBlob":{"$type":"blob","ref":{"$link":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"},"mimeType":"application/json","size":100}
|
||||
}
|
||||
}`))
|
||||
}))
|
||||
@@ -754,7 +760,7 @@ func TestManifestStore_Put(t *testing.T) {
|
||||
// Handle uploadBlob
|
||||
if r.URL.Path == atproto.RepoUploadBlob {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"blob":{"$type":"blob","ref":{"$link":"bafytest"},"mimeType":"application/json","size":100}}`))
|
||||
w.Write([]byte(`{"blob":{"$type":"blob","ref":{"$link":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"},"mimeType":"application/json","size":100}}`))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -763,7 +769,7 @@ func TestManifestStore_Put(t *testing.T) {
|
||||
json.NewDecoder(r.Body).Decode(&lastBody)
|
||||
w.WriteHeader(tt.serverStatus)
|
||||
if tt.serverStatus == http.StatusOK {
|
||||
w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/abc123","cid":"bafytest"}`))
|
||||
w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/abc123","cid":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"}`))
|
||||
} else {
|
||||
w.Write([]byte(`{"error":"ServerError"}`))
|
||||
}
|
||||
@@ -815,11 +821,11 @@ func TestManifestStore_Put_WithConfigLabels(t *testing.T) {
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == atproto.RepoUploadBlob {
|
||||
w.Write([]byte(`{"blob":{"$type":"blob","ref":{"$link":"bafytest"},"size":100}}`))
|
||||
w.Write([]byte(`{"blob":{"$type":"blob","ref":{"$link":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"},"size":100}}`))
|
||||
return
|
||||
}
|
||||
if r.URL.Path == atproto.RepoPutRecord {
|
||||
w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/config123","cid":"bafytest"}`))
|
||||
w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/config123","cid":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"}`))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -870,7 +876,7 @@ func TestManifestStore_Delete(t *testing.T) {
|
||||
name: "successful delete",
|
||||
digest: "sha256:abc123",
|
||||
serverStatus: http.StatusOK,
|
||||
serverResp: `{"commit":{"cid":"bafytest","rev":"12345"}}`,
|
||||
serverResp: `{"commit":{"cid":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku","rev":"12345"}}`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
@@ -1027,7 +1033,7 @@ func TestManifestStore_Put_ManifestListValidation(t *testing.T) {
|
||||
// Handle uploadBlob
|
||||
if r.URL.Path == atproto.RepoUploadBlob {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"blob":{"$type":"blob","ref":{"$link":"bafytest"},"mimeType":"application/json","size":100}}`))
|
||||
w.Write([]byte(`{"blob":{"$type":"blob","ref":{"$link":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"},"mimeType":"application/json","size":100}}`))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1039,7 +1045,7 @@ func TestManifestStore_Put_ManifestListValidation(t *testing.T) {
|
||||
// If child should exist, return it; otherwise return RecordNotFound
|
||||
if tt.childExists || rkey == childDigest.Encoded() {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/` + rkey + `","cid":"bafytest","value":{}}`))
|
||||
w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/` + rkey + `","cid":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku","value":{}}`))
|
||||
} else {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"error":"RecordNotFound","message":"Record not found"}`))
|
||||
@@ -1050,7 +1056,7 @@ func TestManifestStore_Put_ManifestListValidation(t *testing.T) {
|
||||
// Handle putRecord
|
||||
if r.URL.Path == atproto.RepoPutRecord {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/test123","cid":"bafytest"}`))
|
||||
w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/test123","cid":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"}`))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1111,14 +1117,14 @@ func TestManifestStore_Put_ManifestListValidation_MultipleChildren(t *testing.T)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == atproto.RepoUploadBlob {
|
||||
w.Write([]byte(`{"blob":{"$type":"blob","ref":{"$link":"bafytest"},"size":100}}`))
|
||||
w.Write([]byte(`{"blob":{"$type":"blob","ref":{"$link":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"},"size":100}}`))
|
||||
return
|
||||
}
|
||||
|
||||
if r.URL.Path == atproto.RepoGetRecord {
|
||||
rkey := r.URL.Query().Get("rkey")
|
||||
if existingManifests[rkey] {
|
||||
w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/` + rkey + `","cid":"bafytest","value":{}}`))
|
||||
w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/` + rkey + `","cid":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku","value":{}}`))
|
||||
} else {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"error":"RecordNotFound"}`))
|
||||
@@ -1127,7 +1133,7 @@ func TestManifestStore_Put_ManifestListValidation_MultipleChildren(t *testing.T)
|
||||
}
|
||||
|
||||
if r.URL.Path == atproto.RepoPutRecord {
|
||||
w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/test123","cid":"bafytest"}`))
|
||||
w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/test123","cid":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"}`))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ func EnsureProfile(ctx context.Context, client *atproto.Client, defaultHoldDID s
|
||||
// GetProfile retrieves the user's profile from their PDS
|
||||
// Returns nil if profile doesn't exist
|
||||
// Automatically migrates old URL-based defaultHold values to DIDs
|
||||
func GetProfile(ctx context.Context, client *atproto.Client) (*atproto.SailorProfileRecord, error) {
|
||||
func GetProfile(ctx context.Context, client *atproto.Client) (*atproto.SailorProfile, error) {
|
||||
record, err := client.GetRecord(ctx, atproto.SailorProfileCollection, ProfileRKey)
|
||||
if err != nil {
|
||||
// Check if it's a 404 (profile doesn't exist)
|
||||
@@ -65,17 +65,17 @@ func GetProfile(ctx context.Context, client *atproto.Client) (*atproto.SailorPro
|
||||
}
|
||||
|
||||
// Parse the profile record
|
||||
var profile atproto.SailorProfileRecord
|
||||
var profile atproto.SailorProfile
|
||||
if err := json.Unmarshal(record.Value, &profile); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse profile: %w", err)
|
||||
}
|
||||
|
||||
// Migrate old URL-based defaultHold to DID format
|
||||
// This ensures backward compatibility with profiles created before DID migration
|
||||
if profile.DefaultHold != "" && !atproto.IsDID(profile.DefaultHold) {
|
||||
if profile.DefaultHold != nil && *profile.DefaultHold != "" && !atproto.IsDID(*profile.DefaultHold) {
|
||||
// Convert URL to DID transparently
|
||||
migratedDID := atproto.ResolveHoldDIDFromURL(profile.DefaultHold)
|
||||
profile.DefaultHold = migratedDID
|
||||
migratedDID := atproto.ResolveHoldDIDFromURL(*profile.DefaultHold)
|
||||
profile.DefaultHold = &migratedDID
|
||||
|
||||
// Persist the migration to PDS in a background goroutine
|
||||
// Use a lock to ensure only one goroutine migrates this DID
|
||||
@@ -94,7 +94,8 @@ func GetProfile(ctx context.Context, client *atproto.Client) (*atproto.SailorPro
|
||||
defer cancel()
|
||||
|
||||
// Update the profile on the PDS
|
||||
profile.UpdatedAt = time.Now()
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
profile.UpdatedAt = &now
|
||||
if err := UpdateProfile(ctx, client, &profile); err != nil {
|
||||
slog.Warn("Failed to persist URL-to-DID migration", "component", "profile", "did", did, "error", err)
|
||||
} else {
|
||||
@@ -109,12 +110,13 @@ func GetProfile(ctx context.Context, client *atproto.Client) (*atproto.SailorPro
|
||||
|
||||
// UpdateProfile updates the user's profile
|
||||
// Normalizes defaultHold to DID format before saving
|
||||
func UpdateProfile(ctx context.Context, client *atproto.Client, profile *atproto.SailorProfileRecord) error {
|
||||
func UpdateProfile(ctx context.Context, client *atproto.Client, profile *atproto.SailorProfile) error {
|
||||
// Normalize defaultHold to DID if it's a URL
|
||||
// This ensures we always store DIDs, even if user provides a URL
|
||||
if profile.DefaultHold != "" && !atproto.IsDID(profile.DefaultHold) {
|
||||
profile.DefaultHold = atproto.ResolveHoldDIDFromURL(profile.DefaultHold)
|
||||
slog.Debug("Normalized defaultHold to DID", "component", "profile", "default_hold", profile.DefaultHold)
|
||||
if profile.DefaultHold != nil && *profile.DefaultHold != "" && !atproto.IsDID(*profile.DefaultHold) {
|
||||
normalized := atproto.ResolveHoldDIDFromURL(*profile.DefaultHold)
|
||||
profile.DefaultHold = &normalized
|
||||
slog.Debug("Normalized defaultHold to DID", "component", "profile", "default_hold", normalized)
|
||||
}
|
||||
|
||||
_, err := client.PutRecord(ctx, atproto.SailorProfileCollection, ProfileRKey, profile)
|
||||
|
||||
@@ -39,7 +39,7 @@ func TestEnsureProfile_Create(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var createdProfile *atproto.SailorProfileRecord
|
||||
var createdProfile *atproto.SailorProfile
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// First request: GetRecord (should 404)
|
||||
@@ -95,12 +95,16 @@ func TestEnsureProfile_Create(t *testing.T) {
|
||||
t.Fatal("Profile was not created")
|
||||
}
|
||||
|
||||
if createdProfile.Type != atproto.SailorProfileCollection {
|
||||
t.Errorf("Type = %v, want %v", createdProfile.Type, atproto.SailorProfileCollection)
|
||||
if createdProfile.LexiconTypeID != atproto.SailorProfileCollection {
|
||||
t.Errorf("LexiconTypeID = %v, want %v", createdProfile.LexiconTypeID, atproto.SailorProfileCollection)
|
||||
}
|
||||
|
||||
if createdProfile.DefaultHold != tt.wantNormalized {
|
||||
t.Errorf("DefaultHold = %v, want %v", createdProfile.DefaultHold, tt.wantNormalized)
|
||||
gotDefaultHold := ""
|
||||
if createdProfile.DefaultHold != nil {
|
||||
gotDefaultHold = *createdProfile.DefaultHold
|
||||
}
|
||||
if gotDefaultHold != tt.wantNormalized {
|
||||
t.Errorf("DefaultHold = %v, want %v", gotDefaultHold, tt.wantNormalized)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -154,7 +158,7 @@ func TestGetProfile(t *testing.T) {
|
||||
name string
|
||||
serverResponse string
|
||||
serverStatus int
|
||||
wantProfile *atproto.SailorProfileRecord
|
||||
wantProfile *atproto.SailorProfile
|
||||
wantNil bool
|
||||
wantErr bool
|
||||
expectMigration bool // Whether URL-to-DID migration should happen
|
||||
@@ -265,8 +269,12 @@ func TestGetProfile(t *testing.T) {
|
||||
}
|
||||
|
||||
// Check that defaultHold is migrated to DID in returned profile
|
||||
if profile.DefaultHold != tt.expectedHoldDID {
|
||||
t.Errorf("DefaultHold = %v, want %v", profile.DefaultHold, tt.expectedHoldDID)
|
||||
gotDefaultHold := ""
|
||||
if profile.DefaultHold != nil {
|
||||
gotDefaultHold = *profile.DefaultHold
|
||||
}
|
||||
if gotDefaultHold != tt.expectedHoldDID {
|
||||
t.Errorf("DefaultHold = %v, want %v", gotDefaultHold, tt.expectedHoldDID)
|
||||
}
|
||||
|
||||
if tt.expectMigration {
|
||||
@@ -366,44 +374,43 @@ func TestGetProfile_MigrationLocking(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// testSailorProfile creates a test profile with the given default hold
|
||||
func testSailorProfile(defaultHold string) *atproto.SailorProfile {
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
profile := &atproto.SailorProfile{
|
||||
LexiconTypeID: atproto.SailorProfileCollection,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: &now,
|
||||
}
|
||||
if defaultHold != "" {
|
||||
profile.DefaultHold = &defaultHold
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
// TestUpdateProfile tests updating a user's profile
|
||||
func TestUpdateProfile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
profile *atproto.SailorProfileRecord
|
||||
profile *atproto.SailorProfile
|
||||
wantNormalized string // Expected defaultHold after normalization
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "update with DID",
|
||||
profile: &atproto.SailorProfileRecord{
|
||||
Type: atproto.SailorProfileCollection,
|
||||
DefaultHold: "did:web:hold02.atcr.io",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
name: "update with DID",
|
||||
profile: testSailorProfile("did:web:hold02.atcr.io"),
|
||||
wantNormalized: "did:web:hold02.atcr.io",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "update with URL - should normalize",
|
||||
profile: &atproto.SailorProfileRecord{
|
||||
Type: atproto.SailorProfileCollection,
|
||||
DefaultHold: "https://hold02.atcr.io",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
name: "update with URL - should normalize",
|
||||
profile: testSailorProfile("https://hold02.atcr.io"),
|
||||
wantNormalized: "did:web:hold02.atcr.io",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "clear default hold",
|
||||
profile: &atproto.SailorProfileRecord{
|
||||
Type: atproto.SailorProfileCollection,
|
||||
DefaultHold: "",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
name: "clear default hold",
|
||||
profile: testSailorProfile(""),
|
||||
wantNormalized: "",
|
||||
wantErr: false,
|
||||
},
|
||||
@@ -454,8 +461,12 @@ func TestUpdateProfile(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify normalization also updated the profile object
|
||||
if tt.profile.DefaultHold != tt.wantNormalized {
|
||||
t.Errorf("profile.DefaultHold = %v, want %v (should be updated in-place)", tt.profile.DefaultHold, tt.wantNormalized)
|
||||
gotProfileHold := ""
|
||||
if tt.profile.DefaultHold != nil {
|
||||
gotProfileHold = *tt.profile.DefaultHold
|
||||
}
|
||||
if gotProfileHold != tt.wantNormalized {
|
||||
t.Errorf("profile.DefaultHold = %v, want %v (should be updated in-place)", gotProfileHold, tt.wantNormalized)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -539,8 +550,8 @@ func TestGetProfile_EmptyDefaultHold(t *testing.T) {
|
||||
t.Fatalf("GetProfile() error = %v", err)
|
||||
}
|
||||
|
||||
if profile.DefaultHold != "" {
|
||||
t.Errorf("DefaultHold = %v, want empty string", profile.DefaultHold)
|
||||
if profile.DefaultHold != nil && *profile.DefaultHold != "" {
|
||||
t.Errorf("DefaultHold = %v, want empty or nil", profile.DefaultHold)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -553,12 +564,7 @@ func TestUpdateProfile_ServerError(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
client := atproto.NewClient(server.URL, "did:plc:test123", "test-token")
|
||||
profile := &atproto.SailorProfileRecord{
|
||||
Type: atproto.SailorProfileCollection,
|
||||
DefaultHold: "did:web:hold01.atcr.io",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
profile := testSailorProfile("did:web:hold01.atcr.io")
|
||||
|
||||
err := UpdateProfile(context.Background(), client, profile)
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ func (s *TagStore) Get(ctx context.Context, tag string) (distribution.Descriptor
|
||||
return distribution.Descriptor{}, distribution.ErrTagUnknown{Tag: tag}
|
||||
}
|
||||
|
||||
var tagRecord atproto.TagRecord
|
||||
var tagRecord atproto.Tag
|
||||
if err := json.Unmarshal(record.Value, &tagRecord); err != nil {
|
||||
return distribution.Descriptor{}, fmt.Errorf("failed to unmarshal tag record: %w", err)
|
||||
}
|
||||
@@ -91,7 +91,7 @@ func (s *TagStore) All(ctx context.Context) ([]string, error) {
|
||||
|
||||
var tags []string
|
||||
for _, record := range records {
|
||||
var tagRecord atproto.TagRecord
|
||||
var tagRecord atproto.Tag
|
||||
if err := json.Unmarshal(record.Value, &tagRecord); err != nil {
|
||||
// Skip invalid records
|
||||
continue
|
||||
@@ -116,7 +116,7 @@ func (s *TagStore) Lookup(ctx context.Context, desc distribution.Descriptor) ([]
|
||||
|
||||
var tags []string
|
||||
for _, record := range records {
|
||||
var tagRecord atproto.TagRecord
|
||||
var tagRecord atproto.Tag
|
||||
if err := json.Unmarshal(record.Value, &tagRecord); err != nil {
|
||||
// Skip invalid records
|
||||
continue
|
||||
|
||||
@@ -229,7 +229,7 @@ func TestTagStore_Tag(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var sentTagRecord *atproto.TagRecord
|
||||
var sentTagRecord *atproto.Tag
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
@@ -254,7 +254,7 @@ func TestTagStore_Tag(t *testing.T) {
|
||||
// Parse and verify tag record
|
||||
recordData := body["record"].(map[string]any)
|
||||
recordBytes, _ := json.Marshal(recordData)
|
||||
var tagRecord atproto.TagRecord
|
||||
var tagRecord atproto.Tag
|
||||
json.Unmarshal(recordBytes, &tagRecord)
|
||||
sentTagRecord = &tagRecord
|
||||
|
||||
@@ -284,8 +284,8 @@ func TestTagStore_Tag(t *testing.T) {
|
||||
|
||||
if !tt.wantErr && sentTagRecord != nil {
|
||||
// Verify the tag record
|
||||
if sentTagRecord.Type != atproto.TagCollection {
|
||||
t.Errorf("Type = %v, want %v", sentTagRecord.Type, atproto.TagCollection)
|
||||
if sentTagRecord.LexiconTypeID != atproto.TagCollection {
|
||||
t.Errorf("LexiconTypeID = %v, want %v", sentTagRecord.LexiconTypeID, atproto.TagCollection)
|
||||
}
|
||||
if sentTagRecord.Repository != "myapp" {
|
||||
t.Errorf("Repository = %v, want myapp", sentTagRecord.Repository)
|
||||
@@ -295,11 +295,11 @@ func TestTagStore_Tag(t *testing.T) {
|
||||
}
|
||||
// New records should have manifest field
|
||||
expectedURI := atproto.BuildManifestURI("did:plc:test123", tt.digest.String())
|
||||
if sentTagRecord.Manifest != expectedURI {
|
||||
if sentTagRecord.Manifest == nil || *sentTagRecord.Manifest != expectedURI {
|
||||
t.Errorf("Manifest = %v, want %v", sentTagRecord.Manifest, expectedURI)
|
||||
}
|
||||
// New records should NOT have manifestDigest field
|
||||
if sentTagRecord.ManifestDigest != "" {
|
||||
if sentTagRecord.ManifestDigest != nil && *sentTagRecord.ManifestDigest != "" {
|
||||
t.Errorf("ManifestDigest should be empty for new records, got %v", sentTagRecord.ManifestDigest)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,8 @@ import (
|
||||
|
||||
"github.com/bluesky-social/indigo/atproto/atclient"
|
||||
indigo_oauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
|
||||
lexutil "github.com/bluesky-social/indigo/lex/util"
|
||||
"github.com/ipfs/go-cid"
|
||||
)
|
||||
|
||||
// Sentinel errors
|
||||
@@ -301,7 +303,7 @@ type Link struct {
|
||||
}
|
||||
|
||||
// UploadBlob uploads binary data to the PDS and returns a blob reference
|
||||
func (c *Client) UploadBlob(ctx context.Context, data []byte, mimeType string) (*ATProtoBlobRef, error) {
|
||||
func (c *Client) UploadBlob(ctx context.Context, data []byte, mimeType string) (*lexutil.LexBlob, error) {
|
||||
// Use session provider (locked OAuth with DPoP) - prevents nonce races
|
||||
if c.sessionProvider != nil {
|
||||
var result struct {
|
||||
@@ -323,7 +325,7 @@ func (c *Client) UploadBlob(ctx context.Context, data []byte, mimeType string) (
|
||||
return nil, fmt.Errorf("uploadBlob failed: %w", err)
|
||||
}
|
||||
|
||||
return &result.Blob, nil
|
||||
return atProtoBlobRefToLexBlob(&result.Blob)
|
||||
}
|
||||
|
||||
// Basic Auth (app passwords)
|
||||
@@ -354,7 +356,22 @@ func (c *Client) UploadBlob(ctx context.Context, data []byte, mimeType string) (
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result.Blob, nil
|
||||
return atProtoBlobRefToLexBlob(&result.Blob)
|
||||
}
|
||||
|
||||
// atProtoBlobRefToLexBlob converts an ATProtoBlobRef to a lexutil.LexBlob
|
||||
func atProtoBlobRefToLexBlob(ref *ATProtoBlobRef) (*lexutil.LexBlob, error) {
|
||||
// Parse the CID string from the $link field
|
||||
c, err := cid.Decode(ref.Ref.Link)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse blob CID %q: %w", ref.Ref.Link, err)
|
||||
}
|
||||
|
||||
return &lexutil.LexBlob{
|
||||
Ref: lexutil.LexLink(c),
|
||||
MimeType: ref.MimeType,
|
||||
Size: ref.Size,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetBlob downloads a blob by its CID from the PDS
|
||||
|
||||
@@ -386,11 +386,11 @@ func TestUploadBlob(t *testing.T) {
|
||||
t.Errorf("Content-Type = %v, want %v", r.Header.Get("Content-Type"), mimeType)
|
||||
}
|
||||
|
||||
// Send response
|
||||
// Send response - use a valid CIDv1 in base32 format
|
||||
response := `{
|
||||
"blob": {
|
||||
"$type": "blob",
|
||||
"ref": {"$link": "bafytest123"},
|
||||
"ref": {"$link": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"},
|
||||
"mimeType": "application/octet-stream",
|
||||
"size": 17
|
||||
}
|
||||
@@ -406,12 +406,14 @@ func TestUploadBlob(t *testing.T) {
|
||||
t.Fatalf("UploadBlob() error = %v", err)
|
||||
}
|
||||
|
||||
if blobRef.Type != "blob" {
|
||||
t.Errorf("Type = %v, want blob", blobRef.Type)
|
||||
if blobRef.MimeType != mimeType {
|
||||
t.Errorf("MimeType = %v, want %v", blobRef.MimeType, mimeType)
|
||||
}
|
||||
|
||||
if blobRef.Ref.Link != "bafytest123" {
|
||||
t.Errorf("Ref.Link = %v, want bafytest123", blobRef.Ref.Link)
|
||||
// LexBlob.Ref is a LexLink (cid.Cid alias), use .String() to get the CID string
|
||||
expectedCID := "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"
|
||||
if blobRef.Ref.String() != expectedCID {
|
||||
t.Errorf("Ref.String() = %v, want %v", blobRef.Ref.String(), expectedCID)
|
||||
}
|
||||
|
||||
if blobRef.Size != 17 {
|
||||
|
||||
@@ -3,17 +3,194 @@
|
||||
|
||||
package main
|
||||
|
||||
// CBOR Code Generator
|
||||
// Lexicon and CBOR Code Generator
|
||||
//
|
||||
// This generates optimized CBOR marshaling code for ATProto records.
|
||||
// This generates:
|
||||
// 1. Go types from lexicon JSON files (via lex/lexgen library)
|
||||
// 2. CBOR marshaling code for ATProto records (via cbor-gen)
|
||||
// 3. Type registration for lexutil (register.go)
|
||||
//
|
||||
// Usage:
|
||||
// go generate ./pkg/atproto/...
|
||||
//
|
||||
// This creates pkg/atproto/cbor_gen.go which should be committed to git.
|
||||
// Only re-run when you modify types in pkg/atproto/types.go
|
||||
//
|
||||
// The //go:generate directive is in lexicon.go
|
||||
// Key insight: We use RegisterLexiconTypeID: false to avoid generating init()
|
||||
// blocks that require CBORMarshaler. This breaks the circular dependency between
|
||||
// lexgen and cbor-gen. See: https://github.com/bluesky-social/indigo/issues/931
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/bluesky-social/indigo/atproto/lexicon"
|
||||
"github.com/bluesky-social/indigo/lex/lexgen"
|
||||
"golang.org/x/tools/imports"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Find repo root
|
||||
repoRoot, err := findRepoRoot()
|
||||
if err != nil {
|
||||
fmt.Printf("failed to find repo root: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
pkgDir := filepath.Join(repoRoot, "pkg/atproto")
|
||||
lexDir := filepath.Join(repoRoot, "lexicons")
|
||||
|
||||
// Step 0: Clean up old register.go to avoid conflicts
|
||||
// (It will be regenerated at the end)
|
||||
os.Remove(filepath.Join(pkgDir, "register.go"))
|
||||
|
||||
// Step 1: Load all lexicon schemas into catalog (for cross-references)
|
||||
fmt.Println("Loading lexicons...")
|
||||
cat := lexicon.NewBaseCatalog()
|
||||
if err := cat.LoadDirectory(lexDir); err != nil {
|
||||
fmt.Printf("failed to load lexicons: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Step 2: Generate Go code for each lexicon file
|
||||
fmt.Println("Running lexgen...")
|
||||
config := &lexgen.GenConfig{
|
||||
RegisterLexiconTypeID: false, // KEY: no init() blocks generated
|
||||
UnknownType: "map-string-any",
|
||||
WarningText: "Code generated by generate.go; DO NOT EDIT.",
|
||||
}
|
||||
|
||||
// Track generated types for register.go
|
||||
var registeredTypes []typeInfo
|
||||
|
||||
// Walk lexicon directory and generate code for each file
|
||||
err = filepath.Walk(lexDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() || !strings.HasSuffix(path, ".json") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load and parse the schema file
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read %s: %w", path, err)
|
||||
}
|
||||
|
||||
var sf lexicon.SchemaFile
|
||||
if err := json.Unmarshal(data, &sf); err != nil {
|
||||
return fmt.Errorf("failed to parse %s: %w", path, err)
|
||||
}
|
||||
|
||||
if err := sf.FinishParse(); err != nil {
|
||||
return fmt.Errorf("failed to finish parse %s: %w", path, err)
|
||||
}
|
||||
|
||||
// Flatten the schema
|
||||
flat, err := lexgen.FlattenSchemaFile(&sf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to flatten schema %s: %w", path, err)
|
||||
}
|
||||
|
||||
// Generate code
|
||||
var buf bytes.Buffer
|
||||
gen := &lexgen.CodeGenerator{
|
||||
Config: config,
|
||||
Lex: flat,
|
||||
Cat: &cat,
|
||||
Out: &buf,
|
||||
}
|
||||
|
||||
if err := gen.WriteLexicon(); err != nil {
|
||||
return fmt.Errorf("failed to generate code for %s: %w", path, err)
|
||||
}
|
||||
|
||||
// Fix package name: lexgen generates "ioatcr" but we want "atproto"
|
||||
code := bytes.Replace(buf.Bytes(), []byte("package ioatcr"), []byte("package atproto"), 1)
|
||||
|
||||
// Format with goimports
|
||||
fileName := gen.FileName()
|
||||
formatted, err := imports.Process(fileName, code, nil)
|
||||
if err != nil {
|
||||
// Write unformatted for debugging
|
||||
outPath := filepath.Join(pkgDir, fileName)
|
||||
os.WriteFile(outPath+".broken", code, 0644)
|
||||
return fmt.Errorf("failed to format %s: %w (wrote to %s.broken)", fileName, err, outPath)
|
||||
}
|
||||
|
||||
// Write output file
|
||||
outPath := filepath.Join(pkgDir, fileName)
|
||||
if err := os.WriteFile(outPath, formatted, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write %s: %w", outPath, err)
|
||||
}
|
||||
|
||||
fmt.Printf(" Generated %s\n", fileName)
|
||||
|
||||
// Track type for registration - compute type name from NSID
|
||||
typeName := nsidToTypeName(sf.ID)
|
||||
registeredTypes = append(registeredTypes, typeInfo{
|
||||
NSID: sf.ID,
|
||||
TypeName: typeName,
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("lexgen failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Step 3: Run cbor-gen via exec.Command
|
||||
// This must be a separate process so it can compile the freshly generated types
|
||||
fmt.Println("Running cbor-gen...")
|
||||
if err := runCborGen(repoRoot, pkgDir); err != nil {
|
||||
fmt.Printf("cbor-gen failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Step 4: Generate register.go
|
||||
fmt.Println("Generating register.go...")
|
||||
if err := generateRegisterFile(pkgDir, registeredTypes); err != nil {
|
||||
fmt.Printf("failed to generate register.go: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("Code generation complete!")
|
||||
}
|
||||
|
||||
type typeInfo struct {
|
||||
NSID string
|
||||
TypeName string
|
||||
}
|
||||
|
||||
// nsidToTypeName converts an NSID to a Go type name
|
||||
// io.atcr.manifest → Manifest
|
||||
// io.atcr.hold.captain → HoldCaptain
|
||||
// io.atcr.sailor.profile → SailorProfile
|
||||
func nsidToTypeName(nsid string) string {
|
||||
parts := strings.Split(nsid, ".")
|
||||
if len(parts) < 3 {
|
||||
return ""
|
||||
}
|
||||
// Skip the first two parts (authority, e.g., "io.atcr")
|
||||
// and capitalize each remaining part
|
||||
var result string
|
||||
for _, part := range parts[2:] {
|
||||
if len(part) > 0 {
|
||||
result += strings.ToUpper(part[:1]) + part[1:]
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func runCborGen(repoRoot, pkgDir string) error {
|
||||
// Create a temporary Go file that runs cbor-gen
|
||||
cborGenCode := `//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -25,14 +202,81 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Generate map-style encoders for CrewRecord, CaptainRecord, LayerRecord, and TangledProfileRecord
|
||||
if err := cbg.WriteMapEncodersToFile("cbor_gen.go", "atproto",
|
||||
atproto.CrewRecord{},
|
||||
atproto.CaptainRecord{},
|
||||
atproto.LayerRecord{},
|
||||
// Manifest types
|
||||
atproto.Manifest{},
|
||||
atproto.Manifest_BlobReference{},
|
||||
atproto.Manifest_ManifestReference{},
|
||||
atproto.Manifest_Platform{},
|
||||
atproto.Manifest_Annotations{},
|
||||
atproto.Manifest_BlobReference_Annotations{},
|
||||
atproto.Manifest_ManifestReference_Annotations{},
|
||||
// Tag
|
||||
atproto.Tag{},
|
||||
// Sailor types
|
||||
atproto.SailorProfile{},
|
||||
atproto.SailorStar{},
|
||||
atproto.SailorStar_Subject{},
|
||||
// Hold types
|
||||
atproto.HoldCaptain{},
|
||||
atproto.HoldCrew{},
|
||||
atproto.HoldLayer{},
|
||||
// External types
|
||||
atproto.TangledProfileRecord{},
|
||||
); err != nil {
|
||||
fmt.Printf("Failed to generate CBOR encoders: %v\n", err)
|
||||
fmt.Printf("cbor-gen failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// Write temp file
|
||||
tmpFile := filepath.Join(pkgDir, "cborgen_tmp.go")
|
||||
if err := os.WriteFile(tmpFile, []byte(cborGenCode), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write temp cbor-gen file: %w", err)
|
||||
}
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
// Run it
|
||||
cmd := exec.Command("go", "run", tmpFile)
|
||||
cmd.Dir = pkgDir
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func generateRegisterFile(pkgDir string, types []typeInfo) error {
|
||||
var buf bytes.Buffer
|
||||
|
||||
buf.WriteString("// Code generated by generate.go; DO NOT EDIT.\n\n")
|
||||
buf.WriteString("package atproto\n\n")
|
||||
buf.WriteString("import lexutil \"github.com/bluesky-social/indigo/lex/util\"\n\n")
|
||||
buf.WriteString("func init() {\n")
|
||||
|
||||
for _, t := range types {
|
||||
fmt.Fprintf(&buf, "\tlexutil.RegisterType(%q, &%s{})\n", t.NSID, t.TypeName)
|
||||
}
|
||||
|
||||
buf.WriteString("}\n")
|
||||
|
||||
outPath := filepath.Join(pkgDir, "register.go")
|
||||
return os.WriteFile(outPath, buf.Bytes(), 0644)
|
||||
}
|
||||
|
||||
func findRepoRoot() (string, error) {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
||||
return dir, nil
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return "", fmt.Errorf("go.mod not found")
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
24
pkg/atproto/holdcaptain.go
Normal file
24
pkg/atproto/holdcaptain.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Code generated by generate.go; DO NOT EDIT.
|
||||
|
||||
// Lexicon schema: io.atcr.hold.captain
|
||||
|
||||
package atproto
|
||||
|
||||
// Represents the hold's ownership and metadata. Stored as a singleton record at rkey 'self' in the hold's embedded PDS.
|
||||
type HoldCaptain struct {
|
||||
LexiconTypeID string `json:"$type" cborgen:"$type,const=io.atcr.hold.captain"`
|
||||
// allowAllCrew: Allow any authenticated user to register as crew
|
||||
AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"`
|
||||
// deployedAt: RFC3339 timestamp of when the hold was deployed
|
||||
DeployedAt string `json:"deployedAt" cborgen:"deployedAt"`
|
||||
// enableBlueskyPosts: Enable Bluesky posts when manifests are pushed
|
||||
EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"`
|
||||
// owner: DID of the hold owner
|
||||
Owner string `json:"owner" cborgen:"owner"`
|
||||
// provider: Deployment provider (e.g., fly.io, aws, etc.)
|
||||
Provider *string `json:"provider,omitempty" cborgen:"provider,omitempty"`
|
||||
// public: Whether this hold allows public blob reads (pulls) without authentication
|
||||
Public bool `json:"public" cborgen:"public"`
|
||||
// region: S3 region where blobs are stored
|
||||
Region *string `json:"region,omitempty" cborgen:"region,omitempty"`
|
||||
}
|
||||
18
pkg/atproto/holdcrew.go
Normal file
18
pkg/atproto/holdcrew.go
Normal file
@@ -0,0 +1,18 @@
|
||||
// Code generated by generate.go; DO NOT EDIT.
|
||||
|
||||
// Lexicon schema: io.atcr.hold.crew
|
||||
|
||||
package atproto
|
||||
|
||||
// Crew member in a hold's embedded PDS. Grants access permissions to push blobs to the hold. Stored in the hold's embedded PDS (one record per member).
|
||||
type HoldCrew struct {
|
||||
LexiconTypeID string `json:"$type" cborgen:"$type,const=io.atcr.hold.crew"`
|
||||
// addedAt: RFC3339 timestamp of when the member was added
|
||||
AddedAt string `json:"addedAt" cborgen:"addedAt"`
|
||||
// member: DID of the crew member
|
||||
Member string `json:"member" cborgen:"member"`
|
||||
// permissions: Specific permissions granted to this member
|
||||
Permissions []string `json:"permissions" cborgen:"permissions"`
|
||||
// role: Member's role in the hold
|
||||
Role string `json:"role" cborgen:"role"`
|
||||
}
|
||||
24
pkg/atproto/holdlayer.go
Normal file
24
pkg/atproto/holdlayer.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Code generated by generate.go; DO NOT EDIT.
|
||||
|
||||
// Lexicon schema: io.atcr.hold.layer
|
||||
|
||||
package atproto
|
||||
|
||||
// Represents metadata about a container layer stored in the hold. Stored in the hold's embedded PDS for tracking and analytics.
|
||||
type HoldLayer struct {
|
||||
LexiconTypeID string `json:"$type" cborgen:"$type,const=io.atcr.hold.layer"`
|
||||
// createdAt: RFC3339 timestamp of when the layer was uploaded
|
||||
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
|
||||
// digest: Layer digest (e.g., sha256:abc123...)
|
||||
Digest string `json:"digest" cborgen:"digest"`
|
||||
// mediaType: Media type (e.g., application/vnd.oci.image.layer.v1.tar+gzip)
|
||||
MediaType string `json:"mediaType" cborgen:"mediaType"`
|
||||
// repository: Repository this layer belongs to
|
||||
Repository string `json:"repository" cborgen:"repository"`
|
||||
// size: Size in bytes
|
||||
Size int64 `json:"size" cborgen:"size"`
|
||||
// userDid: DID of user who uploaded this layer
|
||||
UserDid string `json:"userDid" cborgen:"userDid"`
|
||||
// userHandle: Handle of user (for display purposes)
|
||||
UserHandle string `json:"userHandle" cborgen:"userHandle"`
|
||||
}
|
||||
@@ -41,6 +41,9 @@ const (
|
||||
// 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"
|
||||
|
||||
// BskyPostCollection is the collection name for Bluesky posts
|
||||
BskyPostCollection = "app.bsky.feed.post"
|
||||
|
||||
18
pkg/atproto/lexicon_embedded.go
Normal file
18
pkg/atproto/lexicon_embedded.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package atproto
|
||||
|
||||
// This file contains ATProto record types that are NOT generated from our lexicons.
|
||||
// These are either external schemas or special types that require manual definition.
|
||||
|
||||
// TangledProfileRecord represents a Tangled profile for the hold
|
||||
// Collection: sh.tangled.actor.profile (external schema - not controlled by ATCR)
|
||||
// Stored in hold's embedded PDS (singleton record at rkey "self")
|
||||
// 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"`
|
||||
}
|
||||
360
pkg/atproto/lexicon_helpers.go
Normal file
360
pkg/atproto/lexicon_helpers.go
Normal file
@@ -0,0 +1,360 @@
|
||||
package atproto
|
||||
|
||||
//go:generate go run generate.go
|
||||
|
||||
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) - LEGACY
|
||||
HoldCollection = "io.atcr.hold"
|
||||
|
||||
// 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"
|
||||
|
||||
// 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"
|
||||
)
|
||||
|
||||
// NewManifestRecord creates a new manifest record from OCI manifest JSON
|
||||
func NewManifestRecord(repository, digest string, ociManifest []byte) (*Manifest, 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 := &Manifest{
|
||||
LexiconTypeID: ManifestCollection,
|
||||
Repository: repository,
|
||||
Digest: digest,
|
||||
MediaType: ociData.MediaType,
|
||||
SchemaVersion: int64(ociData.SchemaVersion),
|
||||
// ManifestBlob will be set by the caller after uploading to blob storage
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
// Handle annotations - Manifest_Annotations is an empty struct in generated code
|
||||
// We don't copy ociData.Annotations since the generated type doesn't support arbitrary keys
|
||||
|
||||
if isManifestList {
|
||||
// Parse manifest list/index
|
||||
record.Manifests = make([]Manifest_ManifestReference, len(ociData.Manifests))
|
||||
for i, m := range ociData.Manifests {
|
||||
var ref struct {
|
||||
MediaType string `json:"mediaType"`
|
||||
Digest string `json:"digest"`
|
||||
Size int64 `json:"size"`
|
||||
Platform *Manifest_Platform `json:"platform,omitempty"`
|
||||
Annotations map[string]string `json:"annotations,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(m, &ref); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse manifest reference %d: %w", i, err)
|
||||
}
|
||||
record.Manifests[i] = Manifest_ManifestReference{
|
||||
MediaType: ref.MediaType,
|
||||
Digest: ref.Digest,
|
||||
Size: ref.Size,
|
||||
Platform: ref.Platform,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Parse image manifest
|
||||
if len(ociData.Config) > 0 {
|
||||
var config Manifest_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([]Manifest_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 Manifest_BlobReference
|
||||
if err := json.Unmarshal(ociData.Subject, &subject); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
record.Subject = &subject
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// 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) *Tag {
|
||||
// Build AT-URI for the manifest
|
||||
// Format: at://did:plc:xyz/io.atcr.manifest/<digest-without-sha256-prefix>
|
||||
manifestURI := BuildManifestURI(did, manifestDigest)
|
||||
|
||||
return &Tag{
|
||||
LexiconTypeID: TagCollection,
|
||||
Repository: repository,
|
||||
Tag: tag,
|
||||
Manifest: &manifestURI,
|
||||
// Note: ManifestDigest is not set for new records (only for backward compat with old records)
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
// NewSailorProfileRecord creates a new sailor profile record
|
||||
func NewSailorProfileRecord(defaultHold string) *SailorProfile {
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
var holdPtr *string
|
||||
if defaultHold != "" {
|
||||
holdPtr = &defaultHold
|
||||
}
|
||||
return &SailorProfile{
|
||||
LexiconTypeID: SailorProfileCollection,
|
||||
DefaultHold: holdPtr,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: &now,
|
||||
}
|
||||
}
|
||||
|
||||
// NewStarRecord creates a new star record
|
||||
func NewStarRecord(ownerDID, repository string) *SailorStar {
|
||||
return &SailorStar{
|
||||
LexiconTypeID: StarCollection,
|
||||
Subject: SailorStar_Subject{
|
||||
Did: ownerDID,
|
||||
Repository: repository,
|
||||
},
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
// NewLayerRecord creates a new layer record
|
||||
func NewLayerRecord(digest string, size int64, mediaType, repository, userDID, userHandle string) *HoldLayer {
|
||||
return &HoldLayer{
|
||||
LexiconTypeID: LayerCollection,
|
||||
Digest: digest,
|
||||
Size: size,
|
||||
MediaType: mediaType,
|
||||
Repository: repository,
|
||||
UserDid: userDID,
|
||||
UserHandle: userHandle,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
// 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 Tag, preferring the manifest field
|
||||
// Returns the digest with "sha256:" prefix (e.g., "sha256:abc123...")
|
||||
func (t *Tag) GetManifestDigest() (string, error) {
|
||||
// Prefer the new manifest field
|
||||
if t.Manifest != nil && *t.Manifest != "" {
|
||||
return ParseManifestURI(*t.Manifest)
|
||||
}
|
||||
|
||||
// Fall back to the legacy manifestDigest field
|
||||
if t.ManifestDigest != nil && *t.ManifestDigest != "" {
|
||||
return *t.ManifestDigest, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("tag record has neither manifest nor manifestDigest field")
|
||||
}
|
||||
@@ -104,7 +104,7 @@ func TestNewManifestRecord(t *testing.T) {
|
||||
digest string
|
||||
ociManifest string
|
||||
wantErr bool
|
||||
checkFunc func(*testing.T, *ManifestRecord)
|
||||
checkFunc func(*testing.T, *Manifest)
|
||||
}{
|
||||
{
|
||||
name: "valid OCI manifest",
|
||||
@@ -112,9 +112,9 @@ func TestNewManifestRecord(t *testing.T) {
|
||||
digest: "sha256:abc123",
|
||||
ociManifest: validOCIManifest,
|
||||
wantErr: false,
|
||||
checkFunc: func(t *testing.T, record *ManifestRecord) {
|
||||
if record.Type != ManifestCollection {
|
||||
t.Errorf("Type = %v, want %v", record.Type, ManifestCollection)
|
||||
checkFunc: func(t *testing.T, record *Manifest) {
|
||||
if record.LexiconTypeID != ManifestCollection {
|
||||
t.Errorf("LexiconTypeID = %v, want %v", record.LexiconTypeID, ManifestCollection)
|
||||
}
|
||||
if record.Repository != "myapp" {
|
||||
t.Errorf("Repository = %v, want myapp", record.Repository)
|
||||
@@ -143,11 +143,9 @@ func TestNewManifestRecord(t *testing.T) {
|
||||
if record.Layers[1].Digest != "sha256:layer2" {
|
||||
t.Errorf("Layers[1].Digest = %v, want sha256:layer2", record.Layers[1].Digest)
|
||||
}
|
||||
if record.Annotations["org.opencontainers.image.created"] != "2025-01-01T00:00:00Z" {
|
||||
t.Errorf("Annotations missing expected key")
|
||||
}
|
||||
if record.CreatedAt.IsZero() {
|
||||
t.Error("CreatedAt should not be zero")
|
||||
// Note: Annotations are not copied to generated type (empty struct)
|
||||
if record.CreatedAt == "" {
|
||||
t.Error("CreatedAt should not be empty")
|
||||
}
|
||||
if record.Subject != nil {
|
||||
t.Error("Subject should be nil")
|
||||
@@ -160,7 +158,7 @@ func TestNewManifestRecord(t *testing.T) {
|
||||
digest: "sha256:abc123",
|
||||
ociManifest: manifestWithSubject,
|
||||
wantErr: false,
|
||||
checkFunc: func(t *testing.T, record *ManifestRecord) {
|
||||
checkFunc: func(t *testing.T, record *Manifest) {
|
||||
if record.Subject == nil {
|
||||
t.Fatal("Subject should not be nil")
|
||||
}
|
||||
@@ -192,7 +190,7 @@ func TestNewManifestRecord(t *testing.T) {
|
||||
digest: "sha256:multiarch",
|
||||
ociManifest: manifestList,
|
||||
wantErr: false,
|
||||
checkFunc: func(t *testing.T, record *ManifestRecord) {
|
||||
checkFunc: func(t *testing.T, record *Manifest) {
|
||||
if record.MediaType != "application/vnd.oci.image.index.v1+json" {
|
||||
t.Errorf("MediaType = %v, want application/vnd.oci.image.index.v1+json", record.MediaType)
|
||||
}
|
||||
@@ -219,8 +217,8 @@ func TestNewManifestRecord(t *testing.T) {
|
||||
if record.Manifests[0].Platform.Architecture != "amd64" {
|
||||
t.Errorf("Platform.Architecture = %v, want amd64", record.Manifests[0].Platform.Architecture)
|
||||
}
|
||||
if record.Manifests[0].Platform.OS != "linux" {
|
||||
t.Errorf("Platform.OS = %v, want linux", record.Manifests[0].Platform.OS)
|
||||
if record.Manifests[0].Platform.Os != "linux" {
|
||||
t.Errorf("Platform.Os = %v, want linux", record.Manifests[0].Platform.Os)
|
||||
}
|
||||
|
||||
// Check second manifest (arm64)
|
||||
@@ -230,7 +228,7 @@ func TestNewManifestRecord(t *testing.T) {
|
||||
if record.Manifests[1].Platform.Architecture != "arm64" {
|
||||
t.Errorf("Platform.Architecture = %v, want arm64", record.Manifests[1].Platform.Architecture)
|
||||
}
|
||||
if record.Manifests[1].Platform.Variant != "v8" {
|
||||
if record.Manifests[1].Platform.Variant == nil || *record.Manifests[1].Platform.Variant != "v8" {
|
||||
t.Errorf("Platform.Variant = %v, want v8", record.Manifests[1].Platform.Variant)
|
||||
}
|
||||
},
|
||||
@@ -268,12 +266,13 @@ func TestNewManifestRecord(t *testing.T) {
|
||||
|
||||
func TestNewTagRecord(t *testing.T) {
|
||||
did := "did:plc:test123"
|
||||
before := time.Now()
|
||||
// Truncate to second precision since RFC3339 doesn't have sub-second precision
|
||||
before := time.Now().Truncate(time.Second)
|
||||
record := NewTagRecord(did, "myapp", "latest", "sha256:abc123")
|
||||
after := time.Now()
|
||||
after := time.Now().Truncate(time.Second).Add(time.Second)
|
||||
|
||||
if record.Type != TagCollection {
|
||||
t.Errorf("Type = %v, want %v", record.Type, TagCollection)
|
||||
if record.LexiconTypeID != TagCollection {
|
||||
t.Errorf("LexiconTypeID = %v, want %v", record.LexiconTypeID, TagCollection)
|
||||
}
|
||||
|
||||
if record.Repository != "myapp" {
|
||||
@@ -286,17 +285,21 @@ func TestNewTagRecord(t *testing.T) {
|
||||
|
||||
// New records should have manifest field (AT-URI)
|
||||
expectedURI := "at://did:plc:test123/io.atcr.manifest/abc123"
|
||||
if record.Manifest != expectedURI {
|
||||
if record.Manifest == nil || *record.Manifest != expectedURI {
|
||||
t.Errorf("Manifest = %v, want %v", record.Manifest, expectedURI)
|
||||
}
|
||||
|
||||
// New records should NOT have manifestDigest field
|
||||
if record.ManifestDigest != "" {
|
||||
t.Errorf("ManifestDigest should be empty for new records, got %v", record.ManifestDigest)
|
||||
if record.ManifestDigest != nil && *record.ManifestDigest != "" {
|
||||
t.Errorf("ManifestDigest should be nil for new records, got %v", record.ManifestDigest)
|
||||
}
|
||||
|
||||
if record.UpdatedAt.Before(before) || record.UpdatedAt.After(after) {
|
||||
t.Errorf("UpdatedAt = %v, want between %v and %v", record.UpdatedAt, before, after)
|
||||
createdAt, err := time.Parse(time.RFC3339, record.CreatedAt)
|
||||
if err != nil {
|
||||
t.Errorf("CreatedAt is not valid RFC3339: %v", err)
|
||||
}
|
||||
if createdAt.Before(before) || createdAt.After(after) {
|
||||
t.Errorf("CreatedAt = %v, want between %v and %v", createdAt, before, after)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,47 +394,50 @@ func TestParseManifestURI(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTagRecord_GetManifestDigest(t *testing.T) {
|
||||
manifestURI := "at://did:plc:test123/io.atcr.manifest/abc123"
|
||||
digestValue := "sha256:def456"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
record TagRecord
|
||||
record Tag
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "new record with manifest field",
|
||||
record: TagRecord{
|
||||
Manifest: "at://did:plc:test123/io.atcr.manifest/abc123",
|
||||
record: Tag{
|
||||
Manifest: &manifestURI,
|
||||
},
|
||||
want: "sha256:abc123",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "old record with manifestDigest field",
|
||||
record: TagRecord{
|
||||
ManifestDigest: "sha256:def456",
|
||||
record: Tag{
|
||||
ManifestDigest: &digestValue,
|
||||
},
|
||||
want: "sha256:def456",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "prefers manifest over manifestDigest",
|
||||
record: TagRecord{
|
||||
Manifest: "at://did:plc:test123/io.atcr.manifest/abc123",
|
||||
ManifestDigest: "sha256:def456",
|
||||
record: Tag{
|
||||
Manifest: &manifestURI,
|
||||
ManifestDigest: &digestValue,
|
||||
},
|
||||
want: "sha256:abc123",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "no fields set",
|
||||
record: TagRecord{},
|
||||
record: Tag{},
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid manifest URI",
|
||||
record: TagRecord{
|
||||
Manifest: "invalid-uri",
|
||||
record: Tag{
|
||||
Manifest: func() *string { s := "invalid-uri"; return &s }(),
|
||||
},
|
||||
want: "",
|
||||
wantErr: true,
|
||||
@@ -452,55 +458,7 @@ func TestTagRecord_GetManifestDigest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewHoldRecord(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
endpoint string
|
||||
owner string
|
||||
public bool
|
||||
}{
|
||||
{
|
||||
name: "public hold",
|
||||
endpoint: "https://hold1.example.com",
|
||||
owner: "did:plc:alice123",
|
||||
public: true,
|
||||
},
|
||||
{
|
||||
name: "private hold",
|
||||
endpoint: "https://hold2.example.com",
|
||||
owner: "did:plc:bob456",
|
||||
public: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
before := time.Now()
|
||||
record := NewHoldRecord(tt.endpoint, tt.owner, tt.public)
|
||||
after := time.Now()
|
||||
|
||||
if record.Type != HoldCollection {
|
||||
t.Errorf("Type = %v, want %v", record.Type, HoldCollection)
|
||||
}
|
||||
|
||||
if record.Endpoint != tt.endpoint {
|
||||
t.Errorf("Endpoint = %v, want %v", record.Endpoint, tt.endpoint)
|
||||
}
|
||||
|
||||
if record.Owner != tt.owner {
|
||||
t.Errorf("Owner = %v, want %v", record.Owner, tt.owner)
|
||||
}
|
||||
|
||||
if record.Public != tt.public {
|
||||
t.Errorf("Public = %v, want %v", record.Public, tt.public)
|
||||
}
|
||||
|
||||
if record.CreatedAt.Before(before) || record.CreatedAt.After(after) {
|
||||
t.Errorf("CreatedAt = %v, want between %v and %v", record.CreatedAt, before, after)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
// TestNewHoldRecord is removed - HoldRecord is no longer supported (legacy BYOS)
|
||||
|
||||
func TestNewSailorProfileRecord(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -523,53 +481,72 @@ func TestNewSailorProfileRecord(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
before := time.Now()
|
||||
// Truncate to second precision since RFC3339 doesn't have sub-second precision
|
||||
before := time.Now().Truncate(time.Second)
|
||||
record := NewSailorProfileRecord(tt.defaultHold)
|
||||
after := time.Now()
|
||||
after := time.Now().Truncate(time.Second).Add(time.Second)
|
||||
|
||||
if record.Type != SailorProfileCollection {
|
||||
t.Errorf("Type = %v, want %v", record.Type, SailorProfileCollection)
|
||||
if record.LexiconTypeID != SailorProfileCollection {
|
||||
t.Errorf("LexiconTypeID = %v, want %v", record.LexiconTypeID, SailorProfileCollection)
|
||||
}
|
||||
|
||||
if record.DefaultHold != tt.defaultHold {
|
||||
t.Errorf("DefaultHold = %v, want %v", record.DefaultHold, tt.defaultHold)
|
||||
if tt.defaultHold == "" {
|
||||
if record.DefaultHold != nil {
|
||||
t.Errorf("DefaultHold = %v, want nil", record.DefaultHold)
|
||||
}
|
||||
} else {
|
||||
if record.DefaultHold == nil || *record.DefaultHold != tt.defaultHold {
|
||||
t.Errorf("DefaultHold = %v, want %v", record.DefaultHold, tt.defaultHold)
|
||||
}
|
||||
}
|
||||
|
||||
if record.CreatedAt.Before(before) || record.CreatedAt.After(after) {
|
||||
t.Errorf("CreatedAt = %v, want between %v and %v", record.CreatedAt, before, after)
|
||||
createdAt, err := time.Parse(time.RFC3339, record.CreatedAt)
|
||||
if err != nil {
|
||||
t.Errorf("CreatedAt is not valid RFC3339: %v", err)
|
||||
}
|
||||
if createdAt.Before(before) || createdAt.After(after) {
|
||||
t.Errorf("CreatedAt = %v, want between %v and %v", createdAt, before, after)
|
||||
}
|
||||
|
||||
if record.UpdatedAt.Before(before) || record.UpdatedAt.After(after) {
|
||||
t.Errorf("UpdatedAt = %v, want between %v and %v", record.UpdatedAt, before, after)
|
||||
}
|
||||
|
||||
// CreatedAt and UpdatedAt should be equal for new records
|
||||
if !record.CreatedAt.Equal(record.UpdatedAt) {
|
||||
t.Errorf("CreatedAt (%v) != UpdatedAt (%v)", record.CreatedAt, record.UpdatedAt)
|
||||
if record.UpdatedAt == nil {
|
||||
t.Error("UpdatedAt should not be nil")
|
||||
} else {
|
||||
updatedAt, err := time.Parse(time.RFC3339, *record.UpdatedAt)
|
||||
if err != nil {
|
||||
t.Errorf("UpdatedAt is not valid RFC3339: %v", err)
|
||||
}
|
||||
if updatedAt.Before(before) || updatedAt.After(after) {
|
||||
t.Errorf("UpdatedAt = %v, want between %v and %v", updatedAt, before, after)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewStarRecord(t *testing.T) {
|
||||
before := time.Now()
|
||||
// Truncate to second precision since RFC3339 doesn't have sub-second precision
|
||||
before := time.Now().Truncate(time.Second)
|
||||
record := NewStarRecord("did:plc:alice123", "myapp")
|
||||
after := time.Now()
|
||||
after := time.Now().Truncate(time.Second).Add(time.Second)
|
||||
|
||||
if record.Type != StarCollection {
|
||||
t.Errorf("Type = %v, want %v", record.Type, StarCollection)
|
||||
if record.LexiconTypeID != StarCollection {
|
||||
t.Errorf("LexiconTypeID = %v, want %v", record.LexiconTypeID, StarCollection)
|
||||
}
|
||||
|
||||
if record.Subject.DID != "did:plc:alice123" {
|
||||
t.Errorf("Subject.DID = %v, want did:plc:alice123", record.Subject.DID)
|
||||
if record.Subject.Did != "did:plc:alice123" {
|
||||
t.Errorf("Subject.Did = %v, want did:plc:alice123", record.Subject.Did)
|
||||
}
|
||||
|
||||
if record.Subject.Repository != "myapp" {
|
||||
t.Errorf("Subject.Repository = %v, want myapp", record.Subject.Repository)
|
||||
}
|
||||
|
||||
if record.CreatedAt.Before(before) || record.CreatedAt.After(after) {
|
||||
t.Errorf("CreatedAt = %v, want between %v and %v", record.CreatedAt, before, after)
|
||||
createdAt, err := time.Parse(time.RFC3339, record.CreatedAt)
|
||||
if err != nil {
|
||||
t.Errorf("CreatedAt is not valid RFC3339: %v", err)
|
||||
}
|
||||
if createdAt.Before(before) || createdAt.After(after) {
|
||||
t.Errorf("CreatedAt = %v, want between %v and %v", createdAt, before, after)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -857,7 +834,8 @@ func TestManifestRecord_JSONSerialization(t *testing.T) {
|
||||
}
|
||||
|
||||
// Add hold DID
|
||||
record.HoldDID = "did:web:hold01.atcr.io"
|
||||
holdDID := "did:web:hold01.atcr.io"
|
||||
record.HoldDid = &holdDID
|
||||
|
||||
// Serialize to JSON
|
||||
jsonData, err := json.Marshal(record)
|
||||
@@ -866,14 +844,14 @@ func TestManifestRecord_JSONSerialization(t *testing.T) {
|
||||
}
|
||||
|
||||
// Deserialize from JSON
|
||||
var decoded ManifestRecord
|
||||
var decoded Manifest
|
||||
if err := json.Unmarshal(jsonData, &decoded); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify fields
|
||||
if decoded.Type != record.Type {
|
||||
t.Errorf("Type = %v, want %v", decoded.Type, record.Type)
|
||||
if decoded.LexiconTypeID != record.LexiconTypeID {
|
||||
t.Errorf("LexiconTypeID = %v, want %v", decoded.LexiconTypeID, record.LexiconTypeID)
|
||||
}
|
||||
if decoded.Repository != record.Repository {
|
||||
t.Errorf("Repository = %v, want %v", decoded.Repository, record.Repository)
|
||||
@@ -881,8 +859,8 @@ func TestManifestRecord_JSONSerialization(t *testing.T) {
|
||||
if decoded.Digest != record.Digest {
|
||||
t.Errorf("Digest = %v, want %v", decoded.Digest, record.Digest)
|
||||
}
|
||||
if decoded.HoldDID != record.HoldDID {
|
||||
t.Errorf("HoldDID = %v, want %v", decoded.HoldDID, record.HoldDID)
|
||||
if decoded.HoldDid == nil || *decoded.HoldDid != *record.HoldDid {
|
||||
t.Errorf("HoldDid = %v, want %v", decoded.HoldDid, record.HoldDid)
|
||||
}
|
||||
if decoded.Config.Digest != record.Config.Digest {
|
||||
t.Errorf("Config.Digest = %v, want %v", decoded.Config.Digest, record.Config.Digest)
|
||||
@@ -893,14 +871,12 @@ func TestManifestRecord_JSONSerialization(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBlobReference_JSONSerialization(t *testing.T) {
|
||||
blob := BlobReference{
|
||||
blob := Manifest_BlobReference{
|
||||
MediaType: "application/vnd.oci.image.layer.v1.tar+gzip",
|
||||
Digest: "sha256:abc123",
|
||||
Size: 12345,
|
||||
URLs: []string{"https://s3.example.com/blob"},
|
||||
Annotations: map[string]string{
|
||||
"key": "value",
|
||||
},
|
||||
Urls: []string{"https://s3.example.com/blob"},
|
||||
// Note: Annotations is now an empty struct, not a map
|
||||
}
|
||||
|
||||
// Serialize
|
||||
@@ -910,7 +886,7 @@ func TestBlobReference_JSONSerialization(t *testing.T) {
|
||||
}
|
||||
|
||||
// Deserialize
|
||||
var decoded BlobReference
|
||||
var decoded Manifest_BlobReference
|
||||
if err := json.Unmarshal(jsonData, &decoded); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
@@ -928,8 +904,8 @@ func TestBlobReference_JSONSerialization(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStarSubject_JSONSerialization(t *testing.T) {
|
||||
subject := StarSubject{
|
||||
DID: "did:plc:alice123",
|
||||
subject := SailorStar_Subject{
|
||||
Did: "did:plc:alice123",
|
||||
Repository: "myapp",
|
||||
}
|
||||
|
||||
@@ -940,14 +916,14 @@ func TestStarSubject_JSONSerialization(t *testing.T) {
|
||||
}
|
||||
|
||||
// Deserialize
|
||||
var decoded StarSubject
|
||||
var decoded SailorStar_Subject
|
||||
if err := json.Unmarshal(jsonData, &decoded); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify
|
||||
if decoded.DID != subject.DID {
|
||||
t.Errorf("DID = %v, want %v", decoded.DID, subject.DID)
|
||||
if decoded.Did != subject.Did {
|
||||
t.Errorf("Did = %v, want %v", decoded.Did, subject.Did)
|
||||
}
|
||||
if decoded.Repository != subject.Repository {
|
||||
t.Errorf("Repository = %v, want %v", decoded.Repository, subject.Repository)
|
||||
@@ -1194,8 +1170,8 @@ func TestNewLayerRecord(t *testing.T) {
|
||||
t.Fatal("NewLayerRecord() returned nil")
|
||||
}
|
||||
|
||||
if record.Type != LayerCollection {
|
||||
t.Errorf("Type = %q, want %q", record.Type, LayerCollection)
|
||||
if record.LexiconTypeID != LayerCollection {
|
||||
t.Errorf("LexiconTypeID = %q, want %q", record.LexiconTypeID, LayerCollection)
|
||||
}
|
||||
|
||||
if record.Digest != tt.digest {
|
||||
@@ -1214,8 +1190,8 @@ func TestNewLayerRecord(t *testing.T) {
|
||||
t.Errorf("Repository = %q, want %q", record.Repository, tt.repository)
|
||||
}
|
||||
|
||||
if record.UserDID != tt.userDID {
|
||||
t.Errorf("UserDID = %q, want %q", record.UserDID, tt.userDID)
|
||||
if record.UserDid != tt.userDID {
|
||||
t.Errorf("UserDid = %q, want %q", record.UserDid, tt.userDID)
|
||||
}
|
||||
|
||||
if record.UserHandle != tt.userHandle {
|
||||
@@ -1237,7 +1213,7 @@ func TestNewLayerRecord(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewLayerRecordJSON(t *testing.T) {
|
||||
// Test that LayerRecord can be marshaled/unmarshaled to/from JSON
|
||||
// Test that HoldLayer can be marshaled/unmarshaled to/from JSON
|
||||
record := NewLayerRecord(
|
||||
"sha256:abc123",
|
||||
1024,
|
||||
@@ -1254,14 +1230,14 @@ func TestNewLayerRecordJSON(t *testing.T) {
|
||||
}
|
||||
|
||||
// Unmarshal back
|
||||
var decoded LayerRecord
|
||||
var decoded HoldLayer
|
||||
if err := json.Unmarshal(jsonData, &decoded); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify fields match
|
||||
if decoded.Type != record.Type {
|
||||
t.Errorf("Type = %q, want %q", decoded.Type, record.Type)
|
||||
if decoded.LexiconTypeID != record.LexiconTypeID {
|
||||
t.Errorf("LexiconTypeID = %q, want %q", decoded.LexiconTypeID, record.LexiconTypeID)
|
||||
}
|
||||
if decoded.Digest != record.Digest {
|
||||
t.Errorf("Digest = %q, want %q", decoded.Digest, record.Digest)
|
||||
@@ -1275,8 +1251,8 @@ func TestNewLayerRecordJSON(t *testing.T) {
|
||||
if decoded.Repository != record.Repository {
|
||||
t.Errorf("Repository = %q, want %q", decoded.Repository, record.Repository)
|
||||
}
|
||||
if decoded.UserDID != record.UserDID {
|
||||
t.Errorf("UserDID = %q, want %q", decoded.UserDID, record.UserDID)
|
||||
if decoded.UserDid != record.UserDid {
|
||||
t.Errorf("UserDid = %q, want %q", decoded.UserDid, record.UserDid)
|
||||
}
|
||||
if decoded.UserHandle != record.UserHandle {
|
||||
t.Errorf("UserHandle = %q, want %q", decoded.UserHandle, record.UserHandle)
|
||||
|
||||
103
pkg/atproto/manifest.go
Normal file
103
pkg/atproto/manifest.go
Normal file
@@ -0,0 +1,103 @@
|
||||
// Code generated by generate.go; DO NOT EDIT.
|
||||
|
||||
// Lexicon schema: io.atcr.manifest
|
||||
|
||||
package atproto
|
||||
|
||||
import (
|
||||
lexutil "github.com/bluesky-social/indigo/lex/util"
|
||||
)
|
||||
|
||||
// A container image manifest following OCI specification, stored in ATProto
|
||||
type Manifest struct {
|
||||
LexiconTypeID string `json:"$type" cborgen:"$type,const=io.atcr.manifest"`
|
||||
// annotations: Optional metadata annotations
|
||||
Annotations *Manifest_Annotations `json:"annotations,omitempty" cborgen:"annotations,omitempty"`
|
||||
// config: Reference to image configuration blob
|
||||
Config *Manifest_BlobReference `json:"config,omitempty" cborgen:"config,omitempty"`
|
||||
// createdAt: Record creation timestamp
|
||||
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
|
||||
// digest: Content digest (e.g., 'sha256:abc123...')
|
||||
Digest string `json:"digest" cborgen:"digest"`
|
||||
// holdDid: DID of the hold service where blobs are stored (e.g., 'did:web:hold01.atcr.io'). Primary reference for hold resolution.
|
||||
HoldDid *string `json:"holdDid,omitempty" cborgen:"holdDid,omitempty"`
|
||||
// holdEndpoint: Hold service endpoint URL where blobs are stored. DEPRECATED: Use holdDid instead. Kept for backward compatibility.
|
||||
HoldEndpoint *string `json:"holdEndpoint,omitempty" cborgen:"holdEndpoint,omitempty"`
|
||||
// layers: Filesystem layers (for image manifests)
|
||||
Layers []Manifest_BlobReference `json:"layers,omitempty" cborgen:"layers,omitempty"`
|
||||
// manifestBlob: The full OCI manifest stored as a blob in ATProto.
|
||||
ManifestBlob *lexutil.LexBlob `json:"manifestBlob,omitempty" cborgen:"manifestBlob,omitempty"`
|
||||
// manifests: Referenced manifests (for manifest lists/indexes)
|
||||
Manifests []Manifest_ManifestReference `json:"manifests,omitempty" cborgen:"manifests,omitempty"`
|
||||
// mediaType: OCI media type
|
||||
MediaType string `json:"mediaType" cborgen:"mediaType"`
|
||||
// repository: Repository name (e.g., 'myapp'). Scoped to user's DID.
|
||||
Repository string `json:"repository" cborgen:"repository"`
|
||||
// schemaVersion: OCI schema version (typically 2)
|
||||
SchemaVersion int64 `json:"schemaVersion" cborgen:"schemaVersion"`
|
||||
// subject: Optional reference to another manifest (for attestations, signatures)
|
||||
Subject *Manifest_BlobReference `json:"subject,omitempty" cborgen:"subject,omitempty"`
|
||||
}
|
||||
|
||||
// Optional metadata annotations
|
||||
type Manifest_Annotations struct {
|
||||
}
|
||||
|
||||
// Manifest_BlobReference is a "blobReference" in the io.atcr.manifest schema.
|
||||
//
|
||||
// Reference to a blob stored in S3 or external storage
|
||||
type Manifest_BlobReference struct {
|
||||
LexiconTypeID string `json:"$type,omitempty" cborgen:"$type,const=io.atcr.manifest#blobReference,omitempty"`
|
||||
// annotations: Optional metadata
|
||||
Annotations *Manifest_BlobReference_Annotations `json:"annotations,omitempty" cborgen:"annotations,omitempty"`
|
||||
// digest: Content digest (e.g., 'sha256:...')
|
||||
Digest string `json:"digest" cborgen:"digest"`
|
||||
// mediaType: MIME type of the blob
|
||||
MediaType string `json:"mediaType" cborgen:"mediaType"`
|
||||
// size: Size in bytes
|
||||
Size int64 `json:"size" cborgen:"size"`
|
||||
// urls: Optional direct URLs to blob (for BYOS)
|
||||
Urls []string `json:"urls,omitempty" cborgen:"urls,omitempty"`
|
||||
}
|
||||
|
||||
// Optional metadata
|
||||
type Manifest_BlobReference_Annotations struct {
|
||||
}
|
||||
|
||||
// Manifest_ManifestReference is a "manifestReference" in the io.atcr.manifest schema.
|
||||
//
|
||||
// Reference to a manifest in a manifest list/index
|
||||
type Manifest_ManifestReference struct {
|
||||
LexiconTypeID string `json:"$type,omitempty" cborgen:"$type,const=io.atcr.manifest#manifestReference,omitempty"`
|
||||
// annotations: Optional metadata
|
||||
Annotations *Manifest_ManifestReference_Annotations `json:"annotations,omitempty" cborgen:"annotations,omitempty"`
|
||||
// digest: Content digest (e.g., 'sha256:...')
|
||||
Digest string `json:"digest" cborgen:"digest"`
|
||||
// mediaType: Media type of the referenced manifest
|
||||
MediaType string `json:"mediaType" cborgen:"mediaType"`
|
||||
// platform: Platform information for this manifest
|
||||
Platform *Manifest_Platform `json:"platform,omitempty" cborgen:"platform,omitempty"`
|
||||
// size: Size in bytes
|
||||
Size int64 `json:"size" cborgen:"size"`
|
||||
}
|
||||
|
||||
// Optional metadata
|
||||
type Manifest_ManifestReference_Annotations struct {
|
||||
}
|
||||
|
||||
// Manifest_Platform is a "platform" in the io.atcr.manifest schema.
|
||||
//
|
||||
// Platform information describing OS and architecture
|
||||
type Manifest_Platform struct {
|
||||
LexiconTypeID string `json:"$type,omitempty" cborgen:"$type,const=io.atcr.manifest#platform,omitempty"`
|
||||
// architecture: CPU architecture (e.g., 'amd64', 'arm64', 'arm')
|
||||
Architecture string `json:"architecture" cborgen:"architecture"`
|
||||
// os: Operating system (e.g., 'linux', 'windows', 'darwin')
|
||||
Os string `json:"os" cborgen:"os"`
|
||||
// osFeatures: Optional OS features
|
||||
OsFeatures []string `json:"osFeatures,omitempty" cborgen:"osFeatures,omitempty"`
|
||||
// osVersion: Optional OS version
|
||||
OsVersion *string `json:"osVersion,omitempty" cborgen:"osVersion,omitempty"`
|
||||
// variant: Optional CPU variant (e.g., 'v7' for ARM)
|
||||
Variant *string `json:"variant,omitempty" cborgen:"variant,omitempty"`
|
||||
}
|
||||
15
pkg/atproto/register.go
Normal file
15
pkg/atproto/register.go
Normal file
@@ -0,0 +1,15 @@
|
||||
// Code generated by generate.go; DO NOT EDIT.
|
||||
|
||||
package atproto
|
||||
|
||||
import lexutil "github.com/bluesky-social/indigo/lex/util"
|
||||
|
||||
func init() {
|
||||
lexutil.RegisterType("io.atcr.hold.captain", &HoldCaptain{})
|
||||
lexutil.RegisterType("io.atcr.hold.crew", &HoldCrew{})
|
||||
lexutil.RegisterType("io.atcr.hold.layer", &HoldLayer{})
|
||||
lexutil.RegisterType("io.atcr.manifest", &Manifest{})
|
||||
lexutil.RegisterType("io.atcr.sailor.profile", &SailorProfile{})
|
||||
lexutil.RegisterType("io.atcr.sailor.star", &SailorStar{})
|
||||
lexutil.RegisterType("io.atcr.tag", &Tag{})
|
||||
}
|
||||
16
pkg/atproto/sailorprofile.go
Normal file
16
pkg/atproto/sailorprofile.go
Normal file
@@ -0,0 +1,16 @@
|
||||
// Code generated by generate.go; DO NOT EDIT.
|
||||
|
||||
// Lexicon schema: io.atcr.sailor.profile
|
||||
|
||||
package atproto
|
||||
|
||||
// User profile for ATCR registry. Stores preferences like default hold for blob storage.
|
||||
type SailorProfile struct {
|
||||
LexiconTypeID string `json:"$type" cborgen:"$type,const=io.atcr.sailor.profile"`
|
||||
// createdAt: Profile creation timestamp
|
||||
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
|
||||
// defaultHold: Default hold endpoint for blob storage. If null, user has opted out of defaults.
|
||||
DefaultHold *string `json:"defaultHold,omitempty" cborgen:"defaultHold,omitempty"`
|
||||
// updatedAt: Profile last updated timestamp
|
||||
UpdatedAt *string `json:"updatedAt,omitempty" cborgen:"updatedAt,omitempty"`
|
||||
}
|
||||
25
pkg/atproto/sailorstar.go
Normal file
25
pkg/atproto/sailorstar.go
Normal file
@@ -0,0 +1,25 @@
|
||||
// Code generated by generate.go; DO NOT EDIT.
|
||||
|
||||
// Lexicon schema: io.atcr.sailor.star
|
||||
|
||||
package atproto
|
||||
|
||||
// A star (like) on a container image repository. Stored in the starrer's PDS, similar to Bluesky likes.
|
||||
type SailorStar struct {
|
||||
LexiconTypeID string `json:"$type" cborgen:"$type,const=io.atcr.sailor.star"`
|
||||
// createdAt: Star creation timestamp
|
||||
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
|
||||
// subject: The repository being starred
|
||||
Subject SailorStar_Subject `json:"subject" cborgen:"subject"`
|
||||
}
|
||||
|
||||
// SailorStar_Subject is a "subject" in the io.atcr.sailor.star schema.
|
||||
//
|
||||
// Reference to a repository owned by a user
|
||||
type SailorStar_Subject struct {
|
||||
LexiconTypeID string `json:"$type,omitempty" cborgen:"$type,const=io.atcr.sailor.star#subject,omitempty"`
|
||||
// did: DID of the repository owner
|
||||
Did string `json:"did" cborgen:"did"`
|
||||
// repository: Repository name (e.g., 'myapp')
|
||||
Repository string `json:"repository" cborgen:"repository"`
|
||||
}
|
||||
20
pkg/atproto/tag.go
Normal file
20
pkg/atproto/tag.go
Normal file
@@ -0,0 +1,20 @@
|
||||
// Code generated by generate.go; DO NOT EDIT.
|
||||
|
||||
// Lexicon schema: io.atcr.tag
|
||||
|
||||
package atproto
|
||||
|
||||
// A named tag pointing to a specific manifest digest
|
||||
type Tag struct {
|
||||
LexiconTypeID string `json:"$type" cborgen:"$type,const=io.atcr.tag"`
|
||||
// createdAt: Tag creation timestamp
|
||||
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
|
||||
// manifest: 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.
|
||||
Manifest *string `json:"manifest,omitempty" cborgen:"manifest,omitempty"`
|
||||
// manifestDigest: DEPRECATED: Digest of the manifest (e.g., 'sha256:...'). Kept for backward compatibility with old records. New records should use 'manifest' field instead.
|
||||
ManifestDigest *string `json:"manifestDigest,omitempty" cborgen:"manifestDigest,omitempty"`
|
||||
// repository: Repository name (e.g., 'myapp'). Scoped to user's DID.
|
||||
Repository string `json:"repository" cborgen:"repository"`
|
||||
// tag: Tag name (e.g., 'latest', 'v1.0.0', '12-slim')
|
||||
Tag string `json:"tag" cborgen:"tag"`
|
||||
}
|
||||
@@ -21,7 +21,7 @@ type HoldAuthorizer interface {
|
||||
|
||||
// GetCaptainRecord retrieves the captain record for a hold
|
||||
// Used to check public flag and allowAllCrew settings
|
||||
GetCaptainRecord(ctx context.Context, holdDID string) (*atproto.CaptainRecord, error)
|
||||
GetCaptainRecord(ctx context.Context, holdDID string) (*atproto.HoldCaptain, error)
|
||||
|
||||
// IsCrewMember checks if userDID is a crew member of holdDID
|
||||
IsCrewMember(ctx context.Context, holdDID, userDID string) (bool, error)
|
||||
@@ -32,7 +32,7 @@ type HoldAuthorizer interface {
|
||||
// Read access rules:
|
||||
// - Public hold: allow anyone (even anonymous)
|
||||
// - Private hold: require authentication (any authenticated user)
|
||||
func CheckReadAccessWithCaptain(captain *atproto.CaptainRecord, userDID string) bool {
|
||||
func CheckReadAccessWithCaptain(captain *atproto.HoldCaptain, userDID string) bool {
|
||||
if captain.Public {
|
||||
// Public hold - allow anyone (even anonymous)
|
||||
return true
|
||||
@@ -55,7 +55,7 @@ func CheckReadAccessWithCaptain(captain *atproto.CaptainRecord, userDID string)
|
||||
// Write access rules:
|
||||
// - Must be authenticated
|
||||
// - Must be hold owner OR crew member
|
||||
func CheckWriteAccessWithCaptain(captain *atproto.CaptainRecord, userDID string, isCrew bool) bool {
|
||||
func CheckWriteAccessWithCaptain(captain *atproto.HoldCaptain, userDID string, isCrew bool) bool {
|
||||
slog.Debug("Checking write access", "userDID", userDID, "owner", captain.Owner, "isCrew", isCrew)
|
||||
|
||||
if userDID == "" {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
func TestCheckReadAccessWithCaptain_PublicHold(t *testing.T) {
|
||||
captain := &atproto.CaptainRecord{
|
||||
captain := &atproto.HoldCaptain{
|
||||
Public: true,
|
||||
Owner: "did:plc:owner123",
|
||||
}
|
||||
@@ -26,7 +26,7 @@ func TestCheckReadAccessWithCaptain_PublicHold(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCheckReadAccessWithCaptain_PrivateHold(t *testing.T) {
|
||||
captain := &atproto.CaptainRecord{
|
||||
captain := &atproto.HoldCaptain{
|
||||
Public: false,
|
||||
Owner: "did:plc:owner123",
|
||||
}
|
||||
@@ -45,7 +45,7 @@ func TestCheckReadAccessWithCaptain_PrivateHold(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCheckWriteAccessWithCaptain_Owner(t *testing.T) {
|
||||
captain := &atproto.CaptainRecord{
|
||||
captain := &atproto.HoldCaptain{
|
||||
Public: false,
|
||||
Owner: "did:plc:owner123",
|
||||
}
|
||||
@@ -58,7 +58,7 @@ func TestCheckWriteAccessWithCaptain_Owner(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCheckWriteAccessWithCaptain_Crew(t *testing.T) {
|
||||
captain := &atproto.CaptainRecord{
|
||||
captain := &atproto.HoldCaptain{
|
||||
Public: false,
|
||||
Owner: "did:plc:owner123",
|
||||
}
|
||||
@@ -77,7 +77,7 @@ func TestCheckWriteAccessWithCaptain_Crew(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCheckWriteAccessWithCaptain_Anonymous(t *testing.T) {
|
||||
captain := &atproto.CaptainRecord{
|
||||
captain := &atproto.HoldCaptain{
|
||||
Public: false,
|
||||
Owner: "did:plc:owner123",
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ func NewLocalHoldAuthorizerFromInterface(holdPDS any) HoldAuthorizer {
|
||||
}
|
||||
|
||||
// GetCaptainRecord retrieves the captain record from the hold's PDS
|
||||
func (a *LocalHoldAuthorizer) GetCaptainRecord(ctx context.Context, holdDID string) (*atproto.CaptainRecord, error) {
|
||||
func (a *LocalHoldAuthorizer) GetCaptainRecord(ctx context.Context, holdDID string) (*atproto.HoldCaptain, error) {
|
||||
// Verify that the requested holdDID matches this hold
|
||||
if holdDID != a.pds.DID() {
|
||||
return nil, fmt.Errorf("holdDID mismatch: requested %s, this hold is %s", holdDID, a.pds.DID())
|
||||
@@ -47,7 +47,7 @@ func (a *LocalHoldAuthorizer) GetCaptainRecord(ctx context.Context, holdDID stri
|
||||
return nil, fmt.Errorf("failed to get captain record: %w", err)
|
||||
}
|
||||
|
||||
// The PDS returns *atproto.CaptainRecord directly now (after we update pds to use atproto types)
|
||||
// The PDS returns *atproto.HoldCaptain directly
|
||||
return pdsCaptain, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -101,14 +101,14 @@ func (a *RemoteHoldAuthorizer) cleanupRecentDenials() {
|
||||
// 1. Check database cache
|
||||
// 2. If cache miss or expired, query hold's XRPC endpoint
|
||||
// 3. Update cache
|
||||
func (a *RemoteHoldAuthorizer) GetCaptainRecord(ctx context.Context, holdDID string) (*atproto.CaptainRecord, error) {
|
||||
func (a *RemoteHoldAuthorizer) GetCaptainRecord(ctx context.Context, holdDID string) (*atproto.HoldCaptain, error) {
|
||||
// Try cache first
|
||||
if a.db != nil {
|
||||
cached, err := a.getCachedCaptainRecord(holdDID)
|
||||
if err == nil && cached != nil {
|
||||
// Cache hit - check if still valid
|
||||
if time.Since(cached.UpdatedAt) < a.cacheTTL {
|
||||
return cached.CaptainRecord, nil
|
||||
return cached.HoldCaptain, nil
|
||||
}
|
||||
// Cache expired - continue to fetch fresh data
|
||||
}
|
||||
@@ -133,7 +133,7 @@ func (a *RemoteHoldAuthorizer) GetCaptainRecord(ctx context.Context, holdDID str
|
||||
|
||||
// captainRecordWithMeta includes UpdatedAt for cache management
|
||||
type captainRecordWithMeta struct {
|
||||
*atproto.CaptainRecord
|
||||
*atproto.HoldCaptain
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ func (a *RemoteHoldAuthorizer) getCachedCaptainRecord(holdDID string) (*captainR
|
||||
WHERE hold_did = ?
|
||||
`
|
||||
|
||||
var record atproto.CaptainRecord
|
||||
var record atproto.HoldCaptain
|
||||
var deployedAt, region, provider sql.NullString
|
||||
var updatedAt time.Time
|
||||
|
||||
@@ -172,20 +172,20 @@ func (a *RemoteHoldAuthorizer) getCachedCaptainRecord(holdDID string) (*captainR
|
||||
record.DeployedAt = deployedAt.String
|
||||
}
|
||||
if region.Valid {
|
||||
record.Region = region.String
|
||||
record.Region = ®ion.String
|
||||
}
|
||||
if provider.Valid {
|
||||
record.Provider = provider.String
|
||||
record.Provider = &provider.String
|
||||
}
|
||||
|
||||
return &captainRecordWithMeta{
|
||||
CaptainRecord: &record,
|
||||
UpdatedAt: updatedAt,
|
||||
HoldCaptain: &record,
|
||||
UpdatedAt: updatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// setCachedCaptainRecord stores a captain record in database cache
|
||||
func (a *RemoteHoldAuthorizer) setCachedCaptainRecord(holdDID string, record *atproto.CaptainRecord) error {
|
||||
func (a *RemoteHoldAuthorizer) setCachedCaptainRecord(holdDID string, record *atproto.HoldCaptain) error {
|
||||
query := `
|
||||
INSERT INTO hold_captain_records (
|
||||
hold_did, owner_did, public, allow_all_crew,
|
||||
@@ -207,8 +207,8 @@ func (a *RemoteHoldAuthorizer) setCachedCaptainRecord(holdDID string, record *at
|
||||
record.Public,
|
||||
record.AllowAllCrew,
|
||||
nullString(record.DeployedAt),
|
||||
nullString(record.Region),
|
||||
nullString(record.Provider),
|
||||
nullStringPtr(record.Region),
|
||||
nullStringPtr(record.Provider),
|
||||
time.Now(),
|
||||
)
|
||||
|
||||
@@ -216,7 +216,7 @@ func (a *RemoteHoldAuthorizer) setCachedCaptainRecord(holdDID string, record *at
|
||||
}
|
||||
|
||||
// fetchCaptainRecordFromXRPC queries the hold's XRPC endpoint for captain record
|
||||
func (a *RemoteHoldAuthorizer) fetchCaptainRecordFromXRPC(ctx context.Context, holdDID string) (*atproto.CaptainRecord, error) {
|
||||
func (a *RemoteHoldAuthorizer) fetchCaptainRecordFromXRPC(ctx context.Context, holdDID string) (*atproto.HoldCaptain, error) {
|
||||
// Resolve DID to URL
|
||||
holdURL := atproto.ResolveHoldURL(holdDID)
|
||||
|
||||
@@ -261,14 +261,20 @@ func (a *RemoteHoldAuthorizer) fetchCaptainRecordFromXRPC(ctx context.Context, h
|
||||
}
|
||||
|
||||
// Convert to our type
|
||||
record := &atproto.CaptainRecord{
|
||||
Type: atproto.CaptainCollection,
|
||||
Owner: xrpcResp.Value.Owner,
|
||||
Public: xrpcResp.Value.Public,
|
||||
AllowAllCrew: xrpcResp.Value.AllowAllCrew,
|
||||
DeployedAt: xrpcResp.Value.DeployedAt,
|
||||
Region: xrpcResp.Value.Region,
|
||||
Provider: xrpcResp.Value.Provider,
|
||||
record := &atproto.HoldCaptain{
|
||||
LexiconTypeID: atproto.CaptainCollection,
|
||||
Owner: xrpcResp.Value.Owner,
|
||||
Public: xrpcResp.Value.Public,
|
||||
AllowAllCrew: xrpcResp.Value.AllowAllCrew,
|
||||
DeployedAt: xrpcResp.Value.DeployedAt,
|
||||
}
|
||||
|
||||
// Handle optional pointer fields
|
||||
if xrpcResp.Value.Region != "" {
|
||||
record.Region = &xrpcResp.Value.Region
|
||||
}
|
||||
if xrpcResp.Value.Provider != "" {
|
||||
record.Provider = &xrpcResp.Value.Provider
|
||||
}
|
||||
|
||||
return record, nil
|
||||
@@ -408,6 +414,14 @@ func nullString(s string) sql.NullString {
|
||||
return sql.NullString{String: s, Valid: true}
|
||||
}
|
||||
|
||||
// nullStringPtr converts a *string to sql.NullString
|
||||
func nullStringPtr(s *string) sql.NullString {
|
||||
if s == nil || *s == "" {
|
||||
return sql.NullString{Valid: false}
|
||||
}
|
||||
return sql.NullString{String: *s, Valid: true}
|
||||
}
|
||||
|
||||
// getCachedApproval checks if user has a cached crew approval
|
||||
func (a *RemoteHoldAuthorizer) getCachedApproval(holdDID, userDID string) (bool, error) {
|
||||
query := `
|
||||
|
||||
@@ -14,6 +14,11 @@ import (
|
||||
"atcr.io/pkg/atproto"
|
||||
)
|
||||
|
||||
// ptrString returns a pointer to the given string
|
||||
func ptrString(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
func TestNewRemoteHoldAuthorizer(t *testing.T) {
|
||||
// Test with nil database (should still work)
|
||||
authorizer := NewRemoteHoldAuthorizer(nil, false)
|
||||
@@ -133,14 +138,14 @@ func TestGetCaptainRecord_CacheHit(t *testing.T) {
|
||||
holdDID := "did:web:hold01.atcr.io"
|
||||
|
||||
// Pre-populate cache with a captain record
|
||||
captainRecord := &atproto.CaptainRecord{
|
||||
Type: atproto.CaptainCollection,
|
||||
Owner: "did:plc:owner123",
|
||||
Public: true,
|
||||
AllowAllCrew: false,
|
||||
DeployedAt: "2025-10-28T00:00:00Z",
|
||||
Region: "us-east-1",
|
||||
Provider: "fly.io",
|
||||
captainRecord := &atproto.HoldCaptain{
|
||||
LexiconTypeID: atproto.CaptainCollection,
|
||||
Owner: "did:plc:owner123",
|
||||
Public: true,
|
||||
AllowAllCrew: false,
|
||||
DeployedAt: "2025-10-28T00:00:00Z",
|
||||
Region: ptrString("us-east-1"),
|
||||
Provider: ptrString("fly.io"),
|
||||
}
|
||||
|
||||
err := remote.setCachedCaptainRecord(holdDID, captainRecord)
|
||||
|
||||
@@ -18,8 +18,8 @@ const (
|
||||
// CreateCaptainRecord creates the captain record for the hold (first-time only).
|
||||
// This will FAIL if the captain record already exists. Use UpdateCaptainRecord to modify.
|
||||
func (p *HoldPDS) CreateCaptainRecord(ctx context.Context, ownerDID string, public bool, allowAllCrew bool, enableBlueskyPosts bool) (cid.Cid, error) {
|
||||
captainRecord := &atproto.CaptainRecord{
|
||||
Type: atproto.CaptainCollection,
|
||||
captainRecord := &atproto.HoldCaptain{
|
||||
LexiconTypeID: atproto.CaptainCollection,
|
||||
Owner: ownerDID,
|
||||
Public: public,
|
||||
AllowAllCrew: allowAllCrew,
|
||||
@@ -40,7 +40,7 @@ func (p *HoldPDS) CreateCaptainRecord(ctx context.Context, ownerDID string, publ
|
||||
}
|
||||
|
||||
// GetCaptainRecord retrieves the captain record
|
||||
func (p *HoldPDS) GetCaptainRecord(ctx context.Context) (cid.Cid, *atproto.CaptainRecord, error) {
|
||||
func (p *HoldPDS) GetCaptainRecord(ctx context.Context) (cid.Cid, *atproto.HoldCaptain, error) {
|
||||
// Use repomgr.GetRecord - our types are registered in init()
|
||||
// so it will automatically unmarshal to the concrete type
|
||||
recordCID, val, err := p.repomgr.GetRecord(ctx, p.uid, atproto.CaptainCollection, CaptainRkey, cid.Undef)
|
||||
@@ -49,7 +49,7 @@ func (p *HoldPDS) GetCaptainRecord(ctx context.Context) (cid.Cid, *atproto.Capta
|
||||
}
|
||||
|
||||
// Type assert to our concrete type
|
||||
captainRecord, ok := val.(*atproto.CaptainRecord)
|
||||
captainRecord, ok := val.(*atproto.HoldCaptain)
|
||||
if !ok {
|
||||
return cid.Undef, nil, fmt.Errorf("unexpected type for captain record: %T", val)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,11 @@ import (
|
||||
"atcr.io/pkg/atproto"
|
||||
)
|
||||
|
||||
// ptrString returns a pointer to the given string
|
||||
func ptrString(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
// setupTestPDS creates a test PDS instance in a temporary directory
|
||||
// It initializes the repo but does NOT create captain/crew records
|
||||
// Tests should call Bootstrap or create records as needed
|
||||
@@ -146,8 +151,8 @@ func TestCreateCaptainRecord(t *testing.T) {
|
||||
if captain.EnableBlueskyPosts != tt.enableBlueskyPosts {
|
||||
t.Errorf("Expected enableBlueskyPosts=%v, got %v", tt.enableBlueskyPosts, captain.EnableBlueskyPosts)
|
||||
}
|
||||
if captain.Type != atproto.CaptainCollection {
|
||||
t.Errorf("Expected type %s, got %s", atproto.CaptainCollection, captain.Type)
|
||||
if captain.LexiconTypeID != atproto.CaptainCollection {
|
||||
t.Errorf("Expected type %s, got %s", atproto.CaptainCollection, captain.LexiconTypeID)
|
||||
}
|
||||
if captain.DeployedAt == "" {
|
||||
t.Error("Expected deployedAt to be set")
|
||||
@@ -322,40 +327,40 @@ func TestUpdateCaptainRecord_NotFound(t *testing.T) {
|
||||
func TestCaptainRecord_CBORRoundtrip(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
record *atproto.CaptainRecord
|
||||
record *atproto.HoldCaptain
|
||||
}{
|
||||
{
|
||||
name: "Basic captain",
|
||||
record: &atproto.CaptainRecord{
|
||||
Type: atproto.CaptainCollection,
|
||||
Owner: "did:plc:alice123",
|
||||
Public: true,
|
||||
AllowAllCrew: false,
|
||||
DeployedAt: "2025-10-16T12:00:00Z",
|
||||
record: &atproto.HoldCaptain{
|
||||
LexiconTypeID: atproto.CaptainCollection,
|
||||
Owner: "did:plc:alice123",
|
||||
Public: true,
|
||||
AllowAllCrew: false,
|
||||
DeployedAt: "2025-10-16T12:00:00Z",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Captain with optional fields",
|
||||
record: &atproto.CaptainRecord{
|
||||
Type: atproto.CaptainCollection,
|
||||
Owner: "did:plc:bob456",
|
||||
Public: false,
|
||||
AllowAllCrew: true,
|
||||
DeployedAt: "2025-10-16T12:00:00Z",
|
||||
Region: "us-west-2",
|
||||
Provider: "fly.io",
|
||||
record: &atproto.HoldCaptain{
|
||||
LexiconTypeID: atproto.CaptainCollection,
|
||||
Owner: "did:plc:bob456",
|
||||
Public: false,
|
||||
AllowAllCrew: true,
|
||||
DeployedAt: "2025-10-16T12:00:00Z",
|
||||
Region: ptrString("us-west-2"),
|
||||
Provider: ptrString("fly.io"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Captain with empty optional fields",
|
||||
record: &atproto.CaptainRecord{
|
||||
Type: atproto.CaptainCollection,
|
||||
Owner: "did:plc:charlie789",
|
||||
Public: true,
|
||||
AllowAllCrew: true,
|
||||
DeployedAt: "2025-10-16T12:00:00Z",
|
||||
Region: "",
|
||||
Provider: "",
|
||||
record: &atproto.HoldCaptain{
|
||||
LexiconTypeID: atproto.CaptainCollection,
|
||||
Owner: "did:plc:charlie789",
|
||||
Public: true,
|
||||
AllowAllCrew: true,
|
||||
DeployedAt: "2025-10-16T12:00:00Z",
|
||||
Region: ptrString(""),
|
||||
Provider: ptrString(""),
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -375,15 +380,15 @@ func TestCaptainRecord_CBORRoundtrip(t *testing.T) {
|
||||
}
|
||||
|
||||
// Unmarshal from CBOR
|
||||
var decoded atproto.CaptainRecord
|
||||
var decoded atproto.HoldCaptain
|
||||
err = decoded.UnmarshalCBOR(bytes.NewReader(cborBytes))
|
||||
if err != nil {
|
||||
t.Fatalf("UnmarshalCBOR failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify all fields match
|
||||
if decoded.Type != tt.record.Type {
|
||||
t.Errorf("Type mismatch: expected %s, got %s", tt.record.Type, decoded.Type)
|
||||
if decoded.LexiconTypeID != tt.record.LexiconTypeID {
|
||||
t.Errorf("LexiconTypeID mismatch: expected %s, got %s", tt.record.LexiconTypeID, decoded.LexiconTypeID)
|
||||
}
|
||||
if decoded.Owner != tt.record.Owner {
|
||||
t.Errorf("Owner mismatch: expected %s, got %s", tt.record.Owner, decoded.Owner)
|
||||
@@ -397,11 +402,17 @@ func TestCaptainRecord_CBORRoundtrip(t *testing.T) {
|
||||
if decoded.DeployedAt != tt.record.DeployedAt {
|
||||
t.Errorf("DeployedAt mismatch: expected %s, got %s", tt.record.DeployedAt, decoded.DeployedAt)
|
||||
}
|
||||
if decoded.Region != tt.record.Region {
|
||||
t.Errorf("Region mismatch: expected %s, got %s", tt.record.Region, decoded.Region)
|
||||
// Compare Region pointers (may be nil)
|
||||
if (decoded.Region == nil) != (tt.record.Region == nil) {
|
||||
t.Errorf("Region nil mismatch: expected %v, got %v", tt.record.Region, decoded.Region)
|
||||
} else if decoded.Region != nil && *decoded.Region != *tt.record.Region {
|
||||
t.Errorf("Region mismatch: expected %q, got %q", *tt.record.Region, *decoded.Region)
|
||||
}
|
||||
if decoded.Provider != tt.record.Provider {
|
||||
t.Errorf("Provider mismatch: expected %s, got %s", tt.record.Provider, decoded.Provider)
|
||||
// Compare Provider pointers (may be nil)
|
||||
if (decoded.Provider == nil) != (tt.record.Provider == nil) {
|
||||
t.Errorf("Provider nil mismatch: expected %v, got %v", tt.record.Provider, decoded.Provider)
|
||||
} else if decoded.Provider != nil && *decoded.Provider != *tt.record.Provider {
|
||||
t.Errorf("Provider mismatch: expected %q, got %q", *tt.record.Provider, *decoded.Provider)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,12 +15,12 @@ import (
|
||||
|
||||
// AddCrewMember adds a new crew member to the hold and commits to carstore
|
||||
func (p *HoldPDS) AddCrewMember(ctx context.Context, memberDID, role string, permissions []string) (cid.Cid, error) {
|
||||
crewRecord := &atproto.CrewRecord{
|
||||
Type: atproto.CrewCollection,
|
||||
Member: memberDID,
|
||||
Role: role,
|
||||
Permissions: permissions,
|
||||
AddedAt: time.Now().Format(time.RFC3339),
|
||||
crewRecord := &atproto.HoldCrew{
|
||||
LexiconTypeID: atproto.CrewCollection,
|
||||
Member: memberDID,
|
||||
Role: role,
|
||||
Permissions: permissions,
|
||||
AddedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
// Use repomgr for crew operations - auto-generated rkey is fine
|
||||
@@ -33,7 +33,7 @@ func (p *HoldPDS) AddCrewMember(ctx context.Context, memberDID, role string, per
|
||||
}
|
||||
|
||||
// GetCrewMember retrieves a crew member by their record key
|
||||
func (p *HoldPDS) GetCrewMember(ctx context.Context, rkey string) (cid.Cid, *atproto.CrewRecord, error) {
|
||||
func (p *HoldPDS) GetCrewMember(ctx context.Context, rkey string) (cid.Cid, *atproto.HoldCrew, error) {
|
||||
// Use repomgr.GetRecord - our types are registered in init()
|
||||
recordCID, val, err := p.repomgr.GetRecord(ctx, p.uid, atproto.CrewCollection, rkey, cid.Undef)
|
||||
if err != nil {
|
||||
@@ -41,7 +41,7 @@ func (p *HoldPDS) GetCrewMember(ctx context.Context, rkey string) (cid.Cid, *atp
|
||||
}
|
||||
|
||||
// Type assert to our concrete type
|
||||
crewRecord, ok := val.(*atproto.CrewRecord)
|
||||
crewRecord, ok := val.(*atproto.HoldCrew)
|
||||
if !ok {
|
||||
return cid.Undef, nil, fmt.Errorf("unexpected type for crew record: %T", val)
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func (p *HoldPDS) GetCrewMember(ctx context.Context, rkey string) (cid.Cid, *atp
|
||||
type CrewMemberWithKey struct {
|
||||
Rkey string
|
||||
Cid cid.Cid
|
||||
Record *atproto.CrewRecord
|
||||
Record *atproto.HoldCrew
|
||||
}
|
||||
|
||||
// ListCrewMembers returns all crew members with their rkeys
|
||||
@@ -108,7 +108,7 @@ func (p *HoldPDS) ListCrewMembers(ctx context.Context) ([]*CrewMemberWithKey, er
|
||||
}
|
||||
|
||||
// Unmarshal the CBOR bytes into our concrete type
|
||||
var crewRecord atproto.CrewRecord
|
||||
var crewRecord atproto.HoldCrew
|
||||
if err := crewRecord.UnmarshalCBOR(bytes.NewReader(*recBytes)); err != nil {
|
||||
return fmt.Errorf("failed to decode crew record: %w", err)
|
||||
}
|
||||
|
||||
@@ -53,8 +53,8 @@ func TestAddCrewMember(t *testing.T) {
|
||||
t.Errorf("Expected permission[%d]=%s, got %s", i, perm, crew.Record.Permissions[i])
|
||||
}
|
||||
}
|
||||
if crew.Record.Type != atproto.CrewCollection {
|
||||
t.Errorf("Expected type %s, got %s", atproto.CrewCollection, crew.Record.Type)
|
||||
if crew.Record.LexiconTypeID != atproto.CrewCollection {
|
||||
t.Errorf("Expected type %s, got %s", atproto.CrewCollection, crew.Record.LexiconTypeID)
|
||||
}
|
||||
if crew.Record.AddedAt == "" {
|
||||
t.Error("Expected addedAt to be set")
|
||||
@@ -348,46 +348,46 @@ func TestRemoveCrewMember_Multiple(t *testing.T) {
|
||||
func TestCrewRecord_CBORRoundtrip(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
record *atproto.CrewRecord
|
||||
record *atproto.HoldCrew
|
||||
}{
|
||||
{
|
||||
name: "Basic crew member",
|
||||
record: &atproto.CrewRecord{
|
||||
Type: atproto.CrewCollection,
|
||||
Member: "did:plc:alice123",
|
||||
Role: "writer",
|
||||
Permissions: []string{"blob:read", "blob:write"},
|
||||
AddedAt: "2025-10-16T12:00:00Z",
|
||||
record: &atproto.HoldCrew{
|
||||
LexiconTypeID: atproto.CrewCollection,
|
||||
Member: "did:plc:alice123",
|
||||
Role: "writer",
|
||||
Permissions: []string{"blob:read", "blob:write"},
|
||||
AddedAt: "2025-10-16T12:00:00Z",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Admin crew member",
|
||||
record: &atproto.CrewRecord{
|
||||
Type: atproto.CrewCollection,
|
||||
Member: "did:plc:bob456",
|
||||
Role: "admin",
|
||||
Permissions: []string{"blob:read", "blob:write", "crew:admin"},
|
||||
AddedAt: "2025-10-16T13:00:00Z",
|
||||
record: &atproto.HoldCrew{
|
||||
LexiconTypeID: atproto.CrewCollection,
|
||||
Member: "did:plc:bob456",
|
||||
Role: "admin",
|
||||
Permissions: []string{"blob:read", "blob:write", "crew:admin"},
|
||||
AddedAt: "2025-10-16T13:00:00Z",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Reader crew member",
|
||||
record: &atproto.CrewRecord{
|
||||
Type: atproto.CrewCollection,
|
||||
Member: "did:plc:charlie789",
|
||||
Role: "reader",
|
||||
Permissions: []string{"blob:read"},
|
||||
AddedAt: "2025-10-16T14:00:00Z",
|
||||
record: &atproto.HoldCrew{
|
||||
LexiconTypeID: atproto.CrewCollection,
|
||||
Member: "did:plc:charlie789",
|
||||
Role: "reader",
|
||||
Permissions: []string{"blob:read"},
|
||||
AddedAt: "2025-10-16T14:00:00Z",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Crew member with empty permissions",
|
||||
record: &atproto.CrewRecord{
|
||||
Type: atproto.CrewCollection,
|
||||
Member: "did:plc:dave012",
|
||||
Role: "none",
|
||||
Permissions: []string{},
|
||||
AddedAt: "2025-10-16T15:00:00Z",
|
||||
record: &atproto.HoldCrew{
|
||||
LexiconTypeID: atproto.CrewCollection,
|
||||
Member: "did:plc:dave012",
|
||||
Role: "none",
|
||||
Permissions: []string{},
|
||||
AddedAt: "2025-10-16T15:00:00Z",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -407,15 +407,15 @@ func TestCrewRecord_CBORRoundtrip(t *testing.T) {
|
||||
}
|
||||
|
||||
// Unmarshal from CBOR
|
||||
var decoded atproto.CrewRecord
|
||||
var decoded atproto.HoldCrew
|
||||
err = decoded.UnmarshalCBOR(bytes.NewReader(cborBytes))
|
||||
if err != nil {
|
||||
t.Fatalf("UnmarshalCBOR failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify all fields match
|
||||
if decoded.Type != tt.record.Type {
|
||||
t.Errorf("Type mismatch: expected %s, got %s", tt.record.Type, decoded.Type)
|
||||
if decoded.LexiconTypeID != tt.record.LexiconTypeID {
|
||||
t.Errorf("LexiconTypeID mismatch: expected %s, got %s", tt.record.LexiconTypeID, decoded.LexiconTypeID)
|
||||
}
|
||||
if decoded.Member != tt.record.Member {
|
||||
t.Errorf("Member mismatch: expected %s, got %s", tt.record.Member, decoded.Member)
|
||||
|
||||
@@ -9,10 +9,10 @@ import (
|
||||
|
||||
// CreateLayerRecord creates a new layer record in the hold's PDS
|
||||
// Returns the rkey and CID of the created record
|
||||
func (p *HoldPDS) CreateLayerRecord(ctx context.Context, record *atproto.LayerRecord) (string, string, error) {
|
||||
func (p *HoldPDS) CreateLayerRecord(ctx context.Context, record *atproto.HoldLayer) (string, string, error) {
|
||||
// Validate record
|
||||
if record.Type != atproto.LayerCollection {
|
||||
return "", "", fmt.Errorf("invalid record type: %s", record.Type)
|
||||
if record.LexiconTypeID != atproto.LayerCollection {
|
||||
return "", "", fmt.Errorf("invalid record type: %s", record.LexiconTypeID)
|
||||
}
|
||||
|
||||
if record.Digest == "" {
|
||||
@@ -40,7 +40,7 @@ func (p *HoldPDS) CreateLayerRecord(ctx context.Context, record *atproto.LayerRe
|
||||
|
||||
// GetLayerRecord retrieves a specific layer record by rkey
|
||||
// Note: This is a simplified implementation. For production, you may need to pass the CID
|
||||
func (p *HoldPDS) GetLayerRecord(ctx context.Context, rkey string) (*atproto.LayerRecord, error) {
|
||||
func (p *HoldPDS) GetLayerRecord(ctx context.Context, rkey string) (*atproto.HoldLayer, error) {
|
||||
// For now, we don't implement this as it's not needed for the manifest post feature
|
||||
// Full implementation would require querying the carstore with a specific CID
|
||||
return nil, fmt.Errorf("GetLayerRecord not yet implemented - use via XRPC listRecords instead")
|
||||
@@ -50,7 +50,7 @@ func (p *HoldPDS) GetLayerRecord(ctx context.Context, rkey string) (*atproto.Lay
|
||||
// Returns records, next cursor (empty if no more), and error
|
||||
// Note: This is a simplified implementation. For production, consider adding filters
|
||||
// (by repository, user, digest, etc.) and proper pagination
|
||||
func (p *HoldPDS) ListLayerRecords(ctx context.Context, limit int, cursor string) ([]*atproto.LayerRecord, string, error) {
|
||||
func (p *HoldPDS) ListLayerRecords(ctx context.Context, limit int, cursor string) ([]*atproto.HoldLayer, string, error) {
|
||||
// For now, return empty list - full implementation would query the carstore
|
||||
// This would require iterating over records in the collection and filtering
|
||||
// In practice, layer records are mainly for analytics and Bluesky posts,
|
||||
|
||||
@@ -12,7 +12,7 @@ func TestCreateLayerRecord(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
record *atproto.LayerRecord
|
||||
record *atproto.HoldLayer
|
||||
wantErr bool
|
||||
errSubstr string
|
||||
}{
|
||||
@@ -42,13 +42,13 @@ func TestCreateLayerRecord(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "invalid record type",
|
||||
record: &atproto.LayerRecord{
|
||||
Type: "wrong.type",
|
||||
record: &atproto.HoldLayer{
|
||||
LexiconTypeID: "wrong.type",
|
||||
Digest: "sha256:abc123",
|
||||
Size: 1024,
|
||||
MediaType: "application/vnd.oci.image.layer.v1.tar",
|
||||
Repository: "test",
|
||||
UserDID: "did:plc:test",
|
||||
UserDid: "did:plc:test",
|
||||
UserHandle: "test.example.com",
|
||||
},
|
||||
wantErr: true,
|
||||
@@ -56,13 +56,13 @@ func TestCreateLayerRecord(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "missing digest",
|
||||
record: &atproto.LayerRecord{
|
||||
Type: atproto.LayerCollection,
|
||||
record: &atproto.HoldLayer{
|
||||
LexiconTypeID: atproto.LayerCollection,
|
||||
Digest: "",
|
||||
Size: 1024,
|
||||
MediaType: "application/vnd.oci.image.layer.v1.tar",
|
||||
Repository: "test",
|
||||
UserDID: "did:plc:test",
|
||||
UserDid: "did:plc:test",
|
||||
UserHandle: "test.example.com",
|
||||
},
|
||||
wantErr: true,
|
||||
@@ -70,13 +70,13 @@ func TestCreateLayerRecord(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "zero size",
|
||||
record: &atproto.LayerRecord{
|
||||
Type: atproto.LayerCollection,
|
||||
record: &atproto.HoldLayer{
|
||||
LexiconTypeID: atproto.LayerCollection,
|
||||
Digest: "sha256:abc123",
|
||||
Size: 0,
|
||||
MediaType: "application/vnd.oci.image.layer.v1.tar",
|
||||
Repository: "test",
|
||||
UserDID: "did:plc:test",
|
||||
UserDid: "did:plc:test",
|
||||
UserHandle: "test.example.com",
|
||||
},
|
||||
wantErr: true,
|
||||
@@ -84,13 +84,13 @@ func TestCreateLayerRecord(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "negative size",
|
||||
record: &atproto.LayerRecord{
|
||||
Type: atproto.LayerCollection,
|
||||
record: &atproto.HoldLayer{
|
||||
LexiconTypeID: atproto.LayerCollection,
|
||||
Digest: "sha256:abc123",
|
||||
Size: -1,
|
||||
MediaType: "application/vnd.oci.image.layer.v1.tar",
|
||||
Repository: "test",
|
||||
UserDID: "did:plc:test",
|
||||
UserDid: "did:plc:test",
|
||||
UserHandle: "test.example.com",
|
||||
},
|
||||
wantErr: true,
|
||||
@@ -191,8 +191,8 @@ func TestNewLayerRecord(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify all fields are set correctly
|
||||
if record.Type != atproto.LayerCollection {
|
||||
t.Errorf("Type = %q, want %q", record.Type, atproto.LayerCollection)
|
||||
if record.LexiconTypeID != atproto.LayerCollection {
|
||||
t.Errorf("LexiconTypeID = %q, want %q", record.LexiconTypeID, atproto.LayerCollection)
|
||||
}
|
||||
|
||||
if record.Digest != digest {
|
||||
@@ -211,8 +211,8 @@ func TestNewLayerRecord(t *testing.T) {
|
||||
t.Errorf("Repository = %q, want %q", record.Repository, repository)
|
||||
}
|
||||
|
||||
if record.UserDID != userDID {
|
||||
t.Errorf("UserDID = %q, want %q", record.UserDID, userDID)
|
||||
if record.UserDid != userDID {
|
||||
t.Errorf("UserDid = %q, want %q", record.UserDid, userDID)
|
||||
}
|
||||
|
||||
if record.UserHandle != userHandle {
|
||||
@@ -282,8 +282,8 @@ func TestLayerRecord_FieldValidation(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify the record can be created
|
||||
if record.Type != atproto.LayerCollection {
|
||||
t.Errorf("Type = %q, want %q", record.Type, atproto.LayerCollection)
|
||||
if record.LexiconTypeID != atproto.LayerCollection {
|
||||
t.Errorf("Type = %q, want %q", record.LexiconTypeID, atproto.LayerCollection)
|
||||
}
|
||||
|
||||
if record.Digest != tt.digest {
|
||||
|
||||
@@ -19,14 +19,10 @@ import (
|
||||
"github.com/ipfs/go-cid"
|
||||
)
|
||||
|
||||
// init registers our custom ATProto types with indigo's lexutil type registry
|
||||
// This allows repomgr.GetRecord to automatically unmarshal our types
|
||||
// init registers the TangledProfileRecord type with indigo's lexutil type registry.
|
||||
// Note: HoldCaptain, HoldCrew, and HoldLayer are registered in pkg/atproto/register.go (generated).
|
||||
// TangledProfileRecord is external (sh.tangled.actor.profile) so we register it here.
|
||||
func init() {
|
||||
// Register captain, crew, tangled profile, and layer record types
|
||||
// These must match the $type field in the records
|
||||
lexutil.RegisterType(atproto.CaptainCollection, &atproto.CaptainRecord{})
|
||||
lexutil.RegisterType(atproto.CrewCollection, &atproto.CrewRecord{})
|
||||
lexutil.RegisterType(atproto.LayerCollection, &atproto.LayerRecord{})
|
||||
lexutil.RegisterType(atproto.TangledProfileCollection, &atproto.TangledProfileRecord{})
|
||||
}
|
||||
|
||||
|
||||
@@ -150,8 +150,8 @@ func TestBootstrap_NewRepo(t *testing.T) {
|
||||
if captain.AllowAllCrew != allowAllCrew {
|
||||
t.Errorf("Expected allowAllCrew=%v, got %v", allowAllCrew, captain.AllowAllCrew)
|
||||
}
|
||||
if captain.Type != atproto.CaptainCollection {
|
||||
t.Errorf("Expected type %s, got %s", atproto.CaptainCollection, captain.Type)
|
||||
if captain.LexiconTypeID != atproto.CaptainCollection {
|
||||
t.Errorf("Expected type %s, got %s", atproto.CaptainCollection, captain.LexiconTypeID)
|
||||
}
|
||||
if captain.DeployedAt == "" {
|
||||
t.Error("Expected deployedAt to be set")
|
||||
@@ -317,8 +317,8 @@ func TestLexiconTypeRegistration(t *testing.T) {
|
||||
if captain == nil {
|
||||
t.Fatal("Expected non-nil captain record")
|
||||
}
|
||||
if captain.Type != atproto.CaptainCollection {
|
||||
t.Errorf("Expected captain type %s, got %s", atproto.CaptainCollection, captain.Type)
|
||||
if captain.LexiconTypeID != atproto.CaptainCollection {
|
||||
t.Errorf("Expected captain type %s, got %s", atproto.CaptainCollection, captain.LexiconTypeID)
|
||||
}
|
||||
|
||||
// Do the same for crew record
|
||||
@@ -331,8 +331,8 @@ func TestLexiconTypeRegistration(t *testing.T) {
|
||||
}
|
||||
|
||||
crew := crewMembers[0].Record
|
||||
if crew.Type != atproto.CrewCollection {
|
||||
t.Errorf("Expected crew type %s, got %s", atproto.CrewCollection, crew.Type)
|
||||
if crew.LexiconTypeID != atproto.CrewCollection {
|
||||
t.Errorf("Expected crew type %s, got %s", atproto.CrewCollection, crew.LexiconTypeID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user