A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
73
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`