A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix realip

+876
+3
cmd/hold/main.go
··· 97 97 // Setup HTTP routes with chi router 98 98 r := chi.NewRouter() 99 99 100 + // Add RealIP middleware to extract real client IP from proxy headers 101 + r.Use(middleware.RealIP) 102 + 100 103 // Add logging middleware to log all HTTP requests 101 104 r.Use(middleware.Logger) 102 105
+873
docs/BLUESKY_MANIFEST_POSTS.md
··· 1 + # Bluesky Manifest Posts 2 + 3 + ## Overview 4 + 5 + This document describes the feature for posting to Bluesky when OCI manifests are uploaded to ATCR holds. When a user pushes an image to the registry, the hold's embedded PDS will: 6 + 7 + 1. Create `io.atcr.hold.layer` records for structured metadata tracking 8 + 2. Post to Bluesky announcing the push (similar to the "what's new" feed on the AppView web UI) 9 + 10 + ## Architecture 11 + 12 + ### High-Level Flow 13 + 14 + ``` 15 + User pushes image 16 + โ†“ 17 + AppView receives manifest PUT request 18 + โ†“ 19 + AppView stores manifest in user's PDS 20 + โ†“ 21 + AppView notifies hold via XRPC 22 + โ†“ 23 + Hold creates layer records in embedded PDS 24 + โ†“ 25 + Hold creates Bluesky post 26 + โ†“ 27 + Post appears in Bluesky feed 28 + ``` 29 + 30 + ### Component Interactions 31 + 32 + **AppView** (`pkg/appview/storage/manifest_store.go`): 33 + - After successfully uploading manifest to user's PDS 34 + - Extracts manifest metadata (repository, tag, user info, layers) 35 + - Calls hold's `io.atcr.hold.notifyManifest` XRPC endpoint 36 + - Uses service token from user's PDS for authentication 37 + - Gracefully handles notification failures (doesn't fail manifest upload) 38 + 39 + **Hold** (`pkg/hold/oci/xrpc.go`): 40 + - Receives manifest notification via new XRPC endpoint 41 + - Validates service token and extracts user DID 42 + - Creates layer records for each blob reference in manifest 43 + - Creates Bluesky post announcing the push 44 + - Returns success/failure status 45 + 46 + **Hold's Embedded PDS** (`pkg/hold/pds/`): 47 + - Stores layer records in `io.atcr.hold.layer` collection 48 + - Stores Bluesky posts in `app.bsky.feed.post` collection 49 + - Both are ATProto records with auto-generated TID rkeys 50 + - Queryable via standard ATProto sync endpoints 51 + 52 + ## Implementation Details 53 + 54 + ### 1. Layer Record Schema 55 + 56 + **File**: `pkg/atproto/lexicon.go` 57 + 58 + **Collection**: `io.atcr.hold.layer` 59 + 60 + **Purpose**: Structured metadata about container layers stored in the hold 61 + 62 + **Schema**: 63 + ```go 64 + type LayerRecord struct { 65 + // Type identifier (always "io.atcr.hold.layer") 66 + Type string `json:"$type" cborgen:"$type"` 67 + 68 + // Digest of the layer (e.g., "sha256:abc123...") 69 + Digest string `json:"digest" cborgen:"digest"` 70 + 71 + // Size in bytes 72 + Size int64 `json:"size" cborgen:"size"` 73 + 74 + // MediaType of the layer 75 + MediaType string `json:"mediaType" cborgen:"mediaType"` 76 + 77 + // Repository this layer belongs to (e.g., "alice/myapp") 78 + Repository string `json:"repository" cborgen:"repository"` 79 + 80 + // User DID who uploaded this layer 81 + UserDID string `json:"userDid" cborgen:"userDid"` 82 + 83 + // User handle (for display purposes) 84 + UserHandle string `json:"userHandle,omitempty" cborgen:"userHandle,omitempty"` 85 + 86 + // Timestamp 87 + CreatedAt time.Time `json:"createdAt" cborgen:"createdAt"` 88 + } 89 + ``` 90 + 91 + **Constructor**: 92 + ```go 93 + func NewLayerRecord(digest string, size int64, mediaType, repository, userDID, userHandle string) *LayerRecord { 94 + return &LayerRecord{ 95 + Type: LayerCollection, 96 + Digest: digest, 97 + Size: size, 98 + MediaType: mediaType, 99 + Repository: repository, 100 + UserDID: userDID, 101 + UserHandle: userHandle, 102 + CreatedAt: time.Now(), 103 + } 104 + } 105 + ``` 106 + 107 + **Why CBOR tags**: The hold's embedded PDS uses CBOR encoding for efficient storage in the SQLite-backed carstore. All records stored in the hold must have `cborgen:` tags. 108 + 109 + ### 2. XRPC Manifest Notification Endpoint 110 + 111 + **File**: `pkg/hold/oci/xrpc.go` 112 + 113 + **Endpoint**: `POST /xrpc/io.atcr.hold.notifyManifest` 114 + 115 + **Authentication**: Service token from user's PDS (same pattern as blob upload endpoints) 116 + 117 + **Request Schema**: 118 + ```go 119 + type NotifyManifestRequest struct { 120 + // Repository name (e.g., "alice/myapp") 121 + Repository string `json:"repository"` 122 + 123 + // Tag (e.g., "latest", "v1.0.0") 124 + Tag string `json:"tag"` 125 + 126 + // User DID (e.g., "did:plc:abc123") 127 + UserDID string `json:"userDid"` 128 + 129 + // User handle (e.g., "alice.bsky.social") 130 + UserHandle string `json:"userHandle"` 131 + 132 + // Manifest content (parsed from uploaded manifest) 133 + Manifest struct { 134 + MediaType string `json:"mediaType"` 135 + Config struct { 136 + Digest string `json:"digest"` 137 + Size int64 `json:"size"` 138 + } `json:"config"` 139 + Layers []struct { 140 + Digest string `json:"digest"` 141 + Size int64 `json:"size"` 142 + MediaType string `json:"mediaType"` 143 + } `json:"layers"` 144 + } `json:"manifest"` 145 + } 146 + ``` 147 + 148 + **Response Schema**: 149 + ```go 150 + type NotifyManifestResponse struct { 151 + Success bool `json:"success"` 152 + LayersCreated int `json:"layersCreated"` 153 + PostCreated bool `json:"postCreated"` 154 + PostURI string `json:"postUri,omitempty"` // ATProto URI if post created 155 + Error string `json:"error,omitempty"` 156 + } 157 + ``` 158 + 159 + **Handler Implementation**: 160 + ```go 161 + func (h *XRPCHandler) HandleNotifyManifest(w http.ResponseWriter, r *http.Request) { 162 + ctx := r.Context() 163 + 164 + // 1. Validate service token (reuse existing auth middleware pattern) 165 + userDID, err := h.validateServiceToken(ctx, r) 166 + if err != nil { 167 + writeXRPCError(w, "InvalidToken", err.Error()) 168 + return 169 + } 170 + 171 + // 2. Parse request 172 + var req NotifyManifestRequest 173 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 174 + writeXRPCError(w, "InvalidRequest", err.Error()) 175 + return 176 + } 177 + 178 + // 3. Verify user DID matches token 179 + if req.UserDID != userDID { 180 + writeXRPCError(w, "Unauthorized", "user DID mismatch") 181 + return 182 + } 183 + 184 + // 4. Create layer records for each blob 185 + layersCreated := 0 186 + for _, layer := range req.Manifest.Layers { 187 + record := atproto.NewLayerRecord( 188 + layer.Digest, 189 + layer.Size, 190 + layer.MediaType, 191 + req.Repository, 192 + req.UserDID, 193 + req.UserHandle, 194 + ) 195 + 196 + _, _, err := h.pds.CreateLayerRecord(ctx, record) 197 + if err != nil { 198 + log.Printf("Failed to create layer record: %v", err) 199 + // Continue creating other records 200 + } else { 201 + layersCreated++ 202 + } 203 + } 204 + 205 + // 5. Create Bluesky post 206 + postURI, err := h.pds.CreateManifestPost(ctx, req.Repository, req.Tag, req.UserHandle) 207 + 208 + // 6. Return response 209 + resp := NotifyManifestResponse{ 210 + Success: layersCreated > 0 || err == nil, 211 + LayersCreated: layersCreated, 212 + PostCreated: err == nil, 213 + PostURI: postURI, 214 + } 215 + 216 + if err != nil && layersCreated == 0 { 217 + resp.Error = err.Error() 218 + } 219 + 220 + w.Header().Set("Content-Type", "application/json") 221 + json.NewEncoder(w).Encode(resp) 222 + } 223 + ``` 224 + 225 + ### 3. Hold PDS Layer Record Methods 226 + 227 + **File**: `pkg/hold/pds/layer.go` (new file) 228 + 229 + **Methods**: 230 + 231 + ```go 232 + // CreateLayerRecord creates a new layer record in the hold's PDS 233 + func (p *HoldPDS) CreateLayerRecord(ctx context.Context, record *atproto.LayerRecord) (string, string, error) { 234 + // Validate record 235 + if record.Type != atproto.LayerCollection { 236 + return "", "", fmt.Errorf("invalid record type: %s", record.Type) 237 + } 238 + 239 + if record.Digest == "" { 240 + return "", "", fmt.Errorf("digest is required") 241 + } 242 + 243 + // Create record with auto-generated TID rkey 244 + rkey, recordCID, err := p.repomgr.CreateRecord( 245 + ctx, 246 + p.uid, 247 + atproto.LayerCollection, 248 + record, 249 + ) 250 + 251 + if err != nil { 252 + return "", "", fmt.Errorf("failed to create layer record: %w", err) 253 + } 254 + 255 + log.Printf("Created layer record at %s/%s (digest: %s, size: %d)", 256 + atproto.LayerCollection, rkey, record.Digest, record.Size) 257 + 258 + return rkey, recordCID.String(), nil 259 + } 260 + 261 + // ListLayerRecords lists layer records with optional filtering 262 + func (p *HoldPDS) ListLayerRecords(ctx context.Context, limit int, cursor string) ([]*atproto.LayerRecord, string, error) { 263 + // Implementation using repomgr.GetRecord for pagination 264 + // This would query the carstore and unmarshal layer records 265 + // Return records + next cursor for pagination 266 + } 267 + 268 + // GetLayerRecord retrieves a specific layer record by rkey 269 + func (p *HoldPDS) GetLayerRecord(ctx context.Context, rkey string) (*atproto.LayerRecord, error) { 270 + // Implementation using repomgr.GetRecord 271 + } 272 + ``` 273 + 274 + ### 4. Bluesky Post Creation 275 + 276 + **File**: `pkg/hold/pds/manifest_post.go` (new file) 277 + 278 + **Pattern**: Reuse existing `status.go` pattern 279 + 280 + ```go 281 + // CreateManifestPost creates a Bluesky post announcing a manifest upload 282 + func (p *HoldPDS) CreateManifestPost(ctx context.Context, repository, tag, userHandle string) (string, error) { 283 + now := time.Now() 284 + 285 + // Format post text (similar to "what's new" feed) 286 + text := formatManifestPostText(repository, tag, userHandle) 287 + 288 + // Create post struct 289 + post := &bsky.FeedPost{ 290 + LexiconTypeID: "app.bsky.feed.post", 291 + Text: text, 292 + CreatedAt: now.Format(time.RFC3339), 293 + // Optional: Add embed with link to AppView 294 + // Embed: &bsky.FeedPost_Embed{...} 295 + } 296 + 297 + // Create record with auto-generated TID 298 + rkey, recordCID, err := p.repomgr.CreateRecord( 299 + ctx, 300 + p.uid, 301 + "app.bsky.feed.post", 302 + post, 303 + ) 304 + 305 + if err != nil { 306 + return "", fmt.Errorf("failed to create manifest post: %w", err) 307 + } 308 + 309 + // Build ATProto URI for the post 310 + postURI := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", p.did, rkey) 311 + 312 + log.Printf("Created manifest post: %s (cid: %s)", postURI, recordCID) 313 + 314 + return postURI, nil 315 + } 316 + 317 + // formatManifestPostText generates the post text 318 + func formatManifestPostText(repository, tag, userHandle string) string { 319 + // Example formats: 320 + // "@alice.bsky.social pushed alice/myapp:latest to ATCR" 321 + // "New image pushed: alice/myapp:v1.0.0 by @alice.bsky.social" 322 + // "๐Ÿ“ฆ alice/myapp:latest pushed by @alice.bsky.social" 323 + 324 + return fmt.Sprintf("๐Ÿ“ฆ %s:%s pushed by @%s", repository, tag, userHandle) 325 + } 326 + ``` 327 + 328 + **Advanced Post Options**: 329 + 330 + ```go 331 + // Example with embedded link to AppView 332 + post := &bsky.FeedPost{ 333 + LexiconTypeID: "app.bsky.feed.post", 334 + Text: text, 335 + CreatedAt: now.Format(time.RFC3339), 336 + Embed: &bsky.FeedPost_Embed{ 337 + FeedPost_External: &bsky.EmbedExternal{ 338 + External: &bsky.EmbedExternal_External{ 339 + Uri: fmt.Sprintf("https://atcr.io/%s", repository), 340 + Title: fmt.Sprintf("%s:%s", repository, tag), 341 + Description: "View on ATCR", 342 + }, 343 + }, 344 + }, 345 + } 346 + 347 + // Example with facets (mentions) 348 + // This would require parsing the text and creating facet structs 349 + // for @mentions to be clickable in Bluesky 350 + ``` 351 + 352 + ### 5. AppView Integration 353 + 354 + **File**: `pkg/appview/storage/manifest_store.go` 355 + 356 + **Integration Point**: After `client.PutRecord()` succeeds (around line 130-140) 357 + 358 + ```go 359 + // Existing code: 360 + recordURI, recordCID, err := ms.client.PutRecord(ctx, atproto.ManifestCollection, rkey, manifestRecord) 361 + if err != nil { 362 + return "", fmt.Errorf("failed to store manifest in PDS: %w", err) 363 + } 364 + 365 + // NEW: Notify hold about manifest upload 366 + if err := ms.notifyHoldAboutManifest(ctx, desc, manifestRecord, tag); err != nil { 367 + // Log error but don't fail the manifest upload 368 + log.Printf("Failed to notify hold about manifest: %v", err) 369 + } 370 + 371 + return desc.Digest.String(), nil 372 + ``` 373 + 374 + **Implementation**: 375 + 376 + ```go 377 + // notifyHoldAboutManifest sends manifest metadata to the hold 378 + func (ms *ManifestStore) notifyHoldAboutManifest( 379 + ctx context.Context, 380 + desc distribution.Descriptor, 381 + manifestRecord *atproto.ManifestRecord, 382 + tag string, 383 + ) error { 384 + // 1. Get registry context 385 + regCtx, err := storage.GetRegistryContext(ctx) 386 + if err != nil { 387 + return fmt.Errorf("failed to get registry context: %w", err) 388 + } 389 + 390 + // 2. Resolve hold DID to endpoint 391 + holdEndpoint, err := ms.resolver.ResolveDIDToHTTPEndpoint(ctx, manifestRecord.HoldDID) 392 + if err != nil { 393 + return fmt.Errorf("failed to resolve hold DID: %w", err) 394 + } 395 + 396 + // 3. Get service token from user's PDS 397 + serviceToken, err := regCtx.Refresher.GetServiceToken(ctx, regCtx.DID, manifestRecord.HoldDID) 398 + if err != nil { 399 + return fmt.Errorf("failed to get service token: %w", err) 400 + } 401 + 402 + // 4. Parse manifest to extract layer info 403 + var parsedManifest struct { 404 + MediaType string `json:"mediaType"` 405 + Config distribution.Descriptor `json:"config"` 406 + Layers []distribution.Descriptor `json:"layers"` 407 + } 408 + 409 + if err := json.Unmarshal(manifestRecord.ManifestBlob.Data, &parsedManifest); err != nil { 410 + return fmt.Errorf("failed to parse manifest: %w", err) 411 + } 412 + 413 + // 5. Build notification request 414 + notifyReq := map[string]interface{}{ 415 + "repository": ms.repository, 416 + "tag": tag, 417 + "userDid": regCtx.DID, 418 + "userHandle": regCtx.Handle, // Need to add this to RegistryContext 419 + "manifest": map[string]interface{}{ 420 + "mediaType": parsedManifest.MediaType, 421 + "config": map[string]interface{}{ 422 + "digest": parsedManifest.Config.Digest.String(), 423 + "size": parsedManifest.Config.Size, 424 + }, 425 + "layers": func() []map[string]interface{} { 426 + layers := make([]map[string]interface{}, len(parsedManifest.Layers)) 427 + for i, layer := range parsedManifest.Layers { 428 + layers[i] = map[string]interface{}{ 429 + "digest": layer.Digest.String(), 430 + "size": layer.Size, 431 + "mediaType": layer.MediaType, 432 + } 433 + } 434 + return layers 435 + }(), 436 + }, 437 + } 438 + 439 + // 6. Call hold's XRPC endpoint 440 + reqBody, _ := json.Marshal(notifyReq) 441 + req, err := http.NewRequestWithContext( 442 + ctx, 443 + "POST", 444 + holdEndpoint+"/xrpc/io.atcr.hold.notifyManifest", 445 + bytes.NewReader(reqBody), 446 + ) 447 + if err != nil { 448 + return err 449 + } 450 + 451 + req.Header.Set("Content-Type", "application/json") 452 + req.Header.Set("Authorization", "Bearer "+serviceToken) 453 + 454 + resp, err := http.DefaultClient.Do(req) 455 + if err != nil { 456 + return err 457 + } 458 + defer resp.Body.Close() 459 + 460 + if resp.StatusCode != http.StatusOK { 461 + body, _ := io.ReadAll(resp.Body) 462 + return fmt.Errorf("hold notification failed: %s (status: %d)", body, resp.StatusCode) 463 + } 464 + 465 + // 7. Parse response (optional logging) 466 + var notifyResp map[string]interface{} 467 + if err := json.NewDecoder(resp.Body).Decode(&notifyResp); err == nil { 468 + log.Printf("Hold notification successful: %+v", notifyResp) 469 + } 470 + 471 + return nil 472 + } 473 + ``` 474 + 475 + ### 6. Record Type Registration 476 + 477 + **File**: `pkg/hold/pds/server.go` 478 + 479 + **In `init()` function** (around line 30): 480 + 481 + ```go 482 + func init() { 483 + // Existing registrations 484 + lexutil.RegisterType(atproto.CaptainCollection, &atproto.CaptainRecord{}) 485 + lexutil.RegisterType(atproto.CrewCollection, &atproto.CrewRecord{}) 486 + lexutil.RegisterType(atproto.TangledProfileCollection, &atproto.TangledProfileRecord{}) 487 + 488 + // NEW: Register layer record type 489 + lexutil.RegisterType(atproto.LayerCollection, &atproto.LayerRecord{}) 490 + } 491 + ``` 492 + 493 + **Why needed**: ATProto's CBOR unmarshaling requires type registration to automatically deserialize records when reading from the carstore. 494 + 495 + ## Testing Strategy 496 + 497 + ### Unit Tests 498 + 499 + **Test Layer Record Creation** (`pkg/hold/pds/layer_test.go`): 500 + ```go 501 + func TestCreateLayerRecord(t *testing.T) { 502 + pds := setupTestPDS(t) 503 + ctx := context.Background() 504 + 505 + record := atproto.NewLayerRecord( 506 + "sha256:abc123", 507 + 1024, 508 + "application/vnd.docker.image.rootfs.diff.tar.gzip", 509 + "alice/myapp", 510 + "did:plc:alice123", 511 + "alice.bsky.social", 512 + ) 513 + 514 + rkey, cid, err := pds.CreateLayerRecord(ctx, record) 515 + assert.NoError(t, err) 516 + assert.NotEmpty(t, rkey) 517 + assert.NotEmpty(t, cid) 518 + 519 + // Verify record was stored 520 + retrieved, err := pds.GetLayerRecord(ctx, rkey) 521 + assert.NoError(t, err) 522 + assert.Equal(t, record.Digest, retrieved.Digest) 523 + } 524 + ``` 525 + 526 + **Test Manifest Post Creation** (`pkg/hold/pds/manifest_post_test.go`): 527 + ```go 528 + func TestCreateManifestPost(t *testing.T) { 529 + pds := setupTestPDS(t) 530 + ctx := context.Background() 531 + 532 + postURI, err := pds.CreateManifestPost(ctx, "alice/myapp", "latest", "alice.bsky.social") 533 + assert.NoError(t, err) 534 + assert.Contains(t, postURI, "app.bsky.feed.post") 535 + 536 + // Parse URI and verify post exists 537 + // at://did:web:hold01.atcr.io/app.bsky.feed.post/{rkey} 538 + } 539 + ``` 540 + 541 + **Test XRPC Endpoint** (`pkg/hold/oci/xrpc_test.go`): 542 + ```go 543 + func TestHandleNotifyManifest(t *testing.T) { 544 + handler := setupTestHandler(t) 545 + 546 + req := NotifyManifestRequest{ 547 + Repository: "alice/myapp", 548 + Tag: "latest", 549 + UserDID: "did:plc:alice123", 550 + UserHandle: "alice.bsky.social", 551 + Manifest: /* ... */, 552 + } 553 + 554 + // Make HTTP request with service token 555 + resp := makeRequest(t, handler, req, validServiceToken) 556 + 557 + assert.Equal(t, http.StatusOK, resp.StatusCode) 558 + 559 + var result NotifyManifestResponse 560 + json.NewDecoder(resp.Body).Decode(&result) 561 + 562 + assert.True(t, result.Success) 563 + assert.Equal(t, 3, result.LayersCreated) // if manifest has 3 layers 564 + assert.True(t, result.PostCreated) 565 + } 566 + ``` 567 + 568 + ### Integration Tests 569 + 570 + **End-to-End Test**: 571 + 1. Push a test image to ATCR 572 + 2. Verify manifest is stored in user's PDS 573 + 3. Verify layer records are created in hold's PDS 574 + 4. Verify Bluesky post is created in hold's PDS 575 + 5. Query ATProto endpoints to retrieve records 576 + 577 + ## Error Handling 578 + 579 + ### AppView Side 580 + 581 + **Notification failures should NOT break manifest uploads**: 582 + - If hold is unreachable: Log error, continue 583 + - If service token fails: Log error, continue 584 + - If hold returns error: Log error, continue 585 + 586 + **Rationale**: Bluesky posts are a "nice to have" feature, not critical infrastructure. Image pushes must succeed even if social features fail. 587 + 588 + ### Hold Side 589 + 590 + **Partial failures are acceptable**: 591 + - If some layer records fail: Create what we can, return partial success 592 + - If Bluesky post fails but layers succeed: Return success with `postCreated: false` 593 + - If all operations fail: Return error response 594 + 595 + **Logging**: 596 + - Log all errors for debugging 597 + - Include user DID, repository, and error details 598 + - Use structured logging for easy querying 599 + 600 + ## Configuration 601 + 602 + ### Environment Variables 603 + 604 + **Hold Service** (`.env.hold.example`): 605 + ```bash 606 + # Enable/disable Bluesky posting 607 + HOLD_BLUESKY_POSTS_ENABLED=true 608 + 609 + # Enable/disable layer record creation 610 + HOLD_LAYER_RECORDS_ENABLED=true 611 + ``` 612 + 613 + **AppView** (`.env.appview.example`): 614 + ```bash 615 + # Enable/disable manifest notifications to holds 616 + ATCR_NOTIFY_HOLDS_ENABLED=true 617 + ``` 618 + 619 + ### Feature Flags 620 + 621 + Consider making this feature opt-in initially: 622 + - Add flag to captain record: `enableSocialPosts bool` 623 + - Check flag before creating posts 624 + - Allow hold owners to disable social features 625 + 626 + ## Performance Considerations 627 + 628 + ### Database Impact 629 + 630 + **Layer records**: Each manifest upload creates N records (where N = number of layers) 631 + - Typical image: 5-10 layers 632 + - Large image: 50+ layers 633 + - Storage: ~500 bytes per record (CBOR compressed) 634 + 635 + **Bluesky posts**: One post per manifest 636 + - Storage: ~200 bytes per post 637 + - Indexed by creation time for feed queries 638 + 639 + **Carstore growth**: Estimate ~5KB per manifest upload (records + post) 640 + 641 + ### Network Impact 642 + 643 + **AppView โ†’ Hold notification**: 644 + - One HTTP POST per manifest upload 645 + - Payload size: ~2-10KB (depends on layer count) 646 + - Should complete in <100ms on local network 647 + 648 + **Service token requests**: 649 + - Tokens cached for 50 seconds 650 + - Minimal overhead if pushing multiple manifests quickly 651 + 652 + ### Optimization Opportunities 653 + 654 + 1. **Batch layer record creation**: Use `BatchWrite` for multiple records 655 + 2. **Async processing**: Queue notifications and process in background 656 + 3. **Rate limiting**: Limit posts per user/hold to prevent spam 657 + 4. **Deduplication**: Skip layer records for already-seen digests 658 + 659 + ## Future Enhancements 660 + 661 + ### Phase 2: Enhanced Posts 662 + 663 + **Rich embeds**: 664 + - Link preview to AppView repository page 665 + - Thumbnail image from first layer 666 + - Metadata badges (image size, layer count, tags) 667 + 668 + **Mentions**: 669 + - Parse user handle and create Bluesky facets for @mentions 670 + - Enable clickable mentions in posts 671 + 672 + **Tags/hashtags**: 673 + - Add `#container`, `#docker`, repository tags 674 + - Improve discoverability in Bluesky 675 + 676 + ### Phase 3: Feed Customization 677 + 678 + **Hold-specific feeds**: 679 + - Query layer records by repository 680 + - Filter by user DID 681 + - Time-based queries 682 + 683 + **ATProto feed generator**: 684 + - Implement `app.bsky.feed.getFeedSkeleton` XRPC endpoint 685 + - Publish hold's feed to Bluesky 686 + - Users can subscribe to hold activity feeds 687 + 688 + ### Phase 4: Analytics 689 + 690 + **Track metrics**: 691 + - Posts per day/week/month 692 + - Most active users 693 + - Most popular repositories 694 + - Storage growth over time 695 + 696 + **Dashboards**: 697 + - Visualize activity on AppView UI 698 + - Show trending images 699 + - Leaderboards for most pushed repositories 700 + 701 + ## Security Considerations 702 + 703 + ### Authentication 704 + 705 + **Service tokens**: 706 + - Validate tokens against user's PDS 707 + - Verify DID matches in token claims 708 + - Check token expiration (60s from PDS) 709 + 710 + **Authorization**: 711 + - Only authenticated users can trigger posts 712 + - Posts created under hold's DID (not user's DID) 713 + - User information is metadata in post text 714 + 715 + ### Privacy 716 + 717 + **User handles**: 718 + - Posts include user handle (`@alice.bsky.social`) 719 + - Consider opt-out mechanism for privacy-conscious users 720 + 721 + **Repository names**: 722 + - Public information (already visible in AppView) 723 + - Consider private repository flags in future 724 + 725 + ### Rate Limiting 726 + 727 + **Prevent spam**: 728 + - Limit posts per user per hour 729 + - Detect rapid-fire pushes (CI/CD) 730 + - Consider aggregating multiple pushes into single post 731 + 732 + **Resource protection**: 733 + - Limit layer record creation to prevent storage exhaustion 734 + - Cap manifest notification payload size 735 + - Timeout long-running operations 736 + 737 + ## Monitoring and Observability 738 + 739 + ### Metrics to Track 740 + 741 + **AppView**: 742 + - `atcr_hold_notifications_total` - Counter of notifications sent 743 + - `atcr_hold_notifications_errors` - Counter of failures 744 + - `atcr_hold_notification_duration_ms` - Histogram of latency 745 + 746 + **Hold**: 747 + - `hold_layer_records_created_total` - Counter of layer records 748 + - `hold_bluesky_posts_created_total` - Counter of posts 749 + - `hold_manifest_notifications_received_total` - Counter of incoming notifications 750 + - `hold_notification_errors_total` - Counter of errors by type 751 + 752 + ### Logging 753 + 754 + **Structured logs**: 755 + ```json 756 + { 757 + "level": "info", 758 + "msg": "manifest notification received", 759 + "repository": "alice/myapp", 760 + "tag": "latest", 761 + "userDid": "did:plc:alice123", 762 + "layerCount": 5, 763 + "layersCreated": 5, 764 + "postCreated": true, 765 + "duration_ms": 45 766 + } 767 + ``` 768 + 769 + ### Alerts 770 + 771 + **Critical issues**: 772 + - High error rate (>10% failures) 773 + - Service token failures (auth issues) 774 + - PDS carstore errors (database problems) 775 + 776 + **Warning issues**: 777 + - Slow notifications (>1s latency) 778 + - Partial failures (some layers not created) 779 + - Missing user handle in context 780 + 781 + ## Migration Strategy 782 + 783 + ### Rollout Plan 784 + 785 + **Phase 1: Development** 786 + - Implement core functionality 787 + - Add comprehensive tests 788 + - Deploy to staging environment 789 + 790 + **Phase 2: Beta** 791 + - Enable for test holds only 792 + - Gather feedback from early users 793 + - Monitor performance and errors 794 + 795 + **Phase 3: Opt-in** 796 + - Add configuration flags 797 + - Allow hold owners to enable feature 798 + - Document setup process 799 + 800 + **Phase 4: Default On** 801 + - Enable by default for new holds 802 + - Migrate existing holds (opt-out available) 803 + - Announce feature publicly 804 + 805 + ### Backward Compatibility 806 + 807 + **No breaking changes**: 808 + - New XRPC endpoint (doesn't affect existing endpoints) 809 + - New record types (isolated collections) 810 + - Optional feature (can be disabled) 811 + 812 + **Existing holds**: 813 + - Work without changes 814 + - Can opt-in by updating hold service 815 + - No data migration required 816 + 817 + ## Example Post Formats 818 + 819 + ### Simple Format 820 + ``` 821 + ๐Ÿ“ฆ alice/myapp:latest pushed by @alice.bsky.social 822 + ``` 823 + 824 + ### Detailed Format 825 + ``` 826 + ๐Ÿ“ฆ New container image pushed! 827 + 828 + alice/myapp:v1.2.3 829 + Pushed by @alice.bsky.social 830 + 5 layers, 125 MB total 831 + 832 + View: https://atcr.io/alice/myapp 833 + ``` 834 + 835 + ### With Emoji/Styling 836 + ``` 837 + ๐Ÿš€ alice/myapp:latest 838 + 839 + โœ… 5 layers 840 + ๐Ÿ“ฆ 125.4 MB 841 + ๐Ÿ‘ค @alice.bsky.social 842 + ๐Ÿ”— atcr.io/alice/myapp 843 + ``` 844 + 845 + ### With Tags 846 + ``` 847 + ๐Ÿ“ฆ alice/myapp:latest pushed by @alice.bsky.social 848 + 849 + #container #docker #atcr 850 + ``` 851 + 852 + ## References 853 + 854 + ### Related Code 855 + 856 + - Existing Bluesky post implementation: `pkg/hold/pds/status.go` 857 + - XRPC endpoint pattern: `pkg/hold/oci/xrpc.go` 858 + - Record type definitions: `pkg/atproto/lexicon.go` 859 + - Manifest storage: `pkg/appview/storage/manifest_store.go` 860 + - Service token handling: `pkg/auth/oauth/refresher.go` 861 + 862 + ### External Documentation 863 + 864 + - ATProto Record Schema: https://atproto.com/specs/record-key 865 + - Bluesky Post Lexicon: https://atproto.com/lexicons/app-bsky-feed#appbskyfeedpost 866 + - CBOR Encoding: https://cbor.io/ 867 + - Bluesky Facets (mentions/links): https://atproto.com/specs/richtext 868 + 869 + ### Tools 870 + 871 + - CBOR code generation: `github.com/whyrusleeping/cbor-gen` 872 + - ATProto libraries: `github.com/bluesky-social/indigo` 873 + - Testing: Standard Go testing + `testify/assert`