3 Commits

Author SHA1 Message Date
Evan Jarrett
a546a9beca lexgen work 2025-12-20 10:49:02 -06:00
Evan Jarrett
59e507fe61 lexgen 2025-12-20 10:47:23 -06:00
Evan Jarrett
2d2fb7906e clean up some lexicon usage 2025-12-20 10:47:23 -06:00
42 changed files with 4669 additions and 1062 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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,
}

View File

@@ -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
}

View File

@@ -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()

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 {

View File

@@ -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
}
}

View 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
View 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
View 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"`
}

View File

@@ -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"

View 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"`
}

View 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")
}

View File

@@ -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
View 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
View 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{})
}

View 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
View 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
View 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"`
}

View File

@@ -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 == "" {

View File

@@ -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",
}

View File

@@ -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
}

View File

@@ -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 = &region.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 := `

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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)
}
})
}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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{})
}

View File

@@ -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)
}
}