lexgen work

This commit is contained in:
Evan Jarrett
2025-12-20 10:49:02 -06:00
parent 59e507fe61
commit a546a9beca
31 changed files with 948 additions and 665 deletions
+7 -6
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
+6 -3
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
+16 -56
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
}
+52 -42
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
}
+36 -54
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,
}
+7 -27
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
}
+8 -37
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()
+26 -63
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
}
+31 -25
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
}
+12 -10
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)
+46 -40
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)
+3 -3
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
+6 -6
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)
}
}
+20 -3
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
+8 -6
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 {
+18
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"`
}
+360
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")
}
+109 -133
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)
+3 -3
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 == "" {
+5 -5
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",
}
+2 -2
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
}
+34 -20
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 := `
+13 -8
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)
+4 -4
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)
}
+43 -32
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)
}
})
}
+10 -10
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)
}
+30 -30
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)
+5 -5
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,
+19 -19
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 {
+3 -7
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{})
}
+6 -6
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)
}
}