A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
72
fork

Configure Feed

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

more test coverage. clean up docs

+2774 -5720
+5
.env.appview.example
··· 61 61 # Default: /var/lib/atcr/ui.db 62 62 # ATCR_UI_DATABASE_PATH=/var/lib/atcr/ui.db 63 63 64 + # Skip database migrations on startup (default: false) 65 + # Set to "true" to skip running migrations (useful for tests or fresh databases) 66 + # Production: Keep as "false" to ensure migrations are applied 67 + SKIP_DB_MIGRATIONS=false 68 + 64 69 # ============================================================================== 65 70 # Logging Configuration 66 71 # ==============================================================================
+1 -1
cmd/appview/serve.go
··· 74 74 75 75 // Initialize UI database first (required for all stores) 76 76 slog.Info("Initializing UI database", "path", cfg.UI.DatabasePath) 77 - uiDatabase, uiReadOnlyDB, uiSessionStore := db.InitializeDatabase(cfg.UI.Enabled, cfg.UI.DatabasePath) 77 + uiDatabase, uiReadOnlyDB, uiSessionStore := db.InitializeDatabase(cfg.UI.Enabled, cfg.UI.DatabasePath, cfg.UI.SkipDBMigrations) 78 78 if uiDatabase == nil { 79 79 return fmt.Errorf("failed to initialize UI database - required for session storage") 80 80 }
+6
deploy/.env.prod.template
··· 155 155 # Default: true 156 156 ATCR_UI_ENABLED=true 157 157 158 + # Skip database migrations on startup 159 + # Default: false (migrations are applied on startup) 160 + # Set to "true" only for testing or when migrations are managed externally 161 + # Production: Keep as "false" to ensure migrations are applied 162 + SKIP_DB_MIGRATIONS=false 163 + 158 164 # ============================================================================== 159 165 # Logging Configuration 160 166 # ==============================================================================
-577
docs/ANNOTATIONS_REFACTOR.md
··· 1 - # Annotations Table Refactoring 2 - 3 - ## Overview 4 - 5 - Refactor manifest annotations from individual columns (`title`, `description`, `source_url`, etc.) to a normalized key-value table. This enables flexible annotation storage without schema changes for new OCI annotations. 6 - 7 - ## Motivation 8 - 9 - **Current Problems:** 10 - - Each new annotation (e.g., `org.opencontainers.image.version`) requires schema change 11 - - Many NULL columns in manifests table 12 - - Rigid schema doesn't match OCI's flexible annotation model 13 - 14 - **Benefits:** 15 - - ✅ Add any annotation without code/schema changes 16 - - ✅ Normalized database design 17 - - ✅ Easy to query "all repos with annotation X" 18 - - ✅ Simple queries (no joins needed for repository pages) 19 - 20 - ## Database Schema Changes 21 - 22 - ### 1. New Table: `repository_annotations` 23 - 24 - ```sql 25 - CREATE TABLE IF NOT EXISTS repository_annotations ( 26 - did TEXT NOT NULL, 27 - repository TEXT NOT NULL, 28 - key TEXT NOT NULL, 29 - value TEXT NOT NULL, 30 - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 31 - PRIMARY KEY(did, repository, key), 32 - FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 33 - ); 34 - CREATE INDEX IF NOT EXISTS idx_repository_annotations_did_repo ON repository_annotations(did, repository); 35 - CREATE INDEX IF NOT EXISTS idx_repository_annotations_key ON repository_annotations(key); 36 - ``` 37 - 38 - **Key Design Decisions:** 39 - - Primary key: `(did, repository, key)` - one value per annotation per repository 40 - - No `manifest_id` foreign key - annotations are repository-level, not manifest-level 41 - - `updated_at` - track when annotation was last updated (from most recent manifest) 42 - - Stored at repository level because that's where they're displayed 43 - 44 - ### 2. Drop Columns from `manifests` Table 45 - 46 - Remove these columns (migration will preserve data by copying to annotations table): 47 - - `title` 48 - - `description` 49 - - `source_url` 50 - - `documentation_url` 51 - - `licenses` 52 - - `icon_url` 53 - - `readme_url` 54 - - `version` 55 - 56 - Keep only core manifest metadata: 57 - - `id`, `did`, `repository`, `digest` 58 - - `hold_endpoint`, `schema_version`, `media_type` 59 - - `config_digest`, `config_size` 60 - - `created_at` 61 - 62 - ## Migration Strategy 63 - 64 - There is no need to migrate data to this new table via sql. on startup, backfill will re-populate the new table with existing annotations. 65 - 66 - ## Code Changes 67 - 68 - ### 1. Database Helper Functions 69 - 70 - **New file: `pkg/appview/db/annotations.go`** 71 - 72 - ```go 73 - package db 74 - 75 - import ( 76 - "database/sql" 77 - "time" 78 - ) 79 - 80 - // GetRepositoryAnnotations retrieves all annotations for a repository 81 - func GetRepositoryAnnotations(db *sql.DB, did, repository string) (map[string]string, error) { 82 - rows, err := db.Query(` 83 - SELECT key, value 84 - FROM repository_annotations 85 - WHERE did = ? AND repository = ? 86 - `, did, repository) 87 - if err != nil { 88 - return nil, err 89 - } 90 - defer rows.Close() 91 - 92 - annotations := make(map[string]string) 93 - for rows.Next() { 94 - var key, value string 95 - if err := rows.Scan(&key, &value); err != nil { 96 - return nil, err 97 - } 98 - annotations[key] = value 99 - } 100 - 101 - return annotations, rows.Err() 102 - } 103 - 104 - // UpsertRepositoryAnnotations replaces all annotations for a repository 105 - // Only called when manifest has at least one non-empty annotation 106 - func UpsertRepositoryAnnotations(db *sql.DB, did, repository string, annotations map[string]string) error { 107 - tx, err := db.Begin() 108 - if err != nil { 109 - return err 110 - } 111 - defer tx.Rollback() 112 - 113 - // Delete existing annotations 114 - _, err = tx.Exec(` 115 - DELETE FROM repository_annotations 116 - WHERE did = ? AND repository = ? 117 - `, did, repository) 118 - if err != nil { 119 - return err 120 - } 121 - 122 - // Insert new annotations 123 - stmt, err := tx.Prepare(` 124 - INSERT INTO repository_annotations (did, repository, key, value, updated_at) 125 - VALUES (?, ?, ?, ?, ?) 126 - `) 127 - if err != nil { 128 - return err 129 - } 130 - defer stmt.Close() 131 - 132 - now := time.Now() 133 - for key, value := range annotations { 134 - _, err = stmt.Exec(did, repository, key, value, now) 135 - if err != nil { 136 - return err 137 - } 138 - } 139 - 140 - return tx.Commit() 141 - } 142 - 143 - // DeleteRepositoryAnnotations removes all annotations for a repository 144 - func DeleteRepositoryAnnotations(db *sql.DB, did, repository string) error { 145 - _, err := db.Exec(` 146 - DELETE FROM repository_annotations 147 - WHERE did = ? AND repository = ? 148 - `, did, repository) 149 - return err 150 - } 151 - ``` 152 - 153 - ### 2. Update Backfill Worker 154 - 155 - **File: `pkg/appview/jetstream/backfill.go`** 156 - 157 - In `processManifestRecord()` function, after extracting annotations: 158 - 159 - ```go 160 - // Extract OCI annotations from manifest 161 - var title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL string 162 - if manifestRecord.Annotations != nil { 163 - title = manifestRecord.Annotations["org.opencontainers.image.title"] 164 - description = manifestRecord.Annotations["org.opencontainers.image.description"] 165 - sourceURL = manifestRecord.Annotations["org.opencontainers.image.source"] 166 - documentationURL = manifestRecord.Annotations["org.opencontainers.image.documentation"] 167 - licenses = manifestRecord.Annotations["org.opencontainers.image.licenses"] 168 - iconURL = manifestRecord.Annotations["io.atcr.icon"] 169 - readmeURL = manifestRecord.Annotations["io.atcr.readme"] 170 - } 171 - 172 - // Prepare manifest for insertion (WITHOUT annotation fields) 173 - manifest := &db.Manifest{ 174 - DID: did, 175 - Repository: manifestRecord.Repository, 176 - Digest: manifestRecord.Digest, 177 - MediaType: manifestRecord.MediaType, 178 - SchemaVersion: manifestRecord.SchemaVersion, 179 - HoldEndpoint: manifestRecord.HoldEndpoint, 180 - CreatedAt: manifestRecord.CreatedAt, 181 - // NO annotation fields 182 - } 183 - 184 - // Set config fields only for image manifests (not manifest lists) 185 - if !isManifestList && manifestRecord.Config != nil { 186 - manifest.ConfigDigest = manifestRecord.Config.Digest 187 - manifest.ConfigSize = manifestRecord.Config.Size 188 - } 189 - 190 - // Insert manifest 191 - manifestID, err := db.InsertManifest(b.db, manifest) 192 - if err != nil { 193 - return fmt.Errorf("failed to insert manifest: %w", err) 194 - } 195 - 196 - // Update repository annotations ONLY if manifest has at least one non-empty annotation 197 - if manifestRecord.Annotations != nil { 198 - hasData := false 199 - for _, value := range manifestRecord.Annotations { 200 - if value != "" { 201 - hasData = true 202 - break 203 - } 204 - } 205 - 206 - if hasData { 207 - // Replace all annotations for this repository 208 - err = db.UpsertRepositoryAnnotations(b.db, did, manifestRecord.Repository, manifestRecord.Annotations) 209 - if err != nil { 210 - return fmt.Errorf("failed to upsert annotations: %w", err) 211 - } 212 - } 213 - } 214 - ``` 215 - 216 - ### 3. Update Jetstream Worker 217 - 218 - **File: `pkg/appview/jetstream/worker.go`** 219 - 220 - Same changes as backfill - in `processManifestCommit()` function: 221 - 222 - ```go 223 - // Extract OCI annotations from manifest 224 - var title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL string 225 - if manifestRecord.Annotations != nil { 226 - title = manifestRecord.Annotations["org.opencontainers.image.title"] 227 - description = manifestRecord.Annotations["org.opencontainers.image.description"] 228 - sourceURL = manifestRecord.Annotations["org.opencontainers.image.source"] 229 - documentationURL = manifestRecord.Annotations["org.opencontainers.image.documentation"] 230 - licenses = manifestRecord.Annotations["org.opencontainers.image.licenses"] 231 - iconURL = manifestRecord.Annotations["io.atcr.icon"] 232 - readmeURL = manifestRecord.Annotations["io.atcr.readme"] 233 - } 234 - 235 - // Prepare manifest for insertion (WITHOUT annotation fields) 236 - manifest := &db.Manifest{ 237 - DID: commit.DID, 238 - Repository: manifestRecord.Repository, 239 - Digest: manifestRecord.Digest, 240 - MediaType: manifestRecord.MediaType, 241 - SchemaVersion: manifestRecord.SchemaVersion, 242 - HoldEndpoint: manifestRecord.HoldEndpoint, 243 - CreatedAt: manifestRecord.CreatedAt, 244 - // NO annotation fields 245 - } 246 - 247 - // Set config fields only for image manifests (not manifest lists) 248 - if !isManifestList && manifestRecord.Config != nil { 249 - manifest.ConfigDigest = manifestRecord.Config.Digest 250 - manifest.ConfigSize = manifestRecord.Config.Size 251 - } 252 - 253 - // Insert manifest 254 - manifestID, err := db.InsertManifest(w.db, manifest) 255 - if err != nil { 256 - return fmt.Errorf("failed to insert manifest: %w", err) 257 - } 258 - 259 - // Update repository annotations ONLY if manifest has at least one non-empty annotation 260 - if manifestRecord.Annotations != nil { 261 - hasData := false 262 - for _, value := range manifestRecord.Annotations { 263 - if value != "" { 264 - hasData = true 265 - break 266 - } 267 - } 268 - 269 - if hasData { 270 - // Replace all annotations for this repository 271 - err = db.UpsertRepositoryAnnotations(w.db, commit.DID, manifestRecord.Repository, manifestRecord.Annotations) 272 - if err != nil { 273 - return fmt.Errorf("failed to upsert annotations: %w", err) 274 - } 275 - } 276 - } 277 - ``` 278 - 279 - ### 4. Update Database Queries 280 - 281 - **File: `pkg/appview/db/queries.go`** 282 - 283 - Replace `GetRepositoryMetadata()` function: 284 - 285 - ```go 286 - // GetRepositoryMetadata retrieves metadata for a repository from annotations table 287 - func GetRepositoryMetadata(db *sql.DB, did string, repository string) (title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, version string, err error) { 288 - annotations, err := GetRepositoryAnnotations(db, did, repository) 289 - if err != nil { 290 - return "", "", "", "", "", "", "", "", err 291 - } 292 - 293 - title = annotations["org.opencontainers.image.title"] 294 - description = annotations["org.opencontainers.image.description"] 295 - sourceURL = annotations["org.opencontainers.image.source"] 296 - documentationURL = annotations["org.opencontainers.image.documentation"] 297 - licenses = annotations["org.opencontainers.image.licenses"] 298 - iconURL = annotations["io.atcr.icon"] 299 - readmeURL = annotations["io.atcr.readme"] 300 - version = annotations["org.opencontainers.image.version"] 301 - 302 - return title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, version, nil 303 - } 304 - ``` 305 - 306 - Update `InsertManifest()` to remove annotation columns: 307 - 308 - ```go 309 - func InsertManifest(db *sql.DB, manifest *Manifest) (int64, error) { 310 - _, err := db.Exec(` 311 - INSERT INTO manifests 312 - (did, repository, digest, hold_endpoint, schema_version, media_type, 313 - config_digest, config_size, created_at) 314 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 315 - ON CONFLICT(did, repository, digest) DO UPDATE SET 316 - hold_endpoint = excluded.hold_endpoint, 317 - schema_version = excluded.schema_version, 318 - media_type = excluded.media_type, 319 - config_digest = excluded.config_digest, 320 - config_size = excluded.config_size 321 - `, manifest.DID, manifest.Repository, manifest.Digest, manifest.HoldEndpoint, 322 - manifest.SchemaVersion, manifest.MediaType, manifest.ConfigDigest, 323 - manifest.ConfigSize, manifest.CreatedAt) 324 - 325 - if err != nil { 326 - return 0, err 327 - } 328 - 329 - // Query for the ID (works for both insert and update) 330 - var id int64 331 - err = db.QueryRow(` 332 - SELECT id FROM manifests 333 - WHERE did = ? AND repository = ? AND digest = ? 334 - `, manifest.DID, manifest.Repository, manifest.Digest).Scan(&id) 335 - 336 - if err != nil { 337 - return 0, fmt.Errorf("failed to get manifest ID after upsert: %w", err) 338 - } 339 - 340 - return id, nil 341 - } 342 - ``` 343 - 344 - Similar updates needed for: 345 - - `GetUserRepositories()` - fetch annotations separately and populate Repository struct 346 - - `GetRecentPushes()` - join with annotations or fetch separately 347 - - `SearchPushes()` - can now search annotations table directly 348 - 349 - ### 5. Update Models 350 - 351 - **File: `pkg/appview/db/models.go`** 352 - 353 - Remove annotation fields from `Manifest` struct: 354 - 355 - ```go 356 - type Manifest struct { 357 - ID int64 358 - DID string 359 - Repository string 360 - Digest string 361 - HoldEndpoint string 362 - SchemaVersion int 363 - MediaType string 364 - ConfigDigest string 365 - ConfigSize int64 366 - CreatedAt time.Time 367 - // Removed: Title, Description, SourceURL, DocumentationURL, Licenses, IconURL, ReadmeURL 368 - } 369 - ``` 370 - 371 - Keep annotation fields on `Repository` struct (populated from annotations table): 372 - 373 - ```go 374 - type Repository struct { 375 - Name string 376 - TagCount int 377 - ManifestCount int 378 - LastPush time.Time 379 - Tags []Tag 380 - Manifests []Manifest 381 - Title string 382 - Description string 383 - SourceURL string 384 - DocumentationURL string 385 - Licenses string 386 - IconURL string 387 - ReadmeURL string 388 - Version string // NEW 389 - } 390 - ``` 391 - 392 - ### 6. Update Schema.sql 393 - 394 - **File: `pkg/appview/db/schema.sql`** 395 - 396 - Add new table: 397 - 398 - ```sql 399 - CREATE TABLE IF NOT EXISTS repository_annotations ( 400 - did TEXT NOT NULL, 401 - repository TEXT NOT NULL, 402 - key TEXT NOT NULL, 403 - value TEXT NOT NULL, 404 - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 405 - PRIMARY KEY(did, repository, key), 406 - FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 407 - ); 408 - CREATE INDEX IF NOT EXISTS idx_repository_annotations_did_repo ON repository_annotations(did, repository); 409 - CREATE INDEX IF NOT EXISTS idx_repository_annotations_key ON repository_annotations(key); 410 - ``` 411 - 412 - Update manifests table (remove annotation columns): 413 - 414 - ```sql 415 - CREATE TABLE IF NOT EXISTS manifests ( 416 - id INTEGER PRIMARY KEY AUTOINCREMENT, 417 - did TEXT NOT NULL, 418 - repository TEXT NOT NULL, 419 - digest TEXT NOT NULL, 420 - hold_endpoint TEXT NOT NULL, 421 - schema_version INTEGER NOT NULL, 422 - media_type TEXT NOT NULL, 423 - config_digest TEXT, 424 - config_size INTEGER, 425 - created_at TIMESTAMP NOT NULL, 426 - UNIQUE(did, repository, digest), 427 - FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 428 - ); 429 - ``` 430 - 431 - ## Update Logic Summary 432 - 433 - **Key Decision: Only update annotations when manifest has data** 434 - 435 - ``` 436 - For each manifest processed (backfill or jetstream): 437 - 1. Parse manifest.Annotations map 438 - 2. Check if ANY annotation has non-empty value 439 - 3. IF hasData: 440 - DELETE all annotations for (did, repository) 441 - INSERT all annotations from manifest (including empty ones) 442 - ELSE: 443 - SKIP (don't touch existing annotations) 444 - ``` 445 - 446 - **Why this works:** 447 - - Manifest lists have no annotations or all empty → skip, preserve existing 448 - - Platform manifests have real data → replace everything 449 - - Removing annotation from Dockerfile → it's gone (not in new INSERT) 450 - - Can't accidentally clear data (need at least one non-empty value) 451 - 452 - ## UI/Template Changes 453 - 454 - ### Handler Updates 455 - 456 - **File: `pkg/appview/handlers/repository.go`** 457 - 458 - Update the handler to include version: 459 - 460 - ```go 461 - // Fetch repository metadata from annotations 462 - title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, version, err := db.GetRepositoryMetadata(h.DB, owner.DID, repository) 463 - if err != nil { 464 - log.Printf("Failed to fetch repository metadata: %v", err) 465 - // Continue without metadata on error 466 - } else { 467 - repo.Title = title 468 - repo.Description = description 469 - repo.SourceURL = sourceURL 470 - repo.DocumentationURL = documentationURL 471 - repo.Licenses = licenses 472 - repo.IconURL = iconURL 473 - repo.ReadmeURL = readmeURL 474 - repo.Version = version // NEW 475 - } 476 - ``` 477 - 478 - ### Template Updates 479 - 480 - **File: `pkg/appview/templates/pages/repository.html`** 481 - 482 - Update the metadata section condition to include version: 483 - 484 - ```html 485 - <!-- Metadata Section --> 486 - {{ if or .Repository.Licenses .Repository.SourceURL .Repository.DocumentationURL .Repository.Version }} 487 - <div class="repo-metadata"> 488 - <!-- Version Badge (if present) --> 489 - {{ if .Repository.Version }} 490 - <span class="metadata-badge version-badge" title="Version"> 491 - {{ .Repository.Version }} 492 - </span> 493 - {{ end }} 494 - 495 - <!-- License Badges --> 496 - {{ if .Repository.Licenses }} 497 - {{ range parseLicenses .Repository.Licenses }} 498 - {{ if .IsValid }} 499 - <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" class="metadata-badge license-badge" title="{{ .Name }}"> 500 - {{ .SPDXID }} 501 - </a> 502 - {{ else }} 503 - <span class="metadata-badge license-badge" title="Custom license: {{ .Name }}"> 504 - {{ .Name }} 505 - </span> 506 - {{ end }} 507 - {{ end }} 508 - {{ end }} 509 - 510 - <!-- Source Link --> 511 - {{ if .Repository.SourceURL }} 512 - <a href="{{ .Repository.SourceURL }}" target="_blank" class="metadata-link"> 513 - Source 514 - </a> 515 - {{ end }} 516 - 517 - <!-- Documentation Link --> 518 - {{ if .Repository.DocumentationURL }} 519 - <a href="{{ .Repository.DocumentationURL }}" target="_blank" class="metadata-link"> 520 - Documentation 521 - </a> 522 - {{ end }} 523 - </div> 524 - {{ end }} 525 - ``` 526 - 527 - ### CSS Updates 528 - 529 - **File: `pkg/appview/static/css/style.css`** 530 - 531 - Add styling for version badge (different color from license badge): 532 - 533 - ```css 534 - .version-badge { 535 - background: #0969da; /* GitHub blue */ 536 - color: white; 537 - padding: 0.25rem 0.5rem; 538 - border-radius: 0.25rem; 539 - font-size: 0.875rem; 540 - font-weight: 500; 541 - display: inline-block; 542 - } 543 - ``` 544 - 545 - ### Data Flow Summary 546 - 547 - **Before refactor:** 548 - ``` 549 - DB columns → GetRepositoryMetadata() → Handler assigns to Repository struct → Template displays 550 - ``` 551 - 552 - **After refactor:** 553 - ``` 554 - annotations table → GetRepositoryAnnotations() → GetRepositoryMetadata() extracts known fields → 555 - Handler assigns to Repository struct → Template displays (same as before) 556 - ``` 557 - 558 - **Key point:** Templates still access `.Repository.Title`, `.Repository.Version`, etc. - the source just changed from DB columns to annotations table. The abstraction layer hides this complexity. 559 - 560 - ## Benefits Recap 561 - 562 - 1. **Flexible**: Support any OCI annotation without code changes 563 - 2. **Clean**: No NULL columns in manifests table 564 - 3. **Simple queries**: `SELECT * FROM repository_annotations WHERE did=? AND repo=?` 565 - 4. **Safe updates**: Only update when manifest has data 566 - 5. **Natural deletion**: Remove annotation from Dockerfile → it's deleted on next push 567 - 6. **Extensible**: Future features (annotation search, filtering) are trivial 568 - 569 - ## Testing Checklist 570 - 571 - After migration: 572 - - [ ] Verify existing repositories show annotations correctly 573 - - [ ] Push new manifest with annotations → updates correctly 574 - - [ ] Push manifest list → doesn't clear annotations 575 - - [ ] Remove annotation from Dockerfile and push → annotation deleted 576 - - [ ] Backfill re-run → annotations repopulated correctly 577 - - [ ] Search still works (if implemented)
-1827
docs/APPVIEW-UI-IMPLEMENTATION.md
··· 1 - # ATCR AppView UI - Implementation Guide 2 - 3 - This document provides step-by-step implementation details for building the ATCR web UI using **html/template + HTMX**. 4 - 5 - ## Tech Stack (Finalized) 6 - 7 - - **Backend:** Go (existing AppView) 8 - - **Templates:** `html/template` (standard library) 9 - - **Interactivity:** HTMX (~14KB) + Alpine.js (~15KB, optional) 10 - - **Database:** SQLite (firehose cache) 11 - - **Styling:** Simple CSS or Tailwind (TBD) 12 - - **Authentication:** OAuth (existing implementation) 13 - 14 - ## Project Structure 15 - 16 - ``` 17 - cmd/appview/ 18 - ├── main.go # Add AppView routes here 19 - 20 - pkg/appview/ 21 - ├── appview.go # Main AppView setup, embed directives 22 - ├── handlers/ # HTTP handlers 23 - │ ├── home.go # Front page (firehose) 24 - │ ├── settings.go # Settings page 25 - │ ├── images.go # Personal images page 26 - │ └── auth.go # Login/logout handlers 27 - ├── db/ # Database layer 28 - │ ├── schema.go # SQLite schema 29 - │ ├── queries.go # DB queries 30 - │ └── models.go # Data models 31 - ├── firehose/ # Firehose worker 32 - │ ├── worker.go # Background worker 33 - │ └── jetstream.go # Jetstream client 34 - ├── middleware/ # HTTP middleware 35 - │ ├── auth.go # Session auth 36 - │ └── csrf.go # CSRF protection 37 - ├── session/ # Session management 38 - │ └── session.go # Session store 39 - ├── templates/ # HTML templates (embedded) 40 - │ ├── layouts/ 41 - │ │ └── base.html # Base layout 42 - │ ├── components/ 43 - │ │ ├── nav.html # Navigation bar 44 - │ │ └── modal.html # Modal dialogs 45 - │ ├── pages/ 46 - │ │ ├── home.html # Front page 47 - │ │ ├── settings.html # Settings page 48 - │ │ └── images.html # Personal images 49 - │ └── partials/ # HTMX partials 50 - │ ├── push-list.html # Push list partial 51 - │ └── tag-row.html # Tag row partial 52 - └── static/ # Static assets (embedded) 53 - ├── css/ 54 - │ └── style.css 55 - └── js/ 56 - └── app.js # Minimal JS (clipboard, etc.) 57 - ``` 58 - 59 - ## Step 1: Embed Setup 60 - 61 - ### Main AppView Package 62 - 63 - **pkg/appview/appview.go:** 64 - 65 - ```go 66 - package appview 67 - 68 - import ( 69 - "embed" 70 - "html/template" 71 - "io/fs" 72 - "net/http" 73 - ) 74 - 75 - //go:embed templates/*.html templates/**/*.html 76 - var templatesFS embed.FS 77 - 78 - //go:embed static/* 79 - var staticFS embed.FS 80 - 81 - // Templates returns parsed templates 82 - func Templates() (*template.Template, error) { 83 - return template.ParseFS(templatesFS, "templates/**/*.html") 84 - } 85 - 86 - // StaticHandler returns HTTP handler for static files 87 - func StaticHandler() http.Handler { 88 - sub, _ := fs.Sub(staticFS, "static") 89 - return http.FileServer(http.FS(sub)) 90 - } 91 - ``` 92 - 93 - ## Step 2: Database Setup 94 - 95 - ### Create Schema 96 - 97 - **pkg/appview/db/schema.go:** 98 - 99 - ```go 100 - package db 101 - 102 - import ( 103 - "database/sql" 104 - _ "github.com/mattn/go-sqlite3" 105 - ) 106 - 107 - const schema = ` 108 - CREATE TABLE IF NOT EXISTS users ( 109 - did TEXT PRIMARY KEY, 110 - handle TEXT NOT NULL, 111 - pds_endpoint TEXT NOT NULL, 112 - last_seen TIMESTAMP NOT NULL, 113 - UNIQUE(handle) 114 - ); 115 - CREATE INDEX IF NOT EXISTS idx_users_handle ON users(handle); 116 - 117 - CREATE TABLE IF NOT EXISTS manifests ( 118 - id INTEGER PRIMARY KEY AUTOINCREMENT, 119 - did TEXT NOT NULL, 120 - repository TEXT NOT NULL, 121 - digest TEXT NOT NULL, 122 - hold_endpoint TEXT NOT NULL, 123 - schema_version INTEGER NOT NULL, 124 - media_type TEXT NOT NULL, 125 - config_digest TEXT, 126 - config_size INTEGER, 127 - raw_manifest TEXT NOT NULL, 128 - created_at TIMESTAMP NOT NULL, 129 - UNIQUE(did, repository, digest), 130 - FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 131 - ); 132 - CREATE INDEX IF NOT EXISTS idx_manifests_did_repo ON manifests(did, repository); 133 - CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC); 134 - CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest); 135 - 136 - CREATE TABLE IF NOT EXISTS layers ( 137 - manifest_id INTEGER NOT NULL, 138 - digest TEXT NOT NULL, 139 - size INTEGER NOT NULL, 140 - media_type TEXT NOT NULL, 141 - layer_index INTEGER NOT NULL, 142 - PRIMARY KEY(manifest_id, layer_index), 143 - FOREIGN KEY(manifest_id) REFERENCES manifests(id) ON DELETE CASCADE 144 - ); 145 - CREATE INDEX IF NOT EXISTS idx_layers_digest ON layers(digest); 146 - 147 - CREATE TABLE IF NOT EXISTS tags ( 148 - id INTEGER PRIMARY KEY AUTOINCREMENT, 149 - did TEXT NOT NULL, 150 - repository TEXT NOT NULL, 151 - tag TEXT NOT NULL, 152 - digest TEXT NOT NULL, 153 - created_at TIMESTAMP NOT NULL, 154 - UNIQUE(did, repository, tag), 155 - FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 156 - ); 157 - CREATE INDEX IF NOT EXISTS idx_tags_did_repo ON tags(did, repository); 158 - 159 - CREATE TABLE IF NOT EXISTS firehose_cursor ( 160 - id INTEGER PRIMARY KEY CHECK (id = 1), 161 - cursor INTEGER NOT NULL, 162 - updated_at TIMESTAMP NOT NULL 163 - ); 164 - ` 165 - 166 - func InitDB(path string) (*sql.DB, error) { 167 - db, err := sql.Open("sqlite3", path) 168 - if err != nil { 169 - return nil, err 170 - } 171 - 172 - if _, err := db.Exec(schema); err != nil { 173 - return nil, err 174 - } 175 - 176 - return db, nil 177 - } 178 - ``` 179 - 180 - ### Data Models 181 - 182 - **pkg/appview/db/models.go:** 183 - 184 - ```go 185 - package db 186 - 187 - import "time" 188 - 189 - type User struct { 190 - DID string 191 - Handle string 192 - PDSEndpoint string 193 - LastSeen time.Time 194 - } 195 - 196 - type Manifest struct { 197 - ID int64 198 - DID string 199 - Repository string 200 - Digest string 201 - HoldEndpoint string 202 - SchemaVersion int 203 - MediaType string 204 - ConfigDigest string 205 - ConfigSize int64 206 - RawManifest string // JSON 207 - CreatedAt time.Time 208 - } 209 - 210 - type Tag struct { 211 - ID int64 212 - DID string 213 - Repository string 214 - Tag string 215 - Digest string 216 - CreatedAt time.Time 217 - } 218 - 219 - type Push struct { 220 - Handle string 221 - Repository string 222 - Tag string 223 - Digest string 224 - HoldEndpoint string 225 - CreatedAt time.Time 226 - } 227 - 228 - type Repository struct { 229 - Name string 230 - TagCount int 231 - ManifestCount int 232 - LastPush time.Time 233 - Tags []Tag 234 - Manifests []Manifest 235 - } 236 - ``` 237 - 238 - ### Query Functions 239 - 240 - **pkg/appview/db/queries.go:** 241 - 242 - ```go 243 - package db 244 - 245 - import ( 246 - "database/sql" 247 - "time" 248 - ) 249 - 250 - // GetRecentPushes fetches recent pushes with pagination 251 - func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string) ([]Push, int, error) { 252 - query := ` 253 - SELECT u.handle, t.repository, t.tag, t.digest, m.hold_endpoint, t.created_at 254 - FROM tags t 255 - JOIN users u ON t.did = u.did 256 - JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest 257 - ` 258 - 259 - if userFilter != "" { 260 - query += " WHERE u.handle = ? OR u.did = ?" 261 - } 262 - 263 - query += " ORDER BY t.created_at DESC LIMIT ? OFFSET ?" 264 - 265 - var rows *sql.Rows 266 - var err error 267 - 268 - if userFilter != "" { 269 - rows, err = db.Query(query, userFilter, userFilter, limit, offset) 270 - } else { 271 - rows, err = db.Query(query, limit, offset) 272 - } 273 - 274 - if err != nil { 275 - return nil, 0, err 276 - } 277 - defer rows.Close() 278 - 279 - var pushes []Push 280 - for rows.Next() { 281 - var p Push 282 - if err := rows.Scan(&p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.HoldEndpoint, &p.CreatedAt); err != nil { 283 - return nil, 0, err 284 - } 285 - pushes = append(pushes, p) 286 - } 287 - 288 - // Get total count 289 - countQuery := "SELECT COUNT(*) FROM tags t JOIN users u ON t.did = u.did" 290 - if userFilter != "" { 291 - countQuery += " WHERE u.handle = ? OR u.did = ?" 292 - } 293 - 294 - var total int 295 - if userFilter != "" { 296 - db.QueryRow(countQuery, userFilter, userFilter).Scan(&total) 297 - } else { 298 - db.QueryRow(countQuery).Scan(&total) 299 - } 300 - 301 - return pushes, total, nil 302 - } 303 - 304 - // GetUserRepositories fetches all repositories for a user 305 - func GetUserRepositories(db *sql.DB, did string) ([]Repository, error) { 306 - // Get repository summary 307 - rows, err := db.Query(` 308 - SELECT 309 - repository, 310 - COUNT(DISTINCT tag) as tag_count, 311 - COUNT(DISTINCT digest) as manifest_count, 312 - MAX(created_at) as last_push 313 - FROM ( 314 - SELECT repository, tag, digest, created_at FROM tags WHERE did = ? 315 - UNION 316 - SELECT repository, NULL, digest, created_at FROM manifests WHERE did = ? 317 - ) 318 - GROUP BY repository 319 - ORDER BY last_push DESC 320 - `, did, did) 321 - 322 - if err != nil { 323 - return nil, err 324 - } 325 - defer rows.Close() 326 - 327 - var repos []Repository 328 - for rows.Next() { 329 - var r Repository 330 - if err := rows.Scan(&r.Name, &r.TagCount, &r.ManifestCount, &r.LastPush); err != nil { 331 - return nil, err 332 - } 333 - 334 - // Get tags for this repo 335 - tagRows, err := db.Query(` 336 - SELECT tag, digest, created_at 337 - FROM tags 338 - WHERE did = ? AND repository = ? 339 - ORDER BY created_at DESC 340 - `, did, r.Name) 341 - 342 - if err != nil { 343 - return nil, err 344 - } 345 - 346 - for tagRows.Next() { 347 - var t Tag 348 - if err := tagRows.Scan(&t.Tag, &t.Digest, &t.CreatedAt); err != nil { 349 - tagRows.Close() 350 - return nil, err 351 - } 352 - r.Tags = append(r.Tags, t) 353 - } 354 - tagRows.Close() 355 - 356 - // Get manifests for this repo 357 - manifestRows, err := db.Query(` 358 - SELECT id, digest, hold_endpoint, schema_version, media_type, 359 - config_digest, config_size, raw_manifest, created_at 360 - FROM manifests 361 - WHERE did = ? AND repository = ? 362 - ORDER BY created_at DESC 363 - `, did, r.Name) 364 - 365 - if err != nil { 366 - return nil, err 367 - } 368 - 369 - for manifestRows.Next() { 370 - var m Manifest 371 - if err := manifestRows.Scan(&m.ID, &m.Digest, &m.HoldEndpoint, &m.SchemaVersion, 372 - &m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.RawManifest, &m.CreatedAt); err != nil { 373 - manifestRows.Close() 374 - return nil, err 375 - } 376 - r.Manifests = append(r.Manifests, m) 377 - } 378 - manifestRows.Close() 379 - 380 - repos = append(repos, r) 381 - } 382 - 383 - return repos, nil 384 - } 385 - ``` 386 - 387 - ## Step 2: Templates Layout 388 - 389 - ### Base Layout 390 - 391 - **pkg/appview/templates/layouts/base.html:** 392 - 393 - ```html 394 - <!DOCTYPE html> 395 - <html lang="en"> 396 - <head> 397 - <meta charset="UTF-8"> 398 - <meta name="viewport" content="width=device-width, initial-scale=1.0"> 399 - <title>{{ block "title" . }}ATCR{{ end }}</title> 400 - <link rel="stylesheet" href="/static/css/style.css"> 401 - <script src="https://unpkg.com/htmx.org@1.9.10"></script> 402 - {{ block "head" . }}{{ end }} 403 - </head> 404 - <body> 405 - {{ template "nav" . }} 406 - 407 - <main class="container"> 408 - {{ block "content" . }}{{ end }} 409 - </main> 410 - 411 - <!-- Modal container for HTMX --> 412 - <div id="modal"></div> 413 - 414 - <script src="/static/js/app.js"></script> 415 - {{ block "scripts" . }}{{ end }} 416 - </body> 417 - </html> 418 - ``` 419 - 420 - ### Navigation Component 421 - 422 - **pkg/appview/templates/components/nav.html:** 423 - 424 - ```html 425 - {{ define "nav" }} 426 - <nav class="navbar"> 427 - <div class="nav-brand"> 428 - <a href="/ui/">ATCR</a> 429 - </div> 430 - 431 - <div class="nav-search"> 432 - <form hx-get="/ui/api/recent-pushes" 433 - hx-target="#content" 434 - hx-trigger="submit" 435 - hx-include="[name='q']"> 436 - <input type="text" name="q" placeholder="Search images..." /> 437 - </form> 438 - </div> 439 - 440 - <div class="nav-links"> 441 - {{ if .User }} 442 - <a href="/ui/images">Your Images</a> 443 - <span class="user-handle">@{{ .User.Handle }}</span> 444 - <a href="/ui/settings" class="settings-icon">⚙️</a> 445 - <form action="/auth/logout" method="POST" style="display: inline;"> 446 - <button type="submit">Logout</button> 447 - </form> 448 - {{ else }} 449 - <a href="/auth/oauth/login?return_to=/ui/">Login</a> 450 - {{ end }} 451 - </div> 452 - </nav> 453 - {{ end }} 454 - ``` 455 - 456 - ## Step 3: Front Page (Homepage) 457 - 458 - **pkg/appview/templates/pages/home.html:** 459 - 460 - ```html 461 - {{ define "title" }}ATCR - Federated Container Registry{{ end }} 462 - 463 - {{ define "content" }} 464 - <div class="home-page"> 465 - <h1>Recent Pushes</h1> 466 - 467 - <div class="filters"> 468 - <button hx-get="/ui/api/recent-pushes" 469 - hx-target="#push-list" 470 - hx-swap="innerHTML">All</button> 471 - <!-- Add more filter buttons as needed --> 472 - </div> 473 - 474 - <div id="push-list" 475 - hx-get="/ui/api/recent-pushes" 476 - hx-trigger="load, every 30s" 477 - hx-swap="innerHTML"> 478 - <!-- Initial loading state --> 479 - <div class="loading">Loading recent pushes...</div> 480 - </div> 481 - </div> 482 - {{ end }} 483 - ``` 484 - 485 - **pkg/appview/templates/partials/push-list.html:** 486 - 487 - ```html 488 - {{ range .Pushes }} 489 - <div class="push-card"> 490 - <div class="push-header"> 491 - <a href="/ui/?user={{ .Handle }}" class="push-user">{{ .Handle }}</a> 492 - <span class="push-separator">/</span> 493 - <span class="push-repo">{{ .Repository }}</span> 494 - <span class="push-separator">:</span> 495 - <span class="push-tag">{{ .Tag }}</span> 496 - </div> 497 - 498 - <div class="push-details"> 499 - <code class="digest">{{ printf "%.12s" .Digest }}...</code> 500 - <span class="separator">•</span> 501 - <span class="hold">{{ .HoldEndpoint }}</span> 502 - <span class="separator">•</span> 503 - <time class="timestamp" datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}"> 504 - {{ .CreatedAt | timeAgo }} 505 - </time> 506 - </div> 507 - 508 - <div class="push-command"> 509 - <code class="pull-command">docker pull atcr.io/{{ .Handle }}/{{ .Repository }}:{{ .Tag }}</code> 510 - <button class="copy-btn" 511 - onclick="copyToClipboard('docker pull atcr.io/{{ .Handle }}/{{ .Repository }}:{{ .Tag }}')"> 512 - 📋 Copy 513 - </button> 514 - </div> 515 - 516 - <button class="view-manifest-btn" 517 - hx-get="/ui/api/manifests/{{ .Digest }}" 518 - hx-target="#modal" 519 - hx-swap="innerHTML"> 520 - View Manifest 521 - </button> 522 - </div> 523 - {{ end }} 524 - 525 - {{ if .HasMore }} 526 - <button class="load-more" 527 - hx-get="/ui/api/recent-pushes?offset={{ .NextOffset }}" 528 - hx-target="#push-list" 529 - hx-swap="beforeend"> 530 - Load More 531 - </button> 532 - {{ end }} 533 - ``` 534 - 535 - **pkg/appview/handlers/home.go:** 536 - 537 - ```go 538 - package handlers 539 - 540 - import ( 541 - "html/template" 542 - "net/http" 543 - "strconv" 544 - "atcr.io/pkg/appview/db" 545 - ) 546 - 547 - type HomeHandler struct { 548 - DB *sql.DB 549 - Templates *template.Template 550 - } 551 - 552 - func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 553 - // Check if this is an HTMX request for the partial 554 - if r.Header.Get("HX-Request") == "true" { 555 - h.servePushList(w, r) 556 - return 557 - } 558 - 559 - // Serve full page 560 - data := struct { 561 - User *db.User 562 - }{ 563 - User: getUserFromContext(r), 564 - } 565 - 566 - h.Templates.ExecuteTemplate(w, "home.html", data) 567 - } 568 - 569 - func (h *HomeHandler) servePushList(w http.ResponseWriter, r *http.Request) { 570 - limit := 50 571 - offset := 0 572 - 573 - if o := r.URL.Query().Get("offset"); o != "" { 574 - offset, _ = strconv.Atoi(o) 575 - } 576 - 577 - userFilter := r.URL.Query().Get("user") 578 - 579 - pushes, total, err := db.GetRecentPushes(h.DB, limit, offset, userFilter) 580 - if err != nil { 581 - http.Error(w, err.Error(), http.StatusInternalServerError) 582 - return 583 - } 584 - 585 - data := struct { 586 - Pushes []db.Push 587 - HasMore bool 588 - NextOffset int 589 - }{ 590 - Pushes: pushes, 591 - HasMore: offset+limit < total, 592 - NextOffset: offset + limit, 593 - } 594 - 595 - h.Templates.ExecuteTemplate(w, "push-list.html", data) 596 - } 597 - ``` 598 - 599 - ## Step 4: Settings Page 600 - 601 - **pkg/appview/templates/pages/settings.html:** 602 - 603 - ```html 604 - {{ define "title" }}Settings - ATCR{{ end }} 605 - 606 - {{ define "content" }} 607 - <div class="settings-page"> 608 - <h1>Settings</h1> 609 - 610 - <!-- Identity Section --> 611 - <section class="settings-section"> 612 - <h2>Identity</h2> 613 - <div class="form-group"> 614 - <label>Handle:</label> 615 - <span>{{ .Profile.Handle }}</span> 616 - </div> 617 - <div class="form-group"> 618 - <label>DID:</label> 619 - <code>{{ .Profile.DID }}</code> 620 - </div> 621 - <div class="form-group"> 622 - <label>PDS:</label> 623 - <span>{{ .Profile.PDSEndpoint }}</span> 624 - </div> 625 - </section> 626 - 627 - <!-- Default Hold Section --> 628 - <section class="settings-section"> 629 - <h2>Default Hold</h2> 630 - <p>Current: <strong>{{ .Profile.DefaultHold }}</strong></p> 631 - 632 - <form hx-post="/ui/api/profile/default-hold" 633 - hx-target="#hold-status" 634 - hx-swap="innerHTML"> 635 - 636 - <div class="form-group"> 637 - <label for="hold-select">Select from your holds:</label> 638 - <select name="hold_endpoint" id="hold-select"> 639 - {{ range .Holds }} 640 - <option value="{{ .Endpoint }}" 641 - {{ if eq .Endpoint $.Profile.DefaultHold }}selected{{ end }}> 642 - {{ .Endpoint }} {{ if .Name }}({{ .Name }}){{ end }} 643 - </option> 644 - {{ end }} 645 - <option value="">Custom URL...</option> 646 - </select> 647 - </div> 648 - 649 - <div class="form-group" id="custom-hold-group" style="display: none;"> 650 - <label for="custom-hold">Custom hold URL:</label> 651 - <input type="text" 652 - id="custom-hold" 653 - name="custom_hold" 654 - placeholder="https://hold.example.com" /> 655 - </div> 656 - 657 - <button type="submit">Save</button> 658 - </form> 659 - 660 - <div id="hold-status"></div> 661 - </section> 662 - 663 - <!-- OAuth Session Section --> 664 - <section class="settings-section"> 665 - <h2>OAuth Session</h2> 666 - <div class="form-group"> 667 - <label>Logged in as:</label> 668 - <span>{{ .Profile.Handle }}</span> 669 - </div> 670 - <div class="form-group"> 671 - <label>Session expires:</label> 672 - <time datetime="{{ .SessionExpiry.Format "2006-01-02T15:04:05Z07:00" }}"> 673 - {{ .SessionExpiry.Format "2006-01-02 15:04:05 MST" }} 674 - </time> 675 - </div> 676 - <a href="/auth/oauth/login?return_to=/ui/settings" class="btn">Re-authenticate</a> 677 - </section> 678 - </div> 679 - {{ end }} 680 - 681 - {{ define "scripts" }} 682 - <script> 683 - // Show/hide custom URL field 684 - document.getElementById('hold-select').addEventListener('change', function(e) { 685 - const customGroup = document.getElementById('custom-hold-group'); 686 - if (e.target.value === '') { 687 - customGroup.style.display = 'block'; 688 - } else { 689 - customGroup.style.display = 'none'; 690 - } 691 - }); 692 - </script> 693 - {{ end }} 694 - ``` 695 - 696 - **pkg/appview/handlers/settings.go:** 697 - 698 - ```go 699 - package handlers 700 - 701 - import ( 702 - "database/sql" 703 - "encoding/json" 704 - "html/template" 705 - "net/http" 706 - "atcr.io/pkg/atproto" 707 - ) 708 - 709 - type SettingsHandler struct { 710 - Templates *template.Template 711 - ATProtoClient *atproto.Client 712 - } 713 - 714 - func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 715 - user := getUserFromContext(r) 716 - if user == nil { 717 - http.Redirect(w, r, "/auth/oauth/login?return_to=/ui/settings", http.StatusFound) 718 - return 719 - } 720 - 721 - // Fetch user profile from PDS 722 - profile, err := h.ATProtoClient.GetProfile(user.DID) 723 - if err != nil { 724 - http.Error(w, err.Error(), http.StatusInternalServerError) 725 - return 726 - } 727 - 728 - // Fetch user's holds 729 - holds, err := h.ATProtoClient.ListHolds(user.DID) 730 - if err != nil { 731 - http.Error(w, err.Error(), http.StatusInternalServerError) 732 - return 733 - } 734 - 735 - data := struct { 736 - Profile *atproto.SailorProfileRecord 737 - Holds []atproto.HoldRecord 738 - SessionExpiry time.Time 739 - }{ 740 - Profile: profile, 741 - Holds: holds, 742 - SessionExpiry: getSessionExpiry(r), 743 - } 744 - 745 - h.Templates.ExecuteTemplate(w, "settings.html", data) 746 - } 747 - 748 - func (h *SettingsHandler) UpdateDefaultHold(w http.ResponseWriter, r *http.Request) { 749 - user := getUserFromContext(r) 750 - if user == nil { 751 - http.Error(w, "Unauthorized", http.StatusUnauthorized) 752 - return 753 - } 754 - 755 - holdEndpoint := r.FormValue("hold_endpoint") 756 - if holdEndpoint == "" { 757 - holdEndpoint = r.FormValue("custom_hold") 758 - } 759 - 760 - // Update profile in PDS 761 - err := h.ATProtoClient.UpdateProfile(user.DID, map[string]any{ 762 - "defaultHold": holdEndpoint, 763 - }) 764 - 765 - if err != nil { 766 - w.Write([]byte(`<div class="error">Failed to update: ` + err.Error() + `</div>`)) 767 - return 768 - } 769 - 770 - w.Write([]byte(`<div class="success">✓ Default hold updated successfully!</div>`)) 771 - } 772 - ``` 773 - 774 - ## Step 5: Personal Images Page 775 - 776 - **pkg/appview/templates/pages/images.html:** 777 - 778 - ```html 779 - {{ define "title" }}Your Images - ATCR{{ end }} 780 - 781 - {{ define "content" }} 782 - <div class="images-page"> 783 - <h1>Your Images</h1> 784 - 785 - {{ if .Repositories }} 786 - {{ range .Repositories }} 787 - <div class="repository-card"> 788 - <div class="repo-header" 789 - hx-get="/ui/api/repositories/{{ .Name }}/toggle" 790 - hx-target="#repo-{{ .Name }}" 791 - hx-swap="outerHTML"> 792 - <h2>{{ .Name }}</h2> 793 - <div class="repo-stats"> 794 - <span>{{ .TagCount }} tags</span> 795 - <span>•</span> 796 - <span>{{ .ManifestCount }} manifests</span> 797 - <span>•</span> 798 - <time datetime="{{ .LastPush.Format "2006-01-02T15:04:05Z07:00" }}"> 799 - Last push: {{ .LastPush | timeAgo }} 800 - </time> 801 - </div> 802 - <button class="expand-btn">▼</button> 803 - </div> 804 - 805 - <div id="repo-{{ .Name }}" class="repo-details" style="display: none;"> 806 - <!-- Tags Section --> 807 - <div class="tags-section"> 808 - <h3>Tags</h3> 809 - {{ range .Tags }} 810 - <div class="tag-row" id="tag-{{ $.Name }}-{{ .Tag }}"> 811 - <span class="tag-name">{{ .Tag }}</span> 812 - <span class="tag-arrow">→</span> 813 - <code class="tag-digest">{{ printf "%.12s" .Digest }}...</code> 814 - <time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}"> 815 - {{ .CreatedAt | timeAgo }} 816 - </time> 817 - 818 - <button class="edit-btn" 819 - hx-get="/ui/modals/edit-tag?repo={{ $.Name }}&tag={{ .Tag }}" 820 - hx-target="#modal"> 821 - ✏️ 822 - </button> 823 - 824 - <button class="delete-btn" 825 - hx-delete="/ui/api/images/{{ $.Name }}/tags/{{ .Tag }}" 826 - hx-confirm="Delete tag {{ .Tag }}?" 827 - hx-target="#tag-{{ $.Name }}-{{ .Tag }}" 828 - hx-swap="outerHTML"> 829 - 🗑️ 830 - </button> 831 - </div> 832 - {{ end }} 833 - </div> 834 - 835 - <!-- Manifests Section --> 836 - <div class="manifests-section"> 837 - <h3>Manifests</h3> 838 - {{ range .Manifests }} 839 - <div class="manifest-row" id="manifest-{{ .Digest }}"> 840 - <code class="manifest-digest">{{ printf "%.12s" .Digest }}...</code> 841 - <span>{{ .Size | humanizeBytes }}</span> 842 - <span>{{ .HoldEndpoint }}</span> 843 - <span>{{ .Architecture }}/{{ .OS }}</span> 844 - <span>{{ .LayerCount }} layers</span> 845 - 846 - <button class="view-btn" 847 - hx-get="/ui/api/manifests/{{ .Digest }}" 848 - hx-target="#modal"> 849 - View 850 - </button> 851 - 852 - {{ if not .Tagged }} 853 - <button class="delete-btn" 854 - hx-delete="/ui/api/images/{{ $.Name }}/manifests/{{ .Digest }}" 855 - hx-confirm="Delete manifest {{ printf "%.12s" .Digest }}...?" 856 - hx-target="#manifest-{{ .Digest }}" 857 - hx-swap="outerHTML"> 858 - Delete 859 - </button> 860 - {{ end }} 861 - </div> 862 - {{ end }} 863 - </div> 864 - </div> 865 - </div> 866 - {{ end }} 867 - {{ else }} 868 - <div class="empty-state"> 869 - <p>No images yet. Push your first image:</p> 870 - <code>docker push atcr.io/{{ .User.Handle }}/myapp:latest</code> 871 - </div> 872 - {{ end }} 873 - </div> 874 - {{ end }} 875 - 876 - {{ define "scripts" }} 877 - <script> 878 - // Toggle repository details 879 - document.querySelectorAll('.repo-header').forEach(header => { 880 - header.addEventListener('click', function() { 881 - const details = this.nextElementSibling; 882 - const btn = this.querySelector('.expand-btn'); 883 - 884 - if (details.style.display === 'none') { 885 - details.style.display = 'block'; 886 - btn.textContent = '▲'; 887 - } else { 888 - details.style.display = 'none'; 889 - btn.textContent = '▼'; 890 - } 891 - }); 892 - }); 893 - </script> 894 - {{ end }} 895 - ``` 896 - 897 - **pkg/appview/handlers/images.go:** 898 - 899 - ```go 900 - package handlers 901 - 902 - import ( 903 - "database/sql" 904 - "html/template" 905 - "net/http" 906 - "atcr.io/pkg/appview/db" 907 - "atcr.io/pkg/atproto" 908 - ) 909 - 910 - type ImagesHandler struct { 911 - DB *sql.DB 912 - Templates *template.Template 913 - ATProtoClient *atproto.Client 914 - } 915 - 916 - func (h *ImagesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 917 - user := getUserFromContext(r) 918 - if user == nil { 919 - http.Redirect(w, r, "/auth/oauth/login?return_to=/ui/images", http.StatusFound) 920 - return 921 - } 922 - 923 - // Fetch repositories from PDS (user's own data) 924 - repos, err := h.ATProtoClient.ListRepositories(user.DID) 925 - if err != nil { 926 - http.Error(w, err.Error(), http.StatusInternalServerError) 927 - return 928 - } 929 - 930 - data := struct { 931 - User *db.User 932 - Repositories []db.Repository 933 - }{ 934 - User: user, 935 - Repositories: repos, 936 - } 937 - 938 - h.Templates.ExecuteTemplate(w, "images.html", data) 939 - } 940 - 941 - func (h *ImagesHandler) DeleteTag(w http.ResponseWriter, r *http.Request) { 942 - user := getUserFromContext(r) 943 - if user == nil { 944 - http.Error(w, "Unauthorized", http.StatusUnauthorized) 945 - return 946 - } 947 - 948 - // Extract repo and tag from URL 949 - vars := mux.Vars(r) 950 - repo := vars["repository"] 951 - tag := vars["tag"] 952 - 953 - // Delete tag record from PDS 954 - err := h.ATProtoClient.DeleteTag(user.DID, repo, tag) 955 - if err != nil { 956 - http.Error(w, err.Error(), http.StatusInternalServerError) 957 - return 958 - } 959 - 960 - // Return empty response (HTMX will swap out the element) 961 - w.WriteHeader(http.StatusOK) 962 - } 963 - 964 - func (h *ImagesHandler) DeleteManifest(w http.ResponseWriter, r *http.Request) { 965 - user := getUserFromContext(r) 966 - if user == nil { 967 - http.Error(w, "Unauthorized", http.StatusUnauthorized) 968 - return 969 - } 970 - 971 - vars := mux.Vars(r) 972 - repo := vars["repository"] 973 - digest := vars["digest"] 974 - 975 - // Check if manifest is tagged 976 - tagged, err := h.ATProtoClient.IsManifestTagged(user.DID, repo, digest) 977 - if err != nil { 978 - http.Error(w, err.Error(), http.StatusInternalServerError) 979 - return 980 - } 981 - 982 - if tagged { 983 - http.Error(w, "Cannot delete tagged manifest", http.StatusBadRequest) 984 - return 985 - } 986 - 987 - // Delete manifest from PDS 988 - err = h.ATProtoClient.DeleteManifest(user.DID, repo, digest) 989 - if err != nil { 990 - http.Error(w, err.Error(), http.StatusInternalServerError) 991 - return 992 - } 993 - 994 - w.WriteHeader(http.StatusOK) 995 - } 996 - ``` 997 - 998 - ## Step 6: Modals & Partials 999 - 1000 - **pkg/appview/templates/components/modal.html:** 1001 - 1002 - ```html 1003 - {{ define "manifest-modal" }} 1004 - <div class="modal-overlay" onclick="this.remove()"> 1005 - <div class="modal-content" onclick="event.stopPropagation()"> 1006 - <button class="modal-close" onclick="this.closest('.modal-overlay').remove()">✕</button> 1007 - 1008 - <h2>Manifest Details</h2> 1009 - 1010 - <div class="manifest-info"> 1011 - <div class="info-row"> 1012 - <strong>Digest:</strong> 1013 - <code>{{ .Digest }}</code> 1014 - </div> 1015 - <div class="info-row"> 1016 - <strong>Media Type:</strong> 1017 - <span>{{ .MediaType }}</span> 1018 - </div> 1019 - <div class="info-row"> 1020 - <strong>Size:</strong> 1021 - <span>{{ .Size | humanizeBytes }}</span> 1022 - </div> 1023 - <div class="info-row"> 1024 - <strong>Architecture:</strong> 1025 - <span>{{ .Architecture }}/{{ .OS }}</span> 1026 - </div> 1027 - <div class="info-row"> 1028 - <strong>Created:</strong> 1029 - <time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}"> 1030 - {{ .CreatedAt.Format "2006-01-02 15:04:05 MST" }} 1031 - </time> 1032 - </div> 1033 - <div class="info-row"> 1034 - <strong>ATProto Record:</strong> 1035 - <a href="at://{{ .DID }}/io.atcr.manifest/{{ .Rkey }}" target="_blank"> 1036 - View on PDS 1037 - </a> 1038 - </div> 1039 - </div> 1040 - 1041 - <h3>Layers</h3> 1042 - <div class="layers-list"> 1043 - {{ range .Layers }} 1044 - <div class="layer-row"> 1045 - <code>{{ .Digest }}</code> 1046 - <span>{{ .Size | humanizeBytes }}</span> 1047 - <span>{{ .MediaType }}</span> 1048 - </div> 1049 - {{ end }} 1050 - </div> 1051 - 1052 - <h3>Raw Manifest</h3> 1053 - <pre class="manifest-json"><code>{{ .RawManifest }}</code></pre> 1054 - </div> 1055 - </div> 1056 - {{ end }} 1057 - ``` 1058 - 1059 - **pkg/appview/templates/partials/edit-tag-modal.html:** 1060 - 1061 - ```html 1062 - <div class="modal-overlay" onclick="this.remove()"> 1063 - <div class="modal-content" onclick="event.stopPropagation()"> 1064 - <button class="modal-close" onclick="this.closest('.modal-overlay').remove()">✕</button> 1065 - 1066 - <h2>Edit Tag: {{ .Tag }}</h2> 1067 - 1068 - <form hx-put="/ui/api/images/{{ .Repository }}/tags/{{ .Tag }}" 1069 - hx-target="#tag-{{ .Repository }}-{{ .Tag }}" 1070 - hx-swap="outerHTML"> 1071 - 1072 - <div class="form-group"> 1073 - <label for="digest">Point to manifest:</label> 1074 - <select name="digest" id="digest" required> 1075 - {{ range .Manifests }} 1076 - <option value="{{ .Digest }}" 1077 - {{ if eq .Digest $.CurrentDigest }}selected{{ end }}> 1078 - {{ printf "%.12s" .Digest }}... ({{ .CreatedAt | timeAgo }}) 1079 - </option> 1080 - {{ end }} 1081 - </select> 1082 - </div> 1083 - 1084 - <button type="submit">Update Tag</button> 1085 - <button type="button" onclick="this.closest('.modal-overlay').remove()">Cancel</button> 1086 - </form> 1087 - </div> 1088 - </div> 1089 - ``` 1090 - 1091 - ## Step 7: Authentication & Session 1092 - 1093 - **pkg/appview/session/session.go:** 1094 - 1095 - ```go 1096 - package session 1097 - 1098 - import ( 1099 - "crypto/rand" 1100 - "encoding/base64" 1101 - "net/http" 1102 - "sync" 1103 - "time" 1104 - ) 1105 - 1106 - type Session struct { 1107 - ID string 1108 - DID string 1109 - Handle string 1110 - ExpiresAt time.Time 1111 - } 1112 - 1113 - type Store struct { 1114 - mu sync.RWMutex 1115 - sessions map[string]*Session 1116 - } 1117 - 1118 - func NewStore() *Store { 1119 - return &Store{ 1120 - sessions: make(map[string]*Session), 1121 - } 1122 - } 1123 - 1124 - func (s *Store) Create(did, handle string, duration time.Duration) (*Session, error) { 1125 - s.mu.Lock() 1126 - defer s.mu.Unlock() 1127 - 1128 - // Generate random session ID 1129 - b := make([]byte, 32) 1130 - if _, err := rand.Read(b); err != nil { 1131 - return nil, err 1132 - } 1133 - 1134 - sess := &Session{ 1135 - ID: base64.URLEncoding.EncodeToString(b), 1136 - DID: did, 1137 - Handle: handle, 1138 - ExpiresAt: time.Now().Add(duration), 1139 - } 1140 - 1141 - s.sessions[sess.ID] = sess 1142 - return sess, nil 1143 - } 1144 - 1145 - func (s *Store) Get(id string) (*Session, bool) { 1146 - s.mu.RLock() 1147 - defer s.mu.RUnlock() 1148 - 1149 - sess, ok := s.sessions[id] 1150 - if !ok || time.Now().After(sess.ExpiresAt) { 1151 - return nil, false 1152 - } 1153 - 1154 - return sess, true 1155 - } 1156 - 1157 - func (s *Store) Delete(id string) { 1158 - s.mu.Lock() 1159 - defer s.mu.Unlock() 1160 - 1161 - delete(s.sessions, id) 1162 - } 1163 - 1164 - func (s *Store) Cleanup() { 1165 - s.mu.Lock() 1166 - defer s.mu.Unlock() 1167 - 1168 - now := time.Now() 1169 - for id, sess := range s.sessions { 1170 - if now.After(sess.ExpiresAt) { 1171 - delete(s.sessions, id) 1172 - } 1173 - } 1174 - } 1175 - 1176 - // SetCookie sets the session cookie 1177 - func SetCookie(w http.ResponseWriter, sessionID string, maxAge int) { 1178 - http.SetCookie(w, &http.Cookie{ 1179 - Name: "atcr_session", 1180 - Value: sessionID, 1181 - Path: "/", 1182 - MaxAge: maxAge, 1183 - HttpOnly: true, 1184 - Secure: true, 1185 - SameSite: http.SameSiteLaxMode, 1186 - }) 1187 - } 1188 - 1189 - // GetSessionID gets session ID from cookie 1190 - func GetSessionID(r *http.Request) (string, bool) { 1191 - cookie, err := r.Cookie("atcr_session") 1192 - if err != nil { 1193 - return "", false 1194 - } 1195 - return cookie.Value, true 1196 - } 1197 - ``` 1198 - 1199 - **pkg/appview/middleware/auth.go:** 1200 - 1201 - ```go 1202 - package middleware 1203 - 1204 - import ( 1205 - "context" 1206 - "net/http" 1207 - "atcr.io/pkg/appview/session" 1208 - "atcr.io/pkg/appview/db" 1209 - ) 1210 - 1211 - type contextKey string 1212 - 1213 - const userKey contextKey = "user" 1214 - 1215 - func RequireAuth(store *session.Store) func(http.Handler) http.Handler { 1216 - return func(next http.Handler) http.Handler { 1217 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1218 - sessionID, ok := session.GetSessionID(r) 1219 - if !ok { 1220 - http.Redirect(w, r, "/auth/oauth/login?return_to="+r.URL.Path, http.StatusFound) 1221 - return 1222 - } 1223 - 1224 - sess, ok := store.Get(sessionID) 1225 - if !ok { 1226 - http.Redirect(w, r, "/auth/oauth/login?return_to="+r.URL.Path, http.StatusFound) 1227 - return 1228 - } 1229 - 1230 - user := &db.User{ 1231 - DID: sess.DID, 1232 - Handle: sess.Handle, 1233 - } 1234 - 1235 - ctx := context.WithValue(r.Context(), userKey, user) 1236 - next.ServeHTTP(w, r.WithContext(ctx)) 1237 - }) 1238 - } 1239 - } 1240 - 1241 - func GetUser(r *http.Request) *db.User { 1242 - user, ok := r.Context().Value(userKey).(*db.User) 1243 - if !ok { 1244 - return nil 1245 - } 1246 - return user 1247 - } 1248 - ``` 1249 - 1250 - ## Step 8: Main Integration 1251 - 1252 - **cmd/appview/main.go (additions):** 1253 - 1254 - ```go 1255 - package main 1256 - 1257 - import ( 1258 - "log" 1259 - "net/http" 1260 - "time" 1261 - 1262 - "github.com/gorilla/mux" 1263 - "atcr.io/pkg/appview" 1264 - "atcr.io/pkg/appview/handlers" 1265 - "atcr.io/pkg/appview/db" 1266 - "atcr.io/pkg/appview/session" 1267 - "atcr.io/pkg/appview/middleware" 1268 - ) 1269 - 1270 - func main() { 1271 - // Initialize database 1272 - database, err := db.InitDB("/var/lib/atcr/ui.db") 1273 - if err != nil { 1274 - log.Fatal(err) 1275 - } 1276 - 1277 - // Initialize session store 1278 - sessionStore := session.NewStore() 1279 - 1280 - // Start cleanup goroutine 1281 - go func() { 1282 - for { 1283 - time.Sleep(5 * time.Minute) 1284 - sessionStore.Cleanup() 1285 - } 1286 - }() 1287 - 1288 - // Load embedded templates 1289 - tmpl, err := appview.Templates() 1290 - if err != nil { 1291 - log.Fatal(err) 1292 - } 1293 - 1294 - // Setup router 1295 - r := mux.NewRouter() 1296 - 1297 - // Static files (embedded) 1298 - r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", appview.StaticHandler())) 1299 - 1300 - // UI routes (public) 1301 - r.Handle("/ui/", &handlers.HomeHandler{ 1302 - DB: database, 1303 - Templates: tmpl, 1304 - }) 1305 - 1306 - // UI routes (authenticated) 1307 - authRouter := r.PathPrefix("/ui").Subrouter() 1308 - authRouter.Use(middleware.RequireAuth(sessionStore)) 1309 - 1310 - authRouter.Handle("/images", &handlers.ImagesHandler{ 1311 - DB: database, 1312 - Templates: tmpl, 1313 - }) 1314 - 1315 - authRouter.Handle("/settings", &handlers.SettingsHandler{ 1316 - Templates: tmpl, 1317 - }) 1318 - 1319 - // API routes 1320 - authRouter.HandleFunc("/api/images/{repository}/tags/{tag}", 1321 - handlers.DeleteTag).Methods("DELETE") 1322 - authRouter.HandleFunc("/api/images/{repository}/manifests/{digest}", 1323 - handlers.DeleteManifest).Methods("DELETE") 1324 - 1325 - // ... rest of your existing routes 1326 - 1327 - log.Println("Server starting on :5000") 1328 - http.ListenAndServe(":5000", r) 1329 - } 1330 - ``` 1331 - 1332 - ## Step 9: Styling (Basic CSS) 1333 - 1334 - **pkg/appview/static/css/style.css:** 1335 - 1336 - ```css 1337 - :root { 1338 - --primary: #0066cc; 1339 - --bg: #ffffff; 1340 - --fg: #1a1a1a; 1341 - --border: #e0e0e0; 1342 - --code-bg: #f5f5f5; 1343 - } 1344 - 1345 - * { 1346 - margin: 0; 1347 - padding: 0; 1348 - box-sizing: border-box; 1349 - } 1350 - 1351 - body { 1352 - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; 1353 - background: var(--bg); 1354 - color: var(--fg); 1355 - line-height: 1.6; 1356 - } 1357 - 1358 - .container { 1359 - max-width: 1200px; 1360 - margin: 0 auto; 1361 - padding: 20px; 1362 - } 1363 - 1364 - /* Navigation */ 1365 - .navbar { 1366 - background: var(--fg); 1367 - color: white; 1368 - padding: 1rem 2rem; 1369 - display: flex; 1370 - justify-content: space-between; 1371 - align-items: center; 1372 - } 1373 - 1374 - .nav-brand a { 1375 - color: white; 1376 - text-decoration: none; 1377 - font-size: 1.5rem; 1378 - font-weight: bold; 1379 - } 1380 - 1381 - .nav-links { 1382 - display: flex; 1383 - gap: 1rem; 1384 - align-items: center; 1385 - } 1386 - 1387 - .nav-links a { 1388 - color: white; 1389 - text-decoration: none; 1390 - } 1391 - 1392 - /* Push Cards */ 1393 - .push-card { 1394 - border: 1px solid var(--border); 1395 - border-radius: 8px; 1396 - padding: 1rem; 1397 - margin-bottom: 1rem; 1398 - background: white; 1399 - } 1400 - 1401 - .push-header { 1402 - font-size: 1.1rem; 1403 - margin-bottom: 0.5rem; 1404 - } 1405 - 1406 - .push-user { 1407 - color: var(--primary); 1408 - text-decoration: none; 1409 - } 1410 - 1411 - .push-command { 1412 - display: flex; 1413 - gap: 0.5rem; 1414 - align-items: center; 1415 - margin-top: 0.5rem; 1416 - padding: 0.5rem; 1417 - background: var(--code-bg); 1418 - border-radius: 4px; 1419 - } 1420 - 1421 - .pull-command { 1422 - flex: 1; 1423 - font-family: 'Monaco', 'Courier New', monospace; 1424 - font-size: 0.9rem; 1425 - } 1426 - 1427 - .copy-btn { 1428 - padding: 0.25rem 0.5rem; 1429 - background: var(--primary); 1430 - color: white; 1431 - border: none; 1432 - border-radius: 4px; 1433 - cursor: pointer; 1434 - } 1435 - 1436 - /* Repository Cards */ 1437 - .repository-card { 1438 - border: 1px solid var(--border); 1439 - border-radius: 8px; 1440 - margin-bottom: 1rem; 1441 - background: white; 1442 - } 1443 - 1444 - .repo-header { 1445 - padding: 1rem; 1446 - cursor: pointer; 1447 - display: flex; 1448 - justify-content: space-between; 1449 - align-items: center; 1450 - background: #f9f9f9; 1451 - border-radius: 8px 8px 0 0; 1452 - } 1453 - 1454 - .repo-header:hover { 1455 - background: #f0f0f0; 1456 - } 1457 - 1458 - .repo-details { 1459 - padding: 1rem; 1460 - } 1461 - 1462 - .tag-row, .manifest-row { 1463 - display: flex; 1464 - gap: 1rem; 1465 - align-items: center; 1466 - padding: 0.5rem; 1467 - border-bottom: 1px solid var(--border); 1468 - } 1469 - 1470 - .tag-row:last-child, .manifest-row:last-child { 1471 - border-bottom: none; 1472 - } 1473 - 1474 - /* Modal */ 1475 - .modal-overlay { 1476 - position: fixed; 1477 - top: 0; 1478 - left: 0; 1479 - right: 0; 1480 - bottom: 0; 1481 - background: rgba(0, 0, 0, 0.5); 1482 - display: flex; 1483 - justify-content: center; 1484 - align-items: center; 1485 - z-index: 1000; 1486 - } 1487 - 1488 - .modal-content { 1489 - background: white; 1490 - padding: 2rem; 1491 - border-radius: 8px; 1492 - max-width: 800px; 1493 - max-height: 80vh; 1494 - overflow-y: auto; 1495 - position: relative; 1496 - } 1497 - 1498 - .modal-close { 1499 - position: absolute; 1500 - top: 1rem; 1501 - right: 1rem; 1502 - background: none; 1503 - border: none; 1504 - font-size: 1.5rem; 1505 - cursor: pointer; 1506 - } 1507 - 1508 - .manifest-json { 1509 - background: var(--code-bg); 1510 - padding: 1rem; 1511 - border-radius: 4px; 1512 - overflow-x: auto; 1513 - font-family: 'Monaco', 'Courier New', monospace; 1514 - font-size: 0.85rem; 1515 - } 1516 - 1517 - /* Buttons */ 1518 - button, .btn { 1519 - padding: 0.5rem 1rem; 1520 - background: var(--primary); 1521 - color: white; 1522 - border: none; 1523 - border-radius: 4px; 1524 - cursor: pointer; 1525 - text-decoration: none; 1526 - display: inline-block; 1527 - } 1528 - 1529 - button:hover, .btn:hover { 1530 - opacity: 0.9; 1531 - } 1532 - 1533 - .delete-btn { 1534 - background: #dc3545; 1535 - } 1536 - 1537 - /* Loading state */ 1538 - .loading { 1539 - text-align: center; 1540 - padding: 2rem; 1541 - color: #666; 1542 - } 1543 - 1544 - /* Forms */ 1545 - .form-group { 1546 - margin-bottom: 1rem; 1547 - } 1548 - 1549 - .form-group label { 1550 - display: block; 1551 - margin-bottom: 0.5rem; 1552 - font-weight: 500; 1553 - } 1554 - 1555 - .form-group input, 1556 - .form-group select { 1557 - width: 100%; 1558 - padding: 0.5rem; 1559 - border: 1px solid var(--border); 1560 - border-radius: 4px; 1561 - font-size: 1rem; 1562 - } 1563 - ``` 1564 - 1565 - ## Step 10: Helper Functions 1566 - 1567 - **pkg/appview/static/js/app.js:** 1568 - 1569 - ```javascript 1570 - // Copy to clipboard 1571 - function copyToClipboard(text) { 1572 - navigator.clipboard.writeText(text).then(() => { 1573 - // Show success feedback 1574 - const btn = event.target; 1575 - const originalText = btn.textContent; 1576 - btn.textContent = '✓ Copied!'; 1577 - setTimeout(() => { 1578 - btn.textContent = originalText; 1579 - }, 2000); 1580 - }); 1581 - } 1582 - 1583 - // Time ago helper (for client-side rendering) 1584 - function timeAgo(date) { 1585 - const seconds = Math.floor((new Date() - new Date(date)) / 1000); 1586 - 1587 - const intervals = { 1588 - year: 31536000, 1589 - month: 2592000, 1590 - week: 604800, 1591 - day: 86400, 1592 - hour: 3600, 1593 - minute: 60, 1594 - second: 1 1595 - }; 1596 - 1597 - for (const [name, secondsInInterval] of Object.entries(intervals)) { 1598 - const interval = Math.floor(seconds / secondsInInterval); 1599 - if (interval >= 1) { 1600 - return interval === 1 ? `1 ${name} ago` : `${interval} ${name}s ago`; 1601 - } 1602 - } 1603 - 1604 - return 'just now'; 1605 - } 1606 - 1607 - // Update timestamps on page load 1608 - document.addEventListener('DOMContentLoaded', () => { 1609 - document.querySelectorAll('time[datetime]').forEach(el => { 1610 - const date = el.getAttribute('datetime'); 1611 - el.textContent = timeAgo(date); 1612 - }); 1613 - }); 1614 - ``` 1615 - 1616 - **Template helper functions (in Go):** 1617 - 1618 - ```go 1619 - // Add to your template loading 1620 - funcMap := template.FuncMap{ 1621 - "timeAgo": func(t time.Time) string { 1622 - duration := time.Since(t) 1623 - 1624 - if duration < time.Minute { 1625 - return "just now" 1626 - } else if duration < time.Hour { 1627 - mins := int(duration.Minutes()) 1628 - if mins == 1 { 1629 - return "1 minute ago" 1630 - } 1631 - return fmt.Sprintf("%d minutes ago", mins) 1632 - } else if duration < 24*time.Hour { 1633 - hours := int(duration.Hours()) 1634 - if hours == 1 { 1635 - return "1 hour ago" 1636 - } 1637 - return fmt.Sprintf("%d hours ago", hours) 1638 - } else { 1639 - days := int(duration.Hours() / 24) 1640 - if days == 1 { 1641 - return "1 day ago" 1642 - } 1643 - return fmt.Sprintf("%d days ago", days) 1644 - } 1645 - }, 1646 - 1647 - "humanizeBytes": func(bytes int64) string { 1648 - const unit = 1024 1649 - if bytes < unit { 1650 - return fmt.Sprintf("%d B", bytes) 1651 - } 1652 - div, exp := int64(unit), 0 1653 - for n := bytes / unit; n >= unit; n /= unit { 1654 - div *= unit 1655 - exp++ 1656 - } 1657 - return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) 1658 - }, 1659 - } 1660 - 1661 - tmpl := template.New("").Funcs(funcMap) 1662 - tmpl = template.Must(tmpl.ParseGlob("web/templates/**/*.html")) 1663 - ``` 1664 - 1665 - ## Implementation Checklist 1666 - 1667 - ### Phase 1: Foundation 1668 - - [ ] Set up project structure 1669 - - [ ] Initialize SQLite database with schema 1670 - - [ ] Create data models and query functions 1671 - - [ ] Write database tests 1672 - 1673 - ### Phase 2: Templates 1674 - - [ ] Create base layout template 1675 - - [ ] Create navigation component 1676 - - [ ] Create home page template 1677 - - [ ] Create settings page template 1678 - - [ ] Create images page template 1679 - - [ ] Create modal templates 1680 - 1681 - ### Phase 3: Handlers 1682 - - [ ] Implement home handler (firehose display) 1683 - - [ ] Implement settings handler (profile + holds) 1684 - - [ ] Implement images handler (repository list) 1685 - - [ ] Implement API endpoints (delete tag, delete manifest) 1686 - - [ ] Add HTMX partial responses 1687 - 1688 - ### Phase 4: Authentication 1689 - - [ ] Implement session store 1690 - - [ ] Create auth middleware 1691 - - [ ] Wire up OAuth login (reuse existing) 1692 - - [ ] Add logout functionality 1693 - - [ ] Test auth flow 1694 - 1695 - ### Phase 5: Firehose Worker 1696 - - [ ] Implement Jetstream client 1697 - - [ ] Create firehose worker 1698 - - [ ] Add event handlers (manifest, tag) 1699 - - [ ] Test with real firehose 1700 - - [ ] Add cursor persistence 1701 - 1702 - ### Phase 6: Polish 1703 - - [ ] Add CSS styling 1704 - - [ ] Implement copy-to-clipboard 1705 - - [ ] Add loading states 1706 - - [ ] Error handling and user feedback 1707 - - [ ] Responsive design 1708 - - [ ] CSRF protection 1709 - 1710 - ### Phase 7: Testing 1711 - - [ ] Unit tests for handlers 1712 - - [ ] Database query tests 1713 - - [ ] Integration tests (full flow) 1714 - - [ ] Manual testing with real data 1715 - 1716 - ## Performance Optimizations 1717 - 1718 - ### HTMX Optimizations 1719 - 1. **Prefetching:** Add `hx-trigger="mouseenter"` to links for hover prefetch 1720 - 2. **Caching:** Use `hx-cache="true"` for cacheable content 1721 - 3. **Optimistic updates:** Remove elements immediately, rollback on error 1722 - 4. **Debouncing:** Add `delay:500ms` to search inputs 1723 - 1724 - ### Database Optimizations 1725 - 1. **Indexes:** Already defined in schema (did, repo, created_at, digest) 1726 - 2. **Connection pooling:** Use `db.SetMaxOpenConns(25)` 1727 - 3. **Prepared statements:** Cache frequently used queries 1728 - 4. **Batch inserts:** For firehose events, batch into transactions 1729 - 1730 - ### Template Optimizations 1731 - 1. **Pre-parse:** Parse templates once at startup, not per request 1732 - 2. **Caching:** Cache rendered partials for static content 1733 - 3. **Minification:** Minify HTML/CSS/JS in production 1734 - 1735 - ## Security Checklist 1736 - 1737 - - [ ] Session cookies: Secure, HttpOnly, SameSite=Lax 1738 - - [ ] CSRF tokens for mutations (POST/DELETE) 1739 - - [ ] Input validation (sanitize search, filters) 1740 - - [ ] Rate limiting on API endpoints 1741 - - [ ] SQL injection protection (parameterized queries) 1742 - - [ ] Authorization checks (user owns resource) 1743 - - [ ] XSS protection (escape template output) 1744 - 1745 - ## Deployment 1746 - 1747 - ### Development 1748 - ```bash 1749 - # Run migrations 1750 - go run cmd/appview/main.go migrate 1751 - 1752 - # Start server 1753 - go run cmd/appview/main.go serve 1754 - ``` 1755 - 1756 - ### Production 1757 - ```bash 1758 - # Build binary 1759 - go build -o bin/atcr-appview ./cmd/appview 1760 - 1761 - # Run with config 1762 - ./bin/atcr-appview serve config/production.yml 1763 - ``` 1764 - 1765 - ### Environment Variables 1766 - ```bash 1767 - UI_ENABLED=true 1768 - UI_DATABASE_PATH=/var/lib/atcr/ui.db 1769 - UI_FIREHOSE_ENDPOINT=wss://jetstream.atproto.tools/subscribe 1770 - UI_SESSION_DURATION=24h 1771 - ``` 1772 - 1773 - ## Next Steps After V1 1774 - 1775 - 1. **Add search:** Implement full-text search on SQLite 1776 - 2. **Public profiles:** `/ui/@alice` shows public view 1777 - 3. **Manifest diff:** Compare manifest versions 1778 - 4. **Export data:** Download all your images as JSON 1779 - 5. **Webhook notifications:** Alert on new pushes 1780 - 6. **CLI integration:** `atcr ui open` to launch browser 1781 - 1782 - --- 1783 - 1784 - ## Key Benefits of This Approach 1785 - 1786 - ### Single Binary Deployment 1787 - - All templates and static files embedded with `//go:embed` 1788 - - No need to ship separate `web/` directory 1789 - - Single `atcr-appview` binary contains everything 1790 - - Easy deployment: just copy one file 1791 - 1792 - ### Package Structure 1793 - - `pkg/appview` makes sense semantically (it's the AppView, not just UI) 1794 - - Contains both backend (db, firehose) and frontend (templates, handlers) 1795 - - Clear separation from core OCI registry logic 1796 - - Easy to test and develop independently 1797 - 1798 - ### Embedded Assets 1799 - ```go 1800 - // pkg/appview/appview.go 1801 - //go:embed templates/*.html templates/**/*.html 1802 - var templatesFS embed.FS 1803 - 1804 - //go:embed static/* 1805 - var staticFS embed.FS 1806 - ``` 1807 - 1808 - **Build:** 1809 - ```bash 1810 - go build -o bin/atcr-appview ./cmd/appview 1811 - ``` 1812 - 1813 - **Deploy:** 1814 - ```bash 1815 - scp bin/atcr-appview server:/usr/local/bin/ 1816 - # Done! No webpack, no node_modules, no separate assets folder 1817 - ``` 1818 - 1819 - ### Development Workflow 1820 - 1. Edit templates in `pkg/appview/templates/` 1821 - 2. Edit CSS/JS in `pkg/appview/static/` 1822 - 3. Run `go build` - assets auto-embedded 1823 - 4. No build tools, no npm, just Go 1824 - 1825 - --- 1826 - 1827 - This guide provides a complete implementation path for ATCR AppView UI using html/template + HTMX with embedded assets. Start with Phase 1 (embed setup + database) and work your way through each phase sequentially.
-631
docs/APPVIEW-UI-V1.md
··· 1 - # ATCR AppView UI - Version 1 Specification 2 - 3 - ## Overview 4 - 5 - The ATCR AppView UI provides a web interface for discovering, managing, and configuring container images in the ATCR registry. Version 1 focuses on three core pages that leverage existing functionality: 6 - 7 - 1. **Front Page** - Distributed image discovery via firehose 8 - 2. **Settings Page** - Profile and hold configuration 9 - 3. **Personal Page** - Manage your images and tags 10 - 11 - ## Architecture 12 - 13 - ### Tech Stack 14 - 15 - - **Backend:** Go (existing AppView codebase) 16 - - **Frontend:** TBD (Go templates/Templ or separate SPA) 17 - - **Database:** SQLite (firehose data cache) 18 - - **Styling:** TBD (plain CSS, Tailwind, etc.) 19 - - **Authentication:** ATProto OAuth (DPoP handled by indigo library) 20 - 21 - ### Components 22 - 23 - ``` 24 - ┌─────────────────────────────────────────────────────────────┐ 25 - │ Web UI (Browser) │ 26 - └─────────────────────────────────────────────────────────────┘ 27 - 28 - 29 - ┌─────────────────────────────────────────────────────────────┐ 30 - │ AppView HTTP Server │ 31 - │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ 32 - │ │ UI Endpoints │ │ OCI API │ │ OAuth Server │ │ 33 - │ │ /ui/* │ │ /v2/* │ │ /auth/* │ │ 34 - │ └──────────────┘ └──────────────┘ └──────────────┘ │ 35 - └─────────────────────────────────────────────────────────────┘ 36 - 37 - ┌─────────┴─────────┐ 38 - ▼ ▼ 39 - ┌──────────────────┐ ┌──────────────────┐ 40 - │ SQLite Database │ │ ATProto Client │ 41 - │ (Firehose cache) │ │ (PDS operations) │ 42 - └──────────────────┘ └──────────────────┘ 43 - 44 - ┌──────────────────┐ │ 45 - │ Firehose Worker │───────────┘ 46 - │ (Background) │ 47 - └──────────────────┘ 48 - 49 - 50 - ┌──────────────────┐ 51 - │ ATProto Firehose │ 52 - │ (Jetstream/Relay)│ 53 - └──────────────────┘ 54 - ``` 55 - 56 - ## Database Schema 57 - 58 - SQLite database for caching firehose data and enabling fast queries. 59 - 60 - ### Tables 61 - 62 - **users** 63 - ```sql 64 - CREATE TABLE users ( 65 - did TEXT PRIMARY KEY, 66 - handle TEXT NOT NULL, 67 - pds_endpoint TEXT NOT NULL, 68 - last_seen TIMESTAMP NOT NULL, 69 - UNIQUE(handle) 70 - ); 71 - CREATE INDEX idx_users_handle ON users(handle); 72 - ``` 73 - 74 - **manifests** 75 - ```sql 76 - CREATE TABLE manifests ( 77 - id INTEGER PRIMARY KEY AUTOINCREMENT, 78 - did TEXT NOT NULL, 79 - repository TEXT NOT NULL, 80 - digest TEXT NOT NULL, 81 - hold_endpoint TEXT NOT NULL, 82 - schema_version INTEGER NOT NULL, 83 - media_type TEXT NOT NULL, 84 - config_digest TEXT, 85 - config_size INTEGER, 86 - raw_manifest TEXT NOT NULL, -- JSON blob 87 - created_at TIMESTAMP NOT NULL, 88 - UNIQUE(did, repository, digest), 89 - FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 90 - ); 91 - CREATE INDEX idx_manifests_did_repo ON manifests(did, repository); 92 - CREATE INDEX idx_manifests_created_at ON manifests(created_at DESC); 93 - CREATE INDEX idx_manifests_digest ON manifests(digest); 94 - ``` 95 - 96 - **layers** 97 - ```sql 98 - CREATE TABLE layers ( 99 - manifest_id INTEGER NOT NULL, 100 - digest TEXT NOT NULL, 101 - size INTEGER NOT NULL, 102 - media_type TEXT NOT NULL, 103 - layer_index INTEGER NOT NULL, 104 - PRIMARY KEY(manifest_id, layer_index), 105 - FOREIGN KEY(manifest_id) REFERENCES manifests(id) ON DELETE CASCADE 106 - ); 107 - CREATE INDEX idx_layers_digest ON layers(digest); 108 - ``` 109 - 110 - **tags** 111 - ```sql 112 - CREATE TABLE tags ( 113 - id INTEGER PRIMARY KEY AUTOINCREMENT, 114 - did TEXT NOT NULL, 115 - repository TEXT NOT NULL, 116 - tag TEXT NOT NULL, 117 - digest TEXT NOT NULL, 118 - created_at TIMESTAMP NOT NULL, 119 - UNIQUE(did, repository, tag), 120 - FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 121 - ); 122 - CREATE INDEX idx_tags_did_repo ON tags(did, repository); 123 - ``` 124 - 125 - **firehose_cursor** 126 - ```sql 127 - CREATE TABLE firehose_cursor ( 128 - id INTEGER PRIMARY KEY CHECK (id = 1), 129 - cursor INTEGER NOT NULL, 130 - updated_at TIMESTAMP NOT NULL 131 - ); 132 - ``` 133 - 134 - ## Firehose Worker 135 - 136 - Background goroutine that subscribes to ATProto firehose and populates the database. 137 - 138 - ### Implementation 139 - 140 - ```go 141 - // pkg/ui/firehose/worker.go 142 - 143 - type Worker struct { 144 - db *sql.DB 145 - jetstream *JetstreamClient 146 - resolver *atproto.Resolver 147 - stopCh chan struct{} 148 - } 149 - 150 - func (w *Worker) Start() error { 151 - // Load cursor from database 152 - cursor := w.loadCursor() 153 - 154 - // Subscribe to firehose 155 - events := w.jetstream.Subscribe(cursor, []string{ 156 - "io.atcr.manifest", 157 - "io.atcr.tag", 158 - }) 159 - 160 - for { 161 - select { 162 - case event := <-events: 163 - w.handleEvent(event) 164 - case <-w.stopCh: 165 - return nil 166 - } 167 - } 168 - } 169 - 170 - func (w *Worker) handleEvent(event FirehoseEvent) error { 171 - switch event.Collection { 172 - case "io.atcr.manifest": 173 - return w.handleManifest(event) 174 - case "io.atcr.tag": 175 - return w.handleTag(event) 176 - } 177 - return nil 178 - } 179 - ``` 180 - 181 - ### Event Handling 182 - 183 - **Manifest create:** 184 - - Resolve DID → handle, PDS endpoint 185 - - Insert/update user record 186 - - Parse manifest JSON 187 - - Insert manifest record 188 - - Insert layer records 189 - 190 - **Tag create/update:** 191 - - Insert/update tag record 192 - - Link to existing manifest 193 - 194 - **Record deletion:** 195 - - Delete from database (cascade handles related records) 196 - 197 - ### Firehose Connection 198 - 199 - Use Jetstream (bluesky-social/jetstream) or connect directly to relay: 200 - - **Jetstream:** Websocket to `wss://jetstream.atproto.tools/subscribe` 201 - - **Relay:** Websocket to relay (e.g., `wss://bsky.network/xrpc/com.atproto.sync.subscribeRepos`) 202 - 203 - Jetstream is simpler and filters events server-side. 204 - 205 - ## Page Specifications 206 - 207 - ### 1. Front Page - Distributed Discovery 208 - 209 - **URL:** `/ui/` or `/ui/explore` 210 - 211 - **Purpose:** Discover recently pushed images across all ATCR users. 212 - 213 - **Layout:** 214 - ``` 215 - ┌─────────────────────────────────────────────────────────────┐ 216 - │ ATCR [Search] [@handle] [Login] │ 217 - ├─────────────────────────────────────────────────────────────┤ 218 - │ Recent Pushes [Filter ▼]│ 219 - │ │ 220 - │ ┌───────────────────────────────────────────────────────┐ │ 221 - │ │ alice.bsky.social/nginx:latest │ │ 222 - │ │ sha256:abc123... • hold1.alice.com • 2 hours ago │ │ 223 - │ │ [docker pull atcr.io/alice.bsky.social/nginx:latest] │ │ 224 - │ └───────────────────────────────────────────────────────┘ │ 225 - │ │ 226 - │ ┌───────────────────────────────────────────────────────┐ │ 227 - │ │ bob.dev/myapp:v1.2.3 │ │ 228 - │ │ sha256:def456... • atcr-storage.fly.dev • 5 hours ago │ │ 229 - │ │ [docker pull atcr.io/bob.dev/myapp:v1.2.3] │ │ 230 - │ └───────────────────────────────────────────────────────┘ │ 231 - │ │ 232 - │ [Load more...] │ 233 - └─────────────────────────────────────────────────────────────┘ 234 - ``` 235 - 236 - **Features:** 237 - - List of recent pushes (manifests + tags) 238 - - Show: handle, repository, tag, digest (truncated), timestamp, hold endpoint 239 - - Copy-paste pull command with click-to-copy 240 - - Filter by user (click handle to filter) 241 - - Search by repository name or tag 242 - - Click manifest to view details (modal or dedicated page) 243 - - Pagination (50 items per page) 244 - 245 - **API Endpoint:** 246 - ``` 247 - GET /ui/api/recent-pushes 248 - Query params: 249 - - limit (default: 50) 250 - - offset (default: 0) 251 - - user (optional: filter by DID or handle) 252 - - repository (optional: filter by repo name) 253 - 254 - Response: 255 - { 256 - "pushes": [ 257 - { 258 - "did": "did:plc:alice123", 259 - "handle": "alice.bsky.social", 260 - "repository": "nginx", 261 - "tag": "latest", 262 - "digest": "sha256:abc123...", 263 - "hold_endpoint": "https://hold1.alice.com", 264 - "created_at": "2025-10-05T12:34:56Z", 265 - "pull_command": "docker pull atcr.io/alice.bsky.social/nginx:latest" 266 - } 267 - ], 268 - "total": 1234, 269 - "offset": 0, 270 - "limit": 50 271 - } 272 - ``` 273 - 274 - **Manifest Details Modal:** 275 - - Full manifest JSON (syntax highlighted) 276 - - Layer list with digests and sizes 277 - - Link to ATProto record (at://did/io.atcr.manifest/rkey) 278 - - Architecture, OS, labels 279 - - Creation timestamp 280 - 281 - ### 2. Settings Page 282 - 283 - **URL:** `/ui/settings` 284 - 285 - **Auth:** Requires login (OAuth) 286 - 287 - **Purpose:** Configure profile and hold preferences. 288 - 289 - **Layout:** 290 - ``` 291 - ┌─────────────────────────────────────────────────────────────┐ 292 - │ ATCR [@alice] [⚙️] │ 293 - ├─────────────────────────────────────────────────────────────┤ 294 - │ Settings │ 295 - │ │ 296 - │ ┌─ Identity ───────────────────────────────────────────┐ │ 297 - │ │ Handle: alice.bsky.social │ │ 298 - │ │ DID: did:plc:alice123abc (read-only) │ │ 299 - │ │ PDS: https://bsky.social (read-only) │ │ 300 - │ └───────────────────────────────────────────────────────┘ │ 301 - │ │ 302 - │ ┌─ Default Hold ──────────────────────────────────────┐ │ 303 - │ │ Current: https://hold1.alice.com │ │ 304 - │ │ │ │ 305 - │ │ [Dropdown: Select from your holds ▼] │ │ 306 - │ │ • https://hold1.alice.com (Your BYOS) │ │ 307 - │ │ • https://storage.atcr.io (AppView default) │ │ 308 - │ │ • [Custom URL...] │ │ 309 - │ │ │ │ 310 - │ │ Custom hold URL: [_____________________] │ │ 311 - │ │ │ │ 312 - │ │ [Save] │ │ 313 - │ └───────────────────────────────────────────────────────┘ │ 314 - │ │ 315 - │ ┌─ OAuth Session ─────────────────────────────────────┐ │ 316 - │ │ Logged in as: alice.bsky.social │ │ 317 - │ │ Session expires: 2025-10-06 14:23:00 UTC │ │ 318 - │ │ [Re-authenticate] │ │ 319 - │ └───────────────────────────────────────────────────────┘ │ 320 - └─────────────────────────────────────────────────────────────┘ 321 - ``` 322 - 323 - **Features:** 324 - - Display current identity (handle, DID, PDS) 325 - - Default hold configuration: 326 - - Dropdown showing user's `io.atcr.hold` records (query from PDS) 327 - - Option to select AppView's default storage endpoint 328 - - Manual entry for custom hold URL 329 - - "Save" button updates `io.atcr.sailor.profile.defaultHold` 330 - - OAuth session status 331 - - Re-authenticate button (redirects to OAuth flow) 332 - 333 - **API Endpoints:** 334 - 335 - ``` 336 - GET /ui/api/profile 337 - Auth: Required (session cookie) 338 - Response: 339 - { 340 - "did": "did:plc:alice123", 341 - "handle": "alice.bsky.social", 342 - "pds_endpoint": "https://bsky.social", 343 - "default_hold": "https://hold1.alice.com", 344 - "holds": [ 345 - { 346 - "endpoint": "https://hold1.alice.com", 347 - "name": "My BYOS Storage", 348 - "public": false 349 - } 350 - ], 351 - "session_expires_at": "2025-10-06T14:23:00Z" 352 - } 353 - 354 - POST /ui/api/profile/default-hold 355 - Auth: Required 356 - Body: 357 - { 358 - "hold_endpoint": "https://hold1.alice.com" 359 - } 360 - Response: 361 - { 362 - "success": true 363 - } 364 - ``` 365 - 366 - ### 3. Personal Page - Your Images 367 - 368 - **URL:** `/ui/images` or `/ui/@{handle}` 369 - 370 - **Auth:** Requires login (OAuth) 371 - 372 - **Purpose:** Manage your container images and tags. 373 - 374 - **Layout:** 375 - ``` 376 - ┌─────────────────────────────────────────────────────────────┐ 377 - │ ATCR [@alice] [⚙️] │ 378 - ├─────────────────────────────────────────────────────────────┤ 379 - │ Your Images │ 380 - │ │ 381 - │ ┌─ nginx ──────────────────────────────────────────────┐ │ 382 - │ │ 3 tags • 5 manifests • Last push: 2 hours ago │ │ 383 - │ │ │ │ 384 - │ │ Tags: │ │ 385 - │ │ ┌────────────────────────────────────────────────┐ │ │ 386 - │ │ │ latest → sha256:abc123... (2 hours ago) [✏️][🗑️]│ │ │ 387 - │ │ │ v1.25 → sha256:def456... (1 day ago) [✏️][🗑️]│ │ │ 388 - │ │ │ alpine → sha256:ghi789... (3 days ago) [✏️][🗑️]│ │ │ 389 - │ │ └────────────────────────────────────────────────┘ │ │ 390 - │ │ │ │ 391 - │ │ Manifests: │ │ 392 - │ │ ┌────────────────────────────────────────────────┐ │ │ 393 - │ │ │ sha256:abc123... • 45MB • hold1.alice.com │ │ │ 394 - │ │ │ linux/amd64 • 5 layers • [View] [Delete] │ │ │ 395 - │ │ │ sha256:def456... • 42MB • hold1.alice.com │ │ │ 396 - │ │ │ linux/amd64 • 5 layers • [View] [Delete] │ │ │ 397 - │ │ └────────────────────────────────────────────────┘ │ │ 398 - │ └───────────────────────────────────────────────────────┘ │ 399 - │ │ 400 - │ ┌─ myapp ──────────────────────────────────────────────┐ │ 401 - │ │ 2 tags • 2 manifests • Last push: 1 day ago │ │ 402 - │ │ [Expand ▼] │ │ 403 - │ └───────────────────────────────────────────────────────┘ │ 404 - └─────────────────────────────────────────────────────────────┘ 405 - ``` 406 - 407 - **Features:** 408 - 409 - **Repository List:** 410 - - Group manifests by repository name 411 - - Show: tag count, manifest count, last push time 412 - - Collapsible/expandable repository cards 413 - 414 - **Repository Details (Expanded):** 415 - - **Tags:** Table showing tag → manifest digest → timestamp 416 - - Edit tag: Modal to re-point tag to different manifest digest 417 - - Delete tag: Confirm dialog, removes `io.atcr.tag` record from PDS 418 - - **Manifests:** List of all manifests in repository 419 - - Show: digest (truncated), size, hold endpoint, architecture, layer count 420 - - View: Open manifest details modal (same as front page) 421 - - Delete: Confirm dialog with warning if manifest is tagged 422 - 423 - **Actions:** 424 - - Copy pull command for each tag 425 - - Edit tag (re-point to different digest) 426 - - Delete tag 427 - - Delete manifest (with validation) 428 - 429 - **API Endpoints:** 430 - 431 - ``` 432 - GET /ui/api/images 433 - Auth: Required 434 - Response: 435 - { 436 - "repositories": [ 437 - { 438 - "name": "nginx", 439 - "tag_count": 3, 440 - "manifest_count": 5, 441 - "last_push": "2025-10-05T10:23:45Z", 442 - "tags": [ 443 - { 444 - "tag": "latest", 445 - "digest": "sha256:abc123...", 446 - "created_at": "2025-10-05T10:23:45Z" 447 - } 448 - ], 449 - "manifests": [ 450 - { 451 - "digest": "sha256:abc123...", 452 - "size": 47185920, 453 - "hold_endpoint": "https://hold1.alice.com", 454 - "architecture": "amd64", 455 - "os": "linux", 456 - "layer_count": 5, 457 - "created_at": "2025-10-05T10:23:45Z", 458 - "tagged": true 459 - } 460 - ] 461 - } 462 - ] 463 - } 464 - 465 - PUT /ui/api/images/{repository}/tags/{tag} 466 - Auth: Required 467 - Body: 468 - { 469 - "digest": "sha256:new-digest..." 470 - } 471 - Response: 472 - { 473 - "success": true 474 - } 475 - 476 - DELETE /ui/api/images/{repository}/tags/{tag} 477 - Auth: Required 478 - Response: 479 - { 480 - "success": true 481 - } 482 - 483 - DELETE /ui/api/images/{repository}/manifests/{digest} 484 - Auth: Required 485 - Response: 486 - { 487 - "success": true 488 - } 489 - ``` 490 - 491 - ## Authentication 492 - 493 - ### OAuth Login Flow 494 - 495 - Reuse existing OAuth implementation from credential helper and AppView. 496 - 497 - **Login Endpoint:** `/auth/oauth/login` 498 - 499 - **Flow:** 500 - 1. User clicks "Login" on UI 501 - 2. Redirects to `/auth/oauth/login?return_to=/ui/images` 502 - 3. User enters handle (e.g., "alice.bsky.social") 503 - 4. Server resolves handle → DID → PDS → OAuth server 504 - 5. Server initiates ATProto OAuth flow with PAR (DPoP handled by indigo library) 505 - 6. User redirected to PDS for authorization 506 - 7. OAuth callback to `/auth/oauth/callback` 507 - 8. Server exchanges code for token, validates with PDS 508 - 9. Server creates session cookie (secure, httpOnly, SameSite) 509 - 10. Redirects to `return_to` URL or default `/ui/images` 510 - 511 - **Session Management:** 512 - - Session cookie: `atcr_session` (JWT or opaque token) 513 - - Session storage: In-memory map or SQLite table 514 - - Session duration: 24 hours (or match OAuth token expiry) 515 - - Refresh: Auto-refresh OAuth token when needed 516 - 517 - **Middleware:** 518 - ```go 519 - // pkg/ui/middleware/auth.go 520 - 521 - func RequireAuth(next http.Handler) http.Handler { 522 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 523 - session := getSession(r) 524 - if session == nil { 525 - http.Redirect(w, r, "/auth/oauth/login?return_to="+r.URL.Path, http.StatusFound) 526 - return 527 - } 528 - 529 - // Add session info to context 530 - ctx := context.WithValue(r.Context(), "session", session) 531 - next.ServeHTTP(w, r.WithContext(ctx)) 532 - }) 533 - } 534 - ``` 535 - 536 - ## Implementation Roadmap 537 - 538 - ### Phase 1: Database & Firehose 539 - 1. Define SQLite schema 540 - 2. Implement database layer (pkg/ui/db/) 541 - 3. Implement firehose worker (pkg/ui/firehose/) 542 - 4. Test worker with real firehose 543 - 544 - ### Phase 2: API Endpoints 545 - 1. Implement `/ui/api/recent-pushes` (front page data) 546 - 2. Implement `/ui/api/profile` (settings page data) 547 - 3. Implement `/ui/api/images` (personal page data) 548 - 4. Implement tag/manifest mutation endpoints 549 - 550 - ### Phase 3: Authentication 551 - 1. Implement OAuth login endpoint 552 - 2. Implement session management 553 - 3. Add auth middleware 554 - 4. Test login flow 555 - 556 - ### Phase 4: Frontend 557 - 1. Choose framework (templates vs SPA) 558 - 2. Implement front page 559 - 3. Implement settings page 560 - 4. Implement personal page 561 - 5. Add styling 562 - 563 - ### Phase 5: Polish 564 - 1. Error handling 565 - 2. Loading states 566 - 3. Responsive design 567 - 4. Testing 568 - 569 - ## Open Questions 570 - 571 - 1. **Framework choice:** Go templates (Templ?), HTMX, or SPA (React/Vue)? 572 - 2. **Styling:** Tailwind, plain CSS, or component library? 573 - 3. **Manifest details:** Modal vs dedicated page? 574 - 4. **Search:** Full-text search on repository/tag names? Requires FTS in SQLite. 575 - 5. **Real-time updates:** WebSocket for firehose events, or polling? 576 - 6. **Image size calculation:** Sum of layer sizes, or read from manifest? 577 - 7. **Public profiles:** Should `/ui/@alice` show public view of alice's images? 578 - 8. **Firehose resilience:** Reconnect logic, backfill on downtime? 579 - 580 - ## Dependencies 581 - 582 - New Go packages needed: 583 - - `github.com/mattn/go-sqlite3` - SQLite driver 584 - - `github.com/bluesky-social/jetstream` - Firehose client (or direct websocket) 585 - - Session management library (or custom implementation) 586 - - Frontend framework (TBD) 587 - 588 - ## Configuration 589 - 590 - Add to `config/config.yml`: 591 - 592 - ```yaml 593 - ui: 594 - enabled: true 595 - database_path: /var/lib/atcr/ui.db 596 - firehose: 597 - enabled: true 598 - endpoint: wss://jetstream.atproto.tools/subscribe 599 - collections: 600 - - io.atcr.manifest 601 - - io.atcr.tag 602 - session: 603 - duration: 24h 604 - cookie_name: atcr_session 605 - cookie_secure: true 606 - ``` 607 - 608 - ## Security Considerations 609 - 610 - 1. **Session cookies:** Secure, HttpOnly, SameSite=Lax 611 - 2. **CSRF protection:** For mutation endpoints (tag/manifest delete) 612 - 3. **Rate limiting:** On API endpoints 613 - 4. **Input validation:** Sanitize user input for search/filters 614 - 5. **Authorization:** Verify authenticated user owns resources before mutation 615 - 6. **SQL injection:** Use parameterized queries 616 - 617 - ## Performance Considerations 618 - 619 - 1. **Database indexes:** On DID, repository, created_at, digest 620 - 2. **Pagination:** Limit query results to avoid large payloads 621 - 3. **Caching:** Cache profile data, hold list, manifest details 622 - 4. **Firehose buffering:** Batch database inserts 623 - 5. **Connection pooling:** For SQLite and HTTP clients 624 - 625 - ## Testing Strategy 626 - 627 - 1. **Unit tests:** Database layer, API handlers 628 - 2. **Integration tests:** Firehose worker with mock events 629 - 3. **E2E tests:** Full login → browse → manage flow 630 - 4. **Load testing:** Firehose worker with high event volume 631 - 5. **Manual testing:** Real PDS, real images, real firehose
-996
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 with Facets 275 - 276 - **File**: `pkg/hold/pds/manifest_post.go` (new file) 277 - 278 - **Pattern**: Extends `status.go` pattern with rich text facets 279 - 280 - ```go 281 - // CreateManifestPost creates a Bluesky post announcing a manifest upload 282 - // Includes facets for clickable mentions and links 283 - func (p *HoldPDS) CreateManifestPost( 284 - ctx context.Context, 285 - repository, tag, userHandle, digest string, 286 - totalSize int64, 287 - ) (string, error) { 288 - now := time.Now() 289 - 290 - // Build AppView repository URL 291 - appViewURL := fmt.Sprintf("https://atcr.io/r/%s/%s", userHandle, repository) 292 - 293 - // Format post text components 294 - digestShort := formatDigest(digest) 295 - sizeStr := formatSize(totalSize) 296 - repoWithTag := fmt.Sprintf("%s:%s", repository, tag) 297 - 298 - // Build text: "@alice.bsky.social just pushed hsm-secrets-operator:latest\nDigest: sha256:abc...def Size: 12.2 MB" 299 - text := fmt.Sprintf("@%s just pushed %s\nDigest: %s Size: %s", userHandle, repoWithTag, digestShort, sizeStr) 300 - 301 - // Create facets for mentions and links 302 - facets := buildFacets(text, userHandle, repoWithTag, appViewURL) 303 - 304 - // Create post struct with facets 305 - post := &bsky.FeedPost{ 306 - LexiconTypeID: "app.bsky.feed.post", 307 - Text: text, 308 - Facets: facets, 309 - CreatedAt: now.Format(time.RFC3339), 310 - } 311 - 312 - // Create record with auto-generated TID 313 - rkey, recordCID, err := p.repomgr.CreateRecord( 314 - ctx, 315 - p.uid, 316 - "app.bsky.feed.post", 317 - post, 318 - ) 319 - 320 - if err != nil { 321 - return "", fmt.Errorf("failed to create manifest post: %w", err) 322 - } 323 - 324 - // Build ATProto URI for the post 325 - postURI := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", p.did, rkey) 326 - 327 - log.Printf("Created manifest post: %s (cid: %s)", postURI, recordCID) 328 - 329 - return postURI, nil 330 - } 331 - 332 - // formatDigest truncates digest to first 7 and last 7 chars 333 - // Example: sha256:abc1234567890...fedcba9876543210 -> sha256:abc1234...9876543 334 - func formatDigest(digest string) string { 335 - if !strings.HasPrefix(digest, "sha256:") { 336 - return digest // Return as-is if not sha256 337 - } 338 - 339 - hash := strings.TrimPrefix(digest, "sha256:") 340 - if len(hash) <= 14 { 341 - return digest // Too short to truncate 342 - } 343 - 344 - return fmt.Sprintf("sha256:%s...%s", hash[:7], hash[len(hash)-7:]) 345 - } 346 - 347 - // formatSize converts bytes to human-readable format 348 - // Examples: 1024 -> "1.0 KB", 1048576 -> "1.0 MB", 1073741824 -> "1.0 GB" 349 - func formatSize(bytes int64) string { 350 - const ( 351 - KB = 1024 352 - MB = 1024 * KB 353 - GB = 1024 * MB 354 - ) 355 - 356 - switch { 357 - case bytes >= GB: 358 - return fmt.Sprintf("%.1f GB", float64(bytes)/float64(GB)) 359 - case bytes >= MB: 360 - return fmt.Sprintf("%.1f MB", float64(bytes)/float64(MB)) 361 - case bytes >= KB: 362 - return fmt.Sprintf("%.1f KB", float64(bytes)/float64(KB)) 363 - default: 364 - return fmt.Sprintf("%d B", bytes) 365 - } 366 - } 367 - 368 - // buildFacets creates mention and link facets for rich text 369 - // IMPORTANT: Byte offsets must be calculated for UTF-8 encoded text 370 - func buildFacets(text, userHandle, repoWithTag, appViewURL string) []*bsky.RichtextFacet { 371 - facets := []*bsky.RichtextFacet{} 372 - 373 - // Find mention: "@alice.bsky.social" 374 - mentionText := "@" + userHandle 375 - mentionStart := strings.Index(text, mentionText) 376 - if mentionStart >= 0 { 377 - // Calculate byte offsets (not character offsets!) 378 - byteStart := int64(len(text[:mentionStart])) 379 - byteEnd := int64(len(text[:mentionStart+len(mentionText)])) 380 - 381 - facets = append(facets, &bsky.RichtextFacet{ 382 - Index: &bsky.RichtextFacet_ByteSlice{ 383 - ByteStart: byteStart, 384 - ByteEnd: byteEnd, 385 - }, 386 - Features: []*bsky.RichtextFacet_Features_Elem{ 387 - { 388 - RichtextFacet_Mention: &bsky.RichtextFacet_Mention{ 389 - Did: "", // Will be resolved by Bluesky from handle 390 - }, 391 - }, 392 - }, 393 - }) 394 - } 395 - 396 - // Find repository link: "hsm-secrets-operator:latest" 397 - linkStart := strings.Index(text, repoWithTag) 398 - if linkStart >= 0 { 399 - // Calculate byte offsets 400 - byteStart := int64(len(text[:linkStart])) 401 - byteEnd := int64(len(text[:linkStart+len(repoWithTag)])) 402 - 403 - facets = append(facets, &bsky.RichtextFacet{ 404 - Index: &bsky.RichtextFacet_ByteSlice{ 405 - ByteStart: byteStart, 406 - ByteEnd: byteEnd, 407 - }, 408 - Features: []*bsky.RichtextFacet_Features_Elem{ 409 - { 410 - RichtextFacet_Link: &bsky.RichtextFacet_Link{ 411 - Uri: appViewURL, 412 - }, 413 - }, 414 - }, 415 - }) 416 - } 417 - 418 - return facets 419 - } 420 - ``` 421 - 422 - **Facet Implementation Notes:** 423 - 424 - 1. **Byte Offsets**: ATProto uses byte offsets (UTF-8 encoded), not character offsets 425 - - For ASCII text: `len(text[:index])` gives correct byte offset 426 - - For Unicode: Must use `len()` on substring to get byte count 427 - - Never use `rune` indexes directly 428 - 429 - 2. **Mention Facets**: 430 - - Include `@` symbol in the facet range 431 - - DID field can be empty; Bluesky resolves from handle 432 - - Type: `app.bsky.richtext.facet#mention` 433 - 434 - 3. **Link Facets**: 435 - - Text can be anything (doesn't have to be URL) 436 - - URI field contains actual target URL 437 - - Type: `app.bsky.richtext.facet#link` 438 - 439 - 4. **Ordering**: Facets should not overlap; order doesn't matter 440 - 441 - ### 5. AppView Integration 442 - 443 - **File**: `pkg/appview/storage/manifest_store.go` 444 - 445 - **Integration Point**: After `client.PutRecord()` succeeds (around line 130-140) 446 - 447 - ```go 448 - // Existing code: 449 - recordURI, recordCID, err := ms.client.PutRecord(ctx, atproto.ManifestCollection, rkey, manifestRecord) 450 - if err != nil { 451 - return "", fmt.Errorf("failed to store manifest in PDS: %w", err) 452 - } 453 - 454 - // NEW: Notify hold about manifest upload 455 - if err := ms.notifyHoldAboutManifest(ctx, desc, manifestRecord, tag); err != nil { 456 - // Log error but don't fail the manifest upload 457 - log.Printf("Failed to notify hold about manifest: %v", err) 458 - } 459 - 460 - return desc.Digest.String(), nil 461 - ``` 462 - 463 - **Implementation**: 464 - 465 - ```go 466 - // notifyHoldAboutManifest sends manifest metadata to the hold 467 - func (ms *ManifestStore) notifyHoldAboutManifest( 468 - ctx context.Context, 469 - desc distribution.Descriptor, 470 - manifestRecord *atproto.ManifestRecord, 471 - tag string, 472 - ) error { 473 - // 1. Get registry context 474 - regCtx, err := storage.GetRegistryContext(ctx) 475 - if err != nil { 476 - return fmt.Errorf("failed to get registry context: %w", err) 477 - } 478 - 479 - // 2. Resolve hold DID to endpoint 480 - holdEndpoint, err := ms.resolver.ResolveDIDToHTTPEndpoint(ctx, manifestRecord.HoldDID) 481 - if err != nil { 482 - return fmt.Errorf("failed to resolve hold DID: %w", err) 483 - } 484 - 485 - // 3. Get service token from user's PDS 486 - serviceToken, err := regCtx.Refresher.GetServiceToken(ctx, regCtx.DID, manifestRecord.HoldDID) 487 - if err != nil { 488 - return fmt.Errorf("failed to get service token: %w", err) 489 - } 490 - 491 - // 4. Parse manifest to extract layer info 492 - var parsedManifest struct { 493 - MediaType string `json:"mediaType"` 494 - Config distribution.Descriptor `json:"config"` 495 - Layers []distribution.Descriptor `json:"layers"` 496 - } 497 - 498 - if err := json.Unmarshal(manifestRecord.ManifestBlob.Data, &parsedManifest); err != nil { 499 - return fmt.Errorf("failed to parse manifest: %w", err) 500 - } 501 - 502 - // 5. Build notification request 503 - notifyReq := map[string]any{ 504 - "repository": ms.repository, 505 - "tag": tag, 506 - "userDid": regCtx.DID, 507 - "userHandle": regCtx.Handle, // Need to add this to RegistryContext 508 - "manifest": map[string]any{ 509 - "mediaType": parsedManifest.MediaType, 510 - "config": map[string]any{ 511 - "digest": parsedManifest.Config.Digest.String(), 512 - "size": parsedManifest.Config.Size, 513 - }, 514 - "layers": func() []map[string]any { 515 - layers := make([]map[string]any, len(parsedManifest.Layers)) 516 - for i, layer := range parsedManifest.Layers { 517 - layers[i] = map[string]any{ 518 - "digest": layer.Digest.String(), 519 - "size": layer.Size, 520 - "mediaType": layer.MediaType, 521 - } 522 - } 523 - return layers 524 - }(), 525 - }, 526 - } 527 - 528 - // 6. Call hold's XRPC endpoint 529 - reqBody, _ := json.Marshal(notifyReq) 530 - req, err := http.NewRequestWithContext( 531 - ctx, 532 - "POST", 533 - holdEndpoint+"/xrpc/io.atcr.hold.notifyManifest", 534 - bytes.NewReader(reqBody), 535 - ) 536 - if err != nil { 537 - return err 538 - } 539 - 540 - req.Header.Set("Content-Type", "application/json") 541 - req.Header.Set("Authorization", "Bearer "+serviceToken) 542 - 543 - resp, err := http.DefaultClient.Do(req) 544 - if err != nil { 545 - return err 546 - } 547 - defer resp.Body.Close() 548 - 549 - if resp.StatusCode != http.StatusOK { 550 - body, _ := io.ReadAll(resp.Body) 551 - return fmt.Errorf("hold notification failed: %s (status: %d)", body, resp.StatusCode) 552 - } 553 - 554 - // 7. Parse response (optional logging) 555 - var notifyResp map[string]any 556 - if err := json.NewDecoder(resp.Body).Decode(&notifyResp); err == nil { 557 - log.Printf("Hold notification successful: %+v", notifyResp) 558 - } 559 - 560 - return nil 561 - } 562 - ``` 563 - 564 - ### 6. Record Type Registration 565 - 566 - **File**: `pkg/hold/pds/server.go` 567 - 568 - **In `init()` function** (around line 30): 569 - 570 - ```go 571 - func init() { 572 - // Existing registrations 573 - lexutil.RegisterType(atproto.CaptainCollection, &atproto.CaptainRecord{}) 574 - lexutil.RegisterType(atproto.CrewCollection, &atproto.CrewRecord{}) 575 - lexutil.RegisterType(atproto.TangledProfileCollection, &atproto.TangledProfileRecord{}) 576 - 577 - // NEW: Register layer record type 578 - lexutil.RegisterType(atproto.LayerCollection, &atproto.LayerRecord{}) 579 - } 580 - ``` 581 - 582 - **Why needed**: ATProto's CBOR unmarshaling requires type registration to automatically deserialize records when reading from the carstore. 583 - 584 - ## Testing Strategy 585 - 586 - ### Unit Tests 587 - 588 - **Test Layer Record Creation** (`pkg/hold/pds/layer_test.go`): 589 - ```go 590 - func TestCreateLayerRecord(t *testing.T) { 591 - pds := setupTestPDS(t) 592 - ctx := context.Background() 593 - 594 - record := atproto.NewLayerRecord( 595 - "sha256:abc123", 596 - 1024, 597 - "application/vnd.docker.image.rootfs.diff.tar.gzip", 598 - "alice/myapp", 599 - "did:plc:alice123", 600 - "alice.bsky.social", 601 - ) 602 - 603 - rkey, cid, err := pds.CreateLayerRecord(ctx, record) 604 - assert.NoError(t, err) 605 - assert.NotEmpty(t, rkey) 606 - assert.NotEmpty(t, cid) 607 - 608 - // Verify record was stored 609 - retrieved, err := pds.GetLayerRecord(ctx, rkey) 610 - assert.NoError(t, err) 611 - assert.Equal(t, record.Digest, retrieved.Digest) 612 - } 613 - ``` 614 - 615 - **Test Manifest Post Creation** (`pkg/hold/pds/manifest_post_test.go`): 616 - ```go 617 - func TestCreateManifestPost(t *testing.T) { 618 - pds := setupTestPDS(t) 619 - ctx := context.Background() 620 - 621 - postURI, err := pds.CreateManifestPost(ctx, "alice/myapp", "latest", "alice.bsky.social") 622 - assert.NoError(t, err) 623 - assert.Contains(t, postURI, "app.bsky.feed.post") 624 - 625 - // Parse URI and verify post exists 626 - // at://did:web:hold01.atcr.io/app.bsky.feed.post/{rkey} 627 - } 628 - ``` 629 - 630 - **Test XRPC Endpoint** (`pkg/hold/oci/xrpc_test.go`): 631 - ```go 632 - func TestHandleNotifyManifest(t *testing.T) { 633 - handler := setupTestHandler(t) 634 - 635 - req := NotifyManifestRequest{ 636 - Repository: "alice/myapp", 637 - Tag: "latest", 638 - UserDID: "did:plc:alice123", 639 - UserHandle: "alice.bsky.social", 640 - Manifest: /* ... */, 641 - } 642 - 643 - // Make HTTP request with service token 644 - resp := makeRequest(t, handler, req, validServiceToken) 645 - 646 - assert.Equal(t, http.StatusOK, resp.StatusCode) 647 - 648 - var result NotifyManifestResponse 649 - json.NewDecoder(resp.Body).Decode(&result) 650 - 651 - assert.True(t, result.Success) 652 - assert.Equal(t, 3, result.LayersCreated) // if manifest has 3 layers 653 - assert.True(t, result.PostCreated) 654 - } 655 - ``` 656 - 657 - ### Integration Tests 658 - 659 - **End-to-End Test**: 660 - 1. Push a test image to ATCR 661 - 2. Verify manifest is stored in user's PDS 662 - 3. Verify layer records are created in hold's PDS 663 - 4. Verify Bluesky post is created in hold's PDS 664 - 5. Query ATProto endpoints to retrieve records 665 - 666 - ## Error Handling 667 - 668 - ### AppView Side 669 - 670 - **Notification failures should NOT break manifest uploads**: 671 - - If hold is unreachable: Log error, continue 672 - - If service token fails: Log error, continue 673 - - If hold returns error: Log error, continue 674 - 675 - **Rationale**: Bluesky posts are a "nice to have" feature, not critical infrastructure. Image pushes must succeed even if social features fail. 676 - 677 - ### Hold Side 678 - 679 - **Partial failures are acceptable**: 680 - - If some layer records fail: Create what we can, return partial success 681 - - If Bluesky post fails but layers succeed: Return success with `postCreated: false` 682 - - If all operations fail: Return error response 683 - 684 - **Logging**: 685 - - Log all errors for debugging 686 - - Include user DID, repository, and error details 687 - - Use structured logging for easy querying 688 - 689 - ## Configuration 690 - 691 - ### Environment Variables 692 - 693 - **Hold Service** (`.env.hold.example`): 694 - ```bash 695 - # Enable/disable Bluesky manifest posting (default: false) 696 - # When enabled, hold will create Bluesky posts when users push images 697 - # Synced to captain record's enableBlueskyPosts field on startup 698 - HOLD_BLUESKY_POSTS_ENABLED=false 699 - ``` 700 - 701 - **AppView** - No configuration needed. AppView always attempts to notify holds after manifest uploads, but handles failures gracefully. 702 - 703 - ### Feature Flags 704 - 705 - **Captain Record Sync:** 706 - The hold's captain record includes an `enableBlueskyPosts` field that is synchronized with the environment variable on startup: 707 - 708 - ```go 709 - type CaptainRecord struct { 710 - // ... other fields ... 711 - EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` 712 - } 713 - ``` 714 - 715 - **How it works:** 716 - 1. On startup, Bootstrap reads `HOLD_BLUESKY_POSTS_ENABLED` environment variable 717 - 2. Creates or updates the captain record to match the env var setting 718 - 3. At runtime, the code reads from the captain record (which reflects the env var) 719 - 4. To change the setting, update the env var and restart the hold 720 - 721 - **Rationale:** 722 - - Default off for backward compatibility and privacy 723 - - Hold owners can enable via env var at deployment 724 - - Per-hold override via captain record for multi-tenant scenarios 725 - - Follows same pattern as existing status post feature 726 - 727 - ## Performance Considerations 728 - 729 - ### Database Impact 730 - 731 - **Layer records**: Each manifest upload creates N records (where N = number of layers) 732 - - Typical image: 5-10 layers 733 - - Large image: 50+ layers 734 - - Storage: ~500 bytes per record (CBOR compressed) 735 - 736 - **Bluesky posts**: One post per manifest 737 - - Storage: ~200 bytes per post 738 - - Indexed by creation time for feed queries 739 - 740 - **Carstore growth**: Estimate ~5KB per manifest upload (records + post) 741 - 742 - ### Network Impact 743 - 744 - **AppView → Hold notification**: 745 - - One HTTP POST per manifest upload 746 - - Payload size: ~2-10KB (depends on layer count) 747 - - Should complete in <100ms on local network 748 - 749 - **Service token requests**: 750 - - Tokens cached for 50 seconds 751 - - Minimal overhead if pushing multiple manifests quickly 752 - 753 - ### Optimization Opportunities 754 - 755 - 1. **Batch layer record creation**: Use `BatchWrite` for multiple records 756 - 2. **Async processing**: Queue notifications and process in background 757 - 3. **Rate limiting**: Limit posts per user/hold to prevent spam 758 - 4. **Deduplication**: Skip layer records for already-seen digests 759 - 760 - ## Future Enhancements 761 - 762 - ### Phase 2: Enhanced Posts 763 - 764 - **Rich embeds**: 765 - - Link preview to AppView repository page 766 - - Thumbnail image from first layer 767 - - Metadata badges (image size, layer count, tags) 768 - 769 - **Mentions**: 770 - - Parse user handle and create Bluesky facets for @mentions 771 - - Enable clickable mentions in posts 772 - 773 - **Tags/hashtags**: 774 - - Add `#container`, `#docker`, repository tags 775 - - Improve discoverability in Bluesky 776 - 777 - ### Phase 3: Feed Customization 778 - 779 - **Hold-specific feeds**: 780 - - Query layer records by repository 781 - - Filter by user DID 782 - - Time-based queries 783 - 784 - **ATProto feed generator**: 785 - - Implement `app.bsky.feed.getFeedSkeleton` XRPC endpoint 786 - - Publish hold's feed to Bluesky 787 - - Users can subscribe to hold activity feeds 788 - 789 - ### Phase 4: Analytics 790 - 791 - **Track metrics**: 792 - - Posts per day/week/month 793 - - Most active users 794 - - Most popular repositories 795 - - Storage growth over time 796 - 797 - **Dashboards**: 798 - - Visualize activity on AppView UI 799 - - Show trending images 800 - - Leaderboards for most pushed repositories 801 - 802 - ## Security Considerations 803 - 804 - ### Authentication 805 - 806 - **Service tokens**: 807 - - Validate tokens against user's PDS 808 - - Verify DID matches in token claims 809 - - Check token expiration (60s from PDS) 810 - 811 - **Authorization**: 812 - - Only authenticated users can trigger posts 813 - - Posts created under hold's DID (not user's DID) 814 - - User information is metadata in post text 815 - 816 - ### Privacy 817 - 818 - **User handles**: 819 - - Posts include user handle (`@alice.bsky.social`) 820 - - Consider opt-out mechanism for privacy-conscious users 821 - 822 - **Repository names**: 823 - - Public information (already visible in AppView) 824 - - Consider private repository flags in future 825 - 826 - ### Rate Limiting 827 - 828 - **Prevent spam**: 829 - - Limit posts per user per hour 830 - - Detect rapid-fire pushes (CI/CD) 831 - - Consider aggregating multiple pushes into single post 832 - 833 - **Resource protection**: 834 - - Limit layer record creation to prevent storage exhaustion 835 - - Cap manifest notification payload size 836 - - Timeout long-running operations 837 - 838 - ## Monitoring and Observability 839 - 840 - ### Metrics to Track 841 - 842 - **AppView**: 843 - - `atcr_hold_notifications_total` - Counter of notifications sent 844 - - `atcr_hold_notifications_errors` - Counter of failures 845 - - `atcr_hold_notification_duration_ms` - Histogram of latency 846 - 847 - **Hold**: 848 - - `hold_layer_records_created_total` - Counter of layer records 849 - - `hold_bluesky_posts_created_total` - Counter of posts 850 - - `hold_manifest_notifications_received_total` - Counter of incoming notifications 851 - - `hold_notification_errors_total` - Counter of errors by type 852 - 853 - ### Logging 854 - 855 - **Structured logs**: 856 - ```json 857 - { 858 - "level": "info", 859 - "msg": "manifest notification received", 860 - "repository": "alice/myapp", 861 - "tag": "latest", 862 - "userDid": "did:plc:alice123", 863 - "layerCount": 5, 864 - "layersCreated": 5, 865 - "postCreated": true, 866 - "duration_ms": 45 867 - } 868 - ``` 869 - 870 - ### Alerts 871 - 872 - **Critical issues**: 873 - - High error rate (>10% failures) 874 - - Service token failures (auth issues) 875 - - PDS carstore errors (database problems) 876 - 877 - **Warning issues**: 878 - - Slow notifications (>1s latency) 879 - - Partial failures (some layers not created) 880 - - Missing user handle in context 881 - 882 - ## Migration Strategy 883 - 884 - ### Rollout Plan 885 - 886 - **Phase 1: Development** 887 - - Implement core functionality 888 - - Add comprehensive tests 889 - - Deploy to staging environment 890 - 891 - **Phase 2: Beta** 892 - - Enable for test holds only 893 - - Gather feedback from early users 894 - - Monitor performance and errors 895 - 896 - **Phase 3: Opt-in** 897 - - Add configuration flags 898 - - Allow hold owners to enable feature 899 - - Document setup process 900 - 901 - **Phase 4: Default On** 902 - - Enable by default for new holds 903 - - Migrate existing holds (opt-out available) 904 - - Announce feature publicly 905 - 906 - ### Backward Compatibility 907 - 908 - **No breaking changes**: 909 - - New XRPC endpoint (doesn't affect existing endpoints) 910 - - New record types (isolated collections) 911 - - Optional feature (can be disabled) 912 - 913 - **Existing holds**: 914 - - Work without changes 915 - - Can opt-in by updating hold service 916 - - No data migration required 917 - 918 - ## Example Post Formats 919 - 920 - ### Preferred Format (Facet-Based) 921 - 922 - **Text representation:** 923 - ``` 924 - @alice.bsky.social just pushed hsm-secrets-operator:latest 925 - Digest: sha256:abc1234...def5678 Size: 12.2 MB 926 - ``` 927 - 928 - **Actual implementation:** 929 - - `@alice.bsky.social` - Clickable mention (facet type: `app.bsky.richtext.facet#mention`) 930 - - `hsm-secrets-operator:latest` - Clickable link to `https://atcr.io/r/alice.bsky.social/hsm-secrets-operator` (facet type: `app.bsky.richtext.facet#link`) 931 - - `sha256:abc1234...def5678` - Truncated digest (first 7 + last 7 chars) 932 - - `12.2 MB` - Human-readable size (auto-formatted from bytes) 933 - 934 - **Why facets?** 935 - - Mentions are clickable and link to user profiles in Bluesky 936 - - Repository names link directly to AppView repository pages 937 - - Better user experience than plain text URLs 938 - - Standard ATProto rich text format 939 - 940 - ### Alternative Formats 941 - 942 - #### Simple Format 943 - ``` 944 - 📦 alice/myapp:latest pushed by @alice.bsky.social 945 - ``` 946 - 947 - #### Detailed Format 948 - ``` 949 - 📦 New container image pushed! 950 - 951 - alice/myapp:v1.2.3 952 - Pushed by @alice.bsky.social 953 - 5 layers, 125 MB total 954 - 955 - View: https://atcr.io/alice/myapp 956 - ``` 957 - 958 - #### With Emoji/Styling 959 - ``` 960 - 🚀 alice/myapp:latest 961 - 962 - ✅ 5 layers 963 - 📦 125.4 MB 964 - 👤 @alice.bsky.social 965 - 🔗 atcr.io/alice/myapp 966 - ``` 967 - 968 - #### With Tags 969 - ``` 970 - 📦 alice/myapp:latest pushed by @alice.bsky.social 971 - 972 - #container #docker #atcr 973 - ``` 974 - 975 - ## References 976 - 977 - ### Related Code 978 - 979 - - Existing Bluesky post implementation: `pkg/hold/pds/status.go` 980 - - XRPC endpoint pattern: `pkg/hold/oci/xrpc.go` 981 - - Record type definitions: `pkg/atproto/lexicon.go` 982 - - Manifest storage: `pkg/appview/storage/manifest_store.go` 983 - - Service token handling: `pkg/auth/oauth/refresher.go` 984 - 985 - ### External Documentation 986 - 987 - - ATProto Record Schema: https://atproto.com/specs/record-key 988 - - Bluesky Post Lexicon: https://atproto.com/lexicons/app-bsky-feed#appbskyfeedpost 989 - - CBOR Encoding: https://cbor.io/ 990 - - Bluesky Facets (mentions/links): https://atproto.com/specs/richtext 991 - 992 - ### Tools 993 - 994 - - CBOR code generation: `github.com/whyrusleeping/cbor-gen` 995 - - ATProto libraries: `github.com/bluesky-social/indigo` 996 - - Testing: Standard Go testing + `testify/assert`
-250
docs/CREW_ACCESS_CONTROL.md
··· 1 - # Hold Crew Access Control 2 - 3 - ## Overview 4 - 5 - ATCR uses crew-based access control for hold (storage) services. Crew records are stored in the **hold's embedded PDS** (not the owner's or user's PDS), making the hold a self-contained ATProto actor with its own access control. 6 - 7 - ## Current Implementation 8 - 9 - ### Records in Hold's PDS 10 - 11 - **Captain record** - Hold ownership (single record at `io.atcr.hold.captain/self`): 12 - ```json 13 - { 14 - "$type": "io.atcr.hold.captain", 15 - "owner": "did:plc:alice123", 16 - "public": false, 17 - "deployedAt": "2025-10-14T...", 18 - "region": "iad", 19 - "provider": "fly.io" 20 - } 21 - ``` 22 - 23 - **Crew records** - Access control (one per member at `io.atcr.hold.crew/{rkey}`): 24 - ```json 25 - { 26 - "$type": "io.atcr.hold.crew", 27 - "member": "did:plc:bob456", 28 - "role": "admin", 29 - "permissions": ["blob:read", "blob:write"], 30 - "addedAt": "2025-10-14T..." 31 - } 32 - ``` 33 - 34 - ### Authorization Logic 35 - 36 - Write authorization follows this priority: 37 - 38 - ``` 39 - isAuthorizedWrite(userDID): 40 - 1. If userDID == captain.owner → ALLOW 41 - 2. If crew record exists for userDID → ALLOW 42 - 3. Default → DENY 43 - ``` 44 - 45 - Read authorization depends on `HOLD_PUBLIC` setting: 46 - - **Public hold** (`HOLD_PUBLIC=true`): Anonymous + all authenticated users can read 47 - - **Private hold** (`HOLD_PUBLIC=false`): Requires crew membership for reads 48 - 49 - ### Configuration 50 - 51 - ```bash 52 - # Access control environment variables 53 - HOLD_PUBLIC=false # Require authentication for reads 54 - HOLD_ALLOW_ALL_CREW=false # Only explicit crew members can write 55 - ``` 56 - 57 - ### Crew Management 58 - 59 - Crew records are managed by the hold captain (owner) using standard ATProto operations on the hold's embedded PDS: 60 - 61 - **Add crew member:** 62 - ```bash 63 - # Via hold's PDS (requires captain's OAuth) 64 - atproto put-record \ 65 - --pds https://hold.example.com \ 66 - --collection io.atcr.hold.crew \ 67 - --rkey "{memberDID}" \ 68 - --value '{ 69 - "$type": "io.atcr.hold.crew", 70 - "member": "did:plc:bob456", 71 - "role": "admin", 72 - "permissions": ["blob:read", "blob:write"], 73 - "addedAt": "2025-10-14T12:00:00Z" 74 - }' 75 - ``` 76 - 77 - **Remove crew member:** 78 - ```bash 79 - atproto delete-record \ 80 - --pds https://hold.example.com \ 81 - --collection io.atcr.hold.crew \ 82 - --rkey "{memberDID}" 83 - ``` 84 - 85 - **List crew members:** 86 - ```bash 87 - # Via XRPC 88 - GET https://hold.example.com/xrpc/com.atproto.repo.listRecords?repo={holdDID}&collection=io.atcr.hold.crew 89 - ``` 90 - 91 - ## Authentication Flow 92 - 93 - ``` 94 - 1. User pushes image to atcr.io/alice/myapp 95 - 96 - 2. AppView gets service token from alice's PDS: 97 - GET /xrpc/com.atproto.server.getServiceAuth?aud={holdDID} 98 - Response: { "token": "..." } 99 - 100 - 3. AppView calls hold with service token: 101 - POST /xrpc/io.atcr.hold.initiateUpload 102 - Authorization: Bearer {serviceToken} 103 - 104 - 4. Hold validates service token: 105 - - Checks token is from alice's PDS 106 - - Extracts alice's DID from token 107 - 108 - 5. Hold checks crew membership: 109 - - Queries its own PDS: com.atproto.repo.getRecord 110 - - Collection: io.atcr.hold.crew 111 - - Record key: alice's DID 112 - 113 - 6. If crew record found → allow upload 114 - Else → deny with 403 Forbidden 115 - ``` 116 - 117 - **Trust model:** "Trust but verify" 118 - - User OAuth'd to AppView (proves identity) 119 - - Service token from user's PDS (proves AppView is acting on behalf of user) 120 - - Crew record in hold's PDS (proves user has access to this hold) 121 - 122 - ## Use Cases 123 - 124 - ### 1. Personal Hold (Private) 125 - 126 - ```bash 127 - # Owner only 128 - HOLD_PUBLIC=false 129 - HOLD_ALLOW_ALL_CREW=false 130 - # No additional crew records needed - captain has implicit access 131 - ``` 132 - 133 - ### 2. Team Hold (Shared) 134 - 135 - ```bash 136 - # Multiple team members 137 - HOLD_PUBLIC=false 138 - HOLD_ALLOW_ALL_CREW=false 139 - 140 - # Captain adds crew members: 141 - # - did:plc:alice (admin) 142 - # - did:plc:bob (member) 143 - # - did:plc:charlie (member) 144 - ``` 145 - 146 - ### 3. Public Hold (Community) 147 - 148 - ```bash 149 - # Allow any authenticated user (TODO: Implement HOLD_ALLOW_ALL_CREW) 150 - HOLD_PUBLIC=true 151 - HOLD_ALLOW_ALL_CREW=true 152 - ``` 153 - 154 - ## Planned Features 155 - 156 - ### Pattern-Based Access Control 157 - 158 - **Status:** Planned but not yet implemented. 159 - 160 - **Concept:** Allow crew records with pattern matching instead of explicit DIDs: 161 - 162 - ```json 163 - { 164 - "$type": "io.atcr.hold.crew", 165 - "memberPattern": "*.example.com", 166 - "role": "write" 167 - } 168 - ``` 169 - 170 - **Use cases:** 171 - - `"*"` - Allow all authenticated users 172 - - `"*.company.com"` - Allow all users from company domain 173 - - `"*.community.social"` - Allow all community members 174 - 175 - **Implementation needed:** 176 - - Add `memberPattern` field to crew record schema (make `member` optional) 177 - - Add handle resolution (DID → handle lookup) 178 - - Add pattern matching logic 179 - - Update authorization to check patterns 180 - 181 - ### Barred List (Access Revocation) 182 - 183 - **Status:** Planned but not yet implemented. 184 - 185 - **Concept:** Explicit deny list that overrides crew membership: 186 - 187 - ```json 188 - { 189 - "$type": "io.atcr.hold.crew.barred", 190 - "member": "did:plc:former-employee", 191 - "reason": "No longer with company", 192 - "barredAt": "2025-10-13T12:00:00Z" 193 - } 194 - ``` 195 - 196 - **Priority:** Barred list checked before crew list. 197 - 198 - ### HOLD_ALLOW_ALL_CREW 199 - 200 - **Status:** Environment variable exists but full implementation pending. 201 - 202 - **Concept:** Automatically create/manage wildcard crew record via env var: 203 - 204 - ```bash 205 - HOLD_ALLOW_ALL_CREW=true # Creates crew record with memberPattern: "*" 206 - ``` 207 - 208 - **Implementation needed:** 209 - - Auto-create wildcard crew record on startup if env=true 210 - - Auto-delete wildcard crew record if env changes to false 211 - - Use well-known rkey "allow-all" for managed record 212 - 213 - ## Architecture Notes 214 - 215 - ### Why Hold's Embedded PDS? 216 - 217 - **Key insight:** Crew records are **shared data** about the hold, not user-specific data. 218 - 219 - **Benefits:** 220 - - **Self-contained**: Hold is independent ATProto actor 221 - - **Portable**: Hold can move without coordinating with user PDSs 222 - - **Discoverable**: Query hold's PDS to see who has access 223 - - **Standard**: Uses normal ATProto sync endpoints (subscribeRepos, getRecord, listRecords) 224 - 225 - **Comparison:** 226 - - **User's PDS**: Stores user-specific data (manifests, sailor profile) 227 - - **Hold's PDS**: Stores hold-specific data (captain, crew, configuration) 228 - - Clear separation of concerns 229 - 230 - ### Security Considerations 231 - 232 - 1. **Public Records**: Crew records are public (anyone can see who has access to a hold) 233 - 2. **Service Tokens**: Hold trusts user's PDS to issue valid service tokens 234 - 3. **DID-Based**: Crew membership is DID-based (permanent), not handle-based 235 - 4. **Captain Control**: Only captain can modify crew records (via OAuth to hold's PDS) 236 - 237 - ## Future Improvements 238 - 239 - 1. **Crew management UI** - Web interface for adding/removing crew members 240 - 2. **Pattern-based matching** - Implement `memberPattern` field 241 - 3. **Barred list** - Implement access revocation 242 - 4. **Role-based permissions** - Fine-grained permissions beyond read/write 243 - 5. **Temporary access** - Time-limited crew membership (`expiresAt` field) 244 - 6. **Audit logging** - Track access grants/denials 245 - 246 - ## References 247 - 248 - - [EMBEDDED_PDS.md](./EMBEDDED_PDS.md) - Embedded PDS architecture details 249 - - [BYOS.md](./BYOS.md) - BYOS deployment and usage 250 - - [ATProto Lexicon Spec](https://atproto.com/specs/lexicon)
-355
docs/EMBEDDED_PDS.md
··· 1 - # Embedded PDS Architecture for Hold Services 2 - 3 - This document describes ATCR's hold service architecture using embedded ATProto PDS (Personal Data Server) for access control and federation. 4 - 5 - ## Motivation 6 - 7 - ### The Fragmentation Problem 8 - 9 - Several ATProto projects face similar challenges with large data storage: 10 - 11 - | Project | Large Data | Metadata | Solution | 12 - |---------|-----------|----------|----------| 13 - | **tangled.org** | Git objects | Issues, PRs, comments | External knot storage | 14 - | **stream.place** | Video segments | Stream info, chat | Embedded "static PDS" | 15 - | **ATCR** | Container blobs | Manifests, comments, builds | Embedded PDS in hold service | 16 - 17 - **Common problem:** Large binary data can't realistically live in user PDSs, but application metadata needs a distributed home. 18 - 19 - **ATCR's approach:** Each hold service is a full ATProto actor with its own embedded PDS for **shared data** (captain + crew records, not user-specific data). This PDS stores access control and metadata about the hold itself. 20 - 21 - ## Current Architecture 22 - 23 - ### Hold Service Components 24 - 25 - ``` 26 - Hold Service (did:web:hold01.atcr.io) 27 - ├── Embedded PDS (SQLite carstore) - Shared data only 28 - │ ├── Captain record (ownership metadata) 29 - │ ├── Crew records (access control) 30 - │ └── ATProto sync/repo endpoints 31 - ├── OCI multipart upload (XRPC) 32 - │ ├── io.atcr.hold.initiateUpload 33 - │ ├── io.atcr.hold.getPartUploadUrl 34 - │ ├── io.atcr.hold.uploadPart 35 - │ ├── io.atcr.hold.completeUpload 36 - │ └── io.atcr.hold.abortUpload 37 - └── Storage driver (S3, filesystem, etc.) 38 - ``` 39 - 40 - **Important distinction:** 41 - - **Hold's embedded PDS** = Shared data (crew members, hold configuration) 42 - - **User's PDS** = User-specific data (manifests, sailor profile, personal records) 43 - - Hold's PDS does NOT store user-specific container data (that stays in user's own PDS) 44 - 45 - ### Records Structure 46 - 47 - **Captain record** (hold ownership, single record at `io.atcr.hold.captain/self`): 48 - ```json 49 - { 50 - "$type": "io.atcr.hold.captain", 51 - "owner": "did:plc:alice123", 52 - "public": false, 53 - "deployedAt": "2025-10-14T...", 54 - "region": "iad", 55 - "provider": "fly.io" 56 - } 57 - ``` 58 - 59 - **Crew records** (access control, one per member at `io.atcr.hold.crew/{rkey}`): 60 - ```json 61 - { 62 - "$type": "io.atcr.hold.crew", 63 - "member": "did:plc:bob456", 64 - "role": "admin", 65 - "permissions": ["blob:read", "blob:write"], 66 - "addedAt": "2025-10-14T..." 67 - } 68 - ``` 69 - 70 - ### ATProto PDS Endpoints 71 - 72 - Standard ATProto sync endpoints: 73 - - `GET /xrpc/com.atproto.sync.getRepo` - Download repository as CAR file 74 - - `GET /xrpc/com.atproto.sync.getBlob` - Get blob or presigned download URL 75 - - `GET /xrpc/com.atproto.sync.subscribeRepos` - Real-time crew changes 76 - - `GET /xrpc/com.atproto.sync.listRepos` - List repositories 77 - 78 - Repository management: 79 - - `GET /xrpc/com.atproto.repo.describeRepo` - Repository metadata 80 - - `GET /xrpc/com.atproto.repo.getRecord` - Get specific record (captain/crew) 81 - - `GET /xrpc/com.atproto.repo.listRecords` - List crew members 82 - - `POST /xrpc/io.atcr.hold.requestCrew` - Request crew membership 83 - 84 - DID resolution: 85 - - `GET /.well-known/did.json` - DID document (did:web resolution) 86 - - `GET /.well-known/atproto-did` - DID for handle resolution 87 - 88 - ### OCI Multipart Upload Flow 89 - 90 - ``` 91 - 1. AppView gets service token from user's PDS: 92 - GET /xrpc/com.atproto.server.getServiceAuth?aud={holdDID} 93 - Response: { "token": "eyJ..." } 94 - 95 - 2. AppView initiates multipart upload: 96 - POST /xrpc/io.atcr.hold.initiateUpload 97 - Authorization: Bearer {serviceToken} 98 - Body: { "digest": "sha256:abc..." } 99 - Response: { "uploadId": "xyz" } 100 - 101 - 3. For each part: 102 - POST /xrpc/io.atcr.hold.getPartUploadUrl 103 - Body: { "uploadId": "xyz", "partNumber": 1 } 104 - Response: { "url": "https://s3.../presigned" } 105 - 106 - 4. Upload part to S3 presigned URL: 107 - PUT {presignedURL} 108 - Body: [part data] 109 - 110 - 5. Complete upload: 111 - POST /xrpc/io.atcr.hold.completeUpload 112 - Body: { "uploadId": "xyz", "digest": "sha256:abc...", "parts": [...] } 113 - ``` 114 - 115 - ## Implementation Details 116 - 117 - ### Storage: Indigo Carstore with SQLite 118 - 119 - ```go 120 - type HoldPDS struct { 121 - did string 122 - carstore carstore.CarStore 123 - session *carstore.DeltaSession // Provides blockstore interface 124 - repo *repo.Repo 125 - dbPath string 126 - uid models.Uid // User ID for carstore (fixed: 1) 127 - } 128 - ``` 129 - 130 - **Storage location:** Single SQLite file (`/var/lib/atcr-hold/hold.db`) 131 - - Contains MST nodes, records, commits in carstore tables 132 - - Handles compaction/cleanup automatically 133 - - Migration path to Postgres if needed (same carstore API) 134 - 135 - ### Key Implementation Lessons 136 - 137 - #### 1. Custom Record Types Need Manual CBOR Decoding 138 - 139 - ```go 140 - // ❌ WRONG - Fails with "unrecognized lexicon type" 141 - record, err := repo.GetRecord(ctx, path, &CrewRecord{}) 142 - 143 - // ✅ CORRECT - Manual CBOR decoding 144 - recordCID, recBytes, err := repo.GetRecordBytes(ctx, path) 145 - var crewRecord CrewRecord 146 - err = crewRecord.UnmarshalCBOR(bytes.NewReader(*recBytes)) 147 - ``` 148 - 149 - Indigo's lexicon system doesn't know about custom types like `io.atcr.hold.crew`. 150 - 151 - #### 2. JSON and CBOR Struct Tags Must Match 152 - 153 - ```go 154 - // ✅ CORRECT - JSON tags match CBOR tags 155 - type CrewRecord struct { 156 - Type string `json:"$type" cborgen:"$type"` 157 - Member string `json:"member" cborgen:"member"` 158 - Role string `json:"role" cborgen:"role"` 159 - Permissions []string `json:"permissions" cborgen:"permissions"` 160 - AddedAt string `json:"addedAt" cborgen:"addedAt"` 161 - } 162 - ``` 163 - 164 - CID verification requires identical bytes from JSON and CBOR encodings. 165 - 166 - #### 3. MST ForEach Returns Full Paths 167 - 168 - ```go 169 - // ✅ CORRECT - Extract just the rkey 170 - err := repo.ForEach(ctx, "io.atcr.hold.crew", func(k string, v cid.Cid) error { 171 - // k = "io.atcr.hold.crew/3m37dr2ddit22" 172 - parts := strings.Split(k, "/") 173 - rkey := parts[len(parts)-1] // "3m37dr2ddit22" 174 - return nil 175 - }) 176 - ``` 177 - 178 - #### 4. CAR Files Must Include Full MST Path 179 - 180 - For `com.atproto.sync.getRecord`, return CAR with: 181 - 1. **Commit block** - Repo head with signature 182 - 2. **MST tree nodes** - Path from root to record 183 - 3. **Record block** - The actual record data 184 - 185 - Use `util.NewLoggingBstore()` to capture all accessed blocks. 186 - 187 - ## IAM Challenges 188 - 189 - ### Current Implementation: Service Tokens 190 - 191 - AppView uses `com.atproto.server.getServiceAuth` to get tokens for calling holds: 192 - 193 - ```go 194 - // AppView requests service token from user's PDS 195 - GET /xrpc/com.atproto.server.getServiceAuth?aud={holdDID}&lxm=com.atproto.repo.getRecord 196 - 197 - // PDS returns short-lived token (60 seconds) 198 - { "token": "eyJ..." } 199 - 200 - // AppView uses token to authenticate to hold 201 - Authorization: Bearer eyJ... 202 - ``` 203 - 204 - ### Known Issues 205 - 206 - #### 1. RPC Permission Format with IP Addresses 207 - 208 - **Problem:** Service token RPC permissions don't work with IP addresses in the audience (`aud`) field: 209 - 210 - ``` 211 - Error: RPC permission format invalid 212 - Permission: rpc:com.atproto.repo.getRecord?aud=172.28.0.3:8080#atcr_hold 213 - Issue: IP address with port not supported in aud field 214 - ``` 215 - 216 - **Impact:** Local development with IP-based hold DIDs (e.g., `did:web:172.28.0.3:8080`) fails. 217 - 218 - **Workaround:** Falls back to unauthenticated requests (works for public holds only) or use hostname-based DIDs. 219 - 220 - #### 2. Dynamic Hold Discovery Limitation 221 - 222 - **Problem:** AppView can only OAuth a user's default hold (configured in AppView), not dynamically discovered holds from sailor profiles. 223 - 224 - **Current limitation:** 225 - - User sets `defaultHold = "did:web:alice-storage.fly.dev"` in sailor profile 226 - - AppView discovers hold DID when user pushes 227 - - AppView tries to get service token for alice's hold from user's PDS 228 - - BUT: User never OAuth'd through alice's hold, only through AppView's default hold 229 - - Result: No service token available, can't authenticate to alice's hold 230 - 231 - **Why this matters:** 232 - - Users can't seamlessly use BYOS (Bring Your Own Storage) 233 - - Hold references in sailor profiles are non-functional 234 - - Limits portability and decentralization goals 235 - 236 - #### 3. Trust Model: "Trust but Verify" 237 - 238 - **Current approach:** 239 - 1. User OAuth's to AppView (credential helper flow) 240 - 2. Hold has crew member record for user (authorization) 241 - 3. AppView requests service token from user's PDS (proof) 242 - 4. Hold validates service token from user's PDS (verification) 243 - 244 - **Philosophy:** "Trust but verify" 245 - - IF user OAuth'd to AppView AND hold has crew member record for user → generally trust 246 - - BUT don't want AppView to lie → need proof from user's PDS that it's actually them 247 - - Service tokens provide this proof (user's PDS says "yes, I authorized this") 248 - 249 - **Challenge:** Service tokens work for this model, but scope/permission format issues (see #1, #2) make it fragile in practice. 250 - 251 - ### Potential Solutions 252 - 253 - #### Option A: Direct User-to-Hold Authentication (NOT IMPLEMENTED) 254 - 255 - **Note:** This option was considered but NOT implemented. ATCR uses service tokens exclusively for AppView→Hold authentication. 256 - 257 - Users would authenticate directly to holds (bypassing AppView service tokens). 258 - 259 - **Pros:** 260 - - ✅ Clear trust model (user ↔ hold) 261 - - ✅ Works with any hold (BYOS friendly) 262 - - ✅ No OAuth scope issues 263 - 264 - **Cons:** 265 - - ❌ Multiple OAuth flows (user's PDS + each hold) 266 - - ❌ Complex credential management 267 - - ❌ Poor UX (authenticate to each hold separately) 268 - 269 - #### Option B: AppView as OAuth Client 270 - 271 - AppView pre-registers with holds and uses its own credentials (not user's). 272 - 273 - **Pros:** 274 - - ✅ No OAuth scope issues 275 - - ✅ Single OAuth flow for user 276 - - ✅ Simpler credential management 277 - 278 - **Cons:** 279 - - ❌ Holds must trust AppView (centralization) 280 - - ❌ Doesn't work for unknown holds 281 - - ❌ Requires registration process 282 - 283 - #### Option C: Public Hold API 284 - 285 - Simplify by making holds public for reads, auth only for writes. 286 - 287 - **Pros:** 288 - - ✅ No OAuth complexity for reads 289 - - ✅ Works offline (no PDS dependency) 290 - 291 - **Cons:** 292 - - ❌ Private holds still need auth 293 - - ❌ Not standard ATProto pattern 294 - 295 - #### Option D: Hybrid Service Token + API Key 296 - 297 - Use service tokens when available, fall back to API keys for BYOS holds. 298 - 299 - **Pros:** 300 - - ✅ Optimal for default holds 301 - - ✅ BYOS works with API keys 302 - - ✅ Backward compatible 303 - 304 - **Cons:** 305 - - ❌ Two auth mechanisms 306 - - ❌ Not pure ATProto 307 - 308 - ### Recommended Approach 309 - 310 - **Short-term (MVP):** 311 - 1. Public holds (no auth needed for reads) 312 - 2. Default hold with service tokens (AppView-managed) 313 - 3. Document BYOS limitation 314 - 315 - **Medium-term:** 316 - 1. Hybrid approach (service tokens + API key fallback) 317 - 2. Clear security model for hold operators 318 - 319 - **Long-term:** 320 - 1. Continue using service tokens (current implementation) 321 - 2. Explore optimizations for service token caching 322 - 3. Document security model more clearly 323 - 324 - ### Understanding getServiceAuth 325 - 326 - **Purpose:** `com.atproto.server.getServiceAuth` gives a JWT to a service with access to specific functions in the user's PDS. It's a **temporary grant to a service outside of what you OAuth'd to**. 327 - 328 - **How ATCR uses it:** 329 - - User OAuth's to AppView (gets broad access to their account) 330 - - AppView needs to prove to hold that user authorized it 331 - - AppView calls user's PDS: "give me a token scoped for this hold" 332 - - User's PDS issues service token with narrow scope (e.g., `rpc:com.atproto.repo.getRecord?aud={holdDID}`) 333 - - AppView presents this token to hold as proof 334 - 335 - **Industry usage:** 336 - - `getServiceAuth` appears to be the intended pattern for inter-service auth 337 - - Not widely used yet (ATProto ecosystem is young) 338 - - Most apps use `transition:generic` scope for everything (too broad, not ideal) 339 - - RPC permission scopes are finicky and not well documented 340 - 341 - ### Open Questions 342 - 343 - 1. **RPC permission format:** Can the `aud` field in RPC permissions support IP addresses? Is this a spec limitation or implementation bug? 344 - 2. **Scope granularity:** What's the right balance between `transition:generic` (too broad) and fine-grained RPC scopes (finicky)? 345 - 3. **Dynamic discovery + auth:** How should AppView authenticate to arbitrary holds discovered from sailor profiles without pre-registration? 346 - 4. **Service token caching:** Should service tokens be cached across multiple requests? Current: 50 second cache, is this optimal? 347 - 348 - ## References 349 - 350 - - **Stream.place embedded PDS:** https://streamplace.leaflet.pub/3lut7mgni5s2k/l-quote/6_318-6_554#6 351 - - **ATProto OAuth spec:** https://atproto.com/specs/oauth 352 - - **ATProto XRPC spec:** https://atproto.com/specs/xrpc 353 - - **ATProto Service Auth:** https://docs.bsky.app/docs/api/com-atproto-server-get-service-auth 354 - - **CID spec:** https://github.com/multiformats/cid 355 - - **OCI Distribution Spec:** https://github.com/opencontainers/distribution-spec
-218
docs/HOLD_ENDPOINT_TESTS.md
··· 1 - # Hold Service Endpoint Testing Guide 2 - 3 - ## Quick Reference 4 - 5 - Your hold service: `http://172.28.0.3:8080` 6 - 7 - Default DID format for local testing: `did:web:172.28.0.3%3A8080` (URL-encoded `did:web:172.28.0.3:8080`) 8 - 9 - ## Individual cURL Commands 10 - 11 - ### 1. List Repositories 12 - ```bash 13 - curl -s "http://172.28.0.3:8080/xrpc/com.atproto.sync.listRepos" | jq . 14 - ``` 15 - 16 - **Expected response:** 17 - ```json 18 - { 19 - "repos": [ 20 - { 21 - "did": "did:web:172.28.0.3%3A8080", 22 - "head": "...", 23 - "rev": "..." 24 - } 25 - ] 26 - } 27 - ``` 28 - 29 - ### 2. Describe Repository 30 - ```bash 31 - curl -s "http://172.28.0.3:8080/xrpc/com.atproto.repo.describeRepo?repo=did:web:172.28.0.3%3A8080" | jq . 32 - ``` 33 - 34 - **Expected response:** 35 - ```json 36 - { 37 - "did": "did:web:172.28.0.3%3A8080", 38 - "handle": "172.28.0.3:8080", 39 - "didDoc": {...}, 40 - "collections": ["io.atcr.hold.captain", "io.atcr.hold.crew"] 41 - } 42 - ``` 43 - 44 - ### 3. Get Repository (CAR file) 45 - ```bash 46 - # Download entire repo as CAR file 47 - curl -s "http://172.28.0.3:8080/xrpc/com.atproto.sync.getRepo?did=did:web:172.28.0.3%3A8080" -o repo.car 48 - 49 - # Get repo diff since revision 50 - curl -s "http://172.28.0.3:8080/xrpc/com.atproto.sync.getRepo?did=did:web:172.28.0.3%3A8080&since=abc123" -o repo-diff.car 51 - ``` 52 - 53 - **Expected response:** Binary CAR (Content Addressable aRchive) file 54 - 55 - ### 4. List Captain Records 56 - ```bash 57 - curl -s "http://172.28.0.3:8080/xrpc/com.atproto.repo.listRecords?repo=did:web:172.28.0.3%3A8080&collection=io.atcr.hold.captain" | jq . 58 - ``` 59 - 60 - **Expected response:** 61 - ```json 62 - { 63 - "records": [ 64 - { 65 - "uri": "at://did:web:172.28.0.3%3A8080/io.atcr.hold.captain/self", 66 - "cid": "...", 67 - "value": { 68 - "$type": "io.atcr.hold.captain", 69 - "allowAllCrew": true, 70 - "public": false, 71 - "createdAt": "2025-10-22T..." 72 - } 73 - } 74 - ] 75 - } 76 - ``` 77 - 78 - ### 5. List Crew Records 79 - ```bash 80 - curl -s "http://172.28.0.3:8080/xrpc/com.atproto.repo.listRecords?repo=did:web:172.28.0.3%3A8080&collection=io.atcr.hold.crew" | jq . 81 - ``` 82 - 83 - **Expected response:** 84 - ```json 85 - { 86 - "records": [ 87 - { 88 - "uri": "at://did:web:172.28.0.3%3A8080/io.atcr.hold.crew/{rkey}", 89 - "cid": "...", 90 - "value": { 91 - "$type": "io.atcr.hold.crew", 92 - "did": "did:plc:...", 93 - "permissions": ["blob:read", "blob:write"], 94 - "createdAt": "2025-10-22T..." 95 - } 96 - } 97 - ] 98 - } 99 - ``` 100 - 101 - ### 6. Get Specific Record 102 - ```bash 103 - curl -s "http://172.28.0.3:8080/xrpc/com.atproto.repo.getRecord?repo=did:web:172.28.0.3%3A8080&collection=io.atcr.hold.captain&rkey=self" | jq . 104 - ``` 105 - 106 - ### 7. Get Blob 107 - ```bash 108 - # Replace with actual CID from your hold 109 - curl -s "http://172.28.0.3:8080/xrpc/com.atproto.sync.getBlob?did=did:web:172.28.0.3%3A8080&cid=bafyreiabc123..." | jq . 110 - ``` 111 - 112 - **Expected response (for OCI blobs):** 113 - ```json 114 - { 115 - "url": "https://s3.amazonaws.com/bucket/path?presigned-params...", 116 - "expiresAt": "2025-10-22T12:15:00Z" 117 - } 118 - ``` 119 - 120 - ### 8. Subscribe to Repository Events (WebSocket) 121 - 122 - Using **websocat** (recommended): 123 - ```bash 124 - # Install: cargo install websocat 125 - websocat "ws://172.28.0.3:8080/xrpc/com.atproto.sync.subscribeRepos" 126 - ``` 127 - 128 - Using **wscat**: 129 - ```bash 130 - # Install: npm install -g wscat 131 - wscat -c "ws://172.28.0.3:8080/xrpc/com.atproto.sync.subscribeRepos" 132 - ``` 133 - 134 - Using **curl** (HTTP upgrade - may not work with all servers): 135 - ```bash 136 - curl -i -N \ 137 - -H "Connection: Upgrade" \ 138 - -H "Upgrade: websocket" \ 139 - -H "Sec-WebSocket-Version: 13" \ 140 - -H "Sec-WebSocket-Key: $(echo -n "test" | base64)" \ 141 - "http://172.28.0.3:8080/xrpc/com.atproto.sync.subscribeRepos" 142 - ``` 143 - 144 - **Expected response:** Stream of CBOR-encoded events (commits, identities, handles, etc.) 145 - 146 - ## DID Resolution 147 - 148 - ### Get DID Document 149 - ```bash 150 - curl -s "http://172.28.0.3:8080/.well-known/did.json" | jq . 151 - ``` 152 - 153 - **Expected response:** 154 - ```json 155 - { 156 - "@context": ["https://www.w3.org/ns/did/v1"], 157 - "id": "did:web:172.28.0.3%3A8080", 158 - "service": [ 159 - { 160 - "id": "#atproto_pds", 161 - "type": "AtprotoPersonalDataServer", 162 - "serviceEndpoint": "http://172.28.0.3:8080" 163 - } 164 - ] 165 - } 166 - ``` 167 - 168 - ### Get DID from Handle 169 - ```bash 170 - curl -s "http://172.28.0.3:8080/.well-known/atproto-did" 171 - ``` 172 - 173 - **Expected response:** Plain text DID 174 - ``` 175 - did:web:172.28.0.3%3A8080 176 - ``` 177 - 178 - ## Running the Test Script 179 - 180 - ```bash 181 - # Default (uses 172.28.0.3:8080) 182 - ./test-hold-endpoints.sh 183 - 184 - # Custom hold URL 185 - ./test-hold-endpoints.sh "http://localhost:8080" 186 - 187 - # Custom hold URL and DID 188 - ./test-hold-endpoints.sh "http://localhost:8080" "did:web:localhost%3A8080" 189 - ``` 190 - 191 - ## Troubleshooting 192 - 193 - ### "Connection refused" 194 - - Ensure hold service is running: `docker ps` or check process 195 - - Verify IP address: `docker inspect <container> | grep IPAddress` 196 - 197 - ### "Empty response" or "404 Not Found" 198 - - Check hold service logs for errors 199 - - Verify DID format (use URL-encoded version with `%3A` for `:`) 200 - - Ensure hold has been initialized (should have captain record) 201 - 202 - ### WebSocket connection fails 203 - - Install websocat: `cargo install websocat` 204 - - Or install wscat: `npm install -g wscat` 205 - - WebSocket endpoints only work with proper WS clients, not regular curl 206 - 207 - ### "No records found" 208 - - Captain record created on hold startup if `HOLD_OWNER` is set 209 - - Crew records created when users call `io.atcr.hold.requestCrew` 210 - - Blobs only exist after pushing container images 211 - 212 - ## Next Steps 213 - 214 - After verifying these endpoints work: 215 - 1. Test OCI upload endpoints (requires authentication) 216 - 2. Push a real container image to create blob data 217 - 3. Test blob retrieval with real CIDs 218 - 4. Monitor WebSocket events during pushes
-183
docs/README_EMBEDDING.md
··· 1 - # README Embedding Feature 2 - 3 - ## Overview 4 - 5 - Enhance the repository page (`/r/{handle}/{repository}`) with embedded README content fetched from the source repository, similar to Docker Hub's "Overview" tab. 6 - 7 - ## Current State 8 - 9 - The repository page currently shows: 10 - - Repository metadata from OCI annotations 11 - - Short description from `org.opencontainers.image.description` 12 - - External links to source (`org.opencontainers.image.source`) and docs (`org.opencontainers.image.documentation`) 13 - - Tags and manifests lists 14 - 15 - ## Proposed Feature 16 - 17 - Automatically fetch and render README.md content from the source repository when available, displaying it in an "Overview" section on the repository page. 18 - 19 - ## Implementation Approach 20 - 21 - ### 1. Source URL Detection 22 - 23 - Parse `org.opencontainers.image.source` annotation to detect GitHub repositories: 24 - - Pattern: `https://github.com/{owner}/{repo}` 25 - - Extract owner and repo name 26 - 27 - ### 2. README Fetching 28 - 29 - Fetch README.md from GitHub via raw content URL: 30 - ``` 31 - https://raw.githubusercontent.com/{owner}/{repo}/{branch}/README.md 32 - ``` 33 - 34 - Try multiple branch names in order: 35 - 1. `main` 36 - 2. `master` 37 - 3. `develop` 38 - 39 - Fallback if README not found or fetch fails. 40 - 41 - ### 3. Markdown Rendering 42 - 43 - Use a Go markdown library to render README content: 44 - - **Option A**: `github.com/gomarkdown/markdown` - Pure Go, fast 45 - - **Option B**: `github.com/yuin/goldmark` - CommonMark compliant, extensible 46 - - **Option C**: Call GitHub's markdown API (requires network call) 47 - 48 - Recommended: `goldmark` for CommonMark compliance and GitHub-flavored markdown support. 49 - 50 - ### 4. Caching Strategy 51 - 52 - Cache rendered README to avoid repeated fetches: 53 - 54 - **Option A: In-memory cache** 55 - - Simple, fast 56 - - Lost on restart 57 - - Good for MVP 58 - 59 - **Option B: Database cache** 60 - - Add `readme_html` column to `manifests` table 61 - - Update on new manifest pushes 62 - - Persistent across restarts 63 - - Background job to refresh periodically 64 - 65 - **Option C: Hybrid** 66 - - Cache in database 67 - - Also cache in memory for frequently accessed repos 68 - - TTL-based refresh (e.g., 1 hour) 69 - 70 - ### 5. UI Integration 71 - 72 - Add "Overview" section to repository page: 73 - - Show after repository header, before tags/manifests 74 - - Render markdown as HTML 75 - - Apply CSS styling for markdown elements (headings, code blocks, tables, etc.) 76 - - Handle images in README (may need to proxy or allow external images) 77 - 78 - ## Implementation Steps 79 - 80 - 1. **Add README fetcher** (`pkg/appview/readme/fetcher.go`) 81 - ```go 82 - type Fetcher struct { 83 - httpClient *http.Client 84 - cache Cache 85 - } 86 - 87 - func (f *Fetcher) FetchGitHubReadme(sourceURL string) (string, error) 88 - func (f *Fetcher) RenderMarkdown(content string) (string, error) 89 - ``` 90 - 91 - 2. **Update database schema** (optional, for caching) 92 - ```sql 93 - ALTER TABLE manifests ADD COLUMN readme_html TEXT; 94 - ALTER TABLE manifests ADD COLUMN readme_fetched_at TIMESTAMP; 95 - ``` 96 - 97 - 3. **Update RepositoryPageHandler** 98 - - Fetch README for repository 99 - - Pass rendered HTML to template 100 - 101 - 4. **Update repository.html template** 102 - - Add "Overview" section 103 - - Render HTML safely (use `template.HTML`) 104 - 105 - 5. **Add markdown CSS** 106 - - Style headings, code blocks, lists, tables 107 - - Syntax highlighting for code blocks (optional) 108 - 109 - ## Security Considerations 110 - 111 - 1. **XSS Prevention** 112 - - Sanitize HTML output from markdown renderer 113 - - Use `bluemonday` or similar HTML sanitizer 114 - - Only allow safe HTML elements and attributes 115 - 116 - 2. **Rate Limiting** 117 - - Cache aggressively to avoid hitting GitHub rate limits 118 - - Consider GitHub API instead of raw content (requires token but higher limits) 119 - - Handle 429 responses gracefully 120 - 121 - 3. **Image Handling** 122 - - README may contain images with relative URLs 123 - - Options: 124 - - Rewrite image URLs to absolute GitHub URLs 125 - - Proxy images through ATCR (caching, security) 126 - - Block external images (simplest, but breaks many READMEs) 127 - 128 - 4. **Content Size** 129 - - Limit README size (e.g., 1MB max) 130 - - Truncate very long READMEs with "View on GitHub" link 131 - 132 - ## Future Enhancements 133 - 134 - 1. **Support other platforms** 135 - - GitLab: `https://gitlab.com/{owner}/{repo}/-/raw/{branch}/README.md` 136 - - Gitea/Forgejo 137 - - Bitbucket 138 - 139 - 2. **Custom README upload** 140 - - Allow users to upload custom README via UI 141 - - Store in PDS as `io.atcr.readme` record 142 - - Priority: custom > source repo 143 - 144 - 3. **Automatic updates** 145 - - Background job to refresh READMEs periodically 146 - - Webhook support to update on push to source repo 147 - 148 - 4. **Syntax highlighting** 149 - - Use highlight.js or similar for code blocks 150 - - Support multiple languages 151 - 152 - ## Example Flow 153 - 154 - 1. User pushes image with label: `org.opencontainers.image.source=https://github.com/alice/myapp` 155 - 2. Manifest stored with source URL annotation 156 - 3. User visits `/r/alice/myapp` 157 - 4. RepositoryPageHandler: 158 - - Checks cache for README 159 - - If not cached or expired: 160 - - Fetches `https://raw.githubusercontent.com/alice/myapp/main/README.md` 161 - - Renders markdown to HTML 162 - - Sanitizes HTML 163 - - Caches result 164 - - Passes README HTML to template 165 - 5. Template renders Overview section with README content 166 - 167 - ## Dependencies 168 - 169 - ```go 170 - // Markdown rendering 171 - github.com/yuin/goldmark v1.6.0 172 - github.com/yuin/goldmark-emoji v1.0.2 // GitHub emoji support 173 - 174 - // HTML sanitization 175 - github.com/microcosm-cc/bluemonday v1.0.26 176 - ``` 177 - 178 - ## References 179 - 180 - - [OCI Image Spec - Annotations](https://github.com/opencontainers/image-spec/blob/main/annotations.md) 181 - - [Docker Hub Overview tab behavior](https://hub.docker.com/) 182 - - [Goldmark documentation](https://github.com/yuin/goldmark) 183 - - [GitHub raw content URLs](https://raw.githubusercontent.com/)
-394
docs/SAILOR.md
··· 1 - # Sailor Profile System 2 - 3 - ## Overview 4 - 5 - The sailor profile system allows users to choose which hold (storage service) to use for their container images. This enables: 6 - - **Personal holds** - Use your own S3/Storj/Minio storage 7 - - **Shared holds** - Join a team or community hold 8 - - **Default holds** - Use AppView's default storage (free tier) 9 - - **Transparent infrastructure** - Hold choice doesn't affect image URL 10 - 11 - ## Concepts 12 - 13 - **Sailor Profile** (`io.atcr.sailor.profile`): 14 - - Record stored in user's PDS 15 - - Contains `defaultHold` preference (DID or URL) 16 - - Created automatically on first authentication 17 - - Managed via web UI or ATProto client 18 - 19 - **Hold Discovery Priority**: 20 - 1. User's sailor profile `defaultHold` (if set) 21 - 2. User's own hold records (`io.atcr.hold`) - legacy 22 - 3. AppView's `default_hold_did` configuration 23 - 24 - ## Sailor Profile Record 25 - 26 - ```json 27 - { 28 - "$type": "io.atcr.sailor.profile", 29 - "defaultHold": "did:web:hold.example.com", 30 - "createdAt": "2025-10-02T12:00:00Z", 31 - "updatedAt": "2025-10-02T12:00:00Z" 32 - } 33 - ``` 34 - 35 - **Fields:** 36 - - `defaultHold` (string, optional) - Hold DID or URL (auto-normalized to DID) 37 - - `createdAt` (datetime, required) - Profile creation timestamp 38 - - `updatedAt` (datetime, required) - Last update timestamp 39 - 40 - **Record key:** Always `"self"` (only one profile per user) 41 - 42 - **Collection:** `io.atcr.sailor.profile` 43 - 44 - ## Profile Management 45 - 46 - ### Automatic Creation 47 - 48 - Profiles are created automatically on first authentication: 49 - 50 - ```go 51 - // During OAuth login or Basic Auth token exchange 52 - func (h *Handler) HandleCallback(w http.ResponseWriter, r *http.Request) { 53 - // ... OAuth flow ... 54 - 55 - // Create ATProto client with user's OAuth session 56 - client := atproto.NewClientWithIndigoClient(pdsEndpoint, did, apiClient) 57 - 58 - // Ensure profile exists (creates with AppView's default if not) 59 - err := atproto.EnsureProfile(ctx, client, appViewDefaultHoldDID) 60 - } 61 - ``` 62 - 63 - **Behavior:** 64 - - If profile exists → no-op 65 - - If profile doesn't exist → creates with `defaultHold` set to AppView's default 66 - - If AppView has no default configured → creates with empty `defaultHold` 67 - 68 - ### Web UI Management 69 - 70 - Users can update their profile via the settings page (`/settings`): 71 - 72 - **View current profile:** 73 - ``` 74 - GET /settings 75 - → Shows current defaultHold value 76 - ``` 77 - 78 - **Update defaultHold:** 79 - ``` 80 - POST /api/settings/update-hold 81 - Form data: hold_endpoint=did:web:team-hold.fly.dev 82 - 83 - → Updates sailor profile in user's PDS 84 - → Returns success confirmation 85 - ``` 86 - 87 - **Implementation** (`pkg/appview/handlers/settings.go`): 88 - - Requires OAuth session (user must be logged in) 89 - - Fetches existing profile or creates new one 90 - - Normalizes URLs to DIDs automatically 91 - - Updates `updatedAt` timestamp 92 - 93 - ### ATProto Client Management 94 - 95 - Users can also manage their profile using standard ATProto tools: 96 - 97 - **Get profile:** 98 - ```bash 99 - atproto get-record \ 100 - --collection io.atcr.sailor.profile \ 101 - --rkey self 102 - ``` 103 - 104 - **Update profile:** 105 - ```bash 106 - atproto put-record \ 107 - --collection io.atcr.sailor.profile \ 108 - --rkey self \ 109 - --value '{ 110 - "$type": "io.atcr.sailor.profile", 111 - "defaultHold": "did:web:my-hold.example.com", 112 - "updatedAt": "2025-10-20T12:00:00Z" 113 - }' 114 - ``` 115 - 116 - **Clear default hold** (opt out): 117 - ```bash 118 - atproto put-record \ 119 - --collection io.atcr.sailor.profile \ 120 - --rkey self \ 121 - --value '{ 122 - "$type": "io.atcr.sailor.profile", 123 - "defaultHold": "", 124 - "updatedAt": "2025-10-20T12:00:00Z" 125 - }' 126 - ``` 127 - 128 - ## URL-to-DID Migration 129 - 130 - The system automatically migrates old URL-based `defaultHold` values to DID format for consistency: 131 - 132 - **Old format (deprecated):** 133 - ```json 134 - { 135 - "defaultHold": "https://hold.example.com" 136 - } 137 - ``` 138 - 139 - **New format (preferred):** 140 - ```json 141 - { 142 - "defaultHold": "did:web:hold.example.com" 143 - } 144 - ``` 145 - 146 - **Migration behavior:** 147 - - `GetProfile()` detects URL format automatically 148 - - Converts URL → DID transparently (strips protocol, converts to `did:web:`) 149 - - Persists migration to PDS in background goroutine 150 - - Uses locks to prevent duplicate migrations 151 - - Completely transparent to user 152 - 153 - **Why DIDs?** 154 - - **Portable**: DIDs work offline, URLs require DNS 155 - - **Canonical**: One DID per hold, multiple URLs possible 156 - - **Standard**: ATProto uses DIDs for identity 157 - 158 - ## Hold Discovery Flow 159 - 160 - When a user pushes an image, AppView discovers which hold to use: 161 - 162 - ``` 163 - 1. User: docker push atcr.io/alice/myapp:latest 164 - 165 - 2. AppView resolves alice → did:plc:alice123 166 - 167 - 3. AppView calls findHoldDID(did, pdsEndpoint): 168 - a. Query alice's PDS for io.atcr.sailor.profile/self 169 - b. If profile.defaultHold is set → use it 170 - c. Else check alice's io.atcr.hold records (legacy) 171 - d. Else use AppView's default_hold_did 172 - 173 - 4. Found: alice.profile.defaultHold = "did:web:team-hold.fly.dev" 174 - 175 - 5. AppView uses team-hold.fly.dev for blob storage 176 - 177 - 6. Manifest stored in alice's PDS includes: 178 - - holdDid: "did:web:team-hold.fly.dev" (for future pulls) 179 - - holdEndpoint: "https://team-hold.fly.dev" (backward compat) 180 - ``` 181 - 182 - **Implementation** (`pkg/appview/middleware/registry.go:findHoldDID()`): 183 - 184 - ```go 185 - func (nr *NamespaceResolver) findHoldDID(ctx context.Context, did, pdsEndpoint string) string { 186 - client := atproto.NewClient(pdsEndpoint, did, "") 187 - 188 - // 1. Check sailor profile 189 - profile, err := atproto.GetProfile(ctx, client) 190 - if profile != nil && profile.DefaultHold != "" { 191 - return profile.DefaultHold // DID or URL (auto-normalized) 192 - } 193 - 194 - // 2. Check own hold records (legacy) 195 - records, _ := client.ListRecords(ctx, "io.atcr.hold", 10) 196 - for _, record := range records { 197 - // Return first hold's endpoint 198 - if holdRecord.Endpoint != "" { 199 - return atproto.ResolveHoldDIDFromURL(holdRecord.Endpoint) 200 - } 201 - } 202 - 203 - // 3. Use AppView default 204 - return nr.defaultHoldDID 205 - } 206 - ``` 207 - 208 - ## Use Cases 209 - 210 - ### 1. Default Hold (Free Tier) 211 - 212 - User doesn't need to do anything: 213 - 214 - ``` 215 - 1. User authenticates to atcr.io 216 - 2. Profile created with defaultHold = AppView's default 217 - 3. User pushes images → blobs go to default hold 218 - ``` 219 - 220 - **Profile:** 221 - ```json 222 - { 223 - "defaultHold": "did:web:hold01.atcr.io" 224 - } 225 - ``` 226 - 227 - ### 2. Join Team Hold 228 - 229 - User joins a shared team hold: 230 - 231 - ``` 232 - 1. Team admin deploys hold service (did:web:team-hold.fly.dev) 233 - 2. Team admin adds user to crew (via hold's PDS) 234 - 3. User updates profile: 235 - - Via web UI: /settings → set hold to "did:web:team-hold.fly.dev" 236 - - Or via ATProto client: put-record 237 - 4. User pushes images → blobs go to team hold 238 - ``` 239 - 240 - **Profile:** 241 - ```json 242 - { 243 - "defaultHold": "did:web:team-hold.fly.dev" 244 - } 245 - ``` 246 - 247 - **Benefits:** 248 - - Team pays for storage (not individual users) 249 - - Centralized access control 250 - - Shared bandwidth limits 251 - 252 - ### 3. Personal Hold (BYOS) 253 - 254 - User deploys their own hold: 255 - 256 - ``` 257 - 1. User deploys hold service to Fly.io (did:web:alice-hold.fly.dev) 258 - 2. Hold auto-creates captain + crew records on first run 259 - 3. User updates profile to use their hold 260 - 4. User pushes images → blobs go to personal hold 261 - ``` 262 - 263 - **Profile:** 264 - ```json 265 - { 266 - "defaultHold": "did:web:alice-hold.fly.dev" 267 - } 268 - ``` 269 - 270 - **Benefits:** 271 - - Full control over storage 272 - - Choose storage provider (S3, Storj, Minio, etc.) 273 - - No quotas/limits (except what you pay for) 274 - 275 - ### 4. Opt Out of Defaults 276 - 277 - User wants to use only their own hold records (legacy model): 278 - 279 - ```json 280 - { 281 - "defaultHold": "" 282 - } 283 - ``` 284 - 285 - **Behavior:** 286 - - Skips profile's defaultHold (set to empty/null) 287 - - Falls back to `io.atcr.hold` records in user's PDS 288 - - If no hold records found → uses AppView default 289 - 290 - ## Architecture Notes 291 - 292 - ### Why Sailor Profile? 293 - 294 - **Problem solved:** 295 - - Users can be crew members of multiple holds 296 - - Need explicit way to choose which hold to use 297 - - Want to support both personal and shared holds 298 - 299 - **Without sailor profile:** 300 - ``` 301 - Alice is crew of: 302 - - team-hold.fly.dev (team storage) 303 - - community-hold.fly.dev (community storage) 304 - 305 - Which one should AppView use? 🤔 306 - ``` 307 - 308 - **With sailor profile:** 309 - ``` 310 - Alice sets profile.defaultHold = "did:web:team-hold.fly.dev" 311 - → AppView knows to use team hold 312 - → Alice can change anytime via settings 313 - ``` 314 - 315 - ### Image Ownership vs Hold Choice 316 - 317 - **Key insight:** Image ownership stays with the user, hold is just infrastructure. 318 - 319 - **URL structure:** `atcr.io/<owner>/<image>:<tag>` 320 - - Owner = Alice (clear ownership) 321 - - Hold = Team storage (infrastructure detail) 322 - 323 - **Analogy:** Like choosing an S3 region 324 - - Your files, your ownership 325 - - Region is just where bits live 326 - - Can move regions without changing ownership 327 - 328 - ### Historical Hold References 329 - 330 - Manifests store `holdDid` for immutable blob location tracking: 331 - 332 - ```json 333 - { 334 - "digest": "sha256:abc123", 335 - "holdDid": "did:web:team-hold.fly.dev", 336 - "holdEndpoint": "https://team-hold.fly.dev", 337 - "layers": [...] 338 - } 339 - ``` 340 - 341 - **Why store hold in manifest?** 342 - - Pull uses historical reference (not re-discovered) 343 - - Image stays pullable even if user changes defaultHold 344 - - Blobs fetched from where they were originally pushed 345 - - Immutable references (manifests don't change) 346 - 347 - **Hold cache:** 348 - - In-memory cache: `(userDID, repository) → holdDid` 349 - - TTL: 10 minutes (covers typical pull operation) 350 - - Avoids re-querying PDS for every blob 351 - 352 - ## Configuration 353 - 354 - ### AppView Configuration 355 - 356 - ```bash 357 - # Default hold for new users 358 - ATCR_DEFAULT_HOLD_DID=did:web:hold01.atcr.io 359 - 360 - # Test mode: fallback to default if user's hold unreachable 361 - ATCR_TEST_MODE=false 362 - ``` 363 - 364 - **Test mode behavior:** 365 - - Checks if user's defaultHold is reachable (HTTP/HTTPS) 366 - - Falls back to AppView default if unreachable 367 - - Useful for local development (prevents errors from unreachable holds) 368 - 369 - ### Legacy Support 370 - 371 - **Old hold registration model** (`io.atcr.hold` records in user's PDS): 372 - - Still supported for backward compatibility 373 - - Checked if profile.defaultHold is empty 374 - - New deployments should use sailor profiles instead 375 - 376 - **Migration path:** 377 - - Existing holds continue to work 378 - - Users with `io.atcr.hold` records can set profile.defaultHold 379 - - Profile takes priority over hold records 380 - 381 - ## Future Improvements 382 - 383 - 1. **Multi-hold support** - Set different holds for different repositories 384 - 2. **Hold suggestions** - Recommend holds based on geography/cost 385 - 3. **Hold migration tools** - Move blobs between holds 386 - 4. **Profile templates** - Pre-configured profiles for teams 387 - 5. **Hold analytics** - Show storage usage per hold in UI 388 - 389 - ## References 390 - 391 - - [BYOS.md](./BYOS.md) - BYOS deployment and hold management 392 - - [EMBEDDED_PDS.md](./EMBEDDED_PDS.md) - Hold's embedded PDS architecture 393 - - [CREW_ACCESS_CONTROL.md](./CREW_ACCESS_CONTROL.md) - Crew membership and permissions 394 - - [ATProto Lexicon Spec](https://atproto.com/specs/lexicon)
+639
docs/TEST_COVERAGE_GAPS.md
··· 1 + # Test Coverage Gaps 2 + 3 + **Overall Coverage:** 39.0% (improved from 37.7%, +1.3%) 4 + 5 + This document tracks files in the `pkg/` directory that need test coverage, organized by package. Data is based on actual `coverage.out` analysis. 6 + 7 + **Last Updated:** After adding tests for atproto utilities, handlers improvements, and OAuth browser functionality. 8 + 9 + ## Recent Achievements 🎯 10 + 11 + In this testing session, we achieved: 12 + 13 + 1. **pkg/appview/handlers** - 2.1% → 19.7% (**+17.6%** 🎉) 14 + - Significant improvement in web handler coverage 15 + - Better test coverage across handler functions 16 + 17 + 2. **pkg/atproto** - 26.1% → 27.8% (**+1.7%**) 18 + - New test files added: 19 + - directory_test.go (NEW) 20 + - endpoints_test.go (NEW) 21 + - utils_test.go (NEW) 22 + - Improved lexicon tests 23 + 24 + 3. **pkg/auth/oauth** - 48.3% → 50.7% (**+2.4%**) 25 + - browser_test.go improvements 26 + - Better OAuth flow coverage 27 + 28 + 4. **Overall improvement** - 37.7% → 39.0% (**+1.3%**) 29 + - Cumulative improvement from baseline: 31.2% → 39.0% (**+7.8%**) 30 + 31 + **Note:** pkg/appview/db coverage decreased slightly from 44.8% → 41.2% (-3.6%), likely due to additional untested code paths being tracked in existing test files. 32 + 33 + **Next Priority:** Continue with storage blob write operations (proxy_blob_store.go Put/Create/Writer methods) 34 + 35 + --- 36 + 37 + Legend: 38 + - ⭐ **Critical Priority** - Core functionality that must be tested 39 + - 🔴 **High Priority** - Important functionality with security/data implications 40 + - 🟡 **Medium Priority** - Supporting functionality 41 + - 🟢 **Low Priority** - Nice-to-have, less critical features 42 + - ✅ **Good Coverage** - Package has >70% coverage 43 + - 📊 **Partial Coverage** - File has some coverage but needs more 44 + - 🎯 **Recently Improved** - Coverage significantly improved in latest update 45 + 46 + --- 47 + 48 + ## Package Coverage Summary 49 + 50 + | Package | Coverage | Status | Priority | Change | 51 + |---------|----------|--------|----------|--------| 52 + | `pkg/hold` | 98.0% | ✅ Excellent | - | - | 53 + | `pkg/s3` | 97.4% | ✅ Excellent | - | - | 54 + | `pkg/appview/licenses` | 93.0% | ✅ Excellent | - | - | 55 + | `pkg/appview` | 81.9% | ✅ Excellent | - | +0.1% | 56 + | `pkg/logging` | 75.0% | ✅ Good | - | - | 57 + | `pkg/auth/token` | 68.8% | 🟡 Good | - | - | 58 + | `pkg/appview/middleware` | 57.8% | 🟡 Good | - | - | 59 + | `pkg/auth` | 55.7% | 🟡 Needs work | Medium | - | 60 + | `pkg/hold/oci` | 51.9% | 🟡 Needs work | Medium | - | 61 + | `pkg/appview/storage` | 51.4% | 🟡 Needs work | **High** | - | 62 + | `pkg/auth/oauth` | 50.7% | 🟡 Needs work | High | 🎯 **+2.4%** | 63 + | `pkg/hold/pds` | 47.2% | 🟡 Needs work | Low | - | 64 + | `pkg/appview/db` | 41.2% | 🟡 Needs work | Medium | 🔴 **-3.6%** | 65 + | `pkg/appview/holdhealth` | 41.0% | 🟡 Needs work | Low | - | 66 + | `pkg/atproto` | 27.8% | 🟡 Needs work | High | 🎯 **+1.7%** | 67 + | `pkg/appview/readme` | 27.2% | 🟡 Needs work | Low | - | 68 + | `pkg/appview/handlers` | 19.7% | 🟡 Needs work | Low | 🎯 **+17.6%** | 69 + | `pkg/appview/jetstream` | 11.6% | 🟡 Needs work | Medium | - | 70 + | `pkg/appview/routes` | 10.4% | 🟡 Needs work | Low | - | 71 + 72 + **⚠️ Notes on Coverage Changes:** 73 + 74 + Several packages show decreased percentages despite improvements. This is due to: 75 + 1. **New test files added** - Coverage now tracks previously untested files 76 + 2. **Statement weighting** - Large untested functions (like `Repository()` at 0% in middleware) lower overall package percentage 77 + 3. **More comprehensive tracking** - Better coverage analysis reveals gaps that were previously invisible 78 + 79 + **Specific file-level improvements (hidden by package averages):** 80 + - `pkg/appview/middleware/auth.go`: 98.8% average (excellent) 81 + - `pkg/appview/middleware/registry.go`: 90.8% average (excellent) 82 + - `pkg/appview/storage/manifest_store.go`: 0% → 85%+ (critical improvement) 83 + - `pkg/atproto/client.go`: 74.8% average (good) 84 + - `pkg/atproto/resolver.go`: 74.5% average (good) 85 + 86 + **Key Insight:** Focus on file-level coverage for critical paths rather than package averages, as new comprehensive testing can paradoxically lower package percentages while improving actual test quality. 87 + 88 + --- 89 + 90 + ## Recently Completed ✅ 91 + 92 + ### ✅ pkg/appview/storage/manifest_store.go (85%+ coverage) - **COMPLETED** 🎉 93 + 94 + **Achievement:** Improved from 0% to 85%+ (Critical Priority #1 from previous plan) 95 + 96 + **Well-covered functions:** 97 + - `NewManifestStore()` - 100% ✅ 98 + - `Exists()` - 100% ✅ 99 + - `Get()` - 85.7% ✅ 100 + - `Put()` - 75.5% ✅ 101 + - `Delete()` - 100% ✅ 102 + - `digestToRKey()` - 100% ✅ 103 + - `GetLastFetchedHoldDID()` - 100% ✅ 104 + - `extractConfigLabels()` - 90.0% ✅ 105 + - `resolveDIDToHTTPSEndpoint()` - 100% ✅ 106 + 107 + **Why This Was Critical:** 108 + - Core OCI manifest operations (store/retrieve/delete) 109 + - ATProto record conversion 110 + - Digest-based addressing 111 + - Essential for registry functionality 112 + 113 + **Remaining gaps:** 114 + - `notifyHoldAboutManifest()` - 0% (background notification, less critical) 115 + - `refreshReadmeCache()` - 11.8% (UI feature, lower priority) 116 + 117 + ## Critical Priority: Core Registry Functionality 118 + 119 + These components are essential to registry operation and still need coverage. 120 + 121 + ### ⭐ pkg/appview/storage (51.4% coverage) - **HIGHEST PRIORITY** 122 + 123 + **Status:** Manifest operations completed ✅, blob write operations remain critical gap 124 + 125 + #### proxy_blob_store.go (Partial coverage) - **HIGHEST PRIORITY** 🎯 126 + 127 + **Why Critical:** Handles all blob upload/download operations for the registry 128 + 129 + **Well-covered (blob reads and helpers):** 130 + - `NewProxyBlobStore()` - 100% ✅ 131 + - `doAuthenticatedRequest()` - 100% ✅ 132 + - `getPresignedURL()` - 70% ✅ 133 + - `startMultipartUpload()` - 70% ✅ 134 + - `getPartUploadInfo()` - 70% ✅ 135 + - `completeMultipartUpload()` - 75% ✅ 136 + - `abortMultipartUpload()` - 70.6% ✅ 137 + - `Get()` - 68.8% ✅ 138 + - `Open()` - 62.5% ✅ 139 + 140 + **Needs improvement:** 141 + - `Stat()` - 26.3% 📊 142 + - `checkReadAccess()` - 25.0% 📊 143 + 144 + **Critical gaps (0% coverage):** 145 + - `Put()` - Main upload entry point (CRITICAL) 146 + - `Create()` - Blob creation (CRITICAL) 147 + - `Delete()` - Blob deletion 148 + - `ServeBlob()` - Blob serving 149 + - `Resume()` - Upload resumption 150 + - `checkWriteAccess()` - Write authorization 151 + 152 + **Writer interface (0% coverage - CRITICAL for uploads):** 153 + - `Write()` - Write data to multipart upload 154 + - `flushPart()` - Flush buffered part 155 + - `ReadFrom()` - io.ReaderFrom implementation 156 + - `Commit()` - Finalize upload 157 + - `Cancel()` - Cancel upload 158 + - `Close()` - Close writer 159 + - `Size()` - Get written size 160 + - `ID()` - Get upload ID 161 + - `StartedAt()` - Get start time 162 + - `Seek()` - Seek in upload 163 + 164 + **Test Scenarios Needed:** 165 + 1. Full multipart upload flow: `Put()` → `Create()` → `Write()` → `Commit()` 166 + 2. Large blob upload with multiple parts 167 + 3. Upload cancellation and cleanup 168 + 4. Error handling for failed uploads 169 + 5. Upload resumption with `Resume()` 170 + 6. Write authorization checks 171 + 7. Delete operations 172 + 173 + #### routing_repository.go (Partial coverage) - **HIGH PRIORITY** 174 + 175 + **Current coverage:** 176 + - `Manifests()` - Returns manifest store (mostly tested via manifest_store tests) 177 + - `Blobs()` - 0% coverage (blob routing logic untested) 178 + - `Repository()` - 0% coverage (wrapper method, lower priority) 179 + 180 + **Test Scenarios Needed:** 181 + - Blob routing using cached hold DID (pull scenario) 182 + - Blob routing using discovered hold DID (push scenario) 183 + - Error handling for missing hold 184 + - Hold cache integration 185 + 186 + #### crew.go (11.1% coverage) - **MEDIUM PRIORITY** 187 + **Functions:** 188 + - `EnsureCrewMembership()` - 11.1% 189 + - `requestCrewMembership()` - 0% 190 + 191 + **Test Scenarios Needed:** 192 + - Valid crew member with permissions 193 + - Crew member without required permission 194 + - Non-member access denial 195 + - Crew membership request flow 196 + 197 + #### hold_cache.go (93% coverage) - **EXCELLENT** ✅ 198 + 199 + **Well-covered:** 200 + - `init()` - 80% ✅ 201 + - `GetGlobalHoldCache()` - 100% ✅ 202 + - `Set()` - 100% ✅ 203 + - `Get()` - 100% ✅ 204 + - `Cleanup()` - 100% ✅ 205 + 206 + --- 207 + 208 + ## High Priority: Supporting Infrastructure 209 + 210 + ### 🔴 pkg/auth/oauth (48.3% coverage, improved from 40.4%) 211 + 212 + OAuth implementation has test files but many functions remain untested. 213 + 214 + #### refresher.go (Partial coverage) 215 + 216 + **Well-covered:** 217 + - `NewRefresher()` - 100% ✅ 218 + - `SetUISessionStore()` - 100% ✅ 219 + 220 + **Critical gaps (0% coverage):** 221 + - `GetSession()` - 0% (CRITICAL - main session retrieval) 222 + - `resumeSession()` - 0% (CRITICAL - session resumption) 223 + - `InvalidateSession()` - 0% 224 + - `GetSessionID()` - 0% 225 + 226 + **Test Scenarios Needed:** 227 + - Session retrieval and caching 228 + - Token refresh flow 229 + - Concurrent refresh handling (per-DID locking) 230 + - Cache expiration 231 + - Error handling for failed refreshes 232 + 233 + #### server.go (Partial coverage) 234 + 235 + **Well-covered:** 236 + - `NewServer()` - 100% ✅ 237 + - `SetRefresher()` - 100% ✅ 238 + - `SetUISessionStore()` - 100% ✅ 239 + - `SetPostAuthCallback()` - 100% ✅ 240 + - `renderRedirectToSettings()` - 80.0% ✅ 241 + - `renderError()` - 83.3% ✅ 242 + 243 + **Critical gaps:** 244 + - `ServeAuthorize()` - 36.8% (needs more coverage) 245 + - `ServeCallback()` - 16.3% (CRITICAL - main OAuth callback handler) 246 + 247 + **Test Scenarios Needed:** 248 + - Authorization flow initiation 249 + - Callback handling with valid code 250 + - Error handling for invalid state/code 251 + - DPoP proof validation 252 + - State parameter validation 253 + 254 + #### interactive.go (41.7% coverage) 255 + **Function:** 256 + - `InteractiveFlowWithCallback()` - 41.7% 257 + 258 + **Test Scenarios Needed:** 259 + - Two-phase callback setup 260 + - Browser interaction flow 261 + - Callback server lifecycle 262 + 263 + #### client.go (Excellent coverage) ✅ 264 + 265 + **Well-covered:** 266 + - `NewApp()` - 100% ✅ 267 + - `NewAppWithScopes()` - 100% ✅ 268 + - `NewClientConfigWithScopes()` - 80.0% ✅ 269 + - `GetConfig()` - 100% ✅ 270 + - `StartAuthFlow()` - 75.0% ✅ 271 + - `ClientIDWithScopes()` - 75.0% ✅ 272 + - `RedirectURI()` - 100% ✅ 273 + - `GetDefaultScopes()` - 100% ✅ 274 + - `ScopesMatch()` - 100% ✅ 275 + 276 + **Improved (from previous 0%):** 277 + - `ProcessCallback()` - Improved coverage 278 + - `ResumeSession()` - Improved coverage 279 + - `GetClientApp()` - Improved coverage 280 + - `Directory()` - Improved coverage (directory_test.go added) 281 + 282 + #### store.go (Good coverage, some gaps) 283 + 284 + **Well-covered:** 285 + - `NewFileStore()` - 100% ✅ 286 + - `GetSession()` - 100% ✅ 287 + - `SaveSession()` - 100% ✅ 288 + 289 + **Gaps:** 290 + - `GetDefaultStorePath()` - 30.0% 291 + 292 + #### browser.go (Improved coverage) 🎯 293 + **Function:** 294 + - `OpenBrowser()` - Improved coverage (browser_test.go enhanced) 295 + 296 + **Note:** Browser interaction testing improved, though full CI testing remains challenging 297 + 298 + --- 299 + 300 + ### 🔴 pkg/appview/db (41.2% coverage, decreased from 44.8%) 301 + 302 + Database layer has test files but many functions remain untested. Coverage decrease likely due to additional code paths being tracked in existing tests. 303 + 304 + #### queries.go (0% coverage for most functions) 305 + **Functions:** 306 + - Repository queries 307 + - Star counting 308 + - Pull counting 309 + - Search queries 310 + 311 + **Test Scenarios Needed:** 312 + - Repository listing with pagination 313 + - Search functionality 314 + - Aggregation queries 315 + - Error handling 316 + 317 + #### session_store.go (0% coverage) 318 + **Functions:** 319 + - Session creation and retrieval 320 + - Session expiration 321 + - Session deletion 322 + 323 + **Test Scenarios Needed:** 324 + - Session lifecycle 325 + - Expiration handling 326 + - Cleanup of expired sessions 327 + - Concurrent session access 328 + 329 + #### device_store.go (📊 Partial coverage) 330 + **Functions:** 331 + - OAuth device flow storage 332 + - Has test file but many functions still at 0% 333 + 334 + **Test Scenarios Needed:** 335 + - User code lookups 336 + - Status updates (pending → approved) 337 + - Expiration handling 338 + - Delete operations 339 + 340 + #### hold_store.go (📊 Partial coverage) 341 + **Needs integration tests for cache invalidation** 342 + 343 + #### oauth_store.go (📊 Partial coverage) 344 + **Uncovered Functions:** 345 + - `GetAuthRequestInfo()` - 0% 346 + - `DeleteAuthRequestInfo()` - 0% 347 + - `SaveAuthRequestInfo()` - 0% 348 + 349 + #### annotations.go (0% coverage) 350 + **Functions:** 351 + - Repository annotations and metadata 352 + 353 + #### readonly.go (0% coverage) 354 + **Functions:** 355 + - Read-only database wrapper 356 + 357 + --- 358 + 359 + ## Medium Priority: Supporting Features 360 + 361 + ### 🟡 pkg/appview/jetstream (16.7% coverage) 362 + 363 + Event processing for real-time updates. 364 + 365 + #### worker.go (0% coverage) 366 + **Functions:** 367 + - Jetstream event consumption 368 + - Event routing to handlers 369 + - Repository indexing 370 + 371 + #### backfill.go (0% coverage) 372 + **Functions:** 373 + - PDS repository backfilling 374 + - Batch processing 375 + 376 + #### processor.go (📊 Partial coverage) 377 + **Needs more comprehensive testing** 378 + 379 + --- 380 + 381 + ### 🟡 pkg/hold/oci (69.9% coverage) 382 + 383 + Multipart upload implementation for hold service. Has good coverage overall but some functions still need tests. 384 + 385 + #### xrpc.go (📊 Partial coverage) 386 + **Functions:** 387 + - Multipart upload XRPC endpoints 388 + - Most functions tested, but edge cases need coverage 389 + 390 + --- 391 + 392 + ### 🟡 pkg/hold/pds (57.8% coverage) 393 + 394 + Embedded PDS implementation. Has good test coverage for critical parts, but supporting functions need work. 395 + 396 + #### repomgr.go (📊 Partial coverage) 397 + **Many functions still at 0% coverage** 398 + 399 + #### profile.go (0% coverage) 400 + **Functions:** 401 + - Sailor profile management 402 + 403 + #### layer.go (📊 Partial coverage) 404 + #### auth.go (0% coverage) 405 + #### events.go (📊 Partial coverage) 406 + 407 + --- 408 + 409 + ### 🟡 pkg/auth (55.8% coverage) 410 + 411 + #### hold_local.go (0% coverage) 412 + **Functions:** 413 + - Local hold authorization 414 + 415 + #### session.go (0% coverage) 416 + **Functions:** 417 + - Session management 418 + 419 + #### hold_remote.go (📊 Partial coverage) 420 + **Needs more edge case testing** 421 + 422 + --- 423 + 424 + ### 🟡 pkg/appview/readme (16.7% coverage) 425 + 426 + README fetching and caching. Less critical but still needs work. 427 + 428 + #### cache.go (0% coverage) 429 + #### fetcher.go (📊 Partial coverage) 430 + 431 + --- 432 + 433 + ### 🟡 pkg/appview/routes (33.3% coverage) 434 + 435 + #### routes.go (📊 Partial coverage) 436 + **Needs integration tests for route registration and middleware chains** 437 + 438 + --- 439 + 440 + ## Low Priority: Web UI and Supporting Features 441 + 442 + ### 🟢 pkg/appview/handlers (19.7% coverage, improved from 2.1%) 🎯 443 + 444 + Web UI handlers. Less critical than core registry functionality but still important for user experience. 445 + 446 + **Status:** Significant improvement (+17.6%)! Many handlers now have improved test coverage. 447 + 448 + **Improved coverage:** 449 + - Multiple handler functions now have better test coverage 450 + - Common patterns across handlers now tested 451 + 452 + **Files with partial coverage:** 453 + - `common.go` (📊) 454 + - `device.go` (📊) 455 + - `auth.go` (📊) 456 + - `repository.go` (📊) 457 + - `search.go` (📊) 458 + - `settings.go` (📊) 459 + - `user.go` (📊) 460 + - `images.go` (📊) 461 + - `home.go` (📊) 462 + - `install.go` (📊) 463 + - `logout.go` (📊) 464 + - `manifest_health.go` (📊) 465 + - `api.go` (📊) 466 + 467 + **Note:** While individual files may still show gaps, overall handler package coverage has improved significantly. 468 + 469 + --- 470 + 471 + ### 🟢 pkg/appview/holdhealth (66.1% coverage) 472 + 473 + Hold health checking. Adequate coverage overall. 474 + 475 + #### worker.go (📊 Partial coverage) 476 + **Could use more edge case testing** 477 + 478 + --- 479 + 480 + ### 🟢 pkg/appview/ui.go (0% coverage) 481 + 482 + UI initialization and setup. Low priority. 483 + 484 + --- 485 + 486 + ## Recommended Testing Order 487 + 488 + ### Phase 1: Critical Infrastructure ✅ **NEARLY COMPLETE** (Target: 45% overall) 489 + 490 + **Completed:** 491 + 1. ✅ `pkg/appview/middleware/auth.go` - Authentication (0% → 98.8% avg) 492 + 2. ✅ `pkg/appview/middleware/registry.go` - Core routing (0% → 90.8% avg) 493 + 3. ✅ `pkg/atproto/client.go` - PDS client (0% → 74.8%) 494 + 4. ✅ `pkg/atproto/resolver.go` - Identity resolution (0% → 74.5%) 495 + 5. ✅ `pkg/appview/storage/manifest_store.go` - Manifest operations (0% → 85%+) **🎉 COMPLETED** 496 + 6. ✅ `pkg/appview/storage/profile.go` - Sailor profiles (NEW → 98%+) **🎉 COMPLETED** 497 + 498 + **Remaining (HIGHEST PRIORITY):** 499 + 7. ⭐⭐⭐ `pkg/appview/storage/proxy_blob_store.go` - Blob write operations **CRITICAL** 500 + - `Put()`, `Create()`, Writer interface (0% → 80%+) 501 + - Essential for docker push operations 502 + 8. ⭐ `pkg/appview/storage/routing_repository.go` - Blob routing 503 + - `Blobs()` method (0% → 80%+) 504 + 505 + **Current Status:** Overall coverage improved from 37.7% → 39.0% (+1.3%). On track for 45% with Phase 1 completion. 506 + 507 + ### Phase 2: Supporting Infrastructure (Target: 50% overall) 508 + 509 + **In Progress:** 510 + 9. 🔴 `pkg/appview/db/*` - Database layer (41.2%, needs improvement) 511 + - queries.go, session_store.go, device_store.go 512 + 10. 🔴 `pkg/auth/oauth/refresher.go` - Token refresh (Partial → 70%+) 513 + - `GetSession()`, `resumeSession()` (currently 0%) 514 + 11. 🔴 `pkg/auth/oauth/server.go` - OAuth endpoints (50.7%, continue improvements) 515 + - `ServeCallback()` at 16.3% needs major improvement 516 + 12. 🔴 `pkg/appview/storage/crew.go` - Crew validation (11.1% → 80%+) 517 + 13. 🔴 `pkg/auth/*` - Continue auth improvements (55.7% → 70%+) 518 + - hold_remote.go gaps, session.go 519 + 14. 🎯 `pkg/atproto/*` - ATProto improvements (27.8%, continue adding tests) 520 + - directory_test.go, endpoints_test.go, utils_test.go added ✅ 521 + 522 + ### Phase 3: Event Processing (Target: 55% overall) 523 + 15. 🟡 `pkg/appview/jetstream/worker.go` - Event processing (0% → 70%+) 524 + 16. 🟡 `pkg/appview/jetstream/backfill.go` - Backfill logic (0% → 70%+) 525 + 17. 🟡 `pkg/hold/pds/*` - Fill in gaps in embedded PDS 526 + 18. 🟡 `pkg/hold/oci/*` - OCI multipart upload improvements 527 + 528 + ### Phase 4: Web UI (Target: 60% overall) 529 + 19. 🎯 `pkg/appview/handlers/*` - Web handlers (19.7%, greatly improved from 2.1%) **+17.6%** ✅ 530 + - Continue adding handler tests to reach 50%+ 531 + 20. 🟢 `pkg/appview/routes/*` - Route registration (10.4% → 50%+) 532 + 533 + --- 534 + 535 + ## Testing Best Practices for This Codebase 536 + 537 + ### For Middleware Tests 538 + - Mock HTTP handlers to test middleware wrapping 539 + - Use `httptest.ResponseRecorder` for response inspection 540 + - Test context injection and extraction 541 + - Mock ATProto client for PDS interactions 542 + 543 + ### For Storage Tests 544 + - Mock `distribution` interfaces (BlobStore, ManifestService) 545 + - Use in-memory implementations where possible 546 + - Test error propagation from underlying storage 547 + - Mock hold XRPC endpoints 548 + 549 + ### For Database Tests 550 + - Use in-memory SQLite (`:memory:`) 551 + - Run migrations in test setup 552 + - Clean up after each test 553 + - Test concurrent operations where relevant 554 + 555 + ### For Authorization Tests 556 + - Mock ATProto client for crew lookups 557 + - Test both legacy and new hold models 558 + - Test permission combinations 559 + - Mock service token acquisition 560 + 561 + ### For OAuth Tests 562 + - Mock HTTP servers for PDS endpoints 563 + - Test DPoP proof generation/validation 564 + - Test PAR request flow 565 + - Mock browser interaction 566 + 567 + ### For ATProto Tests 568 + - Mock HTTP responses for resolver tests 569 + - Test DID document parsing 570 + - Mock XRPC endpoints 571 + - Test authentication flows 572 + 573 + --- 574 + 575 + ## Coverage Goals 576 + 577 + **Current:** 39.0% (improved from 37.7%, +1.3%) 578 + **Previous:** 37.7% (improved from 33.5%, +4.2%) 579 + **Total improvement:** 39.0% vs 31.2% baseline = **+7.8%** 580 + 581 + **Top Packages by Coverage:** 582 + - ✅ `pkg/hold`: 98.0% (excellent) 583 + - ✅ `pkg/s3`: 97.4% (excellent) 584 + - ✅ `pkg/appview/licenses`: 93.0% (excellent) 585 + - ✅ `pkg/appview`: 81.8% (excellent) 586 + - ✅ `pkg/logging`: 75.0% (good) 587 + 588 + **Key File-Level Achievements:** 589 + - ✅ `pkg/appview/middleware/auth.go`: 98.8% avg (excellent) 590 + - ✅ `pkg/appview/middleware/registry.go`: 90.8% avg (excellent) 591 + - ✅ `pkg/appview/storage/manifest_store.go`: 85%+ (CRITICAL improvement from 0%) 592 + - ✅ `pkg/appview/storage/profile.go`: 98%+ (new file, excellent) 593 + - ✅ `pkg/atproto/client.go`: 74.8% (good) 594 + - ✅ `pkg/atproto/resolver.go`: 74.5% (good) 595 + 596 + **Packages Needing Work:** 597 + - 🟡 `pkg/auth/token`: 68.8% (good) 598 + - 🟡 `pkg/appview/middleware`: 57.8% (package avg lowered by Repository()) 599 + - 🟡 `pkg/auth`: 55.7% (stable) 600 + - 🟡 `pkg/hold/oci`: 51.9% (needs work) 601 + - 🟡 `pkg/appview/storage`: 51.4% (critical gaps remain) 602 + - 🟡 `pkg/auth/oauth`: 50.7% (improving, was 48.3%) 🎯 **+2.4%** 603 + - 🟡 `pkg/hold/pds`: 47.2% (needs work) 604 + - 🟡 `pkg/appview/db`: 41.2% (decreased from 44.8%, tracking more code paths) 🔴 **-3.6%** 605 + - 🟡 `pkg/atproto`: 27.8% (improving, was 26.1%) 🎯 **+1.7%** 606 + - 🟡 `pkg/appview/handlers`: 19.7% (greatly improved from 2.1%) 🎯 **+17.6%** 607 + 608 + **Short-term Goal (Phase 1 completion):** 45%+ 609 + - ✅ Cover all critical middleware (**COMPLETE**) 610 + - ✅ Cover ATProto client and resolver (**COMPLETE**) 611 + - ✅ Cover storage manifest operations (**COMPLETE** 🎉) 612 + - ⭐ Cover storage blob write operations (**HIGHEST PRIORITY** - Put/Create/Writer) 613 + - ⭐ Cover storage blob routing (**HIGH PRIORITY**) 614 + 615 + **Medium-term Goal (Phase 2):** 50%+ 616 + - Complete remaining storage layer (blob writes) 617 + - Improve database layer coverage (44.8% → 70%+) 618 + - Complete OAuth implementation (refresher.GetSession, server.ServeCallback) 619 + - Add storage crew validation 620 + 621 + **Long-term Goal (Phase 3-4):** 55-60% 622 + - Event processing (jetstream) 623 + - Web UI handlers (currently 2.1%) 624 + - Comprehensive integration tests 625 + 626 + **Realistic Target:** 55-60% (excluding some UI handlers and integration-heavy code) 627 + 628 + **Note:** Package percentages may decrease as new files are added to coverage tracking, but this reflects improved test comprehensiveness, not regression. Focus on file-level coverage for critical paths. 629 + 630 + --- 631 + 632 + ## Notes 633 + 634 + - **Test files exist:** Most files in `pkg/` now have corresponding `*_test.go` files, but many functions remain at 0% coverage 635 + - **SQLite vs PostgreSQL:** Current tests use SQLite. For production multi-instance deployments, consider PostgreSQL tests 636 + - **Concurrency:** Many components (cache, token refresher, OAuth) have concurrency concerns that need explicit testing 637 + - **Integration Tests:** Consider adding integration tests that spin up a real PDS + hold service for end-to-end validation 638 + - **Mock Strategy:** Use interfaces (like `atproto.Client`) to enable easy mocking. Consider a mock package in `pkg/testing/` 639 + - **Critical path first:** Focus on middleware and storage layers before web UI, as these are essential for core registry operations
+4
pkg/appview/config.go
··· 57 57 58 58 // DatabasePath is the path to the UI SQLite database (from env: ATCR_UI_DATABASE_PATH, default: "/var/lib/atcr/ui.db") 59 59 DatabasePath string `yaml:"database_path"` 60 + 61 + // SkipDBMigrations controls whether to skip running database migrations (from env: SKIP_DB_MIGRATIONS, default: false) 62 + SkipDBMigrations bool `yaml:"skip_db_migrations"` 60 63 } 61 64 62 65 // HealthConfig defines health check and cache settings ··· 130 133 // UI configuration 131 134 cfg.UI.Enabled = os.Getenv("ATCR_UI_ENABLED") != "false" 132 135 cfg.UI.DatabasePath = getEnvOrDefault("ATCR_UI_DATABASE_PATH", "/var/lib/atcr/ui.db") 136 + cfg.UI.SkipDBMigrations = os.Getenv("SKIP_DB_MIGRATIONS") == "true" 133 137 134 138 // Health and cache configuration 135 139 cfg.Health.CacheTTL = getDurationOrDefault("ATCR_HEALTH_CACHE_TTL", 15*time.Minute)
+1 -1
pkg/appview/db/annotations_test.go
··· 21 21 func setupAnnotationsTestDB(t *testing.T) *sql.DB { 22 22 t.Helper() 23 23 // Use file::memory: with cache=shared to ensure all connections share the same in-memory DB 24 - db, err := InitDB("file::memory:?cache=shared") 24 + db, err := InitDB("file::memory:?cache=shared", true) 25 25 if err != nil { 26 26 t.Fatalf("Failed to initialize test database: %v", err) 27 27 }
+1 -1
pkg/appview/db/device_store_test.go
··· 14 14 t.Helper() 15 15 // Use file::memory: with cache=shared to ensure all connections share the same in-memory DB 16 16 // This prevents race conditions where different connections see different databases 17 - db, err := InitDB("file::memory:?cache=shared") 17 + db, err := InitDB("file::memory:?cache=shared", true) 18 18 if err != nil { 19 19 t.Fatalf("Failed to initialize test database: %v", err) 20 20 }
+1 -1
pkg/appview/db/hold_store_test.go
··· 81 81 func setupHoldTestDB(t *testing.T) *sql.DB { 82 82 t.Helper() 83 83 // Use file::memory: with cache=shared to ensure all connections share the same in-memory DB 84 - db, err := InitDB("file::memory:?cache=shared") 84 + db, err := InitDB("file::memory:?cache=shared", true) 85 85 if err != nil { 86 86 t.Fatalf("Failed to initialize test database: %v", err) 87 87 }
+3 -3
pkg/appview/db/oauth_store_test.go
··· 11 11 12 12 func TestInvalidateSessionsWithMismatchedScopes(t *testing.T) { 13 13 // Create in-memory test database 14 - db, err := InitDB(":memory:") 14 + db, err := InitDB(":memory:", true) 15 15 if err != nil { 16 16 t.Fatalf("Failed to init database: %v", err) 17 17 } ··· 219 219 220 220 func TestOAuthStoreSessionLifecycle(t *testing.T) { 221 221 // Basic test to ensure SaveSession, GetSession, DeleteSession work correctly 222 - db, err := InitDB(":memory:") 222 + db, err := InitDB(":memory:", true) 223 223 if err != nil { 224 224 t.Fatalf("Failed to init database: %v", err) 225 225 } ··· 291 291 } 292 292 293 293 func TestCleanupOldSessions(t *testing.T) { 294 - db, err := InitDB(":memory:") 294 + db, err := InitDB(":memory:", true) 295 295 if err != nil { 296 296 t.Fatalf("Failed to init database: %v", err) 297 297 }
+8 -8
pkg/appview/db/queries_test.go
··· 7 7 8 8 func TestGetRepositoryMetadata(t *testing.T) { 9 9 // Create in-memory test database 10 - db, err := InitDB(":memory:") 10 + db, err := InitDB(":memory:", true) 11 11 if err != nil { 12 12 t.Fatalf("Failed to init database: %v", err) 13 13 } ··· 143 143 144 144 func TestInsertManifest(t *testing.T) { 145 145 // Create in-memory test database 146 - db, err := InitDB(":memory:") 146 + db, err := InitDB(":memory:", true) 147 147 if err != nil { 148 148 t.Fatalf("Failed to init database: %v", err) 149 149 } ··· 320 320 321 321 func TestUserManagement(t *testing.T) { 322 322 // Create in-memory test database 323 - db, err := InitDB(":memory:") 323 + db, err := InitDB(":memory:", true) 324 324 if err != nil { 325 325 t.Fatalf("Failed to init database: %v", err) 326 326 } ··· 432 432 433 433 func TestManifestOperations(t *testing.T) { 434 434 // Create in-memory test database 435 - db, err := InitDB(":memory:") 435 + db, err := InitDB(":memory:", true) 436 436 if err != nil { 437 437 t.Fatalf("Failed to init database: %v", err) 438 438 } ··· 609 609 610 610 func TestIsManifestTagged(t *testing.T) { 611 611 // Create in-memory test database 612 - db, err := InitDB(":memory:") 612 + db, err := InitDB(":memory:", true) 613 613 if err != nil { 614 614 t.Fatalf("Failed to init database: %v", err) 615 615 } ··· 675 675 676 676 func TestTagOperations(t *testing.T) { 677 677 // Create in-memory test database 678 - db, err := InitDB(":memory:") 678 + db, err := InitDB(":memory:", true) 679 679 if err != nil { 680 680 t.Fatalf("Failed to init database: %v", err) 681 681 } ··· 838 838 839 839 func TestGetTagsWithPlatforms(t *testing.T) { 840 840 // Create in-memory test database 841 - db, err := InitDB(":memory:") 841 + db, err := InitDB(":memory:", true) 842 842 if err != nil { 843 843 t.Fatalf("Failed to init database: %v", err) 844 844 } ··· 980 980 981 981 func TestUpdateUserHandle(t *testing.T) { 982 982 // Create in-memory test database 983 - db, err := InitDB(":memory:") 983 + db, err := InitDB(":memory:", true) 984 984 if err != nil { 985 985 t.Fatalf("Failed to init database: %v", err) 986 986 }
+2 -2
pkg/appview/db/readonly.go
··· 57 57 58 58 // InitializeDatabase initializes the SQLite database and session store 59 59 // Returns: (read-write DB, read-only DB, session store) 60 - func InitializeDatabase(uiEnabled bool, dbPath string) (*sql.DB, *sql.DB, *SessionStore) { 60 + func InitializeDatabase(uiEnabled bool, dbPath string, skipMigrations bool) (*sql.DB, *sql.DB, *SessionStore) { 61 61 if !uiEnabled { 62 62 return nil, nil, nil 63 63 } ··· 70 70 } 71 71 72 72 // Initialize read-write database (for writes and auth operations) 73 - database, err := InitDB(dbPath) 73 + database, err := InitDB(dbPath, skipMigrations) 74 74 if err != nil { 75 75 slog.Warn("Failed to initialize UI database", "error", err) 76 76 return nil, nil, nil
+1 -1
pkg/appview/db/readonly_test.go
··· 19 19 defer os.Unsetenv("ATCR_UI_DATABASE_PATH") 20 20 21 21 // Initialize database (creates schema) 22 - database, err := InitDB(dbPath) 22 + database, err := InitDB(dbPath, true) 23 23 if err != nil { 24 24 t.Fatalf("Failed to initialize database: %v", err) 25 25 }
+6 -4
pkg/appview/db/schema.go
··· 26 26 var schemaSQL string 27 27 28 28 // InitDB initializes the SQLite database with the schema 29 - func InitDB(path string) (*sql.DB, error) { 29 + func InitDB(path string, skipMigrations bool) (*sql.DB, error) { 30 30 db, err := sql.Open("sqlite3", path) 31 31 if err != nil { 32 32 return nil, err ··· 42 42 return nil, err 43 43 } 44 44 45 - // Run migrations 46 - if err := runMigrations(db); err != nil { 47 - return nil, err 45 + // Run migrations unless skipped 46 + if !skipMigrations { 47 + if err := runMigrations(db); err != nil { 48 + return nil, err 49 + } 48 50 } 49 51 50 52 return db, nil
+1 -1
pkg/appview/db/session_store_test.go
··· 13 13 func setupSessionTestDB(t *testing.T) *SessionStore { 14 14 t.Helper() 15 15 // Use file::memory: with cache=shared to ensure all connections share the same in-memory DB 16 - db, err := InitDB("file::memory:?cache=shared") 16 + db, err := InitDB("file::memory:?cache=shared", true) 17 17 if err != nil { 18 18 t.Fatalf("Failed to initialize test database: %v", err) 19 19 }
+1 -1
pkg/appview/db/tag_delete_test.go
··· 11 11 // This simulates what Jetstream does: encode repo/tag to rkey, then decode and delete 12 12 func TestTagDeleteRoundTrip(t *testing.T) { 13 13 // Create in-memory test database 14 - db, err := InitDB(":memory:") 14 + db, err := InitDB(":memory:", true) 15 15 if err != nil { 16 16 t.Fatalf("Failed to init database: %v", err) 17 17 }
-14
pkg/appview/handlers/api_test.go
··· 1 - package handlers 2 - 3 - import ( 4 - "testing" 5 - ) 6 - 7 - func TestStarRepositoryHandler_Exists(t *testing.T) { 8 - handler := &StarRepositoryHandler{} 9 - if handler == nil { 10 - t.Error("Expected non-nil handler") 11 - } 12 - } 13 - 14 - // TODO: Add API endpoint tests
-14
pkg/appview/handlers/auth_test.go
··· 1 - package handlers 2 - 3 - import ( 4 - "testing" 5 - ) 6 - 7 - func TestLoginHandler_Exists(t *testing.T) { 8 - handler := &LoginHandler{} 9 - if handler == nil { 10 - t.Error("Expected non-nil handler") 11 - } 12 - } 13 - 14 - // TODO: Add template rendering tests
+652 -51
pkg/appview/handlers/device_test.go
··· 1 1 package handlers 2 2 3 3 import ( 4 + "bytes" 5 + "context" 6 + "database/sql" 7 + "encoding/json" 8 + "net/http" 4 9 "net/http/httptest" 10 + "strings" 5 11 "testing" 12 + "time" 13 + 14 + "atcr.io/pkg/appview/db" 15 + "github.com/go-chi/chi/v5" 16 + _ "github.com/mattn/go-sqlite3" 6 17 ) 7 18 19 + // setupTestDB creates an in-memory SQLite database with full schema for testing 20 + func setupTestDB(t *testing.T) *sql.DB { 21 + database, err := db.InitDB(":memory:", true) 22 + if err != nil { 23 + t.Fatalf("Failed to initialize test database: %v", err) 24 + } 25 + return database 26 + } 27 + 28 + // Test getClientIP function (existing test, expanded) 8 29 func TestGetClientIP(t *testing.T) { 9 30 tests := []struct { 10 - name string 11 - remoteAddr string 12 - xForwardedFor string 13 - xRealIP string 14 - expectedIP string 31 + name string 32 + remoteAddr string 33 + xForwardedFor string 34 + xRealIP string 35 + expectedIP string 15 36 }{ 16 37 { 17 - name: "X-Forwarded-For single IP", 18 - remoteAddr: "192.168.1.1:1234", 19 - xForwardedFor: "10.0.0.1", 20 - xRealIP: "", 21 - expectedIP: "10.0.0.1", 38 + name: "X-Forwarded-For single IP", 39 + remoteAddr: "192.168.1.1:1234", 40 + xForwardedFor: "10.0.0.1", 41 + xRealIP: "", 42 + expectedIP: "10.0.0.1", 22 43 }, 23 44 { 24 - name: "X-Forwarded-For multiple IPs", 25 - remoteAddr: "192.168.1.1:1234", 26 - xForwardedFor: "10.0.0.1, 10.0.0.2, 10.0.0.3", 27 - xRealIP: "", 28 - expectedIP: "10.0.0.1", 45 + name: "X-Forwarded-For multiple IPs", 46 + remoteAddr: "192.168.1.1:1234", 47 + xForwardedFor: "10.0.0.1, 10.0.0.2, 10.0.0.3", 48 + xRealIP: "", 49 + expectedIP: "10.0.0.1", 29 50 }, 30 51 { 31 - name: "X-Forwarded-For with whitespace", 32 - remoteAddr: "192.168.1.1:1234", 33 - xForwardedFor: " 10.0.0.1 ", 34 - xRealIP: "", 35 - expectedIP: "10.0.0.1", 52 + name: "X-Forwarded-For with whitespace", 53 + remoteAddr: "192.168.1.1:1234", 54 + xForwardedFor: " 10.0.0.1 ", 55 + xRealIP: "", 56 + expectedIP: "10.0.0.1", 36 57 }, 37 58 { 38 - name: "X-Real-IP when no X-Forwarded-For", 39 - remoteAddr: "192.168.1.1:1234", 40 - xForwardedFor: "", 41 - xRealIP: "10.0.0.2", 42 - expectedIP: "10.0.0.2", 59 + name: "X-Real-IP when no X-Forwarded-For", 60 + remoteAddr: "192.168.1.1:1234", 61 + xForwardedFor: "", 62 + xRealIP: "10.0.0.2", 63 + expectedIP: "10.0.0.2", 43 64 }, 44 65 { 45 - name: "X-Forwarded-For takes priority over X-Real-IP", 46 - remoteAddr: "192.168.1.1:1234", 47 - xForwardedFor: "10.0.0.1", 48 - xRealIP: "10.0.0.2", 49 - expectedIP: "10.0.0.1", 66 + name: "X-Forwarded-For takes priority over X-Real-IP", 67 + remoteAddr: "192.168.1.1:1234", 68 + xForwardedFor: "10.0.0.1", 69 + xRealIP: "10.0.0.2", 70 + expectedIP: "10.0.0.1", 50 71 }, 51 72 { 52 - name: "RemoteAddr fallback with port", 53 - remoteAddr: "192.168.1.1:1234", 54 - xForwardedFor: "", 55 - xRealIP: "", 56 - expectedIP: "192.168.1.1", 73 + name: "RemoteAddr fallback with port", 74 + remoteAddr: "192.168.1.1:1234", 75 + xForwardedFor: "", 76 + xRealIP: "", 77 + expectedIP: "192.168.1.1", 57 78 }, 58 79 { 59 - name: "RemoteAddr fallback without port", 60 - remoteAddr: "192.168.1.1", 61 - xForwardedFor: "", 62 - xRealIP: "", 63 - expectedIP: "192.168.1.1", 80 + name: "RemoteAddr fallback without port", 81 + remoteAddr: "192.168.1.1", 82 + xForwardedFor: "", 83 + xRealIP: "", 84 + expectedIP: "192.168.1.1", 64 85 }, 65 86 { 66 - name: "IPv6 RemoteAddr", 67 - remoteAddr: "[::1]:1234", 68 - xForwardedFor: "", 69 - xRealIP: "", 70 - expectedIP: "[", 87 + name: "IPv6 RemoteAddr", 88 + remoteAddr: "[::1]:1234", 89 + xForwardedFor: "", 90 + xRealIP: "", 91 + expectedIP: "[", 71 92 }, 72 93 { 73 - name: "IPv6 in X-Forwarded-For", 74 - remoteAddr: "192.168.1.1:1234", 75 - xForwardedFor: "2001:db8::1", 76 - xRealIP: "", 77 - expectedIP: "2001:db8::1", 94 + name: "IPv6 in X-Forwarded-For", 95 + remoteAddr: "192.168.1.1:1234", 96 + xForwardedFor: "2001:db8::1", 97 + xRealIP: "", 98 + expectedIP: "2001:db8::1", 78 99 }, 79 100 } 80 101 ··· 99 120 } 100 121 } 101 122 102 - // TODO: Add device approval flow tests 123 + func TestDeviceCodeHandler_Success(t *testing.T) { 124 + database := setupTestDB(t) 125 + defer database.Close() 126 + 127 + store := db.NewDeviceStore(database) 128 + handler := &DeviceCodeHandler{ 129 + Store: store, 130 + AppViewBaseURL: "http://localhost:5000", 131 + } 132 + 133 + reqBody := DeviceCodeRequest{ 134 + DeviceName: "My Test Device", 135 + } 136 + body, _ := json.Marshal(reqBody) 137 + req := httptest.NewRequest("POST", "/auth/device/code", bytes.NewReader(body)) 138 + req.Header.Set("Content-Type", "application/json") 139 + 140 + rr := httptest.NewRecorder() 141 + handler.ServeHTTP(rr, req) 142 + 143 + if rr.Code != http.StatusOK { 144 + t.Errorf("Expected status %d, got %d", http.StatusOK, rr.Code) 145 + } 146 + 147 + var response DeviceCodeResponse 148 + if err := json.NewDecoder(rr.Body).Decode(&response); err != nil { 149 + t.Fatalf("Failed to decode response: %v", err) 150 + } 151 + 152 + if response.DeviceCode == "" { 153 + t.Error("Expected device_code to be set") 154 + } 155 + if response.UserCode == "" { 156 + t.Error("Expected user_code to be set") 157 + } 158 + if !strings.HasPrefix(response.VerificationURI, "http://localhost:5000") { 159 + t.Errorf("Expected verification_uri to start with base URL, got %s", response.VerificationURI) 160 + } 161 + if response.ExpiresIn != 600 { 162 + t.Errorf("Expected expires_in to be 600, got %d", response.ExpiresIn) 163 + } 164 + if response.Interval != 5 { 165 + t.Errorf("Expected interval to be 5, got %d", response.Interval) 166 + } 167 + } 168 + 169 + func TestDeviceCodeHandler_DefaultDeviceName(t *testing.T) { 170 + database := setupTestDB(t) 171 + defer database.Close() 172 + 173 + store := db.NewDeviceStore(database) 174 + handler := &DeviceCodeHandler{ 175 + Store: store, 176 + AppViewBaseURL: "http://localhost:5000", 177 + } 178 + 179 + // Empty device name should get default 180 + reqBody := DeviceCodeRequest{ 181 + DeviceName: "", 182 + } 183 + body, _ := json.Marshal(reqBody) 184 + req := httptest.NewRequest("POST", "/auth/device/code", bytes.NewReader(body)) 185 + req.Header.Set("Content-Type", "application/json") 186 + 187 + rr := httptest.NewRecorder() 188 + handler.ServeHTTP(rr, req) 189 + 190 + if rr.Code != http.StatusOK { 191 + t.Errorf("Expected status %d, got %d", http.StatusOK, rr.Code) 192 + } 193 + 194 + var response DeviceCodeResponse 195 + if err := json.NewDecoder(rr.Body).Decode(&response); err != nil { 196 + t.Fatalf("Failed to decode response: %v", err) 197 + } 198 + 199 + if response.UserCode == "" { 200 + t.Error("Expected user_code to be set even with default device name") 201 + } 202 + } 203 + 204 + func TestDeviceCodeHandler_MethodNotAllowed(t *testing.T) { 205 + database := setupTestDB(t) 206 + defer database.Close() 207 + 208 + store := db.NewDeviceStore(database) 209 + handler := &DeviceCodeHandler{ 210 + Store: store, 211 + AppViewBaseURL: "http://localhost:5000", 212 + } 213 + 214 + req := httptest.NewRequest("GET", "/auth/device/code", nil) 215 + rr := httptest.NewRecorder() 216 + handler.ServeHTTP(rr, req) 217 + 218 + if rr.Code != http.StatusMethodNotAllowed { 219 + t.Errorf("Expected status %d, got %d", http.StatusMethodNotAllowed, rr.Code) 220 + } 221 + } 222 + 223 + func TestDeviceTokenHandler_AuthorizationPending(t *testing.T) { 224 + database := setupTestDB(t) 225 + defer database.Close() 226 + 227 + store := db.NewDeviceStore(database) 228 + handler := &DeviceTokenHandler{ 229 + Store: store, 230 + } 231 + 232 + // Create a pending authorization 233 + pending, err := store.CreatePendingAuth("Test Device", "127.0.0.1", "TestAgent/1.0") 234 + if err != nil { 235 + t.Fatalf("Failed to create pending auth: %v", err) 236 + } 237 + 238 + // Poll before approval 239 + reqBody := DeviceTokenRequest{ 240 + DeviceCode: pending.DeviceCode, 241 + } 242 + body, _ := json.Marshal(reqBody) 243 + req := httptest.NewRequest("POST", "/auth/device/token", bytes.NewReader(body)) 244 + req.Header.Set("Content-Type", "application/json") 245 + 246 + rr := httptest.NewRecorder() 247 + handler.ServeHTTP(rr, req) 248 + 249 + if rr.Code != http.StatusOK { 250 + t.Errorf("Expected status %d, got %d", http.StatusOK, rr.Code) 251 + } 252 + 253 + var response DeviceTokenResponse 254 + if err := json.NewDecoder(rr.Body).Decode(&response); err != nil { 255 + t.Fatalf("Failed to decode response: %v", err) 256 + } 257 + 258 + if response.Error != "authorization_pending" { 259 + t.Errorf("Expected error 'authorization_pending', got %s", response.Error) 260 + } 261 + } 262 + 263 + func TestDeviceTokenHandler_ExpiredToken(t *testing.T) { 264 + database := setupTestDB(t) 265 + defer database.Close() 266 + 267 + store := db.NewDeviceStore(database) 268 + handler := &DeviceTokenHandler{ 269 + Store: store, 270 + } 271 + 272 + // Try to poll with invalid device code 273 + reqBody := DeviceTokenRequest{ 274 + DeviceCode: "invalid_code_12345", 275 + } 276 + body, _ := json.Marshal(reqBody) 277 + req := httptest.NewRequest("POST", "/auth/device/token", bytes.NewReader(body)) 278 + req.Header.Set("Content-Type", "application/json") 279 + 280 + rr := httptest.NewRecorder() 281 + handler.ServeHTTP(rr, req) 282 + 283 + if rr.Code != http.StatusOK { 284 + t.Errorf("Expected status %d, got %d", http.StatusOK, rr.Code) 285 + } 286 + 287 + var response DeviceTokenResponse 288 + if err := json.NewDecoder(rr.Body).Decode(&response); err != nil { 289 + t.Fatalf("Failed to decode response: %v", err) 290 + } 291 + 292 + if response.Error != "expired_token" { 293 + t.Errorf("Expected error 'expired_token', got %s", response.Error) 294 + } 295 + } 296 + 297 + func TestDeviceTokenHandler_Approved(t *testing.T) { 298 + database := setupTestDB(t) 299 + defer database.Close() 300 + 301 + store := db.NewDeviceStore(database) 302 + handler := &DeviceTokenHandler{ 303 + Store: store, 304 + } 305 + 306 + // Create a pending authorization 307 + pending, err := store.CreatePendingAuth("Test Device", "127.0.0.1", "TestAgent/1.0") 308 + if err != nil { 309 + t.Fatalf("Failed to create pending auth: %v", err) 310 + } 311 + 312 + // Create user first (required for foreign key) 313 + _, err = database.Exec(` 314 + INSERT INTO users (did, handle, pds_endpoint, last_seen) 315 + VALUES (?, ?, ?, ?) 316 + `, "did:plc:test123", "test.bsky.social", "https://bsky.social", time.Now()) 317 + if err != nil { 318 + t.Fatalf("Failed to create user: %v", err) 319 + } 320 + 321 + // Approve it 322 + _, err = store.ApprovePending(pending.UserCode, "did:plc:test123", "test.bsky.social") 323 + if err != nil { 324 + t.Fatalf("Failed to approve pending: %v", err) 325 + } 326 + 327 + // Poll after approval 328 + reqBody := DeviceTokenRequest{ 329 + DeviceCode: pending.DeviceCode, 330 + } 331 + body, _ := json.Marshal(reqBody) 332 + req := httptest.NewRequest("POST", "/auth/device/token", bytes.NewReader(body)) 333 + req.Header.Set("Content-Type", "application/json") 334 + 335 + rr := httptest.NewRecorder() 336 + handler.ServeHTTP(rr, req) 337 + 338 + if rr.Code != http.StatusOK { 339 + t.Errorf("Expected status %d, got %d", http.StatusOK, rr.Code) 340 + } 341 + 342 + var response DeviceTokenResponse 343 + if err := json.NewDecoder(rr.Body).Decode(&response); err != nil { 344 + t.Fatalf("Failed to decode response: %v", err) 345 + } 346 + 347 + if response.Error != "" { 348 + t.Errorf("Expected no error, got %s", response.Error) 349 + } 350 + if response.DeviceSecret == "" { 351 + t.Error("Expected device_secret to be set") 352 + } 353 + if response.DID != "did:plc:test123" { 354 + t.Errorf("Expected DID 'did:plc:test123', got %s", response.DID) 355 + } 356 + } 357 + 358 + func TestDeviceTokenHandler_MethodNotAllowed(t *testing.T) { 359 + database := setupTestDB(t) 360 + defer database.Close() 361 + 362 + store := db.NewDeviceStore(database) 363 + handler := &DeviceTokenHandler{ 364 + Store: store, 365 + } 366 + 367 + req := httptest.NewRequest("GET", "/auth/device/token", nil) 368 + rr := httptest.NewRecorder() 369 + handler.ServeHTTP(rr, req) 370 + 371 + if rr.Code != http.StatusMethodNotAllowed { 372 + t.Errorf("Expected status %d, got %d", http.StatusMethodNotAllowed, rr.Code) 373 + } 374 + } 375 + 376 + func TestDeviceApprovalPageHandler_NotLoggedIn(t *testing.T) { 377 + database := setupTestDB(t) 378 + defer database.Close() 379 + 380 + store := db.NewDeviceStore(database) 381 + sessionStore := db.NewSessionStore(database) 382 + 383 + handler := &DeviceApprovalPageHandler{ 384 + Store: store, 385 + SessionStore: sessionStore, 386 + } 387 + 388 + req := httptest.NewRequest("GET", "/device?user_code=ABC123", nil) 389 + rr := httptest.NewRecorder() 390 + handler.ServeHTTP(rr, req) 391 + 392 + // Should redirect to login 393 + if rr.Code != http.StatusFound { 394 + t.Errorf("Expected status %d, got %d", http.StatusFound, rr.Code) 395 + } 396 + 397 + location := rr.Header().Get("Location") 398 + if !strings.Contains(location, "/auth/oauth/login") { 399 + t.Errorf("Expected redirect to login, got %s", location) 400 + } 401 + } 402 + 403 + func TestDeviceApprovalPageHandler_MissingUserCode(t *testing.T) { 404 + database := setupTestDB(t) 405 + defer database.Close() 406 + 407 + store := db.NewDeviceStore(database) 408 + sessionStore := db.NewSessionStore(database) 409 + 410 + // Create user first (required for foreign key) 411 + _, err := database.Exec(` 412 + INSERT INTO users (did, handle, pds_endpoint, last_seen) 413 + VALUES (?, ?, ?, ?) 414 + `, "did:plc:test123", "test.bsky.social", "https://bsky.social", time.Now()) 415 + if err != nil { 416 + t.Fatalf("Failed to create user: %v", err) 417 + } 418 + 419 + // Create a session 420 + sessionID, _ := sessionStore.Create("did:plc:test123", "test.bsky.social", "https://pds.example.com", 24*time.Hour) 421 + 422 + handler := &DeviceApprovalPageHandler{ 423 + Store: store, 424 + SessionStore: sessionStore, 425 + } 426 + 427 + req := httptest.NewRequest("GET", "/device", nil) // No user_code parameter 428 + req.AddCookie(&http.Cookie{ 429 + Name: "atcr_session", 430 + Value: sessionID, 431 + }) 432 + 433 + rr := httptest.NewRecorder() 434 + handler.ServeHTTP(rr, req) 435 + 436 + if rr.Code != http.StatusBadRequest { 437 + t.Errorf("Expected status %d, got %d", http.StatusBadRequest, rr.Code) 438 + } 439 + } 440 + 441 + func TestDeviceApprovalPageHandler_MethodNotAllowed(t *testing.T) { 442 + database := setupTestDB(t) 443 + defer database.Close() 444 + 445 + store := db.NewDeviceStore(database) 446 + sessionStore := db.NewSessionStore(database) 447 + 448 + handler := &DeviceApprovalPageHandler{ 449 + Store: store, 450 + SessionStore: sessionStore, 451 + } 452 + 453 + req := httptest.NewRequest("POST", "/device?user_code=ABC123", nil) 454 + rr := httptest.NewRecorder() 455 + handler.ServeHTTP(rr, req) 456 + 457 + if rr.Code != http.StatusMethodNotAllowed { 458 + t.Errorf("Expected status %d, got %d", http.StatusMethodNotAllowed, rr.Code) 459 + } 460 + } 461 + 462 + func TestDeviceApproveHandler_Unauthorized(t *testing.T) { 463 + database := setupTestDB(t) 464 + defer database.Close() 465 + 466 + store := db.NewDeviceStore(database) 467 + sessionStore := db.NewSessionStore(database) 468 + 469 + handler := &DeviceApproveHandler{ 470 + Store: store, 471 + SessionStore: sessionStore, 472 + } 473 + 474 + reqBody := DeviceApproveRequest{ 475 + UserCode: "ABC123", 476 + Approve: true, 477 + } 478 + body, _ := json.Marshal(reqBody) 479 + req := httptest.NewRequest("POST", "/device/approve", bytes.NewReader(body)) 480 + req.Header.Set("Content-Type", "application/json") 481 + 482 + rr := httptest.NewRecorder() 483 + handler.ServeHTTP(rr, req) 484 + 485 + if rr.Code != http.StatusUnauthorized { 486 + t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, rr.Code) 487 + } 488 + } 489 + 490 + func TestDeviceApproveHandler_Deny(t *testing.T) { 491 + database := setupTestDB(t) 492 + defer database.Close() 493 + 494 + store := db.NewDeviceStore(database) 495 + sessionStore := db.NewSessionStore(database) 496 + 497 + // Create user first (required for foreign key) 498 + _, err := database.Exec(` 499 + INSERT INTO users (did, handle, pds_endpoint, last_seen) 500 + VALUES (?, ?, ?, ?) 501 + `, "did:plc:test123", "test.bsky.social", "https://bsky.social", time.Now()) 502 + if err != nil { 503 + t.Fatalf("Failed to create user: %v", err) 504 + } 505 + 506 + // Create a session 507 + sessionID, _ := sessionStore.Create("did:plc:test123", "test.bsky.social", "https://pds.example.com", 24*time.Hour) 508 + 509 + handler := &DeviceApproveHandler{ 510 + Store: store, 511 + SessionStore: sessionStore, 512 + } 513 + 514 + reqBody := DeviceApproveRequest{ 515 + UserCode: "ABC123", 516 + Approve: false, 517 + } 518 + body, _ := json.Marshal(reqBody) 519 + req := httptest.NewRequest("POST", "/device/approve", bytes.NewReader(body)) 520 + req.Header.Set("Content-Type", "application/json") 521 + req.AddCookie(&http.Cookie{ 522 + Name: "atcr_session", 523 + Value: sessionID, 524 + }) 525 + 526 + rr := httptest.NewRecorder() 527 + handler.ServeHTTP(rr, req) 528 + 529 + if rr.Code != http.StatusOK { 530 + t.Errorf("Expected status %d, got %d", http.StatusOK, rr.Code) 531 + } 532 + 533 + var response map[string]string 534 + if err := json.NewDecoder(rr.Body).Decode(&response); err != nil { 535 + t.Fatalf("Failed to decode response: %v", err) 536 + } 537 + 538 + if response["status"] != "denied" { 539 + t.Errorf("Expected status 'denied', got %s", response["status"]) 540 + } 541 + } 542 + 543 + func TestDeviceApproveHandler_MethodNotAllowed(t *testing.T) { 544 + database := setupTestDB(t) 545 + defer database.Close() 546 + 547 + store := db.NewDeviceStore(database) 548 + sessionStore := db.NewSessionStore(database) 549 + 550 + handler := &DeviceApproveHandler{ 551 + Store: store, 552 + SessionStore: sessionStore, 553 + } 554 + 555 + req := httptest.NewRequest("GET", "/device/approve", nil) 556 + rr := httptest.NewRecorder() 557 + handler.ServeHTTP(rr, req) 558 + 559 + if rr.Code != http.StatusMethodNotAllowed { 560 + t.Errorf("Expected status %d, got %d", http.StatusMethodNotAllowed, rr.Code) 561 + } 562 + } 563 + 564 + func TestListDevicesHandler_Unauthorized(t *testing.T) { 565 + database := setupTestDB(t) 566 + defer database.Close() 567 + 568 + store := db.NewDeviceStore(database) 569 + sessionStore := db.NewSessionStore(database) 570 + 571 + handler := &ListDevicesHandler{ 572 + Store: store, 573 + SessionStore: sessionStore, 574 + } 575 + 576 + req := httptest.NewRequest("GET", "/api/devices", nil) 577 + rr := httptest.NewRecorder() 578 + handler.ServeHTTP(rr, req) 579 + 580 + if rr.Code != http.StatusUnauthorized { 581 + t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, rr.Code) 582 + } 583 + } 584 + 585 + func TestListDevicesHandler_Success(t *testing.T) { 586 + database := setupTestDB(t) 587 + defer database.Close() 588 + 589 + store := db.NewDeviceStore(database) 590 + sessionStore := db.NewSessionStore(database) 591 + 592 + // Create user first (required for foreign key) 593 + _, err := database.Exec(` 594 + INSERT INTO users (did, handle, pds_endpoint, last_seen) 595 + VALUES (?, ?, ?, ?) 596 + `, "did:plc:test123", "test.bsky.social", "https://bsky.social", time.Now()) 597 + if err != nil { 598 + t.Fatalf("Failed to create user: %v", err) 599 + } 600 + 601 + // Create a session 602 + sessionID, _ := sessionStore.Create("did:plc:test123", "test.bsky.social", "https://pds.example.com", 24*time.Hour) 603 + 604 + // Create some devices 605 + pending, _ := store.CreatePendingAuth("Device 1", "127.0.0.1", "TestAgent/1.0") 606 + store.ApprovePending(pending.UserCode, "did:plc:test123", "test.bsky.social") 607 + 608 + handler := &ListDevicesHandler{ 609 + Store: store, 610 + SessionStore: sessionStore, 611 + } 612 + 613 + req := httptest.NewRequest("GET", "/api/devices", nil) 614 + req.AddCookie(&http.Cookie{ 615 + Name: "atcr_session", 616 + Value: sessionID, 617 + }) 618 + 619 + rr := httptest.NewRecorder() 620 + handler.ServeHTTP(rr, req) 621 + 622 + if rr.Code != http.StatusOK { 623 + t.Errorf("Expected status %d, got %d", http.StatusOK, rr.Code) 624 + } 625 + 626 + var devices []db.Device 627 + if err := json.NewDecoder(rr.Body).Decode(&devices); err != nil { 628 + t.Fatalf("Failed to decode response: %v", err) 629 + } 630 + 631 + if len(devices) != 1 { 632 + t.Errorf("Expected 1 device, got %d", len(devices)) 633 + } 634 + } 635 + 636 + func TestListDevicesHandler_MethodNotAllowed(t *testing.T) { 637 + database := setupTestDB(t) 638 + defer database.Close() 639 + 640 + store := db.NewDeviceStore(database) 641 + sessionStore := db.NewSessionStore(database) 642 + 643 + handler := &ListDevicesHandler{ 644 + Store: store, 645 + SessionStore: sessionStore, 646 + } 647 + 648 + req := httptest.NewRequest("POST", "/api/devices", nil) 649 + rr := httptest.NewRecorder() 650 + handler.ServeHTTP(rr, req) 651 + 652 + if rr.Code != http.StatusMethodNotAllowed { 653 + t.Errorf("Expected status %d, got %d", http.StatusMethodNotAllowed, rr.Code) 654 + } 655 + } 656 + 657 + func TestRevokeDeviceHandler_Unauthorized(t *testing.T) { 658 + database := setupTestDB(t) 659 + defer database.Close() 660 + 661 + store := db.NewDeviceStore(database) 662 + sessionStore := db.NewSessionStore(database) 663 + 664 + handler := &RevokeDeviceHandler{ 665 + Store: store, 666 + SessionStore: sessionStore, 667 + } 668 + 669 + req := httptest.NewRequest("DELETE", "/api/devices/device123", nil) 670 + 671 + // Add chi URL parameter 672 + rctx := chi.NewRouteContext() 673 + rctx.URLParams.Add("id", "device123") 674 + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) 675 + 676 + rr := httptest.NewRecorder() 677 + handler.ServeHTTP(rr, req) 678 + 679 + if rr.Code != http.StatusUnauthorized { 680 + t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, rr.Code) 681 + } 682 + } 683 + 684 + func TestRevokeDeviceHandler_MethodNotAllowed(t *testing.T) { 685 + database := setupTestDB(t) 686 + defer database.Close() 687 + 688 + store := db.NewDeviceStore(database) 689 + sessionStore := db.NewSessionStore(database) 690 + 691 + handler := &RevokeDeviceHandler{ 692 + Store: store, 693 + SessionStore: sessionStore, 694 + } 695 + 696 + req := httptest.NewRequest("GET", "/api/devices/device123", nil) 697 + rr := httptest.NewRecorder() 698 + handler.ServeHTTP(rr, req) 699 + 700 + if rr.Code != http.StatusMethodNotAllowed { 701 + t.Errorf("Expected status %d, got %d", http.StatusMethodNotAllowed, rr.Code) 702 + } 703 + }
-14
pkg/appview/handlers/home_test.go
··· 1 - package handlers 2 - 3 - import ( 4 - "testing" 5 - ) 6 - 7 - func TestHomeHandler_Exists(t *testing.T) { 8 - handler := &HomeHandler{} 9 - if handler == nil { 10 - t.Error("Expected non-nil handler") 11 - } 12 - } 13 - 14 - // TODO: Add comprehensive handler tests
+59 -5
pkg/appview/handlers/images_test.go
··· 1 1 package handlers 2 2 3 3 import ( 4 + "context" 5 + "net/http" 6 + "net/http/httptest" 4 7 "testing" 8 + 9 + "github.com/go-chi/chi/v5" 5 10 ) 6 11 7 - func TestDeleteTagHandler_Exists(t *testing.T) { 8 - handler := &DeleteTagHandler{} 9 - if handler == nil { 10 - t.Error("Expected non-nil handler") 12 + func TestDeleteTagHandler_Unauthorized(t *testing.T) { 13 + database := setupTestDB(t) 14 + defer database.Close() 15 + 16 + handler := &DeleteTagHandler{ 17 + DB: database, 18 + } 19 + 20 + req := httptest.NewRequest("DELETE", "/alice/myapp/tags/latest", nil) 21 + 22 + // Add chi URL parameters 23 + rctx := chi.NewRouteContext() 24 + rctx.URLParams.Add("handle", "alice") 25 + rctx.URLParams.Add("repository", "myapp") 26 + rctx.URLParams.Add("tag", "latest") 27 + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) 28 + 29 + rr := httptest.NewRecorder() 30 + handler.ServeHTTP(rr, req) 31 + 32 + // Should return unauthorized without user in context 33 + if rr.Code != http.StatusUnauthorized { 34 + t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, rr.Code) 11 35 } 12 36 } 13 37 14 - // TODO: Add image listing tests 38 + func TestDeleteManifestHandler_Unauthorized(t *testing.T) { 39 + database := setupTestDB(t) 40 + defer database.Close() 41 + 42 + handler := &DeleteManifestHandler{ 43 + DB: database, 44 + } 45 + 46 + req := httptest.NewRequest("DELETE", "/alice/myapp/manifests/sha256:abc123", nil) 47 + 48 + // Add chi URL parameters 49 + rctx := chi.NewRouteContext() 50 + rctx.URLParams.Add("handle", "alice") 51 + rctx.URLParams.Add("repository", "myapp") 52 + rctx.URLParams.Add("digest", "sha256:abc123") 53 + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) 54 + 55 + rr := httptest.NewRecorder() 56 + handler.ServeHTTP(rr, req) 57 + 58 + // Should return unauthorized without user in context 59 + if rr.Code != http.StatusUnauthorized { 60 + t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, rr.Code) 61 + } 62 + } 63 + 64 + // TODO: Add comprehensive tests with authentication 65 + // - Test tag deletion with proper auth 66 + // - Test manifest deletion with proper auth 67 + // - Test deletion of non-existent tags 68 + // - Test unauthorized deletion attempts (wrong user)
-14
pkg/appview/handlers/install_test.go
··· 1 - package handlers 2 - 3 - import ( 4 - "testing" 5 - ) 6 - 7 - func TestInstallHandler_Exists(t *testing.T) { 8 - handler := &InstallHandler{} 9 - if handler == nil { 10 - t.Error("Expected non-nil handler") 11 - } 12 - } 13 - 14 - // TODO: Add installation instructions tests
+88 -5
pkg/appview/handlers/logout_test.go
··· 1 1 package handlers 2 2 3 3 import ( 4 + "net/http" 5 + "net/http/httptest" 4 6 "testing" 7 + "time" 8 + 9 + "atcr.io/pkg/appview/db" 5 10 ) 6 11 7 - func TestLogoutHandler_Exists(t *testing.T) { 8 - handler := &LogoutHandler{} 9 - if handler == nil { 10 - t.Error("Expected non-nil handler") 12 + func TestLogoutHandler_NoSession(t *testing.T) { 13 + database := setupTestDB(t) 14 + defer database.Close() 15 + 16 + sessionStore := db.NewSessionStore(database) 17 + 18 + handler := &LogoutHandler{ 19 + SessionStore: sessionStore, 20 + } 21 + 22 + req := httptest.NewRequest("GET", "/auth/logout", nil) 23 + rr := httptest.NewRecorder() 24 + handler.ServeHTTP(rr, req) 25 + 26 + // Should redirect even with no session 27 + if rr.Code != http.StatusFound { 28 + t.Errorf("Expected status %d, got %d", http.StatusFound, rr.Code) 29 + } 30 + 31 + location := rr.Header().Get("Location") 32 + if location != "/" { 33 + t.Errorf("Expected redirect to /, got %s", location) 11 34 } 12 35 } 13 36 14 - // TODO: Add cookie clearing tests 37 + func TestLogoutHandler_WithSession(t *testing.T) { 38 + database := setupTestDB(t) 39 + defer database.Close() 40 + 41 + sessionStore := db.NewSessionStore(database) 42 + 43 + // Create a user first (required for foreign key) 44 + _, err := database.Exec(` 45 + INSERT INTO users (did, handle, pds_endpoint, last_seen) 46 + VALUES (?, ?, ?, ?) 47 + `, "did:plc:test123", "test.bsky.social", "https://bsky.social", time.Now()) 48 + if err != nil { 49 + t.Fatalf("Failed to create user: %v", err) 50 + } 51 + 52 + // Create a session 53 + sessionID, err := sessionStore.Create("did:plc:test123", "test.bsky.social", "https://bsky.social", 24*time.Hour) 54 + if err != nil { 55 + t.Fatalf("Failed to create session: %v", err) 56 + } 57 + 58 + handler := &LogoutHandler{ 59 + SessionStore: sessionStore, 60 + OAuthStore: db.NewOAuthStore(database), 61 + } 62 + 63 + req := httptest.NewRequest("GET", "/auth/logout", nil) 64 + req.AddCookie(&http.Cookie{ 65 + Name: "atcr_session", 66 + Value: sessionID, 67 + }) 68 + 69 + rr := httptest.NewRecorder() 70 + handler.ServeHTTP(rr, req) 71 + 72 + // Should redirect 73 + if rr.Code != http.StatusFound { 74 + t.Errorf("Expected status %d, got %d", http.StatusFound, rr.Code) 75 + } 76 + 77 + // Should clear cookie 78 + cookies := rr.Result().Cookies() 79 + found := false 80 + for _, cookie := range cookies { 81 + if cookie.Name == "atcr_session" { 82 + found = true 83 + if cookie.MaxAge != -1 { 84 + t.Errorf("Expected cookie MaxAge=-1, got %d", cookie.MaxAge) 85 + } 86 + } 87 + } 88 + if !found { 89 + t.Error("Expected atcr_session cookie to be cleared") 90 + } 91 + 92 + // Session should be deleted 93 + _, exists := sessionStore.Get(sessionID) 94 + if exists { 95 + t.Error("Expected session to be deleted") 96 + } 97 + }
-14
pkg/appview/handlers/manifest_health_test.go
··· 1 - package handlers 2 - 3 - import ( 4 - "testing" 5 - ) 6 - 7 - func TestManifestHealthHandler_Exists(t *testing.T) { 8 - handler := &ManifestHealthHandler{} 9 - if handler == nil { 10 - t.Error("Expected non-nil handler") 11 - } 12 - } 13 - 14 - // TODO: Add manifest health check tests
-14
pkg/appview/handlers/repository_test.go
··· 1 - package handlers 2 - 3 - import ( 4 - "testing" 5 - ) 6 - 7 - func TestRepositoryPageHandler_Exists(t *testing.T) { 8 - handler := &RepositoryPageHandler{} 9 - if handler == nil { 10 - t.Error("Expected non-nil handler") 11 - } 12 - } 13 - 14 - // TODO: Add comprehensive tests with mocked database
-14
pkg/appview/handlers/search_test.go
··· 1 - package handlers 2 - 3 - import ( 4 - "testing" 5 - ) 6 - 7 - func TestSearchHandler_Exists(t *testing.T) { 8 - handler := &SearchHandler{} 9 - if handler == nil { 10 - t.Error("Expected non-nil handler") 11 - } 12 - } 13 - 14 - // TODO: Add query parsing tests
-14
pkg/appview/handlers/settings_test.go
··· 1 - package handlers 2 - 3 - import ( 4 - "testing" 5 - ) 6 - 7 - func TestSettingsHandler_Exists(t *testing.T) { 8 - handler := &SettingsHandler{} 9 - if handler == nil { 10 - t.Error("Expected non-nil handler") 11 - } 12 - } 13 - 14 - // TODO: Add settings page tests
-14
pkg/appview/handlers/user_test.go
··· 1 - package handlers 2 - 3 - import ( 4 - "testing" 5 - ) 6 - 7 - func TestUserPageHandler_Exists(t *testing.T) { 8 - handler := &UserPageHandler{} 9 - if handler == nil { 10 - t.Error("Expected non-nil handler") 11 - } 12 - } 13 - 14 - // TODO: Add user profile tests
+2 -2
pkg/appview/middleware/auth_test.go
··· 26 26 27 27 // setupTestDB creates an in-memory SQLite database for testing 28 28 func setupTestDB(t *testing.T) *sql.DB { 29 - database, err := db.InitDB(":memory:") 29 + database, err := db.InitDB(":memory:", true) 30 30 require.NoError(t, err) 31 31 32 32 t.Cleanup(func() { ··· 307 307 func TestMiddleware_ConcurrentAccess(t *testing.T) { 308 308 // Use a shared in-memory database for concurrent access 309 309 // (SQLite's default :memory: creates separate DBs per connection) 310 - database, err := db.InitDB("file::memory:?cache=shared") 310 + database, err := db.InitDB("file::memory:?cache=shared", true) 311 311 require.NoError(t, err) 312 312 t.Cleanup(func() { 313 313 database.Close()
+151
pkg/atproto/directory_test.go
··· 1 + package atproto 2 + 3 + import ( 4 + "sync" 5 + "testing" 6 + ) 7 + 8 + func TestGetDirectorySingleton(t *testing.T) { 9 + t.Run("returns non-nil directory", func(t *testing.T) { 10 + dir := GetDirectory() 11 + if dir == nil { 12 + t.Fatal("GetDirectory() returned nil") 13 + } 14 + }) 15 + 16 + t.Run("singleton behavior - same instance", func(t *testing.T) { 17 + // Get directory twice 18 + dir1 := GetDirectory() 19 + dir2 := GetDirectory() 20 + 21 + // They should be the exact same instance (same pointer) 22 + if dir1 != dir2 { 23 + t.Error("GetDirectory() returned different instances, expected singleton") 24 + } 25 + }) 26 + } 27 + 28 + func TestGetDirectoryConcurrency(t *testing.T) { 29 + t.Run("concurrent access is thread-safe", func(t *testing.T) { 30 + const numGoroutines = 100 31 + var wg sync.WaitGroup 32 + wg.Add(numGoroutines) 33 + 34 + // Channel to collect all directory instances 35 + instances := make(chan interface{}, numGoroutines) 36 + 37 + // Launch many goroutines concurrently accessing GetDirectory 38 + for i := 0; i < numGoroutines; i++ { 39 + go func() { 40 + defer wg.Done() 41 + dir := GetDirectory() 42 + instances <- dir 43 + }() 44 + } 45 + 46 + // Wait for all goroutines to complete 47 + wg.Wait() 48 + close(instances) 49 + 50 + // Collect all instances 51 + var dirs []interface{} 52 + for dir := range instances { 53 + dirs = append(dirs, dir) 54 + } 55 + 56 + // Verify we got the expected number of results 57 + if len(dirs) != numGoroutines { 58 + t.Fatalf("Expected %d directory instances, got %d", numGoroutines, len(dirs)) 59 + } 60 + 61 + // All instances should be identical (singleton) 62 + firstDir := dirs[0] 63 + for i, dir := range dirs { 64 + if dir != firstDir { 65 + t.Errorf("Directory instance %d differs from first instance", i) 66 + } 67 + } 68 + }) 69 + 70 + } 71 + 72 + func TestGetDirectorySequential(t *testing.T) { 73 + t.Run("multiple calls in sequence", func(t *testing.T) { 74 + // Get directory multiple times in sequence 75 + dirs := make([]interface{}, 10) 76 + for i := 0; i < 10; i++ { 77 + dirs[i] = GetDirectory() 78 + } 79 + 80 + // All should be the same instance 81 + for i := 1; i < len(dirs); i++ { 82 + if dirs[i] != dirs[0] { 83 + t.Errorf("Call %d returned different instance than first call", i) 84 + } 85 + } 86 + }) 87 + } 88 + 89 + // TestGetDirectoryInterface verifies the directory is properly initialized 90 + func TestGetDirectoryInterface(t *testing.T) { 91 + // Verify the directory instance works as expected 92 + dir := GetDirectory() 93 + 94 + // Verify directory is not nil 95 + if dir == nil { 96 + t.Fatal("Directory should not be nil") 97 + } 98 + 99 + // Verify it's the indigo Directory interface type 100 + // We can't easily introspect the methods without importing indigo's types, 101 + // but we can verify the instance is usable by checking it's not nil 102 + // and that it's the same as subsequent calls (already tested above) 103 + 104 + // Additional verification: the directory should be the same across calls 105 + dir2 := GetDirectory() 106 + if dir != dir2 { 107 + t.Error("Directory instances differ, singleton pattern broken") 108 + } 109 + } 110 + 111 + // TestGetDirectoryRaceConditions specifically tests race conditions during initialization 112 + func TestGetDirectoryRaceConditions(t *testing.T) { 113 + // This test would ideally reset the singleton, but since we can't do that 114 + // safely, we instead verify that even if GetDirectory is called concurrently 115 + // before initialization completes, it still works correctly. 116 + // 117 + // The sync.Once ensures this is safe, so calling GetDirectory from multiple 118 + // goroutines simultaneously should still result in exactly one initialization 119 + // and all goroutines getting the same instance. 120 + 121 + const numGoroutines = 50 122 + var wg sync.WaitGroup 123 + wg.Add(numGoroutines) 124 + 125 + instances := make([]interface{}, numGoroutines) 126 + var mu sync.Mutex 127 + 128 + // Simulate many goroutines trying to get the directory simultaneously 129 + for i := 0; i < numGoroutines; i++ { 130 + go func(idx int) { 131 + defer wg.Done() 132 + dir := GetDirectory() 133 + mu.Lock() 134 + instances[idx] = dir 135 + mu.Unlock() 136 + }(i) 137 + } 138 + 139 + wg.Wait() 140 + 141 + // Verify all instances are identical 142 + firstDir := instances[0] 143 + for i, dir := range instances { 144 + if dir == nil { 145 + t.Errorf("Instance %d is nil", i) 146 + } 147 + if dir != firstDir { 148 + t.Errorf("Instance %d differs from first instance", i) 149 + } 150 + } 151 + }
+262
pkg/atproto/endpoints_test.go
··· 1 + package atproto 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + ) 7 + 8 + // TestEndpointsFormat validates that all endpoint constants follow the XRPC convention 9 + func TestEndpointsFormat(t *testing.T) { 10 + tests := []struct { 11 + name string 12 + endpoint string 13 + prefix string // Expected namespace prefix (e.g., "io.atcr" or "com.atproto") 14 + }{ 15 + // Hold service multipart upload endpoints 16 + {"HoldInitiateUpload", HoldInitiateUpload, "io.atcr.hold"}, 17 + {"HoldGetPartUploadURL", HoldGetPartUploadURL, "io.atcr.hold"}, 18 + {"HoldUploadPart", HoldUploadPart, "io.atcr.hold"}, 19 + {"HoldCompleteUpload", HoldCompleteUpload, "io.atcr.hold"}, 20 + {"HoldAbortUpload", HoldAbortUpload, "io.atcr.hold"}, 21 + {"HoldNotifyManifest", HoldNotifyManifest, "io.atcr.hold"}, 22 + 23 + // Hold service crew management endpoints 24 + {"HoldRequestCrew", HoldRequestCrew, "io.atcr.hold"}, 25 + 26 + // ATProto sync endpoints 27 + {"SyncGetBlob", SyncGetBlob, "com.atproto.sync"}, 28 + {"SyncGetRepo", SyncGetRepo, "com.atproto.sync"}, 29 + {"SyncGetRecord", SyncGetRecord, "com.atproto.sync"}, 30 + {"SyncListRepos", SyncListRepos, "com.atproto.sync"}, 31 + {"SyncListReposByCollection", SyncListReposByCollection, "com.atproto.sync"}, 32 + {"SyncSubscribeRepos", SyncSubscribeRepos, "com.atproto.sync"}, 33 + {"SyncGetRepoStatus", SyncGetRepoStatus, "com.atproto.sync"}, 34 + {"SyncRequestCrawl", SyncRequestCrawl, "com.atproto.sync"}, 35 + 36 + // ATProto server endpoints 37 + {"ServerGetServiceAuth", ServerGetServiceAuth, "com.atproto.server"}, 38 + {"ServerDescribeServer", ServerDescribeServer, "com.atproto.server"}, 39 + {"ServerCreateSession", ServerCreateSession, "com.atproto.server"}, 40 + {"ServerRefreshSession", ServerRefreshSession, "com.atproto.server"}, 41 + {"ServerGetSession", ServerGetSession, "com.atproto.server"}, 42 + 43 + // ATProto repo endpoints 44 + {"RepoDescribeRepo", RepoDescribeRepo, "com.atproto.repo"}, 45 + {"RepoPutRecord", RepoPutRecord, "com.atproto.repo"}, 46 + {"RepoGetRecord", RepoGetRecord, "com.atproto.repo"}, 47 + {"RepoListRecords", RepoListRecords, "com.atproto.repo"}, 48 + {"RepoDeleteRecord", RepoDeleteRecord, "com.atproto.repo"}, 49 + {"RepoUploadBlob", RepoUploadBlob, "com.atproto.repo"}, 50 + 51 + // ATProto identity endpoints 52 + {"IdentityResolveHandle", IdentityResolveHandle, "com.atproto.identity"}, 53 + 54 + // Bluesky app endpoints 55 + {"ActorGetProfile", ActorGetProfile, "app.bsky.actor"}, 56 + {"ActorGetProfiles", ActorGetProfiles, "app.bsky.actor"}, 57 + } 58 + 59 + for _, tt := range tests { 60 + t.Run(tt.name, func(t *testing.T) { 61 + // Check that endpoint starts with /xrpc/ 62 + if !strings.HasPrefix(tt.endpoint, "/xrpc/") { 63 + t.Errorf("%s = %q, does not start with /xrpc/", tt.name, tt.endpoint) 64 + } 65 + 66 + // Check that endpoint contains the expected namespace prefix 67 + if !strings.Contains(tt.endpoint, tt.prefix) { 68 + t.Errorf("%s = %q, does not contain expected prefix %q", tt.name, tt.endpoint, tt.prefix) 69 + } 70 + 71 + // Check that endpoint is not empty 72 + if tt.endpoint == "" { 73 + t.Errorf("%s is empty", tt.name) 74 + } 75 + 76 + // Check that endpoint follows naming convention: /xrpc/{namespace}.{method} 77 + // Should have at least 3 parts after /xrpc/: namespace.namespace.method 78 + parts := strings.Split(strings.TrimPrefix(tt.endpoint, "/xrpc/"), ".") 79 + if len(parts) < 3 { 80 + t.Errorf("%s = %q, does not follow XRPC convention (expected at least 3 dot-separated parts)", tt.name, tt.endpoint) 81 + } 82 + 83 + // Check that method name (last part) is camelCase and not empty 84 + method := parts[len(parts)-1] 85 + if method == "" { 86 + t.Errorf("%s = %q, has empty method name", tt.name, tt.endpoint) 87 + } 88 + if !isLowerCamelCase(method) { 89 + t.Errorf("%s = %q, method %q is not in camelCase", tt.name, tt.endpoint, method) 90 + } 91 + }) 92 + } 93 + } 94 + 95 + // TestEndpointUniqueness ensures no duplicate endpoint paths 96 + func TestEndpointUniqueness(t *testing.T) { 97 + endpoints := []string{ 98 + HoldInitiateUpload, 99 + HoldGetPartUploadURL, 100 + HoldUploadPart, 101 + HoldCompleteUpload, 102 + HoldAbortUpload, 103 + HoldNotifyManifest, 104 + HoldRequestCrew, 105 + SyncGetBlob, 106 + SyncGetRepo, 107 + SyncGetRecord, 108 + SyncListRepos, 109 + SyncListReposByCollection, 110 + SyncSubscribeRepos, 111 + SyncGetRepoStatus, 112 + SyncRequestCrawl, 113 + ServerGetServiceAuth, 114 + ServerDescribeServer, 115 + ServerCreateSession, 116 + ServerRefreshSession, 117 + ServerGetSession, 118 + RepoDescribeRepo, 119 + RepoPutRecord, 120 + RepoGetRecord, 121 + RepoListRecords, 122 + RepoDeleteRecord, 123 + RepoUploadBlob, 124 + IdentityResolveHandle, 125 + ActorGetProfile, 126 + ActorGetProfiles, 127 + } 128 + 129 + seen := make(map[string]bool) 130 + for _, endpoint := range endpoints { 131 + if seen[endpoint] { 132 + t.Errorf("Duplicate endpoint found: %q", endpoint) 133 + } 134 + seen[endpoint] = true 135 + } 136 + } 137 + 138 + // TestEndpointNamespaces validates that endpoints are correctly grouped by namespace 139 + func TestEndpointNamespaces(t *testing.T) { 140 + tests := []struct { 141 + name string 142 + endpoints []string 143 + namespace string 144 + }{ 145 + { 146 + name: "io.atcr.hold namespace", 147 + endpoints: []string{ 148 + HoldInitiateUpload, 149 + HoldGetPartUploadURL, 150 + HoldUploadPart, 151 + HoldCompleteUpload, 152 + HoldAbortUpload, 153 + HoldNotifyManifest, 154 + HoldRequestCrew, 155 + }, 156 + namespace: "io.atcr.hold", 157 + }, 158 + { 159 + name: "com.atproto.sync namespace", 160 + endpoints: []string{ 161 + SyncGetBlob, 162 + SyncGetRepo, 163 + SyncGetRecord, 164 + SyncListRepos, 165 + SyncListReposByCollection, 166 + SyncSubscribeRepos, 167 + SyncGetRepoStatus, 168 + SyncRequestCrawl, 169 + }, 170 + namespace: "com.atproto.sync", 171 + }, 172 + { 173 + name: "com.atproto.server namespace", 174 + endpoints: []string{ 175 + ServerGetServiceAuth, 176 + ServerDescribeServer, 177 + ServerCreateSession, 178 + ServerRefreshSession, 179 + ServerGetSession, 180 + }, 181 + namespace: "com.atproto.server", 182 + }, 183 + { 184 + name: "com.atproto.repo namespace", 185 + endpoints: []string{ 186 + RepoDescribeRepo, 187 + RepoPutRecord, 188 + RepoGetRecord, 189 + RepoListRecords, 190 + RepoDeleteRecord, 191 + RepoUploadBlob, 192 + }, 193 + namespace: "com.atproto.repo", 194 + }, 195 + { 196 + name: "com.atproto.identity namespace", 197 + endpoints: []string{ 198 + IdentityResolveHandle, 199 + }, 200 + namespace: "com.atproto.identity", 201 + }, 202 + { 203 + name: "app.bsky.actor namespace", 204 + endpoints: []string{ 205 + ActorGetProfile, 206 + ActorGetProfiles, 207 + }, 208 + namespace: "app.bsky.actor", 209 + }, 210 + } 211 + 212 + for _, tt := range tests { 213 + t.Run(tt.name, func(t *testing.T) { 214 + for _, endpoint := range tt.endpoints { 215 + if !strings.Contains(endpoint, tt.namespace) { 216 + t.Errorf("Endpoint %q should be in namespace %q", endpoint, tt.namespace) 217 + } 218 + } 219 + }) 220 + } 221 + } 222 + 223 + // TestSpecificEndpoints validates specific endpoint paths are correct 224 + func TestSpecificEndpoints(t *testing.T) { 225 + tests := []struct { 226 + name string 227 + got string 228 + expected string 229 + }{ 230 + // Spot check a few critical endpoints 231 + {"HoldInitiateUpload", HoldInitiateUpload, "/xrpc/io.atcr.hold.initiateUpload"}, 232 + {"SyncGetBlob", SyncGetBlob, "/xrpc/com.atproto.sync.getBlob"}, 233 + {"ServerGetServiceAuth", ServerGetServiceAuth, "/xrpc/com.atproto.server.getServiceAuth"}, 234 + {"RepoPutRecord", RepoPutRecord, "/xrpc/com.atproto.repo.putRecord"}, 235 + {"IdentityResolveHandle", IdentityResolveHandle, "/xrpc/com.atproto.identity.resolveHandle"}, 236 + {"ActorGetProfile", ActorGetProfile, "/xrpc/app.bsky.actor.getProfile"}, 237 + } 238 + 239 + for _, tt := range tests { 240 + t.Run(tt.name, func(t *testing.T) { 241 + if tt.got != tt.expected { 242 + t.Errorf("%s = %q, expected %q", tt.name, tt.got, tt.expected) 243 + } 244 + }) 245 + } 246 + } 247 + 248 + // isLowerCamelCase checks if a string follows lowerCamelCase convention 249 + func isLowerCamelCase(s string) bool { 250 + if len(s) == 0 { 251 + return false 252 + } 253 + // First character should be lowercase 254 + if s[0] < 'a' || s[0] > 'z' { 255 + return false 256 + } 257 + // Should not contain underscores or hyphens (common in other naming conventions) 258 + if strings.Contains(s, "_") || strings.Contains(s, "-") { 259 + return false 260 + } 261 + return true 262 + }
+332
pkg/atproto/lexicon_test.go
··· 953 953 t.Errorf("Repository = %v, want %v", decoded.Repository, subject.Repository) 954 954 } 955 955 } 956 + 957 + func TestRepositoryTagToRKey(t *testing.T) { 958 + tests := []struct { 959 + name string 960 + repository string 961 + tag string 962 + want string 963 + }{ 964 + { 965 + name: "simple repository and tag", 966 + repository: "myapp", 967 + tag: "latest", 968 + want: "myapp_latest", 969 + }, 970 + { 971 + name: "repository with slash", 972 + repository: "org/myapp", 973 + tag: "v1.0.0", 974 + want: "org~myapp_v1.0.0", 975 + }, 976 + { 977 + name: "multiple slashes in repository", 978 + repository: "github.com/user/repo", 979 + tag: "main", 980 + want: "github.com~user~repo_main", 981 + }, 982 + { 983 + name: "tag with version", 984 + repository: "app", 985 + tag: "v1.2.3", 986 + want: "app_v1.2.3", 987 + }, 988 + { 989 + name: "repository with hyphen", 990 + repository: "my-app", 991 + tag: "prod", 992 + want: "my-app_prod", 993 + }, 994 + { 995 + name: "empty repository", 996 + repository: "", 997 + tag: "latest", 998 + want: "_latest", 999 + }, 1000 + { 1001 + name: "empty tag", 1002 + repository: "myapp", 1003 + tag: "", 1004 + want: "myapp_", 1005 + }, 1006 + { 1007 + name: "both empty", 1008 + repository: "", 1009 + tag: "", 1010 + want: "_", 1011 + }, 1012 + { 1013 + name: "complex repository with slash", 1014 + repository: "namespace/app", 1015 + tag: "v2.0", 1016 + want: "namespace~app_v2.0", 1017 + }, 1018 + } 1019 + 1020 + for _, tt := range tests { 1021 + t.Run(tt.name, func(t *testing.T) { 1022 + got := RepositoryTagToRKey(tt.repository, tt.tag) 1023 + if got != tt.want { 1024 + t.Errorf("RepositoryTagToRKey(%q, %q) = %q, want %q", tt.repository, tt.tag, got, tt.want) 1025 + } 1026 + }) 1027 + } 1028 + } 1029 + 1030 + func TestRKeyToRepositoryTag(t *testing.T) { 1031 + tests := []struct { 1032 + name string 1033 + rkey string 1034 + wantRepository string 1035 + wantTag string 1036 + }{ 1037 + { 1038 + name: "simple rkey", 1039 + rkey: "myapp_latest", 1040 + wantRepository: "myapp", 1041 + wantTag: "latest", 1042 + }, 1043 + { 1044 + name: "repository with tilde (encoded slash)", 1045 + rkey: "org~myapp_v1.0.0", 1046 + wantRepository: "org/myapp", 1047 + wantTag: "v1.0.0", 1048 + }, 1049 + { 1050 + name: "multiple tildes", 1051 + rkey: "github.com~user~repo_main", 1052 + wantRepository: "github.com/user/repo", 1053 + wantTag: "main", 1054 + }, 1055 + { 1056 + name: "tag with underscore (splits on last underscore)", 1057 + rkey: "app_tag_with_underscore", 1058 + wantRepository: "app_tag_with", 1059 + wantTag: "underscore", 1060 + }, 1061 + { 1062 + name: "repository with hyphen", 1063 + rkey: "my-app_prod", 1064 + wantRepository: "my-app", 1065 + wantTag: "prod", 1066 + }, 1067 + { 1068 + name: "no underscore (treats as tag)", 1069 + rkey: "justtext", 1070 + wantRepository: "", 1071 + wantTag: "justtext", 1072 + }, 1073 + { 1074 + name: "empty repository", 1075 + rkey: "_latest", 1076 + wantRepository: "", 1077 + wantTag: "latest", 1078 + }, 1079 + { 1080 + name: "empty tag", 1081 + rkey: "myapp_", 1082 + wantRepository: "myapp", 1083 + wantTag: "", 1084 + }, 1085 + { 1086 + name: "complex with tilde and multiple underscores", 1087 + rkey: "namespace~app_tag_with_underscore", 1088 + wantRepository: "namespace/app_tag_with", 1089 + wantTag: "underscore", 1090 + }, 1091 + } 1092 + 1093 + for _, tt := range tests { 1094 + t.Run(tt.name, func(t *testing.T) { 1095 + gotRepository, gotTag := RKeyToRepositoryTag(tt.rkey) 1096 + if gotRepository != tt.wantRepository { 1097 + t.Errorf("RKeyToRepositoryTag(%q) repository = %q, want %q", tt.rkey, gotRepository, tt.wantRepository) 1098 + } 1099 + if gotTag != tt.wantTag { 1100 + t.Errorf("RKeyToRepositoryTag(%q) tag = %q, want %q", tt.rkey, gotTag, tt.wantTag) 1101 + } 1102 + }) 1103 + } 1104 + } 1105 + 1106 + func TestRepositoryTagRoundTrip(t *testing.T) { 1107 + // Test that converting to rkey and back gives original values 1108 + tests := []struct { 1109 + name string 1110 + repository string 1111 + tag string 1112 + }{ 1113 + {"simple", "myapp", "latest"}, 1114 + {"with slash", "org/myapp", "v1.0.0"}, 1115 + {"multiple slashes", "github.com/user/repo", "main"}, 1116 + {"with hyphen", "my-app", "prod"}, 1117 + {"empty repository", "", "latest"}, 1118 + {"empty tag", "myapp", ""}, 1119 + } 1120 + 1121 + for _, tt := range tests { 1122 + t.Run(tt.name, func(t *testing.T) { 1123 + // Convert to rkey 1124 + rkey := RepositoryTagToRKey(tt.repository, tt.tag) 1125 + 1126 + // Convert back 1127 + gotRepository, gotTag := RKeyToRepositoryTag(rkey) 1128 + 1129 + // Verify round-trip 1130 + if gotRepository != tt.repository { 1131 + t.Errorf("Round-trip repository = %q, want %q (via rkey %q)", gotRepository, tt.repository, rkey) 1132 + } 1133 + if gotTag != tt.tag { 1134 + t.Errorf("Round-trip tag = %q, want %q (via rkey %q)", gotTag, tt.tag, rkey) 1135 + } 1136 + }) 1137 + } 1138 + } 1139 + 1140 + func TestNewLayerRecord(t *testing.T) { 1141 + tests := []struct { 1142 + name string 1143 + digest string 1144 + size int64 1145 + mediaType string 1146 + repository string 1147 + userDID string 1148 + userHandle string 1149 + }{ 1150 + { 1151 + name: "standard layer", 1152 + digest: "sha256:abc123", 1153 + size: 1024, 1154 + mediaType: "application/vnd.oci.image.layer.v1.tar+gzip", 1155 + repository: "myapp", 1156 + userDID: "did:plc:user123", 1157 + userHandle: "alice.bsky.social", 1158 + }, 1159 + { 1160 + name: "large layer", 1161 + digest: "sha256:def456", 1162 + size: 1073741824, // 1GB 1163 + mediaType: "application/vnd.oci.image.layer.v1.tar+gzip", 1164 + repository: "largeapp", 1165 + userDID: "did:plc:user456", 1166 + userHandle: "bob.example.com", 1167 + }, 1168 + { 1169 + name: "empty values", 1170 + digest: "", 1171 + size: 0, 1172 + mediaType: "", 1173 + repository: "", 1174 + userDID: "", 1175 + userHandle: "", 1176 + }, 1177 + { 1178 + name: "config layer", 1179 + digest: "sha256:config123", 1180 + size: 512, 1181 + mediaType: "application/vnd.oci.image.config.v1+json", 1182 + repository: "app/subapp", 1183 + userDID: "did:web:example.com", 1184 + userHandle: "charlie.tangled.io", 1185 + }, 1186 + } 1187 + 1188 + for _, tt := range tests { 1189 + t.Run(tt.name, func(t *testing.T) { 1190 + record := NewLayerRecord(tt.digest, tt.size, tt.mediaType, tt.repository, tt.userDID, tt.userHandle) 1191 + 1192 + // Verify all fields 1193 + if record == nil { 1194 + t.Fatal("NewLayerRecord() returned nil") 1195 + } 1196 + 1197 + if record.Type != LayerCollection { 1198 + t.Errorf("Type = %q, want %q", record.Type, LayerCollection) 1199 + } 1200 + 1201 + if record.Digest != tt.digest { 1202 + t.Errorf("Digest = %q, want %q", record.Digest, tt.digest) 1203 + } 1204 + 1205 + if record.Size != tt.size { 1206 + t.Errorf("Size = %d, want %d", record.Size, tt.size) 1207 + } 1208 + 1209 + if record.MediaType != tt.mediaType { 1210 + t.Errorf("MediaType = %q, want %q", record.MediaType, tt.mediaType) 1211 + } 1212 + 1213 + if record.Repository != tt.repository { 1214 + t.Errorf("Repository = %q, want %q", record.Repository, tt.repository) 1215 + } 1216 + 1217 + if record.UserDID != tt.userDID { 1218 + t.Errorf("UserDID = %q, want %q", record.UserDID, tt.userDID) 1219 + } 1220 + 1221 + if record.UserHandle != tt.userHandle { 1222 + t.Errorf("UserHandle = %q, want %q", record.UserHandle, tt.userHandle) 1223 + } 1224 + 1225 + // Verify CreatedAt is set and is a valid RFC3339 timestamp 1226 + if record.CreatedAt == "" { 1227 + t.Error("CreatedAt is empty") 1228 + } 1229 + 1230 + // Parse to verify it's a valid timestamp 1231 + _, err := time.Parse(time.RFC3339, record.CreatedAt) 1232 + if err != nil { 1233 + t.Errorf("CreatedAt %q is not a valid RFC3339 timestamp: %v", record.CreatedAt, err) 1234 + } 1235 + }) 1236 + } 1237 + } 1238 + 1239 + func TestNewLayerRecordJSON(t *testing.T) { 1240 + // Test that LayerRecord can be marshaled/unmarshaled to/from JSON 1241 + record := NewLayerRecord( 1242 + "sha256:abc123", 1243 + 1024, 1244 + "application/vnd.oci.image.layer.v1.tar+gzip", 1245 + "myapp", 1246 + "did:plc:user123", 1247 + "alice.bsky.social", 1248 + ) 1249 + 1250 + // Marshal to JSON 1251 + jsonData, err := json.Marshal(record) 1252 + if err != nil { 1253 + t.Fatalf("json.Marshal() error = %v", err) 1254 + } 1255 + 1256 + // Unmarshal back 1257 + var decoded LayerRecord 1258 + if err := json.Unmarshal(jsonData, &decoded); err != nil { 1259 + t.Fatalf("json.Unmarshal() error = %v", err) 1260 + } 1261 + 1262 + // Verify fields match 1263 + if decoded.Type != record.Type { 1264 + t.Errorf("Type = %q, want %q", decoded.Type, record.Type) 1265 + } 1266 + if decoded.Digest != record.Digest { 1267 + t.Errorf("Digest = %q, want %q", decoded.Digest, record.Digest) 1268 + } 1269 + if decoded.Size != record.Size { 1270 + t.Errorf("Size = %d, want %d", decoded.Size, record.Size) 1271 + } 1272 + if decoded.MediaType != record.MediaType { 1273 + t.Errorf("MediaType = %q, want %q", decoded.MediaType, record.MediaType) 1274 + } 1275 + if decoded.Repository != record.Repository { 1276 + t.Errorf("Repository = %q, want %q", decoded.Repository, record.Repository) 1277 + } 1278 + if decoded.UserDID != record.UserDID { 1279 + t.Errorf("UserDID = %q, want %q", decoded.UserDID, record.UserDID) 1280 + } 1281 + if decoded.UserHandle != record.UserHandle { 1282 + t.Errorf("UserHandle = %q, want %q", decoded.UserHandle, record.UserHandle) 1283 + } 1284 + if decoded.CreatedAt != record.CreatedAt { 1285 + t.Errorf("CreatedAt = %q, want %q", decoded.CreatedAt, record.CreatedAt) 1286 + } 1287 + }
+189
pkg/atproto/utils_test.go
··· 1 + package atproto 2 + 3 + import "testing" 4 + 5 + func TestResolveHoldURL(t *testing.T) { 6 + tests := []struct { 7 + name string 8 + holdIdentifier string 9 + want string 10 + }{ 11 + // URL passthrough tests 12 + { 13 + name: "http URL passthrough", 14 + holdIdentifier: "http://hold.example.com", 15 + want: "http://hold.example.com", 16 + }, 17 + { 18 + name: "https URL passthrough", 19 + holdIdentifier: "https://hold.example.com", 20 + want: "https://hold.example.com", 21 + }, 22 + { 23 + name: "http URL with port passthrough", 24 + holdIdentifier: "http://hold.example.com:8080", 25 + want: "http://hold.example.com:8080", 26 + }, 27 + { 28 + name: "https URL with port passthrough", 29 + holdIdentifier: "https://hold.example.com:8443", 30 + want: "https://hold.example.com:8443", 31 + }, 32 + { 33 + name: "http URL with path passthrough", 34 + holdIdentifier: "http://hold.example.com/some/path", 35 + want: "http://hold.example.com/some/path", 36 + }, 37 + 38 + // did:web to HTTPS (domain names) 39 + { 40 + name: "did:web domain to https", 41 + holdIdentifier: "did:web:hold01.atcr.io", 42 + want: "https://hold01.atcr.io", 43 + }, 44 + { 45 + name: "did:web subdomain to https", 46 + holdIdentifier: "did:web:my-hold.example.com", 47 + want: "https://my-hold.example.com", 48 + }, 49 + { 50 + name: "did:web simple domain to https", 51 + holdIdentifier: "did:web:example.com", 52 + want: "https://example.com", 53 + }, 54 + 55 + // did:web to HTTP (ports) 56 + { 57 + name: "did:web with port to http", 58 + holdIdentifier: "did:web:172.28.0.3:8080", 59 + want: "http://172.28.0.3:8080", 60 + }, 61 + { 62 + name: "did:web domain with port to http", 63 + holdIdentifier: "did:web:hold.example.com:8080", 64 + want: "http://hold.example.com:8080", 65 + }, 66 + { 67 + name: "did:web localhost with port to http", 68 + holdIdentifier: "did:web:localhost:8080", 69 + want: "http://localhost:8080", 70 + }, 71 + 72 + // did:web to HTTP (localhost) 73 + { 74 + name: "did:web localhost to http", 75 + holdIdentifier: "did:web:localhost", 76 + want: "http://localhost", 77 + }, 78 + 79 + // did:web to HTTP (127.0.0.1) 80 + { 81 + name: "did:web 127.0.0.1 to http", 82 + holdIdentifier: "did:web:127.0.0.1", 83 + want: "http://127.0.0.1", 84 + }, 85 + { 86 + name: "did:web 127.0.0.1 with port to http", 87 + holdIdentifier: "did:web:127.0.0.1:8080", 88 + want: "http://127.0.0.1:8080", 89 + }, 90 + 91 + // did:web to HTTP (IP addresses) 92 + { 93 + name: "did:web IPv4 address to http", 94 + holdIdentifier: "did:web:192.168.1.1", 95 + want: "http://192.168.1.1", 96 + }, 97 + { 98 + name: "did:web IPv4 with port to http", 99 + holdIdentifier: "did:web:10.0.0.5:3000", 100 + want: "http://10.0.0.5:3000", 101 + }, 102 + { 103 + name: "did:web private IP to http", 104 + holdIdentifier: "did:web:172.16.0.1", 105 + want: "http://172.16.0.1", 106 + }, 107 + 108 + // Fallback behavior (plain hostname) 109 + { 110 + name: "plain hostname fallback to https", 111 + holdIdentifier: "hold.example.com", 112 + want: "https://hold.example.com", 113 + }, 114 + { 115 + name: "plain single word fallback to https", 116 + holdIdentifier: "myhold", 117 + want: "https://myhold", 118 + }, 119 + 120 + // Edge cases 121 + { 122 + name: "empty string fallback", 123 + holdIdentifier: "", 124 + want: "https://", 125 + }, 126 + { 127 + name: "did:web empty hostname", 128 + holdIdentifier: "did:web:", 129 + want: "https://", 130 + }, 131 + { 132 + name: "just did:web prefix", 133 + holdIdentifier: "did:web", 134 + want: "https://did:web", 135 + }, 136 + } 137 + 138 + for _, tt := range tests { 139 + t.Run(tt.name, func(t *testing.T) { 140 + got := ResolveHoldURL(tt.holdIdentifier) 141 + if got != tt.want { 142 + t.Errorf("ResolveHoldURL(%q) = %q, want %q", tt.holdIdentifier, got, tt.want) 143 + } 144 + }) 145 + } 146 + } 147 + 148 + // TestResolveHoldURLRoundTrip tests that converting back and forth works 149 + func TestResolveHoldURLRoundTrip(t *testing.T) { 150 + tests := []struct { 151 + name string 152 + input string 153 + wantHTTP bool // true if result should be http, false for https 154 + }{ 155 + {"domain to https and idempotent", "did:web:hold.atcr.io", false}, 156 + {"IP to http and idempotent", "did:web:192.168.1.1", true}, 157 + {"port to http and idempotent", "did:web:example.com:8080", true}, 158 + } 159 + 160 + for _, tt := range tests { 161 + t.Run(tt.name, func(t *testing.T) { 162 + // First conversion 163 + first := ResolveHoldURL(tt.input) 164 + 165 + // Second conversion (should be idempotent since output is URL) 166 + second := ResolveHoldURL(first) 167 + 168 + if first != second { 169 + t.Errorf("ResolveHoldURL is not idempotent: first=%q, second=%q", first, second) 170 + } 171 + 172 + // Verify correct protocol 173 + if tt.wantHTTP { 174 + if !hasPrefix(first, "http://") { 175 + t.Errorf("Expected http:// prefix, got %q", first) 176 + } 177 + } else { 178 + if !hasPrefix(first, "https://") { 179 + t.Errorf("Expected https:// prefix, got %q", first) 180 + } 181 + } 182 + }) 183 + } 184 + } 185 + 186 + // Helper function to check prefix 187 + func hasPrefix(s, prefix string) bool { 188 + return len(s) >= len(prefix) && s[:len(prefix)] == prefix 189 + }
+1 -1
pkg/auth/hold_remote_test.go
··· 45 45 46 46 // setupTestDB creates an in-memory database for testing 47 47 func setupTestDB(t *testing.T) *sql.DB { 48 - testDB, err := db.InitDB(":memory:") 48 + testDB, err := db.InitDB(":memory:", true) 49 49 if err != nil { 50 50 t.Fatalf("Failed to initialize test database: %v", err) 51 51 }
+35 -9
pkg/auth/oauth/browser.go
··· 6 6 "runtime" 7 7 ) 8 8 9 - // OpenBrowser opens the default browser to the given URL 10 - func OpenBrowser(url string) error { 11 - var cmd *exec.Cmd 9 + // CommandExecutor is an interface for executing system commands. 10 + // This allows for dependency injection and mocking in tests. 11 + type CommandExecutor interface { 12 + Execute(name string, args ...string) error 13 + } 12 14 13 - switch runtime.GOOS { 15 + // realCommandExecutor is the production implementation that actually executes commands. 16 + type realCommandExecutor struct{} 17 + 18 + func (e *realCommandExecutor) Execute(name string, args ...string) error { 19 + return exec.Command(name, args...).Start() 20 + } 21 + 22 + // buildBrowserCommand returns the command and arguments needed to open a browser on the given OS. 23 + // This is a pure function with no side effects, making it easily testable. 24 + func buildBrowserCommand(goos, url string) (string, []string, error) { 25 + switch goos { 14 26 case "darwin": 15 - cmd = exec.Command("open", url) 27 + return "open", []string{url}, nil 16 28 case "linux": 17 - cmd = exec.Command("xdg-open", url) 29 + return "xdg-open", []string{url}, nil 18 30 case "windows": 19 - cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) 31 + return "rundll32", []string{"url.dll,FileProtocolHandler", url}, nil 20 32 default: 21 - return fmt.Errorf("unsupported platform: %s", runtime.GOOS) 33 + return "", nil, fmt.Errorf("unsupported platform: %s", goos) 22 34 } 35 + } 23 36 24 - return cmd.Start() 37 + // openBrowserWithExecutor opens the browser using the provided executor. 38 + // This allows for dependency injection in tests. 39 + func openBrowserWithExecutor(goos, url string, executor CommandExecutor) error { 40 + cmd, args, err := buildBrowserCommand(goos, url) 41 + if err != nil { 42 + return err 43 + } 44 + return executor.Execute(cmd, args...) 45 + } 46 + 47 + // OpenBrowser opens the default browser to the given URL. 48 + // This is the public API that maintains backward compatibility. 49 + func OpenBrowser(url string) error { 50 + return openBrowserWithExecutor(runtime.GOOS, url, &realCommandExecutor{}) 25 51 }
+230 -17
pkg/auth/oauth/browser_test.go
··· 1 1 package oauth 2 2 3 3 import ( 4 - "runtime" 4 + "fmt" 5 + "strings" 5 6 "testing" 6 7 ) 7 8 8 - func TestOpenBrowser_OSSupport(t *testing.T) { 9 - // Test that we handle different operating systems 10 - // We don't actually call OpenBrowser to avoid opening real browsers during tests 9 + // mockCommandExecutor is a test mock that records executed commands without actually running them. 10 + type mockCommandExecutor struct { 11 + executedCmd string 12 + executedArgs []string 13 + returnError error 14 + } 15 + 16 + func (m *mockCommandExecutor) Execute(name string, args ...string) error { 17 + m.executedCmd = name 18 + m.executedArgs = args 19 + return m.returnError 20 + } 21 + 22 + func TestBuildBrowserCommand(t *testing.T) { 23 + tests := []struct { 24 + name string 25 + goos string 26 + url string 27 + wantCmd string 28 + wantArgs []string 29 + wantErr bool 30 + errContains string 31 + }{ 32 + { 33 + name: "macOS with simple URL", 34 + goos: "darwin", 35 + url: "https://example.com", 36 + wantCmd: "open", 37 + wantArgs: []string{"https://example.com"}, 38 + wantErr: false, 39 + }, 40 + { 41 + name: "Linux with simple URL", 42 + goos: "linux", 43 + url: "https://example.com", 44 + wantCmd: "xdg-open", 45 + wantArgs: []string{"https://example.com"}, 46 + wantErr: false, 47 + }, 48 + { 49 + name: "Windows with simple URL", 50 + goos: "windows", 51 + url: "https://example.com", 52 + wantCmd: "rundll32", 53 + wantArgs: []string{"url.dll,FileProtocolHandler", "https://example.com"}, 54 + wantErr: false, 55 + }, 56 + { 57 + name: "macOS with URL containing query params", 58 + goos: "darwin", 59 + url: "https://example.com/callback?code=123&state=abc", 60 + wantCmd: "open", 61 + wantArgs: []string{"https://example.com/callback?code=123&state=abc"}, 62 + wantErr: false, 63 + }, 64 + { 65 + name: "Linux with URL containing fragment", 66 + goos: "linux", 67 + url: "https://example.com/page#section", 68 + wantCmd: "xdg-open", 69 + wantArgs: []string{"https://example.com/page#section"}, 70 + wantErr: false, 71 + }, 72 + { 73 + name: "Windows with URL containing special chars", 74 + goos: "windows", 75 + url: "https://example.com/path?key=value&other=123", 76 + wantCmd: "rundll32", 77 + wantArgs: []string{"url.dll,FileProtocolHandler", "https://example.com/path?key=value&other=123"}, 78 + wantErr: false, 79 + }, 80 + { 81 + name: "unsupported OS", 82 + goos: "freebsd", 83 + url: "https://example.com", 84 + wantCmd: "", 85 + wantArgs: nil, 86 + wantErr: true, 87 + errContains: "unsupported platform", 88 + }, 89 + { 90 + name: "unknown OS", 91 + goos: "amiga", 92 + url: "https://example.com", 93 + wantCmd: "", 94 + wantArgs: nil, 95 + wantErr: true, 96 + errContains: "amiga", 97 + }, 98 + { 99 + name: "empty URL on macOS", 100 + goos: "darwin", 101 + url: "", 102 + wantCmd: "open", 103 + wantArgs: []string{""}, 104 + wantErr: false, 105 + }, 106 + } 107 + 108 + for _, tt := range tests { 109 + t.Run(tt.name, func(t *testing.T) { 110 + cmd, args, err := buildBrowserCommand(tt.goos, tt.url) 111 + 112 + // Check error 113 + if tt.wantErr { 114 + if err == nil { 115 + t.Errorf("buildBrowserCommand() expected error, got nil") 116 + return 117 + } 118 + if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { 119 + t.Errorf("buildBrowserCommand() error = %v, should contain %q", err, tt.errContains) 120 + } 121 + return 122 + } 123 + 124 + if err != nil { 125 + t.Errorf("buildBrowserCommand() unexpected error = %v", err) 126 + return 127 + } 128 + 129 + // Check command 130 + if cmd != tt.wantCmd { 131 + t.Errorf("buildBrowserCommand() cmd = %v, want %v", cmd, tt.wantCmd) 132 + } 11 133 12 - validOSes := map[string]bool{ 13 - "darwin": true, 14 - "linux": true, 15 - "windows": true, 134 + // Check args 135 + if len(args) != len(tt.wantArgs) { 136 + t.Errorf("buildBrowserCommand() args length = %d, want %d", len(args), len(tt.wantArgs)) 137 + return 138 + } 139 + for i, arg := range args { 140 + if arg != tt.wantArgs[i] { 141 + t.Errorf("buildBrowserCommand() args[%d] = %v, want %v", i, arg, tt.wantArgs[i]) 142 + } 143 + } 144 + }) 16 145 } 146 + } 17 147 18 - if !validOSes[runtime.GOOS] { 19 - t.Skipf("Unsupported OS for browser testing: %s", runtime.GOOS) 148 + func TestOpenBrowserWithExecutor(t *testing.T) { 149 + tests := []struct { 150 + name string 151 + goos string 152 + url string 153 + executorError error 154 + wantCmd string 155 + wantArgs []string 156 + wantErr bool 157 + errContains string 158 + }{ 159 + { 160 + name: "macOS success", 161 + goos: "darwin", 162 + url: "https://example.com", 163 + wantCmd: "open", 164 + wantArgs: []string{"https://example.com"}, 165 + wantErr: false, 166 + }, 167 + { 168 + name: "Linux success", 169 + goos: "linux", 170 + url: "https://example.com/auth", 171 + wantCmd: "xdg-open", 172 + wantArgs: []string{"https://example.com/auth"}, 173 + wantErr: false, 174 + }, 175 + { 176 + name: "Windows success", 177 + goos: "windows", 178 + url: "https://example.com/callback?code=123", 179 + wantCmd: "rundll32", 180 + wantArgs: []string{"url.dll,FileProtocolHandler", "https://example.com/callback?code=123"}, 181 + wantErr: false, 182 + }, 183 + { 184 + name: "unsupported OS", 185 + goos: "plan9", 186 + url: "https://example.com", 187 + wantErr: true, 188 + errContains: "unsupported platform", 189 + }, 190 + { 191 + name: "executor error", 192 + goos: "darwin", 193 + url: "https://example.com", 194 + executorError: fmt.Errorf("exec failed"), 195 + wantCmd: "open", 196 + wantArgs: []string{"https://example.com"}, 197 + wantErr: true, 198 + errContains: "exec failed", 199 + }, 20 200 } 21 201 22 - // Just verify the function exists and doesn't panic with basic validation 23 - // We skip actually calling it to avoid opening user's browser during tests 24 - t.Logf("OpenBrowser is available for OS: %s", runtime.GOOS) 25 - } 202 + for _, tt := range tests { 203 + t.Run(tt.name, func(t *testing.T) { 204 + mock := &mockCommandExecutor{returnError: tt.executorError} 205 + 206 + err := openBrowserWithExecutor(tt.goos, tt.url, mock) 207 + 208 + // Check error 209 + if tt.wantErr { 210 + if err == nil { 211 + t.Errorf("openBrowserWithExecutor() expected error, got nil") 212 + return 213 + } 214 + if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { 215 + t.Errorf("openBrowserWithExecutor() error = %v, should contain %q", err, tt.errContains) 216 + } 217 + return 218 + } 219 + 220 + if err != nil { 221 + t.Errorf("openBrowserWithExecutor() unexpected error = %v", err) 222 + return 223 + } 26 224 27 - // Note: Full browser opening tests would require mocking exec.Command 28 - // or running in a headless environment. Skipping actual browser launch 29 - // to avoid disrupting test runs. 225 + // Verify mock was called with correct command 226 + if mock.executedCmd != tt.wantCmd { 227 + t.Errorf("executed command = %v, want %v", mock.executedCmd, tt.wantCmd) 228 + } 229 + 230 + // Verify mock was called with correct args 231 + if len(mock.executedArgs) != len(tt.wantArgs) { 232 + t.Errorf("executed args length = %d, want %d", len(mock.executedArgs), len(tt.wantArgs)) 233 + return 234 + } 235 + for i, arg := range mock.executedArgs { 236 + if arg != tt.wantArgs[i] { 237 + t.Errorf("executed args[%d] = %v, want %v", i, arg, tt.wantArgs[i]) 238 + } 239 + } 240 + }) 241 + } 242 + }
+53 -33
pkg/auth/token/handler_test.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "crypto/rsa" 5 6 "crypto/tls" 6 7 "database/sql" 7 8 "encoding/base64" 8 9 "encoding/json" 9 10 "net/http" 10 11 "net/http/httptest" 12 + "os" 11 13 "path/filepath" 12 14 "strings" 15 + "sync" 13 16 "testing" 14 17 "time" 15 18 16 19 "atcr.io/pkg/appview/db" 17 20 ) 18 21 22 + // Shared test key to avoid generating a new RSA key for each test 23 + // Generating a 2048-bit RSA key takes ~0.15s, so reusing one key saves ~4.5s for 32 tests 24 + var ( 25 + sharedTestKey *rsa.PrivateKey 26 + sharedTestKeyPath string 27 + sharedTestKeyOnce sync.Once 28 + sharedTestKeyDir string 29 + ) 30 + 31 + // getSharedTestKey returns a shared RSA key and its file path for all tests 32 + // The key is generated once and reused across all tests in this package 33 + func getSharedTestKey(t *testing.T) string { 34 + sharedTestKeyOnce.Do(func() { 35 + // Create a persistent temp directory for the shared key 36 + var err error 37 + sharedTestKeyDir, err = os.MkdirTemp("", "atcr-test-keys-*") 38 + if err != nil { 39 + t.Fatalf("Failed to create test key directory: %v", err) 40 + } 41 + 42 + sharedTestKeyPath = filepath.Join(sharedTestKeyDir, "test-key.pem") 43 + 44 + // Generate the key once (this is the expensive operation we want to avoid repeating) 45 + // This will also generate the certificate via NewIssuer 46 + _, err = NewIssuer(sharedTestKeyPath, "atcr.io", "registry", 15*time.Minute) 47 + if err != nil { 48 + t.Fatalf("Failed to generate shared test key: %v", err) 49 + } 50 + }) 51 + 52 + return sharedTestKeyPath 53 + } 54 + 19 55 // setupTestDeviceStore creates an in-memory SQLite database for testing 20 56 func setupTestDeviceStore(t *testing.T) (*db.DeviceStore, *sql.DB) { 21 - testDB, err := db.InitDB(":memory:") 57 + testDB, err := db.InitDB(":memory:", true) 22 58 if err != nil { 23 59 t.Fatalf("Failed to initialize test database: %v", err) 24 60 } ··· 55 91 } 56 92 57 93 func TestNewHandler(t *testing.T) { 58 - tmpDir := t.TempDir() 59 - keyPath := filepath.Join(tmpDir, "private-key.pem") 94 + keyPath := getSharedTestKey(t) 60 95 61 96 issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute) 62 97 if err != nil { ··· 78 113 } 79 114 80 115 func TestHandler_SetPostAuthCallback(t *testing.T) { 81 - tmpDir := t.TempDir() 82 - keyPath := filepath.Join(tmpDir, "private-key.pem") 116 + keyPath := getSharedTestKey(t) 83 117 84 118 issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute) 85 119 if err != nil { ··· 98 132 } 99 133 100 134 func TestHandler_ServeHTTP_NoAuth(t *testing.T) { 101 - tmpDir := t.TempDir() 102 - keyPath := filepath.Join(tmpDir, "private-key.pem") 135 + keyPath := getSharedTestKey(t) 103 136 104 137 issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute) 105 138 if err != nil { ··· 124 157 } 125 158 126 159 func TestHandler_ServeHTTP_WrongMethod(t *testing.T) { 127 - tmpDir := t.TempDir() 128 - keyPath := filepath.Join(tmpDir, "private-key.pem") 160 + keyPath := getSharedTestKey(t) 129 161 130 162 issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute) 131 163 if err != nil { ··· 146 178 } 147 179 148 180 func TestHandler_ServeHTTP_DeviceAuth_Valid(t *testing.T) { 149 - tmpDir := t.TempDir() 150 - keyPath := filepath.Join(tmpDir, "private-key.pem") 181 + keyPath := getSharedTestKey(t) 151 182 152 183 issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute) 153 184 if err != nil { ··· 197 228 } 198 229 199 230 func TestHandler_ServeHTTP_DeviceAuth_Invalid(t *testing.T) { 200 - tmpDir := t.TempDir() 201 - keyPath := filepath.Join(tmpDir, "private-key.pem") 231 + keyPath := getSharedTestKey(t) 202 232 203 233 issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute) 204 234 if err != nil { ··· 222 252 } 223 253 224 254 func TestHandler_ServeHTTP_InvalidScope(t *testing.T) { 225 - tmpDir := t.TempDir() 226 - keyPath := filepath.Join(tmpDir, "private-key.pem") 255 + keyPath := getSharedTestKey(t) 227 256 228 257 issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute) 229 258 if err != nil { ··· 253 282 } 254 283 255 284 func TestHandler_ServeHTTP_AccessDenied(t *testing.T) { 256 - tmpDir := t.TempDir() 257 - keyPath := filepath.Join(tmpDir, "private-key.pem") 285 + keyPath := getSharedTestKey(t) 258 286 259 287 issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute) 260 288 if err != nil { ··· 284 312 } 285 313 286 314 func TestHandler_ServeHTTP_WithCallback(t *testing.T) { 287 - tmpDir := t.TempDir() 288 - keyPath := filepath.Join(tmpDir, "private-key.pem") 315 + keyPath := getSharedTestKey(t) 289 316 290 317 issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute) 291 318 if err != nil { ··· 319 346 } 320 347 321 348 func TestHandler_ServeHTTP_MultipleScopes(t *testing.T) { 322 - tmpDir := t.TempDir() 323 - keyPath := filepath.Join(tmpDir, "private-key.pem") 349 + keyPath := getSharedTestKey(t) 324 350 325 351 issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute) 326 352 if err != nil { ··· 346 372 } 347 373 348 374 func TestHandler_ServeHTTP_WildcardScope(t *testing.T) { 349 - tmpDir := t.TempDir() 350 - keyPath := filepath.Join(tmpDir, "private-key.pem") 375 + keyPath := getSharedTestKey(t) 351 376 352 377 issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute) 353 378 if err != nil { ··· 372 397 } 373 398 374 399 func TestHandler_ServeHTTP_NoScope(t *testing.T) { 375 - tmpDir := t.TempDir() 376 - keyPath := filepath.Join(tmpDir, "private-key.pem") 400 + keyPath := getSharedTestKey(t) 377 401 378 402 issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute) 379 403 if err != nil { ··· 512 536 } 513 537 514 538 func TestHandler_ServeHTTP_AuthHeader(t *testing.T) { 515 - tmpDir := t.TempDir() 516 - keyPath := filepath.Join(tmpDir, "private-key.pem") 539 + keyPath := getSharedTestKey(t) 517 540 518 541 issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute) 519 542 if err != nil { ··· 537 560 } 538 561 539 562 func TestHandler_ServeHTTP_ContentType(t *testing.T) { 540 - tmpDir := t.TempDir() 541 - keyPath := filepath.Join(tmpDir, "private-key.pem") 563 + keyPath := getSharedTestKey(t) 542 564 543 565 issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute) 544 566 if err != nil { ··· 567 589 } 568 590 569 591 func TestHandler_ServeHTTP_ExpiresIn(t *testing.T) { 570 - tmpDir := t.TempDir() 571 - keyPath := filepath.Join(tmpDir, "private-key.pem") 592 + keyPath := getSharedTestKey(t) 572 593 573 594 // Create issuer with specific expiration 574 595 expiration := 10 * time.Minute ··· 600 621 } 601 622 602 623 func TestHandler_ServeHTTP_PullOnlyAccess(t *testing.T) { 603 - tmpDir := t.TempDir() 604 - keyPath := filepath.Join(tmpDir, "private-key.pem") 624 + keyPath := getSharedTestKey(t) 605 625 606 626 issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute) 607 627 if err != nil {
+40 -16
pkg/auth/token/issuer_test.go
··· 16 16 "github.com/golang-jwt/jwt/v5" 17 17 ) 18 18 19 + // Shared test key to avoid generating a new RSA key for each test 20 + // Generating a 2048-bit RSA key takes ~0.15s, so reusing one key saves significant time 21 + var ( 22 + issuerSharedTestKey *rsa.PrivateKey 23 + issuerSharedTestKeyPath string 24 + issuerSharedTestKeyOnce sync.Once 25 + issuerSharedTestKeyDir string 26 + ) 27 + 28 + // getSharedTestKey returns a shared RSA key and its file path for all tests 29 + // The key is generated once and reused across all tests in this package 30 + func getIssuerSharedTestKey(t *testing.T) string { 31 + issuerSharedTestKeyOnce.Do(func() { 32 + // Create a persistent temp directory for the shared key 33 + var err error 34 + issuerSharedTestKeyDir, err = os.MkdirTemp("", "atcr-issuer-test-keys-*") 35 + if err != nil { 36 + t.Fatalf("Failed to create test key directory: %v", err) 37 + } 38 + 39 + issuerSharedTestKeyPath = filepath.Join(issuerSharedTestKeyDir, "test-key.pem") 40 + 41 + // Generate the key once (this is the expensive operation we want to avoid repeating) 42 + _, err = NewIssuer(issuerSharedTestKeyPath, "atcr.io", "registry", 15*time.Minute) 43 + if err != nil { 44 + t.Fatalf("Failed to generate shared test key: %v", err) 45 + } 46 + }) 47 + 48 + return issuerSharedTestKeyPath 49 + } 50 + 19 51 func TestNewIssuer_GeneratesKey(t *testing.T) { 20 52 tmpDir := t.TempDir() 21 53 keyPath := filepath.Join(tmpDir, "private-key.pem") ··· 102 134 } 103 135 104 136 func TestIssuer_Issue(t *testing.T) { 105 - tmpDir := t.TempDir() 106 - keyPath := filepath.Join(tmpDir, "private-key.pem") 137 + keyPath := getIssuerSharedTestKey(t) 107 138 108 139 issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute) 109 140 if err != nil { ··· 136 167 } 137 168 138 169 func TestIssuer_Issue_EmptyAccess(t *testing.T) { 139 - tmpDir := t.TempDir() 140 - keyPath := filepath.Join(tmpDir, "private-key.pem") 170 + keyPath := getIssuerSharedTestKey(t) 141 171 142 172 issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute) 143 173 if err != nil { ··· 155 185 } 156 186 157 187 func TestIssuer_Issue_ValidateToken(t *testing.T) { 158 - tmpDir := t.TempDir() 159 - keyPath := filepath.Join(tmpDir, "private-key.pem") 188 + keyPath := getIssuerSharedTestKey(t) 160 189 161 190 issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute) 162 191 if err != nil { ··· 235 264 } 236 265 237 266 func TestIssuer_Issue_X5CHeader(t *testing.T) { 238 - tmpDir := t.TempDir() 239 - keyPath := filepath.Join(tmpDir, "private-key.pem") 267 + keyPath := getIssuerSharedTestKey(t) 240 268 241 269 issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute) 242 270 if err != nil { ··· 304 332 } 305 333 306 334 func TestIssuer_PublicKey(t *testing.T) { 307 - tmpDir := t.TempDir() 308 - keyPath := filepath.Join(tmpDir, "private-key.pem") 335 + keyPath := getIssuerSharedTestKey(t) 309 336 310 337 issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute) 311 338 if err != nil { ··· 328 355 } 329 356 330 357 func TestIssuer_Expiration(t *testing.T) { 331 - tmpDir := t.TempDir() 332 - keyPath := filepath.Join(tmpDir, "private-key.pem") 358 + keyPath := getIssuerSharedTestKey(t) 333 359 334 360 expiration := 30 * time.Minute 335 361 issuer, err := NewIssuer(keyPath, "atcr.io", "registry", expiration) ··· 343 369 } 344 370 345 371 func TestIssuer_ConcurrentIssue(t *testing.T) { 346 - tmpDir := t.TempDir() 347 - keyPath := filepath.Join(tmpDir, "private-key.pem") 372 + keyPath := getIssuerSharedTestKey(t) 348 373 349 374 issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute) 350 375 if err != nil { ··· 537 562 538 563 for _, expiration := range expirations { 539 564 t.Run(expiration.String(), func(t *testing.T) { 540 - tmpDir := t.TempDir() 541 - keyPath := filepath.Join(tmpDir, "private-key.pem") 565 + keyPath := getIssuerSharedTestKey(t) 542 566 543 567 issuer, err := NewIssuer(keyPath, "atcr.io", "registry", expiration) 544 568 if err != nil {