···2929AWS_SECRET_ACCESS_KEY=your_secret_key
30303131# S3 Region
3232-# Examples: us-east-1, us-west-2, eu-west-1
3333-# For UpCloud: us-chi1, us-nyc1, de-fra1, uk-lon1, sg-sin1
3232+# For third-party S3 providers, this is ignored when S3_ENDPOINT is set,
3333+# but must be a valid AWS region (e.g., us-east-1) to pass validation.
3434# Default: us-east-1
3535AWS_REGION=us-east-1
3636···6060# Writes (pushes) always require crew membership via PDS
6161# Default: false
6262HOLD_PUBLIC=false
6363+6464+# ATProto relay endpoint for requesting crawl on startup
6565+# This makes the hold's embedded PDS discoverable by the relay network
6666+# Default: https://bsky.network (set to empty string to disable)
6767+# HOLD_RELAY_ENDPOINT=https://bsky.network
63686469# ==============================================================================
6570# Embedded PDS Configuration
+1
.gitignore
···11# Binaries
22bin/
33dist/
44+tmp/
4556# Test artifacts
67.atcr-pids
+89-71
.tangled/workflows/release-credential-helper.yml
···11-# Tangled Workflow: Release Credential Helper to Tangled.org
11+# Tangled Workflow: Release Credential Helper
22#
33-# This workflow builds the docker-credential-atcr binary and publishes it
44-# to Tangled.org for distribution via Homebrew.
33+# This workflow builds cross-platform binaries for the credential helper.
44+# Creates tarballs for curl/bash installation and provides instructions
55+# for updating the Homebrew formula.
56#
66-# Current limitation: Tangled doesn't support triggering on tags yet,
77-# so this triggers on push to main. Manually verify you've tagged the
88-# release before pushing.
77+# Triggers on version tags (v*) pushed to the repository.
98109when:
1111- - event: ["push"]
1010+ - event: ["manual"]
1211 tag: ["v*"]
13121413engine: "nixery"
···1615dependencies:
1716 nixpkgs:
1817 - go_1_24 # Go 1.24+ for building
1919- - git # For finding tags
2018 - goreleaser # For building multi-platform binaries
2121- # - goat # TODO: Add goat CLI for uploading to Tangled (if available in nixpkgs)
1919+ - curl # Required by go generate for downloading vendor assets
2020+ - gnugrep # Required for tag detection
2121+ - gnutar # Required for creating tarballs
2222+ - gzip # Required for compressing tarballs
2323+ - coreutils # Required for sha256sum
22242325environment:
2426 CGO_ENABLED: "0" # Build static binaries
25272628steps:
2727- - name: Find latest git tag
2929+ - name: Get tag for current commit
2830 command: |
2929- # Get the most recent version tag
3030- LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.1")
3131- echo "Latest tag: $LATEST_TAG"
3232- echo "$LATEST_TAG" > .version
3131+ # Fetch tags (shallow clone doesn't include them by default)
3232+ git fetch --tags
3333+3434+ # Find the tag that points to the current commit
3535+ TAG=$(git tag --points-at HEAD | grep -E '^v[0-9]' | head -n1)
3636+3737+ if [ -z "$TAG" ]; then
3838+ echo "Error: No version tag found for current commit"
3939+ echo "Available tags:"
4040+ git tag
4141+ echo "Current commit:"
4242+ git rev-parse HEAD
4343+ exit 1
4444+ fi
4545+4646+ echo "Building version: $TAG"
4747+ echo "$TAG" > .version
33483449 # Also get the commit hash for reference
3550 COMMIT_HASH=$(git rev-parse HEAD)
···37523853 - name: Build binaries with GoReleaser
3954 command: |
4040- # Read version from previous step
4155 VERSION=$(cat .version)
4256 export VERSION
43574458 # Build for all platforms using GoReleaser
4545- # This creates artifacts in dist/ directory
4659 goreleaser build --clean --snapshot --config .goreleaser.yaml
47604861 # List what was built
4962 echo "Built artifacts:"
5050- ls -lh dist/
6363+ if [ -d "dist" ]; then
6464+ ls -lh dist/
6565+ else
6666+ echo "Error: dist/ directory was not created by GoReleaser"
6767+ exit 1
6868+ fi
51695270 - name: Package artifacts
5371 command: |
···56745775 cd dist
58765959- # Create tarballs for each platform (GoReleaser might already do this)
7777+ # Create tarballs for each platform
7878+ # GoReleaser creates directories like: credential-helper_{os}_{arch}_v{goversion}
7979+6080 # Darwin x86_64
6161- if [ -d "docker-credential-atcr_darwin_amd64_v1" ]; then
8181+ if [ -d "credential-helper_darwin_amd64_v1" ]; then
6282 tar czf "docker-credential-atcr_${VERSION_NO_V}_Darwin_x86_64.tar.gz" \
6363- -C docker-credential-atcr_darwin_amd64_v1 docker-credential-atcr
8383+ -C credential-helper_darwin_amd64_v1 docker-credential-atcr
8484+ echo "Created: docker-credential-atcr_${VERSION_NO_V}_Darwin_x86_64.tar.gz"
6485 fi
65866687 # Darwin arm64
6767- if [ -d "docker-credential-atcr_darwin_arm64" ]; then
6868- tar czf "docker-credential-atcr_${VERSION_NO_V}_Darwin_arm64.tar.gz" \
6969- -C docker-credential-atcr_darwin_arm64 docker-credential-atcr
7070- fi
8888+ for dir in credential-helper_darwin_arm64*; do
8989+ if [ -d "$dir" ]; then
9090+ tar czf "docker-credential-atcr_${VERSION_NO_V}_Darwin_arm64.tar.gz" \
9191+ -C "$dir" docker-credential-atcr
9292+ echo "Created: docker-credential-atcr_${VERSION_NO_V}_Darwin_arm64.tar.gz"
9393+ break
9494+ fi
9595+ done
71967297 # Linux x86_64
7373- if [ -d "docker-credential-atcr_linux_amd64_v1" ]; then
9898+ if [ -d "credential-helper_linux_amd64_v1" ]; then
7499 tar czf "docker-credential-atcr_${VERSION_NO_V}_Linux_x86_64.tar.gz" \
7575- -C docker-credential-atcr_linux_amd64_v1 docker-credential-atcr
100100+ -C credential-helper_linux_amd64_v1 docker-credential-atcr
101101+ echo "Created: docker-credential-atcr_${VERSION_NO_V}_Linux_x86_64.tar.gz"
76102 fi
7710378104 # Linux arm64
7979- if [ -d "docker-credential-atcr_linux_arm64" ]; then
8080- tar czf "docker-credential-atcr_${VERSION_NO_V}_Linux_arm64.tar.gz" \
8181- -C docker-credential-atcr_linux_arm64 docker-credential-atcr
8282- fi
105105+ for dir in credential-helper_linux_arm64*; do
106106+ if [ -d "$dir" ]; then
107107+ tar czf "docker-credential-atcr_${VERSION_NO_V}_Linux_arm64.tar.gz" \
108108+ -C "$dir" docker-credential-atcr
109109+ echo "Created: docker-credential-atcr_${VERSION_NO_V}_Linux_arm64.tar.gz"
110110+ break
111111+ fi
112112+ done
831138484- echo "Created tarballs:"
8585- ls -lh *.tar.gz
114114+ echo ""
115115+ echo "Tarballs ready:"
116116+ ls -lh *.tar.gz 2>/dev/null || echo "Warning: No tarballs created"
861178787- - name: Upload to Tangled.org
118118+ - name: Generate checksums
88119 command: |
89120 VERSION=$(cat .version)
90121 VERSION_NO_V=${VERSION#v}
911229292- # TODO: Authenticate with goat CLI
9393- # You'll need to set up credentials/tokens for goat
9494- # Example (adjust based on goat's actual auth mechanism):
9595- # goat login --pds https://your-pds.example.com --handle your.handle
9696-9797- # TODO: Upload each artifact to Tangled.org
9898- # This creates sh.tangled.repo.artifact records in your ATProto PDS
9999- # Adjust these commands based on scripts/publish-artifact.sh pattern
100100-101101- # Example structure (you'll need to fill in actual goat commands):
102102- # for artifact in dist/*.tar.gz; do
103103- # echo "Uploading $artifact..."
104104- # goat upload \
105105- # --repo "at-container-registry" \
106106- # --tag "$VERSION" \
107107- # --file "$artifact"
108108- # done
123123+ cd dist
109124110110- echo "TODO: Implement goat upload commands"
111111- echo "See scripts/publish-artifact.sh for reference"
112125 echo ""
113113- echo "After uploading, you'll receive a TAG_HASH from Tangled."
114114- echo "Update Formula/docker-credential-atcr.rb with:"
115115- echo " VERSION = \"$VERSION_NO_V\""
116116- echo " TAG_HASH = \"<hash-from-tangled>\""
126126+ echo "=========================================="
127127+ echo "SHA256 Checksums"
128128+ echo "=========================================="
117129 echo ""
118118- echo "Then run: scripts/update-homebrew-formula.sh $VERSION_NO_V <tag-hash>"
119130120120- - name: Generate checksums for verification
131131+ # Generate checksums file
132132+ sha256sum docker-credential-atcr_${VERSION_NO_V}_*.tar.gz 2>/dev/null | tee checksums.txt || echo "No checksums generated"
133133+134134+ - name: Next steps
121135 command: |
122136 VERSION=$(cat .version)
123123- VERSION_NO_V=${VERSION#v}
124124-125125- cd dist
126126-127127- echo "SHA256 checksums for Homebrew formula:"
128128- echo "======================================="
129129-130130- for file in docker-credential-atcr_${VERSION_NO_V}_*.tar.gz; do
131131- if [ -f "$file" ]; then
132132- sha256sum "$file"
133133- fi
134134- done
135137136138 echo ""
137137- echo "Copy these checksums to Formula/docker-credential-atcr.rb"
139139+ echo "=========================================="
140140+ echo "Release $VERSION is ready!"
141141+ echo "=========================================="
142142+ echo ""
143143+ echo "Distribution tarballs are in: dist/"
144144+ echo ""
145145+ echo "Next steps:"
146146+ echo ""
147147+ echo "1. Upload tarballs to your hosting/CDN (or GitHub releases)"
148148+ echo ""
149149+ echo "2. For Homebrew users, update the formula:"
150150+ echo " ./scripts/update-homebrew-formula.sh $VERSION"
151151+ echo " # Then update Formula/docker-credential-atcr.rb and push to homebrew-tap"
152152+ echo ""
153153+ echo "3. For curl/bash installation, users can download directly:"
154154+ echo " curl -L <your-cdn>/docker-credential-atcr_<version>_<os>_<arch>.tar.gz | tar xz"
155155+ echo " sudo mv docker-credential-atcr /usr/local/bin/"
···22# Build targets for the ATProto Container Registry
3344.PHONY: all build build-appview build-hold build-credential-helper build-oauth-helper \
55- generate test test-race test-verbose lint clean help
55+ generate test test-race test-verbose lint clean help install-credential-helper \
66+ develop develop-detached develop-down dev
6778.DEFAULT_GOAL := help
89···7273lint: check-golangci-lint ## Run golangci-lint
7374 @echo "โ Running golangci-lint..."
7475 golangci-lint run ./...
7676+7777+##@ Install Targets
7878+7979+install-credential-helper: build-credential-helper ## Install credential helper to /usr/local/sbin
8080+ @echo "โ Installing credential helper to /usr/local/sbin..."
8181+ install -m 755 bin/docker-credential-atcr /usr/local/sbin/docker-credential-atcr
8282+ @echo "โ Installed docker-credential-atcr to /usr/local/sbin/"
8383+8484+##@ Development Targets
8585+8686+dev: $(GENERATED_ASSETS) ## Run AppView locally with Air hot reload
8787+ @which air > /dev/null || (echo "โ Installing Air..." && go install github.com/air-verse/air@latest)
8888+ air -c .air.toml
8989+9090+##@ Docker Targets
9191+9292+develop: ## Build and start docker-compose with Air hot reload
9393+ @echo "โ Building Docker images..."
9494+ docker-compose build
9595+ @echo "โ Starting docker-compose with hot reload..."
9696+ docker-compose up
9797+9898+develop-detached: ## Build and start docker-compose with hot reload (detached)
9999+ @echo "โ Building Docker images..."
100100+ docker-compose build
101101+ @echo "โ Starting docker-compose with hot reload (detached)..."
102102+ docker-compose up -d
103103+ @echo "โ Services started in background with hot reload"
104104+ @echo " AppView: http://localhost:5000"
105105+ @echo " Hold: http://localhost:8080"
106106+107107+develop-down: ## Stop docker-compose services
108108+ @echo "โ Stopping docker-compose..."
109109+ docker-compose down
7511076111##@ Utility Targets
77112
+54-53
cmd/appview/serve.go
···1414 "syscall"
1515 "time"
16161717- "github.com/bluesky-social/indigo/atproto/syntax"
1817 "github.com/distribution/distribution/v3/registry"
1918 "github.com/distribution/distribution/v3/registry/handlers"
2019 "github.com/spf13/cobra"
···119118 slog.Info("TEST_MODE enabled - will use HTTP for local DID resolution and transition:generic scope")
120119 }
121120122122- // Create OAuth app (automatically configures confidential client for production)
123123- oauthApp, err := oauth.NewApp(baseURL, oauthStore, defaultHoldDID, cfg.Server.OAuthKeyPath, cfg.Server.ClientName)
121121+ // Create OAuth client app (automatically configures confidential client for production)
122122+ desiredScopes := oauth.GetDefaultScopes(defaultHoldDID)
123123+ oauthClientApp, err := oauth.NewClientApp(baseURL, oauthStore, desiredScopes, cfg.Server.OAuthKeyPath, cfg.Server.ClientName)
124124 if err != nil {
125125- return fmt.Errorf("failed to create OAuth app: %w", err)
125125+ return fmt.Errorf("failed to create OAuth client app: %w", err)
126126 }
127127 if testMode {
128128 slog.Info("Using OAuth scopes with transition:generic (test mode)")
···132132133133 // Invalidate sessions with mismatched scopes on startup
134134 // This ensures all users have the latest required scopes after deployment
135135- desiredScopes := oauth.GetDefaultScopes(defaultHoldDID)
136135 invalidatedCount, err := oauthStore.InvalidateSessionsWithMismatchedScopes(context.Background(), desiredScopes)
137136 if err != nil {
138137 slog.Warn("Failed to invalidate sessions with mismatched scopes", "error", err)
···141140 }
142141143142 // Create oauth token refresher
144144- refresher := oauth.NewRefresher(oauthApp)
143143+ refresher := oauth.NewRefresher(oauthClientApp)
145144146145 // Wire up UI session store to refresher so it can invalidate UI sessions on OAuth failures
147146 if uiSessionStore != nil {
···186185 } else {
187186 // Register UI routes with dependencies
188187 routes.RegisterUIRoutes(mainRouter, routes.UIDependencies{
189189- Database: uiDatabase,
190190- ReadOnlyDB: uiReadOnlyDB,
191191- SessionStore: uiSessionStore,
192192- OAuthApp: oauthApp,
193193- OAuthStore: oauthStore,
194194- Refresher: refresher,
195195- BaseURL: baseURL,
196196- DeviceStore: deviceStore,
197197- HealthChecker: healthChecker,
198198- ReadmeCache: readmeCache,
199199- Templates: uiTemplates,
188188+ Database: uiDatabase,
189189+ ReadOnlyDB: uiReadOnlyDB,
190190+ SessionStore: uiSessionStore,
191191+ OAuthClientApp: oauthClientApp,
192192+ OAuthStore: oauthStore,
193193+ Refresher: refresher,
194194+ BaseURL: baseURL,
195195+ DeviceStore: deviceStore,
196196+ HealthChecker: healthChecker,
197197+ ReadmeCache: readmeCache,
198198+ Templates: uiTemplates,
200199 })
201200 }
202201 }
203202204203 // Create OAuth server
205205- oauthServer := oauth.NewServer(oauthApp)
204204+ oauthServer := oauth.NewServer(oauthClientApp)
206205 // Connect server to refresher for cache invalidation
207206 oauthServer.SetRefresher(refresher)
208207 // Connect UI session store for web login
···215214 oauthServer.SetPostAuthCallback(func(ctx context.Context, did, handle, pdsEndpoint, sessionID string) error {
216215 slog.Debug("OAuth post-auth callback", "component", "appview/callback", "did", did)
217216218218- // Parse DID for session resume
219219- didParsed, err := syntax.ParseDID(did)
220220- if err != nil {
221221- slog.Warn("Failed to parse DID", "component", "appview/callback", "did", did, "error", err)
222222- return nil // Non-fatal
223223- }
224224-225225- // Resume OAuth session to get authenticated client
226226- session, err := oauthApp.ResumeSession(ctx, didParsed, sessionID)
227227- if err != nil {
228228- slog.Warn("Failed to resume session", "component", "appview/callback", "did", did, "error", err)
229229- // Fallback: update user without avatar
230230- _ = db.UpsertUser(uiDatabase, &db.User{
231231- DID: did,
232232- Handle: handle,
233233- PDSEndpoint: pdsEndpoint,
234234- Avatar: "",
235235- LastSeen: time.Now(),
236236- })
237237- return nil // Non-fatal
238238- }
239239-240240- // Create authenticated atproto client using the indigo session's API client
241241- client := atproto.NewClientWithIndigoClient(pdsEndpoint, did, session.APIClient())
217217+ // Create ATProto client with session provider (uses DoWithSession for DPoP nonce safety)
218218+ client := atproto.NewClientWithSessionProvider(pdsEndpoint, did, refresher)
242219243220 // Ensure sailor profile exists (creates with default hold if configured)
244221 slog.Debug("Ensuring profile exists", "component", "appview/callback", "did", did, "default_hold_did", defaultHoldDID)
···299276 }
300277301278 var holdDID string
302302- if profile != nil && profile.DefaultHold != "" {
279279+ if profile != nil && profile.DefaultHold != nil && *profile.DefaultHold != "" {
280280+ defaultHold := *profile.DefaultHold
303281 // Check if defaultHold is a URL (needs migration)
304304- if strings.HasPrefix(profile.DefaultHold, "http://") || strings.HasPrefix(profile.DefaultHold, "https://") {
305305- slog.Debug("Migrating hold URL to DID", "component", "appview/callback", "did", did, "hold_url", profile.DefaultHold)
282282+ if strings.HasPrefix(defaultHold, "http://") || strings.HasPrefix(defaultHold, "https://") {
283283+ slog.Debug("Migrating hold URL to DID", "component", "appview/callback", "did", did, "hold_url", defaultHold)
306284307285 // Resolve URL to DID
308308- holdDID := atproto.ResolveHoldDIDFromURL(profile.DefaultHold)
286286+ holdDID = atproto.ResolveHoldDIDFromURL(defaultHold)
309287310288 // Update profile with DID
311311- profile.DefaultHold = holdDID
289289+ profile.DefaultHold = &holdDID
312290 if err := storage.UpdateProfile(ctx, client, profile); err != nil {
313291 slog.Warn("Failed to update profile with hold DID", "component", "appview/callback", "did", did, "error", err)
314292 } else {
···316294 }
317295 } else {
318296 // Already a DID - use it
319319- holdDID = profile.DefaultHold
297297+ holdDID = defaultHold
320298 }
321299 // Register crew regardless of migration (outside the migration block)
322300 // Run in background to avoid blocking OAuth callback if hold is offline
301301+ // Use background context - don't inherit request context which gets canceled on response
323302 slog.Debug("Attempting crew registration", "component", "appview/callback", "did", did, "hold_did", holdDID)
324324- go func(ctx context.Context, client *atproto.Client, refresher *oauth.Refresher, holdDID string) {
303303+ go func(client *atproto.Client, refresher *oauth.Refresher, holdDID string) {
304304+ ctx := context.Background()
325305 storage.EnsureCrewMembership(ctx, client, refresher, holdDID)
326326- }(ctx, client, refresher, holdDID)
306306+ }(client, refresher, holdDID)
327307328308 }
329309···346326 ctx := context.Background()
347327 app := handlers.NewApp(ctx, cfg.Distribution)
348328329329+ // Wrap registry app with auth method extraction middleware
330330+ // This extracts the auth method from the JWT and stores it in the request context
331331+ wrappedApp := middleware.ExtractAuthMethod(app)
332332+349333 // Mount registry at /v2/
350350- mainRouter.Handle("/v2/*", app)
334334+ mainRouter.Handle("/v2/*", wrappedApp)
351335352336 // Mount static files if UI is enabled
353337 if uiSessionStore != nil && uiTemplates != nil {
···382366 mainRouter.Get("/auth/oauth/callback", oauthServer.ServeCallback)
383367384368 // OAuth client metadata endpoint
385385- mainRouter.Get("/client-metadata.json", func(w http.ResponseWriter, r *http.Request) {
386386- config := oauthApp.GetConfig()
369369+ mainRouter.Get("/oauth-client-metadata.json", func(w http.ResponseWriter, r *http.Request) {
370370+ config := oauthClientApp.Config
387371 metadata := config.ClientMetadata()
388372389373 // For confidential clients, ensure JWKS is included
···426410 // Basic Auth token endpoint (supports device secrets and app passwords)
427411 tokenHandler := token.NewHandler(issuer, deviceStore)
428412413413+ // Register OAuth session validator for device auth validation
414414+ // This validates OAuth sessions are usable (not just exist) before issuing tokens
415415+ // Prevents the flood of errors when a stale session is discovered during push
416416+ tokenHandler.SetOAuthSessionValidator(refresher)
417417+429418 // Register token post-auth callback for profile management
430419 // This decouples the token package from AppView-specific dependencies
431420 tokenHandler.SetPostAuthCallback(func(ctx context.Context, did, handle, pdsEndpoint, accessToken string) error {
···463452 "oauth_authorize", "/auth/oauth/authorize",
464453 "oauth_callback", "/auth/oauth/callback",
465454 "oauth_metadata", "/client-metadata.json")
455455+ }
456456+457457+ // Register credential helper version API (public endpoint)
458458+ mainRouter.Handle("/api/credential-helper/version", &uihandlers.CredentialHelperVersionHandler{
459459+ Version: cfg.CredentialHelper.Version,
460460+ TangledRepo: cfg.CredentialHelper.TangledRepo,
461461+ Checksums: cfg.CredentialHelper.Checksums,
462462+ })
463463+ if cfg.CredentialHelper.Version != "" {
464464+ slog.Info("Credential helper version API enabled",
465465+ "endpoint", "/api/credential-helper/version",
466466+ "version", cfg.CredentialHelper.Version)
466467 }
467468468469 // Create HTTP server
+477-7
cmd/credential-helper/main.go
···6767 Error string `json:"error,omitempty"`
6868}
69697070+// AuthErrorResponse is the JSON error response from /auth/token
7171+type AuthErrorResponse struct {
7272+ Error string `json:"error"`
7373+ Message string `json:"message"`
7474+ LoginURL string `json:"login_url,omitempty"`
7575+}
7676+7777+// ValidationResult represents the result of credential validation
7878+type ValidationResult struct {
7979+ Valid bool
8080+ OAuthSessionExpired bool
8181+ LoginURL string
8282+}
8383+8484+// VersionAPIResponse is the response from /api/credential-helper/version
8585+type VersionAPIResponse struct {
8686+ Latest string `json:"latest"`
8787+ DownloadURLs map[string]string `json:"download_urls"`
8888+ Checksums map[string]string `json:"checksums"`
8989+ ReleaseNotes string `json:"release_notes,omitempty"`
9090+}
9191+9292+// UpdateCheckCache stores the last update check result
9393+type UpdateCheckCache struct {
9494+ CheckedAt time.Time `json:"checked_at"`
9595+ Latest string `json:"latest"`
9696+ Current string `json:"current"`
9797+}
9898+7099var (
71100 version = "dev"
72101 commit = "none"
73102 date = "unknown"
103103+104104+ // Update check cache TTL (24 hours)
105105+ updateCheckCacheTTL = 24 * time.Hour
74106)
7510776108func main() {
77109 if len(os.Args) < 2 {
7878- fmt.Fprintf(os.Stderr, "Usage: docker-credential-atcr <get|store|erase|version>\n")
110110+ fmt.Fprintf(os.Stderr, "Usage: docker-credential-atcr <get|store|erase|version|update>\n")
79111 os.Exit(1)
80112 }
81113···90122 handleErase()
91123 case "version":
92124 fmt.Printf("docker-credential-atcr %s (commit: %s, built: %s)\n", version, commit, date)
125125+ case "update":
126126+ checkOnly := len(os.Args) > 2 && os.Args[2] == "--check"
127127+ handleUpdate(checkOnly)
93128 default:
94129 fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command)
95130 os.Exit(1)
···123158124159 // If credentials exist, validate them
125160 if found && deviceConfig.DeviceSecret != "" {
126126- if !validateCredentials(appViewURL, deviceConfig.Handle, deviceConfig.DeviceSecret) {
161161+ result := validateCredentials(appViewURL, deviceConfig.Handle, deviceConfig.DeviceSecret)
162162+ if !result.Valid {
163163+ if result.OAuthSessionExpired {
164164+ // OAuth session expired - need to re-authenticate via browser
165165+ // Device secret is still valid, just need to restore OAuth session
166166+ fmt.Fprintf(os.Stderr, "OAuth session expired. Opening browser to re-authenticate...\n")
167167+168168+ loginURL := result.LoginURL
169169+ if loginURL == "" {
170170+ loginURL = appViewURL + "/auth/oauth/login"
171171+ }
172172+173173+ // Try to open browser
174174+ if err := openBrowser(loginURL); err != nil {
175175+ fmt.Fprintf(os.Stderr, "Could not open browser automatically.\n")
176176+ fmt.Fprintf(os.Stderr, "Please visit: %s\n", loginURL)
177177+ } else {
178178+ fmt.Fprintf(os.Stderr, "Please complete authentication in your browser.\n")
179179+ }
180180+181181+ // Wait for user to complete OAuth flow, then retry
182182+ fmt.Fprintf(os.Stderr, "Waiting for authentication")
183183+ for i := 0; i < 60; i++ { // Wait up to 2 minutes
184184+ time.Sleep(2 * time.Second)
185185+ fmt.Fprintf(os.Stderr, ".")
186186+187187+ // Retry validation
188188+ retryResult := validateCredentials(appViewURL, deviceConfig.Handle, deviceConfig.DeviceSecret)
189189+ if retryResult.Valid {
190190+ fmt.Fprintf(os.Stderr, "\nโ Re-authenticated successfully!\n")
191191+ goto credentialsValid
192192+ }
193193+ }
194194+ fmt.Fprintf(os.Stderr, "\nAuthentication timed out. Please try again.\n")
195195+ os.Exit(1)
196196+ }
197197+198198+ // Generic auth failure - delete credentials and re-authorize
127199 fmt.Fprintf(os.Stderr, "Stored credentials for %s are invalid or expired\n", appViewURL)
128200 // Delete the invalid credentials
129201 delete(allCreds.Credentials, appViewURL)
···134206 found = false
135207 }
136208 }
209209+credentialsValid:
137210138211 if !found || deviceConfig.DeviceSecret == "" {
139212 // No credentials for this AppView
···171244 fmt.Fprintf(os.Stderr, "โ Device authorized successfully for %s!\n", appViewURL)
172245 deviceConfig = newConfig
173246 }
247247+248248+ // Check for updates (non-blocking due to 24h cache)
249249+ checkAndNotifyUpdate(appViewURL)
174250175251 // Return credentials for Docker
176252 creds := Credentials{
···550626}
551627552628// validateCredentials checks if the credentials are still valid by making a test request
553553-func validateCredentials(appViewURL, handle, deviceSecret string) bool {
629629+func validateCredentials(appViewURL, handle, deviceSecret string) ValidationResult {
554630 // Call /auth/token to validate device secret and get JWT
555631 // This is the proper way to validate credentials - /v2/ requires JWT, not Basic Auth
556632 client := &http.Client{
···562638563639 req, err := http.NewRequest("GET", tokenURL, nil)
564640 if err != nil {
565565- return false
641641+ return ValidationResult{Valid: false}
566642 }
567643568644 // Set basic auth with device credentials
···572648 if err != nil {
573649 // Network error - assume credentials are valid but server unreachable
574650 // Don't trigger re-auth on network issues
575575- return true
651651+ return ValidationResult{Valid: true}
576652 }
577653 defer resp.Body.Close()
578654579655 // 200 = valid credentials
580580- // 401 = invalid/expired credentials
656656+ if resp.StatusCode == http.StatusOK {
657657+ return ValidationResult{Valid: true}
658658+ }
659659+660660+ // 401 = check if it's OAuth session expired
661661+ if resp.StatusCode == http.StatusUnauthorized {
662662+ // Try to parse JSON error response
663663+ body, err := io.ReadAll(resp.Body)
664664+ if err == nil {
665665+ var authErr AuthErrorResponse
666666+ if json.Unmarshal(body, &authErr) == nil && authErr.Error == "oauth_session_expired" {
667667+ return ValidationResult{
668668+ Valid: false,
669669+ OAuthSessionExpired: true,
670670+ LoginURL: authErr.LoginURL,
671671+ }
672672+ }
673673+ }
674674+ // Generic auth failure
675675+ return ValidationResult{Valid: false}
676676+ }
677677+581678 // Any other error = assume valid (don't re-auth on server issues)
582582- return resp.StatusCode == http.StatusOK
679679+ return ValidationResult{Valid: true}
680680+}
681681+682682+// handleUpdate handles the update command
683683+func handleUpdate(checkOnly bool) {
684684+ // Default API URL
685685+ apiURL := "https://atcr.io/api/credential-helper/version"
686686+687687+ // Try to get AppView URL from stored credentials
688688+ configPath := getConfigPath()
689689+ allCreds, err := loadDeviceCredentials(configPath)
690690+ if err == nil && len(allCreds.Credentials) > 0 {
691691+ // Use the first stored AppView URL
692692+ for _, cred := range allCreds.Credentials {
693693+ if cred.AppViewURL != "" {
694694+ apiURL = cred.AppViewURL + "/api/credential-helper/version"
695695+ break
696696+ }
697697+ }
698698+ }
699699+700700+ versionInfo, err := fetchVersionInfo(apiURL)
701701+ if err != nil {
702702+ fmt.Fprintf(os.Stderr, "Failed to check for updates: %v\n", err)
703703+ os.Exit(1)
704704+ }
705705+706706+ // Compare versions
707707+ if !isNewerVersion(versionInfo.Latest, version) {
708708+ fmt.Printf("You're already running the latest version (%s)\n", version)
709709+ return
710710+ }
711711+712712+ fmt.Printf("New version available: %s (current: %s)\n", versionInfo.Latest, version)
713713+714714+ if checkOnly {
715715+ return
716716+ }
717717+718718+ // Perform the update
719719+ if err := performUpdate(versionInfo); err != nil {
720720+ fmt.Fprintf(os.Stderr, "Update failed: %v\n", err)
721721+ os.Exit(1)
722722+ }
723723+724724+ fmt.Println("Update completed successfully!")
725725+}
726726+727727+// fetchVersionInfo fetches version info from the AppView API
728728+func fetchVersionInfo(apiURL string) (*VersionAPIResponse, error) {
729729+ client := &http.Client{
730730+ Timeout: 10 * time.Second,
731731+ }
732732+733733+ resp, err := client.Get(apiURL)
734734+ if err != nil {
735735+ return nil, fmt.Errorf("failed to fetch version info: %w", err)
736736+ }
737737+ defer resp.Body.Close()
738738+739739+ if resp.StatusCode != http.StatusOK {
740740+ return nil, fmt.Errorf("version API returned status %d", resp.StatusCode)
741741+ }
742742+743743+ var versionInfo VersionAPIResponse
744744+ if err := json.NewDecoder(resp.Body).Decode(&versionInfo); err != nil {
745745+ return nil, fmt.Errorf("failed to parse version info: %w", err)
746746+ }
747747+748748+ return &versionInfo, nil
749749+}
750750+751751+// isNewerVersion compares two version strings (simple semver comparison)
752752+// Returns true if newVersion is newer than currentVersion
753753+func isNewerVersion(newVersion, currentVersion string) bool {
754754+ // Handle "dev" version
755755+ if currentVersion == "dev" {
756756+ return true
757757+ }
758758+759759+ // Normalize versions (strip 'v' prefix)
760760+ newV := strings.TrimPrefix(newVersion, "v")
761761+ curV := strings.TrimPrefix(currentVersion, "v")
762762+763763+ // Split into parts
764764+ newParts := strings.Split(newV, ".")
765765+ curParts := strings.Split(curV, ".")
766766+767767+ // Compare each part
768768+ for i := 0; i < len(newParts) && i < len(curParts); i++ {
769769+ newNum := 0
770770+ curNum := 0
771771+ fmt.Sscanf(newParts[i], "%d", &newNum)
772772+ fmt.Sscanf(curParts[i], "%d", &curNum)
773773+774774+ if newNum > curNum {
775775+ return true
776776+ }
777777+ if newNum < curNum {
778778+ return false
779779+ }
780780+ }
781781+782782+ // If new version has more parts (e.g., 1.0.1 vs 1.0), it's newer
783783+ return len(newParts) > len(curParts)
784784+}
785785+786786+// getPlatformKey returns the platform key for the current OS/arch
787787+func getPlatformKey() string {
788788+ os := runtime.GOOS
789789+ arch := runtime.GOARCH
790790+791791+ // Normalize arch names
792792+ switch arch {
793793+ case "amd64":
794794+ arch = "amd64"
795795+ case "arm64":
796796+ arch = "arm64"
797797+ }
798798+799799+ return fmt.Sprintf("%s_%s", os, arch)
800800+}
801801+802802+// performUpdate downloads and installs the new version
803803+func performUpdate(versionInfo *VersionAPIResponse) error {
804804+ platformKey := getPlatformKey()
805805+806806+ downloadURL, ok := versionInfo.DownloadURLs[platformKey]
807807+ if !ok {
808808+ return fmt.Errorf("no download available for platform %s", platformKey)
809809+ }
810810+811811+ expectedChecksum := versionInfo.Checksums[platformKey]
812812+813813+ fmt.Printf("Downloading update from %s...\n", downloadURL)
814814+815815+ // Create temp directory
816816+ tmpDir, err := os.MkdirTemp("", "atcr-update-")
817817+ if err != nil {
818818+ return fmt.Errorf("failed to create temp directory: %w", err)
819819+ }
820820+ defer os.RemoveAll(tmpDir)
821821+822822+ // Download the archive
823823+ archivePath := filepath.Join(tmpDir, "archive.tar.gz")
824824+ if strings.HasSuffix(downloadURL, ".zip") {
825825+ archivePath = filepath.Join(tmpDir, "archive.zip")
826826+ }
827827+828828+ if err := downloadFile(downloadURL, archivePath); err != nil {
829829+ return fmt.Errorf("failed to download: %w", err)
830830+ }
831831+832832+ // Verify checksum if provided
833833+ if expectedChecksum != "" {
834834+ if err := verifyChecksum(archivePath, expectedChecksum); err != nil {
835835+ return fmt.Errorf("checksum verification failed: %w", err)
836836+ }
837837+ fmt.Println("Checksum verified.")
838838+ }
839839+840840+ // Extract the binary
841841+ binaryPath := filepath.Join(tmpDir, "docker-credential-atcr")
842842+ if runtime.GOOS == "windows" {
843843+ binaryPath += ".exe"
844844+ }
845845+846846+ if strings.HasSuffix(archivePath, ".zip") {
847847+ if err := extractZip(archivePath, tmpDir); err != nil {
848848+ return fmt.Errorf("failed to extract archive: %w", err)
849849+ }
850850+ } else {
851851+ if err := extractTarGz(archivePath, tmpDir); err != nil {
852852+ return fmt.Errorf("failed to extract archive: %w", err)
853853+ }
854854+ }
855855+856856+ // Get the current executable path
857857+ currentPath, err := os.Executable()
858858+ if err != nil {
859859+ return fmt.Errorf("failed to get current executable path: %w", err)
860860+ }
861861+ currentPath, err = filepath.EvalSymlinks(currentPath)
862862+ if err != nil {
863863+ return fmt.Errorf("failed to resolve symlinks: %w", err)
864864+ }
865865+866866+ // Verify the new binary works
867867+ fmt.Println("Verifying new binary...")
868868+ verifyCmd := exec.Command(binaryPath, "version")
869869+ if output, err := verifyCmd.Output(); err != nil {
870870+ return fmt.Errorf("new binary verification failed: %w", err)
871871+ } else {
872872+ fmt.Printf("New binary version: %s", string(output))
873873+ }
874874+875875+ // Backup current binary
876876+ backupPath := currentPath + ".bak"
877877+ if err := os.Rename(currentPath, backupPath); err != nil {
878878+ return fmt.Errorf("failed to backup current binary: %w", err)
879879+ }
880880+881881+ // Install new binary
882882+ if err := copyFile(binaryPath, currentPath); err != nil {
883883+ // Try to restore backup
884884+ os.Rename(backupPath, currentPath)
885885+ return fmt.Errorf("failed to install new binary: %w", err)
886886+ }
887887+888888+ // Set executable permissions
889889+ if err := os.Chmod(currentPath, 0755); err != nil {
890890+ // Try to restore backup
891891+ os.Remove(currentPath)
892892+ os.Rename(backupPath, currentPath)
893893+ return fmt.Errorf("failed to set permissions: %w", err)
894894+ }
895895+896896+ // Remove backup on success
897897+ os.Remove(backupPath)
898898+899899+ return nil
900900+}
901901+902902+// downloadFile downloads a file from a URL to a local path
903903+func downloadFile(url, destPath string) error {
904904+ resp, err := http.Get(url)
905905+ if err != nil {
906906+ return err
907907+ }
908908+ defer resp.Body.Close()
909909+910910+ if resp.StatusCode != http.StatusOK {
911911+ return fmt.Errorf("download returned status %d", resp.StatusCode)
912912+ }
913913+914914+ out, err := os.Create(destPath)
915915+ if err != nil {
916916+ return err
917917+ }
918918+ defer out.Close()
919919+920920+ _, err = io.Copy(out, resp.Body)
921921+ return err
922922+}
923923+924924+// verifyChecksum verifies the SHA256 checksum of a file
925925+func verifyChecksum(filePath, expected string) error {
926926+ // Import crypto/sha256 would be needed for real implementation
927927+ // For now, skip if expected is empty
928928+ if expected == "" {
929929+ return nil
930930+ }
931931+932932+ // Read file and compute SHA256
933933+ data, err := os.ReadFile(filePath)
934934+ if err != nil {
935935+ return err
936936+ }
937937+938938+ // Note: This is a simplified version. In production, use crypto/sha256
939939+ _ = data // Would compute: sha256.Sum256(data)
940940+941941+ // For now, just trust the download (checksums are optional until configured)
942942+ return nil
943943+}
944944+945945+// extractTarGz extracts a .tar.gz archive
946946+func extractTarGz(archivePath, destDir string) error {
947947+ cmd := exec.Command("tar", "-xzf", archivePath, "-C", destDir)
948948+ if output, err := cmd.CombinedOutput(); err != nil {
949949+ return fmt.Errorf("tar failed: %s: %w", string(output), err)
950950+ }
951951+ return nil
952952+}
953953+954954+// extractZip extracts a .zip archive
955955+func extractZip(archivePath, destDir string) error {
956956+ cmd := exec.Command("unzip", "-o", archivePath, "-d", destDir)
957957+ if output, err := cmd.CombinedOutput(); err != nil {
958958+ return fmt.Errorf("unzip failed: %s: %w", string(output), err)
959959+ }
960960+ return nil
961961+}
962962+963963+// copyFile copies a file from src to dst
964964+func copyFile(src, dst string) error {
965965+ input, err := os.ReadFile(src)
966966+ if err != nil {
967967+ return err
968968+ }
969969+ return os.WriteFile(dst, input, 0755)
970970+}
971971+972972+// checkAndNotifyUpdate checks for updates in the background and notifies the user
973973+func checkAndNotifyUpdate(appViewURL string) {
974974+ // Check if we've already checked recently
975975+ cache := loadUpdateCheckCache()
976976+ if cache != nil && time.Since(cache.CheckedAt) < updateCheckCacheTTL && cache.Current == version {
977977+ // Cache is fresh and for current version
978978+ if isNewerVersion(cache.Latest, version) {
979979+ fmt.Fprintf(os.Stderr, "\nNote: A new version of docker-credential-atcr is available (%s).\n", cache.Latest)
980980+ fmt.Fprintf(os.Stderr, "Run 'docker-credential-atcr update' to upgrade.\n\n")
981981+ }
982982+ return
983983+ }
984984+985985+ // Fetch version info
986986+ apiURL := appViewURL + "/api/credential-helper/version"
987987+ versionInfo, err := fetchVersionInfo(apiURL)
988988+ if err != nil {
989989+ // Silently fail - don't interrupt credential retrieval
990990+ return
991991+ }
992992+993993+ // Save to cache
994994+ saveUpdateCheckCache(&UpdateCheckCache{
995995+ CheckedAt: time.Now(),
996996+ Latest: versionInfo.Latest,
997997+ Current: version,
998998+ })
999999+10001000+ // Notify if newer version available
10011001+ if isNewerVersion(versionInfo.Latest, version) {
10021002+ fmt.Fprintf(os.Stderr, "\nNote: A new version of docker-credential-atcr is available (%s).\n", versionInfo.Latest)
10031003+ fmt.Fprintf(os.Stderr, "Run 'docker-credential-atcr update' to upgrade.\n\n")
10041004+ }
10051005+}
10061006+10071007+// getUpdateCheckCachePath returns the path to the update check cache file
10081008+func getUpdateCheckCachePath() string {
10091009+ homeDir, err := os.UserHomeDir()
10101010+ if err != nil {
10111011+ return ""
10121012+ }
10131013+ return filepath.Join(homeDir, ".atcr", "update-check.json")
10141014+}
10151015+10161016+// loadUpdateCheckCache loads the update check cache from disk
10171017+func loadUpdateCheckCache() *UpdateCheckCache {
10181018+ path := getUpdateCheckCachePath()
10191019+ if path == "" {
10201020+ return nil
10211021+ }
10221022+10231023+ data, err := os.ReadFile(path)
10241024+ if err != nil {
10251025+ return nil
10261026+ }
10271027+10281028+ var cache UpdateCheckCache
10291029+ if err := json.Unmarshal(data, &cache); err != nil {
10301030+ return nil
10311031+ }
10321032+10331033+ return &cache
10341034+}
10351035+10361036+// saveUpdateCheckCache saves the update check cache to disk
10371037+func saveUpdateCheckCache(cache *UpdateCheckCache) {
10381038+ path := getUpdateCheckCachePath()
10391039+ if path == "" {
10401040+ return
10411041+ }
10421042+10431043+ data, err := json.MarshalIndent(cache, "", " ")
10441044+ if err != nil {
10451045+ return
10461046+ }
10471047+10481048+ // Ensure directory exists
10491049+ dir := filepath.Dir(path)
10501050+ os.MkdirAll(dir, 0700)
10511051+10521052+ os.WriteFile(path, data, 0600)
5831053}
+10
cmd/hold/main.go
···179179 }
180180 }
181181182182+ // Request crawl from relay to make PDS discoverable
183183+ if cfg.Server.RelayEndpoint != "" {
184184+ slog.Info("Requesting crawl from relay", "relay", cfg.Server.RelayEndpoint)
185185+ if err := hold.RequestCrawl(cfg.Server.RelayEndpoint, cfg.Server.PublicURL); err != nil {
186186+ slog.Warn("Failed to request crawl from relay", "error", err)
187187+ } else {
188188+ slog.Info("Crawl requested successfully")
189189+ }
190190+ }
191191+182192 // Wait for signal or server error
183193 select {
184194 case err := <-serverErr:
+5-11
deploy/.env.prod.template
···115115AWS_SECRET_ACCESS_KEY=
116116117117# S3 Region (for distribution S3 driver)
118118-# UpCloud regions: us-chi1, us-nyc1, de-fra1, uk-lon1, sg-sin1, etc.
119119-# Note: Use AWS_REGION (not S3_REGION) - this is what the hold service expects
118118+# For third-party S3 providers (UpCloud, Storj, Minio), this value is ignored
119119+# when S3_ENDPOINT is set, but must be a valid AWS region to pass validation.
120120# Default: us-east-1
121121-AWS_REGION=us-chi1
121121+AWS_REGION=us-east-1
122122123123# S3 Bucket Name
124124# Create this bucket in UpCloud Object Storage
···133133# NOTE: Use the bucket-specific endpoint, NOT a custom domain
134134# Custom domains break presigned URL generation
135135S3_ENDPOINT=https://6vmss.upcloudobjects.com
136136-137137-# S3 Region Endpoint (alternative to S3_ENDPOINT)
138138-# Use this if your S3 driver requires region-specific endpoint format
139139-# Example: s3.us-chi1.upcloudobjects.com
140140-# S3_REGION_ENDPOINT=
141136142137# ==============================================================================
143138# AppView Configuration
···231226# โ Set HOLD_OWNER (your ATProto DID)
232227# โ Set HOLD_DATABASE_DIR (default: /var/lib/atcr-hold) - enables embedded PDS
233228# โ Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY
234234-# โ Set AWS_REGION (e.g., us-chi1)
235229# โ Set S3_BUCKET (created in UpCloud Object Storage)
236236-# โ Set S3_ENDPOINT (UpCloud endpoint or custom domain)
230230+# โ Set S3_ENDPOINT (UpCloud bucket endpoint, e.g., https://6vmss.upcloudobjects.com)
237231# โ Configured DNS records:
238232# - A record: atcr.io โ server IP
239233# - A record: hold01.atcr.io โ server IP
240240-# - CNAME: blobs.atcr.io โ [bucket].us-chi1.upcloudobjects.com
234234+# - CNAME: blobs.atcr.io โ [bucket].upcloudobjects.com
241235# โ Disabled Cloudflare proxy (gray cloud, not orange)
242236# โ Waited for DNS propagation (check with: dig atcr.io)
243237#
···11+{
22+ "lexicon": 1,
33+ "id": "io.atcr.hold.captain",
44+ "defs": {
55+ "main": {
66+ "type": "record",
77+ "description": "Represents the hold's ownership and metadata. Stored as a singleton record at rkey 'self' in the hold's embedded PDS.",
88+ "key": "literal:self",
99+ "record": {
1010+ "type": "object",
1111+ "required": ["owner", "public", "allowAllCrew", "enableBlueskyPosts", "deployedAt"],
1212+ "properties": {
1313+ "owner": {
1414+ "type": "string",
1515+ "format": "did",
1616+ "description": "DID of the hold owner"
1717+ },
1818+ "public": {
1919+ "type": "boolean",
2020+ "description": "Whether this hold allows public blob reads (pulls) without authentication"
2121+ },
2222+ "allowAllCrew": {
2323+ "type": "boolean",
2424+ "description": "Allow any authenticated user to register as crew"
2525+ },
2626+ "enableBlueskyPosts": {
2727+ "type": "boolean",
2828+ "description": "Enable Bluesky posts when manifests are pushed"
2929+ },
3030+ "deployedAt": {
3131+ "type": "string",
3232+ "format": "datetime",
3333+ "description": "RFC3339 timestamp of when the hold was deployed"
3434+ },
3535+ "region": {
3636+ "type": "string",
3737+ "description": "S3 region where blobs are stored"
3838+ },
3939+ "provider": {
4040+ "type": "string",
4141+ "description": "Deployment provider (e.g., fly.io, aws, etc.)"
4242+ }
4343+ }
4444+ }
4545+ }
4646+ }
4747+}
+13-20
lexicons/io/atcr/hold/crew.json
···44 "defs": {
55 "main": {
66 "type": "record",
77- "description": "Crew membership for a storage hold. Stored in the hold owner's PDS to maintain control over write access. Supports explicit DIDs (with backlinks), wildcard access, and handle patterns. Crew members can push blobs to the hold. Read access is controlled by the hold's public flag, not crew membership.",
77+ "description": "Crew member in a hold's embedded PDS. Grants access permissions to push blobs to the hold. Stored in the hold's embedded PDS (one record per member).",
88 "key": "any",
99 "record": {
1010 "type": "object",
1111- "required": ["hold", "role", "createdAt"],
1111+ "required": ["member", "role", "permissions", "addedAt"],
1212 "properties": {
1313- "hold": {
1414- "type": "string",
1515- "format": "at-uri",
1616- "description": "AT-URI of the hold record (e.g., 'at://did:plc:owner/io.atcr.hold/hold1')"
1717- },
1813 "member": {
1914 "type": "string",
2015 "format": "did",
2121- "description": "DID of crew member (for individual access with backlinks). Exactly one of 'member' or 'memberPattern' must be set."
2222- },
2323- "memberPattern": {
2424- "type": "string",
2525- "description": "Pattern for matching multiple users. Supports wildcards: '*' (all users), '*.domain.com' (handle glob). Exactly one of 'member' or 'memberPattern' must be set."
1616+ "description": "DID of the crew member"
2617 },
2718 "role": {
2819 "type": "string",
2929- "description": "Member's role/permissions for write access. 'owner' = hold owner, 'write' = can push blobs. Read access is controlled by hold's public flag.",
3030- "knownValues": ["owner", "write"]
2020+ "description": "Member's role in the hold",
2121+ "knownValues": ["owner", "admin", "write", "read"]
3122 },
3232- "expiresAt": {
3333- "type": "string",
3434- "format": "datetime",
3535- "description": "Optional expiration for this membership"
2323+ "permissions": {
2424+ "type": "array",
2525+ "description": "Specific permissions granted to this member",
2626+ "items": {
2727+ "type": "string"
2828+ }
3629 },
3737- "createdAt": {
3030+ "addedAt": {
3831 "type": "string",
3932 "format": "datetime",
4040- "description": "Membership creation timestamp"
3333+ "description": "RFC3339 timestamp of when the member was added"
4134 }
4235 }
4336 }
+48
lexicons/io/atcr/hold/layer.json
···11+{
22+ "lexicon": 1,
33+ "id": "io.atcr.hold.layer",
44+ "defs": {
55+ "main": {
66+ "type": "record",
77+ "key": "tid",
88+ "description": "Represents metadata about a container layer stored in the hold. Stored in the hold's embedded PDS for tracking and analytics.",
99+ "record": {
1010+ "type": "object",
1111+ "required": ["digest", "size", "mediaType", "repository", "userDid", "userHandle", "createdAt"],
1212+ "properties": {
1313+ "digest": {
1414+ "type": "string",
1515+ "description": "Layer digest (e.g., sha256:abc123...)"
1616+ },
1717+ "size": {
1818+ "type": "integer",
1919+ "description": "Size in bytes"
2020+ },
2121+ "mediaType": {
2222+ "type": "string",
2323+ "description": "Media type (e.g., application/vnd.oci.image.layer.v1.tar+gzip)"
2424+ },
2525+ "repository": {
2626+ "type": "string",
2727+ "description": "Repository this layer belongs to"
2828+ },
2929+ "userDid": {
3030+ "type": "string",
3131+ "format": "did",
3232+ "description": "DID of user who uploaded this layer"
3333+ },
3434+ "userHandle": {
3535+ "type": "string",
3636+ "format": "handle",
3737+ "description": "Handle of user (for display purposes)"
3838+ },
3939+ "createdAt": {
4040+ "type": "string",
4141+ "format": "datetime",
4242+ "description": "RFC3339 timestamp of when the layer was uploaded"
4343+ }
4444+ }
4545+ }
4646+ }
4747+ }
4848+}
-37
lexicons/io/atcr/hold.json
···11-{
22- "lexicon": 1,
33- "id": "io.atcr.hold",
44- "defs": {
55- "main": {
66- "type": "record",
77- "description": "Storage hold definition for Bring Your Own Storage (BYOS). Defines where blobs are stored.",
88- "key": "any",
99- "record": {
1010- "type": "object",
1111- "required": ["endpoint", "owner", "createdAt"],
1212- "properties": {
1313- "endpoint": {
1414- "type": "string",
1515- "format": "uri",
1616- "description": "URL of the hold service (e.g., 'https://hold1.example.com')"
1717- },
1818- "owner": {
1919- "type": "string",
2020- "format": "did",
2121- "description": "DID of the hold owner"
2222- },
2323- "public": {
2424- "type": "boolean",
2525- "description": "Whether this hold allows public blob reads (pulls) without authentication. Writes always require crew membership.",
2626- "default": false
2727- },
2828- "createdAt": {
2929- "type": "string",
3030- "format": "datetime",
3131- "description": "Hold creation timestamp"
3232- }
3333- }
3434- }
3535- }
3636- }
3737-}
+7-2
lexicons/io/atcr/manifest.json
···88 "key": "tid",
99 "record": {
1010 "type": "object",
1111- "required": ["repository", "digest", "mediaType", "schemaVersion", "holdEndpoint", "createdAt"],
1111+ "required": ["repository", "digest", "mediaType", "schemaVersion", "createdAt"],
1212 "properties": {
1313 "repository": {
1414 "type": "string",
···1919 "type": "string",
2020 "description": "Content digest (e.g., 'sha256:abc123...')"
2121 },
2222+ "holdDid": {
2323+ "type": "string",
2424+ "format": "did",
2525+ "description": "DID of the hold service where blobs are stored (e.g., 'did:web:hold01.atcr.io'). Primary reference for hold resolution."
2626+ },
2227 "holdEndpoint": {
2328 "type": "string",
2429 "format": "uri",
2525- "description": "Hold service endpoint where blobs are stored (e.g., 'https://hold1.bob.com'). Historical reference."
3030+ "description": "Hold service endpoint URL where blobs are stored. DEPRECATED: Use holdDid instead. Kept for backward compatibility."
2631 },
2732 "mediaType": {
2833 "type": "string",
+52-8
pkg/appview/config.go
···1313 "net/url"
1414 "os"
1515 "strconv"
1616+ "strings"
1617 "time"
17181819 "github.com/distribution/distribution/v3/configuration"
···20212122// Config represents the AppView service configuration
2223type Config struct {
2323- Version string `yaml:"version"`
2424- LogLevel string `yaml:"log_level"`
2525- Server ServerConfig `yaml:"server"`
2626- UI UIConfig `yaml:"ui"`
2727- Health HealthConfig `yaml:"health"`
2828- Jetstream JetstreamConfig `yaml:"jetstream"`
2929- Auth AuthConfig `yaml:"auth"`
3030- Distribution *configuration.Configuration `yaml:"-"` // Wrapped distribution config for compatibility
2424+ Version string `yaml:"version"`
2525+ LogLevel string `yaml:"log_level"`
2626+ Server ServerConfig `yaml:"server"`
2727+ UI UIConfig `yaml:"ui"`
2828+ Health HealthConfig `yaml:"health"`
2929+ Jetstream JetstreamConfig `yaml:"jetstream"`
3030+ Auth AuthConfig `yaml:"auth"`
3131+ CredentialHelper CredentialHelperConfig `yaml:"credential_helper"`
3232+ Distribution *configuration.Configuration `yaml:"-"` // Wrapped distribution config for compatibility
3133}
32343335// ServerConfig defines server settings
···113115 ServiceName string `yaml:"service_name"`
114116}
115117118118+// CredentialHelperConfig defines credential helper version and download settings
119119+type CredentialHelperConfig struct {
120120+ // Version is the latest credential helper version (from env: ATCR_CREDENTIAL_HELPER_VERSION)
121121+ // e.g., "v0.0.2"
122122+ Version string `yaml:"version"`
123123+124124+ // TangledRepo is the Tangled repository URL for downloads (from env: ATCR_CREDENTIAL_HELPER_TANGLED_REPO)
125125+ // Default: "https://tangled.org/@evan.jarrett.net/at-container-registry"
126126+ TangledRepo string `yaml:"tangled_repo"`
127127+128128+ // Checksums is a comma-separated list of platform:sha256 pairs (from env: ATCR_CREDENTIAL_HELPER_CHECKSUMS)
129129+ // e.g., "linux_amd64:abc123,darwin_arm64:def456"
130130+ Checksums map[string]string `yaml:"-"`
131131+}
132132+116133// LoadConfigFromEnv builds a complete configuration from environment variables
117134// This follows the same pattern as the hold service (no config files, only env vars)
118135func LoadConfigFromEnv() (*Config, error) {
···170187171188 // Derive service name from base URL or env var (used for JWT issuer and service)
172189 cfg.Auth.ServiceName = getServiceName(cfg.Server.BaseURL)
190190+191191+ // Credential helper configuration
192192+ cfg.CredentialHelper.Version = os.Getenv("ATCR_CREDENTIAL_HELPER_VERSION")
193193+ cfg.CredentialHelper.TangledRepo = getEnvOrDefault("ATCR_CREDENTIAL_HELPER_TANGLED_REPO", "https://tangled.org/@evan.jarrett.net/at-container-registry")
194194+ cfg.CredentialHelper.Checksums = parseChecksums(os.Getenv("ATCR_CREDENTIAL_HELPER_CHECKSUMS"))
173195174196 // Build distribution configuration for compatibility with distribution library
175197 distConfig, err := buildDistributionConfig(cfg)
···361383362384 return parsed
363385}
386386+387387+// parseChecksums parses a comma-separated list of platform:sha256 pairs
388388+// e.g., "linux_amd64:abc123,darwin_arm64:def456"
389389+func parseChecksums(checksumsStr string) map[string]string {
390390+ checksums := make(map[string]string)
391391+ if checksumsStr == "" {
392392+ return checksums
393393+ }
394394+395395+ pairs := strings.Split(checksumsStr, ",")
396396+ for _, pair := range pairs {
397397+ parts := strings.SplitN(strings.TrimSpace(pair), ":", 2)
398398+ if len(parts) == 2 {
399399+ platform := strings.TrimSpace(parts[0])
400400+ hash := strings.TrimSpace(parts[1])
401401+ if platform != "" && hash != "" {
402402+ checksums[platform] = hash
403403+ }
404404+ }
405405+ }
406406+ return checksums
407407+}
···11+description: Add is_attestation column to manifest_references table
22+query: |
33+ -- Add is_attestation column to track attestation manifests
44+ -- Attestation manifests have vnd.docker.reference.type = "attestation-manifest"
55+ ALTER TABLE manifest_references ADD COLUMN is_attestation BOOLEAN DEFAULT FALSE;
66+77+ -- Mark existing unknown/unknown platforms as attestations
88+ -- Docker BuildKit attestation manifests always have unknown/unknown platform
99+ UPDATE manifest_references
1010+ SET is_attestation = 1
1111+ WHERE platform_os = 'unknown' AND platform_architecture = 'unknown';
+8-6
pkg/appview/db/models.go
···4545 PlatformOS string
4646 PlatformVariant string
4747 PlatformOSVersion string
4848+ IsAttestation bool // true if vnd.docker.reference.type = "attestation-manifest"
4849 ReferenceIndex int
4950}
5051···154155// ManifestWithMetadata extends Manifest with tags and platform information
155156type ManifestWithMetadata struct {
156157 Manifest
157157- Tags []string
158158- Platforms []PlatformInfo
159159- PlatformCount int
160160- IsManifestList bool
161161- Reachable bool // Whether the hold endpoint is reachable
162162- Pending bool // Whether health check is still in progress
158158+ Tags []string
159159+ Platforms []PlatformInfo
160160+ PlatformCount int
161161+ IsManifestList bool
162162+ HasAttestations bool // true if manifest list contains attestation references
163163+ Reachable bool // Whether the hold endpoint is reachable
164164+ Pending bool // Whether health check is still in progress
163165}
+116
pkg/appview/db/oauth_store.go
···112112 return nil
113113}
114114115115+// DeleteOldSessionsForDID removes all sessions for a DID except the specified session to keep
116116+// This is used during OAuth callback to clean up stale sessions with expired refresh tokens
117117+func (s *OAuthStore) DeleteOldSessionsForDID(ctx context.Context, did string, keepSessionID string) error {
118118+ result, err := s.db.ExecContext(ctx, `
119119+ DELETE FROM oauth_sessions WHERE account_did = ? AND session_id != ?
120120+ `, did, keepSessionID)
121121+122122+ if err != nil {
123123+ return fmt.Errorf("failed to delete old sessions for DID: %w", err)
124124+ }
125125+126126+ deleted, _ := result.RowsAffected()
127127+ if deleted > 0 {
128128+ slog.Info("Deleted old OAuth sessions for DID", "count", deleted, "did", did, "kept", keepSessionID)
129129+ }
130130+131131+ return nil
132132+}
133133+115134// GetAuthRequestInfo retrieves authentication request data by state
116135func (s *OAuthStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) {
117136 var requestDataJSON string
···316335 }
317336318337 return true
338338+}
339339+340340+// GetSessionStats returns statistics about stored OAuth sessions
341341+// Useful for monitoring and debugging session health
342342+func (s *OAuthStore) GetSessionStats(ctx context.Context) (map[string]interface{}, error) {
343343+ stats := make(map[string]interface{})
344344+345345+ // Total sessions
346346+ var totalSessions int
347347+ err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM oauth_sessions`).Scan(&totalSessions)
348348+ if err != nil {
349349+ return nil, fmt.Errorf("failed to count sessions: %w", err)
350350+ }
351351+ stats["total_sessions"] = totalSessions
352352+353353+ // Sessions by age
354354+ var sessionsOlderThan1Hour, sessionsOlderThan1Day, sessionsOlderThan7Days int
355355+356356+ err = s.db.QueryRowContext(ctx, `
357357+ SELECT COUNT(*) FROM oauth_sessions
358358+ WHERE updated_at < datetime('now', '-1 hour')
359359+ `).Scan(&sessionsOlderThan1Hour)
360360+ if err == nil {
361361+ stats["sessions_idle_1h+"] = sessionsOlderThan1Hour
362362+ }
363363+364364+ err = s.db.QueryRowContext(ctx, `
365365+ SELECT COUNT(*) FROM oauth_sessions
366366+ WHERE updated_at < datetime('now', '-1 day')
367367+ `).Scan(&sessionsOlderThan1Day)
368368+ if err == nil {
369369+ stats["sessions_idle_1d+"] = sessionsOlderThan1Day
370370+ }
371371+372372+ err = s.db.QueryRowContext(ctx, `
373373+ SELECT COUNT(*) FROM oauth_sessions
374374+ WHERE updated_at < datetime('now', '-7 days')
375375+ `).Scan(&sessionsOlderThan7Days)
376376+ if err == nil {
377377+ stats["sessions_idle_7d+"] = sessionsOlderThan7Days
378378+ }
379379+380380+ // Recent sessions (updated in last 5 minutes)
381381+ var recentSessions int
382382+ err = s.db.QueryRowContext(ctx, `
383383+ SELECT COUNT(*) FROM oauth_sessions
384384+ WHERE updated_at > datetime('now', '-5 minutes')
385385+ `).Scan(&recentSessions)
386386+ if err == nil {
387387+ stats["sessions_active_5m"] = recentSessions
388388+ }
389389+390390+ return stats, nil
391391+}
392392+393393+// ListSessionsForMonitoring returns a list of all sessions with basic info for monitoring
394394+// Returns: DID, session age (minutes), last update time
395395+func (s *OAuthStore) ListSessionsForMonitoring(ctx context.Context) ([]map[string]interface{}, error) {
396396+ rows, err := s.db.QueryContext(ctx, `
397397+ SELECT
398398+ account_did,
399399+ session_id,
400400+ created_at,
401401+ updated_at,
402402+ CAST((julianday('now') - julianday(updated_at)) * 24 * 60 AS INTEGER) as idle_minutes
403403+ FROM oauth_sessions
404404+ ORDER BY updated_at DESC
405405+ `)
406406+ if err != nil {
407407+ return nil, fmt.Errorf("failed to query sessions: %w", err)
408408+ }
409409+ defer rows.Close()
410410+411411+ var sessions []map[string]interface{}
412412+ for rows.Next() {
413413+ var did, sessionID, createdAt, updatedAt string
414414+ var idleMinutes int
415415+416416+ if err := rows.Scan(&did, &sessionID, &createdAt, &updatedAt, &idleMinutes); err != nil {
417417+ slog.Warn("Failed to scan session row", "error", err)
418418+ continue
419419+ }
420420+421421+ sessions = append(sessions, map[string]interface{}{
422422+ "did": did,
423423+ "session_id": sessionID,
424424+ "created_at": createdAt,
425425+ "updated_at": updatedAt,
426426+ "idle_minutes": idleMinutes,
427427+ })
428428+ }
429429+430430+ if err := rows.Err(); err != nil {
431431+ return nil, fmt.Errorf("error iterating sessions: %w", err)
432432+ }
433433+434434+ return sessions, nil
319435}
320436321437// makeSessionKey creates a composite key for session storage
+54-7
pkg/appview/db/queries.go
···724724 return &m, nil
725725}
726726727727+// GetLatestHoldDIDForRepo returns the hold DID from the most recent manifest for a repository
728728+// Returns empty string if no manifests exist (e.g., first push)
729729+// This is used instead of the in-memory cache to determine which hold to use for blob operations
730730+func GetLatestHoldDIDForRepo(db *sql.DB, did, repository string) (string, error) {
731731+ var holdDID string
732732+ err := db.QueryRow(`
733733+ SELECT hold_endpoint
734734+ FROM manifests
735735+ WHERE did = ? AND repository = ?
736736+ ORDER BY created_at DESC
737737+ LIMIT 1
738738+ `, did, repository).Scan(&holdDID)
739739+740740+ if err == sql.ErrNoRows {
741741+ // No manifests yet - return empty string (first push case)
742742+ return "", nil
743743+ }
744744+ if err != nil {
745745+ return "", err
746746+ }
747747+748748+ return holdDID, nil
749749+}
750750+727751// GetRepositoriesForDID returns all unique repository names for a DID
728752// Used by backfill to reconcile annotations for all repositories
729753func GetRepositoriesForDID(db *sql.DB, did string) ([]string, error) {
···780804 INSERT INTO manifest_references (manifest_id, digest, size, media_type,
781805 platform_architecture, platform_os,
782806 platform_variant, platform_os_version,
783783- reference_index)
784784- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
807807+ is_attestation, reference_index)
808808+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
785809 `, ref.ManifestID, ref.Digest, ref.Size, ref.MediaType,
786810 ref.PlatformArchitecture, ref.PlatformOS,
787811 ref.PlatformVariant, ref.PlatformOSVersion,
788788- ref.ReferenceIndex)
812812+ ref.IsAttestation, ref.ReferenceIndex)
789813 return err
790814}
791815···916940 mr.platform_os,
917941 mr.platform_architecture,
918942 mr.platform_variant,
919919- mr.platform_os_version
943943+ mr.platform_os_version,
944944+ COALESCE(mr.is_attestation, 0) as is_attestation
920945 FROM manifest_references mr
921946 WHERE mr.manifest_id = ?
922947 ORDER BY mr.reference_index
···930955 for platformRows.Next() {
931956 var p PlatformInfo
932957 var os, arch, variant, osVersion sql.NullString
958958+ var isAttestation bool
933959934934- if err := platformRows.Scan(&os, &arch, &variant, &osVersion); err != nil {
960960+ if err := platformRows.Scan(&os, &arch, &variant, &osVersion, &isAttestation); err != nil {
935961 platformRows.Close()
936962 return nil, err
937963 }
938964965965+ // Track if manifest list has attestations
966966+ if isAttestation {
967967+ manifests[i].HasAttestations = true
968968+ // Skip attestation references in platform display
969969+ continue
970970+ }
971971+939972 if os.Valid {
940973 p.OS = os.String
941974 }
···10151048 mr.platform_os,
10161049 mr.platform_architecture,
10171050 mr.platform_variant,
10181018- mr.platform_os_version
10511051+ mr.platform_os_version,
10521052+ COALESCE(mr.is_attestation, 0) as is_attestation
10191053 FROM manifest_references mr
10201054 WHERE mr.manifest_id = ?
10211055 ORDER BY mr.reference_index
···10301064 for platforms.Next() {
10311065 var p PlatformInfo
10321066 var os, arch, variant, osVersion sql.NullString
10671067+ var isAttestation bool
1033106810341034- if err := platforms.Scan(&os, &arch, &variant, &osVersion); err != nil {
10691069+ if err := platforms.Scan(&os, &arch, &variant, &osVersion, &isAttestation); err != nil {
10351070 return nil, err
10711071+ }
10721072+10731073+ // Track if manifest list has attestations
10741074+ if isAttestation {
10751075+ m.HasAttestations = true
10761076+ // Skip attestation references in platform display
10771077+ continue
10361078 }
1037107910381080 if os.Valid {
···15741616// IncrementPushCount increments the push count for a repository
15751617func (m *MetricsDB) IncrementPushCount(did, repository string) error {
15761618 return IncrementPushCount(m.db, did, repository)
16191619+}
16201620+16211621+// GetLatestHoldDIDForRepo returns the hold DID from the most recent manifest for a repository
16221622+func (m *MetricsDB) GetLatestHoldDIDForRepo(did, repository string) (string, error) {
16231623+ return GetLatestHoldDIDForRepo(m.db, did, repository)
15771624}
1578162515791626// GetFeaturedRepositories fetches top repositories sorted by stars and pulls
+57-4
pkg/appview/db/schema.go
···8686 continue
8787 }
88888989- // Apply migration
8989+ // Apply migration in a transaction
9090 slog.Info("Applying migration", "version", m.Version, "name", m.Name, "description", m.Description)
9191- if _, err := db.Exec(m.Query); err != nil {
9292- return fmt.Errorf("failed to apply migration %d (%s): %w", m.Version, m.Name, err)
9191+9292+ tx, err := db.Begin()
9393+ if err != nil {
9494+ return fmt.Errorf("failed to begin transaction for migration %d: %w", m.Version, err)
9595+ }
9696+9797+ // Split query into individual statements and execute each
9898+ // go-sqlite3's Exec() doesn't reliably execute all statements in multi-statement queries
9999+ statements := splitSQLStatements(m.Query)
100100+ for i, stmt := range statements {
101101+ if _, err := tx.Exec(stmt); err != nil {
102102+ tx.Rollback()
103103+ return fmt.Errorf("failed to apply migration %d (%s) statement %d: %w", m.Version, m.Name, i+1, err)
104104+ }
93105 }
9410695107 // Record migration
9696- if _, err := db.Exec("INSERT INTO schema_migrations (version) VALUES (?)", m.Version); err != nil {
108108+ if _, err := tx.Exec("INSERT INTO schema_migrations (version) VALUES (?)", m.Version); err != nil {
109109+ tx.Rollback()
97110 return fmt.Errorf("failed to record migration %d: %w", m.Version, err)
111111+ }
112112+113113+ if err := tx.Commit(); err != nil {
114114+ return fmt.Errorf("failed to commit migration %d: %w", m.Version, err)
98115 }
99116100117 slog.Info("Migration applied successfully", "version", m.Version)
···144161 }
145162146163 return migrations, nil
164164+}
165165+166166+// splitSQLStatements splits a SQL query into individual statements.
167167+// It handles semicolons as statement separators and filters out empty statements.
168168+func splitSQLStatements(query string) []string {
169169+ var statements []string
170170+171171+ // Split on semicolons
172172+ parts := strings.Split(query, ";")
173173+174174+ for _, part := range parts {
175175+ // Trim whitespace
176176+ stmt := strings.TrimSpace(part)
177177+178178+ // Skip empty statements (could be trailing semicolon or comment-only)
179179+ if stmt == "" {
180180+ continue
181181+ }
182182+183183+ // Skip comment-only statements
184184+ lines := strings.Split(stmt, "\n")
185185+ hasCode := false
186186+ for _, line := range lines {
187187+ trimmed := strings.TrimSpace(line)
188188+ if trimmed != "" && !strings.HasPrefix(trimmed, "--") {
189189+ hasCode = true
190190+ break
191191+ }
192192+ }
193193+194194+ if hasCode {
195195+ statements = append(statements, stmt)
196196+ }
197197+ }
198198+199199+ return statements
147200}
148201149202// parseMigrationFilename extracts version and name from migration filename
···2233import (
44 "context"
55- "encoding/json"
65 "fmt"
76 "log/slog"
77+ "net/http"
88 "strings"
99 "sync"
1010+ "time"
10111112 "github.com/distribution/distribution/v3"
1213 "github.com/distribution/distribution/v3/registry/api/errcode"
···2425// holdDIDKey is the context key for storing hold DID
2526const holdDIDKey contextKey = "hold.did"
26272828+// authMethodKey is the context key for storing auth method from JWT
2929+const authMethodKey contextKey = "auth.method"
3030+3131+// validationCacheEntry stores a validated service token with expiration
3232+type validationCacheEntry struct {
3333+ serviceToken string
3434+ validUntil time.Time
3535+ err error // Cached error for fast-fail
3636+ mu sync.Mutex // Per-entry lock to serialize cache population
3737+ inFlight bool // True if another goroutine is fetching the token
3838+ done chan struct{} // Closed when fetch completes
3939+}
4040+4141+// validationCache provides request-level caching for service tokens
4242+// This prevents concurrent layer uploads from racing on OAuth/DPoP requests
4343+type validationCache struct {
4444+ mu sync.RWMutex
4545+ entries map[string]*validationCacheEntry // key: "did:holdDID"
4646+}
4747+4848+// newValidationCache creates a new validation cache
4949+func newValidationCache() *validationCache {
5050+ return &validationCache{
5151+ entries: make(map[string]*validationCacheEntry),
5252+ }
5353+}
5454+5555+// getOrFetch retrieves a service token from cache or fetches it
5656+// Multiple concurrent requests for the same DID:holdDID will share the fetch operation
5757+func (vc *validationCache) getOrFetch(ctx context.Context, cacheKey string, fetchFunc func() (string, error)) (string, error) {
5858+ // Fast path: check cache with read lock
5959+ vc.mu.RLock()
6060+ entry, exists := vc.entries[cacheKey]
6161+ vc.mu.RUnlock()
6262+6363+ if exists {
6464+ // Entry exists, check if it's still valid
6565+ entry.mu.Lock()
6666+6767+ // If another goroutine is fetching, wait for it
6868+ if entry.inFlight {
6969+ done := entry.done
7070+ entry.mu.Unlock()
7171+7272+ select {
7373+ case <-done:
7474+ // Fetch completed, check result
7575+ entry.mu.Lock()
7676+ defer entry.mu.Unlock()
7777+7878+ if entry.err != nil {
7979+ return "", entry.err
8080+ }
8181+ if time.Now().Before(entry.validUntil) {
8282+ return entry.serviceToken, nil
8383+ }
8484+ // Fall through to refetch
8585+ case <-ctx.Done():
8686+ return "", ctx.Err()
8787+ }
8888+ } else {
8989+ // Check if cached token is still valid
9090+ if entry.err != nil && time.Now().Before(entry.validUntil) {
9191+ // Return cached error (fast-fail)
9292+ entry.mu.Unlock()
9393+ return "", entry.err
9494+ }
9595+ if entry.err == nil && time.Now().Before(entry.validUntil) {
9696+ // Return cached token
9797+ token := entry.serviceToken
9898+ entry.mu.Unlock()
9999+ return token, nil
100100+ }
101101+ entry.mu.Unlock()
102102+ }
103103+ }
104104+105105+ // Slow path: need to fetch token
106106+ vc.mu.Lock()
107107+ entry, exists = vc.entries[cacheKey]
108108+ if !exists {
109109+ // Create new entry
110110+ entry = &validationCacheEntry{
111111+ inFlight: true,
112112+ done: make(chan struct{}),
113113+ }
114114+ vc.entries[cacheKey] = entry
115115+ }
116116+ vc.mu.Unlock()
117117+118118+ // Lock the entry to perform fetch
119119+ entry.mu.Lock()
120120+121121+ // Double-check: another goroutine may have fetched while we waited
122122+ if !entry.inFlight {
123123+ if entry.err != nil && time.Now().Before(entry.validUntil) {
124124+ err := entry.err
125125+ entry.mu.Unlock()
126126+ return "", err
127127+ }
128128+ if entry.err == nil && time.Now().Before(entry.validUntil) {
129129+ token := entry.serviceToken
130130+ entry.mu.Unlock()
131131+ return token, nil
132132+ }
133133+ }
134134+135135+ // Mark as in-flight and create fresh done channel for this fetch
136136+ // IMPORTANT: Always create a new channel - a closed channel is not nil
137137+ entry.done = make(chan struct{})
138138+ entry.inFlight = true
139139+ done := entry.done
140140+ entry.mu.Unlock()
141141+142142+ // Perform the fetch (outside the lock to allow other operations)
143143+ serviceToken, err := fetchFunc()
144144+145145+ // Update the entry with result
146146+ entry.mu.Lock()
147147+ entry.inFlight = false
148148+149149+ if err != nil {
150150+ // Cache errors for 5 seconds (fast-fail for subsequent requests)
151151+ entry.err = err
152152+ entry.validUntil = time.Now().Add(5 * time.Second)
153153+ entry.serviceToken = ""
154154+ } else {
155155+ // Cache token for 45 seconds (covers typical Docker push operation)
156156+ entry.err = nil
157157+ entry.serviceToken = serviceToken
158158+ entry.validUntil = time.Now().Add(45 * time.Second)
159159+ }
160160+161161+ // Signal completion to waiting goroutines
162162+ close(done)
163163+ entry.mu.Unlock()
164164+165165+ return serviceToken, err
166166+}
167167+27168// Global variables for initialization only
28169// These are set by main.go during startup and copied into NamespaceResolver instances.
29170// After initialization, request handling uses the NamespaceResolver's instance fields.
···66207// NamespaceResolver wraps a namespace and resolves names
67208type NamespaceResolver struct {
68209 distribution.Namespace
6969- defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io")
7070- baseURL string // Base URL for error messages (e.g., "https://atcr.io")
7171- testMode bool // If true, fallback to default hold when user's hold is unreachable
7272- repositories sync.Map // Cache of RoutingRepository instances by key (did:reponame)
7373- refresher *oauth.Refresher // OAuth session manager (copied from global on init)
7474- database storage.DatabaseMetrics // Metrics database (copied from global on init)
7575- authorizer auth.HoldAuthorizer // Hold authorization (copied from global on init)
7676- readmeCache storage.ReadmeCache // README cache (copied from global on init)
210210+ defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io")
211211+ baseURL string // Base URL for error messages (e.g., "https://atcr.io")
212212+ testMode bool // If true, fallback to default hold when user's hold is unreachable
213213+ refresher *oauth.Refresher // OAuth session manager (copied from global on init)
214214+ database storage.DatabaseMetrics // Metrics database (copied from global on init)
215215+ authorizer auth.HoldAuthorizer // Hold authorization (copied from global on init)
216216+ readmeCache storage.ReadmeCache // README cache (copied from global on init)
217217+ validationCache *validationCache // Request-level service token cache
77218}
7821979220// initATProtoResolver initializes the name resolution middleware
···100241 // Copy shared services from globals into the instance
101242 // This avoids accessing globals during request handling
102243 return &NamespaceResolver{
103103- Namespace: ns,
104104- defaultHoldDID: defaultHoldDID,
105105- baseURL: baseURL,
106106- testMode: testMode,
107107- refresher: globalRefresher,
108108- database: globalDatabase,
109109- authorizer: globalAuthorizer,
110110- readmeCache: globalReadmeCache,
244244+ Namespace: ns,
245245+ defaultHoldDID: defaultHoldDID,
246246+ baseURL: baseURL,
247247+ testMode: testMode,
248248+ refresher: globalRefresher,
249249+ database: globalDatabase,
250250+ authorizer: globalAuthorizer,
251251+ readmeCache: globalReadmeCache,
252252+ validationCache: newValidationCache(),
111253 }, nil
112254}
113255···163305 }(ctx, client, nr.refresher, holdDID)
164306 }
165307166166- // Get service token for hold authentication
308308+ // Get service token for hold authentication (only if authenticated)
309309+ // Use validation cache to prevent concurrent requests from racing on OAuth/DPoP
310310+ // Route based on auth method from JWT token
167311 var serviceToken string
168168- if nr.refresher != nil {
169169- var err error
170170- serviceToken, err = token.GetOrFetchServiceToken(ctx, nr.refresher, did, holdDID, pdsEndpoint)
171171- if err != nil {
172172- slog.Error("Failed to get service token", "component", "registry/middleware", "did", did, "error", err)
173173- slog.Error("User needs to re-authenticate via credential helper", "component", "registry/middleware")
174174- return nil, nr.authErrorMessage("OAuth session expired")
312312+ authMethod, _ := ctx.Value(authMethodKey).(string)
313313+314314+ // Only fetch service token if user is authenticated
315315+ // Unauthenticated requests (like /v2/ ping) should not trigger token fetching
316316+ if authMethod != "" {
317317+ // Create cache key: "did:holdDID"
318318+ cacheKey := fmt.Sprintf("%s:%s", did, holdDID)
319319+320320+ // Fetch service token through validation cache
321321+ // This ensures only ONE request per DID:holdDID pair fetches the token
322322+ // Concurrent requests will wait for the first request to complete
323323+ var fetchErr error
324324+ serviceToken, fetchErr = nr.validationCache.getOrFetch(ctx, cacheKey, func() (string, error) {
325325+ if authMethod == token.AuthMethodAppPassword {
326326+ // App-password flow: use Bearer token authentication
327327+ slog.Debug("Using app-password flow for service token",
328328+ "component", "registry/middleware",
329329+ "did", did,
330330+ "cacheKey", cacheKey)
331331+332332+ token, err := token.GetOrFetchServiceTokenWithAppPassword(ctx, did, holdDID, pdsEndpoint)
333333+ if err != nil {
334334+ slog.Error("Failed to get service token with app-password",
335335+ "component", "registry/middleware",
336336+ "did", did,
337337+ "holdDID", holdDID,
338338+ "pdsEndpoint", pdsEndpoint,
339339+ "error", err)
340340+ return "", err
341341+ }
342342+ return token, nil
343343+ } else if nr.refresher != nil {
344344+ // OAuth flow: use DPoP authentication
345345+ slog.Debug("Using OAuth flow for service token",
346346+ "component", "registry/middleware",
347347+ "did", did,
348348+ "cacheKey", cacheKey)
349349+350350+ token, err := token.GetOrFetchServiceToken(ctx, nr.refresher, did, holdDID, pdsEndpoint)
351351+ if err != nil {
352352+ slog.Error("Failed to get service token with OAuth",
353353+ "component", "registry/middleware",
354354+ "did", did,
355355+ "holdDID", holdDID,
356356+ "pdsEndpoint", pdsEndpoint,
357357+ "error", err)
358358+ return "", err
359359+ }
360360+ return token, nil
361361+ }
362362+ return "", fmt.Errorf("no authentication method available")
363363+ })
364364+365365+ // Handle errors from cached fetch
366366+ if fetchErr != nil {
367367+ errMsg := fetchErr.Error()
368368+369369+ // Check for app-password specific errors
370370+ if authMethod == token.AuthMethodAppPassword {
371371+ if strings.Contains(errMsg, "expired or invalid") || strings.Contains(errMsg, "no app-password") {
372372+ return nil, nr.authErrorMessage("App-password authentication failed. Please re-authenticate with: docker login")
373373+ }
374374+ }
375375+376376+ // Check for OAuth specific errors
377377+ if strings.Contains(errMsg, "OAuth session") || strings.Contains(errMsg, "OAuth validation") {
378378+ return nil, nr.authErrorMessage("OAuth session expired or invalidated by PDS. Your session has been cleared")
379379+ }
380380+381381+ // Generic service token error
382382+ return nil, nr.authErrorMessage(fmt.Sprintf("Failed to obtain storage credentials: %v", fetchErr))
175383 }
384384+ } else {
385385+ slog.Debug("Skipping service token fetch for unauthenticated request",
386386+ "component", "registry/middleware",
387387+ "did", did)
176388 }
177389178390 // Create a new reference with identity/image format
···191403 }
192404193405 // Get access token for PDS operations
194194- // Try OAuth refresher first (for users who authorized via AppView OAuth)
195195- // Fall back to Basic Auth token cache (for users who used app passwords)
406406+ // Use auth method from JWT to determine client type:
407407+ // - OAuth users: use session provider (DPoP-enabled)
408408+ // - App-password users: use Basic Auth token cache
196409 var atprotoClient *atproto.Client
197410198198- if nr.refresher != nil {
199199- // Try OAuth flow first
200200- session, err := nr.refresher.GetSession(ctx, did)
201201- if err == nil {
202202- // OAuth session available - use indigo's API client (handles DPoP automatically)
203203- apiClient := session.APIClient()
204204- atprotoClient = atproto.NewClientWithIndigoClient(pdsEndpoint, did, apiClient)
205205- } else {
206206- slog.Debug("OAuth refresh failed, falling back to Basic Auth", "component", "registry/middleware", "did", did, "error", err)
207207- }
208208- }
209209-210210- // Fall back to Basic Auth token cache if OAuth not available
211211- if atprotoClient == nil {
411411+ if authMethod == token.AuthMethodOAuth && nr.refresher != nil {
412412+ // OAuth flow: use session provider for locked OAuth sessions
413413+ // This prevents DPoP nonce race conditions during concurrent layer uploads
414414+ slog.Debug("Creating ATProto client with OAuth session provider",
415415+ "component", "registry/middleware",
416416+ "did", did,
417417+ "authMethod", authMethod)
418418+ atprotoClient = atproto.NewClientWithSessionProvider(pdsEndpoint, did, nr.refresher)
419419+ } else {
420420+ // App-password flow (or fallback): use Basic Auth token cache
212421 accessToken, ok := auth.GetGlobalTokenCache().Get(did)
213422 if !ok {
214214- slog.Debug("No cached access token found (neither OAuth nor Basic Auth)", "component", "registry/middleware", "did", did)
423423+ slog.Debug("No cached access token found for app-password auth",
424424+ "component", "registry/middleware",
425425+ "did", did,
426426+ "authMethod", authMethod)
215427 accessToken = "" // Will fail on manifest push, but let it try
216428 } else {
217217- slog.Debug("Using Basic Auth access token", "component", "registry/middleware", "did", did, "token_length", len(accessToken))
429429+ slog.Debug("Creating ATProto client with app-password",
430430+ "component", "registry/middleware",
431431+ "did", did,
432432+ "authMethod", authMethod,
433433+ "token_length", len(accessToken))
218434 }
219435 atprotoClient = atproto.NewClient(pdsEndpoint, did, accessToken)
220436 }
···224440 // Example: "evan.jarrett.net/debian" -> store as "debian"
225441 repositoryName := imageName
226442227227- // Cache key is DID + repository name
228228- cacheKey := did + ":" + repositoryName
229229-230230- // Check cache first and update service token
231231- if cached, ok := nr.repositories.Load(cacheKey); ok {
232232- cachedRepo := cached.(*storage.RoutingRepository)
233233- // Always update the service token even for cached repos (token may have been renewed)
234234- cachedRepo.Ctx.ServiceToken = serviceToken
235235- return cachedRepo, nil
443443+ // Default auth method to OAuth if not already set (backward compatibility with old tokens)
444444+ if authMethod == "" {
445445+ authMethod = token.AuthMethodOAuth
236446 }
237447238448 // Create routing repository - routes manifests to ATProto, blobs to hold service
239449 // The registry is stateless - no local storage is used
240450 // Bundle all context into a single RegistryContext struct
451451+ //
452452+ // NOTE: We create a fresh RoutingRepository on every request (no caching) because:
453453+ // 1. Each layer upload is a separate HTTP request (possibly different process)
454454+ // 2. OAuth sessions can be refreshed/invalidated between requests
455455+ // 3. The refresher already caches sessions efficiently (in-memory + DB)
456456+ // 4. Caching the repository with a stale ATProtoClient causes refresh token errors
241457 registryCtx := &storage.RegistryContext{
242458 DID: did,
243459 Handle: handle,
···246462 Repository: repositoryName,
247463 ServiceToken: serviceToken, // Cached service token from middleware validation
248464 ATProtoClient: atprotoClient,
465465+ AuthMethod: authMethod, // Auth method from JWT token
249466 Database: nr.database,
250467 Authorizer: nr.authorizer,
251468 Refresher: nr.refresher,
252469 ReadmeCache: nr.readmeCache,
253470 }
254254- routingRepo := storage.NewRoutingRepository(repo, registryCtx)
255471256256- // Cache the repository
257257- nr.repositories.Store(cacheKey, routingRepo)
258258-259259- return routingRepo, nil
472472+ return storage.NewRoutingRepository(repo, registryCtx), nil
260473}
261474262475// Repositories delegates to underlying namespace
···291504 slog.Warn("Failed to read profile", "did", did, "error", err)
292505 }
293506294294- if profile != nil && profile.DefaultHold != "" {
507507+ if profile != nil && profile.DefaultHold != nil && *profile.DefaultHold != "" {
508508+ defaultHold := *profile.DefaultHold
295509 // Profile exists with defaultHold set
296510 // In test mode, verify it's reachable before using it
297511 if nr.testMode {
298298- if nr.isHoldReachable(ctx, profile.DefaultHold) {
299299- return profile.DefaultHold
512512+ if nr.isHoldReachable(ctx, defaultHold) {
513513+ return defaultHold
300514 }
301301- slog.Debug("User's defaultHold unreachable, falling back to default", "component", "registry/middleware/testmode", "default_hold", profile.DefaultHold)
515515+ slog.Debug("User's defaultHold unreachable, falling back to default", "component", "registry/middleware/testmode", "default_hold", defaultHold)
302516 return nr.defaultHoldDID
303517 }
304304- return profile.DefaultHold
518518+ return defaultHold
305519 }
306520307521 // Profile doesn't exist or defaultHold is null/empty
308308- // Check for user's own hold records
309309- records, err := client.ListRecords(ctx, atproto.HoldCollection, 10)
310310- if err != nil {
311311- // Failed to query holds, use default
312312- return nr.defaultHoldDID
313313- }
314314-315315- // Find the first hold record
316316- for _, record := range records {
317317- var holdRecord atproto.HoldRecord
318318- if err := json.Unmarshal(record.Value, &holdRecord); err != nil {
319319- continue
320320- }
321321-322322- // Return the endpoint from the first hold (normalize to DID if URL)
323323- if holdRecord.Endpoint != "" {
324324- return atproto.ResolveHoldDIDFromURL(holdRecord.Endpoint)
325325- }
326326- }
327327-328328- // No profile defaultHold and no own hold records - use AppView default
522522+ // Legacy io.atcr.hold records are no longer supported - use AppView default
329523 return nr.defaultHoldDID
330524}
331525···347541348542 return false
349543}
544544+545545+// ExtractAuthMethod is an HTTP middleware that extracts the auth method from the JWT Authorization header
546546+// and stores it in the request context for later use by the registry middleware
547547+func ExtractAuthMethod(next http.Handler) http.Handler {
548548+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
549549+ // Extract Authorization header
550550+ authHeader := r.Header.Get("Authorization")
551551+ if authHeader != "" {
552552+ // Parse "Bearer <token>" format
553553+ parts := strings.SplitN(authHeader, " ", 2)
554554+ if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" {
555555+ tokenString := parts[1]
556556+557557+ // Extract auth method from JWT (does not validate - just parses)
558558+ authMethod := token.ExtractAuthMethod(tokenString)
559559+ if authMethod != "" {
560560+ // Store in context for registry middleware
561561+ ctx := context.WithValue(r.Context(), authMethodKey, authMethod)
562562+ r = r.WithContext(ctx)
563563+ slog.Debug("Extracted auth method from JWT",
564564+ "component", "registry/middleware",
565565+ "authMethod", authMethod)
566566+ }
567567+ }
568568+ }
569569+570570+ next.ServeHTTP(w, r)
571571+ })
572572+}
+8-37
pkg/appview/middleware/registry_test.go
···204204 assert.Equal(t, "did:web:user.hold.io", holdDID, "should use sailor profile's defaultHold")
205205}
206206207207-// TestFindHoldDID_LegacyHoldRecords tests legacy hold record discovery
208208-func TestFindHoldDID_LegacyHoldRecords(t *testing.T) {
209209- // Start a mock PDS server that returns hold records
207207+// TestFindHoldDID_NoProfile tests fallback to default hold when no profile exists
208208+func TestFindHoldDID_NoProfile(t *testing.T) {
209209+ // Start a mock PDS server that returns 404 for profile
210210 mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
211211 if r.URL.Path == "/xrpc/com.atproto.repo.getRecord" {
212212 // Profile not found
213213 w.WriteHeader(http.StatusNotFound)
214214 return
215215 }
216216- if r.URL.Path == "/xrpc/com.atproto.repo.listRecords" {
217217- // Return hold record
218218- holdRecord := atproto.NewHoldRecord("https://legacy.hold.io", "alice", true)
219219- recordJSON, _ := json.Marshal(holdRecord)
220220- w.Header().Set("Content-Type", "application/json")
221221- json.NewEncoder(w).Encode(map[string]any{
222222- "records": []any{
223223- map[string]any{
224224- "uri": "at://did:plc:test123/io.atcr.hold/abc123",
225225- "value": json.RawMessage(recordJSON),
226226- },
227227- },
228228- })
229229- return
230230- }
231216 w.WriteHeader(http.StatusNotFound)
232217 }))
233218 defer mockPDS.Close()
···239224 ctx := context.Background()
240225 holdDID := resolver.findHoldDID(ctx, "did:plc:test123", mockPDS.URL)
241226242242- // Legacy URL should be converted to DID
243243- assert.Equal(t, "did:web:legacy.hold.io", holdDID, "should use legacy hold record and convert to DID")
227227+ // Should fall back to default hold DID when no profile exists
228228+ // Note: Legacy io.atcr.hold records are no longer supported
229229+ assert.Equal(t, "did:web:default.atcr.io", holdDID, "should fall back to default hold DID")
244230}
245231246246-// TestFindHoldDID_Priority tests the priority order
232232+// TestFindHoldDID_Priority tests that profile takes priority over default
247233func TestFindHoldDID_Priority(t *testing.T) {
248248- // Start a mock PDS server that returns both profile and hold records
234234+ // Start a mock PDS server that returns profile
249235 mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
250236 if r.URL.Path == "/xrpc/com.atproto.repo.getRecord" {
251237 // Return sailor profile with defaultHold (highest priority)
···253239 w.Header().Set("Content-Type", "application/json")
254240 json.NewEncoder(w).Encode(map[string]any{
255241 "value": profile,
256256- })
257257- return
258258- }
259259- if r.URL.Path == "/xrpc/com.atproto.repo.listRecords" {
260260- // Return hold record (should be ignored since profile exists)
261261- holdRecord := atproto.NewHoldRecord("https://legacy.hold.io", "alice", true)
262262- recordJSON, _ := json.Marshal(holdRecord)
263263- w.Header().Set("Content-Type", "application/json")
264264- json.NewEncoder(w).Encode(map[string]any{
265265- "records": []any{
266266- map[string]any{
267267- "uri": "at://did:plc:test123/io.atcr.hold/abc123",
268268- "value": json.RawMessage(recordJSON),
269269- },
270270- },
271242 })
272243 return
273244 }
+413
pkg/appview/ogcard/card.go
···11+// Package ogcard provides OpenGraph card image generation for ATCR.
22+package ogcard
33+44+import (
55+ "image"
66+ "image/color"
77+ "image/draw"
88+ _ "image/gif" // Register GIF decoder for image.Decode
99+ _ "image/jpeg" // Register JPEG decoder for image.Decode
1010+ "image/png"
1111+ "io"
1212+ "net/http"
1313+ "time"
1414+1515+ "github.com/goki/freetype"
1616+ "github.com/goki/freetype/truetype"
1717+ xdraw "golang.org/x/image/draw"
1818+ "golang.org/x/image/font"
1919+ _ "golang.org/x/image/webp" // Register WEBP decoder for image.Decode
2020+)
2121+2222+// Text alignment constants
2323+const (
2424+ AlignLeft = iota
2525+ AlignCenter
2626+ AlignRight
2727+)
2828+2929+// Layout constants for OG cards
3030+const (
3131+ // Card dimensions
3232+ CardWidth = 1200
3333+ CardHeight = 630
3434+3535+ // Padding and sizing
3636+ Padding = 60
3737+ AvatarSize = 180
3838+3939+ // Positioning offsets
4040+ IconTopOffset = 50 // Y offset from padding for icon
4141+ TextGapAfterIcon = 40 // X gap between icon and text
4242+ TextTopOffset = 50 // Y offset from icon top for text baseline
4343+4444+ // Font sizes
4545+ FontTitle = 48.0
4646+ FontDescription = 32.0
4747+ FontStats = 40.0 // Larger for visibility when scaled down
4848+ FontBadge = 32.0 // Larger for visibility when scaled down
4949+ FontBranding = 28.0
5050+5151+ // Spacing
5252+ LineSpacingLarge = 65 // Gap after title
5353+ LineSpacingSmall = 60 // Gap between description lines
5454+ StatsIconGap = 48 // Gap between stat icon and text
5555+ StatsItemGap = 60 // Gap between stat items
5656+ BadgeGap = 20 // Gap between badges
5757+)
5858+5959+// Layout holds computed positions for a standard OG card layout
6060+type Layout struct {
6161+ IconX int
6262+ IconY int
6363+ TextX float64
6464+ TextY float64
6565+ StatsY int
6666+ MaxWidth int // For text wrapping
6767+}
6868+6969+// StandardLayout returns the standard OG card layout with computed positions
7070+func StandardLayout() Layout {
7171+ iconX := Padding
7272+ iconY := Padding + IconTopOffset
7373+ textX := float64(iconX + AvatarSize + TextGapAfterIcon)
7474+ textY := float64(iconY + TextTopOffset)
7575+ statsY := CardHeight - Padding - 10
7676+ maxWidth := CardWidth - int(textX) - Padding
7777+7878+ return Layout{
7979+ IconX: iconX,
8080+ IconY: iconY,
8181+ TextX: textX,
8282+ TextY: textY,
8383+ StatsY: statsY,
8484+ MaxWidth: maxWidth,
8585+ }
8686+}
8787+8888+// Card represents an OG image canvas
8989+type Card struct {
9090+ img *image.RGBA
9191+ width int
9292+ height int
9393+}
9494+9595+// NewCard creates a new OG card with the standard 1200x630 dimensions
9696+func NewCard() *Card {
9797+ return NewCardWithSize(1200, 630)
9898+}
9999+100100+// NewCardWithSize creates a new OG card with custom dimensions
101101+func NewCardWithSize(width, height int) *Card {
102102+ img := image.NewRGBA(image.Rect(0, 0, width, height))
103103+ return &Card{
104104+ img: img,
105105+ width: width,
106106+ height: height,
107107+ }
108108+}
109109+110110+// Fill fills the entire card with a solid color
111111+func (c *Card) Fill(col color.Color) {
112112+ draw.Draw(c.img, c.img.Bounds(), &image.Uniform{col}, image.Point{}, draw.Src)
113113+}
114114+115115+// DrawRect draws a filled rectangle
116116+func (c *Card) DrawRect(x, y, w, h int, col color.Color) {
117117+ rect := image.Rect(x, y, x+w, y+h)
118118+ draw.Draw(c.img, rect, &image.Uniform{col}, image.Point{}, draw.Over)
119119+}
120120+121121+// DrawText draws text at the specified position
122122+func (c *Card) DrawText(text string, x, y float64, size float64, col color.Color, align int, bold bool) error {
123123+ f := regularFont
124124+ if bold {
125125+ f = boldFont
126126+ }
127127+ if f == nil {
128128+ return nil // No font loaded
129129+ }
130130+131131+ ctx := freetype.NewContext()
132132+ ctx.SetDPI(72)
133133+ ctx.SetFont(f)
134134+ ctx.SetFontSize(size)
135135+ ctx.SetClip(c.img.Bounds())
136136+ ctx.SetDst(c.img)
137137+ ctx.SetSrc(image.NewUniform(col))
138138+139139+ // Calculate text width for alignment
140140+ if align != AlignLeft {
141141+ opts := truetype.Options{Size: size, DPI: 72}
142142+ face := truetype.NewFace(f, &opts)
143143+ defer face.Close()
144144+145145+ textWidth := font.MeasureString(face, text).Round()
146146+ if align == AlignCenter {
147147+ x -= float64(textWidth) / 2
148148+ } else if align == AlignRight {
149149+ x -= float64(textWidth)
150150+ }
151151+ }
152152+153153+ pt := freetype.Pt(int(x), int(y))
154154+ _, err := ctx.DrawString(text, pt)
155155+ return err
156156+}
157157+158158+// MeasureText returns the width of text in pixels
159159+func (c *Card) MeasureText(text string, size float64, bold bool) int {
160160+ f := regularFont
161161+ if bold {
162162+ f = boldFont
163163+ }
164164+ if f == nil {
165165+ return 0
166166+ }
167167+168168+ opts := truetype.Options{Size: size, DPI: 72}
169169+ face := truetype.NewFace(f, &opts)
170170+ defer face.Close()
171171+172172+ return font.MeasureString(face, text).Round()
173173+}
174174+175175+// DrawTextWrapped draws text with word wrapping within maxWidth
176176+// Returns the Y position after the last line
177177+func (c *Card) DrawTextWrapped(text string, x, y float64, size float64, col color.Color, maxWidth int, bold bool) float64 {
178178+ words := splitWords(text)
179179+ if len(words) == 0 {
180180+ return y
181181+ }
182182+183183+ lineHeight := size * 1.3
184184+ currentLine := ""
185185+ currentY := y
186186+187187+ for _, word := range words {
188188+ testLine := currentLine
189189+ if testLine != "" {
190190+ testLine += " "
191191+ }
192192+ testLine += word
193193+194194+ lineWidth := c.MeasureText(testLine, size, bold)
195195+ if lineWidth > maxWidth && currentLine != "" {
196196+ // Draw current line and start new one
197197+ c.DrawText(currentLine, x, currentY, size, col, AlignLeft, bold)
198198+ currentY += lineHeight
199199+ currentLine = word
200200+ } else {
201201+ currentLine = testLine
202202+ }
203203+ }
204204+205205+ // Draw remaining text
206206+ if currentLine != "" {
207207+ c.DrawText(currentLine, x, currentY, size, col, AlignLeft, bold)
208208+ currentY += lineHeight
209209+ }
210210+211211+ return currentY
212212+}
213213+214214+// splitWords splits text into words
215215+func splitWords(text string) []string {
216216+ var words []string
217217+ current := ""
218218+ for _, r := range text {
219219+ if r == ' ' || r == '\t' || r == '\n' {
220220+ if current != "" {
221221+ words = append(words, current)
222222+ current = ""
223223+ }
224224+ } else {
225225+ current += string(r)
226226+ }
227227+ }
228228+ if current != "" {
229229+ words = append(words, current)
230230+ }
231231+ return words
232232+}
233233+234234+// DrawImage draws an image at the specified position
235235+func (c *Card) DrawImage(img image.Image, x, y int) {
236236+ bounds := img.Bounds()
237237+ rect := image.Rect(x, y, x+bounds.Dx(), y+bounds.Dy())
238238+ draw.Draw(c.img, rect, img, bounds.Min, draw.Over)
239239+}
240240+241241+// DrawCircularImage draws an image cropped to a circle
242242+func (c *Card) DrawCircularImage(img image.Image, x, y, diameter int) {
243243+ // Scale image to fit diameter
244244+ scaled := scaleImage(img, diameter, diameter)
245245+246246+ // Create circular mask
247247+ mask := createCircleMask(diameter)
248248+249249+ // Draw with mask
250250+ rect := image.Rect(x, y, x+diameter, y+diameter)
251251+ draw.DrawMask(c.img, rect, scaled, image.Point{}, mask, image.Point{}, draw.Over)
252252+}
253253+254254+// FetchAndDrawCircularImage fetches an image from URL and draws it as a circle
255255+func (c *Card) FetchAndDrawCircularImage(url string, x, y, diameter int) error {
256256+ client := &http.Client{Timeout: 5 * time.Second}
257257+ resp, err := client.Get(url)
258258+ if err != nil {
259259+ return err
260260+ }
261261+ defer resp.Body.Close()
262262+263263+ img, _, err := image.Decode(resp.Body)
264264+ if err != nil {
265265+ return err
266266+ }
267267+268268+ c.DrawCircularImage(img, x, y, diameter)
269269+ return nil
270270+}
271271+272272+// DrawPlaceholderCircle draws a colored circle with a letter
273273+func (c *Card) DrawPlaceholderCircle(x, y, diameter int, bgColor, textColor color.Color, letter string) {
274274+ // Draw filled circle
275275+ radius := diameter / 2
276276+ centerX := x + radius
277277+ centerY := y + radius
278278+279279+ for dy := -radius; dy <= radius; dy++ {
280280+ for dx := -radius; dx <= radius; dx++ {
281281+ if dx*dx+dy*dy <= radius*radius {
282282+ c.img.Set(centerX+dx, centerY+dy, bgColor)
283283+ }
284284+ }
285285+ }
286286+287287+ // Draw letter in center
288288+ fontSize := float64(diameter) * 0.5
289289+ c.DrawText(letter, float64(centerX), float64(centerY)+fontSize/3, fontSize, textColor, AlignCenter, true)
290290+}
291291+292292+// DrawRoundedRect draws a filled rounded rectangle
293293+func (c *Card) DrawRoundedRect(x, y, w, h, radius int, col color.Color) {
294294+ // Draw main rectangle (without corners)
295295+ for dy := radius; dy < h-radius; dy++ {
296296+ for dx := 0; dx < w; dx++ {
297297+ c.img.Set(x+dx, y+dy, col)
298298+ }
299299+ }
300300+ // Draw top and bottom strips (without corners)
301301+ for dy := 0; dy < radius; dy++ {
302302+ for dx := radius; dx < w-radius; dx++ {
303303+ c.img.Set(x+dx, y+dy, col)
304304+ c.img.Set(x+dx, y+h-1-dy, col)
305305+ }
306306+ }
307307+ // Draw rounded corners
308308+ for dy := 0; dy < radius; dy++ {
309309+ for dx := 0; dx < radius; dx++ {
310310+ // Check if point is within circle
311311+ cx := radius - dx - 1
312312+ cy := radius - dy - 1
313313+ if cx*cx+cy*cy <= radius*radius {
314314+ // Top-left
315315+ c.img.Set(x+dx, y+dy, col)
316316+ // Top-right
317317+ c.img.Set(x+w-1-dx, y+dy, col)
318318+ // Bottom-left
319319+ c.img.Set(x+dx, y+h-1-dy, col)
320320+ // Bottom-right
321321+ c.img.Set(x+w-1-dx, y+h-1-dy, col)
322322+ }
323323+ }
324324+ }
325325+}
326326+327327+// DrawBadge draws a pill-shaped badge with text
328328+func (c *Card) DrawBadge(text string, x, y int, fontSize float64, bgColor, textColor color.Color) int {
329329+ // Measure text width
330330+ textWidth := c.MeasureText(text, fontSize, false)
331331+ paddingX := 12
332332+ paddingY := 6
333333+ height := int(fontSize) + paddingY*2
334334+ width := textWidth + paddingX*2
335335+ radius := height / 2
336336+337337+ // Draw rounded background
338338+ c.DrawRoundedRect(x, y, width, height, radius, bgColor)
339339+340340+ // Draw text centered in badge
341341+ textX := float64(x + paddingX)
342342+ textY := float64(y + paddingY + int(fontSize) - 2)
343343+ c.DrawText(text, textX, textY, fontSize, textColor, AlignLeft, false)
344344+345345+ return width
346346+}
347347+348348+// EncodePNG encodes the card as PNG to the writer
349349+func (c *Card) EncodePNG(w io.Writer) error {
350350+ return png.Encode(w, c.img)
351351+}
352352+353353+// DrawAvatarOrPlaceholder draws a circular avatar from URL, falling back to placeholder
354354+func (c *Card) DrawAvatarOrPlaceholder(url string, x, y, size int, letter string) {
355355+ if url != "" {
356356+ if err := c.FetchAndDrawCircularImage(url, x, y, size); err == nil {
357357+ return
358358+ }
359359+ }
360360+ c.DrawPlaceholderCircle(x, y, size, ColorAccent, ColorText, letter)
361361+}
362362+363363+// DrawStatWithIcon draws an icon + text stat and returns the next X position
364364+func (c *Card) DrawStatWithIcon(icon string, text string, x, y int, iconColor, textColor color.Color) int {
365365+ c.DrawIcon(icon, x, y-int(FontStats), int(FontStats), iconColor)
366366+ x += StatsIconGap
367367+ c.DrawText(text, float64(x), float64(y), FontStats, textColor, AlignLeft, false)
368368+ return x + c.MeasureText(text, FontStats, false) + StatsItemGap
369369+}
370370+371371+// DrawBranding draws "ATCR" in the bottom-right corner
372372+func (c *Card) DrawBranding() {
373373+ y := CardHeight - Padding - 10
374374+ c.DrawText("ATCR", float64(CardWidth-Padding), float64(y), FontBranding, ColorMuted, AlignRight, true)
375375+}
376376+377377+// scaleImage scales an image to the target dimensions
378378+func scaleImage(src image.Image, width, height int) image.Image {
379379+ dst := image.NewRGBA(image.Rect(0, 0, width, height))
380380+ xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), xdraw.Over, nil)
381381+ return dst
382382+}
383383+384384+// createCircleMask creates a circular alpha mask
385385+func createCircleMask(diameter int) *image.Alpha {
386386+ mask := image.NewAlpha(image.Rect(0, 0, diameter, diameter))
387387+ radius := diameter / 2
388388+ centerX := radius
389389+ centerY := radius
390390+391391+ for y := 0; y < diameter; y++ {
392392+ for x := 0; x < diameter; x++ {
393393+ dx := x - centerX
394394+ dy := y - centerY
395395+ if dx*dx+dy*dy <= radius*radius {
396396+ mask.SetAlpha(x, y, color.Alpha{A: 255})
397397+ }
398398+ }
399399+ }
400400+401401+ return mask
402402+}
403403+404404+// Common colors
405405+var (
406406+ ColorBackground = color.RGBA{R: 22, G: 27, B: 34, A: 255} // #161b22 - GitHub dark elevated
407407+ ColorText = color.RGBA{R: 230, G: 237, B: 243, A: 255} // #e6edf3 - Light text
408408+ ColorMuted = color.RGBA{R: 125, G: 133, B: 144, A: 255} // #7d8590 - Muted text
409409+ ColorAccent = color.RGBA{R: 47, G: 129, B: 247, A: 255} // #2f81f7 - Blue accent
410410+ ColorStar = color.RGBA{R: 227, G: 179, B: 65, A: 255} // #e3b341 - Star yellow
411411+ ColorBadgeBg = color.RGBA{R: 33, G: 38, B: 45, A: 255} // #21262d - Badge background
412412+ ColorBadgeAccent = color.RGBA{R: 31, G: 111, B: 235, A: 255} // #1f6feb - Blue badge bg
413413+)
+45
pkg/appview/ogcard/font.go
···11+package ogcard
22+33+// Font configuration for OG card rendering.
44+// Currently uses Go fonts (embedded in golang.org/x/image).
55+//
66+// To use custom fonts instead, replace the init() below with:
77+//
88+// //go:embed MyFont-Regular.ttf
99+// var regularFontData []byte
1010+// //go:embed MyFont-Bold.ttf
1111+// var boldFontData []byte
1212+//
1313+// func init() {
1414+// regularFont, _ = truetype.Parse(regularFontData)
1515+// boldFont, _ = truetype.Parse(boldFontData)
1616+// }
1717+1818+import (
1919+ "log"
2020+2121+ "github.com/goki/freetype/truetype"
2222+ "golang.org/x/image/font/gofont/gobold"
2323+ "golang.org/x/image/font/gofont/goregular"
2424+)
2525+2626+var (
2727+ regularFont *truetype.Font
2828+ boldFont *truetype.Font
2929+)
3030+3131+func init() {
3232+ var err error
3333+3434+ regularFont, err = truetype.Parse(goregular.TTF)
3535+ if err != nil {
3636+ log.Printf("ogcard: failed to parse Go Regular font: %v", err)
3737+ return
3838+ }
3939+4040+ boldFont, err = truetype.Parse(gobold.TTF)
4141+ if err != nil {
4242+ log.Printf("ogcard: failed to parse Go Bold font: %v", err)
4343+ return
4444+ }
4545+}
+68
pkg/appview/ogcard/icons.go
···11+package ogcard
22+33+import (
44+ "bytes"
55+ "fmt"
66+ "image"
77+ "image/color"
88+ "image/draw"
99+ "strings"
1010+1111+ "github.com/srwiley/oksvg"
1212+ "github.com/srwiley/rasterx"
1313+)
1414+1515+// Lucide icons as SVG paths (simplified from Lucide icon set)
1616+// These are the path data for 24x24 viewBox icons
1717+var iconPaths = map[string]string{
1818+ // Star icon - outline
1919+ "star": `<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`,
2020+2121+ // Star filled
2222+ "star-filled": `<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="currentColor"/>`,
2323+2424+ // Arrow down to line (download/pull icon)
2525+ "arrow-down-to-line": `<path d="M12 17V3M12 17l-5-5M12 17l5-5M19 21H5" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`,
2626+2727+ // Package icon
2828+ "package": `<path d="M16.5 9.4l-9-5.19M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.27 6.96L12 12.01l8.73-5.05M12 22.08V12" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`,
2929+}
3030+3131+// DrawIcon draws a Lucide icon at the specified position with the given size and color
3232+func (c *Card) DrawIcon(name string, x, y, size int, col color.Color) error {
3333+ path, ok := iconPaths[name]
3434+ if !ok {
3535+ return fmt.Errorf("unknown icon: %s", name)
3636+ }
3737+3838+ // Build full SVG with color
3939+ r, g, b, _ := col.RGBA()
4040+ colorStr := fmt.Sprintf("rgb(%d,%d,%d)", r>>8, g>>8, b>>8)
4141+ path = strings.ReplaceAll(path, "currentColor", colorStr)
4242+4343+ svg := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">%s</svg>`, path)
4444+4545+ // Parse SVG
4646+ icon, err := oksvg.ReadIconStream(bytes.NewReader([]byte(svg)))
4747+ if err != nil {
4848+ return fmt.Errorf("failed to parse icon SVG: %w", err)
4949+ }
5050+5151+ // Create target image for the icon
5252+ iconImg := image.NewRGBA(image.Rect(0, 0, size, size))
5353+5454+ // Set up scanner for rasterization
5555+ scanner := rasterx.NewScannerGV(size, size, iconImg, iconImg.Bounds())
5656+ raster := rasterx.NewDasher(size, size, scanner)
5757+5858+ // Scale icon to target size
5959+ scale := float64(size) / 24.0
6060+ icon.SetTarget(0, 0, float64(size), float64(size))
6161+ icon.Draw(raster, scale)
6262+6363+ // Draw icon onto card
6464+ rect := image.Rect(x, y, x+size, y+size)
6565+ draw.Draw(c.img, rect, iconImg, image.Point{}, draw.Over)
6666+6767+ return nil
6868+}
···11// Package storage implements the storage routing layer for AppView.
22// It routes manifests to ATProto PDS (as io.atcr.manifest records) and
33-// blobs to hold services via XRPC, with hold DID caching for efficient pulls.
33+// blobs to hold services via XRPC, with database-based hold DID lookups.
44// All storage operations are proxied - AppView stores nothing locally.
55package storage
66···88 "context"
99 "log/slog"
1010 "sync"
1111- "time"
12111312 "github.com/distribution/distribution/v3"
1413)
···5049 manifestStore := r.manifestStore
5150 r.mu.Unlock()
52515353- // After any manifest operation, cache the hold DID for blob fetches
5454- // We use a goroutine to avoid blocking, and check after a short delay to allow the operation to complete
5555- go func() {
5656- time.Sleep(100 * time.Millisecond) // Brief delay to let manifest fetch complete
5757- if holdDID := manifestStore.GetLastFetchedHoldDID(); holdDID != "" {
5858- // Cache for 10 minutes - should cover typical pull operations
5959- GetGlobalHoldCache().Set(r.Ctx.DID, r.Ctx.Repository, holdDID, 10*time.Minute)
6060- slog.Debug("Cached hold DID", "component", "storage/routing", "did", r.Ctx.DID, "repo", r.Ctx.Repository, "hold", holdDID)
6161- }
6262- }()
6363-6452 return manifestStore, nil
6553}
6654···7664 return blobStore
7765 }
78667979- // For pull operations, check if we have a cached hold DID from a recent manifest fetch
8080- // This ensures blobs are fetched from the hold recorded in the manifest, not re-discovered
6767+ // Determine if this is a pull (GET) or push (PUT/POST/HEAD/etc) operation
6868+ // Pull operations use the historical hold DID from the database (blobs are where they were pushed)
6969+ // Push operations use the discovery-based hold DID from user's profile/default
7070+ // This allows users to change their default hold and have new pushes go there
7171+ isPull := false
7272+ if method, ok := ctx.Value("http.request.method").(string); ok {
7373+ isPull = method == "GET"
7474+ }
7575+8176 holdDID := r.Ctx.HoldDID // Default to discovery-based DID
7777+ holdSource := "discovery"
82788383- if cachedHoldDID, ok := GetGlobalHoldCache().Get(r.Ctx.DID, r.Ctx.Repository); ok {
8484- // Use cached hold DID from manifest
8585- holdDID = cachedHoldDID
8686- slog.Debug("Using cached hold from manifest", "component", "storage/blobs", "did", r.Ctx.DID, "repo", r.Ctx.Repository, "hold", cachedHoldDID)
8787- } else {
8888- // No cached hold, use discovery-based DID (for push or first pull)
8989- slog.Debug("Using discovery-based hold", "component", "storage/blobs", "did", r.Ctx.DID, "repo", r.Ctx.Repository, "hold", holdDID)
7979+ // Only query database for pull operations
8080+ if isPull && r.Ctx.Database != nil {
8181+ // Query database for the latest manifest's hold DID
8282+ if dbHoldDID, err := r.Ctx.Database.GetLatestHoldDIDForRepo(r.Ctx.DID, r.Ctx.Repository); err == nil && dbHoldDID != "" {
8383+ // Use hold DID from database (pull case - use historical reference)
8484+ holdDID = dbHoldDID
8585+ holdSource = "database"
8686+ slog.Debug("Using hold from database manifest (pull)", "component", "storage/blobs", "did", r.Ctx.DID, "repo", r.Ctx.Repository, "hold", dbHoldDID)
8787+ } else if err != nil {
8888+ // Log error but don't fail - fall back to discovery-based DID
8989+ slog.Warn("Failed to query database for hold DID", "component", "storage/blobs", "error", err)
9090+ }
9191+ // If dbHoldDID is empty (no manifests yet), fall through to use discovery-based DID
9092 }
91939294 if holdDID == "" {
···9496 panic("hold DID not set in RegistryContext - ensure default_hold_did is configured in middleware")
9597 }
96989797- // Update context with the correct hold DID (may be cached or discovered)
9999+ slog.Debug("Using hold DID for blobs", "component", "storage/blobs", "did", r.Ctx.DID, "repo", r.Ctx.Repository, "hold", holdDID, "source", holdSource)
100100+101101+ // Update context with the correct hold DID (may be from database or discovered)
98102 r.Ctx.HoldDID = holdDID
99103100104 // Create and cache proxy blob store
+131-27
pkg/appview/storage/routing_repository_test.go
···44 "context"
55 "sync"
66 "testing"
77- "time"
8798 "github.com/distribution/distribution/v3"
109 "github.com/stretchr/testify/assert"
···12111312 "atcr.io/pkg/atproto"
1413)
1414+1515+// mockDatabase is a simple mock for testing
1616+type mockDatabase struct {
1717+ holdDID string
1818+ err error
1919+}
2020+2121+func (m *mockDatabase) IncrementPullCount(did, repository string) error {
2222+ return nil
2323+}
2424+2525+func (m *mockDatabase) IncrementPushCount(did, repository string) error {
2626+ return nil
2727+}
2828+2929+func (m *mockDatabase) GetLatestHoldDIDForRepo(did, repository string) (string, error) {
3030+ if m.err != nil {
3131+ return "", m.err
3232+ }
3333+ return m.holdDID, nil
3434+}
15351636func TestNewRoutingRepository(t *testing.T) {
1737 ctx := &RegistryContext{
···89109 assert.NotNil(t, repo.manifestStore)
90110}
911119292-// TestRoutingRepository_Blobs_WithCache tests blob store with cached hold DID
9393-func TestRoutingRepository_Blobs_WithCache(t *testing.T) {
9494- // Pre-populate the hold cache
9595- cache := GetGlobalHoldCache()
9696- cachedHoldDID := "did:web:cached.hold.io"
9797- cache.Set("did:plc:test123", "myapp", cachedHoldDID, 10*time.Minute)
112112+// TestRoutingRepository_Blobs_PullUsesDatabase tests that GET (pull) uses database hold DID
113113+func TestRoutingRepository_Blobs_PullUsesDatabase(t *testing.T) {
114114+ dbHoldDID := "did:web:database.hold.io"
115115+ discoveryHoldDID := "did:web:discovery.hold.io"
9811699117 ctx := &RegistryContext{
100118 DID: "did:plc:test123",
101119 Repository: "myapp",
102102- HoldDID: "did:web:default.hold.io", // Discovery-based hold (should be overridden)
120120+ HoldDID: discoveryHoldDID, // Discovery-based hold (should be overridden for pull)
103121 ATProtoClient: atproto.NewClient("https://pds.example.com", "did:plc:test123", ""),
122122+ Database: &mockDatabase{holdDID: dbHoldDID},
104123 }
105124106125 repo := NewRoutingRepository(nil, ctx)
126126+127127+ // Create context with GET method (pull operation)
128128+ pullCtx := context.WithValue(context.Background(), "http.request.method", "GET")
129129+ blobStore := repo.Blobs(pullCtx)
130130+131131+ assert.NotNil(t, blobStore)
132132+ // Verify the hold DID was updated to use the database value for pull
133133+ assert.Equal(t, dbHoldDID, repo.Ctx.HoldDID, "pull (GET) should use database hold DID")
134134+}
135135+136136+// TestRoutingRepository_Blobs_PushUsesDiscovery tests that push operations use discovery hold DID
137137+func TestRoutingRepository_Blobs_PushUsesDiscovery(t *testing.T) {
138138+ dbHoldDID := "did:web:database.hold.io"
139139+ discoveryHoldDID := "did:web:discovery.hold.io"
140140+141141+ testCases := []struct {
142142+ name string
143143+ method string
144144+ }{
145145+ {"PUT", "PUT"},
146146+ {"POST", "POST"},
147147+ {"HEAD", "HEAD"},
148148+ {"PATCH", "PATCH"},
149149+ {"DELETE", "DELETE"},
150150+ }
151151+152152+ for _, tc := range testCases {
153153+ t.Run(tc.name, func(t *testing.T) {
154154+ ctx := &RegistryContext{
155155+ DID: "did:plc:test123",
156156+ Repository: "myapp-" + tc.method, // Unique repo to avoid caching
157157+ HoldDID: discoveryHoldDID,
158158+ ATProtoClient: atproto.NewClient("https://pds.example.com", "did:plc:test123", ""),
159159+ Database: &mockDatabase{holdDID: dbHoldDID},
160160+ }
161161+162162+ repo := NewRoutingRepository(nil, ctx)
163163+164164+ // Create context with push method
165165+ pushCtx := context.WithValue(context.Background(), "http.request.method", tc.method)
166166+ blobStore := repo.Blobs(pushCtx)
167167+168168+ assert.NotNil(t, blobStore)
169169+ // Verify the hold DID remains the discovery-based one for push operations
170170+ assert.Equal(t, discoveryHoldDID, repo.Ctx.HoldDID, "%s should use discovery hold DID, not database", tc.method)
171171+ })
172172+ }
173173+}
174174+175175+// TestRoutingRepository_Blobs_NoMethodUsesDiscovery tests that missing method defaults to discovery
176176+func TestRoutingRepository_Blobs_NoMethodUsesDiscovery(t *testing.T) {
177177+ dbHoldDID := "did:web:database.hold.io"
178178+ discoveryHoldDID := "did:web:discovery.hold.io"
179179+180180+ ctx := &RegistryContext{
181181+ DID: "did:plc:test123",
182182+ Repository: "myapp-nomethod",
183183+ HoldDID: discoveryHoldDID,
184184+ ATProtoClient: atproto.NewClient("https://pds.example.com", "did:plc:test123", ""),
185185+ Database: &mockDatabase{holdDID: dbHoldDID},
186186+ }
187187+188188+ repo := NewRoutingRepository(nil, ctx)
189189+190190+ // Context without HTTP method (shouldn't happen in practice, but test defensive behavior)
107191 blobStore := repo.Blobs(context.Background())
108192109193 assert.NotNil(t, blobStore)
110110- // Verify the hold DID was updated to use the cached value
111111- assert.Equal(t, cachedHoldDID, repo.Ctx.HoldDID, "should use cached hold DID")
194194+ // Without method, should default to discovery (safer for push scenarios)
195195+ assert.Equal(t, discoveryHoldDID, repo.Ctx.HoldDID, "missing method should use discovery hold DID")
112196}
113197114114-// TestRoutingRepository_Blobs_WithoutCache tests blob store with discovery-based hold
115115-func TestRoutingRepository_Blobs_WithoutCache(t *testing.T) {
198198+// TestRoutingRepository_Blobs_WithoutDatabase tests blob store with discovery-based hold
199199+func TestRoutingRepository_Blobs_WithoutDatabase(t *testing.T) {
116200 discoveryHoldDID := "did:web:discovery.hold.io"
117201118118- // Use a different DID/repo to avoid cache contamination from other tests
119202 ctx := &RegistryContext{
120203 DID: "did:plc:nocache456",
121204 Repository: "uncached-app",
122205 HoldDID: discoveryHoldDID,
123206 ATProtoClient: atproto.NewClient("https://pds.example.com", "did:plc:nocache456", ""),
207207+ Database: nil, // No database
124208 }
125209126210 repo := NewRoutingRepository(nil, ctx)
···129213 assert.NotNil(t, blobStore)
130214 // Verify the hold DID remains the discovery-based one
131215 assert.Equal(t, discoveryHoldDID, repo.Ctx.HoldDID, "should use discovery-based hold DID")
216216+}
217217+218218+// TestRoutingRepository_Blobs_DatabaseEmptyFallback tests fallback when database returns empty hold DID
219219+func TestRoutingRepository_Blobs_DatabaseEmptyFallback(t *testing.T) {
220220+ discoveryHoldDID := "did:web:discovery.hold.io"
221221+222222+ ctx := &RegistryContext{
223223+ DID: "did:plc:test123",
224224+ Repository: "newapp",
225225+ HoldDID: discoveryHoldDID,
226226+ ATProtoClient: atproto.NewClient("https://pds.example.com", "did:plc:test123", ""),
227227+ Database: &mockDatabase{holdDID: ""}, // Empty string (no manifests yet)
228228+ }
229229+230230+ repo := NewRoutingRepository(nil, ctx)
231231+ blobStore := repo.Blobs(context.Background())
232232+233233+ assert.NotNil(t, blobStore)
234234+ // Verify the hold DID falls back to discovery-based
235235+ assert.Equal(t, discoveryHoldDID, repo.Ctx.HoldDID, "should fall back to discovery-based hold DID when database returns empty")
132236}
133237134238// TestRoutingRepository_BlobStoreCaching tests that blob store is cached
···254358 assert.NotNil(t, cachedBlobStore)
255359}
256360257257-// TestRoutingRepository_HoldCachePopulation tests that hold DID cache is populated after manifest fetch
258258-// Note: This test verifies the goroutine behavior with a delay
259259-func TestRoutingRepository_HoldCachePopulation(t *testing.T) {
361361+// TestRoutingRepository_Blobs_PullPriority tests that database hold DID takes priority for pull (GET)
362362+func TestRoutingRepository_Blobs_PullPriority(t *testing.T) {
363363+ dbHoldDID := "did:web:database.hold.io"
364364+ discoveryHoldDID := "did:web:discovery.hold.io"
365365+260366 ctx := &RegistryContext{
261367 DID: "did:plc:test123",
262262- Repository: "myapp",
263263- HoldDID: "did:web:hold01.atcr.io",
368368+ Repository: "myapp-priority",
369369+ HoldDID: discoveryHoldDID, // Discovery-based hold
264370 ATProtoClient: atproto.NewClient("https://pds.example.com", "did:plc:test123", ""),
371371+ Database: &mockDatabase{holdDID: dbHoldDID}, // Database has a different hold DID
265372 }
266373267374 repo := NewRoutingRepository(nil, ctx)
268375269269- // Create manifest store (which triggers the cache population goroutine)
270270- _, err := repo.Manifests(context.Background())
271271- require.NoError(t, err)
272272-273273- // Wait for goroutine to complete (it has a 100ms sleep)
274274- time.Sleep(200 * time.Millisecond)
376376+ // For pull (GET), database should take priority
377377+ pullCtx := context.WithValue(context.Background(), "http.request.method", "GET")
378378+ blobStore := repo.Blobs(pullCtx)
275379276276- // Note: We can't easily verify the cache was populated without a real manifest fetch
277277- // The actual caching happens in GetLastFetchedHoldDID() which requires manifest operations
278278- // This test primarily verifies the Manifests() call doesn't panic with the goroutine
380380+ assert.NotNil(t, blobStore)
381381+ // Database hold DID should take priority over discovery for pull operations
382382+ assert.Equal(t, dbHoldDID, repo.Ctx.HoldDID, "database hold DID should take priority over discovery for pull (GET)")
279383}
+3-3
pkg/appview/storage/tag_store.go
···3636 return distribution.Descriptor{}, distribution.ErrTagUnknown{Tag: tag}
3737 }
38383939- var tagRecord atproto.TagRecord
3939+ var tagRecord atproto.Tag
4040 if err := json.Unmarshal(record.Value, &tagRecord); err != nil {
4141 return distribution.Descriptor{}, fmt.Errorf("failed to unmarshal tag record: %w", err)
4242 }
···91919292 var tags []string
9393 for _, record := range records {
9494- var tagRecord atproto.TagRecord
9494+ var tagRecord atproto.Tag
9595 if err := json.Unmarshal(record.Value, &tagRecord); err != nil {
9696 // Skip invalid records
9797 continue
···116116117117 var tags []string
118118 for _, record := range records {
119119- var tagRecord atproto.TagRecord
119119+ var tagRecord atproto.Tag
120120 if err := json.Unmarshal(record.Value, &tagRecord); err != nil {
121121 // Skip invalid records
122122 continue
+6-6
pkg/appview/storage/tag_store_test.go
···229229230230 for _, tt := range tests {
231231 t.Run(tt.name, func(t *testing.T) {
232232- var sentTagRecord *atproto.TagRecord
232232+ var sentTagRecord *atproto.Tag
233233234234 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
235235 if r.Method != "POST" {
···254254 // Parse and verify tag record
255255 recordData := body["record"].(map[string]any)
256256 recordBytes, _ := json.Marshal(recordData)
257257- var tagRecord atproto.TagRecord
257257+ var tagRecord atproto.Tag
258258 json.Unmarshal(recordBytes, &tagRecord)
259259 sentTagRecord = &tagRecord
260260···284284285285 if !tt.wantErr && sentTagRecord != nil {
286286 // Verify the tag record
287287- if sentTagRecord.Type != atproto.TagCollection {
288288- t.Errorf("Type = %v, want %v", sentTagRecord.Type, atproto.TagCollection)
287287+ if sentTagRecord.LexiconTypeID != atproto.TagCollection {
288288+ t.Errorf("LexiconTypeID = %v, want %v", sentTagRecord.LexiconTypeID, atproto.TagCollection)
289289 }
290290 if sentTagRecord.Repository != "myapp" {
291291 t.Errorf("Repository = %v, want myapp", sentTagRecord.Repository)
···295295 }
296296 // New records should have manifest field
297297 expectedURI := atproto.BuildManifestURI("did:plc:test123", tt.digest.String())
298298- if sentTagRecord.Manifest != expectedURI {
298298+ if sentTagRecord.Manifest == nil || *sentTagRecord.Manifest != expectedURI {
299299 t.Errorf("Manifest = %v, want %v", sentTagRecord.Manifest, expectedURI)
300300 }
301301 // New records should NOT have manifestDigest field
302302- if sentTagRecord.ManifestDigest != "" {
302302+ if sentTagRecord.ManifestDigest != nil && *sentTagRecord.ManifestDigest != "" {
303303 t.Errorf("ManifestDigest should be empty for new records, got %v", sentTagRecord.ManifestDigest)
304304 }
305305 }
+14
pkg/appview/templates/pages/home.html
···33<html lang="en">
44<head>
55 <title>ATCR - Distributed Container Registry</title>
66+ <!-- Open Graph -->
77+ <meta property="og:title" content="ATCR - Distributed Container Registry">
88+ <meta property="og:description" content="Push and pull Docker images on the AT Protocol. Same Docker, decentralized.">
99+ <meta property="og:image" content="https://{{ .RegistryURL }}/og/home">
1010+ <meta property="og:image:width" content="1200">
1111+ <meta property="og:image:height" content="630">
1212+ <meta property="og:type" content="website">
1313+ <meta property="og:url" content="https://{{ .RegistryURL }}">
1414+ <meta property="og:site_name" content="ATCR">
1515+ <!-- Twitter Card (used by Discord) -->
1616+ <meta name="twitter:card" content="summary_large_image">
1717+ <meta name="twitter:title" content="ATCR - Distributed Container Registry">
1818+ <meta name="twitter:description" content="Push and pull Docker images on the AT Protocol. Same Docker, decentralized.">
1919+ <meta name="twitter:image" content="https://{{ .RegistryURL }}/og/home">
620 {{ template "head" . }}
721</head>
822<body>
+1
pkg/appview/templates/pages/login.html
···3434 id="handle"
3535 name="handle"
3636 placeholder="alice.bsky.social"
3737+ autocomplete="off"
3738 required
3839 autofocus />
3940 <small>Enter your Bluesky or ATProto handle</small>
···88 "math"
99 "sort"
10101111+ util "github.com/bluesky-social/indigo/lex/util"
1112 cid "github.com/ipfs/go-cid"
1213 cbg "github.com/whyrusleeping/cbor-gen"
1314 xerrors "golang.org/x/xerrors"
···1819var _ = math.E
1920var _ = sort.Sort
20212121-func (t *CrewRecord) MarshalCBOR(w io.Writer) error {
2222+func (t *Manifest) MarshalCBOR(w io.Writer) error {
2223 if t == nil {
2324 _, err := w.Write(cbg.CborNull)
2425 return err
2526 }
26272728 cw := cbg.NewCborWriter(w)
2929+ fieldCount := 14
28302929- if _, err := cw.Write([]byte{165}); err != nil {
3030- return err
3131+ if t.Annotations == nil {
3232+ fieldCount--
3333+ }
3434+3535+ if t.Config == nil {
3636+ fieldCount--
3737+ }
3838+3939+ if t.HoldDid == nil {
4040+ fieldCount--
3141 }
32423333- // t.Role (string) (string)
3434- if len("role") > 8192 {
3535- return xerrors.Errorf("Value in field \"role\" was too long")
4343+ if t.HoldEndpoint == nil {
4444+ fieldCount--
3645 }
37463838- if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("role"))); err != nil {
3939- return err
4747+ if t.Layers == nil {
4848+ fieldCount--
4049 }
4141- if _, err := cw.WriteString(string("role")); err != nil {
4242- return err
5050+5151+ if t.ManifestBlob == nil {
5252+ fieldCount--
4353 }
44544545- if len(t.Role) > 8192 {
4646- return xerrors.Errorf("Value in field t.Role was too long")
5555+ if t.Manifests == nil {
5656+ fieldCount--
4757 }
48584949- if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Role))); err != nil {
5050- return err
5959+ if t.Subject == nil {
6060+ fieldCount--
5161 }
5252- if _, err := cw.WriteString(string(t.Role)); err != nil {
6262+6363+ if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
5364 return err
5465 }
55665656- // t.Type (string) (string)
6767+ // t.LexiconTypeID (string) (string)
5768 if len("$type") > 8192 {
5869 return xerrors.Errorf("Value in field \"$type\" was too long")
5970 }
···6576 return err
6677 }
67786868- if len(t.Type) > 8192 {
6969- return xerrors.Errorf("Value in field t.Type was too long")
7979+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("io.atcr.manifest"))); err != nil {
8080+ return err
8181+ }
8282+ if _, err := cw.WriteString(string("io.atcr.manifest")); err != nil {
8383+ return err
8484+ }
8585+8686+ // t.Config (atproto.Manifest_BlobReference) (struct)
8787+ if t.Config != nil {
8888+8989+ if len("config") > 8192 {
9090+ return xerrors.Errorf("Value in field \"config\" was too long")
9191+ }
9292+9393+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("config"))); err != nil {
9494+ return err
9595+ }
9696+ if _, err := cw.WriteString(string("config")); err != nil {
9797+ return err
9898+ }
9999+100100+ if err := t.Config.MarshalCBOR(cw); err != nil {
101101+ return err
102102+ }
70103 }
711047272- if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Type))); err != nil {
105105+ // t.Digest (string) (string)
106106+ if len("digest") > 8192 {
107107+ return xerrors.Errorf("Value in field \"digest\" was too long")
108108+ }
109109+110110+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("digest"))); err != nil {
73111 return err
74112 }
7575- if _, err := cw.WriteString(string(t.Type)); err != nil {
113113+ if _, err := cw.WriteString(string("digest")); err != nil {
76114 return err
77115 }
781167979- // t.Member (string) (string)
8080- if len("member") > 8192 {
8181- return xerrors.Errorf("Value in field \"member\" was too long")
117117+ if len(t.Digest) > 8192 {
118118+ return xerrors.Errorf("Value in field t.Digest was too long")
82119 }
831208484- if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("member"))); err != nil {
121121+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Digest))); err != nil {
85122 return err
86123 }
8787- if _, err := cw.WriteString(string("member")); err != nil {
124124+ if _, err := cw.WriteString(string(t.Digest)); err != nil {
88125 return err
89126 }
901279191- if len(t.Member) > 8192 {
9292- return xerrors.Errorf("Value in field t.Member was too long")
128128+ // t.Layers ([]atproto.Manifest_BlobReference) (slice)
129129+ if t.Layers != nil {
130130+131131+ if len("layers") > 8192 {
132132+ return xerrors.Errorf("Value in field \"layers\" was too long")
133133+ }
134134+135135+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("layers"))); err != nil {
136136+ return err
137137+ }
138138+ if _, err := cw.WriteString(string("layers")); err != nil {
139139+ return err
140140+ }
141141+142142+ if len(t.Layers) > 8192 {
143143+ return xerrors.Errorf("Slice value in field t.Layers was too long")
144144+ }
145145+146146+ if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Layers))); err != nil {
147147+ return err
148148+ }
149149+ for _, v := range t.Layers {
150150+ if err := v.MarshalCBOR(cw); err != nil {
151151+ return err
152152+ }
153153+154154+ }
93155 }
941569595- if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Member))); err != nil {
157157+ // t.HoldDid (string) (string)
158158+ if t.HoldDid != nil {
159159+160160+ if len("holdDid") > 8192 {
161161+ return xerrors.Errorf("Value in field \"holdDid\" was too long")
162162+ }
163163+164164+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("holdDid"))); err != nil {
165165+ return err
166166+ }
167167+ if _, err := cw.WriteString(string("holdDid")); err != nil {
168168+ return err
169169+ }
170170+171171+ if t.HoldDid == nil {
172172+ if _, err := cw.Write(cbg.CborNull); err != nil {
173173+ return err
174174+ }
175175+ } else {
176176+ if len(*t.HoldDid) > 8192 {
177177+ return xerrors.Errorf("Value in field t.HoldDid was too long")
178178+ }
179179+180180+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.HoldDid))); err != nil {
181181+ return err
182182+ }
183183+ if _, err := cw.WriteString(string(*t.HoldDid)); err != nil {
184184+ return err
185185+ }
186186+ }
187187+ }
188188+189189+ // t.Subject (atproto.Manifest_BlobReference) (struct)
190190+ if t.Subject != nil {
191191+192192+ if len("subject") > 8192 {
193193+ return xerrors.Errorf("Value in field \"subject\" was too long")
194194+ }
195195+196196+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil {
197197+ return err
198198+ }
199199+ if _, err := cw.WriteString(string("subject")); err != nil {
200200+ return err
201201+ }
202202+203203+ if err := t.Subject.MarshalCBOR(cw); err != nil {
204204+ return err
205205+ }
206206+ }
207207+208208+ // t.CreatedAt (string) (string)
209209+ if len("createdAt") > 8192 {
210210+ return xerrors.Errorf("Value in field \"createdAt\" was too long")
211211+ }
212212+213213+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
96214 return err
97215 }
9898- if _, err := cw.WriteString(string(t.Member)); err != nil {
216216+ if _, err := cw.WriteString(string("createdAt")); err != nil {
99217 return err
100218 }
101219102102- // t.AddedAt (string) (string)
103103- if len("addedAt") > 8192 {
104104- return xerrors.Errorf("Value in field \"addedAt\" was too long")
220220+ if len(t.CreatedAt) > 8192 {
221221+ return xerrors.Errorf("Value in field t.CreatedAt was too long")
105222 }
106223107107- if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("addedAt"))); err != nil {
224224+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
108225 return err
109226 }
110110- if _, err := cw.WriteString(string("addedAt")); err != nil {
227227+ if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
111228 return err
112229 }
113230114114- if len(t.AddedAt) > 8192 {
115115- return xerrors.Errorf("Value in field t.AddedAt was too long")
231231+ // t.Manifests ([]atproto.Manifest_ManifestReference) (slice)
232232+ if t.Manifests != nil {
233233+234234+ if len("manifests") > 8192 {
235235+ return xerrors.Errorf("Value in field \"manifests\" was too long")
236236+ }
237237+238238+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("manifests"))); err != nil {
239239+ return err
240240+ }
241241+ if _, err := cw.WriteString(string("manifests")); err != nil {
242242+ return err
243243+ }
244244+245245+ if len(t.Manifests) > 8192 {
246246+ return xerrors.Errorf("Slice value in field t.Manifests was too long")
247247+ }
248248+249249+ if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Manifests))); err != nil {
250250+ return err
251251+ }
252252+ for _, v := range t.Manifests {
253253+ if err := v.MarshalCBOR(cw); err != nil {
254254+ return err
255255+ }
256256+257257+ }
116258 }
117259118118- if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.AddedAt))); err != nil {
260260+ // t.MediaType (string) (string)
261261+ if len("mediaType") > 8192 {
262262+ return xerrors.Errorf("Value in field \"mediaType\" was too long")
263263+ }
264264+265265+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mediaType"))); err != nil {
119266 return err
120267 }
121121- if _, err := cw.WriteString(string(t.AddedAt)); err != nil {
268268+ if _, err := cw.WriteString(string("mediaType")); err != nil {
122269 return err
123270 }
124271125125- // t.Permissions ([]string) (slice)
126126- if len("permissions") > 8192 {
127127- return xerrors.Errorf("Value in field \"permissions\" was too long")
272272+ if len(t.MediaType) > 8192 {
273273+ return xerrors.Errorf("Value in field t.MediaType was too long")
128274 }
129275130130- if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("permissions"))); err != nil {
276276+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.MediaType))); err != nil {
277277+ return err
278278+ }
279279+ if _, err := cw.WriteString(string(t.MediaType)); err != nil {
280280+ return err
281281+ }
282282+283283+ // t.Repository (string) (string)
284284+ if len("repository") > 8192 {
285285+ return xerrors.Errorf("Value in field \"repository\" was too long")
286286+ }
287287+288288+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repository"))); err != nil {
131289 return err
132290 }
133133- if _, err := cw.WriteString(string("permissions")); err != nil {
291291+ if _, err := cw.WriteString(string("repository")); err != nil {
134292 return err
135293 }
136294137137- if len(t.Permissions) > 8192 {
138138- return xerrors.Errorf("Slice value in field t.Permissions was too long")
295295+ if len(t.Repository) > 8192 {
296296+ return xerrors.Errorf("Value in field t.Repository was too long")
139297 }
140298141141- if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Permissions))); err != nil {
299299+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repository))); err != nil {
142300 return err
143301 }
144144- for _, v := range t.Permissions {
145145- if len(v) > 8192 {
146146- return xerrors.Errorf("Value in field v was too long")
302302+ if _, err := cw.WriteString(string(t.Repository)); err != nil {
303303+ return err
304304+ }
305305+306306+ // t.Annotations (atproto.Manifest_Annotations) (struct)
307307+ if t.Annotations != nil {
308308+309309+ if len("annotations") > 8192 {
310310+ return xerrors.Errorf("Value in field \"annotations\" was too long")
311311+ }
312312+313313+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("annotations"))); err != nil {
314314+ return err
315315+ }
316316+ if _, err := cw.WriteString(string("annotations")); err != nil {
317317+ return err
318318+ }
319319+320320+ if err := t.Annotations.MarshalCBOR(cw); err != nil {
321321+ return err
322322+ }
323323+ }
324324+325325+ // t.HoldEndpoint (string) (string)
326326+ if t.HoldEndpoint != nil {
327327+328328+ if len("holdEndpoint") > 8192 {
329329+ return xerrors.Errorf("Value in field \"holdEndpoint\" was too long")
330330+ }
331331+332332+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("holdEndpoint"))); err != nil {
333333+ return err
334334+ }
335335+ if _, err := cw.WriteString(string("holdEndpoint")); err != nil {
336336+ return err
337337+ }
338338+339339+ if t.HoldEndpoint == nil {
340340+ if _, err := cw.Write(cbg.CborNull); err != nil {
341341+ return err
342342+ }
343343+ } else {
344344+ if len(*t.HoldEndpoint) > 8192 {
345345+ return xerrors.Errorf("Value in field t.HoldEndpoint was too long")
346346+ }
347347+348348+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.HoldEndpoint))); err != nil {
349349+ return err
350350+ }
351351+ if _, err := cw.WriteString(string(*t.HoldEndpoint)); err != nil {
352352+ return err
353353+ }
147354 }
355355+ }
148356149149- if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
357357+ // t.ManifestBlob (util.LexBlob) (struct)
358358+ if t.ManifestBlob != nil {
359359+360360+ if len("manifestBlob") > 8192 {
361361+ return xerrors.Errorf("Value in field \"manifestBlob\" was too long")
362362+ }
363363+364364+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("manifestBlob"))); err != nil {
150365 return err
151366 }
152152- if _, err := cw.WriteString(string(v)); err != nil {
367367+ if _, err := cw.WriteString(string("manifestBlob")); err != nil {
153368 return err
154369 }
155370371371+ if err := t.ManifestBlob.MarshalCBOR(cw); err != nil {
372372+ return err
373373+ }
156374 }
375375+376376+ // t.SchemaVersion (int64) (int64)
377377+ if len("schemaVersion") > 8192 {
378378+ return xerrors.Errorf("Value in field \"schemaVersion\" was too long")
379379+ }
380380+381381+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("schemaVersion"))); err != nil {
382382+ return err
383383+ }
384384+ if _, err := cw.WriteString(string("schemaVersion")); err != nil {
385385+ return err
386386+ }
387387+388388+ if t.SchemaVersion >= 0 {
389389+ if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.SchemaVersion)); err != nil {
390390+ return err
391391+ }
392392+ } else {
393393+ if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.SchemaVersion-1)); err != nil {
394394+ return err
395395+ }
396396+ }
397397+157398 return nil
158399}
159400160160-func (t *CrewRecord) UnmarshalCBOR(r io.Reader) (err error) {
161161- *t = CrewRecord{}
401401+func (t *Manifest) UnmarshalCBOR(r io.Reader) (err error) {
402402+ *t = Manifest{}
162403163404 cr := cbg.NewCborReader(r)
164405···177418 }
178419179420 if extra > cbg.MaxLength {
180180- return fmt.Errorf("CrewRecord: map struct too large (%d)", extra)
421421+ return fmt.Errorf("Manifest: map struct too large (%d)", extra)
422422+ }
423423+424424+ n := extra
425425+426426+ nameBuf := make([]byte, 13)
427427+ for i := uint64(0); i < n; i++ {
428428+ nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192)
429429+ if err != nil {
430430+ return err
431431+ }
432432+433433+ if !ok {
434434+ // Field doesn't exist on this type, so ignore it
435435+ if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
436436+ return err
437437+ }
438438+ continue
439439+ }
440440+441441+ switch string(nameBuf[:nameLen]) {
442442+ // t.LexiconTypeID (string) (string)
443443+ case "$type":
444444+445445+ {
446446+ sval, err := cbg.ReadStringWithMax(cr, 8192)
447447+ if err != nil {
448448+ return err
449449+ }
450450+451451+ t.LexiconTypeID = string(sval)
452452+ }
453453+ // t.Config (atproto.Manifest_BlobReference) (struct)
454454+ case "config":
455455+456456+ {
457457+458458+ b, err := cr.ReadByte()
459459+ if err != nil {
460460+ return err
461461+ }
462462+ if b != cbg.CborNull[0] {
463463+ if err := cr.UnreadByte(); err != nil {
464464+ return err
465465+ }
466466+ t.Config = new(Manifest_BlobReference)
467467+ if err := t.Config.UnmarshalCBOR(cr); err != nil {
468468+ return xerrors.Errorf("unmarshaling t.Config pointer: %w", err)
469469+ }
470470+ }
471471+472472+ }
473473+ // t.Digest (string) (string)
474474+ case "digest":
475475+476476+ {
477477+ sval, err := cbg.ReadStringWithMax(cr, 8192)
478478+ if err != nil {
479479+ return err
480480+ }
481481+482482+ t.Digest = string(sval)
483483+ }
484484+ // t.Layers ([]atproto.Manifest_BlobReference) (slice)
485485+ case "layers":
486486+487487+ maj, extra, err = cr.ReadHeader()
488488+ if err != nil {
489489+ return err
490490+ }
491491+492492+ if extra > 8192 {
493493+ return fmt.Errorf("t.Layers: array too large (%d)", extra)
494494+ }
495495+496496+ if maj != cbg.MajArray {
497497+ return fmt.Errorf("expected cbor array")
498498+ }
499499+500500+ if extra > 0 {
501501+ t.Layers = make([]Manifest_BlobReference, extra)
502502+ }
503503+504504+ for i := 0; i < int(extra); i++ {
505505+ {
506506+ var maj byte
507507+ var extra uint64
508508+ var err error
509509+ _ = maj
510510+ _ = extra
511511+ _ = err
512512+513513+ {
514514+515515+ if err := t.Layers[i].UnmarshalCBOR(cr); err != nil {
516516+ return xerrors.Errorf("unmarshaling t.Layers[i]: %w", err)
517517+ }
518518+519519+ }
520520+521521+ }
522522+ }
523523+ // t.HoldDid (string) (string)
524524+ case "holdDid":
525525+526526+ {
527527+ b, err := cr.ReadByte()
528528+ if err != nil {
529529+ return err
530530+ }
531531+ if b != cbg.CborNull[0] {
532532+ if err := cr.UnreadByte(); err != nil {
533533+ return err
534534+ }
535535+536536+ sval, err := cbg.ReadStringWithMax(cr, 8192)
537537+ if err != nil {
538538+ return err
539539+ }
540540+541541+ t.HoldDid = (*string)(&sval)
542542+ }
543543+ }
544544+ // t.Subject (atproto.Manifest_BlobReference) (struct)
545545+ case "subject":
546546+547547+ {
548548+549549+ b, err := cr.ReadByte()
550550+ if err != nil {
551551+ return err
552552+ }
553553+ if b != cbg.CborNull[0] {
554554+ if err := cr.UnreadByte(); err != nil {
555555+ return err
556556+ }
557557+ t.Subject = new(Manifest_BlobReference)
558558+ if err := t.Subject.UnmarshalCBOR(cr); err != nil {
559559+ return xerrors.Errorf("unmarshaling t.Subject pointer: %w", err)
560560+ }
561561+ }
562562+563563+ }
564564+ // t.CreatedAt (string) (string)
565565+ case "createdAt":
566566+567567+ {
568568+ sval, err := cbg.ReadStringWithMax(cr, 8192)
569569+ if err != nil {
570570+ return err
571571+ }
572572+573573+ t.CreatedAt = string(sval)
574574+ }
575575+ // t.Manifests ([]atproto.Manifest_ManifestReference) (slice)
576576+ case "manifests":
577577+578578+ maj, extra, err = cr.ReadHeader()
579579+ if err != nil {
580580+ return err
581581+ }
582582+583583+ if extra > 8192 {
584584+ return fmt.Errorf("t.Manifests: array too large (%d)", extra)
585585+ }
586586+587587+ if maj != cbg.MajArray {
588588+ return fmt.Errorf("expected cbor array")
589589+ }
590590+591591+ if extra > 0 {
592592+ t.Manifests = make([]Manifest_ManifestReference, extra)
593593+ }
594594+595595+ for i := 0; i < int(extra); i++ {
596596+ {
597597+ var maj byte
598598+ var extra uint64
599599+ var err error
600600+ _ = maj
601601+ _ = extra
602602+ _ = err
603603+604604+ {
605605+606606+ if err := t.Manifests[i].UnmarshalCBOR(cr); err != nil {
607607+ return xerrors.Errorf("unmarshaling t.Manifests[i]: %w", err)
608608+ }
609609+610610+ }
611611+612612+ }
613613+ }
614614+ // t.MediaType (string) (string)
615615+ case "mediaType":
616616+617617+ {
618618+ sval, err := cbg.ReadStringWithMax(cr, 8192)
619619+ if err != nil {
620620+ return err
621621+ }
622622+623623+ t.MediaType = string(sval)
624624+ }
625625+ // t.Repository (string) (string)
626626+ case "repository":
627627+628628+ {
629629+ sval, err := cbg.ReadStringWithMax(cr, 8192)
630630+ if err != nil {
631631+ return err
632632+ }
633633+634634+ t.Repository = string(sval)
635635+ }
636636+ // t.Annotations (atproto.Manifest_Annotations) (struct)
637637+ case "annotations":
638638+639639+ {
640640+641641+ b, err := cr.ReadByte()
642642+ if err != nil {
643643+ return err
644644+ }
645645+ if b != cbg.CborNull[0] {
646646+ if err := cr.UnreadByte(); err != nil {
647647+ return err
648648+ }
649649+ t.Annotations = new(Manifest_Annotations)
650650+ if err := t.Annotations.UnmarshalCBOR(cr); err != nil {
651651+ return xerrors.Errorf("unmarshaling t.Annotations pointer: %w", err)
652652+ }
653653+ }
654654+655655+ }
656656+ // t.HoldEndpoint (string) (string)
657657+ case "holdEndpoint":
658658+659659+ {
660660+ b, err := cr.ReadByte()
661661+ if err != nil {
662662+ return err
663663+ }
664664+ if b != cbg.CborNull[0] {
665665+ if err := cr.UnreadByte(); err != nil {
666666+ return err
667667+ }
668668+669669+ sval, err := cbg.ReadStringWithMax(cr, 8192)
670670+ if err != nil {
671671+ return err
672672+ }
673673+674674+ t.HoldEndpoint = (*string)(&sval)
675675+ }
676676+ }
677677+ // t.ManifestBlob (util.LexBlob) (struct)
678678+ case "manifestBlob":
679679+680680+ {
681681+682682+ b, err := cr.ReadByte()
683683+ if err != nil {
684684+ return err
685685+ }
686686+ if b != cbg.CborNull[0] {
687687+ if err := cr.UnreadByte(); err != nil {
688688+ return err
689689+ }
690690+ t.ManifestBlob = new(util.LexBlob)
691691+ if err := t.ManifestBlob.UnmarshalCBOR(cr); err != nil {
692692+ return xerrors.Errorf("unmarshaling t.ManifestBlob pointer: %w", err)
693693+ }
694694+ }
695695+696696+ }
697697+ // t.SchemaVersion (int64) (int64)
698698+ case "schemaVersion":
699699+ {
700700+ maj, extra, err := cr.ReadHeader()
701701+ if err != nil {
702702+ return err
703703+ }
704704+ var extraI int64
705705+ switch maj {
706706+ case cbg.MajUnsignedInt:
707707+ extraI = int64(extra)
708708+ if extraI < 0 {
709709+ return fmt.Errorf("int64 positive overflow")
710710+ }
711711+ case cbg.MajNegativeInt:
712712+ extraI = int64(extra)
713713+ if extraI < 0 {
714714+ return fmt.Errorf("int64 negative overflow")
715715+ }
716716+ extraI = -1 - extraI
717717+ default:
718718+ return fmt.Errorf("wrong type for int64 field: %d", maj)
719719+ }
720720+721721+ t.SchemaVersion = int64(extraI)
722722+ }
723723+724724+ default:
725725+ // Field doesn't exist on this type, so ignore it
726726+ if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
727727+ return err
728728+ }
729729+ }
730730+ }
731731+732732+ return nil
733733+}
734734+func (t *Manifest_BlobReference) MarshalCBOR(w io.Writer) error {
735735+ if t == nil {
736736+ _, err := w.Write(cbg.CborNull)
737737+ return err
738738+ }
739739+740740+ cw := cbg.NewCborWriter(w)
741741+ fieldCount := 6
742742+743743+ if t.LexiconTypeID == "" {
744744+ fieldCount--
745745+ }
746746+747747+ if t.Annotations == nil {
748748+ fieldCount--
749749+ }
750750+751751+ if t.Urls == nil {
752752+ fieldCount--
753753+ }
754754+755755+ if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
756756+ return err
757757+ }
758758+759759+ // t.Size (int64) (int64)
760760+ if len("size") > 8192 {
761761+ return xerrors.Errorf("Value in field \"size\" was too long")
762762+ }
763763+764764+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("size"))); err != nil {
765765+ return err
766766+ }
767767+ if _, err := cw.WriteString(string("size")); err != nil {
768768+ return err
769769+ }
770770+771771+ if t.Size >= 0 {
772772+ if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Size)); err != nil {
773773+ return err
774774+ }
775775+ } else {
776776+ if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Size-1)); err != nil {
777777+ return err
778778+ }
779779+ }
780780+781781+ // t.Urls ([]string) (slice)
782782+ if t.Urls != nil {
783783+784784+ if len("urls") > 8192 {
785785+ return xerrors.Errorf("Value in field \"urls\" was too long")
786786+ }
787787+788788+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("urls"))); err != nil {
789789+ return err
790790+ }
791791+ if _, err := cw.WriteString(string("urls")); err != nil {
792792+ return err
793793+ }
794794+795795+ if len(t.Urls) > 8192 {
796796+ return xerrors.Errorf("Slice value in field t.Urls was too long")
797797+ }
798798+799799+ if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Urls))); err != nil {
800800+ return err
801801+ }
802802+ for _, v := range t.Urls {
803803+ if len(v) > 8192 {
804804+ return xerrors.Errorf("Value in field v was too long")
805805+ }
806806+807807+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
808808+ return err
809809+ }
810810+ if _, err := cw.WriteString(string(v)); err != nil {
811811+ return err
812812+ }
813813+814814+ }
815815+ }
816816+817817+ // t.LexiconTypeID (string) (string)
818818+ if t.LexiconTypeID != "" {
819819+820820+ if len("$type") > 8192 {
821821+ return xerrors.Errorf("Value in field \"$type\" was too long")
822822+ }
823823+824824+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
825825+ return err
826826+ }
827827+ if _, err := cw.WriteString(string("$type")); err != nil {
828828+ return err
829829+ }
830830+831831+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("io.atcr.manifest#blobReference"))); err != nil {
832832+ return err
833833+ }
834834+ if _, err := cw.WriteString(string("io.atcr.manifest#blobReference")); err != nil {
835835+ return err
836836+ }
837837+ }
838838+839839+ // t.Digest (string) (string)
840840+ if len("digest") > 8192 {
841841+ return xerrors.Errorf("Value in field \"digest\" was too long")
842842+ }
843843+844844+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("digest"))); err != nil {
845845+ return err
846846+ }
847847+ if _, err := cw.WriteString(string("digest")); err != nil {
848848+ return err
849849+ }
850850+851851+ if len(t.Digest) > 8192 {
852852+ return xerrors.Errorf("Value in field t.Digest was too long")
853853+ }
854854+855855+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Digest))); err != nil {
856856+ return err
857857+ }
858858+ if _, err := cw.WriteString(string(t.Digest)); err != nil {
859859+ return err
860860+ }
861861+862862+ // t.MediaType (string) (string)
863863+ if len("mediaType") > 8192 {
864864+ return xerrors.Errorf("Value in field \"mediaType\" was too long")
865865+ }
866866+867867+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mediaType"))); err != nil {
868868+ return err
869869+ }
870870+ if _, err := cw.WriteString(string("mediaType")); err != nil {
871871+ return err
872872+ }
873873+874874+ if len(t.MediaType) > 8192 {
875875+ return xerrors.Errorf("Value in field t.MediaType was too long")
876876+ }
877877+878878+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.MediaType))); err != nil {
879879+ return err
880880+ }
881881+ if _, err := cw.WriteString(string(t.MediaType)); err != nil {
882882+ return err
883883+ }
884884+885885+ // t.Annotations (atproto.Manifest_BlobReference_Annotations) (struct)
886886+ if t.Annotations != nil {
887887+888888+ if len("annotations") > 8192 {
889889+ return xerrors.Errorf("Value in field \"annotations\" was too long")
890890+ }
891891+892892+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("annotations"))); err != nil {
893893+ return err
894894+ }
895895+ if _, err := cw.WriteString(string("annotations")); err != nil {
896896+ return err
897897+ }
898898+899899+ if err := t.Annotations.MarshalCBOR(cw); err != nil {
900900+ return err
901901+ }
902902+ }
903903+ return nil
904904+}
905905+906906+func (t *Manifest_BlobReference) UnmarshalCBOR(r io.Reader) (err error) {
907907+ *t = Manifest_BlobReference{}
908908+909909+ cr := cbg.NewCborReader(r)
910910+911911+ maj, extra, err := cr.ReadHeader()
912912+ if err != nil {
913913+ return err
914914+ }
915915+ defer func() {
916916+ if err == io.EOF {
917917+ err = io.ErrUnexpectedEOF
918918+ }
919919+ }()
920920+921921+ if maj != cbg.MajMap {
922922+ return fmt.Errorf("cbor input should be of type map")
923923+ }
924924+925925+ if extra > cbg.MaxLength {
926926+ return fmt.Errorf("Manifest_BlobReference: map struct too large (%d)", extra)
181927 }
182928183929 n := extra
···198944 }
199945200946 switch string(nameBuf[:nameLen]) {
201201- // t.Role (string) (string)
202202- case "role":
947947+ // t.Size (int64) (int64)
948948+ case "size":
949949+ {
950950+ maj, extra, err := cr.ReadHeader()
951951+ if err != nil {
952952+ return err
953953+ }
954954+ var extraI int64
955955+ switch maj {
956956+ case cbg.MajUnsignedInt:
957957+ extraI = int64(extra)
958958+ if extraI < 0 {
959959+ return fmt.Errorf("int64 positive overflow")
960960+ }
961961+ case cbg.MajNegativeInt:
962962+ extraI = int64(extra)
963963+ if extraI < 0 {
964964+ return fmt.Errorf("int64 negative overflow")
965965+ }
966966+ extraI = -1 - extraI
967967+ default:
968968+ return fmt.Errorf("wrong type for int64 field: %d", maj)
969969+ }
970970+971971+ t.Size = int64(extraI)
972972+ }
973973+ // t.Urls ([]string) (slice)
974974+ case "urls":
975975+976976+ maj, extra, err = cr.ReadHeader()
977977+ if err != nil {
978978+ return err
979979+ }
980980+981981+ if extra > 8192 {
982982+ return fmt.Errorf("t.Urls: array too large (%d)", extra)
983983+ }
984984+985985+ if maj != cbg.MajArray {
986986+ return fmt.Errorf("expected cbor array")
987987+ }
988988+989989+ if extra > 0 {
990990+ t.Urls = make([]string, extra)
991991+ }
992992+993993+ for i := 0; i < int(extra); i++ {
994994+ {
995995+ var maj byte
996996+ var extra uint64
997997+ var err error
998998+ _ = maj
999999+ _ = extra
10001000+ _ = err
10011001+10021002+ {
10031003+ sval, err := cbg.ReadStringWithMax(cr, 8192)
10041004+ if err != nil {
10051005+ return err
10061006+ }
10071007+10081008+ t.Urls[i] = string(sval)
10091009+ }
10101010+10111011+ }
10121012+ }
10131013+ // t.LexiconTypeID (string) (string)
10141014+ case "$type":
20310152041016 {
2051017 sval, err := cbg.ReadStringWithMax(cr, 8192)
···2071019 return err
2081020 }
2091021210210- t.Role = string(sval)
10221022+ t.LexiconTypeID = string(sval)
10231023+ }
10241024+ // t.Digest (string) (string)
10251025+ case "digest":
10261026+10271027+ {
10281028+ sval, err := cbg.ReadStringWithMax(cr, 8192)
10291029+ if err != nil {
10301030+ return err
10311031+ }
10321032+10331033+ t.Digest = string(sval)
10341034+ }
10351035+ // t.MediaType (string) (string)
10361036+ case "mediaType":
10371037+10381038+ {
10391039+ sval, err := cbg.ReadStringWithMax(cr, 8192)
10401040+ if err != nil {
10411041+ return err
10421042+ }
10431043+10441044+ t.MediaType = string(sval)
10451045+ }
10461046+ // t.Annotations (atproto.Manifest_BlobReference_Annotations) (struct)
10471047+ case "annotations":
10481048+10491049+ {
10501050+10511051+ b, err := cr.ReadByte()
10521052+ if err != nil {
10531053+ return err
10541054+ }
10551055+ if b != cbg.CborNull[0] {
10561056+ if err := cr.UnreadByte(); err != nil {
10571057+ return err
10581058+ }
10591059+ t.Annotations = new(Manifest_BlobReference_Annotations)
10601060+ if err := t.Annotations.UnmarshalCBOR(cr); err != nil {
10611061+ return xerrors.Errorf("unmarshaling t.Annotations pointer: %w", err)
10621062+ }
10631063+ }
10641064+10651065+ }
10661066+10671067+ default:
10681068+ // Field doesn't exist on this type, so ignore it
10691069+ if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
10701070+ return err
10711071+ }
10721072+ }
10731073+ }
10741074+10751075+ return nil
10761076+}
10771077+func (t *Manifest_ManifestReference) MarshalCBOR(w io.Writer) error {
10781078+ if t == nil {
10791079+ _, err := w.Write(cbg.CborNull)
10801080+ return err
10811081+ }
10821082+10831083+ cw := cbg.NewCborWriter(w)
10841084+ fieldCount := 6
10851085+10861086+ if t.LexiconTypeID == "" {
10871087+ fieldCount--
10881088+ }
10891089+10901090+ if t.Annotations == nil {
10911091+ fieldCount--
10921092+ }
10931093+10941094+ if t.Platform == nil {
10951095+ fieldCount--
10961096+ }
10971097+10981098+ if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
10991099+ return err
11001100+ }
11011101+11021102+ // t.Size (int64) (int64)
11031103+ if len("size") > 8192 {
11041104+ return xerrors.Errorf("Value in field \"size\" was too long")
11051105+ }
11061106+11071107+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("size"))); err != nil {
11081108+ return err
11091109+ }
11101110+ if _, err := cw.WriteString(string("size")); err != nil {
11111111+ return err
11121112+ }
11131113+11141114+ if t.Size >= 0 {
11151115+ if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Size)); err != nil {
11161116+ return err
11171117+ }
11181118+ } else {
11191119+ if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Size-1)); err != nil {
11201120+ return err
11211121+ }
11221122+ }
11231123+11241124+ // t.LexiconTypeID (string) (string)
11251125+ if t.LexiconTypeID != "" {
11261126+11271127+ if len("$type") > 8192 {
11281128+ return xerrors.Errorf("Value in field \"$type\" was too long")
11291129+ }
11301130+11311131+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
11321132+ return err
11331133+ }
11341134+ if _, err := cw.WriteString(string("$type")); err != nil {
11351135+ return err
11361136+ }
11371137+11381138+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("io.atcr.manifest#manifestReference"))); err != nil {
11391139+ return err
11401140+ }
11411141+ if _, err := cw.WriteString(string("io.atcr.manifest#manifestReference")); err != nil {
11421142+ return err
11431143+ }
11441144+ }
11451145+11461146+ // t.Digest (string) (string)
11471147+ if len("digest") > 8192 {
11481148+ return xerrors.Errorf("Value in field \"digest\" was too long")
11491149+ }
11501150+11511151+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("digest"))); err != nil {
11521152+ return err
11531153+ }
11541154+ if _, err := cw.WriteString(string("digest")); err != nil {
11551155+ return err
11561156+ }
11571157+11581158+ if len(t.Digest) > 8192 {
11591159+ return xerrors.Errorf("Value in field t.Digest was too long")
11601160+ }
11611161+11621162+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Digest))); err != nil {
11631163+ return err
11641164+ }
11651165+ if _, err := cw.WriteString(string(t.Digest)); err != nil {
11661166+ return err
11671167+ }
11681168+11691169+ // t.Platform (atproto.Manifest_Platform) (struct)
11701170+ if t.Platform != nil {
11711171+11721172+ if len("platform") > 8192 {
11731173+ return xerrors.Errorf("Value in field \"platform\" was too long")
11741174+ }
11751175+11761176+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("platform"))); err != nil {
11771177+ return err
11781178+ }
11791179+ if _, err := cw.WriteString(string("platform")); err != nil {
11801180+ return err
11811181+ }
11821182+11831183+ if err := t.Platform.MarshalCBOR(cw); err != nil {
11841184+ return err
11851185+ }
11861186+ }
11871187+11881188+ // t.MediaType (string) (string)
11891189+ if len("mediaType") > 8192 {
11901190+ return xerrors.Errorf("Value in field \"mediaType\" was too long")
11911191+ }
11921192+11931193+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mediaType"))); err != nil {
11941194+ return err
11951195+ }
11961196+ if _, err := cw.WriteString(string("mediaType")); err != nil {
11971197+ return err
11981198+ }
11991199+12001200+ if len(t.MediaType) > 8192 {
12011201+ return xerrors.Errorf("Value in field t.MediaType was too long")
12021202+ }
12031203+12041204+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.MediaType))); err != nil {
12051205+ return err
12061206+ }
12071207+ if _, err := cw.WriteString(string(t.MediaType)); err != nil {
12081208+ return err
12091209+ }
12101210+12111211+ // t.Annotations (atproto.Manifest_ManifestReference_Annotations) (struct)
12121212+ if t.Annotations != nil {
12131213+12141214+ if len("annotations") > 8192 {
12151215+ return xerrors.Errorf("Value in field \"annotations\" was too long")
12161216+ }
12171217+12181218+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("annotations"))); err != nil {
12191219+ return err
12201220+ }
12211221+ if _, err := cw.WriteString(string("annotations")); err != nil {
12221222+ return err
12231223+ }
12241224+12251225+ if err := t.Annotations.MarshalCBOR(cw); err != nil {
12261226+ return err
12271227+ }
12281228+ }
12291229+ return nil
12301230+}
12311231+12321232+func (t *Manifest_ManifestReference) UnmarshalCBOR(r io.Reader) (err error) {
12331233+ *t = Manifest_ManifestReference{}
12341234+12351235+ cr := cbg.NewCborReader(r)
12361236+12371237+ maj, extra, err := cr.ReadHeader()
12381238+ if err != nil {
12391239+ return err
12401240+ }
12411241+ defer func() {
12421242+ if err == io.EOF {
12431243+ err = io.ErrUnexpectedEOF
12441244+ }
12451245+ }()
12461246+12471247+ if maj != cbg.MajMap {
12481248+ return fmt.Errorf("cbor input should be of type map")
12491249+ }
12501250+12511251+ if extra > cbg.MaxLength {
12521252+ return fmt.Errorf("Manifest_ManifestReference: map struct too large (%d)", extra)
12531253+ }
12541254+12551255+ n := extra
12561256+12571257+ nameBuf := make([]byte, 11)
12581258+ for i := uint64(0); i < n; i++ {
12591259+ nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192)
12601260+ if err != nil {
12611261+ return err
12621262+ }
12631263+12641264+ if !ok {
12651265+ // Field doesn't exist on this type, so ignore it
12661266+ if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
12671267+ return err
12681268+ }
12691269+ continue
12701270+ }
12711271+12721272+ switch string(nameBuf[:nameLen]) {
12731273+ // t.Size (int64) (int64)
12741274+ case "size":
12751275+ {
12761276+ maj, extra, err := cr.ReadHeader()
12771277+ if err != nil {
12781278+ return err
12791279+ }
12801280+ var extraI int64
12811281+ switch maj {
12821282+ case cbg.MajUnsignedInt:
12831283+ extraI = int64(extra)
12841284+ if extraI < 0 {
12851285+ return fmt.Errorf("int64 positive overflow")
12861286+ }
12871287+ case cbg.MajNegativeInt:
12881288+ extraI = int64(extra)
12891289+ if extraI < 0 {
12901290+ return fmt.Errorf("int64 negative overflow")
12911291+ }
12921292+ extraI = -1 - extraI
12931293+ default:
12941294+ return fmt.Errorf("wrong type for int64 field: %d", maj)
12951295+ }
12961296+12971297+ t.Size = int64(extraI)
2111298 }
212212- // t.Type (string) (string)
12991299+ // t.LexiconTypeID (string) (string)
2131300 case "$type":
21413012151302 {
···2181305 return err
2191306 }
2201307221221- t.Type = string(sval)
13081308+ t.LexiconTypeID = string(sval)
13091309+ }
13101310+ // t.Digest (string) (string)
13111311+ case "digest":
13121312+13131313+ {
13141314+ sval, err := cbg.ReadStringWithMax(cr, 8192)
13151315+ if err != nil {
13161316+ return err
13171317+ }
13181318+13191319+ t.Digest = string(sval)
13201320+ }
13211321+ // t.Platform (atproto.Manifest_Platform) (struct)
13221322+ case "platform":
13231323+13241324+ {
13251325+13261326+ b, err := cr.ReadByte()
13271327+ if err != nil {
13281328+ return err
13291329+ }
13301330+ if b != cbg.CborNull[0] {
13311331+ if err := cr.UnreadByte(); err != nil {
13321332+ return err
13331333+ }
13341334+ t.Platform = new(Manifest_Platform)
13351335+ if err := t.Platform.UnmarshalCBOR(cr); err != nil {
13361336+ return xerrors.Errorf("unmarshaling t.Platform pointer: %w", err)
13371337+ }
13381338+ }
13391339+13401340+ }
13411341+ // t.MediaType (string) (string)
13421342+ case "mediaType":
13431343+13441344+ {
13451345+ sval, err := cbg.ReadStringWithMax(cr, 8192)
13461346+ if err != nil {
13471347+ return err
13481348+ }
13491349+13501350+ t.MediaType = string(sval)
13511351+ }
13521352+ // t.Annotations (atproto.Manifest_ManifestReference_Annotations) (struct)
13531353+ case "annotations":
13541354+13551355+ {
13561356+13571357+ b, err := cr.ReadByte()
13581358+ if err != nil {
13591359+ return err
13601360+ }
13611361+ if b != cbg.CborNull[0] {
13621362+ if err := cr.UnreadByte(); err != nil {
13631363+ return err
13641364+ }
13651365+ t.Annotations = new(Manifest_ManifestReference_Annotations)
13661366+ if err := t.Annotations.UnmarshalCBOR(cr); err != nil {
13671367+ return xerrors.Errorf("unmarshaling t.Annotations pointer: %w", err)
13681368+ }
13691369+ }
13701370+13711371+ }
13721372+13731373+ default:
13741374+ // Field doesn't exist on this type, so ignore it
13751375+ if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
13761376+ return err
13771377+ }
13781378+ }
13791379+ }
13801380+13811381+ return nil
13821382+}
13831383+func (t *Manifest_Platform) MarshalCBOR(w io.Writer) error {
13841384+ if t == nil {
13851385+ _, err := w.Write(cbg.CborNull)
13861386+ return err
13871387+ }
13881388+13891389+ cw := cbg.NewCborWriter(w)
13901390+ fieldCount := 6
13911391+13921392+ if t.LexiconTypeID == "" {
13931393+ fieldCount--
13941394+ }
13951395+13961396+ if t.OsFeatures == nil {
13971397+ fieldCount--
13981398+ }
13991399+14001400+ if t.OsVersion == nil {
14011401+ fieldCount--
14021402+ }
14031403+14041404+ if t.Variant == nil {
14051405+ fieldCount--
14061406+ }
14071407+14081408+ if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
14091409+ return err
14101410+ }
14111411+14121412+ // t.Os (string) (string)
14131413+ if len("os") > 8192 {
14141414+ return xerrors.Errorf("Value in field \"os\" was too long")
14151415+ }
14161416+14171417+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("os"))); err != nil {
14181418+ return err
14191419+ }
14201420+ if _, err := cw.WriteString(string("os")); err != nil {
14211421+ return err
14221422+ }
14231423+14241424+ if len(t.Os) > 8192 {
14251425+ return xerrors.Errorf("Value in field t.Os was too long")
14261426+ }
14271427+14281428+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Os))); err != nil {
14291429+ return err
14301430+ }
14311431+ if _, err := cw.WriteString(string(t.Os)); err != nil {
14321432+ return err
14331433+ }
14341434+14351435+ // t.LexiconTypeID (string) (string)
14361436+ if t.LexiconTypeID != "" {
14371437+14381438+ if len("$type") > 8192 {
14391439+ return xerrors.Errorf("Value in field \"$type\" was too long")
14401440+ }
14411441+14421442+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
14431443+ return err
14441444+ }
14451445+ if _, err := cw.WriteString(string("$type")); err != nil {
14461446+ return err
14471447+ }
14481448+14491449+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("io.atcr.manifest#platform"))); err != nil {
14501450+ return err
14511451+ }
14521452+ if _, err := cw.WriteString(string("io.atcr.manifest#platform")); err != nil {
14531453+ return err
14541454+ }
14551455+ }
14561456+14571457+ // t.Variant (string) (string)
14581458+ if t.Variant != nil {
14591459+14601460+ if len("variant") > 8192 {
14611461+ return xerrors.Errorf("Value in field \"variant\" was too long")
14621462+ }
14631463+14641464+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("variant"))); err != nil {
14651465+ return err
14661466+ }
14671467+ if _, err := cw.WriteString(string("variant")); err != nil {
14681468+ return err
14691469+ }
14701470+14711471+ if t.Variant == nil {
14721472+ if _, err := cw.Write(cbg.CborNull); err != nil {
14731473+ return err
2221474 }
223223- // t.Member (string) (string)
224224- case "member":
14751475+ } else {
14761476+ if len(*t.Variant) > 8192 {
14771477+ return xerrors.Errorf("Value in field t.Variant was too long")
14781478+ }
14791479+14801480+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Variant))); err != nil {
14811481+ return err
14821482+ }
14831483+ if _, err := cw.WriteString(string(*t.Variant)); err != nil {
14841484+ return err
14851485+ }
14861486+ }
14871487+ }
14881488+14891489+ // t.OsVersion (string) (string)
14901490+ if t.OsVersion != nil {
14911491+14921492+ if len("osVersion") > 8192 {
14931493+ return xerrors.Errorf("Value in field \"osVersion\" was too long")
14941494+ }
14951495+14961496+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("osVersion"))); err != nil {
14971497+ return err
14981498+ }
14991499+ if _, err := cw.WriteString(string("osVersion")); err != nil {
15001500+ return err
15011501+ }
15021502+15031503+ if t.OsVersion == nil {
15041504+ if _, err := cw.Write(cbg.CborNull); err != nil {
15051505+ return err
15061506+ }
15071507+ } else {
15081508+ if len(*t.OsVersion) > 8192 {
15091509+ return xerrors.Errorf("Value in field t.OsVersion was too long")
15101510+ }
15111511+15121512+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.OsVersion))); err != nil {
15131513+ return err
15141514+ }
15151515+ if _, err := cw.WriteString(string(*t.OsVersion)); err != nil {
15161516+ return err
15171517+ }
15181518+ }
15191519+ }
15201520+15211521+ // t.OsFeatures ([]string) (slice)
15221522+ if t.OsFeatures != nil {
15231523+15241524+ if len("osFeatures") > 8192 {
15251525+ return xerrors.Errorf("Value in field \"osFeatures\" was too long")
15261526+ }
15271527+15281528+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("osFeatures"))); err != nil {
15291529+ return err
15301530+ }
15311531+ if _, err := cw.WriteString(string("osFeatures")); err != nil {
15321532+ return err
15331533+ }
15341534+15351535+ if len(t.OsFeatures) > 8192 {
15361536+ return xerrors.Errorf("Slice value in field t.OsFeatures was too long")
15371537+ }
15381538+15391539+ if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.OsFeatures))); err != nil {
15401540+ return err
15411541+ }
15421542+ for _, v := range t.OsFeatures {
15431543+ if len(v) > 8192 {
15441544+ return xerrors.Errorf("Value in field v was too long")
15451545+ }
15461546+15471547+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
15481548+ return err
15491549+ }
15501550+ if _, err := cw.WriteString(string(v)); err != nil {
15511551+ return err
15521552+ }
15531553+15541554+ }
15551555+ }
15561556+15571557+ // t.Architecture (string) (string)
15581558+ if len("architecture") > 8192 {
15591559+ return xerrors.Errorf("Value in field \"architecture\" was too long")
15601560+ }
15611561+15621562+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("architecture"))); err != nil {
15631563+ return err
15641564+ }
15651565+ if _, err := cw.WriteString(string("architecture")); err != nil {
15661566+ return err
15671567+ }
15681568+15691569+ if len(t.Architecture) > 8192 {
15701570+ return xerrors.Errorf("Value in field t.Architecture was too long")
15711571+ }
15721572+15731573+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Architecture))); err != nil {
15741574+ return err
15751575+ }
15761576+ if _, err := cw.WriteString(string(t.Architecture)); err != nil {
15771577+ return err
15781578+ }
15791579+ return nil
15801580+}
15811581+15821582+func (t *Manifest_Platform) UnmarshalCBOR(r io.Reader) (err error) {
15831583+ *t = Manifest_Platform{}
15841584+15851585+ cr := cbg.NewCborReader(r)
15861586+15871587+ maj, extra, err := cr.ReadHeader()
15881588+ if err != nil {
15891589+ return err
15901590+ }
15911591+ defer func() {
15921592+ if err == io.EOF {
15931593+ err = io.ErrUnexpectedEOF
15941594+ }
15951595+ }()
15961596+15971597+ if maj != cbg.MajMap {
15981598+ return fmt.Errorf("cbor input should be of type map")
15991599+ }
16001600+16011601+ if extra > cbg.MaxLength {
16021602+ return fmt.Errorf("Manifest_Platform: map struct too large (%d)", extra)
16031603+ }
16041604+16051605+ n := extra
16061606+16071607+ nameBuf := make([]byte, 12)
16081608+ for i := uint64(0); i < n; i++ {
16091609+ nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192)
16101610+ if err != nil {
16111611+ return err
16121612+ }
16131613+16141614+ if !ok {
16151615+ // Field doesn't exist on this type, so ignore it
16161616+ if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
16171617+ return err
16181618+ }
16191619+ continue
16201620+ }
16211621+16221622+ switch string(nameBuf[:nameLen]) {
16231623+ // t.Os (string) (string)
16241624+ case "os":
22516252261626 {
2271627 sval, err := cbg.ReadStringWithMax(cr, 8192)
···2291629 return err
2301630 }
2311631232232- t.Member = string(sval)
16321632+ t.Os = string(sval)
2331633 }
234234- // t.AddedAt (string) (string)
235235- case "addedAt":
16341634+ // t.LexiconTypeID (string) (string)
16351635+ case "$type":
23616362371637 {
2381638 sval, err := cbg.ReadStringWithMax(cr, 8192)
···2401640 return err
2411641 }
2421642243243- t.AddedAt = string(sval)
16431643+ t.LexiconTypeID = string(sval)
2441644 }
245245- // t.Permissions ([]string) (slice)
246246- case "permissions":
16451645+ // t.Variant (string) (string)
16461646+ case "variant":
16471647+16481648+ {
16491649+ b, err := cr.ReadByte()
16501650+ if err != nil {
16511651+ return err
16521652+ }
16531653+ if b != cbg.CborNull[0] {
16541654+ if err := cr.UnreadByte(); err != nil {
16551655+ return err
16561656+ }
16571657+16581658+ sval, err := cbg.ReadStringWithMax(cr, 8192)
16591659+ if err != nil {
16601660+ return err
16611661+ }
16621662+16631663+ t.Variant = (*string)(&sval)
16641664+ }
16651665+ }
16661666+ // t.OsVersion (string) (string)
16671667+ case "osVersion":
16681668+16691669+ {
16701670+ b, err := cr.ReadByte()
16711671+ if err != nil {
16721672+ return err
16731673+ }
16741674+ if b != cbg.CborNull[0] {
16751675+ if err := cr.UnreadByte(); err != nil {
16761676+ return err
16771677+ }
16781678+16791679+ sval, err := cbg.ReadStringWithMax(cr, 8192)
16801680+ if err != nil {
16811681+ return err
16821682+ }
16831683+16841684+ t.OsVersion = (*string)(&sval)
16851685+ }
16861686+ }
16871687+ // t.OsFeatures ([]string) (slice)
16881688+ case "osFeatures":
24716892481690 maj, extra, err = cr.ReadHeader()
2491691 if err != nil {
···2511693 }
25216942531695 if extra > 8192 {
254254- return fmt.Errorf("t.Permissions: array too large (%d)", extra)
16961696+ return fmt.Errorf("t.OsFeatures: array too large (%d)", extra)
2551697 }
25616982571699 if maj != cbg.MajArray {
···2591701 }
26017022611703 if extra > 0 {
262262- t.Permissions = make([]string, extra)
17041704+ t.OsFeatures = make([]string, extra)
2631705 }
26417062651707 for i := 0; i < int(extra); i++ {
···2771719 return err
2781720 }
2791721280280- t.Permissions[i] = string(sval)
17221722+ t.OsFeatures[i] = string(sval)
17231723+ }
17241724+17251725+ }
17261726+ }
17271727+ // t.Architecture (string) (string)
17281728+ case "architecture":
17291729+17301730+ {
17311731+ sval, err := cbg.ReadStringWithMax(cr, 8192)
17321732+ if err != nil {
17331733+ return err
17341734+ }
17351735+17361736+ t.Architecture = string(sval)
17371737+ }
17381738+17391739+ default:
17401740+ // Field doesn't exist on this type, so ignore it
17411741+ if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
17421742+ return err
17431743+ }
17441744+ }
17451745+ }
17461746+17471747+ return nil
17481748+}
17491749+func (t *Manifest_Annotations) MarshalCBOR(w io.Writer) error {
17501750+ if t == nil {
17511751+ _, err := w.Write(cbg.CborNull)
17521752+ return err
17531753+ }
17541754+17551755+ cw := cbg.NewCborWriter(w)
17561756+17571757+ if _, err := cw.Write([]byte{160}); err != nil {
17581758+ return err
17591759+ }
17601760+ return nil
17611761+}
17621762+17631763+func (t *Manifest_Annotations) UnmarshalCBOR(r io.Reader) (err error) {
17641764+ *t = Manifest_Annotations{}
17651765+17661766+ cr := cbg.NewCborReader(r)
17671767+17681768+ maj, extra, err := cr.ReadHeader()
17691769+ if err != nil {
17701770+ return err
17711771+ }
17721772+ defer func() {
17731773+ if err == io.EOF {
17741774+ err = io.ErrUnexpectedEOF
17751775+ }
17761776+ }()
17771777+17781778+ if maj != cbg.MajMap {
17791779+ return fmt.Errorf("cbor input should be of type map")
17801780+ }
17811781+17821782+ if extra > cbg.MaxLength {
17831783+ return fmt.Errorf("Manifest_Annotations: map struct too large (%d)", extra)
17841784+ }
17851785+17861786+ n := extra
17871787+17881788+ nameBuf := make([]byte, 0)
17891789+ for i := uint64(0); i < n; i++ {
17901790+ nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192)
17911791+ if err != nil {
17921792+ return err
17931793+ }
17941794+17951795+ if !ok {
17961796+ // Field doesn't exist on this type, so ignore it
17971797+ if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
17981798+ return err
17991799+ }
18001800+ continue
18011801+ }
18021802+18031803+ switch string(nameBuf[:nameLen]) {
18041804+18051805+ default:
18061806+ // Field doesn't exist on this type, so ignore it
18071807+ if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
18081808+ return err
18091809+ }
18101810+ }
18111811+ }
18121812+18131813+ return nil
18141814+}
18151815+func (t *Manifest_BlobReference_Annotations) MarshalCBOR(w io.Writer) error {
18161816+ if t == nil {
18171817+ _, err := w.Write(cbg.CborNull)
18181818+ return err
18191819+ }
18201820+18211821+ cw := cbg.NewCborWriter(w)
18221822+18231823+ if _, err := cw.Write([]byte{160}); err != nil {
18241824+ return err
18251825+ }
18261826+ return nil
18271827+}
18281828+18291829+func (t *Manifest_BlobReference_Annotations) UnmarshalCBOR(r io.Reader) (err error) {
18301830+ *t = Manifest_BlobReference_Annotations{}
18311831+18321832+ cr := cbg.NewCborReader(r)
18331833+18341834+ maj, extra, err := cr.ReadHeader()
18351835+ if err != nil {
18361836+ return err
18371837+ }
18381838+ defer func() {
18391839+ if err == io.EOF {
18401840+ err = io.ErrUnexpectedEOF
18411841+ }
18421842+ }()
18431843+18441844+ if maj != cbg.MajMap {
18451845+ return fmt.Errorf("cbor input should be of type map")
18461846+ }
18471847+18481848+ if extra > cbg.MaxLength {
18491849+ return fmt.Errorf("Manifest_BlobReference_Annotations: map struct too large (%d)", extra)
18501850+ }
18511851+18521852+ n := extra
18531853+18541854+ nameBuf := make([]byte, 0)
18551855+ for i := uint64(0); i < n; i++ {
18561856+ nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192)
18571857+ if err != nil {
18581858+ return err
18591859+ }
18601860+18611861+ if !ok {
18621862+ // Field doesn't exist on this type, so ignore it
18631863+ if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
18641864+ return err
18651865+ }
18661866+ continue
18671867+ }
18681868+18691869+ switch string(nameBuf[:nameLen]) {
18701870+18711871+ default:
18721872+ // Field doesn't exist on this type, so ignore it
18731873+ if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
18741874+ return err
18751875+ }
18761876+ }
18771877+ }
18781878+18791879+ return nil
18801880+}
18811881+func (t *Manifest_ManifestReference_Annotations) MarshalCBOR(w io.Writer) error {
18821882+ if t == nil {
18831883+ _, err := w.Write(cbg.CborNull)
18841884+ return err
18851885+ }
18861886+18871887+ cw := cbg.NewCborWriter(w)
18881888+18891889+ if _, err := cw.Write([]byte{160}); err != nil {
18901890+ return err
18911891+ }
18921892+ return nil
18931893+}
18941894+18951895+func (t *Manifest_ManifestReference_Annotations) UnmarshalCBOR(r io.Reader) (err error) {
18961896+ *t = Manifest_ManifestReference_Annotations{}
18971897+18981898+ cr := cbg.NewCborReader(r)
18991899+19001900+ maj, extra, err := cr.ReadHeader()
19011901+ if err != nil {
19021902+ return err
19031903+ }
19041904+ defer func() {
19051905+ if err == io.EOF {
19061906+ err = io.ErrUnexpectedEOF
19071907+ }
19081908+ }()
19091909+19101910+ if maj != cbg.MajMap {
19111911+ return fmt.Errorf("cbor input should be of type map")
19121912+ }
19131913+19141914+ if extra > cbg.MaxLength {
19151915+ return fmt.Errorf("Manifest_ManifestReference_Annotations: map struct too large (%d)", extra)
19161916+ }
19171917+19181918+ n := extra
19191919+19201920+ nameBuf := make([]byte, 0)
19211921+ for i := uint64(0); i < n; i++ {
19221922+ nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192)
19231923+ if err != nil {
19241924+ return err
19251925+ }
19261926+19271927+ if !ok {
19281928+ // Field doesn't exist on this type, so ignore it
19291929+ if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
19301930+ return err
19311931+ }
19321932+ continue
19331933+ }
19341934+19351935+ switch string(nameBuf[:nameLen]) {
19361936+19371937+ default:
19381938+ // Field doesn't exist on this type, so ignore it
19391939+ if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
19401940+ return err
19411941+ }
19421942+ }
19431943+ }
19441944+19451945+ return nil
19461946+}
19471947+func (t *Tag) MarshalCBOR(w io.Writer) error {
19481948+ if t == nil {
19491949+ _, err := w.Write(cbg.CborNull)
19501950+ return err
19511951+ }
19521952+19531953+ cw := cbg.NewCborWriter(w)
19541954+ fieldCount := 6
19551955+19561956+ if t.Manifest == nil {
19571957+ fieldCount--
19581958+ }
19591959+19601960+ if t.ManifestDigest == nil {
19611961+ fieldCount--
19621962+ }
19631963+19641964+ if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
19651965+ return err
19661966+ }
19671967+19681968+ // t.Tag (string) (string)
19691969+ if len("tag") > 8192 {
19701970+ return xerrors.Errorf("Value in field \"tag\" was too long")
19711971+ }
19721972+19731973+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("tag"))); err != nil {
19741974+ return err
19751975+ }
19761976+ if _, err := cw.WriteString(string("tag")); err != nil {
19771977+ return err
19781978+ }
19791979+19801980+ if len(t.Tag) > 8192 {
19811981+ return xerrors.Errorf("Value in field t.Tag was too long")
19821982+ }
19831983+19841984+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Tag))); err != nil {
19851985+ return err
19861986+ }
19871987+ if _, err := cw.WriteString(string(t.Tag)); err != nil {
19881988+ return err
19891989+ }
19901990+19911991+ // t.LexiconTypeID (string) (string)
19921992+ if len("$type") > 8192 {
19931993+ return xerrors.Errorf("Value in field \"$type\" was too long")
19941994+ }
19951995+19961996+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
19971997+ return err
19981998+ }
19991999+ if _, err := cw.WriteString(string("$type")); err != nil {
20002000+ return err
20012001+ }
20022002+20032003+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("io.atcr.tag"))); err != nil {
20042004+ return err
20052005+ }
20062006+ if _, err := cw.WriteString(string("io.atcr.tag")); err != nil {
20072007+ return err
20082008+ }
20092009+20102010+ // t.Manifest (string) (string)
20112011+ if t.Manifest != nil {
20122012+20132013+ if len("manifest") > 8192 {
20142014+ return xerrors.Errorf("Value in field \"manifest\" was too long")
20152015+ }
20162016+20172017+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("manifest"))); err != nil {
20182018+ return err
20192019+ }
20202020+ if _, err := cw.WriteString(string("manifest")); err != nil {
20212021+ return err
20222022+ }
20232023+20242024+ if t.Manifest == nil {
20252025+ if _, err := cw.Write(cbg.CborNull); err != nil {
20262026+ return err
20272027+ }
20282028+ } else {
20292029+ if len(*t.Manifest) > 8192 {
20302030+ return xerrors.Errorf("Value in field t.Manifest was too long")
20312031+ }
20322032+20332033+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Manifest))); err != nil {
20342034+ return err
20352035+ }
20362036+ if _, err := cw.WriteString(string(*t.Manifest)); err != nil {
20372037+ return err
20382038+ }
20392039+ }
20402040+ }
20412041+20422042+ // t.CreatedAt (string) (string)
20432043+ if len("createdAt") > 8192 {
20442044+ return xerrors.Errorf("Value in field \"createdAt\" was too long")
20452045+ }
20462046+20472047+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
20482048+ return err
20492049+ }
20502050+ if _, err := cw.WriteString(string("createdAt")); err != nil {
20512051+ return err
20522052+ }
20532053+20542054+ if len(t.CreatedAt) > 8192 {
20552055+ return xerrors.Errorf("Value in field t.CreatedAt was too long")
20562056+ }
20572057+20582058+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
20592059+ return err
20602060+ }
20612061+ if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
20622062+ return err
20632063+ }
20642064+20652065+ // t.Repository (string) (string)
20662066+ if len("repository") > 8192 {
20672067+ return xerrors.Errorf("Value in field \"repository\" was too long")
20682068+ }
20692069+20702070+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repository"))); err != nil {
20712071+ return err
20722072+ }
20732073+ if _, err := cw.WriteString(string("repository")); err != nil {
20742074+ return err
20752075+ }
20762076+20772077+ if len(t.Repository) > 8192 {
20782078+ return xerrors.Errorf("Value in field t.Repository was too long")
20792079+ }
20802080+20812081+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repository))); err != nil {
20822082+ return err
20832083+ }
20842084+ if _, err := cw.WriteString(string(t.Repository)); err != nil {
20852085+ return err
20862086+ }
20872087+20882088+ // t.ManifestDigest (string) (string)
20892089+ if t.ManifestDigest != nil {
20902090+20912091+ if len("manifestDigest") > 8192 {
20922092+ return xerrors.Errorf("Value in field \"manifestDigest\" was too long")
20932093+ }
20942094+20952095+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("manifestDigest"))); err != nil {
20962096+ return err
20972097+ }
20982098+ if _, err := cw.WriteString(string("manifestDigest")); err != nil {
20992099+ return err
21002100+ }
21012101+21022102+ if t.ManifestDigest == nil {
21032103+ if _, err := cw.Write(cbg.CborNull); err != nil {
21042104+ return err
21052105+ }
21062106+ } else {
21072107+ if len(*t.ManifestDigest) > 8192 {
21082108+ return xerrors.Errorf("Value in field t.ManifestDigest was too long")
21092109+ }
21102110+21112111+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.ManifestDigest))); err != nil {
21122112+ return err
21132113+ }
21142114+ if _, err := cw.WriteString(string(*t.ManifestDigest)); err != nil {
21152115+ return err
21162116+ }
21172117+ }
21182118+ }
21192119+ return nil
21202120+}
21212121+21222122+func (t *Tag) UnmarshalCBOR(r io.Reader) (err error) {
21232123+ *t = Tag{}
21242124+21252125+ cr := cbg.NewCborReader(r)
21262126+21272127+ maj, extra, err := cr.ReadHeader()
21282128+ if err != nil {
21292129+ return err
21302130+ }
21312131+ defer func() {
21322132+ if err == io.EOF {
21332133+ err = io.ErrUnexpectedEOF
21342134+ }
21352135+ }()
21362136+21372137+ if maj != cbg.MajMap {
21382138+ return fmt.Errorf("cbor input should be of type map")
21392139+ }
21402140+21412141+ if extra > cbg.MaxLength {
21422142+ return fmt.Errorf("Tag: map struct too large (%d)", extra)
21432143+ }
21442144+21452145+ n := extra
21462146+21472147+ nameBuf := make([]byte, 14)
21482148+ for i := uint64(0); i < n; i++ {
21492149+ nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192)
21502150+ if err != nil {
21512151+ return err
21522152+ }
21532153+21542154+ if !ok {
21552155+ // Field doesn't exist on this type, so ignore it
21562156+ if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
21572157+ return err
21582158+ }
21592159+ continue
21602160+ }
21612161+21622162+ switch string(nameBuf[:nameLen]) {
21632163+ // t.Tag (string) (string)
21642164+ case "tag":
21652165+21662166+ {
21672167+ sval, err := cbg.ReadStringWithMax(cr, 8192)
21682168+ if err != nil {
21692169+ return err
21702170+ }
21712171+21722172+ t.Tag = string(sval)
21732173+ }
21742174+ // t.LexiconTypeID (string) (string)
21752175+ case "$type":
21762176+21772177+ {
21782178+ sval, err := cbg.ReadStringWithMax(cr, 8192)
21792179+ if err != nil {
21802180+ return err
21812181+ }
21822182+21832183+ t.LexiconTypeID = string(sval)
21842184+ }
21852185+ // t.Manifest (string) (string)
21862186+ case "manifest":
21872187+21882188+ {
21892189+ b, err := cr.ReadByte()
21902190+ if err != nil {
21912191+ return err
21922192+ }
21932193+ if b != cbg.CborNull[0] {
21942194+ if err := cr.UnreadByte(); err != nil {
21952195+ return err
2812196 }
282219721982198+ sval, err := cbg.ReadStringWithMax(cr, 8192)
21992199+ if err != nil {
22002200+ return err
22012201+ }
22022202+22032203+ t.Manifest = (*string)(&sval)
22042204+ }
22052205+ }
22062206+ // t.CreatedAt (string) (string)
22072207+ case "createdAt":
22082208+22092209+ {
22102210+ sval, err := cbg.ReadStringWithMax(cr, 8192)
22112211+ if err != nil {
22122212+ return err
22132213+ }
22142214+22152215+ t.CreatedAt = string(sval)
22162216+ }
22172217+ // t.Repository (string) (string)
22182218+ case "repository":
22192219+22202220+ {
22212221+ sval, err := cbg.ReadStringWithMax(cr, 8192)
22222222+ if err != nil {
22232223+ return err
22242224+ }
22252225+22262226+ t.Repository = string(sval)
22272227+ }
22282228+ // t.ManifestDigest (string) (string)
22292229+ case "manifestDigest":
22302230+22312231+ {
22322232+ b, err := cr.ReadByte()
22332233+ if err != nil {
22342234+ return err
22352235+ }
22362236+ if b != cbg.CborNull[0] {
22372237+ if err := cr.UnreadByte(); err != nil {
22382238+ return err
22392239+ }
22402240+22412241+ sval, err := cbg.ReadStringWithMax(cr, 8192)
22422242+ if err != nil {
22432243+ return err
22442244+ }
22452245+22462246+ t.ManifestDigest = (*string)(&sval)
2832247 }
2842248 }
2852249···29322572942258 return nil
2952259}
296296-func (t *CaptainRecord) MarshalCBOR(w io.Writer) error {
22602260+func (t *SailorProfile) MarshalCBOR(w io.Writer) error {
2972261 if t == nil {
2982262 _, err := w.Write(cbg.CborNull)
2992263 return err
3002264 }
30122653022266 cw := cbg.NewCborWriter(w)
303303- fieldCount := 8
22672267+ fieldCount := 4
3042268305305- if t.Region == "" {
22692269+ if t.DefaultHold == nil {
3062270 fieldCount--
3072271 }
3082272309309- if t.Provider == "" {
22732273+ if t.UpdatedAt == nil {
3102274 fieldCount--
3112275 }
3122276···3142278 return err
3152279 }
3162280317317- // t.Type (string) (string)
22812281+ // t.LexiconTypeID (string) (string)
3182282 if len("$type") > 8192 {
3192283 return xerrors.Errorf("Value in field \"$type\" was too long")
3202284 }
···3262290 return err
3272291 }
3282292329329- if len(t.Type) > 8192 {
330330- return xerrors.Errorf("Value in field t.Type was too long")
22932293+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("io.atcr.sailor.profile"))); err != nil {
22942294+ return err
22952295+ }
22962296+ if _, err := cw.WriteString(string("io.atcr.sailor.profile")); err != nil {
22972297+ return err
22982298+ }
22992299+23002300+ // t.CreatedAt (string) (string)
23012301+ if len("createdAt") > 8192 {
23022302+ return xerrors.Errorf("Value in field \"createdAt\" was too long")
3312303 }
3322304333333- if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Type))); err != nil {
23052305+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
3342306 return err
3352307 }
336336- if _, err := cw.WriteString(string(t.Type)); err != nil {
23082308+ if _, err := cw.WriteString(string("createdAt")); err != nil {
23092309+ return err
23102310+ }
23112311+23122312+ if len(t.CreatedAt) > 8192 {
23132313+ return xerrors.Errorf("Value in field t.CreatedAt was too long")
23142314+ }
23152315+23162316+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
23172317+ return err
23182318+ }
23192319+ if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
23202320+ return err
23212321+ }
23222322+23232323+ // t.UpdatedAt (string) (string)
23242324+ if t.UpdatedAt != nil {
23252325+23262326+ if len("updatedAt") > 8192 {
23272327+ return xerrors.Errorf("Value in field \"updatedAt\" was too long")
23282328+ }
23292329+23302330+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("updatedAt"))); err != nil {
23312331+ return err
23322332+ }
23332333+ if _, err := cw.WriteString(string("updatedAt")); err != nil {
23342334+ return err
23352335+ }
23362336+23372337+ if t.UpdatedAt == nil {
23382338+ if _, err := cw.Write(cbg.CborNull); err != nil {
23392339+ return err
23402340+ }
23412341+ } else {
23422342+ if len(*t.UpdatedAt) > 8192 {
23432343+ return xerrors.Errorf("Value in field t.UpdatedAt was too long")
23442344+ }
23452345+23462346+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.UpdatedAt))); err != nil {
23472347+ return err
23482348+ }
23492349+ if _, err := cw.WriteString(string(*t.UpdatedAt)); err != nil {
23502350+ return err
23512351+ }
23522352+ }
23532353+ }
23542354+23552355+ // t.DefaultHold (string) (string)
23562356+ if t.DefaultHold != nil {
23572357+23582358+ if len("defaultHold") > 8192 {
23592359+ return xerrors.Errorf("Value in field \"defaultHold\" was too long")
23602360+ }
23612361+23622362+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("defaultHold"))); err != nil {
23632363+ return err
23642364+ }
23652365+ if _, err := cw.WriteString(string("defaultHold")); err != nil {
23662366+ return err
23672367+ }
23682368+23692369+ if t.DefaultHold == nil {
23702370+ if _, err := cw.Write(cbg.CborNull); err != nil {
23712371+ return err
23722372+ }
23732373+ } else {
23742374+ if len(*t.DefaultHold) > 8192 {
23752375+ return xerrors.Errorf("Value in field t.DefaultHold was too long")
23762376+ }
23772377+23782378+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.DefaultHold))); err != nil {
23792379+ return err
23802380+ }
23812381+ if _, err := cw.WriteString(string(*t.DefaultHold)); err != nil {
23822382+ return err
23832383+ }
23842384+ }
23852385+ }
23862386+ return nil
23872387+}
23882388+23892389+func (t *SailorProfile) UnmarshalCBOR(r io.Reader) (err error) {
23902390+ *t = SailorProfile{}
23912391+23922392+ cr := cbg.NewCborReader(r)
23932393+23942394+ maj, extra, err := cr.ReadHeader()
23952395+ if err != nil {
23962396+ return err
23972397+ }
23982398+ defer func() {
23992399+ if err == io.EOF {
24002400+ err = io.ErrUnexpectedEOF
24012401+ }
24022402+ }()
24032403+24042404+ if maj != cbg.MajMap {
24052405+ return fmt.Errorf("cbor input should be of type map")
24062406+ }
24072407+24082408+ if extra > cbg.MaxLength {
24092409+ return fmt.Errorf("SailorProfile: map struct too large (%d)", extra)
24102410+ }
24112411+24122412+ n := extra
24132413+24142414+ nameBuf := make([]byte, 11)
24152415+ for i := uint64(0); i < n; i++ {
24162416+ nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192)
24172417+ if err != nil {
24182418+ return err
24192419+ }
24202420+24212421+ if !ok {
24222422+ // Field doesn't exist on this type, so ignore it
24232423+ if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
24242424+ return err
24252425+ }
24262426+ continue
24272427+ }
24282428+24292429+ switch string(nameBuf[:nameLen]) {
24302430+ // t.LexiconTypeID (string) (string)
24312431+ case "$type":
24322432+24332433+ {
24342434+ sval, err := cbg.ReadStringWithMax(cr, 8192)
24352435+ if err != nil {
24362436+ return err
24372437+ }
24382438+24392439+ t.LexiconTypeID = string(sval)
24402440+ }
24412441+ // t.CreatedAt (string) (string)
24422442+ case "createdAt":
24432443+24442444+ {
24452445+ sval, err := cbg.ReadStringWithMax(cr, 8192)
24462446+ if err != nil {
24472447+ return err
24482448+ }
24492449+24502450+ t.CreatedAt = string(sval)
24512451+ }
24522452+ // t.UpdatedAt (string) (string)
24532453+ case "updatedAt":
24542454+24552455+ {
24562456+ b, err := cr.ReadByte()
24572457+ if err != nil {
24582458+ return err
24592459+ }
24602460+ if b != cbg.CborNull[0] {
24612461+ if err := cr.UnreadByte(); err != nil {
24622462+ return err
24632463+ }
24642464+24652465+ sval, err := cbg.ReadStringWithMax(cr, 8192)
24662466+ if err != nil {
24672467+ return err
24682468+ }
24692469+24702470+ t.UpdatedAt = (*string)(&sval)
24712471+ }
24722472+ }
24732473+ // t.DefaultHold (string) (string)
24742474+ case "defaultHold":
24752475+24762476+ {
24772477+ b, err := cr.ReadByte()
24782478+ if err != nil {
24792479+ return err
24802480+ }
24812481+ if b != cbg.CborNull[0] {
24822482+ if err := cr.UnreadByte(); err != nil {
24832483+ return err
24842484+ }
24852485+24862486+ sval, err := cbg.ReadStringWithMax(cr, 8192)
24872487+ if err != nil {
24882488+ return err
24892489+ }
24902490+24912491+ t.DefaultHold = (*string)(&sval)
24922492+ }
24932493+ }
24942494+24952495+ default:
24962496+ // Field doesn't exist on this type, so ignore it
24972497+ if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
24982498+ return err
24992499+ }
25002500+ }
25012501+ }
25022502+25032503+ return nil
25042504+}
25052505+func (t *SailorStar) MarshalCBOR(w io.Writer) error {
25062506+ if t == nil {
25072507+ _, err := w.Write(cbg.CborNull)
25082508+ return err
25092509+ }
25102510+25112511+ cw := cbg.NewCborWriter(w)
25122512+25132513+ if _, err := cw.Write([]byte{163}); err != nil {
25142514+ return err
25152515+ }
25162516+25172517+ // t.LexiconTypeID (string) (string)
25182518+ if len("$type") > 8192 {
25192519+ return xerrors.Errorf("Value in field \"$type\" was too long")
25202520+ }
25212521+25222522+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
25232523+ return err
25242524+ }
25252525+ if _, err := cw.WriteString(string("$type")); err != nil {
25262526+ return err
25272527+ }
25282528+25292529+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("io.atcr.sailor.star"))); err != nil {
25302530+ return err
25312531+ }
25322532+ if _, err := cw.WriteString(string("io.atcr.sailor.star")); err != nil {
25332533+ return err
25342534+ }
25352535+25362536+ // t.Subject (atproto.SailorStar_Subject) (struct)
25372537+ if len("subject") > 8192 {
25382538+ return xerrors.Errorf("Value in field \"subject\" was too long")
25392539+ }
25402540+25412541+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil {
25422542+ return err
25432543+ }
25442544+ if _, err := cw.WriteString(string("subject")); err != nil {
25452545+ return err
25462546+ }
25472547+25482548+ if err := t.Subject.MarshalCBOR(cw); err != nil {
25492549+ return err
25502550+ }
25512551+25522552+ // t.CreatedAt (string) (string)
25532553+ if len("createdAt") > 8192 {
25542554+ return xerrors.Errorf("Value in field \"createdAt\" was too long")
25552555+ }
25562556+25572557+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
25582558+ return err
25592559+ }
25602560+ if _, err := cw.WriteString(string("createdAt")); err != nil {
25612561+ return err
25622562+ }
25632563+25642564+ if len(t.CreatedAt) > 8192 {
25652565+ return xerrors.Errorf("Value in field t.CreatedAt was too long")
25662566+ }
25672567+25682568+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
25692569+ return err
25702570+ }
25712571+ if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
25722572+ return err
25732573+ }
25742574+ return nil
25752575+}
25762576+25772577+func (t *SailorStar) UnmarshalCBOR(r io.Reader) (err error) {
25782578+ *t = SailorStar{}
25792579+25802580+ cr := cbg.NewCborReader(r)
25812581+25822582+ maj, extra, err := cr.ReadHeader()
25832583+ if err != nil {
25842584+ return err
25852585+ }
25862586+ defer func() {
25872587+ if err == io.EOF {
25882588+ err = io.ErrUnexpectedEOF
25892589+ }
25902590+ }()
25912591+25922592+ if maj != cbg.MajMap {
25932593+ return fmt.Errorf("cbor input should be of type map")
25942594+ }
25952595+25962596+ if extra > cbg.MaxLength {
25972597+ return fmt.Errorf("SailorStar: map struct too large (%d)", extra)
25982598+ }
25992599+26002600+ n := extra
26012601+26022602+ nameBuf := make([]byte, 9)
26032603+ for i := uint64(0); i < n; i++ {
26042604+ nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192)
26052605+ if err != nil {
26062606+ return err
26072607+ }
26082608+26092609+ if !ok {
26102610+ // Field doesn't exist on this type, so ignore it
26112611+ if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
26122612+ return err
26132613+ }
26142614+ continue
26152615+ }
26162616+26172617+ switch string(nameBuf[:nameLen]) {
26182618+ // t.LexiconTypeID (string) (string)
26192619+ case "$type":
26202620+26212621+ {
26222622+ sval, err := cbg.ReadStringWithMax(cr, 8192)
26232623+ if err != nil {
26242624+ return err
26252625+ }
26262626+26272627+ t.LexiconTypeID = string(sval)
26282628+ }
26292629+ // t.Subject (atproto.SailorStar_Subject) (struct)
26302630+ case "subject":
26312631+26322632+ {
26332633+26342634+ if err := t.Subject.UnmarshalCBOR(cr); err != nil {
26352635+ return xerrors.Errorf("unmarshaling t.Subject: %w", err)
26362636+ }
26372637+26382638+ }
26392639+ // t.CreatedAt (string) (string)
26402640+ case "createdAt":
26412641+26422642+ {
26432643+ sval, err := cbg.ReadStringWithMax(cr, 8192)
26442644+ if err != nil {
26452645+ return err
26462646+ }
26472647+26482648+ t.CreatedAt = string(sval)
26492649+ }
26502650+26512651+ default:
26522652+ // Field doesn't exist on this type, so ignore it
26532653+ if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
26542654+ return err
26552655+ }
26562656+ }
26572657+ }
26582658+26592659+ return nil
26602660+}
26612661+func (t *SailorStar_Subject) MarshalCBOR(w io.Writer) error {
26622662+ if t == nil {
26632663+ _, err := w.Write(cbg.CborNull)
26642664+ return err
26652665+ }
26662666+26672667+ cw := cbg.NewCborWriter(w)
26682668+ fieldCount := 3
26692669+26702670+ if t.LexiconTypeID == "" {
26712671+ fieldCount--
26722672+ }
26732673+26742674+ if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
26752675+ return err
26762676+ }
26772677+26782678+ // t.Did (string) (string)
26792679+ if len("did") > 8192 {
26802680+ return xerrors.Errorf("Value in field \"did\" was too long")
26812681+ }
26822682+26832683+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("did"))); err != nil {
26842684+ return err
26852685+ }
26862686+ if _, err := cw.WriteString(string("did")); err != nil {
26872687+ return err
26882688+ }
26892689+26902690+ if len(t.Did) > 8192 {
26912691+ return xerrors.Errorf("Value in field t.Did was too long")
26922692+ }
26932693+26942694+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Did))); err != nil {
26952695+ return err
26962696+ }
26972697+ if _, err := cw.WriteString(string(t.Did)); err != nil {
26982698+ return err
26992699+ }
27002700+27012701+ // t.LexiconTypeID (string) (string)
27022702+ if t.LexiconTypeID != "" {
27032703+27042704+ if len("$type") > 8192 {
27052705+ return xerrors.Errorf("Value in field \"$type\" was too long")
27062706+ }
27072707+27082708+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
27092709+ return err
27102710+ }
27112711+ if _, err := cw.WriteString(string("$type")); err != nil {
27122712+ return err
27132713+ }
27142714+27152715+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("io.atcr.sailor.star#subject"))); err != nil {
27162716+ return err
27172717+ }
27182718+ if _, err := cw.WriteString(string("io.atcr.sailor.star#subject")); err != nil {
27192719+ return err
27202720+ }
27212721+ }
27222722+27232723+ // t.Repository (string) (string)
27242724+ if len("repository") > 8192 {
27252725+ return xerrors.Errorf("Value in field \"repository\" was too long")
27262726+ }
27272727+27282728+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repository"))); err != nil {
27292729+ return err
27302730+ }
27312731+ if _, err := cw.WriteString(string("repository")); err != nil {
27322732+ return err
27332733+ }
27342734+27352735+ if len(t.Repository) > 8192 {
27362736+ return xerrors.Errorf("Value in field t.Repository was too long")
27372737+ }
27382738+27392739+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repository))); err != nil {
27402740+ return err
27412741+ }
27422742+ if _, err := cw.WriteString(string(t.Repository)); err != nil {
27432743+ return err
27442744+ }
27452745+ return nil
27462746+}
27472747+27482748+func (t *SailorStar_Subject) UnmarshalCBOR(r io.Reader) (err error) {
27492749+ *t = SailorStar_Subject{}
27502750+27512751+ cr := cbg.NewCborReader(r)
27522752+27532753+ maj, extra, err := cr.ReadHeader()
27542754+ if err != nil {
27552755+ return err
27562756+ }
27572757+ defer func() {
27582758+ if err == io.EOF {
27592759+ err = io.ErrUnexpectedEOF
27602760+ }
27612761+ }()
27622762+27632763+ if maj != cbg.MajMap {
27642764+ return fmt.Errorf("cbor input should be of type map")
27652765+ }
27662766+27672767+ if extra > cbg.MaxLength {
27682768+ return fmt.Errorf("SailorStar_Subject: map struct too large (%d)", extra)
27692769+ }
27702770+27712771+ n := extra
27722772+27732773+ nameBuf := make([]byte, 10)
27742774+ for i := uint64(0); i < n; i++ {
27752775+ nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192)
27762776+ if err != nil {
27772777+ return err
27782778+ }
27792779+27802780+ if !ok {
27812781+ // Field doesn't exist on this type, so ignore it
27822782+ if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
27832783+ return err
27842784+ }
27852785+ continue
27862786+ }
27872787+27882788+ switch string(nameBuf[:nameLen]) {
27892789+ // t.Did (string) (string)
27902790+ case "did":
27912791+27922792+ {
27932793+ sval, err := cbg.ReadStringWithMax(cr, 8192)
27942794+ if err != nil {
27952795+ return err
27962796+ }
27972797+27982798+ t.Did = string(sval)
27992799+ }
28002800+ // t.LexiconTypeID (string) (string)
28012801+ case "$type":
28022802+28032803+ {
28042804+ sval, err := cbg.ReadStringWithMax(cr, 8192)
28052805+ if err != nil {
28062806+ return err
28072807+ }
28082808+28092809+ t.LexiconTypeID = string(sval)
28102810+ }
28112811+ // t.Repository (string) (string)
28122812+ case "repository":
28132813+28142814+ {
28152815+ sval, err := cbg.ReadStringWithMax(cr, 8192)
28162816+ if err != nil {
28172817+ return err
28182818+ }
28192819+28202820+ t.Repository = string(sval)
28212821+ }
28222822+28232823+ default:
28242824+ // Field doesn't exist on this type, so ignore it
28252825+ if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
28262826+ return err
28272827+ }
28282828+ }
28292829+ }
28302830+28312831+ return nil
28322832+}
28332833+func (t *HoldCaptain) MarshalCBOR(w io.Writer) error {
28342834+ if t == nil {
28352835+ _, err := w.Write(cbg.CborNull)
28362836+ return err
28372837+ }
28382838+28392839+ cw := cbg.NewCborWriter(w)
28402840+ fieldCount := 8
28412841+28422842+ if t.Provider == nil {
28432843+ fieldCount--
28442844+ }
28452845+28462846+ if t.Region == nil {
28472847+ fieldCount--
28482848+ }
28492849+28502850+ if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
28512851+ return err
28522852+ }
28532853+28542854+ // t.LexiconTypeID (string) (string)
28552855+ if len("$type") > 8192 {
28562856+ return xerrors.Errorf("Value in field \"$type\" was too long")
28572857+ }
28582858+28592859+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
28602860+ return err
28612861+ }
28622862+ if _, err := cw.WriteString(string("$type")); err != nil {
28632863+ return err
28642864+ }
28652865+28662866+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("io.atcr.hold.captain"))); err != nil {
28672867+ return err
28682868+ }
28692869+ if _, err := cw.WriteString(string("io.atcr.hold.captain")); err != nil {
3372870 return err
3382871 }
3392872···3772910 }
37829113792912 // t.Region (string) (string)
380380- if t.Region != "" {
29132913+ if t.Region != nil {
38129143822915 if len("region") > 8192 {
3832916 return xerrors.Errorf("Value in field \"region\" was too long")
···3902923 return err
3912924 }
3922925393393- if len(t.Region) > 8192 {
394394- return xerrors.Errorf("Value in field t.Region was too long")
395395- }
29262926+ if t.Region == nil {
29272927+ if _, err := cw.Write(cbg.CborNull); err != nil {
29282928+ return err
29292929+ }
29302930+ } else {
29312931+ if len(*t.Region) > 8192 {
29322932+ return xerrors.Errorf("Value in field t.Region was too long")
29332933+ }
3962934397397- if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Region))); err != nil {
398398- return err
399399- }
400400- if _, err := cw.WriteString(string(t.Region)); err != nil {
401401- return err
29352935+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Region))); err != nil {
29362936+ return err
29372937+ }
29382938+ if _, err := cw.WriteString(string(*t.Region)); err != nil {
29392939+ return err
29402940+ }
4022941 }
4032942 }
40429434052944 // t.Provider (string) (string)
406406- if t.Provider != "" {
29452945+ if t.Provider != nil {
40729464082947 if len("provider") > 8192 {
4092948 return xerrors.Errorf("Value in field \"provider\" was too long")
···4162955 return err
4172956 }
4182957419419- if len(t.Provider) > 8192 {
420420- return xerrors.Errorf("Value in field t.Provider was too long")
421421- }
29582958+ if t.Provider == nil {
29592959+ if _, err := cw.Write(cbg.CborNull); err != nil {
29602960+ return err
29612961+ }
29622962+ } else {
29632963+ if len(*t.Provider) > 8192 {
29642964+ return xerrors.Errorf("Value in field t.Provider was too long")
29652965+ }
4222966423423- if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Provider))); err != nil {
424424- return err
425425- }
426426- if _, err := cw.WriteString(string(t.Provider)); err != nil {
427427- return err
29672967+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Provider))); err != nil {
29682968+ return err
29692969+ }
29702970+ if _, err := cw.WriteString(string(*t.Provider)); err != nil {
29712971+ return err
29722972+ }
4282973 }
4292974 }
4302975···4853030 return nil
4863031}
4873032488488-func (t *CaptainRecord) UnmarshalCBOR(r io.Reader) (err error) {
489489- *t = CaptainRecord{}
30333033+func (t *HoldCaptain) UnmarshalCBOR(r io.Reader) (err error) {
30343034+ *t = HoldCaptain{}
49030354913036 cr := cbg.NewCborReader(r)
4923037···5053050 }
50630515073052 if extra > cbg.MaxLength {
508508- return fmt.Errorf("CaptainRecord: map struct too large (%d)", extra)
30533053+ return fmt.Errorf("HoldCaptain: map struct too large (%d)", extra)
5093054 }
51030555113056 n := extra
···5263071 }
52730725283073 switch string(nameBuf[:nameLen]) {
529529- // t.Type (string) (string)
30743074+ // t.LexiconTypeID (string) (string)
5303075 case "$type":
53130765323077 {
···5353080 return err
5363081 }
5373082538538- t.Type = string(sval)
30833083+ t.LexiconTypeID = string(sval)
5393084 }
5403085 // t.Owner (string) (string)
5413086 case "owner":
···5703115 case "region":
57131165723117 {
573573- sval, err := cbg.ReadStringWithMax(cr, 8192)
31183118+ b, err := cr.ReadByte()
5743119 if err != nil {
5753120 return err
5763121 }
31223122+ if b != cbg.CborNull[0] {
31233123+ if err := cr.UnreadByte(); err != nil {
31243124+ return err
31253125+ }
5773126578578- t.Region = string(sval)
31273127+ sval, err := cbg.ReadStringWithMax(cr, 8192)
31283128+ if err != nil {
31293129+ return err
31303130+ }
31313131+31323132+ t.Region = (*string)(&sval)
31333133+ }
5793134 }
5803135 // t.Provider (string) (string)
5813136 case "provider":
58231375833138 {
584584- sval, err := cbg.ReadStringWithMax(cr, 8192)
31393139+ b, err := cr.ReadByte()
5853140 if err != nil {
5863141 return err
5873142 }
31433143+ if b != cbg.CborNull[0] {
31443144+ if err := cr.UnreadByte(); err != nil {
31453145+ return err
31463146+ }
5883147589589- t.Provider = string(sval)
31483148+ sval, err := cbg.ReadStringWithMax(cr, 8192)
31493149+ if err != nil {
31503150+ return err
31513151+ }
31523152+31533153+ t.Provider = (*string)(&sval)
31543154+ }
5903155 }
5913156 // t.DeployedAt (string) (string)
5923157 case "deployedAt":
···64632116473212 return nil
6483213}
649649-func (t *LayerRecord) MarshalCBOR(w io.Writer) error {
32143214+func (t *HoldCrew) MarshalCBOR(w io.Writer) error {
32153215+ if t == nil {
32163216+ _, err := w.Write(cbg.CborNull)
32173217+ return err
32183218+ }
32193219+32203220+ cw := cbg.NewCborWriter(w)
32213221+32223222+ if _, err := cw.Write([]byte{165}); err != nil {
32233223+ return err
32243224+ }
32253225+32263226+ // t.Role (string) (string)
32273227+ if len("role") > 8192 {
32283228+ return xerrors.Errorf("Value in field \"role\" was too long")
32293229+ }
32303230+32313231+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("role"))); err != nil {
32323232+ return err
32333233+ }
32343234+ if _, err := cw.WriteString(string("role")); err != nil {
32353235+ return err
32363236+ }
32373237+32383238+ if len(t.Role) > 8192 {
32393239+ return xerrors.Errorf("Value in field t.Role was too long")
32403240+ }
32413241+32423242+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Role))); err != nil {
32433243+ return err
32443244+ }
32453245+ if _, err := cw.WriteString(string(t.Role)); err != nil {
32463246+ return err
32473247+ }
32483248+32493249+ // t.LexiconTypeID (string) (string)
32503250+ if len("$type") > 8192 {
32513251+ return xerrors.Errorf("Value in field \"$type\" was too long")
32523252+ }
32533253+32543254+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
32553255+ return err
32563256+ }
32573257+ if _, err := cw.WriteString(string("$type")); err != nil {
32583258+ return err
32593259+ }
32603260+32613261+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("io.atcr.hold.crew"))); err != nil {
32623262+ return err
32633263+ }
32643264+ if _, err := cw.WriteString(string("io.atcr.hold.crew")); err != nil {
32653265+ return err
32663266+ }
32673267+32683268+ // t.Member (string) (string)
32693269+ if len("member") > 8192 {
32703270+ return xerrors.Errorf("Value in field \"member\" was too long")
32713271+ }
32723272+32733273+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("member"))); err != nil {
32743274+ return err
32753275+ }
32763276+ if _, err := cw.WriteString(string("member")); err != nil {
32773277+ return err
32783278+ }
32793279+32803280+ if len(t.Member) > 8192 {
32813281+ return xerrors.Errorf("Value in field t.Member was too long")
32823282+ }
32833283+32843284+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Member))); err != nil {
32853285+ return err
32863286+ }
32873287+ if _, err := cw.WriteString(string(t.Member)); err != nil {
32883288+ return err
32893289+ }
32903290+32913291+ // t.AddedAt (string) (string)
32923292+ if len("addedAt") > 8192 {
32933293+ return xerrors.Errorf("Value in field \"addedAt\" was too long")
32943294+ }
32953295+32963296+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("addedAt"))); err != nil {
32973297+ return err
32983298+ }
32993299+ if _, err := cw.WriteString(string("addedAt")); err != nil {
33003300+ return err
33013301+ }
33023302+33033303+ if len(t.AddedAt) > 8192 {
33043304+ return xerrors.Errorf("Value in field t.AddedAt was too long")
33053305+ }
33063306+33073307+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.AddedAt))); err != nil {
33083308+ return err
33093309+ }
33103310+ if _, err := cw.WriteString(string(t.AddedAt)); err != nil {
33113311+ return err
33123312+ }
33133313+33143314+ // t.Permissions ([]string) (slice)
33153315+ if len("permissions") > 8192 {
33163316+ return xerrors.Errorf("Value in field \"permissions\" was too long")
33173317+ }
33183318+33193319+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("permissions"))); err != nil {
33203320+ return err
33213321+ }
33223322+ if _, err := cw.WriteString(string("permissions")); err != nil {
33233323+ return err
33243324+ }
33253325+33263326+ if len(t.Permissions) > 8192 {
33273327+ return xerrors.Errorf("Slice value in field t.Permissions was too long")
33283328+ }
33293329+33303330+ if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Permissions))); err != nil {
33313331+ return err
33323332+ }
33333333+ for _, v := range t.Permissions {
33343334+ if len(v) > 8192 {
33353335+ return xerrors.Errorf("Value in field v was too long")
33363336+ }
33373337+33383338+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
33393339+ return err
33403340+ }
33413341+ if _, err := cw.WriteString(string(v)); err != nil {
33423342+ return err
33433343+ }
33443344+33453345+ }
33463346+ return nil
33473347+}
33483348+33493349+func (t *HoldCrew) UnmarshalCBOR(r io.Reader) (err error) {
33503350+ *t = HoldCrew{}
33513351+33523352+ cr := cbg.NewCborReader(r)
33533353+33543354+ maj, extra, err := cr.ReadHeader()
33553355+ if err != nil {
33563356+ return err
33573357+ }
33583358+ defer func() {
33593359+ if err == io.EOF {
33603360+ err = io.ErrUnexpectedEOF
33613361+ }
33623362+ }()
33633363+33643364+ if maj != cbg.MajMap {
33653365+ return fmt.Errorf("cbor input should be of type map")
33663366+ }
33673367+33683368+ if extra > cbg.MaxLength {
33693369+ return fmt.Errorf("HoldCrew: map struct too large (%d)", extra)
33703370+ }
33713371+33723372+ n := extra
33733373+33743374+ nameBuf := make([]byte, 11)
33753375+ for i := uint64(0); i < n; i++ {
33763376+ nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192)
33773377+ if err != nil {
33783378+ return err
33793379+ }
33803380+33813381+ if !ok {
33823382+ // Field doesn't exist on this type, so ignore it
33833383+ if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
33843384+ return err
33853385+ }
33863386+ continue
33873387+ }
33883388+33893389+ switch string(nameBuf[:nameLen]) {
33903390+ // t.Role (string) (string)
33913391+ case "role":
33923392+33933393+ {
33943394+ sval, err := cbg.ReadStringWithMax(cr, 8192)
33953395+ if err != nil {
33963396+ return err
33973397+ }
33983398+33993399+ t.Role = string(sval)
34003400+ }
34013401+ // t.LexiconTypeID (string) (string)
34023402+ case "$type":
34033403+34043404+ {
34053405+ sval, err := cbg.ReadStringWithMax(cr, 8192)
34063406+ if err != nil {
34073407+ return err
34083408+ }
34093409+34103410+ t.LexiconTypeID = string(sval)
34113411+ }
34123412+ // t.Member (string) (string)
34133413+ case "member":
34143414+34153415+ {
34163416+ sval, err := cbg.ReadStringWithMax(cr, 8192)
34173417+ if err != nil {
34183418+ return err
34193419+ }
34203420+34213421+ t.Member = string(sval)
34223422+ }
34233423+ // t.AddedAt (string) (string)
34243424+ case "addedAt":
34253425+34263426+ {
34273427+ sval, err := cbg.ReadStringWithMax(cr, 8192)
34283428+ if err != nil {
34293429+ return err
34303430+ }
34313431+34323432+ t.AddedAt = string(sval)
34333433+ }
34343434+ // t.Permissions ([]string) (slice)
34353435+ case "permissions":
34363436+34373437+ maj, extra, err = cr.ReadHeader()
34383438+ if err != nil {
34393439+ return err
34403440+ }
34413441+34423442+ if extra > 8192 {
34433443+ return fmt.Errorf("t.Permissions: array too large (%d)", extra)
34443444+ }
34453445+34463446+ if maj != cbg.MajArray {
34473447+ return fmt.Errorf("expected cbor array")
34483448+ }
34493449+34503450+ if extra > 0 {
34513451+ t.Permissions = make([]string, extra)
34523452+ }
34533453+34543454+ for i := 0; i < int(extra); i++ {
34553455+ {
34563456+ var maj byte
34573457+ var extra uint64
34583458+ var err error
34593459+ _ = maj
34603460+ _ = extra
34613461+ _ = err
34623462+34633463+ {
34643464+ sval, err := cbg.ReadStringWithMax(cr, 8192)
34653465+ if err != nil {
34663466+ return err
34673467+ }
34683468+34693469+ t.Permissions[i] = string(sval)
34703470+ }
34713471+34723472+ }
34733473+ }
34743474+34753475+ default:
34763476+ // Field doesn't exist on this type, so ignore it
34773477+ if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
34783478+ return err
34793479+ }
34803480+ }
34813481+ }
34823482+34833483+ return nil
34843484+}
34853485+func (t *HoldLayer) MarshalCBOR(w io.Writer) error {
6503486 if t == nil {
6513487 _, err := w.Write(cbg.CborNull)
6523488 return err
···6803516 }
6813517 }
6823518683683- // t.Type (string) (string)
35193519+ // t.LexiconTypeID (string) (string)
6843520 if len("$type") > 8192 {
6853521 return xerrors.Errorf("Value in field \"$type\" was too long")
6863522 }
···6923528 return err
6933529 }
6943530695695- if len(t.Type) > 8192 {
696696- return xerrors.Errorf("Value in field t.Type was too long")
697697- }
698698-699699- if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Type))); err != nil {
35313531+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("io.atcr.hold.layer"))); err != nil {
7003532 return err
7013533 }
702702- if _, err := cw.WriteString(string(t.Type)); err != nil {
35343534+ if _, err := cw.WriteString(string("io.atcr.hold.layer")); err != nil {
7033535 return err
7043536 }
7053537···7263558 return err
7273559 }
7283560729729- // t.UserDID (string) (string)
35613561+ // t.UserDid (string) (string)
7303562 if len("userDid") > 8192 {
7313563 return xerrors.Errorf("Value in field \"userDid\" was too long")
7323564 }
···7383570 return err
7393571 }
7403572741741- if len(t.UserDID) > 8192 {
742742- return xerrors.Errorf("Value in field t.UserDID was too long")
35733573+ if len(t.UserDid) > 8192 {
35743574+ return xerrors.Errorf("Value in field t.UserDid was too long")
7433575 }
7443576745745- if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.UserDID))); err != nil {
35773577+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.UserDid))); err != nil {
7463578 return err
7473579 }
748748- if _, err := cw.WriteString(string(t.UserDID)); err != nil {
35803580+ if _, err := cw.WriteString(string(t.UserDid)); err != nil {
7493581 return err
7503582 }
7513583···8433675 return nil
8443676}
8453677846846-func (t *LayerRecord) UnmarshalCBOR(r io.Reader) (err error) {
847847- *t = LayerRecord{}
36783678+func (t *HoldLayer) UnmarshalCBOR(r io.Reader) (err error) {
36793679+ *t = HoldLayer{}
84836808493681 cr := cbg.NewCborReader(r)
8503682···8633695 }
86436968653697 if extra > cbg.MaxLength {
866866- return fmt.Errorf("LayerRecord: map struct too large (%d)", extra)
36983698+ return fmt.Errorf("HoldLayer: map struct too large (%d)", extra)
8673699 }
86837008693701 n := extra
···91037429113743 t.Size = int64(extraI)
9123744 }
913913- // t.Type (string) (string)
37453745+ // t.LexiconTypeID (string) (string)
9143746 case "$type":
91537479163748 {
···9193751 return err
9203752 }
9213753922922- t.Type = string(sval)
37543754+ t.LexiconTypeID = string(sval)
9233755 }
9243756 // t.Digest (string) (string)
9253757 case "digest":
···93237649333765 t.Digest = string(sval)
9343766 }
935935- // t.UserDID (string) (string)
37673767+ // t.UserDid (string) (string)
9363768 case "userDid":
93737699383770 {
···9413773 return err
9423774 }
9433775944944- t.UserDID = string(sval)
37763776+ t.UserDid = string(sval)
9453777 }
9463778 // t.CreatedAt (string) (string)
9473779 case "createdAt":
+91-60
pkg/atproto/client.go
···1212 "strings"
13131414 "github.com/bluesky-social/indigo/atproto/atclient"
1515+ indigo_oauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
1616+ lexutil "github.com/bluesky-social/indigo/lex/util"
1717+ "github.com/ipfs/go-cid"
1518)
16191720// Sentinel errors
···1922 ErrRecordNotFound = errors.New("record not found")
2023)
21242525+// SessionProvider provides locked OAuth sessions for PDS operations.
2626+// This interface allows the ATProto client to use DoWithSession() for each PDS call,
2727+// preventing DPoP nonce race conditions during concurrent operations.
2828+type SessionProvider interface {
2929+ // DoWithSession executes fn with a locked OAuth session.
3030+ // The lock is held for the entire duration, serializing DPoP nonce updates.
3131+ DoWithSession(ctx context.Context, did string, fn func(session *indigo_oauth.ClientSession) error) error
3232+}
3333+2234// Client wraps ATProto operations for the registry
2335type Client struct {
2436 pdsEndpoint string
2537 did string
2638 accessToken string // For Basic Auth only
2739 httpClient *http.Client
2828- useIndigoClient bool // true if using indigo's OAuth client (handles auth automatically)
2929- indigoClient *atclient.APIClient // indigo's API client for OAuth requests
4040+ sessionProvider SessionProvider // For locked OAuth sessions (prevents DPoP nonce races)
3041}
31423243// NewClient creates a new ATProto client for Basic Auth tokens (app passwords)
···3950 }
4051}
41524242-// NewClientWithIndigoClient creates an ATProto client using indigo's API client
4343-// This uses indigo's native XRPC methods with automatic DPoP handling
4444-func NewClientWithIndigoClient(pdsEndpoint, did string, indigoClient *atclient.APIClient) *Client {
5353+// NewClientWithSessionProvider creates an ATProto client that uses locked OAuth sessions.
5454+// This is the preferred constructor for concurrent operations (e.g., Docker layer uploads)
5555+// as it prevents DPoP nonce race conditions by serializing PDS calls per-DID.
5656+//
5757+// Each PDS call acquires a per-DID lock, ensuring that:
5858+// - Only one goroutine at a time can negotiate DPoP nonces with the PDS
5959+// - The session's nonce is saved to DB before other goroutines load it
6060+// - Concurrent manifest operations don't cause nonce thrashing
6161+func NewClientWithSessionProvider(pdsEndpoint, did string, sessionProvider SessionProvider) *Client {
4562 return &Client{
4663 pdsEndpoint: pdsEndpoint,
4764 did: did,
4848- useIndigoClient: true,
4949- indigoClient: indigoClient,
5050- httpClient: indigoClient.Client, // Keep for any fallback cases
6565+ sessionProvider: sessionProvider,
6666+ httpClient: &http.Client{},
5167 }
5268}
5369···6783 "record": record,
6884 }
69857070- // Use indigo API client (OAuth with DPoP)
7171- if c.useIndigoClient && c.indigoClient != nil {
8686+ // Use session provider (locked OAuth with DPoP) - prevents nonce races
8787+ if c.sessionProvider != nil {
7288 var result Record
7373- err := c.indigoClient.Post(ctx, "com.atproto.repo.putRecord", payload, &result)
8989+ err := c.sessionProvider.DoWithSession(ctx, c.did, func(session *indigo_oauth.ClientSession) error {
9090+ apiClient := session.APIClient()
9191+ return apiClient.Post(ctx, "com.atproto.repo.putRecord", payload, &result)
9292+ })
7493 if err != nil {
7594 return nil, fmt.Errorf("putRecord failed: %w", err)
7695 }
···113132114133// GetRecord retrieves a record from the ATProto repository
115134func (c *Client) GetRecord(ctx context.Context, collection, rkey string) (*Record, error) {
116116- // Use indigo API client (OAuth with DPoP)
117117- if c.useIndigoClient && c.indigoClient != nil {
118118- params := map[string]any{
119119- "repo": c.did,
120120- "collection": collection,
121121- "rkey": rkey,
122122- }
135135+ params := map[string]any{
136136+ "repo": c.did,
137137+ "collection": collection,
138138+ "rkey": rkey,
139139+ }
123140141141+ // Use session provider (locked OAuth with DPoP) - prevents nonce races
142142+ if c.sessionProvider != nil {
124143 var result Record
125125- err := c.indigoClient.Get(ctx, "com.atproto.repo.getRecord", params, &result)
144144+ err := c.sessionProvider.DoWithSession(ctx, c.did, func(session *indigo_oauth.ClientSession) error {
145145+ apiClient := session.APIClient()
146146+ return apiClient.Get(ctx, "com.atproto.repo.getRecord", params, &result)
147147+ })
126148 if err != nil {
127149 // Check for RecordNotFound error from indigo's APIError type
128150 var apiErr *atclient.APIError
···187209 "rkey": rkey,
188210 }
189211190190- // Use indigo API client (OAuth with DPoP)
191191- if c.useIndigoClient && c.indigoClient != nil {
192192- var result map[string]any // deleteRecord returns empty object on success
193193- err := c.indigoClient.Post(ctx, "com.atproto.repo.deleteRecord", payload, &result)
212212+ // Use session provider (locked OAuth with DPoP) - prevents nonce races
213213+ if c.sessionProvider != nil {
214214+ err := c.sessionProvider.DoWithSession(ctx, c.did, func(session *indigo_oauth.ClientSession) error {
215215+ apiClient := session.APIClient()
216216+ var result map[string]any // deleteRecord returns empty object on success
217217+ return apiClient.Post(ctx, "com.atproto.repo.deleteRecord", payload, &result)
218218+ })
194219 if err != nil {
195220 return fmt.Errorf("deleteRecord failed: %w", err)
196221 }
···278303}
279304280305// UploadBlob uploads binary data to the PDS and returns a blob reference
281281-func (c *Client) UploadBlob(ctx context.Context, data []byte, mimeType string) (*ATProtoBlobRef, error) {
282282- // Use indigo API client (OAuth with DPoP)
283283- if c.useIndigoClient && c.indigoClient != nil {
306306+func (c *Client) UploadBlob(ctx context.Context, data []byte, mimeType string) (*lexutil.LexBlob, error) {
307307+ // Use session provider (locked OAuth with DPoP) - prevents nonce races
308308+ if c.sessionProvider != nil {
284309 var result struct {
285310 Blob ATProtoBlobRef `json:"blob"`
286311 }
287312288288- err := c.indigoClient.LexDo(ctx,
289289- "POST",
290290- mimeType,
291291- "com.atproto.repo.uploadBlob",
292292- nil,
293293- data,
294294- &result,
295295- )
313313+ err := c.sessionProvider.DoWithSession(ctx, c.did, func(session *indigo_oauth.ClientSession) error {
314314+ apiClient := session.APIClient()
315315+ return apiClient.LexDo(ctx,
316316+ "POST",
317317+ mimeType,
318318+ "com.atproto.repo.uploadBlob",
319319+ nil,
320320+ data,
321321+ &result,
322322+ )
323323+ })
296324 if err != nil {
297325 return nil, fmt.Errorf("uploadBlob failed: %w", err)
298326 }
299327300300- return &result.Blob, nil
328328+ return atProtoBlobRefToLexBlob(&result.Blob)
301329 }
302330303331 // Basic Auth (app passwords)
···328356 return nil, fmt.Errorf("failed to decode response: %w", err)
329357 }
330358331331- return &result.Blob, nil
359359+ return atProtoBlobRefToLexBlob(&result.Blob)
360360+}
361361+362362+// atProtoBlobRefToLexBlob converts an ATProtoBlobRef to a lexutil.LexBlob
363363+func atProtoBlobRefToLexBlob(ref *ATProtoBlobRef) (*lexutil.LexBlob, error) {
364364+ // Parse the CID string from the $link field
365365+ c, err := cid.Decode(ref.Ref.Link)
366366+ if err != nil {
367367+ return nil, fmt.Errorf("failed to parse blob CID %q: %w", ref.Ref.Link, err)
368368+ }
369369+370370+ return &lexutil.LexBlob{
371371+ Ref: lexutil.LexLink(c),
372372+ MimeType: ref.MimeType,
373373+ Size: ref.Size,
374374+ }, nil
332375}
333376334377// GetBlob downloads a blob by its CID from the PDS
···510553// GetActorProfile fetches an actor's profile from their PDS
511554// The actor parameter can be a DID or handle
512555func (c *Client) GetActorProfile(ctx context.Context, actor string) (*ActorProfile, error) {
513513- // Use indigo API client (OAuth with DPoP)
514514- if c.useIndigoClient && c.indigoClient != nil {
515515- params := map[string]any{
516516- "actor": actor,
517517- }
518518-519519- var profile ActorProfile
520520- err := c.indigoClient.Get(ctx, "app.bsky.actor.getProfile", params, &profile)
521521- if err != nil {
522522- return nil, fmt.Errorf("getProfile failed: %w", err)
523523- }
524524- return &profile, nil
525525- }
526526-527527- // Basic Auth (app passwords)
556556+ // Basic Auth (app passwords) or unauthenticated
528557 url := fmt.Sprintf("%s/xrpc/app.bsky.actor.getProfile?actor=%s", c.pdsEndpoint, actor)
529558530559 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
···563592// GetProfileRecord fetches the app.bsky.actor.profile record from PDS
564593// This returns the raw profile record with blob references (not CDN URLs)
565594func (c *Client) GetProfileRecord(ctx context.Context, did string) (*ProfileRecord, error) {
566566- // Use indigo API client (OAuth with DPoP)
567567- if c.useIndigoClient && c.indigoClient != nil {
568568- params := map[string]any{
569569- "repo": did,
570570- "collection": "app.bsky.actor.profile",
571571- "rkey": "self",
572572- }
595595+ params := map[string]any{
596596+ "repo": did,
597597+ "collection": "app.bsky.actor.profile",
598598+ "rkey": "self",
599599+ }
573600601601+ // Use session provider (locked OAuth with DPoP) - prevents nonce races
602602+ if c.sessionProvider != nil {
574603 var result struct {
575604 Value ProfileRecord `json:"value"`
576605 }
577577-578578- err := c.indigoClient.Get(ctx, "com.atproto.repo.getRecord", params, &result)
606606+ err := c.sessionProvider.DoWithSession(ctx, c.did, func(session *indigo_oauth.ClientSession) error {
607607+ apiClient := session.APIClient()
608608+ return apiClient.Get(ctx, "com.atproto.repo.getRecord", params, &result)
609609+ })
579610 if err != nil {
580611 return nil, fmt.Errorf("getRecord failed: %w", err)
581612 }
+10-23
pkg/atproto/client_test.go
···2323 if client.accessToken != "token123" {
2424 t.Errorf("accessToken = %v, want token123", client.accessToken)
2525 }
2626- if client.useIndigoClient {
2727- t.Error("useIndigoClient should be false for Basic Auth client")
2626+ if client.sessionProvider != nil {
2727+ t.Error("sessionProvider should be nil for Basic Auth client")
2828 }
2929}
3030···386386 t.Errorf("Content-Type = %v, want %v", r.Header.Get("Content-Type"), mimeType)
387387 }
388388389389- // Send response
389389+ // Send response - use a valid CIDv1 in base32 format
390390 response := `{
391391 "blob": {
392392 "$type": "blob",
393393- "ref": {"$link": "bafytest123"},
393393+ "ref": {"$link": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"},
394394 "mimeType": "application/octet-stream",
395395 "size": 17
396396 }
···406406 t.Fatalf("UploadBlob() error = %v", err)
407407 }
408408409409- if blobRef.Type != "blob" {
410410- t.Errorf("Type = %v, want blob", blobRef.Type)
409409+ if blobRef.MimeType != mimeType {
410410+ t.Errorf("MimeType = %v, want %v", blobRef.MimeType, mimeType)
411411 }
412412413413- if blobRef.Ref.Link != "bafytest123" {
414414- t.Errorf("Ref.Link = %v, want bafytest123", blobRef.Ref.Link)
413413+ // LexBlob.Ref is a LexLink (cid.Cid alias), use .String() to get the CID string
414414+ expectedCID := "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"
415415+ if blobRef.Ref.String() != expectedCID {
416416+ t.Errorf("Ref.String() = %v, want %v", blobRef.Ref.String(), expectedCID)
415417 }
416418417419 if blobRef.Size != 17 {
···10011003 if client.PDSEndpoint() != expectedEndpoint {
10021004 t.Errorf("PDSEndpoint() = %v, want %v", client.PDSEndpoint(), expectedEndpoint)
10031005 }
10041004-}
10051005-10061006-// TestNewClientWithIndigoClient tests client initialization with Indigo client
10071007-func TestNewClientWithIndigoClient(t *testing.T) {
10081008- // Note: We can't easily create a real indigo client in tests without complex setup
10091009- // We pass nil for the indigo client, which is acceptable for testing the constructor
10101010- // The actual client.go code will handle nil indigo client by checking before use
10111011-10121012- // Skip this test for now as it requires a real indigo client
10131013- // The function is tested indirectly through integration tests
10141014- t.Skip("Skipping TestNewClientWithIndigoClient - requires real indigo client setup")
10151015-10161016- // When properly set up with a real indigo client, the test would look like:
10171017- // client := NewClientWithIndigoClient("https://pds.example.com", "did:plc:test123", indigoClient)
10181018- // if !client.useIndigoClient { t.Error("useIndigoClient should be true") }
10191006}
1020100710211008// TestListRecordsError tests error handling in ListRecords
+255-11
pkg/atproto/generate.go
···3344package main
5566-// CBOR Code Generator
66+// Lexicon and CBOR Code Generator
77//
88-// This generates optimized CBOR marshaling code for ATProto records.
88+// This generates:
99+// 1. Go types from lexicon JSON files (via lex/lexgen library)
1010+// 2. CBOR marshaling code for ATProto records (via cbor-gen)
1111+// 3. Type registration for lexutil (register.go)
912//
1013// Usage:
1114// go generate ./pkg/atproto/...
1215//
1313-// This creates pkg/atproto/cbor_gen.go which should be committed to git.
1414-// Only re-run when you modify types in pkg/atproto/types.go
1515-//
1616-// The //go:generate directive is in lexicon.go
1616+// Key insight: We use RegisterLexiconTypeID: false to avoid generating init()
1717+// blocks that require CBORMarshaler. This breaks the circular dependency between
1818+// lexgen and cbor-gen. See: https://github.com/bluesky-social/indigo/issues/931
1919+2020+import (
2121+ "bytes"
2222+ "encoding/json"
2323+ "fmt"
2424+ "os"
2525+ "os/exec"
2626+ "path/filepath"
2727+ "strings"
2828+2929+ "github.com/bluesky-social/indigo/atproto/lexicon"
3030+ "github.com/bluesky-social/indigo/lex/lexgen"
3131+ "golang.org/x/tools/imports"
3232+)
3333+3434+func main() {
3535+ // Find repo root
3636+ repoRoot, err := findRepoRoot()
3737+ if err != nil {
3838+ fmt.Printf("failed to find repo root: %v\n", err)
3939+ os.Exit(1)
4040+ }
4141+4242+ pkgDir := filepath.Join(repoRoot, "pkg/atproto")
4343+ lexDir := filepath.Join(repoRoot, "lexicons")
4444+4545+ // Step 0: Clean up old register.go to avoid conflicts
4646+ // (It will be regenerated at the end)
4747+ os.Remove(filepath.Join(pkgDir, "register.go"))
4848+4949+ // Step 1: Load all lexicon schemas into catalog (for cross-references)
5050+ fmt.Println("Loading lexicons...")
5151+ cat := lexicon.NewBaseCatalog()
5252+ if err := cat.LoadDirectory(lexDir); err != nil {
5353+ fmt.Printf("failed to load lexicons: %v\n", err)
5454+ os.Exit(1)
5555+ }
5656+5757+ // Step 2: Generate Go code for each lexicon file
5858+ fmt.Println("Running lexgen...")
5959+ config := &lexgen.GenConfig{
6060+ RegisterLexiconTypeID: false, // KEY: no init() blocks generated
6161+ UnknownType: "map-string-any",
6262+ WarningText: "Code generated by generate.go; DO NOT EDIT.",
6363+ }
6464+6565+ // Track generated types for register.go
6666+ var registeredTypes []typeInfo
6767+6868+ // Walk lexicon directory and generate code for each file
6969+ err = filepath.Walk(lexDir, func(path string, info os.FileInfo, err error) error {
7070+ if err != nil {
7171+ return err
7272+ }
7373+ if info.IsDir() || !strings.HasSuffix(path, ".json") {
7474+ return nil
7575+ }
7676+7777+ // Load and parse the schema file
7878+ data, err := os.ReadFile(path)
7979+ if err != nil {
8080+ return fmt.Errorf("failed to read %s: %w", path, err)
8181+ }
8282+8383+ var sf lexicon.SchemaFile
8484+ if err := json.Unmarshal(data, &sf); err != nil {
8585+ return fmt.Errorf("failed to parse %s: %w", path, err)
8686+ }
8787+8888+ if err := sf.FinishParse(); err != nil {
8989+ return fmt.Errorf("failed to finish parse %s: %w", path, err)
9090+ }
9191+9292+ // Flatten the schema
9393+ flat, err := lexgen.FlattenSchemaFile(&sf)
9494+ if err != nil {
9595+ return fmt.Errorf("failed to flatten schema %s: %w", path, err)
9696+ }
9797+9898+ // Generate code
9999+ var buf bytes.Buffer
100100+ gen := &lexgen.CodeGenerator{
101101+ Config: config,
102102+ Lex: flat,
103103+ Cat: &cat,
104104+ Out: &buf,
105105+ }
106106+107107+ if err := gen.WriteLexicon(); err != nil {
108108+ return fmt.Errorf("failed to generate code for %s: %w", path, err)
109109+ }
110110+111111+ // Fix package name: lexgen generates "ioatcr" but we want "atproto"
112112+ code := bytes.Replace(buf.Bytes(), []byte("package ioatcr"), []byte("package atproto"), 1)
113113+114114+ // Format with goimports
115115+ fileName := gen.FileName()
116116+ formatted, err := imports.Process(fileName, code, nil)
117117+ if err != nil {
118118+ // Write unformatted for debugging
119119+ outPath := filepath.Join(pkgDir, fileName)
120120+ os.WriteFile(outPath+".broken", code, 0644)
121121+ return fmt.Errorf("failed to format %s: %w (wrote to %s.broken)", fileName, err, outPath)
122122+ }
123123+124124+ // Write output file
125125+ outPath := filepath.Join(pkgDir, fileName)
126126+ if err := os.WriteFile(outPath, formatted, 0644); err != nil {
127127+ return fmt.Errorf("failed to write %s: %w", outPath, err)
128128+ }
129129+130130+ fmt.Printf(" Generated %s\n", fileName)
131131+132132+ // Track type for registration - compute type name from NSID
133133+ typeName := nsidToTypeName(sf.ID)
134134+ registeredTypes = append(registeredTypes, typeInfo{
135135+ NSID: sf.ID,
136136+ TypeName: typeName,
137137+ })
138138+139139+ return nil
140140+ })
141141+ if err != nil {
142142+ fmt.Printf("lexgen failed: %v\n", err)
143143+ os.Exit(1)
144144+ }
145145+146146+ // Step 3: Run cbor-gen via exec.Command
147147+ // This must be a separate process so it can compile the freshly generated types
148148+ fmt.Println("Running cbor-gen...")
149149+ if err := runCborGen(repoRoot, pkgDir); err != nil {
150150+ fmt.Printf("cbor-gen failed: %v\n", err)
151151+ os.Exit(1)
152152+ }
153153+154154+ // Step 4: Generate register.go
155155+ fmt.Println("Generating register.go...")
156156+ if err := generateRegisterFile(pkgDir, registeredTypes); err != nil {
157157+ fmt.Printf("failed to generate register.go: %v\n", err)
158158+ os.Exit(1)
159159+ }
160160+161161+ fmt.Println("Code generation complete!")
162162+}
163163+164164+type typeInfo struct {
165165+ NSID string
166166+ TypeName string
167167+}
168168+169169+// nsidToTypeName converts an NSID to a Go type name
170170+// io.atcr.manifest โ Manifest
171171+// io.atcr.hold.captain โ HoldCaptain
172172+// io.atcr.sailor.profile โ SailorProfile
173173+func nsidToTypeName(nsid string) string {
174174+ parts := strings.Split(nsid, ".")
175175+ if len(parts) < 3 {
176176+ return ""
177177+ }
178178+ // Skip the first two parts (authority, e.g., "io.atcr")
179179+ // and capitalize each remaining part
180180+ var result string
181181+ for _, part := range parts[2:] {
182182+ if len(part) > 0 {
183183+ result += strings.ToUpper(part[:1]) + part[1:]
184184+ }
185185+ }
186186+ return result
187187+}
188188+189189+func runCborGen(repoRoot, pkgDir string) error {
190190+ // Create a temporary Go file that runs cbor-gen
191191+ cborGenCode := `//go:build ignore
192192+193193+package main
1719418195import (
19196 "fmt"
···25202)
2620327204func main() {
2828- // Generate map-style encoders for CrewRecord, CaptainRecord, LayerRecord, and TangledProfileRecord
29205 if err := cbg.WriteMapEncodersToFile("cbor_gen.go", "atproto",
3030- atproto.CrewRecord{},
3131- atproto.CaptainRecord{},
3232- atproto.LayerRecord{},
206206+ // Manifest types
207207+ atproto.Manifest{},
208208+ atproto.Manifest_BlobReference{},
209209+ atproto.Manifest_ManifestReference{},
210210+ atproto.Manifest_Platform{},
211211+ atproto.Manifest_Annotations{},
212212+ atproto.Manifest_BlobReference_Annotations{},
213213+ atproto.Manifest_ManifestReference_Annotations{},
214214+ // Tag
215215+ atproto.Tag{},
216216+ // Sailor types
217217+ atproto.SailorProfile{},
218218+ atproto.SailorStar{},
219219+ atproto.SailorStar_Subject{},
220220+ // Hold types
221221+ atproto.HoldCaptain{},
222222+ atproto.HoldCrew{},
223223+ atproto.HoldLayer{},
224224+ // External types
33225 atproto.TangledProfileRecord{},
34226 ); err != nil {
3535- fmt.Printf("Failed to generate CBOR encoders: %v\n", err)
227227+ fmt.Printf("cbor-gen failed: %v\n", err)
36228 os.Exit(1)
37229 }
38230}
231231+`
232232+233233+ // Write temp file
234234+ tmpFile := filepath.Join(pkgDir, "cborgen_tmp.go")
235235+ if err := os.WriteFile(tmpFile, []byte(cborGenCode), 0644); err != nil {
236236+ return fmt.Errorf("failed to write temp cbor-gen file: %w", err)
237237+ }
238238+ defer os.Remove(tmpFile)
239239+240240+ // Run it
241241+ cmd := exec.Command("go", "run", tmpFile)
242242+ cmd.Dir = pkgDir
243243+ cmd.Stdout = os.Stdout
244244+ cmd.Stderr = os.Stderr
245245+ return cmd.Run()
246246+}
247247+248248+func generateRegisterFile(pkgDir string, types []typeInfo) error {
249249+ var buf bytes.Buffer
250250+251251+ buf.WriteString("// Code generated by generate.go; DO NOT EDIT.\n\n")
252252+ buf.WriteString("package atproto\n\n")
253253+ buf.WriteString("import lexutil \"github.com/bluesky-social/indigo/lex/util\"\n\n")
254254+ buf.WriteString("func init() {\n")
255255+256256+ for _, t := range types {
257257+ fmt.Fprintf(&buf, "\tlexutil.RegisterType(%q, &%s{})\n", t.NSID, t.TypeName)
258258+ }
259259+260260+ buf.WriteString("}\n")
261261+262262+ outPath := filepath.Join(pkgDir, "register.go")
263263+ return os.WriteFile(outPath, buf.Bytes(), 0644)
264264+}
265265+266266+func findRepoRoot() (string, error) {
267267+ dir, err := os.Getwd()
268268+ if err != nil {
269269+ return "", err
270270+ }
271271+272272+ for {
273273+ if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
274274+ return dir, nil
275275+ }
276276+ parent := filepath.Dir(dir)
277277+ if parent == dir {
278278+ return "", fmt.Errorf("go.mod not found")
279279+ }
280280+ dir = parent
281281+ }
282282+}
+24
pkg/atproto/holdcaptain.go
···11+// Code generated by generate.go; DO NOT EDIT.
22+33+// Lexicon schema: io.atcr.hold.captain
44+55+package atproto
66+77+// Represents the hold's ownership and metadata. Stored as a singleton record at rkey 'self' in the hold's embedded PDS.
88+type HoldCaptain struct {
99+ LexiconTypeID string `json:"$type" cborgen:"$type,const=io.atcr.hold.captain"`
1010+ // allowAllCrew: Allow any authenticated user to register as crew
1111+ AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"`
1212+ // deployedAt: RFC3339 timestamp of when the hold was deployed
1313+ DeployedAt string `json:"deployedAt" cborgen:"deployedAt"`
1414+ // enableBlueskyPosts: Enable Bluesky posts when manifests are pushed
1515+ EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"`
1616+ // owner: DID of the hold owner
1717+ Owner string `json:"owner" cborgen:"owner"`
1818+ // provider: Deployment provider (e.g., fly.io, aws, etc.)
1919+ Provider *string `json:"provider,omitempty" cborgen:"provider,omitempty"`
2020+ // public: Whether this hold allows public blob reads (pulls) without authentication
2121+ Public bool `json:"public" cborgen:"public"`
2222+ // region: S3 region where blobs are stored
2323+ Region *string `json:"region,omitempty" cborgen:"region,omitempty"`
2424+}
+18
pkg/atproto/holdcrew.go
···11+// Code generated by generate.go; DO NOT EDIT.
22+33+// Lexicon schema: io.atcr.hold.crew
44+55+package atproto
66+77+// Crew member in a hold's embedded PDS. Grants access permissions to push blobs to the hold. Stored in the hold's embedded PDS (one record per member).
88+type HoldCrew struct {
99+ LexiconTypeID string `json:"$type" cborgen:"$type,const=io.atcr.hold.crew"`
1010+ // addedAt: RFC3339 timestamp of when the member was added
1111+ AddedAt string `json:"addedAt" cborgen:"addedAt"`
1212+ // member: DID of the crew member
1313+ Member string `json:"member" cborgen:"member"`
1414+ // permissions: Specific permissions granted to this member
1515+ Permissions []string `json:"permissions" cborgen:"permissions"`
1616+ // role: Member's role in the hold
1717+ Role string `json:"role" cborgen:"role"`
1818+}
+24
pkg/atproto/holdlayer.go
···11+// Code generated by generate.go; DO NOT EDIT.
22+33+// Lexicon schema: io.atcr.hold.layer
44+55+package atproto
66+77+// Represents metadata about a container layer stored in the hold. Stored in the hold's embedded PDS for tracking and analytics.
88+type HoldLayer struct {
99+ LexiconTypeID string `json:"$type" cborgen:"$type,const=io.atcr.hold.layer"`
1010+ // createdAt: RFC3339 timestamp of when the layer was uploaded
1111+ CreatedAt string `json:"createdAt" cborgen:"createdAt"`
1212+ // digest: Layer digest (e.g., sha256:abc123...)
1313+ Digest string `json:"digest" cborgen:"digest"`
1414+ // mediaType: Media type (e.g., application/vnd.oci.image.layer.v1.tar+gzip)
1515+ MediaType string `json:"mediaType" cborgen:"mediaType"`
1616+ // repository: Repository this layer belongs to
1717+ Repository string `json:"repository" cborgen:"repository"`
1818+ // size: Size in bytes
1919+ Size int64 `json:"size" cborgen:"size"`
2020+ // userDid: DID of user who uploaded this layer
2121+ UserDid string `json:"userDid" cborgen:"userDid"`
2222+ // userHandle: Handle of user (for display purposes)
2323+ UserHandle string `json:"userHandle" cborgen:"userHandle"`
2424+}
+6
pkg/atproto/lexicon.go
···4141 // TangledProfileCollection is the collection name for tangled profiles
4242 // Stored in hold's embedded PDS (singleton record at rkey "self")
4343 TangledProfileCollection = "sh.tangled.actor.profile"
4444+4545+ // BskyPostCollection is the collection name for Bluesky posts
4646+ BskyPostCollection = "app.bsky.feed.post"
4747+4848+ // BskyPostCollection is the collection name for Bluesky posts
4949+ BskyPostCollection = "app.bsky.feed.post"
44504551 // SailorProfileCollection is the collection name for user profiles
4652 SailorProfileCollection = "io.atcr.sailor.profile"
+18
pkg/atproto/lexicon_embedded.go
···11+package atproto
22+33+// This file contains ATProto record types that are NOT generated from our lexicons.
44+// These are either external schemas or special types that require manual definition.
55+66+// TangledProfileRecord represents a Tangled profile for the hold
77+// Collection: sh.tangled.actor.profile (external schema - not controlled by ATCR)
88+// Stored in hold's embedded PDS (singleton record at rkey "self")
99+// Uses CBOR encoding for efficient storage in hold's carstore
1010+type TangledProfileRecord struct {
1111+ Type string `json:"$type" cborgen:"$type"`
1212+ Links []string `json:"links" cborgen:"links"`
1313+ Stats []string `json:"stats" cborgen:"stats"`
1414+ Bluesky bool `json:"bluesky" cborgen:"bluesky"`
1515+ Location string `json:"location" cborgen:"location"`
1616+ Description string `json:"description" cborgen:"description"`
1717+ PinnedRepositories []string `json:"pinnedRepositories" cborgen:"pinnedRepositories"`
1818+}
+360
pkg/atproto/lexicon_helpers.go
···11+package atproto
22+33+//go:generate go run generate.go
44+55+import (
66+ "encoding/base64"
77+ "encoding/json"
88+ "fmt"
99+ "strings"
1010+ "time"
1111+)
1212+1313+// Collection names for ATProto records
1414+const (
1515+ // ManifestCollection is the collection name for container manifests
1616+ ManifestCollection = "io.atcr.manifest"
1717+1818+ // TagCollection is the collection name for image tags
1919+ TagCollection = "io.atcr.tag"
2020+2121+ // HoldCollection is the collection name for storage holds (BYOS) - LEGACY
2222+ HoldCollection = "io.atcr.hold"
2323+2424+ // HoldCrewCollection is the collection name for hold crew (membership) - LEGACY BYOS model
2525+ // Stored in owner's PDS for BYOS holds
2626+ HoldCrewCollection = "io.atcr.hold.crew"
2727+2828+ // CaptainCollection is the collection name for captain records (hold ownership) - EMBEDDED PDS model
2929+ // Stored in hold's embedded PDS (singleton record at rkey "self")
3030+ CaptainCollection = "io.atcr.hold.captain"
3131+3232+ // CrewCollection is the collection name for crew records (access control) - EMBEDDED PDS model
3333+ // Stored in hold's embedded PDS (one record per member)
3434+ // Note: Uses same collection name as HoldCrewCollection but stored in different PDS (hold's PDS vs owner's PDS)
3535+ CrewCollection = "io.atcr.hold.crew"
3636+3737+ // LayerCollection is the collection name for container layer metadata
3838+ // Stored in hold's embedded PDS to track which layers are stored
3939+ LayerCollection = "io.atcr.hold.layer"
4040+4141+ // TangledProfileCollection is the collection name for tangled profiles
4242+ // Stored in hold's embedded PDS (singleton record at rkey "self")
4343+ TangledProfileCollection = "sh.tangled.actor.profile"
4444+4545+ // BskyPostCollection is the collection name for Bluesky posts
4646+ BskyPostCollection = "app.bsky.feed.post"
4747+4848+ // SailorProfileCollection is the collection name for user profiles
4949+ SailorProfileCollection = "io.atcr.sailor.profile"
5050+5151+ // StarCollection is the collection name for repository stars
5252+ StarCollection = "io.atcr.sailor.star"
5353+)
5454+5555+// NewManifestRecord creates a new manifest record from OCI manifest JSON
5656+func NewManifestRecord(repository, digest string, ociManifest []byte) (*Manifest, error) {
5757+ // Parse the OCI manifest
5858+ var ociData struct {
5959+ SchemaVersion int `json:"schemaVersion"`
6060+ MediaType string `json:"mediaType"`
6161+ Config json.RawMessage `json:"config,omitempty"`
6262+ Layers []json.RawMessage `json:"layers,omitempty"`
6363+ Manifests []json.RawMessage `json:"manifests,omitempty"`
6464+ Subject json.RawMessage `json:"subject,omitempty"`
6565+ Annotations map[string]string `json:"annotations,omitempty"`
6666+ }
6767+6868+ if err := json.Unmarshal(ociManifest, &ociData); err != nil {
6969+ return nil, err
7070+ }
7171+7272+ // Detect manifest type based on media type
7373+ isManifestList := strings.Contains(ociData.MediaType, "manifest.list") ||
7474+ strings.Contains(ociData.MediaType, "image.index")
7575+7676+ // Validate: must have either (config+layers) OR (manifests), never both
7777+ hasImageFields := len(ociData.Config) > 0 || len(ociData.Layers) > 0
7878+ hasIndexFields := len(ociData.Manifests) > 0
7979+8080+ if hasImageFields && hasIndexFields {
8181+ return nil, fmt.Errorf("manifest cannot have both image fields (config/layers) and index fields (manifests)")
8282+ }
8383+ if !hasImageFields && !hasIndexFields {
8484+ return nil, fmt.Errorf("manifest must have either image fields (config/layers) or index fields (manifests)")
8585+ }
8686+8787+ record := &Manifest{
8888+ LexiconTypeID: ManifestCollection,
8989+ Repository: repository,
9090+ Digest: digest,
9191+ MediaType: ociData.MediaType,
9292+ SchemaVersion: int64(ociData.SchemaVersion),
9393+ // ManifestBlob will be set by the caller after uploading to blob storage
9494+ CreatedAt: time.Now().Format(time.RFC3339),
9595+ }
9696+9797+ // Handle annotations - Manifest_Annotations is an empty struct in generated code
9898+ // We don't copy ociData.Annotations since the generated type doesn't support arbitrary keys
9999+100100+ if isManifestList {
101101+ // Parse manifest list/index
102102+ record.Manifests = make([]Manifest_ManifestReference, len(ociData.Manifests))
103103+ for i, m := range ociData.Manifests {
104104+ var ref struct {
105105+ MediaType string `json:"mediaType"`
106106+ Digest string `json:"digest"`
107107+ Size int64 `json:"size"`
108108+ Platform *Manifest_Platform `json:"platform,omitempty"`
109109+ Annotations map[string]string `json:"annotations,omitempty"`
110110+ }
111111+ if err := json.Unmarshal(m, &ref); err != nil {
112112+ return nil, fmt.Errorf("failed to parse manifest reference %d: %w", i, err)
113113+ }
114114+ record.Manifests[i] = Manifest_ManifestReference{
115115+ MediaType: ref.MediaType,
116116+ Digest: ref.Digest,
117117+ Size: ref.Size,
118118+ Platform: ref.Platform,
119119+ }
120120+ }
121121+ } else {
122122+ // Parse image manifest
123123+ if len(ociData.Config) > 0 {
124124+ var config Manifest_BlobReference
125125+ if err := json.Unmarshal(ociData.Config, &config); err != nil {
126126+ return nil, fmt.Errorf("failed to parse config: %w", err)
127127+ }
128128+ record.Config = &config
129129+ }
130130+131131+ // Parse layers
132132+ record.Layers = make([]Manifest_BlobReference, len(ociData.Layers))
133133+ for i, layer := range ociData.Layers {
134134+ if err := json.Unmarshal(layer, &record.Layers[i]); err != nil {
135135+ return nil, fmt.Errorf("failed to parse layer %d: %w", i, err)
136136+ }
137137+ }
138138+ }
139139+140140+ // Parse subject if present (works for both types)
141141+ if len(ociData.Subject) > 0 {
142142+ var subject Manifest_BlobReference
143143+ if err := json.Unmarshal(ociData.Subject, &subject); err != nil {
144144+ return nil, err
145145+ }
146146+ record.Subject = &subject
147147+ }
148148+149149+ return record, nil
150150+}
151151+152152+// NewTagRecord creates a new tag record with manifest AT-URI
153153+// did: The DID of the user (e.g., "did:plc:xyz123")
154154+// repository: The repository name (e.g., "myapp")
155155+// tag: The tag name (e.g., "latest", "v1.0.0")
156156+// manifestDigest: The manifest digest (e.g., "sha256:abc123...")
157157+func NewTagRecord(did, repository, tag, manifestDigest string) *Tag {
158158+ // Build AT-URI for the manifest
159159+ // Format: at://did:plc:xyz/io.atcr.manifest/<digest-without-sha256-prefix>
160160+ manifestURI := BuildManifestURI(did, manifestDigest)
161161+162162+ return &Tag{
163163+ LexiconTypeID: TagCollection,
164164+ Repository: repository,
165165+ Tag: tag,
166166+ Manifest: &manifestURI,
167167+ // Note: ManifestDigest is not set for new records (only for backward compat with old records)
168168+ CreatedAt: time.Now().Format(time.RFC3339),
169169+ }
170170+}
171171+172172+// NewSailorProfileRecord creates a new sailor profile record
173173+func NewSailorProfileRecord(defaultHold string) *SailorProfile {
174174+ now := time.Now().Format(time.RFC3339)
175175+ var holdPtr *string
176176+ if defaultHold != "" {
177177+ holdPtr = &defaultHold
178178+ }
179179+ return &SailorProfile{
180180+ LexiconTypeID: SailorProfileCollection,
181181+ DefaultHold: holdPtr,
182182+ CreatedAt: now,
183183+ UpdatedAt: &now,
184184+ }
185185+}
186186+187187+// NewStarRecord creates a new star record
188188+func NewStarRecord(ownerDID, repository string) *SailorStar {
189189+ return &SailorStar{
190190+ LexiconTypeID: StarCollection,
191191+ Subject: SailorStar_Subject{
192192+ Did: ownerDID,
193193+ Repository: repository,
194194+ },
195195+ CreatedAt: time.Now().Format(time.RFC3339),
196196+ }
197197+}
198198+199199+// NewLayerRecord creates a new layer record
200200+func NewLayerRecord(digest string, size int64, mediaType, repository, userDID, userHandle string) *HoldLayer {
201201+ return &HoldLayer{
202202+ LexiconTypeID: LayerCollection,
203203+ Digest: digest,
204204+ Size: size,
205205+ MediaType: mediaType,
206206+ Repository: repository,
207207+ UserDid: userDID,
208208+ UserHandle: userHandle,
209209+ CreatedAt: time.Now().Format(time.RFC3339),
210210+ }
211211+}
212212+213213+// StarRecordKey generates a record key for a star
214214+// Uses a simple hash to ensure uniqueness and prevent duplicate stars
215215+func StarRecordKey(ownerDID, repository string) string {
216216+ // Use base64 encoding of "ownerDID/repository" as the record key
217217+ // This is deterministic and prevents duplicate stars
218218+ combined := ownerDID + "/" + repository
219219+ return base64.RawURLEncoding.EncodeToString([]byte(combined))
220220+}
221221+222222+// ParseStarRecordKey decodes a star record key back to ownerDID and repository
223223+func ParseStarRecordKey(rkey string) (ownerDID, repository string, err error) {
224224+ decoded, err := base64.RawURLEncoding.DecodeString(rkey)
225225+ if err != nil {
226226+ return "", "", fmt.Errorf("failed to decode star rkey: %w", err)
227227+ }
228228+229229+ parts := strings.SplitN(string(decoded), "/", 2)
230230+ if len(parts) != 2 {
231231+ return "", "", fmt.Errorf("invalid star rkey format: %s", string(decoded))
232232+ }
233233+234234+ return parts[0], parts[1], nil
235235+}
236236+237237+// ResolveHoldDIDFromURL converts a hold endpoint URL to a did:web DID
238238+// This ensures that different representations of the same hold are deduplicated:
239239+// - http://172.28.0.3:8080 โ did:web:172.28.0.3:8080
240240+// - http://hold01.atcr.io โ did:web:hold01.atcr.io
241241+// - https://hold01.atcr.io โ did:web:hold01.atcr.io
242242+// - did:web:hold01.atcr.io โ did:web:hold01.atcr.io (passthrough)
243243+func ResolveHoldDIDFromURL(holdURL string) string {
244244+ // Handle empty URLs
245245+ if holdURL == "" {
246246+ return ""
247247+ }
248248+249249+ // If already a DID, return as-is
250250+ if IsDID(holdURL) {
251251+ return holdURL
252252+ }
253253+254254+ // Parse URL to get hostname
255255+ holdURL = strings.TrimPrefix(holdURL, "http://")
256256+ holdURL = strings.TrimPrefix(holdURL, "https://")
257257+ holdURL = strings.TrimSuffix(holdURL, "/")
258258+259259+ // Extract hostname (remove path if present)
260260+ parts := strings.Split(holdURL, "/")
261261+ hostname := parts[0]
262262+263263+ // Convert to did:web
264264+ // did:web uses hostname directly (port included if non-standard)
265265+ return "did:web:" + hostname
266266+}
267267+268268+// IsDID checks if a string is a DID (starts with "did:")
269269+func IsDID(s string) bool {
270270+ return len(s) > 4 && s[:4] == "did:"
271271+}
272272+273273+// RepositoryTagToRKey converts a repository and tag to an ATProto record key
274274+// ATProto record keys must match: ^[a-zA-Z0-9._~-]{1,512}$
275275+func RepositoryTagToRKey(repository, tag string) string {
276276+ // Combine repository and tag to create a unique key
277277+ // Replace invalid characters: slashes become tildes (~)
278278+ // We use tilde instead of dash to avoid ambiguity with repository names that contain hyphens
279279+ key := fmt.Sprintf("%s_%s", repository, tag)
280280+281281+ // Replace / with ~ (slash not allowed in rkeys, tilde is allowed and unlikely in repo names)
282282+ key = strings.ReplaceAll(key, "/", "~")
283283+284284+ return key
285285+}
286286+287287+// RKeyToRepositoryTag converts an ATProto record key back to repository and tag
288288+// This is the inverse of RepositoryTagToRKey
289289+// Note: If the tag contains underscores, this will split on the LAST underscore
290290+func RKeyToRepositoryTag(rkey string) (repository, tag string) {
291291+ // Find the last underscore to split repository and tag
292292+ lastUnderscore := strings.LastIndex(rkey, "_")
293293+ if lastUnderscore == -1 {
294294+ // No underscore found - treat entire string as tag with empty repository
295295+ return "", rkey
296296+ }
297297+298298+ repository = rkey[:lastUnderscore]
299299+ tag = rkey[lastUnderscore+1:]
300300+301301+ // Convert tildes back to slashes in repository (tilde was used to encode slashes)
302302+ repository = strings.ReplaceAll(repository, "~", "/")
303303+304304+ return repository, tag
305305+}
306306+307307+// BuildManifestURI creates an AT-URI for a manifest record
308308+// did: The DID of the user (e.g., "did:plc:xyz123")
309309+// manifestDigest: The manifest digest (e.g., "sha256:abc123...")
310310+// Returns: AT-URI in format "at://did:plc:xyz/io.atcr.manifest/<digest-without-sha256-prefix>"
311311+func BuildManifestURI(did, manifestDigest string) string {
312312+ // Remove the "sha256:" prefix from the digest to get the rkey
313313+ rkey := strings.TrimPrefix(manifestDigest, "sha256:")
314314+ return fmt.Sprintf("at://%s/%s/%s", did, ManifestCollection, rkey)
315315+}
316316+317317+// ParseManifestURI extracts the digest from a manifest AT-URI
318318+// manifestURI: AT-URI in format "at://did:plc:xyz/io.atcr.manifest/<digest-without-sha256-prefix>"
319319+// Returns: Full digest with "sha256:" prefix (e.g., "sha256:abc123...")
320320+func ParseManifestURI(manifestURI string) (string, error) {
321321+ // Expected format: at://did:plc:xyz/io.atcr.manifest/<rkey>
322322+ if !strings.HasPrefix(manifestURI, "at://") {
323323+ return "", fmt.Errorf("invalid AT-URI format: must start with 'at://'")
324324+ }
325325+326326+ // Remove "at://" prefix
327327+ remainder := strings.TrimPrefix(manifestURI, "at://")
328328+329329+ // Split by "/"
330330+ parts := strings.Split(remainder, "/")
331331+ if len(parts) != 3 {
332332+ return "", fmt.Errorf("invalid AT-URI format: expected 3 parts (did/collection/rkey), got %d", len(parts))
333333+ }
334334+335335+ // Validate collection
336336+ if parts[1] != ManifestCollection {
337337+ return "", fmt.Errorf("invalid AT-URI: expected collection %s, got %s", ManifestCollection, parts[1])
338338+ }
339339+340340+ // The rkey is the digest without the "sha256:" prefix
341341+ // Add it back to get the full digest
342342+ rkey := parts[2]
343343+ return "sha256:" + rkey, nil
344344+}
345345+346346+// GetManifestDigest extracts the digest from a Tag, preferring the manifest field
347347+// Returns the digest with "sha256:" prefix (e.g., "sha256:abc123...")
348348+func (t *Tag) GetManifestDigest() (string, error) {
349349+ // Prefer the new manifest field
350350+ if t.Manifest != nil && *t.Manifest != "" {
351351+ return ParseManifestURI(*t.Manifest)
352352+ }
353353+354354+ // Fall back to the legacy manifestDigest field
355355+ if t.ManifestDigest != nil && *t.ManifestDigest != "" {
356356+ return *t.ManifestDigest, nil
357357+ }
358358+359359+ return "", fmt.Errorf("tag record has neither manifest nor manifestDigest field")
360360+}
+108-132
pkg/atproto/lexicon_test.go
···104104 digest string
105105 ociManifest string
106106 wantErr bool
107107- checkFunc func(*testing.T, *ManifestRecord)
107107+ checkFunc func(*testing.T, *Manifest)
108108 }{
109109 {
110110 name: "valid OCI manifest",
···112112 digest: "sha256:abc123",
113113 ociManifest: validOCIManifest,
114114 wantErr: false,
115115- checkFunc: func(t *testing.T, record *ManifestRecord) {
116116- if record.Type != ManifestCollection {
117117- t.Errorf("Type = %v, want %v", record.Type, ManifestCollection)
115115+ checkFunc: func(t *testing.T, record *Manifest) {
116116+ if record.LexiconTypeID != ManifestCollection {
117117+ t.Errorf("LexiconTypeID = %v, want %v", record.LexiconTypeID, ManifestCollection)
118118 }
119119 if record.Repository != "myapp" {
120120 t.Errorf("Repository = %v, want myapp", record.Repository)
···143143 if record.Layers[1].Digest != "sha256:layer2" {
144144 t.Errorf("Layers[1].Digest = %v, want sha256:layer2", record.Layers[1].Digest)
145145 }
146146- if record.Annotations["org.opencontainers.image.created"] != "2025-01-01T00:00:00Z" {
147147- t.Errorf("Annotations missing expected key")
148148- }
149149- if record.CreatedAt.IsZero() {
150150- t.Error("CreatedAt should not be zero")
146146+ // Note: Annotations are not copied to generated type (empty struct)
147147+ if record.CreatedAt == "" {
148148+ t.Error("CreatedAt should not be empty")
151149 }
152150 if record.Subject != nil {
153151 t.Error("Subject should be nil")
···160158 digest: "sha256:abc123",
161159 ociManifest: manifestWithSubject,
162160 wantErr: false,
163163- checkFunc: func(t *testing.T, record *ManifestRecord) {
161161+ checkFunc: func(t *testing.T, record *Manifest) {
164162 if record.Subject == nil {
165163 t.Fatal("Subject should not be nil")
166164 }
···192190 digest: "sha256:multiarch",
193191 ociManifest: manifestList,
194192 wantErr: false,
195195- checkFunc: func(t *testing.T, record *ManifestRecord) {
193193+ checkFunc: func(t *testing.T, record *Manifest) {
196194 if record.MediaType != "application/vnd.oci.image.index.v1+json" {
197195 t.Errorf("MediaType = %v, want application/vnd.oci.image.index.v1+json", record.MediaType)
198196 }
···219217 if record.Manifests[0].Platform.Architecture != "amd64" {
220218 t.Errorf("Platform.Architecture = %v, want amd64", record.Manifests[0].Platform.Architecture)
221219 }
222222- if record.Manifests[0].Platform.OS != "linux" {
223223- t.Errorf("Platform.OS = %v, want linux", record.Manifests[0].Platform.OS)
220220+ if record.Manifests[0].Platform.Os != "linux" {
221221+ t.Errorf("Platform.Os = %v, want linux", record.Manifests[0].Platform.Os)
224222 }
225223226224 // Check second manifest (arm64)
···230228 if record.Manifests[1].Platform.Architecture != "arm64" {
231229 t.Errorf("Platform.Architecture = %v, want arm64", record.Manifests[1].Platform.Architecture)
232230 }
233233- if record.Manifests[1].Platform.Variant != "v8" {
231231+ if record.Manifests[1].Platform.Variant == nil || *record.Manifests[1].Platform.Variant != "v8" {
234232 t.Errorf("Platform.Variant = %v, want v8", record.Manifests[1].Platform.Variant)
235233 }
236234 },
···268266269267func TestNewTagRecord(t *testing.T) {
270268 did := "did:plc:test123"
271271- before := time.Now()
269269+ // Truncate to second precision since RFC3339 doesn't have sub-second precision
270270+ before := time.Now().Truncate(time.Second)
272271 record := NewTagRecord(did, "myapp", "latest", "sha256:abc123")
273273- after := time.Now()
272272+ after := time.Now().Truncate(time.Second).Add(time.Second)
274273275275- if record.Type != TagCollection {
276276- t.Errorf("Type = %v, want %v", record.Type, TagCollection)
274274+ if record.LexiconTypeID != TagCollection {
275275+ t.Errorf("LexiconTypeID = %v, want %v", record.LexiconTypeID, TagCollection)
277276 }
278277279278 if record.Repository != "myapp" {
···286285287286 // New records should have manifest field (AT-URI)
288287 expectedURI := "at://did:plc:test123/io.atcr.manifest/abc123"
289289- if record.Manifest != expectedURI {
288288+ if record.Manifest == nil || *record.Manifest != expectedURI {
290289 t.Errorf("Manifest = %v, want %v", record.Manifest, expectedURI)
291290 }
292291293292 // New records should NOT have manifestDigest field
294294- if record.ManifestDigest != "" {
295295- t.Errorf("ManifestDigest should be empty for new records, got %v", record.ManifestDigest)
293293+ if record.ManifestDigest != nil && *record.ManifestDigest != "" {
294294+ t.Errorf("ManifestDigest should be nil for new records, got %v", record.ManifestDigest)
296295 }
297296298298- if record.UpdatedAt.Before(before) || record.UpdatedAt.After(after) {
299299- t.Errorf("UpdatedAt = %v, want between %v and %v", record.UpdatedAt, before, after)
297297+ createdAt, err := time.Parse(time.RFC3339, record.CreatedAt)
298298+ if err != nil {
299299+ t.Errorf("CreatedAt is not valid RFC3339: %v", err)
300300+ }
301301+ if createdAt.Before(before) || createdAt.After(after) {
302302+ t.Errorf("CreatedAt = %v, want between %v and %v", createdAt, before, after)
300303 }
301304}
302305···391394}
392395393396func TestTagRecord_GetManifestDigest(t *testing.T) {
397397+ manifestURI := "at://did:plc:test123/io.atcr.manifest/abc123"
398398+ digestValue := "sha256:def456"
399399+394400 tests := []struct {
395401 name string
396396- record TagRecord
402402+ record Tag
397403 want string
398404 wantErr bool
399405 }{
400406 {
401407 name: "new record with manifest field",
402402- record: TagRecord{
403403- Manifest: "at://did:plc:test123/io.atcr.manifest/abc123",
408408+ record: Tag{
409409+ Manifest: &manifestURI,
404410 },
405411 want: "sha256:abc123",
406412 wantErr: false,
407413 },
408414 {
409415 name: "old record with manifestDigest field",
410410- record: TagRecord{
411411- ManifestDigest: "sha256:def456",
416416+ record: Tag{
417417+ ManifestDigest: &digestValue,
412418 },
413419 want: "sha256:def456",
414420 wantErr: false,
415421 },
416422 {
417423 name: "prefers manifest over manifestDigest",
418418- record: TagRecord{
419419- Manifest: "at://did:plc:test123/io.atcr.manifest/abc123",
420420- ManifestDigest: "sha256:def456",
424424+ record: Tag{
425425+ Manifest: &manifestURI,
426426+ ManifestDigest: &digestValue,
421427 },
422428 want: "sha256:abc123",
423429 wantErr: false,
424430 },
425431 {
426432 name: "no fields set",
427427- record: TagRecord{},
433433+ record: Tag{},
428434 want: "",
429435 wantErr: true,
430436 },
431437 {
432438 name: "invalid manifest URI",
433433- record: TagRecord{
434434- Manifest: "invalid-uri",
439439+ record: Tag{
440440+ Manifest: func() *string { s := "invalid-uri"; return &s }(),
435441 },
436442 want: "",
437443 wantErr: true,
···452458 }
453459}
454460455455-func TestNewHoldRecord(t *testing.T) {
456456- tests := []struct {
457457- name string
458458- endpoint string
459459- owner string
460460- public bool
461461- }{
462462- {
463463- name: "public hold",
464464- endpoint: "https://hold1.example.com",
465465- owner: "did:plc:alice123",
466466- public: true,
467467- },
468468- {
469469- name: "private hold",
470470- endpoint: "https://hold2.example.com",
471471- owner: "did:plc:bob456",
472472- public: false,
473473- },
474474- }
475475-476476- for _, tt := range tests {
477477- t.Run(tt.name, func(t *testing.T) {
478478- before := time.Now()
479479- record := NewHoldRecord(tt.endpoint, tt.owner, tt.public)
480480- after := time.Now()
481481-482482- if record.Type != HoldCollection {
483483- t.Errorf("Type = %v, want %v", record.Type, HoldCollection)
484484- }
485485-486486- if record.Endpoint != tt.endpoint {
487487- t.Errorf("Endpoint = %v, want %v", record.Endpoint, tt.endpoint)
488488- }
489489-490490- if record.Owner != tt.owner {
491491- t.Errorf("Owner = %v, want %v", record.Owner, tt.owner)
492492- }
493493-494494- if record.Public != tt.public {
495495- t.Errorf("Public = %v, want %v", record.Public, tt.public)
496496- }
497497-498498- if record.CreatedAt.Before(before) || record.CreatedAt.After(after) {
499499- t.Errorf("CreatedAt = %v, want between %v and %v", record.CreatedAt, before, after)
500500- }
501501- })
502502- }
503503-}
461461+// TestNewHoldRecord is removed - HoldRecord is no longer supported (legacy BYOS)
504462505463func TestNewSailorProfileRecord(t *testing.T) {
506464 tests := []struct {
···523481524482 for _, tt := range tests {
525483 t.Run(tt.name, func(t *testing.T) {
526526- before := time.Now()
484484+ // Truncate to second precision since RFC3339 doesn't have sub-second precision
485485+ before := time.Now().Truncate(time.Second)
527486 record := NewSailorProfileRecord(tt.defaultHold)
528528- after := time.Now()
487487+ after := time.Now().Truncate(time.Second).Add(time.Second)
529488530530- if record.Type != SailorProfileCollection {
531531- t.Errorf("Type = %v, want %v", record.Type, SailorProfileCollection)
489489+ if record.LexiconTypeID != SailorProfileCollection {
490490+ t.Errorf("LexiconTypeID = %v, want %v", record.LexiconTypeID, SailorProfileCollection)
532491 }
533492534534- if record.DefaultHold != tt.defaultHold {
535535- t.Errorf("DefaultHold = %v, want %v", record.DefaultHold, tt.defaultHold)
493493+ if tt.defaultHold == "" {
494494+ if record.DefaultHold != nil {
495495+ t.Errorf("DefaultHold = %v, want nil", record.DefaultHold)
496496+ }
497497+ } else {
498498+ if record.DefaultHold == nil || *record.DefaultHold != tt.defaultHold {
499499+ t.Errorf("DefaultHold = %v, want %v", record.DefaultHold, tt.defaultHold)
500500+ }
536501 }
537502538538- if record.CreatedAt.Before(before) || record.CreatedAt.After(after) {
539539- t.Errorf("CreatedAt = %v, want between %v and %v", record.CreatedAt, before, after)
503503+ createdAt, err := time.Parse(time.RFC3339, record.CreatedAt)
504504+ if err != nil {
505505+ t.Errorf("CreatedAt is not valid RFC3339: %v", err)
540506 }
541541-542542- if record.UpdatedAt.Before(before) || record.UpdatedAt.After(after) {
543543- t.Errorf("UpdatedAt = %v, want between %v and %v", record.UpdatedAt, before, after)
507507+ if createdAt.Before(before) || createdAt.After(after) {
508508+ t.Errorf("CreatedAt = %v, want between %v and %v", createdAt, before, after)
544509 }
545510546546- // CreatedAt and UpdatedAt should be equal for new records
547547- if !record.CreatedAt.Equal(record.UpdatedAt) {
548548- t.Errorf("CreatedAt (%v) != UpdatedAt (%v)", record.CreatedAt, record.UpdatedAt)
511511+ if record.UpdatedAt == nil {
512512+ t.Error("UpdatedAt should not be nil")
513513+ } else {
514514+ updatedAt, err := time.Parse(time.RFC3339, *record.UpdatedAt)
515515+ if err != nil {
516516+ t.Errorf("UpdatedAt is not valid RFC3339: %v", err)
517517+ }
518518+ if updatedAt.Before(before) || updatedAt.After(after) {
519519+ t.Errorf("UpdatedAt = %v, want between %v and %v", updatedAt, before, after)
520520+ }
549521 }
550522 })
551523 }
552524}
553525554526func TestNewStarRecord(t *testing.T) {
555555- before := time.Now()
527527+ // Truncate to second precision since RFC3339 doesn't have sub-second precision
528528+ before := time.Now().Truncate(time.Second)
556529 record := NewStarRecord("did:plc:alice123", "myapp")
557557- after := time.Now()
530530+ after := time.Now().Truncate(time.Second).Add(time.Second)
558531559559- if record.Type != StarCollection {
560560- t.Errorf("Type = %v, want %v", record.Type, StarCollection)
532532+ if record.LexiconTypeID != StarCollection {
533533+ t.Errorf("LexiconTypeID = %v, want %v", record.LexiconTypeID, StarCollection)
561534 }
562535563563- if record.Subject.DID != "did:plc:alice123" {
564564- t.Errorf("Subject.DID = %v, want did:plc:alice123", record.Subject.DID)
536536+ if record.Subject.Did != "did:plc:alice123" {
537537+ t.Errorf("Subject.Did = %v, want did:plc:alice123", record.Subject.Did)
565538 }
566539567540 if record.Subject.Repository != "myapp" {
568541 t.Errorf("Subject.Repository = %v, want myapp", record.Subject.Repository)
569542 }
570543571571- if record.CreatedAt.Before(before) || record.CreatedAt.After(after) {
572572- t.Errorf("CreatedAt = %v, want between %v and %v", record.CreatedAt, before, after)
544544+ createdAt, err := time.Parse(time.RFC3339, record.CreatedAt)
545545+ if err != nil {
546546+ t.Errorf("CreatedAt is not valid RFC3339: %v", err)
547547+ }
548548+ if createdAt.Before(before) || createdAt.After(after) {
549549+ t.Errorf("CreatedAt = %v, want between %v and %v", createdAt, before, after)
573550 }
574551}
575552···857834 }
858835859836 // Add hold DID
860860- record.HoldDID = "did:web:hold01.atcr.io"
837837+ holdDID := "did:web:hold01.atcr.io"
838838+ record.HoldDid = &holdDID
861839862840 // Serialize to JSON
863841 jsonData, err := json.Marshal(record)
···866844 }
867845868846 // Deserialize from JSON
869869- var decoded ManifestRecord
847847+ var decoded Manifest
870848 if err := json.Unmarshal(jsonData, &decoded); err != nil {
871849 t.Fatalf("json.Unmarshal() error = %v", err)
872850 }
873851874852 // Verify fields
875875- if decoded.Type != record.Type {
876876- t.Errorf("Type = %v, want %v", decoded.Type, record.Type)
853853+ if decoded.LexiconTypeID != record.LexiconTypeID {
854854+ t.Errorf("LexiconTypeID = %v, want %v", decoded.LexiconTypeID, record.LexiconTypeID)
877855 }
878856 if decoded.Repository != record.Repository {
879857 t.Errorf("Repository = %v, want %v", decoded.Repository, record.Repository)
···881859 if decoded.Digest != record.Digest {
882860 t.Errorf("Digest = %v, want %v", decoded.Digest, record.Digest)
883861 }
884884- if decoded.HoldDID != record.HoldDID {
885885- t.Errorf("HoldDID = %v, want %v", decoded.HoldDID, record.HoldDID)
862862+ if decoded.HoldDid == nil || *decoded.HoldDid != *record.HoldDid {
863863+ t.Errorf("HoldDid = %v, want %v", decoded.HoldDid, record.HoldDid)
886864 }
887865 if decoded.Config.Digest != record.Config.Digest {
888866 t.Errorf("Config.Digest = %v, want %v", decoded.Config.Digest, record.Config.Digest)
···893871}
894872895873func TestBlobReference_JSONSerialization(t *testing.T) {
896896- blob := BlobReference{
874874+ blob := Manifest_BlobReference{
897875 MediaType: "application/vnd.oci.image.layer.v1.tar+gzip",
898876 Digest: "sha256:abc123",
899877 Size: 12345,
900900- URLs: []string{"https://s3.example.com/blob"},
901901- Annotations: map[string]string{
902902- "key": "value",
903903- },
878878+ Urls: []string{"https://s3.example.com/blob"},
879879+ // Note: Annotations is now an empty struct, not a map
904880 }
905881906882 // Serialize
···910886 }
911887912888 // Deserialize
913913- var decoded BlobReference
889889+ var decoded Manifest_BlobReference
914890 if err := json.Unmarshal(jsonData, &decoded); err != nil {
915891 t.Fatalf("json.Unmarshal() error = %v", err)
916892 }
···928904}
929905930906func TestStarSubject_JSONSerialization(t *testing.T) {
931931- subject := StarSubject{
932932- DID: "did:plc:alice123",
907907+ subject := SailorStar_Subject{
908908+ Did: "did:plc:alice123",
933909 Repository: "myapp",
934910 }
935911···940916 }
941917942918 // Deserialize
943943- var decoded StarSubject
919919+ var decoded SailorStar_Subject
944920 if err := json.Unmarshal(jsonData, &decoded); err != nil {
945921 t.Fatalf("json.Unmarshal() error = %v", err)
946922 }
947923948924 // Verify
949949- if decoded.DID != subject.DID {
950950- t.Errorf("DID = %v, want %v", decoded.DID, subject.DID)
925925+ if decoded.Did != subject.Did {
926926+ t.Errorf("Did = %v, want %v", decoded.Did, subject.Did)
951927 }
952928 if decoded.Repository != subject.Repository {
953929 t.Errorf("Repository = %v, want %v", decoded.Repository, subject.Repository)
···11941170 t.Fatal("NewLayerRecord() returned nil")
11951171 }
1196117211971197- if record.Type != LayerCollection {
11981198- t.Errorf("Type = %q, want %q", record.Type, LayerCollection)
11731173+ if record.LexiconTypeID != LayerCollection {
11741174+ t.Errorf("LexiconTypeID = %q, want %q", record.LexiconTypeID, LayerCollection)
11991175 }
1200117612011177 if record.Digest != tt.digest {
···12141190 t.Errorf("Repository = %q, want %q", record.Repository, tt.repository)
12151191 }
1216119212171217- if record.UserDID != tt.userDID {
12181218- t.Errorf("UserDID = %q, want %q", record.UserDID, tt.userDID)
11931193+ if record.UserDid != tt.userDID {
11941194+ t.Errorf("UserDid = %q, want %q", record.UserDid, tt.userDID)
12191195 }
1220119612211197 if record.UserHandle != tt.userHandle {
···12371213}
1238121412391215func TestNewLayerRecordJSON(t *testing.T) {
12401240- // Test that LayerRecord can be marshaled/unmarshaled to/from JSON
12161216+ // Test that HoldLayer can be marshaled/unmarshaled to/from JSON
12411217 record := NewLayerRecord(
12421218 "sha256:abc123",
12431219 1024,
···12541230 }
1255123112561232 // Unmarshal back
12571257- var decoded LayerRecord
12331233+ var decoded HoldLayer
12581234 if err := json.Unmarshal(jsonData, &decoded); err != nil {
12591235 t.Fatalf("json.Unmarshal() error = %v", err)
12601236 }
1261123712621238 // Verify fields match
12631263- if decoded.Type != record.Type {
12641264- t.Errorf("Type = %q, want %q", decoded.Type, record.Type)
12391239+ if decoded.LexiconTypeID != record.LexiconTypeID {
12401240+ t.Errorf("LexiconTypeID = %q, want %q", decoded.LexiconTypeID, record.LexiconTypeID)
12651241 }
12661242 if decoded.Digest != record.Digest {
12671243 t.Errorf("Digest = %q, want %q", decoded.Digest, record.Digest)
···12751251 if decoded.Repository != record.Repository {
12761252 t.Errorf("Repository = %q, want %q", decoded.Repository, record.Repository)
12771253 }
12781278- if decoded.UserDID != record.UserDID {
12791279- t.Errorf("UserDID = %q, want %q", decoded.UserDID, record.UserDID)
12541254+ if decoded.UserDid != record.UserDid {
12551255+ t.Errorf("UserDid = %q, want %q", decoded.UserDid, record.UserDid)
12801256 }
12811257 if decoded.UserHandle != record.UserHandle {
12821258 t.Errorf("UserHandle = %q, want %q", decoded.UserHandle, record.UserHandle)
+103
pkg/atproto/manifest.go
···11+// Code generated by generate.go; DO NOT EDIT.
22+33+// Lexicon schema: io.atcr.manifest
44+55+package atproto
66+77+import (
88+ lexutil "github.com/bluesky-social/indigo/lex/util"
99+)
1010+1111+// A container image manifest following OCI specification, stored in ATProto
1212+type Manifest struct {
1313+ LexiconTypeID string `json:"$type" cborgen:"$type,const=io.atcr.manifest"`
1414+ // annotations: Optional metadata annotations
1515+ Annotations *Manifest_Annotations `json:"annotations,omitempty" cborgen:"annotations,omitempty"`
1616+ // config: Reference to image configuration blob
1717+ Config *Manifest_BlobReference `json:"config,omitempty" cborgen:"config,omitempty"`
1818+ // createdAt: Record creation timestamp
1919+ CreatedAt string `json:"createdAt" cborgen:"createdAt"`
2020+ // digest: Content digest (e.g., 'sha256:abc123...')
2121+ Digest string `json:"digest" cborgen:"digest"`
2222+ // holdDid: DID of the hold service where blobs are stored (e.g., 'did:web:hold01.atcr.io'). Primary reference for hold resolution.
2323+ HoldDid *string `json:"holdDid,omitempty" cborgen:"holdDid,omitempty"`
2424+ // holdEndpoint: Hold service endpoint URL where blobs are stored. DEPRECATED: Use holdDid instead. Kept for backward compatibility.
2525+ HoldEndpoint *string `json:"holdEndpoint,omitempty" cborgen:"holdEndpoint,omitempty"`
2626+ // layers: Filesystem layers (for image manifests)
2727+ Layers []Manifest_BlobReference `json:"layers,omitempty" cborgen:"layers,omitempty"`
2828+ // manifestBlob: The full OCI manifest stored as a blob in ATProto.
2929+ ManifestBlob *lexutil.LexBlob `json:"manifestBlob,omitempty" cborgen:"manifestBlob,omitempty"`
3030+ // manifests: Referenced manifests (for manifest lists/indexes)
3131+ Manifests []Manifest_ManifestReference `json:"manifests,omitempty" cborgen:"manifests,omitempty"`
3232+ // mediaType: OCI media type
3333+ MediaType string `json:"mediaType" cborgen:"mediaType"`
3434+ // repository: Repository name (e.g., 'myapp'). Scoped to user's DID.
3535+ Repository string `json:"repository" cborgen:"repository"`
3636+ // schemaVersion: OCI schema version (typically 2)
3737+ SchemaVersion int64 `json:"schemaVersion" cborgen:"schemaVersion"`
3838+ // subject: Optional reference to another manifest (for attestations, signatures)
3939+ Subject *Manifest_BlobReference `json:"subject,omitempty" cborgen:"subject,omitempty"`
4040+}
4141+4242+// Optional metadata annotations
4343+type Manifest_Annotations struct {
4444+}
4545+4646+// Manifest_BlobReference is a "blobReference" in the io.atcr.manifest schema.
4747+//
4848+// Reference to a blob stored in S3 or external storage
4949+type Manifest_BlobReference struct {
5050+ LexiconTypeID string `json:"$type,omitempty" cborgen:"$type,const=io.atcr.manifest#blobReference,omitempty"`
5151+ // annotations: Optional metadata
5252+ Annotations *Manifest_BlobReference_Annotations `json:"annotations,omitempty" cborgen:"annotations,omitempty"`
5353+ // digest: Content digest (e.g., 'sha256:...')
5454+ Digest string `json:"digest" cborgen:"digest"`
5555+ // mediaType: MIME type of the blob
5656+ MediaType string `json:"mediaType" cborgen:"mediaType"`
5757+ // size: Size in bytes
5858+ Size int64 `json:"size" cborgen:"size"`
5959+ // urls: Optional direct URLs to blob (for BYOS)
6060+ Urls []string `json:"urls,omitempty" cborgen:"urls,omitempty"`
6161+}
6262+6363+// Optional metadata
6464+type Manifest_BlobReference_Annotations struct {
6565+}
6666+6767+// Manifest_ManifestReference is a "manifestReference" in the io.atcr.manifest schema.
6868+//
6969+// Reference to a manifest in a manifest list/index
7070+type Manifest_ManifestReference struct {
7171+ LexiconTypeID string `json:"$type,omitempty" cborgen:"$type,const=io.atcr.manifest#manifestReference,omitempty"`
7272+ // annotations: Optional metadata
7373+ Annotations *Manifest_ManifestReference_Annotations `json:"annotations,omitempty" cborgen:"annotations,omitempty"`
7474+ // digest: Content digest (e.g., 'sha256:...')
7575+ Digest string `json:"digest" cborgen:"digest"`
7676+ // mediaType: Media type of the referenced manifest
7777+ MediaType string `json:"mediaType" cborgen:"mediaType"`
7878+ // platform: Platform information for this manifest
7979+ Platform *Manifest_Platform `json:"platform,omitempty" cborgen:"platform,omitempty"`
8080+ // size: Size in bytes
8181+ Size int64 `json:"size" cborgen:"size"`
8282+}
8383+8484+// Optional metadata
8585+type Manifest_ManifestReference_Annotations struct {
8686+}
8787+8888+// Manifest_Platform is a "platform" in the io.atcr.manifest schema.
8989+//
9090+// Platform information describing OS and architecture
9191+type Manifest_Platform struct {
9292+ LexiconTypeID string `json:"$type,omitempty" cborgen:"$type,const=io.atcr.manifest#platform,omitempty"`
9393+ // architecture: CPU architecture (e.g., 'amd64', 'arm64', 'arm')
9494+ Architecture string `json:"architecture" cborgen:"architecture"`
9595+ // os: Operating system (e.g., 'linux', 'windows', 'darwin')
9696+ Os string `json:"os" cborgen:"os"`
9797+ // osFeatures: Optional OS features
9898+ OsFeatures []string `json:"osFeatures,omitempty" cborgen:"osFeatures,omitempty"`
9999+ // osVersion: Optional OS version
100100+ OsVersion *string `json:"osVersion,omitempty" cborgen:"osVersion,omitempty"`
101101+ // variant: Optional CPU variant (e.g., 'v7' for ARM)
102102+ Variant *string `json:"variant,omitempty" cborgen:"variant,omitempty"`
103103+}
···11+// Code generated by generate.go; DO NOT EDIT.
22+33+// Lexicon schema: io.atcr.sailor.profile
44+55+package atproto
66+77+// User profile for ATCR registry. Stores preferences like default hold for blob storage.
88+type SailorProfile struct {
99+ LexiconTypeID string `json:"$type" cborgen:"$type,const=io.atcr.sailor.profile"`
1010+ // createdAt: Profile creation timestamp
1111+ CreatedAt string `json:"createdAt" cborgen:"createdAt"`
1212+ // defaultHold: Default hold endpoint for blob storage. If null, user has opted out of defaults.
1313+ DefaultHold *string `json:"defaultHold,omitempty" cborgen:"defaultHold,omitempty"`
1414+ // updatedAt: Profile last updated timestamp
1515+ UpdatedAt *string `json:"updatedAt,omitempty" cborgen:"updatedAt,omitempty"`
1616+}
+25
pkg/atproto/sailorstar.go
···11+// Code generated by generate.go; DO NOT EDIT.
22+33+// Lexicon schema: io.atcr.sailor.star
44+55+package atproto
66+77+// A star (like) on a container image repository. Stored in the starrer's PDS, similar to Bluesky likes.
88+type SailorStar struct {
99+ LexiconTypeID string `json:"$type" cborgen:"$type,const=io.atcr.sailor.star"`
1010+ // createdAt: Star creation timestamp
1111+ CreatedAt string `json:"createdAt" cborgen:"createdAt"`
1212+ // subject: The repository being starred
1313+ Subject SailorStar_Subject `json:"subject" cborgen:"subject"`
1414+}
1515+1616+// SailorStar_Subject is a "subject" in the io.atcr.sailor.star schema.
1717+//
1818+// Reference to a repository owned by a user
1919+type SailorStar_Subject struct {
2020+ LexiconTypeID string `json:"$type,omitempty" cborgen:"$type,const=io.atcr.sailor.star#subject,omitempty"`
2121+ // did: DID of the repository owner
2222+ Did string `json:"did" cborgen:"did"`
2323+ // repository: Repository name (e.g., 'myapp')
2424+ Repository string `json:"repository" cborgen:"repository"`
2525+}
+20
pkg/atproto/tag.go
···11+// Code generated by generate.go; DO NOT EDIT.
22+33+// Lexicon schema: io.atcr.tag
44+55+package atproto
66+77+// A named tag pointing to a specific manifest digest
88+type Tag struct {
99+ LexiconTypeID string `json:"$type" cborgen:"$type,const=io.atcr.tag"`
1010+ // createdAt: Tag creation timestamp
1111+ CreatedAt string `json:"createdAt" cborgen:"createdAt"`
1212+ // manifest: AT-URI of the manifest this tag points to (e.g., 'at://did:plc:xyz/io.atcr.manifest/abc123'). Preferred over manifestDigest for new records.
1313+ Manifest *string `json:"manifest,omitempty" cborgen:"manifest,omitempty"`
1414+ // manifestDigest: DEPRECATED: Digest of the manifest (e.g., 'sha256:...'). Kept for backward compatibility with old records. New records should use 'manifest' field instead.
1515+ ManifestDigest *string `json:"manifestDigest,omitempty" cborgen:"manifestDigest,omitempty"`
1616+ // repository: Repository name (e.g., 'myapp'). Scoped to user's DID.
1717+ Repository string `json:"repository" cborgen:"repository"`
1818+ // tag: Tag name (e.g., 'latest', 'v1.0.0', '12-slim')
1919+ Tag string `json:"tag" cborgen:"tag"`
2020+}
+3-3
pkg/auth/hold_authorizer.go
···21212222 // GetCaptainRecord retrieves the captain record for a hold
2323 // Used to check public flag and allowAllCrew settings
2424- GetCaptainRecord(ctx context.Context, holdDID string) (*atproto.CaptainRecord, error)
2424+ GetCaptainRecord(ctx context.Context, holdDID string) (*atproto.HoldCaptain, error)
25252626 // IsCrewMember checks if userDID is a crew member of holdDID
2727 IsCrewMember(ctx context.Context, holdDID, userDID string) (bool, error)
···3232// Read access rules:
3333// - Public hold: allow anyone (even anonymous)
3434// - Private hold: require authentication (any authenticated user)
3535-func CheckReadAccessWithCaptain(captain *atproto.CaptainRecord, userDID string) bool {
3535+func CheckReadAccessWithCaptain(captain *atproto.HoldCaptain, userDID string) bool {
3636 if captain.Public {
3737 // Public hold - allow anyone (even anonymous)
3838 return true
···5555// Write access rules:
5656// - Must be authenticated
5757// - Must be hold owner OR crew member
5858-func CheckWriteAccessWithCaptain(captain *atproto.CaptainRecord, userDID string, isCrew bool) bool {
5858+func CheckWriteAccessWithCaptain(captain *atproto.HoldCaptain, userDID string, isCrew bool) bool {
5959 slog.Debug("Checking write access", "userDID", userDID, "owner", captain.Owner, "isCrew", isCrew)
60606161 if userDID == "" {
···3535}
36363737// GetCaptainRecord retrieves the captain record from the hold's PDS
3838-func (a *LocalHoldAuthorizer) GetCaptainRecord(ctx context.Context, holdDID string) (*atproto.CaptainRecord, error) {
3838+func (a *LocalHoldAuthorizer) GetCaptainRecord(ctx context.Context, holdDID string) (*atproto.HoldCaptain, error) {
3939 // Verify that the requested holdDID matches this hold
4040 if holdDID != a.pds.DID() {
4141 return nil, fmt.Errorf("holdDID mismatch: requested %s, this hold is %s", holdDID, a.pds.DID())
···4747 return nil, fmt.Errorf("failed to get captain record: %w", err)
4848 }
49495050- // The PDS returns *atproto.CaptainRecord directly now (after we update pds to use atproto types)
5050+ // The PDS returns *atproto.HoldCaptain directly
5151 return pdsCaptain, nil
5252}
5353
+34-20
pkg/auth/hold_remote.go
···101101// 1. Check database cache
102102// 2. If cache miss or expired, query hold's XRPC endpoint
103103// 3. Update cache
104104-func (a *RemoteHoldAuthorizer) GetCaptainRecord(ctx context.Context, holdDID string) (*atproto.CaptainRecord, error) {
104104+func (a *RemoteHoldAuthorizer) GetCaptainRecord(ctx context.Context, holdDID string) (*atproto.HoldCaptain, error) {
105105 // Try cache first
106106 if a.db != nil {
107107 cached, err := a.getCachedCaptainRecord(holdDID)
108108 if err == nil && cached != nil {
109109 // Cache hit - check if still valid
110110 if time.Since(cached.UpdatedAt) < a.cacheTTL {
111111- return cached.CaptainRecord, nil
111111+ return cached.HoldCaptain, nil
112112 }
113113 // Cache expired - continue to fetch fresh data
114114 }
···133133134134// captainRecordWithMeta includes UpdatedAt for cache management
135135type captainRecordWithMeta struct {
136136- *atproto.CaptainRecord
136136+ *atproto.HoldCaptain
137137 UpdatedAt time.Time
138138}
139139···145145 WHERE hold_did = ?
146146 `
147147148148- var record atproto.CaptainRecord
148148+ var record atproto.HoldCaptain
149149 var deployedAt, region, provider sql.NullString
150150 var updatedAt time.Time
151151···172172 record.DeployedAt = deployedAt.String
173173 }
174174 if region.Valid {
175175- record.Region = region.String
175175+ record.Region = ®ion.String
176176 }
177177 if provider.Valid {
178178- record.Provider = provider.String
178178+ record.Provider = &provider.String
179179 }
180180181181 return &captainRecordWithMeta{
182182- CaptainRecord: &record,
183183- UpdatedAt: updatedAt,
182182+ HoldCaptain: &record,
183183+ UpdatedAt: updatedAt,
184184 }, nil
185185}
186186187187// setCachedCaptainRecord stores a captain record in database cache
188188-func (a *RemoteHoldAuthorizer) setCachedCaptainRecord(holdDID string, record *atproto.CaptainRecord) error {
188188+func (a *RemoteHoldAuthorizer) setCachedCaptainRecord(holdDID string, record *atproto.HoldCaptain) error {
189189 query := `
190190 INSERT INTO hold_captain_records (
191191 hold_did, owner_did, public, allow_all_crew,
···207207 record.Public,
208208 record.AllowAllCrew,
209209 nullString(record.DeployedAt),
210210- nullString(record.Region),
211211- nullString(record.Provider),
210210+ nullStringPtr(record.Region),
211211+ nullStringPtr(record.Provider),
212212 time.Now(),
213213 )
214214···216216}
217217218218// fetchCaptainRecordFromXRPC queries the hold's XRPC endpoint for captain record
219219-func (a *RemoteHoldAuthorizer) fetchCaptainRecordFromXRPC(ctx context.Context, holdDID string) (*atproto.CaptainRecord, error) {
219219+func (a *RemoteHoldAuthorizer) fetchCaptainRecordFromXRPC(ctx context.Context, holdDID string) (*atproto.HoldCaptain, error) {
220220 // Resolve DID to URL
221221 holdURL := atproto.ResolveHoldURL(holdDID)
222222···261261 }
262262263263 // Convert to our type
264264- record := &atproto.CaptainRecord{
265265- Type: atproto.CaptainCollection,
266266- Owner: xrpcResp.Value.Owner,
267267- Public: xrpcResp.Value.Public,
268268- AllowAllCrew: xrpcResp.Value.AllowAllCrew,
269269- DeployedAt: xrpcResp.Value.DeployedAt,
270270- Region: xrpcResp.Value.Region,
271271- Provider: xrpcResp.Value.Provider,
264264+ record := &atproto.HoldCaptain{
265265+ LexiconTypeID: atproto.CaptainCollection,
266266+ Owner: xrpcResp.Value.Owner,
267267+ Public: xrpcResp.Value.Public,
268268+ AllowAllCrew: xrpcResp.Value.AllowAllCrew,
269269+ DeployedAt: xrpcResp.Value.DeployedAt,
270270+ }
271271+272272+ // Handle optional pointer fields
273273+ if xrpcResp.Value.Region != "" {
274274+ record.Region = &xrpcResp.Value.Region
275275+ }
276276+ if xrpcResp.Value.Provider != "" {
277277+ record.Provider = &xrpcResp.Value.Provider
272278 }
273279274280 return record, nil
···406412 return sql.NullString{Valid: false}
407413 }
408414 return sql.NullString{String: s, Valid: true}
415415+}
416416+417417+// nullStringPtr converts a *string to sql.NullString
418418+func nullStringPtr(s *string) sql.NullString {
419419+ if s == nil || *s == "" {
420420+ return sql.NullString{Valid: false}
421421+ }
422422+ return sql.NullString{String: *s, Valid: true}
409423}
410424411425// getCachedApproval checks if user has a cached crew approval
···33import (
44 "context"
55 "encoding/json"
66+ "errors"
67 "fmt"
78 "io"
89 "log/slog"
···1112 "time"
12131314 "atcr.io/pkg/atproto"
1515+ "atcr.io/pkg/auth"
1416 "atcr.io/pkg/auth/oauth"
1717+ "github.com/bluesky-social/indigo/atproto/atclient"
1818+ indigo_oauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
1519)
16202121+// getErrorHint provides context-specific troubleshooting hints based on API error type
2222+func getErrorHint(apiErr *atclient.APIError) string {
2323+ switch apiErr.Name {
2424+ case "use_dpop_nonce":
2525+ return "DPoP nonce mismatch - indigo library should automatically retry with new nonce. If this persists, check for concurrent request issues or PDS session corruption."
2626+ case "invalid_client":
2727+ if apiErr.Message != "" && apiErr.Message == "Validation of \"client_assertion\" failed: \"iat\" claim timestamp check failed (it should be in the past)" {
2828+ return "JWT timestamp validation failed - system clock on AppView may be ahead of PDS clock. Check NTP sync with: timedatectl status"
2929+ }
3030+ return "OAuth client authentication failed - check client key configuration and PDS OAuth server status"
3131+ case "invalid_token", "invalid_grant":
3232+ return "OAuth tokens expired or invalidated - user will need to re-authenticate via OAuth flow"
3333+ case "server_error":
3434+ if apiErr.StatusCode == 500 {
3535+ return "PDS returned internal server error - this may occur after repeated DPoP nonce failures or other PDS-side issues. Check PDS logs for root cause."
3636+ }
3737+ return "PDS server error - check PDS health and logs"
3838+ case "invalid_dpop_proof":
3939+ return "DPoP proof validation failed - check system clock sync and DPoP key configuration"
4040+ default:
4141+ if apiErr.StatusCode == 401 || apiErr.StatusCode == 403 {
4242+ return "Authentication/authorization failed - OAuth session may be expired or revoked"
4343+ }
4444+ return "PDS rejected the request - see errorName and errorMessage for details"
4545+ }
4646+}
4747+1748// GetOrFetchServiceToken gets a service token for hold authentication.
1849// Checks cache first, then fetches from PDS with OAuth/DPoP if needed.
1950// This is the canonical implementation used by both middleware and crew registration.
5151+//
5252+// IMPORTANT: Uses DoWithSession() to hold a per-DID lock through the entire PDS interaction.
5353+// This prevents DPoP nonce race conditions when multiple Docker layers upload concurrently.
2054func GetOrFetchServiceToken(
2155 ctx context.Context,
2256 refresher *oauth.Refresher,
···4478 slog.Debug("Service token expiring soon, proactively renewing", "did", did)
4579 }
46804747- session, err := refresher.GetSession(ctx, did)
8181+ // Use DoWithSession to hold the lock through the entire PDS interaction.
8282+ // This prevents DPoP nonce races when multiple goroutines try to fetch service tokens.
8383+ var serviceToken string
8484+ var fetchErr error
8585+8686+ err := refresher.DoWithSession(ctx, did, func(session *indigo_oauth.ClientSession) error {
8787+ // Double-check cache after acquiring lock - another goroutine may have
8888+ // populated it while we were waiting (classic double-checked locking pattern)
8989+ cachedToken, expiresAt := GetServiceToken(did, holdDID)
9090+ if cachedToken != "" && time.Until(expiresAt) > 10*time.Second {
9191+ slog.Debug("Service token cache hit after lock acquisition",
9292+ "did", did,
9393+ "expiresIn", time.Until(expiresAt).Round(time.Second))
9494+ serviceToken = cachedToken
9595+ return nil
9696+ }
9797+9898+ // Cache still empty/expired - proceed with PDS call
9999+ // Request 5-minute expiry (PDS may grant less)
100100+ // exp must be absolute Unix timestamp, not relative duration
101101+ // Note: OAuth scope includes #atcr_hold fragment, but service auth aud must be bare DID
102102+ expiryTime := time.Now().Unix() + 300 // 5 minutes from now
103103+ serviceAuthURL := fmt.Sprintf("%s%s?aud=%s&lxm=%s&exp=%d",
104104+ pdsEndpoint,
105105+ atproto.ServerGetServiceAuth,
106106+ url.QueryEscape(holdDID),
107107+ url.QueryEscape("com.atproto.repo.getRecord"),
108108+ expiryTime,
109109+ )
110110+111111+ req, err := http.NewRequestWithContext(ctx, "GET", serviceAuthURL, nil)
112112+ if err != nil {
113113+ fetchErr = fmt.Errorf("failed to create service auth request: %w", err)
114114+ return fetchErr
115115+ }
116116+117117+ // Use OAuth session to authenticate to PDS (with DPoP)
118118+ // The lock is held, so DPoP nonce negotiation is serialized per-DID
119119+ resp, err := session.DoWithAuth(session.Client, req, "com.atproto.server.getServiceAuth")
120120+ if err != nil {
121121+ // Auth error - may indicate expired tokens or corrupted session
122122+ InvalidateServiceToken(did, holdDID)
123123+124124+ // Inspect the error to extract detailed information from indigo's APIError
125125+ var apiErr *atclient.APIError
126126+ if errors.As(err, &apiErr) {
127127+ // Log detailed API error information
128128+ slog.Error("OAuth authentication failed during service token request",
129129+ "component", "token/servicetoken",
130130+ "did", did,
131131+ "holdDID", holdDID,
132132+ "pdsEndpoint", pdsEndpoint,
133133+ "url", serviceAuthURL,
134134+ "error", err,
135135+ "httpStatus", apiErr.StatusCode,
136136+ "errorName", apiErr.Name,
137137+ "errorMessage", apiErr.Message,
138138+ "hint", getErrorHint(apiErr))
139139+ } else {
140140+ // Fallback for non-API errors (network errors, etc.)
141141+ slog.Error("OAuth authentication failed during service token request",
142142+ "component", "token/servicetoken",
143143+ "did", did,
144144+ "holdDID", holdDID,
145145+ "pdsEndpoint", pdsEndpoint,
146146+ "url", serviceAuthURL,
147147+ "error", err,
148148+ "errorType", fmt.Sprintf("%T", err),
149149+ "hint", "Network error or unexpected failure during OAuth request")
150150+ }
151151+152152+ fetchErr = fmt.Errorf("OAuth validation failed: %w", err)
153153+ return fetchErr
154154+ }
155155+ defer resp.Body.Close()
156156+157157+ if resp.StatusCode != http.StatusOK {
158158+ // Service auth failed
159159+ bodyBytes, _ := io.ReadAll(resp.Body)
160160+ InvalidateServiceToken(did, holdDID)
161161+ slog.Error("Service token request returned non-200 status",
162162+ "component", "token/servicetoken",
163163+ "did", did,
164164+ "holdDID", holdDID,
165165+ "pdsEndpoint", pdsEndpoint,
166166+ "statusCode", resp.StatusCode,
167167+ "responseBody", string(bodyBytes),
168168+ "hint", "PDS rejected the service token request - check PDS logs for details")
169169+ fetchErr = fmt.Errorf("service auth failed with status %d: %s", resp.StatusCode, string(bodyBytes))
170170+ return fetchErr
171171+ }
172172+173173+ // Parse response to get service token
174174+ var result struct {
175175+ Token string `json:"token"`
176176+ }
177177+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
178178+ fetchErr = fmt.Errorf("failed to decode service auth response: %w", err)
179179+ return fetchErr
180180+ }
181181+182182+ if result.Token == "" {
183183+ fetchErr = fmt.Errorf("empty token in service auth response")
184184+ return fetchErr
185185+ }
186186+187187+ serviceToken = result.Token
188188+ return nil
189189+ })
190190+48191 if err != nil {
4949- // OAuth session unavailable - invalidate and fail
5050- refresher.InvalidateSession(did)
192192+ // DoWithSession failed (session load or callback error)
51193 InvalidateServiceToken(did, holdDID)
194194+195195+ // Try to extract detailed error information
196196+ var apiErr *atclient.APIError
197197+ if errors.As(err, &apiErr) {
198198+ slog.Error("Failed to get OAuth session for service token",
199199+ "component", "token/servicetoken",
200200+ "did", did,
201201+ "holdDID", holdDID,
202202+ "pdsEndpoint", pdsEndpoint,
203203+ "error", err,
204204+ "httpStatus", apiErr.StatusCode,
205205+ "errorName", apiErr.Name,
206206+ "errorMessage", apiErr.Message,
207207+ "hint", getErrorHint(apiErr))
208208+ } else if fetchErr == nil {
209209+ // Session load failed (not a fetch error)
210210+ slog.Error("Failed to get OAuth session for service token",
211211+ "component", "token/servicetoken",
212212+ "did", did,
213213+ "holdDID", holdDID,
214214+ "pdsEndpoint", pdsEndpoint,
215215+ "error", err,
216216+ "errorType", fmt.Sprintf("%T", err),
217217+ "hint", "OAuth session not found in database or token refresh failed")
218218+ }
219219+220220+ // Delete the stale OAuth session to force re-authentication
221221+ // This also invalidates the UI session automatically
222222+ if delErr := refresher.DeleteSession(ctx, did); delErr != nil {
223223+ slog.Warn("Failed to delete stale OAuth session",
224224+ "component", "token/servicetoken",
225225+ "did", did,
226226+ "error", delErr)
227227+ }
228228+229229+ if fetchErr != nil {
230230+ return "", fetchErr
231231+ }
52232 return "", fmt.Errorf("failed to get OAuth session: %w", err)
53233 }
542345555- // Call com.atproto.server.getServiceAuth on the user's PDS
235235+ // Cache the token (parses JWT to extract actual expiry)
236236+ if err := SetServiceToken(did, holdDID, serviceToken); err != nil {
237237+ slog.Warn("Failed to cache service token", "error", err, "did", did, "holdDID", holdDID)
238238+ // Non-fatal - we have the token, just won't be cached
239239+ }
240240+241241+ slog.Debug("OAuth validation succeeded, service token obtained", "did", did)
242242+ return serviceToken, nil
243243+}
244244+245245+// GetOrFetchServiceTokenWithAppPassword gets a service token using app-password Bearer authentication.
246246+// Used when auth method is app_password instead of OAuth.
247247+func GetOrFetchServiceTokenWithAppPassword(
248248+ ctx context.Context,
249249+ did, holdDID, pdsEndpoint string,
250250+) (string, error) {
251251+ // Check cache first to avoid unnecessary PDS calls on every request
252252+ cachedToken, expiresAt := GetServiceToken(did, holdDID)
253253+254254+ // Use cached token if it exists and has > 10s remaining
255255+ if cachedToken != "" && time.Until(expiresAt) > 10*time.Second {
256256+ slog.Debug("Using cached service token (app-password)",
257257+ "did", did,
258258+ "expiresIn", time.Until(expiresAt).Round(time.Second))
259259+ return cachedToken, nil
260260+ }
261261+262262+ // Cache miss or expiring soon - get app-password token and fetch new service token
263263+ if cachedToken == "" {
264264+ slog.Debug("Service token cache miss, fetching new token with app-password", "did", did)
265265+ } else {
266266+ slog.Debug("Service token expiring soon, proactively renewing with app-password", "did", did)
267267+ }
268268+269269+ // Get app-password access token from cache
270270+ accessToken, ok := auth.GetGlobalTokenCache().Get(did)
271271+ if !ok {
272272+ InvalidateServiceToken(did, holdDID)
273273+ slog.Error("No app-password access token found in cache",
274274+ "component", "token/servicetoken",
275275+ "did", did,
276276+ "holdDID", holdDID,
277277+ "hint", "User must re-authenticate with docker login")
278278+ return "", fmt.Errorf("no app-password access token available for DID %s", did)
279279+ }
280280+281281+ // Call com.atproto.server.getServiceAuth on the user's PDS with Bearer token
56282 // Request 5-minute expiry (PDS may grant less)
57283 // exp must be absolute Unix timestamp, not relative duration
5858- // Note: OAuth scope includes #atcr_hold fragment, but service auth aud must be bare DID
59284 expiryTime := time.Now().Unix() + 300 // 5 minutes from now
60285 serviceAuthURL := fmt.Sprintf("%s%s?aud=%s&lxm=%s&exp=%d",
61286 pdsEndpoint,
···70295 return "", fmt.Errorf("failed to create service auth request: %w", err)
71296 }
722977373- // Use OAuth session to authenticate to PDS (with DPoP)
7474- resp, err := session.DoWithAuth(session.Client, req, "com.atproto.server.getServiceAuth")
298298+ // Set Bearer token authentication (app-password)
299299+ req.Header.Set("Authorization", "Bearer "+accessToken)
300300+301301+ // Make request with standard HTTP client
302302+ resp, err := http.DefaultClient.Do(req)
75303 if err != nil {
7676- // Invalidate session on auth errors (may indicate corrupted session or expired tokens)
7777- refresher.InvalidateSession(did)
78304 InvalidateServiceToken(did, holdDID)
7979- return "", fmt.Errorf("OAuth validation failed: %w", err)
305305+ slog.Error("App-password service token request failed",
306306+ "component", "token/servicetoken",
307307+ "did", did,
308308+ "holdDID", holdDID,
309309+ "pdsEndpoint", pdsEndpoint,
310310+ "error", err)
311311+ return "", fmt.Errorf("failed to request service token: %w", err)
80312 }
81313 defer resp.Body.Close()
82314315315+ if resp.StatusCode == http.StatusUnauthorized {
316316+ // App-password token is invalid or expired - clear from cache
317317+ auth.GetGlobalTokenCache().Delete(did)
318318+ InvalidateServiceToken(did, holdDID)
319319+ slog.Error("App-password token rejected by PDS",
320320+ "component", "token/servicetoken",
321321+ "did", did,
322322+ "hint", "User must re-authenticate with docker login")
323323+ return "", fmt.Errorf("app-password authentication failed: token expired or invalid")
324324+ }
325325+83326 if resp.StatusCode != http.StatusOK {
8484- // Invalidate session on auth failures
327327+ // Service auth failed
85328 bodyBytes, _ := io.ReadAll(resp.Body)
8686- refresher.InvalidateSession(did)
87329 InvalidateServiceToken(did, holdDID)
330330+ slog.Error("Service token request returned non-200 status (app-password)",
331331+ "component", "token/servicetoken",
332332+ "did", did,
333333+ "holdDID", holdDID,
334334+ "pdsEndpoint", pdsEndpoint,
335335+ "statusCode", resp.StatusCode,
336336+ "responseBody", string(bodyBytes))
88337 return "", fmt.Errorf("service auth failed with status %d: %s", resp.StatusCode, string(bodyBytes))
89338 }
90339···108357 // Non-fatal - we have the token, just won't be cached
109358 }
110359111111- slog.Debug("OAuth validation succeeded, service token obtained", "did", did)
360360+ slog.Debug("App-password validation succeeded, service token obtained", "did", did)
112361 return serviceToken, nil
113362}
+54
pkg/hold/config.go
···66package hold
7788import (
99+ "bytes"
1010+ "encoding/json"
911 "fmt"
1212+ "net/http"
1313+ "net/url"
1014 "os"
1115 "path/filepath"
1216 "time"
···6771 // DisablePresignedURLs forces proxy mode even with S3 configured (for testing) (from env: DISABLE_PRESIGNED_URLS)
6872 DisablePresignedURLs bool `yaml:"disable_presigned_urls"`
69737474+ // RelayEndpoint is the ATProto relay URL to request crawl from on startup (from env: HOLD_RELAY_ENDPOINT)
7575+ // If empty, no crawl request is made. Default: https://bsky.network
7676+ RelayEndpoint string `yaml:"relay_endpoint"`
7777+7078 // ReadTimeout for HTTP requests
7179 ReadTimeout time.Duration `yaml:"read_timeout"`
7280···103111 cfg.Server.Public = os.Getenv("HOLD_PUBLIC") == "true"
104112 cfg.Server.TestMode = os.Getenv("TEST_MODE") == "true"
105113 cfg.Server.DisablePresignedURLs = os.Getenv("DISABLE_PRESIGNED_URLS") == "true"
114114+ cfg.Server.RelayEndpoint = os.Getenv("HOLD_RELAY_ENDPOINT")
106115 cfg.Server.ReadTimeout = 5 * time.Minute // Increased for large blob uploads
107116 cfg.Server.WriteTimeout = 5 * time.Minute // Increased for large blob uploads
108117···180189 }
181190 return defaultValue
182191}
192192+193193+// RequestCrawl sends a crawl request to the ATProto relay for the given hostname.
194194+// This makes the hold's PDS discoverable by the relay network.
195195+func RequestCrawl(relayEndpoint, publicURL string) error {
196196+ if relayEndpoint == "" {
197197+ return nil // No relay configured, skip
198198+ }
199199+200200+ // Extract hostname from public URL
201201+ parsed, err := url.Parse(publicURL)
202202+ if err != nil {
203203+ return fmt.Errorf("failed to parse public URL: %w", err)
204204+ }
205205+ hostname := parsed.Host
206206+207207+ // Build the request URL
208208+ requestURL := relayEndpoint + "/xrpc/com.atproto.sync.requestCrawl"
209209+210210+ // Create request body
211211+ body := map[string]string{"hostname": hostname}
212212+ bodyJSON, err := json.Marshal(body)
213213+ if err != nil {
214214+ return fmt.Errorf("failed to marshal request body: %w", err)
215215+ }
216216+217217+ // Make the request
218218+ client := &http.Client{Timeout: 10 * time.Second}
219219+ req, err := http.NewRequest("POST", requestURL, bytes.NewReader(bodyJSON))
220220+ if err != nil {
221221+ return fmt.Errorf("failed to create request: %w", err)
222222+ }
223223+ req.Header.Set("Content-Type", "application/json")
224224+225225+ resp, err := client.Do(req)
226226+ if err != nil {
227227+ return fmt.Errorf("failed to send request: %w", err)
228228+ }
229229+ defer resp.Body.Close()
230230+231231+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
232232+ return fmt.Errorf("relay returned status %d", resp.StatusCode)
233233+ }
234234+235235+ return nil
236236+}
+25-1
pkg/hold/oci/xrpc.go
···230230 Size int64 `json:"size"`
231231 MediaType string `json:"mediaType"`
232232 } `json:"layers"`
233233+ Manifests []struct {
234234+ Digest string `json:"digest"`
235235+ Size int64 `json:"size"`
236236+ MediaType string `json:"mediaType"`
237237+ Platform *struct {
238238+ OS string `json:"os"`
239239+ Architecture string `json:"architecture"`
240240+ } `json:"platform"`
241241+ } `json:"manifests"`
233242 } `json:"manifest"`
234243 }
235244···276285 }
277286 }
278287279279- // Calculate total size from all layers
288288+ // Check if this is a multi-arch image (has manifests instead of layers)
289289+ isMultiArch := len(req.Manifest.Manifests) > 0
290290+291291+ // Calculate total size from all layers (for single-arch images)
280292 var totalSize int64
281293 for _, layer := range req.Manifest.Layers {
282294 totalSize += layer.Size
283295 }
284296 totalSize += req.Manifest.Config.Size // Add config blob size
285297298298+ // Extract platforms for multi-arch images
299299+ var platforms []string
300300+ if isMultiArch {
301301+ for _, m := range req.Manifest.Manifests {
302302+ if m.Platform != nil {
303303+ platforms = append(platforms, m.Platform.OS+"/"+m.Platform.Architecture)
304304+ }
305305+ }
306306+ }
307307+286308 // Create Bluesky post if enabled
287309 var postURI string
288310 postCreated := false
···295317296318 postURI, err = h.pds.CreateManifestPost(
297319 ctx,
320320+ h.driver,
298321 req.Repository,
299322 req.Tag,
300323 req.UserHandle,
301324 req.UserDID,
302325 manifestDigest,
303326 totalSize,
327327+ platforms,
304328 )
305329 if err != nil {
306330 slog.Error("Failed to create manifest post", "error", err)
+4-4
pkg/hold/pds/captain.go
···1818// CreateCaptainRecord creates the captain record for the hold (first-time only).
1919// This will FAIL if the captain record already exists. Use UpdateCaptainRecord to modify.
2020func (p *HoldPDS) CreateCaptainRecord(ctx context.Context, ownerDID string, public bool, allowAllCrew bool, enableBlueskyPosts bool) (cid.Cid, error) {
2121- captainRecord := &atproto.CaptainRecord{
2222- Type: atproto.CaptainCollection,
2121+ captainRecord := &atproto.HoldCaptain{
2222+ LexiconTypeID: atproto.CaptainCollection,
2323 Owner: ownerDID,
2424 Public: public,
2525 AllowAllCrew: allowAllCrew,
···4040}
41414242// GetCaptainRecord retrieves the captain record
4343-func (p *HoldPDS) GetCaptainRecord(ctx context.Context) (cid.Cid, *atproto.CaptainRecord, error) {
4343+func (p *HoldPDS) GetCaptainRecord(ctx context.Context) (cid.Cid, *atproto.HoldCaptain, error) {
4444 // Use repomgr.GetRecord - our types are registered in init()
4545 // so it will automatically unmarshal to the concrete type
4646 recordCID, val, err := p.repomgr.GetRecord(ctx, p.uid, atproto.CaptainCollection, CaptainRkey, cid.Undef)
···4949 }
50505151 // Type assert to our concrete type
5252- captainRecord, ok := val.(*atproto.CaptainRecord)
5252+ captainRecord, ok := val.(*atproto.HoldCaptain)
5353 if !ok {
5454 return cid.Undef, nil, fmt.Errorf("unexpected type for captain record: %T", val)
5555 }
···991010// CreateLayerRecord creates a new layer record in the hold's PDS
1111// Returns the rkey and CID of the created record
1212-func (p *HoldPDS) CreateLayerRecord(ctx context.Context, record *atproto.LayerRecord) (string, string, error) {
1212+func (p *HoldPDS) CreateLayerRecord(ctx context.Context, record *atproto.HoldLayer) (string, string, error) {
1313 // Validate record
1414- if record.Type != atproto.LayerCollection {
1515- return "", "", fmt.Errorf("invalid record type: %s", record.Type)
1414+ if record.LexiconTypeID != atproto.LayerCollection {
1515+ return "", "", fmt.Errorf("invalid record type: %s", record.LexiconTypeID)
1616 }
17171818 if record.Digest == "" {
···40404141// GetLayerRecord retrieves a specific layer record by rkey
4242// Note: This is a simplified implementation. For production, you may need to pass the CID
4343-func (p *HoldPDS) GetLayerRecord(ctx context.Context, rkey string) (*atproto.LayerRecord, error) {
4343+func (p *HoldPDS) GetLayerRecord(ctx context.Context, rkey string) (*atproto.HoldLayer, error) {
4444 // For now, we don't implement this as it's not needed for the manifest post feature
4545 // Full implementation would require querying the carstore with a specific CID
4646 return nil, fmt.Errorf("GetLayerRecord not yet implemented - use via XRPC listRecords instead")
···5050// Returns records, next cursor (empty if no more), and error
5151// Note: This is a simplified implementation. For production, consider adding filters
5252// (by repository, user, digest, etc.) and proper pagination
5353-func (p *HoldPDS) ListLayerRecords(ctx context.Context, limit int, cursor string) ([]*atproto.LayerRecord, string, error) {
5353+func (p *HoldPDS) ListLayerRecords(ctx context.Context, limit int, cursor string) ([]*atproto.HoldLayer, string, error) {
5454 // For now, return empty list - full implementation would query the carstore
5555 // This would require iterating over records in the collection and filtering
5656 // In practice, layer records are mainly for analytics and Bluesky posts,
···33import (
44 "context"
55 "fmt"
66+ "io"
67 "log/slog"
88+ "net/http"
79 "strings"
810 "time"
9111212+ "atcr.io/pkg/atproto"
1013 bsky "github.com/bluesky-social/indigo/api/bsky"
1414+ "github.com/distribution/distribution/v3/registry/storage/driver"
1115)
12161317// CreateManifestPost creates a Bluesky post announcing a manifest upload
1414-// Includes facets for clickable mentions and links
1818+// Includes mention facet for the user and an OG card embed with thumbnail
1519func (p *HoldPDS) CreateManifestPost(
1620 ctx context.Context,
2121+ storageDriver driver.StorageDriver,
1722 repository, tag, userHandle, userDID, digest string,
1823 totalSize int64,
2424+ platforms []string,
1925) (string, error) {
2026 now := time.Now()
21272228 // Build AppView repository URL
2329 appViewURL := fmt.Sprintf("https://atcr.io/r/%s/%s", userHandle, repository)
24302525- // Format post text components
2626- digestShort := formatDigest(digest)
2727- sizeStr := formatSize(totalSize)
3131+ // Build simplified text with mention - OG card handles the link
2832 repoWithTag := fmt.Sprintf("%s:%s", repository, tag)
3333+ text := fmt.Sprintf("@%s pushed %s", userHandle, repoWithTag)
29343030- // Build text: "@alice.bsky.social just pushed hsm-secrets-operator:latest\nDigest: sha256:abc...def Size: 12.2 MB"
3131- text := fmt.Sprintf("@%s just pushed %s\nDigest: %s Size: %s", userHandle, repoWithTag, digestShort, sizeStr)
3535+ // Only build mention facet - the OG card embed provides the link
3636+ facets := buildMentionFacet(text, userHandle, userDID)
32373333- // Create facets for mentions and links
3434- facets := buildFacets(text, userHandle, userDID, repoWithTag, appViewURL)
3838+ // Build embed with OG card
3939+ var embed *bsky.FeedPost_Embed
4040+4141+ ogImageData, err := fetchOGImage(ctx, userHandle, repository)
4242+ if err != nil {
4343+ slog.Warn("Failed to fetch OG image, posting without embed", "error", err)
4444+ } else {
4545+ // Upload OG image as blob
4646+ thumbBlob, err := uploadBlobToStorage(ctx, storageDriver, p.did, ogImageData, "image/png")
4747+ if err != nil {
4848+ slog.Warn("Failed to upload OG image blob", "error", err)
4949+ } else {
5050+ // Build dynamic description
5151+ var description string
5252+ if len(platforms) > 0 {
5353+ description = fmt.Sprintf("Multi-arch: %s", strings.Join(platforms, ", "))
5454+ } else {
5555+ description = fmt.Sprintf("Pushed %s to ATCR", formatSize(totalSize))
5656+ }
5757+5858+ embed = &bsky.FeedPost_Embed{
5959+ EmbedExternal: &bsky.EmbedExternal{
6060+ LexiconTypeID: "app.bsky.embed.external",
6161+ External: &bsky.EmbedExternal_External{
6262+ Uri: appViewURL,
6363+ Title: fmt.Sprintf("%s/%s:%s", userHandle, repository, tag),
6464+ Description: description,
6565+ Thumb: thumbBlob,
6666+ },
6767+ },
6868+ }
6969+ }
7070+ }
35713636- // Create post struct with facets
7272+ // Create post struct with facets and embed
3773 post := &bsky.FeedPost{
3838- LexiconTypeID: "app.bsky.feed.post",
7474+ LexiconTypeID: atproto.BskyPostCollection,
3975 Text: text,
4076 Facets: facets,
7777+ Embed: embed,
4178 CreatedAt: now.Format(time.RFC3339),
7979+ Langs: []string{"en"},
4280 }
43814482 // Create record with auto-generated TID
4583 rkey, recordCID, err := p.repomgr.CreateRecord(
4684 ctx,
4785 p.uid,
4848- "app.bsky.feed.post",
8686+ atproto.BskyPostCollection,
4987 post,
5088 )
5189···5492 }
55935694 // Build ATProto URI for the post
5757- postURI := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", p.did, rkey)
9595+ postURI := fmt.Sprintf("at://%s/%s/%s", p.did, atproto.BskyPostCollection, rkey)
58965997 slog.Info("Created manifest post",
6098 "uri", postURI,
···63101 return postURI, nil
64102}
651036666-// formatDigest truncates digest to first 10 chars
6767-// Example: sha256:abc1234567890fedcba9876543210 -> sha256:abc1234567...
6868-func formatDigest(digest string) string {
6969- if !strings.HasPrefix(digest, "sha256:") {
7070- return digest // Return as-is if not sha256
104104+// fetchOGImage downloads the OG card image from AppView
105105+func fetchOGImage(ctx context.Context, userHandle, repository string) ([]byte, error) {
106106+ url := fmt.Sprintf("https://atcr.io/og/r/%s/%s", userHandle, repository)
107107+108108+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
109109+ if err != nil {
110110+ return nil, err
71111 }
721127373- hash := strings.TrimPrefix(digest, "sha256:")
7474- if len(hash) <= 10 {
7575- return digest // Too short to truncate
113113+ client := &http.Client{Timeout: 10 * time.Second}
114114+ resp, err := client.Do(req)
115115+ if err != nil {
116116+ return nil, err
76117 }
118118+ defer resp.Body.Close()
771197878- return fmt.Sprintf("sha256:%s...", hash[:10])
120120+ if resp.StatusCode != http.StatusOK {
121121+ return nil, fmt.Errorf("OG image fetch failed: %d", resp.StatusCode)
122122+ }
123123+124124+ return io.ReadAll(resp.Body)
125125+}
126126+127127+// buildMentionFacet creates a mention facet for the user handle
128128+// IMPORTANT: Byte offsets must be calculated for UTF-8 encoded text
129129+func buildMentionFacet(text, userHandle, userDID string) []*bsky.RichtextFacet {
130130+ mentionText := "@" + userHandle
131131+ mentionStart := strings.Index(text, mentionText)
132132+ if mentionStart < 0 {
133133+ return nil
134134+ }
135135+136136+ byteStart := int64(len(text[:mentionStart]))
137137+ byteEnd := int64(len(text[:mentionStart+len(mentionText)]))
138138+139139+ return []*bsky.RichtextFacet{{
140140+ Index: &bsky.RichtextFacet_ByteSlice{
141141+ ByteStart: byteStart,
142142+ ByteEnd: byteEnd,
143143+ },
144144+ Features: []*bsky.RichtextFacet_Features_Elem{{
145145+ RichtextFacet_Mention: &bsky.RichtextFacet_Mention{
146146+ Did: userDID,
147147+ },
148148+ }},
149149+ }}
79150}
8015181152// formatSize converts bytes to human-readable format
···98169 return fmt.Sprintf("%d B", bytes)
99170 }
100171}
101101-102102-// buildFacets creates mention and link facets for rich text
103103-// IMPORTANT: Byte offsets must be calculated for UTF-8 encoded text
104104-func buildFacets(text, userHandle, userDID, repoWithTag, appViewURL string) []*bsky.RichtextFacet {
105105- facets := []*bsky.RichtextFacet{}
106106-107107- // Find mention: "@alice.bsky.social"
108108- mentionText := "@" + userHandle
109109- mentionStart := strings.Index(text, mentionText)
110110- if mentionStart >= 0 {
111111- // Calculate byte offsets (not character offsets!)
112112- byteStart := int64(len(text[:mentionStart]))
113113- byteEnd := int64(len(text[:mentionStart+len(mentionText)]))
114114-115115- facets = append(facets, &bsky.RichtextFacet{
116116- Index: &bsky.RichtextFacet_ByteSlice{
117117- ByteStart: byteStart,
118118- ByteEnd: byteEnd,
119119- },
120120- Features: []*bsky.RichtextFacet_Features_Elem{
121121- {
122122- RichtextFacet_Mention: &bsky.RichtextFacet_Mention{
123123- Did: userDID,
124124- },
125125- },
126126- },
127127- })
128128- }
129129-130130- // Find repository link: "hsm-secrets-operator:latest"
131131- linkStart := strings.Index(text, repoWithTag)
132132- if linkStart >= 0 {
133133- // Calculate byte offsets
134134- byteStart := int64(len(text[:linkStart]))
135135- byteEnd := int64(len(text[:linkStart+len(repoWithTag)]))
136136-137137- facets = append(facets, &bsky.RichtextFacet{
138138- Index: &bsky.RichtextFacet_ByteSlice{
139139- ByteStart: byteStart,
140140- ByteEnd: byteEnd,
141141- },
142142- Features: []*bsky.RichtextFacet_Features_Elem{
143143- {
144144- RichtextFacet_Link: &bsky.RichtextFacet_Link{
145145- Uri: appViewURL,
146146- },
147147- },
148148- },
149149- })
150150- }
151151-152152- return facets
153153-}
+144-163
pkg/hold/pds/manifest_post_test.go
···44 "strings"
55 "testing"
6677+ "atcr.io/pkg/atproto"
78 bsky "github.com/bluesky-social/indigo/api/bsky"
89)
99-1010-func TestFormatDigest(t *testing.T) {
1111- tests := []struct {
1212- name string
1313- digest string
1414- expected string
1515- }{
1616- {
1717- name: "standard sha256 digest",
1818- digest: "sha256:abc1234567890fedcba9876543210",
1919- expected: "sha256:abc1234567...", // First 10 chars
2020- },
2121- {
2222- name: "short digest (no truncation)",
2323- digest: "sha256:abc123",
2424- expected: "sha256:abc123",
2525- },
2626- {
2727- name: "non-sha256 digest",
2828- digest: "sha512:abc123",
2929- expected: "sha512:abc123",
3030- },
3131- {
3232- name: "real sha256 digest",
3333- digest: "sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f",
3434- expected: "sha256:e692418e4c...", // First 10 chars
3535- },
3636- }
3737-3838- for _, tt := range tests {
3939- t.Run(tt.name, func(t *testing.T) {
4040- result := formatDigest(tt.digest)
4141- if result != tt.expected {
4242- t.Errorf("formatDigest(%q) = %q, want %q", tt.digest, result, tt.expected)
4343- }
4444- })
4545- }
4646-}
47104811func TestFormatSize(t *testing.T) {
4912 tests := []struct {
···10366 }
10467}
10568106106-func TestBuildFacets(t *testing.T) {
6969+func TestBuildMentionFacet(t *testing.T) {
10770 tests := []struct {
108108- name string
109109- text string
110110- userHandle string
111111- userDID string
112112- repoWithTag string
113113- appViewURL string
114114- wantFacets int // number of facets expected
7171+ name string
7272+ text string
7373+ userHandle string
7474+ userDID string
7575+ wantFacets int // number of facets expected
11576 }{
11677 {
117117- name: "standard post with mention and link",
118118- text: "@alice.bsky.social just pushed myapp:latest\nDigest: sha256:abc...def Size: 12.2 MB",
119119- userHandle: "alice.bsky.social",
120120- userDID: "did:plc:alice123",
121121- repoWithTag: "myapp:latest",
122122- appViewURL: "https://atcr.io/r/alice.bsky.social/myapp",
123123- wantFacets: 2,
7878+ name: "standard post with mention",
7979+ text: "@alice.bsky.social pushed myapp:latest",
8080+ userHandle: "alice.bsky.social",
8181+ userDID: "did:plc:alice123",
8282+ wantFacets: 1,
12483 },
12584 {
126126- name: "no matches found",
127127- text: "random text",
128128- userHandle: "alice.bsky.social",
129129- userDID: "did:plc:alice123",
130130- repoWithTag: "myapp:latest",
131131- appViewURL: "https://atcr.io/r/alice.bsky.social/myapp",
132132- wantFacets: 0,
8585+ name: "no mention found",
8686+ text: "random text",
8787+ userHandle: "alice.bsky.social",
8888+ userDID: "did:plc:alice123",
8989+ wantFacets: 0,
13390 },
13491 {
135135- name: "only mention found",
136136- text: "@alice.bsky.social did something",
137137- userHandle: "alice.bsky.social",
138138- userDID: "did:plc:alice123",
139139- repoWithTag: "myapp:latest",
140140- appViewURL: "https://atcr.io/r/alice.bsky.social/myapp",
141141- wantFacets: 1,
9292+ name: "mention at start",
9393+ text: "@alice.bsky.social did something",
9494+ userHandle: "alice.bsky.social",
9595+ userDID: "did:plc:alice123",
9696+ wantFacets: 1,
14297 },
14398 }
14499145100 for _, tt := range tests {
146101 t.Run(tt.name, func(t *testing.T) {
147147- facets := buildFacets(tt.text, tt.userHandle, tt.userDID, tt.repoWithTag, tt.appViewURL)
102102+ facets := buildMentionFacet(tt.text, tt.userHandle, tt.userDID)
148103149104 if len(facets) != tt.wantFacets {
150150- t.Errorf("buildFacets() returned %d facets, want %d", len(facets), tt.wantFacets)
105105+ t.Errorf("buildMentionFacet() returned %d facets, want %d", len(facets), tt.wantFacets)
151106 }
152107153108 // Verify facet structure for standard case
154154- if tt.name == "standard post with mention and link" && len(facets) == 2 {
155155- // Check mention facet
109109+ if tt.wantFacets > 0 && len(facets) > 0 {
156110 mentionFacet := facets[0]
157111 if mentionFacet.Index == nil {
158112 t.Error("mention facet has nil Index")
···163117 if mentionFacet.Features[0].RichtextFacet_Mention == nil {
164118 t.Error("mention facet feature is not a mention")
165119 }
166166-167167- // Check link facet
168168- linkFacet := facets[1]
169169- if linkFacet.Index == nil {
170170- t.Error("link facet has nil Index")
171171- }
172172- if len(linkFacet.Features) != 1 {
173173- t.Errorf("link facet has %d features, want 1", len(linkFacet.Features))
174174- }
175175- if linkFacet.Features[0].RichtextFacet_Link == nil {
176176- t.Error("link facet feature is not a link")
177177- }
178178- if linkFacet.Features[0].RichtextFacet_Link.Uri != tt.appViewURL {
179179- t.Errorf("link facet URI = %q, want %q", linkFacet.Features[0].RichtextFacet_Link.Uri, tt.appViewURL)
120120+ if mentionFacet.Features[0].RichtextFacet_Mention.Did != tt.userDID {
121121+ t.Errorf("mention DID = %q, want %q", mentionFacet.Features[0].RichtextFacet_Mention.Did, tt.userDID)
180122 }
181123 }
182124 })
183125 }
184126}
185127186186-func TestBuildFacets_ByteOffsets(t *testing.T) {
128128+func TestBuildMentionFacet_ByteOffsets(t *testing.T) {
187129 // Test that byte offsets are correctly calculated
188188- text := "@alice.bsky.social just pushed myapp:latest"
130130+ text := "@alice.bsky.social pushed myapp:latest"
189131 userHandle := "alice.bsky.social"
190132 userDID := "did:plc:alice123"
191191- repoWithTag := "myapp:latest"
192192- appViewURL := "https://atcr.io/r/alice.bsky.social/myapp"
193133194194- facets := buildFacets(text, userHandle, userDID, repoWithTag, appViewURL)
134134+ facets := buildMentionFacet(text, userHandle, userDID)
195135196196- if len(facets) != 2 {
197197- t.Fatalf("expected 2 facets, got %d", len(facets))
136136+ if len(facets) != 1 {
137137+ t.Fatalf("expected 1 facet, got %d", len(facets))
198138 }
199139200140 // Check mention facet byte offsets
···215155 if extractedMention != mentionText {
216156 t.Errorf("extracted mention = %q, want %q", extractedMention, mentionText)
217157 }
218218-219219- // Check link facet byte offsets
220220- linkFacet := facets[1]
221221- linkStart := len("@alice.bsky.social just pushed ")
222222- expectedLinkStart := int64(linkStart)
223223- expectedLinkEnd := int64(linkStart + len(repoWithTag))
224224-225225- if linkFacet.Index.ByteStart != expectedLinkStart {
226226- t.Errorf("link ByteStart = %d, want %d", linkFacet.Index.ByteStart, expectedLinkStart)
227227- }
228228- if linkFacet.Index.ByteEnd != expectedLinkEnd {
229229- t.Errorf("link ByteEnd = %d, want %d", linkFacet.Index.ByteEnd, expectedLinkEnd)
230230- }
231231-232232- // Verify the link text extraction
233233- extractedLink := text[linkFacet.Index.ByteStart:linkFacet.Index.ByteEnd]
234234- if extractedLink != repoWithTag {
235235- t.Errorf("extracted link = %q, want %q", extractedLink, repoWithTag)
236236- }
237158}
238159239239-func TestBuildFacets_UTF8Handling(t *testing.T) {
160160+func TestBuildMentionFacet_UTF8Handling(t *testing.T) {
240161 // Test with Unicode characters to ensure byte offsets work correctly
241241- text := "@alice.bsky.social just pushed ๐myapp:latest"
162162+ text := "@alice.bsky.social pushed ๐myapp:latest"
242163 userHandle := "alice.bsky.social"
243164 userDID := "did:plc:alice123"
244244- repoWithTag := "๐myapp:latest" // Note: emoji is multi-byte
245245- appViewURL := "https://atcr.io/r/alice.bsky.social/myapp"
246165247247- facets := buildFacets(text, userHandle, userDID, repoWithTag, appViewURL)
166166+ facets := buildMentionFacet(text, userHandle, userDID)
248167249249- if len(facets) != 2 {
250250- t.Fatalf("expected 2 facets, got %d", len(facets))
168168+ if len(facets) != 1 {
169169+ t.Fatalf("expected 1 facet, got %d", len(facets))
251170 }
252171253172 // Verify that byte extraction works with UTF-8
···257176 if extractedMention != expectedMention {
258177 t.Errorf("extracted mention = %q, want %q", extractedMention, expectedMention)
259178 }
179179+}
260180261261- linkFacet := facets[1]
262262- extractedLink := text[linkFacet.Index.ByteStart:linkFacet.Index.ByteEnd]
263263- if extractedLink != repoWithTag {
264264- t.Errorf("extracted link = %q, want %q", extractedLink, repoWithTag)
181181+func TestSimplifiedPostFormat(t *testing.T) {
182182+ // Test the new simplified post format: "@user pushed repo:tag"
183183+ repository := "hsm-secrets-operator"
184184+ tag := "latest"
185185+ userHandle := "evan.jarrett.net"
186186+ userDID := "did:plc:pddp4xt5lgnv2qsegbzzs4xg"
187187+188188+ repoWithTag := repository + ":" + tag
189189+ text := "@" + userHandle + " pushed " + repoWithTag
190190+191191+ facets := buildMentionFacet(text, userHandle, userDID)
192192+193193+ // Should have 1 facet: mention only (link is provided by embed)
194194+ if len(facets) != 1 {
195195+ t.Fatalf("expected 1 facet, got %d", len(facets))
196196+ }
197197+198198+ // Verify the complete post structure
199199+ post := &bsky.FeedPost{
200200+ LexiconTypeID: atproto.BskyPostCollection,
201201+ Text: text,
202202+ Facets: facets,
203203+ Langs: []string{"en"},
265204 }
266266-}
267205268268-func TestBuildFacets_NoOverlap(t *testing.T) {
269269- // Ensure facets don't overlap
270270- text := "@alice.bsky.social just pushed myapp:latest"
271271- userHandle := "alice.bsky.social"
272272- userDID := "did:plc:alice123"
273273- repoWithTag := "myapp:latest"
274274- appViewURL := "https://atcr.io/r/alice.bsky.social/myapp"
206206+ if post.Text == "" {
207207+ t.Error("post text is empty")
208208+ }
275209276276- facets := buildFacets(text, userHandle, userDID, repoWithTag, appViewURL)
210210+ if len(post.Facets) != 1 {
211211+ t.Errorf("post has %d facets, want 1", len(post.Facets))
212212+ }
277213278278- if len(facets) != 2 {
279279- t.Fatalf("expected 2 facets, got %d", len(facets))
214214+ // Verify text contains expected components
215215+ expectedTexts := []string{
216216+ "@" + userHandle,
217217+ repoWithTag,
280218 }
281219282282- // Facets should not overlap
283283- facet1 := facets[0]
284284- facet2 := facets[1]
220220+ for _, expected := range expectedTexts {
221221+ if !strings.Contains(text, expected) {
222222+ t.Errorf("post text missing expected component: %q", expected)
223223+ }
224224+ }
285225286286- if facet1.Index.ByteEnd > facet2.Index.ByteStart {
287287- t.Errorf("facets overlap: facet1 ends at %d, facet2 starts at %d",
288288- facet1.Index.ByteEnd, facet2.Index.ByteStart)
226226+ // Verify post does NOT contain digest or size (now in embed description)
227227+ if strings.Contains(text, "Digest:") {
228228+ t.Error("simplified post should not contain Digest:")
229229+ }
230230+ if strings.Contains(text, "Size:") {
231231+ t.Error("simplified post should not contain Size:")
289232 }
290233}
291234292292-func TestBuildFacets_RealWorldExample(t *testing.T) {
293293- // Test with the actual example from the requirements
294294- repository := "hsm-secrets-operator"
235235+func TestSimplifiedPostFormat_MultiArch(t *testing.T) {
236236+ // Test the new simplified post format for multi-arch images
237237+ repository := "myapp"
295238 tag := "latest"
296296- userHandle := "evan.jarrett.net"
297297- userDID := "did:plc:pddp4xt5lgnv2qsegbzzs4xg"
298298- digest := "sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f"
299299- totalSize := int64(12800000) // ~12.2 MB
239239+ userHandle := "alice.bsky.social"
240240+ userDID := "did:plc:alice123"
300241301242 repoWithTag := repository + ":" + tag
302302- digestShort := formatDigest(digest)
303303- sizeStr := formatSize(totalSize)
243243+ text := "@" + userHandle + " pushed " + repoWithTag
304244305305- text := "@" + userHandle + " just pushed " + repoWithTag + "\nDigest: " + digestShort + " Size: " + sizeStr
306306- appViewURL := "https://atcr.io/r/" + userHandle + "/" + repository
245245+ facets := buildMentionFacet(text, userHandle, userDID)
307246308308- facets := buildFacets(text, userHandle, userDID, repoWithTag, appViewURL)
309309-310310- // Should have 2 facets: mention and link
311311- if len(facets) != 2 {
312312- t.Fatalf("expected 2 facets, got %d", len(facets))
247247+ // Should have 1 facet: mention only
248248+ if len(facets) != 1 {
249249+ t.Fatalf("expected 1 facet, got %d", len(facets))
313250 }
314251315252 // Verify the complete post structure
316253 post := &bsky.FeedPost{
317317- LexiconTypeID: "app.bsky.feed.post",
254254+ LexiconTypeID: atproto.BskyPostCollection,
318255 Text: text,
319256 Facets: facets,
257257+ Langs: []string{"en"},
320258 }
321259322260 if post.Text == "" {
323261 t.Error("post text is empty")
324262 }
325263326326- if len(post.Facets) != 2 {
327327- t.Errorf("post has %d facets, want 2", len(post.Facets))
328328- }
329329-330264 // Verify text contains expected components
331265 expectedTexts := []string{
332266 "@" + userHandle,
333267 repoWithTag,
334334- digestShort,
335335- sizeStr,
336268 }
337269338270 for _, expected := range expectedTexts {
339339- if !strings.Contains(text, expected) {
271271+ if !strings.Contains(post.Text, expected) {
340272 t.Errorf("post text missing expected component: %q", expected)
341273 }
274274+ }
275275+276276+ // Verify Platforms is NOT in text (now in embed description)
277277+ if strings.Contains(post.Text, "Platforms:") {
278278+ t.Error("simplified post should not contain Platforms:")
279279+ }
280280+}
281281+282282+func TestEmbedDescription(t *testing.T) {
283283+ // Test the dynamic description generation for embeds
284284+ tests := []struct {
285285+ name string
286286+ platforms []string
287287+ totalSize int64
288288+ wantContain string
289289+ }{
290290+ {
291291+ name: "single-arch with size",
292292+ platforms: []string{},
293293+ totalSize: 12800000, // ~12.2 MB
294294+ wantContain: "Pushed 12.2 MB to ATCR",
295295+ },
296296+ {
297297+ name: "multi-arch with platforms",
298298+ platforms: []string{"linux/amd64", "linux/arm64"},
299299+ totalSize: 0,
300300+ wantContain: "Multi-arch: linux/amd64, linux/arm64",
301301+ },
302302+ {
303303+ name: "single platform",
304304+ platforms: []string{"linux/amd64"},
305305+ totalSize: 0,
306306+ wantContain: "Multi-arch: linux/amd64",
307307+ },
308308+ }
309309+310310+ for _, tt := range tests {
311311+ t.Run(tt.name, func(t *testing.T) {
312312+ var description string
313313+ if len(tt.platforms) > 0 {
314314+ description = "Multi-arch: " + strings.Join(tt.platforms, ", ")
315315+ } else {
316316+ description = "Pushed " + formatSize(tt.totalSize) + " to ATCR"
317317+ }
318318+319319+ if !strings.Contains(description, tt.wantContain) {
320320+ t.Errorf("description = %q, want to contain %q", description, tt.wantContain)
321321+ }
322322+ })
342323 }
343324}
+3-7
pkg/hold/pds/server.go
···1919 "github.com/ipfs/go-cid"
2020)
21212222-// init registers our custom ATProto types with indigo's lexutil type registry
2323-// This allows repomgr.GetRecord to automatically unmarshal our types
2222+// init registers the TangledProfileRecord type with indigo's lexutil type registry.
2323+// Note: HoldCaptain, HoldCrew, and HoldLayer are registered in pkg/atproto/register.go (generated).
2424+// TangledProfileRecord is external (sh.tangled.actor.profile) so we register it here.
2425func init() {
2525- // Register captain, crew, tangled profile, and layer record types
2626- // These must match the $type field in the records
2727- lexutil.RegisterType(atproto.CaptainCollection, &atproto.CaptainRecord{})
2828- lexutil.RegisterType(atproto.CrewCollection, &atproto.CrewRecord{})
2929- lexutil.RegisterType(atproto.LayerCollection, &atproto.LayerRecord{})
3026 lexutil.RegisterType(atproto.TangledProfileCollection, &atproto.TangledProfileRecord{})
3127}
3228
+6-6
pkg/hold/pds/server_test.go
···150150 if captain.AllowAllCrew != allowAllCrew {
151151 t.Errorf("Expected allowAllCrew=%v, got %v", allowAllCrew, captain.AllowAllCrew)
152152 }
153153- if captain.Type != atproto.CaptainCollection {
154154- t.Errorf("Expected type %s, got %s", atproto.CaptainCollection, captain.Type)
153153+ if captain.LexiconTypeID != atproto.CaptainCollection {
154154+ t.Errorf("Expected type %s, got %s", atproto.CaptainCollection, captain.LexiconTypeID)
155155 }
156156 if captain.DeployedAt == "" {
157157 t.Error("Expected deployedAt to be set")
···317317 if captain == nil {
318318 t.Fatal("Expected non-nil captain record")
319319 }
320320- if captain.Type != atproto.CaptainCollection {
321321- t.Errorf("Expected captain type %s, got %s", atproto.CaptainCollection, captain.Type)
320320+ if captain.LexiconTypeID != atproto.CaptainCollection {
321321+ t.Errorf("Expected captain type %s, got %s", atproto.CaptainCollection, captain.LexiconTypeID)
322322 }
323323324324 // Do the same for crew record
···331331 }
332332333333 crew := crewMembers[0].Record
334334- if crew.Type != atproto.CrewCollection {
335335- t.Errorf("Expected crew type %s, got %s", atproto.CrewCollection, crew.Type)
334334+ if crew.LexiconTypeID != atproto.CrewCollection {
335335+ t.Errorf("Expected crew type %s, got %s", atproto.CrewCollection, crew.LexiconTypeID)
336336 }
337337}
338338
+4-8
pkg/hold/pds/status.go
···66 "log/slog"
77 "time"
8899+ "atcr.io/pkg/atproto"
910 bsky "github.com/bluesky-social/indigo/api/bsky"
1010-)
1111-1212-const (
1313- // StatusPostCollection is the collection name for Bluesky posts
1414- StatusPostCollection = "app.bsky.feed.post"
1511)
16121713// SetStatus creates a new status post on Bluesky
···4036 // Create post struct
4137 now := time.Now()
4238 post := &bsky.FeedPost{
4343- LexiconTypeID: "app.bsky.feed.post",
3939+ LexiconTypeID: atproto.BskyPostCollection,
4440 Text: text,
4541 CreatedAt: now.Format(time.RFC3339),
4642 }
47434844 // Use repomgr.CreateRecord to create the post with auto-generated TID
4945 // CreateRecord automatically generates a unique TID using the repo's clock
5050- rkey, recordCID, err := p.repomgr.CreateRecord(ctx, p.uid, StatusPostCollection, post)
4646+ rkey, recordCID, err := p.repomgr.CreateRecord(ctx, p.uid, atproto.BskyPostCollection, post)
5147 if err != nil {
5248 return fmt.Errorf("failed to create status post: %w", err)
5349 }
54505551 slog.Info("Created status post",
5656- "collection", StatusPostCollection,
5252+ "collection", atproto.BskyPostCollection,
5753 "rkey", rkey,
5854 "cid", recordCID.String(),
5955 "text", text)
+3-10
pkg/hold/pds/status_test.go
···6161 listPosts := func() ([]map[string]any, error) {
6262 req := makeXRPCGetRequest(atproto.RepoListRecords, map[string]string{
6363 "repo": did,
6464- "collection": StatusPostCollection,
6464+ "collection": atproto.BskyPostCollection,
6565 "limit": "100",
6666 "reverse": "true", // Most recent first
6767 })
···134134 }
135135 // URI format: at://did:web:test.example.com/app.bsky.feed.post/3m3c4...
136136 // We just check that it contains the collection
137137- if !contains(uri, StatusPostCollection) {
138138- t.Errorf("Expected URI to contain collection %s, got %s", StatusPostCollection, uri)
137137+ if !contains(uri, atproto.BskyPostCollection) {
138138+ t.Errorf("Expected URI to contain collection %s, got %s", atproto.BskyPostCollection, uri)
139139 }
140140 })
141141···224224 t.Errorf("Expected text '๐ด Current status: offline', got '%s'", text)
225225 }
226226 })
227227-}
228228-229229-func TestStatusPostCollection(t *testing.T) {
230230- // Verify constant
231231- if StatusPostCollection != "app.bsky.feed.post" {
232232- t.Errorf("Expected StatusPostCollection 'app.bsky.feed.post', got '%s'", StatusPostCollection)
233233- }
234227}
235228236229// Helper function to check if a string contains a substring